From c04a7ed12f7ac747ab25463b871f66ee112c5b0a Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Mon, 2 Nov 2026 10:08:49 +0100 Subject: [PATCH] feat: add content-addressed Rune code store Add stable versioned source and implementation IDs, exact original-source retention, compiler fingerprints, and an in-memory compiled artifact cache. Use implementation content IDs in runtime actor references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 + CHANGELOG.md | 5 + Cargo.lock | 55 ++++++++ Cargo.toml | 2 + README.md | 8 +- crates/runa-actor/Cargo.toml | 1 + crates/runa-actor/src/lib.rs | 5 +- crates/runa-code/Cargo.toml | 10 ++ crates/runa-code/build.rs | 4 + crates/runa-code/src/lib.rs | 238 +++++++++++++++++++++++++++++++++ crates/runa-core/Cargo.toml | 6 + crates/runa-core/src/lib.rs | 29 ++++ crates/runa-rune/src/lib.rs | 4 + crates/runa-runtime/Cargo.toml | 2 + crates/runa-runtime/src/lib.rs | 96 ++++++++----- crates/runa/Cargo.toml | 2 + crates/runa/src/main.rs | 25 ++-- docs/architecture.md | 12 +- docs/rune-feasibility.md | 4 +- docs/runtime.md | 22 ++- 20 files changed, 474 insertions(+), 58 deletions(-) create mode 100644 crates/runa-code/Cargo.toml create mode 100644 crates/runa-code/build.rs create mode 100644 crates/runa-code/src/lib.rs create mode 100644 crates/runa-core/Cargo.toml create mode 100644 crates/runa-core/src/lib.rs diff --git a/AGENTS.md b/AGENTS.md index d2ca085..f8b5646 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,9 @@ proposed capabilities as plans, not existing behavior. - `Cargo.toml`: Rust workspace definition. - `crates/runa/`: current executable crate and entry point. +- `crates/runa-core/`: stable identity types shared across layers. - `crates/runa-rune/`: Phase 0 Rune compilation and execution experiments. +- `crates/runa-code/`: source identities and in-memory Rune compile cache. - `crates/runa-actor/`: actor value and lifecycle contracts. - `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. - `runa-std/actors/`: default Rune actor scripts loaded by the local CLI. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9c046..27ed7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ All notable changes to this project will be documented in this file. pending-message isolation, failures, backpressure, and all-or-nothing effects. - Interactive local CLI for spawning actors, sending messages, advancing ticks, inspecting actors/events, and stopping/restarting actors. +- Versioned BLAKE3 source and implementation identities, original-source + retention, a Rune compile-input fingerprint, and an in-memory compiled + artifact cache. - Execution-model decision and Phase 1 runtime documentation. - Lifecycle tests proving failed-message retry from committed state and stop/restart preservation of reserved-first FIFO order. @@ -38,6 +41,8 @@ All notable changes to this project will be documented in this file. ### Changed +- Actor implementation IDs now identify compiled source/configuration content + rather than using sequential process-local numbers. - Expanded the architecture specification with crate ownership and dependency boundaries, and moved it to `docs/architecture.md`. - Required agents to pass `mise run pre-commit` before every commit and document diff --git a/Cargo.lock b/Cargo.lock index 41f49db..21e40ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,12 +29,31 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "cc" version = "1.10.0" @@ -61,6 +80,21 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -359,6 +393,8 @@ name = "runa" version = "0.0.0" dependencies = [ "runa-actor", + "runa-code", + "runa-core", "runa-rune", "runa-runtime", ] @@ -366,6 +402,23 @@ dependencies = [ [[package]] name = "runa-actor" version = "0.0.0" +dependencies = [ + "runa-core", +] + +[[package]] +name = "runa-code" +version = "0.0.0" +dependencies = [ + "blake3", + "runa-core", + "runa-rune", + "rune", +] + +[[package]] +name = "runa-core" +version = "0.0.0" [[package]] name = "runa-rune" @@ -379,6 +432,8 @@ name = "runa-runtime" version = "0.0.0" dependencies = [ "runa-actor", + "runa-code", + "runa-core", "runa-rune", "rune", ] diff --git a/Cargo.toml b/Cargo.toml index b288b4c..902f0fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ resolver = "3" members = [ "crates/runa", "crates/runa-actor", + "crates/runa-code", + "crates/runa-core", "crates/runa-rune", "crates/runa-runtime", ] diff --git a/README.md b/README.md index df48965..64212f6 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ Runa is at an early prototype stage. The executable runs a Phase 0 feasibility experiment using Rune 0.14.2: script execution, native calls, async suspension, instruction budgets, stepping, and compiled-code sharing. It also runs a minimal single-worker actor demonstration with bounded mailboxes and explicit message -effects. Persistence, the graphical world, and development API are not -implemented yet. +effects. Source and compiled implementation identities are BLAKE3 content +addresses with an in-memory compile cache. Persistence, behavior editing, +canonicalization, the graphical world, and development API are not implemented +yet. The prototype uses worker-local invocation stacks and cooperative instruction slices. Native callbacks are not preemptible. @@ -88,7 +90,9 @@ test task runs `cargo test --workspace`. ├── Cargo.toml ├── crates/ │ ├── runa/ # Main binary +│ ├── runa-core/ # Shared stable identity types │ ├── runa-actor/ # Actor value contracts +│ ├── runa-code/ # Source identities and compile cache │ ├── runa-rune/ # Phase 0 Rune experiments │ └── runa-runtime/ # Single-worker actor prototype ├── docs/ diff --git a/crates/runa-actor/Cargo.toml b/crates/runa-actor/Cargo.toml index 463ace2..5bd419c 100644 --- a/crates/runa-actor/Cargo.toml +++ b/crates/runa-actor/Cargo.toml @@ -4,3 +4,4 @@ version = "0.0.0" edition = "2024" [dependencies] +runa-core = { path = "../runa-core" } diff --git a/crates/runa-actor/src/lib.rs b/crates/runa-actor/src/lib.rs index 0545d08..e0deda9 100644 --- a/crates/runa-actor/src/lib.rs +++ b/crates/runa-actor/src/lib.rs @@ -1,12 +1,11 @@ //! Value contracts for the single-worker actor prototype. +use runa_core::ImplementationId; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct ActorId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct ImplementationId(pub u64); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MailboxId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/runa-code/Cargo.toml b/crates/runa-code/Cargo.toml new file mode 100644 index 0000000..13abede --- /dev/null +++ b/crates/runa-code/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "runa-code" +version = "0.0.0" +edition = "2024" + +[dependencies] +blake3 = "1.8.7" +runa-core = { path = "../runa-core" } +runa-rune = { path = "../runa-rune" } +rune = "=0.14.2" diff --git a/crates/runa-code/build.rs b/crates/runa-code/build.rs new file mode 100644 index 0000000..b7ec7d6 --- /dev/null +++ b/crates/runa-code/build.rs @@ -0,0 +1,4 @@ +fn main() { + let target = std::env::var("TARGET").expect("Cargo provides the target triple"); + println!("cargo:rustc-env=RUNA_BUILD_TARGET={target}"); +} diff --git a/crates/runa-code/src/lib.rs b/crates/runa-code/src/lib.rs new file mode 100644 index 0000000..14225db --- /dev/null +++ b/crates/runa-code/src/lib.rs @@ -0,0 +1,238 @@ +//! Exact-source identity and in-memory Rune compile cache. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use runa_core::{ImplementationId, SourceId}; +use runa_rune::{CompiledProgram, ExperimentResult}; +use rune::Module; + +const SOURCE_DOMAIN: &[u8] = b"runa-source:v1\0"; +const IMPLEMENTATION_DOMAIN: &[u8] = b"runa-implementation:v1\0"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompileFingerprint { + pub rune_version: String, + pub compiler_settings: String, + pub native_modules: String, + pub runtime_abi: String, + pub target: String, +} + +impl CompileFingerprint { + pub fn current() -> Self { + Self { + rune_version: runa_rune::RUNE_VERSION.to_owned(), + compiler_settings: "default;features:default".to_owned(), + native_modules: runa_rune::COMPILER_CONFIGURATION_ID.to_owned(), + runtime_abi: "runa-runtime-abi:v1".to_owned(), + target: env!("RUNA_BUILD_TARGET").to_owned(), + } + } + + fn hash_into(&self, hasher: &mut blake3::Hasher) { + for field in [ + self.rune_version.as_bytes(), + self.compiler_settings.as_bytes(), + self.native_modules.as_bytes(), + self.runtime_abi.as_bytes(), + self.target.as_bytes(), + ] { + hasher.update(&(field.len() as u64).to_le_bytes()); + hasher.update(field); + } + } +} + +pub struct CompiledArtifact { + pub source_id: SourceId, + pub implementation_id: ImplementationId, + original_source: Arc, + program: Arc, + fingerprint: CompileFingerprint, +} + +impl CompiledArtifact { + pub fn original_source(&self) -> &str { + &self.original_source + } + + pub fn program(&self) -> &Arc { + &self.program + } + + pub fn fingerprint(&self) -> &CompileFingerprint { + &self.fingerprint + } +} + +#[derive(Default)] +pub struct CodeStore { + sources: BTreeMap>, + artifacts: BTreeMap>, +} + +impl CodeStore { + pub fn compile(&mut self, source: &str) -> ExperimentResult> { + self.compile_using(source, CompileFingerprint::current(), || { + CompiledProgram::compile(source) + }) + } + + pub fn compile_with_module( + &mut self, + source: &str, + module_id: &str, + module: Module, + ) -> ExperimentResult> { + if module_id.trim().is_empty() { + return Err("native module identity must not be empty".into()); + } + let mut fingerprint = CompileFingerprint::current(); + fingerprint.native_modules = format!("{};{}", fingerprint.native_modules, module_id); + self.compile_using(source, fingerprint, || { + CompiledProgram::compile_with_module(source, module) + }) + } + + fn compile_using( + &mut self, + source: &str, + fingerprint: CompileFingerprint, + compile: impl FnOnce() -> ExperimentResult, + ) -> ExperimentResult> { + let source_id = source_identity(source); + let implementation_id = implementation_identity(source_id, &fingerprint); + if let Some(artifact) = self.artifacts.get(&implementation_id) { + return Ok(Arc::clone(artifact)); + } + + let original_source: Arc = Arc::from(source); + self.sources + .entry(source_id) + .or_insert_with(|| Arc::clone(&original_source)); + let program = Arc::new(compile()?); + let artifact = Arc::new(CompiledArtifact { + source_id, + implementation_id, + original_source, + program, + fingerprint, + }); + self.artifacts + .insert(implementation_id, Arc::clone(&artifact)); + Ok(artifact) + } + + pub fn original_source(&self, id: SourceId) -> Option<&str> { + self.sources.get(&id).map(AsRef::as_ref) + } + + pub fn artifact(&self, id: ImplementationId) -> Option<&Arc> { + self.artifacts.get(&id) + } + + pub fn source_count(&self) -> usize { + self.sources.len() + } + + pub fn artifact_count(&self) -> usize { + self.artifacts.len() + } +} + +pub fn source_identity(original_source: &str) -> SourceId { + let mut hasher = blake3::Hasher::new(); + hasher.update(SOURCE_DOMAIN); + hasher.update(original_source.as_bytes()); + SourceId(*hasher.finalize().as_bytes()) +} + +pub fn implementation_identity( + source_id: SourceId, + fingerprint: &CompileFingerprint, +) -> ImplementationId { + let mut hasher = blake3::Hasher::new(); + hasher.update(IMPLEMENTATION_DOMAIN); + hasher.update(&source_id.0); + fingerprint.hash_into(&mut hasher); + ImplementationId(*hasher.finalize().as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SOURCE: &str = "pub fn add(a, b) { a + b }\n"; + + #[test] + fn identities_are_stable_domain_separated_and_exact_source() { + let source = source_identity(SOURCE); + assert_eq!(source, source_identity(SOURCE)); + assert_ne!(source, source_identity("pub fn add(a,b){a+b}\n")); + assert!(source.to_string().starts_with("runa-source:v1:blake3:")); + } + + #[test] + fn implementation_key_includes_every_compile_input() -> ExperimentResult<()> { + let source_id = source_identity(SOURCE); + let baseline = CompileFingerprint::current(); + let implementation = implementation_identity(source_id, &baseline); + assert!( + implementation + .to_string() + .starts_with("runa-implementation:v1:blake3:") + ); + + let mutations: [fn(&mut CompileFingerprint); 5] = [ + |fingerprint: &mut CompileFingerprint| fingerprint.rune_version.push_str("-next"), + |fingerprint: &mut CompileFingerprint| { + fingerprint.compiler_settings.push_str("-changed") + }, + |fingerprint: &mut CompileFingerprint| fingerprint.native_modules.push_str("-changed"), + |fingerprint: &mut CompileFingerprint| fingerprint.runtime_abi.push_str("-next"), + |fingerprint: &mut CompileFingerprint| fingerprint.target.push_str("-next"), + ]; + for mutate in mutations { + let mut changed = baseline.clone(); + mutate(&mut changed); + assert_ne!(implementation, implementation_identity(source_id, &changed)); + } + Ok(()) + } + + #[test] + fn cache_reuses_artifact_and_preserves_original_source() -> ExperimentResult<()> { + let mut store = CodeStore::default(); + let first = store.compile(SOURCE)?; + let second = store.compile(SOURCE)?; + assert!(Arc::ptr_eq(&first, &second)); + assert!(Arc::ptr_eq(first.program(), second.program())); + assert_eq!(first.original_source(), SOURCE); + assert_eq!(store.original_source(first.source_id), Some(SOURCE)); + assert_eq!(store.source_count(), 1); + assert_eq!(store.artifact_count(), 1); + Ok(()) + } + + #[test] + fn invalid_source_is_retained_but_not_cached_as_compiled() { + let mut store = CodeStore::default(); + let source = "pub fn invalid( {"; + assert!(store.compile(source).is_err()); + assert_eq!(store.original_source(source_identity(source)), Some(source)); + assert_eq!(store.artifact_count(), 0); + } + + #[test] + fn native_module_identity_changes_compiled_identity() -> ExperimentResult<()> { + let source = "pub fn add(a, b) { a + b }"; + let first = + CodeStore::default().compile_with_module(source, "module-a:v1", Module::new())?; + let second = + CodeStore::default().compile_with_module(source, "module-b:v1", Module::new())?; + assert_eq!(first.source_id, second.source_id); + assert_ne!(first.implementation_id, second.implementation_id); + Ok(()) + } +} diff --git a/crates/runa-core/Cargo.toml b/crates/runa-core/Cargo.toml new file mode 100644 index 0000000..b283998 --- /dev/null +++ b/crates/runa-core/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "runa-core" +version = "0.0.0" +edition = "2024" + +[dependencies] diff --git a/crates/runa-core/src/lib.rs b/crates/runa-core/src/lib.rs new file mode 100644 index 0000000..d0a04e8 --- /dev/null +++ b/crates/runa-core/src/lib.rs @@ -0,0 +1,29 @@ +//! Small stable identity types shared across workspace layers. + +use std::fmt; + +macro_rules! digest_id { + ($name:ident, $prefix:literal) => { + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(pub [u8; 32]); + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self) + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, concat!($prefix, ":blake3:"))?; + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } + } + }; +} + +digest_id!(SourceId, "runa-source:v1"); +digest_id!(ImplementationId, "runa-implementation:v1"); diff --git a/crates/runa-rune/src/lib.rs b/crates/runa-rune/src/lib.rs index 0cdca27..52ae601 100644 --- a/crates/runa-rune/src/lib.rs +++ b/crates/runa-rune/src/lib.rs @@ -12,6 +12,10 @@ use rune::runtime::{RuntimeContext, Unit, budget}; use rune::termcolor::{ColorChoice, StandardStream}; use rune::{Context, Diagnostics, Module, Source, Sources, Vm}; +pub const RUNE_VERSION: &str = "0.14.2"; +pub const COMPILER_CONFIGURATION_ID: &str = + "default-modules; native-functions:v1:native_double,native_suspend"; + mod execution; pub use execution::{Invocation, Slice}; diff --git a/crates/runa-runtime/Cargo.toml b/crates/runa-runtime/Cargo.toml index f49ffa9..7a1fee3 100644 --- a/crates/runa-runtime/Cargo.toml +++ b/crates/runa-runtime/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] runa-actor = { path = "../runa-actor" } +runa-code = { path = "../runa-code" } +runa-core = { path = "../runa-core" } runa-rune = { path = "../runa-rune" } [dev-dependencies] diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index b8952c6..d635e5f 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -6,8 +6,10 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::task::{Context, Wake, Waker}; -use runa_actor::{ActorId, ActorSnapshot, Event, ImplementationId, MailboxId, Message, Status}; -use runa_rune::{CompiledProgram, ExperimentResult, Invocation, Slice}; +use runa_actor::{ActorId, ActorSnapshot, Event, MailboxId, Message, Status}; +use runa_code::CompiledArtifact; +use runa_core::ImplementationId; +use runa_rune::{ExperimentResult, Invocation, Slice}; type WireTransition = (i64, Vec<(u64, i64, i64)>); pub type RuntimeResult = ExperimentResult; @@ -34,20 +36,24 @@ struct Actor { #[derive(Default)] pub struct Runtime { actors: BTreeMap, - implementations: BTreeMap, + implementations: BTreeMap>, next_actor: u64, - next_implementation: u64, events: Vec, } impl Runtime { - pub fn publish(&mut self, program: CompiledProgram) -> ExperimentResult { - self.next_implementation = self - .next_implementation - .checked_add(1) - .ok_or("implementation IDs exhausted")?; - let id = ImplementationId(self.next_implementation); - self.implementations.insert(id, program); + pub fn publish( + &mut self, + artifact: Arc, + ) -> ExperimentResult { + let id = artifact.implementation_id; + if let Some(existing) = self.implementations.get(&id) { + if existing.source_id != artifact.source_id { + return Err("implementation identity collision".into()); + } + return Ok(id); + } + self.implementations.insert(id, artifact); Ok(id) } @@ -205,7 +211,7 @@ impl Runtime { continue; }; actor.snapshot.reserved = Some(message); - let program = &self.implementations[&actor.snapshot.implementation]; + let program = self.implementations[&actor.snapshot.implementation].program(); match Invocation::start( program.vm(), "handle", @@ -247,17 +253,23 @@ impl Runtime { #[cfg(test)] mod tests { use super::*; + use runa_code::CodeStore; fn capacity(n: usize) -> NonZeroUsize { NonZeroUsize::new(n).unwrap() } + fn publish(runtime: &mut Runtime, source: &str) -> ExperimentResult { + runtime.publish(CodeStore::default().compile(source)?) + } + #[test] fn two_actors_isolation_and_effects() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile( + let code = publish( + &mut runtime, "pub fn handle(state, kind, value) { if kind == 1 { (state + 1, [(value, 0, 5)]) } else { (state + value, []) } }", - )?)?; + )?; let first = runtime.spawn(code, 0, capacity(4))?; let second = runtime.spawn(code, 10, capacity(4))?; runtime.send( @@ -285,12 +297,14 @@ mod tests { #[test] fn runaway_does_not_starve_healthy_actor() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let runaway = runtime.publish(CompiledProgram::compile( + let runaway = publish( + &mut runtime, "pub fn handle(state, kind, value) { loop {} }", - )?)?; - let healthy = runtime.publish(CompiledProgram::compile( + )?; + let healthy = publish( + &mut runtime, "pub fn handle(state, kind, value) { (state + value, []) }", - )?)?; + )?; let first = runtime.spawn(runaway, 0, capacity(1))?; let second = runtime.spawn(healthy, 0, capacity(2))?; runtime.send(first, Message { kind: 0, value: 1 })?; @@ -313,9 +327,10 @@ mod tests { #[test] fn invalid_effect_does_not_commit_state_or_partial_sends() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile( + let code = publish( + &mut runtime, "pub fn handle(state, kind, value) { (99, [(value, 0, 1), (999, 0, 2)]) }", - )?)?; + )?; let actor = runtime.spawn(code, 5, capacity(4))?; runtime.send( actor, @@ -375,10 +390,13 @@ mod tests { .function("wait", move || Wait(Arc::clone(&native_gate))) .build()?; let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile_with_module( - "pub async fn handle(state, kind, value) { if kind == 1 { let x = wait().await; (state + x, []) } else { (state + value, []) } }", - module, - )?)?; + let code = runtime.publish( + CodeStore::default().compile_with_module( + "pub async fn handle(state, kind, value) { if kind == 1 { let x = wait().await; (state + x, []) } else { (state + value, []) } }", + "test-gate:v1", + module, + )?, + )?; let actor = runtime.spawn(code, 10, capacity(2))?; runtime.send(actor, Message { kind: 1, value: 0 })?; runtime.send(actor, Message { kind: 0, value: 3 })?; @@ -432,9 +450,10 @@ mod tests { #[test] fn mailbox_capacity_and_runtime_failures_are_explicit() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile( + let code = publish( + &mut runtime, "pub fn handle(state, kind, value) { (state / value, []) }", - )?)?; + )?; let actor = runtime.spawn(code, 5, capacity(1))?; runtime.send(actor, Message { kind: 0, value: 0 })?; assert!(runtime.send(actor, Message { kind: 0, value: 1 }).is_err()); @@ -456,9 +475,10 @@ mod tests { #[test] fn aggregate_self_sends_respect_capacity_without_partial_commit() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile( + let code = publish( + &mut runtime, "pub fn handle(state, kind, value) { (99, [(value, 0, 1), (value, 0, 2)]) }", - )?)?; + )?; let actor = runtime.spawn(code, 5, capacity(1))?; runtime.send( actor, @@ -481,9 +501,7 @@ mod tests { #[test] fn invalid_transition_shape_fails_without_committing() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile( - "pub fn handle(state, kind, value) { 99 }", - )?)?; + let code = publish(&mut runtime, "pub fn handle(state, kind, value) { 99 }")?; let actor = runtime.spawn(code, 5, capacity(1))?; runtime.send(actor, Message { kind: 0, value: 1 })?; for _ in 0..100 { @@ -509,10 +527,13 @@ mod tests { }) .build()?; let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile_with_module( - "fn check() { if fail_once() { 1 / 0 } else { 0 } } pub fn handle(state, kind, value) { let _ = check(); (state + value, []) }", - module, - )?)?; + let code = runtime.publish( + CodeStore::default().compile_with_module( + "fn check() { if fail_once() { 1 / 0 } else { 0 } } pub fn handle(state, kind, value) { let _ = check(); (state + value, []) }", + "test-fail-once:v1", + module, + )?, + )?; let actor = runtime.spawn(code, 10, capacity(3))?; let first = Message { kind: 1, value: 2 }; let second = Message { kind: 1, value: 3 }; @@ -559,9 +580,10 @@ mod tests { #[test] fn stop_and_restart_preserve_reserved_and_queued_fifo_order() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let code = runtime.publish(CompiledProgram::compile( + let code = publish( + &mut runtime, "pub fn handle(state, kind, value) { (state * 10 + value, []) }", - )?)?; + )?; let actor = runtime.spawn(code, 0, capacity(3))?; let first = Message { kind: 0, value: 1 }; let second = Message { kind: 0, value: 2 }; diff --git a/crates/runa/Cargo.toml b/crates/runa/Cargo.toml index a4f6ce1..d3ff6c3 100644 --- a/crates/runa/Cargo.toml +++ b/crates/runa/Cargo.toml @@ -5,5 +5,7 @@ edition = "2024" [dependencies] runa-actor = { path = "../runa-actor" } +runa-code = { path = "../runa-code" } +runa-core = { path = "../runa-core" } runa-rune = { path = "../runa-rune" } runa-runtime = { path = "../runa-runtime" } diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index 0db6632..3f801e1 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -2,19 +2,22 @@ use std::io::{self, BufRead, Write}; use std::num::NonZeroUsize; use runa_actor::{ActorId, Message}; -use runa_rune::{CompiledProgram, ExperimentResult}; +use runa_code::CodeStore; +use runa_core::ImplementationId; +use runa_rune::ExperimentResult; use runa_runtime::Runtime; const MAILBOX_CAPACITY: usize = 64; const DEFAULT_TICK_BUDGET: usize = 32; fn main() -> ExperimentResult<()> { - let program = CompiledProgram::compile(include_str!("../../../runa-std/actors/counter.rn"))?; + let mut code = CodeStore::default(); + let artifact = code.compile(include_str!("../../../runa-std/actors/counter.rn"))?; let mut runtime = Runtime::default(); - let implementation = runtime.publish(program)?; + let implementation = runtime.publish(artifact)?; println!("Runa — Rune Actors"); - println!("Loaded the default counter behavior as implementation {implementation:?}."); + println!("Loaded the default counter behavior as implementation {implementation}."); println!("Type `help` for commands."); run_repl( io::stdin().lock(), @@ -29,7 +32,7 @@ fn run_repl( mut input: R, mut output: W, runtime: &mut Runtime, - implementation: runa_actor::ImplementationId, + implementation: ImplementationId, ) -> io::Result<()> { let mut line = String::new(); loop { @@ -209,9 +212,9 @@ mod tests { #[test] fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let implementation = runtime.publish(CompiledProgram::compile(include_str!( - "../../../runa-std/actors/counter.rn" - ))?)?; + let mut code = CodeStore::default(); + let implementation = + runtime.publish(code.compile(include_str!("../../../runa-std/actors/counter.rn"))?)?; let input = io::Cursor::new( "spawn 0\nspawn 10\nsend 1 1 2\ntick 100\ninspect 1\ninspect 2\nevents\nquit\n", ); @@ -228,9 +231,9 @@ mod tests { #[test] fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let implementation = runtime.publish(CompiledProgram::compile(include_str!( - "../../../runa-std/actors/counter.rn" - ))?)?; + let mut code = CodeStore::default(); + let implementation = + runtime.publish(code.compile(include_str!("../../../runa-std/actors/counter.rn"))?)?; let input = io::Cursor::new("spawn nope\nsend 999 0 1\ntick 1 0\nunknown\nquit\n"); let mut output = Vec::new(); run_repl(input, &mut output, &mut runtime, implementation)?; diff --git a/docs/architecture.md b/docs/architecture.md index 864cd22..e3c4d1d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -303,10 +303,11 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen | Crate | Owns | May depend on | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | -| `runa-core` | Small, stable value types shared across independent layers: `ActorId`, `ImplementationId`, canonical message value types, common error identifiers | Minimal external libraries only | +| `runa-core` | Small, stable value types shared across independent layers: actor IDs, source and implementation IDs, canonical message value types, common error identifiers | Minimal external libraries only | +| `runa-code` | Original source retention, versioned source/implementation hashing, compile-input fingerprints, in-memory compiled artifact cache | `runa-core`, `runa-rune`, BLAKE3 | | `runa-actor` | Mailbox and state-transition semantics, message reservation, acknowledgment, effects, actor lifecycle definitions | `runa-core` | | `runa-rune` | Rune compilation, VM execution, suspension, interruption hooks, language-specific diagnostics | `runa-core`, Rune libraries | -| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-rune` | +| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` | | `runa-image-api` | Authorized in-process operations for spawning, inspecting, sending, editing, upgrading, and debugging | `runa-core`, `runa-runtime` | | `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `runa-core` | | `runa-server` | WebSocket/JSON-RPC transport, sessions, authentication, subscriptions | `runa-protocol`, `runa-image-api` | @@ -314,6 +315,10 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen | `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` | | `runa` | Startup, configuration, resource wiring, shutdown | Top-level crates it instantiates | +The current prototype has added `runa-code` for exact-source identity and +in-memory compilation caching. It deliberately does not canonicalize source +yet; canonicalization needs a semantics-preserving, source-map-aware policy. + **Important:** `runa-core` must not contain schedulers, mailbox implementations, persistence engines, Rune VM types, service traits used by only one crate, generic helper collections, or GUI abstractions. Prefer a type defined in its owning crate until two independent consumers demonstrably need a stable shared representation. ### 11.3 Runtime interfaces and avoiding cycles @@ -326,7 +331,7 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen - `runa-world` uses the same `runa-image-api` operations as the external bridge. It does not reach into runtime internals for convenience. - Native platform handles stay behind `runa-graphics` (or a later platform crate), not in `runa-core`. -For content-addressed code, start with source/implementation IDs in `runa-core` and the actual canonicalization, hashing, storage and compilation-cache logic in the crate that owns those operations. If the code store becomes substantial, extract `runa-code` as a separate crate, depending on `runa-core`, and have `runa-runtime`/`runa-rune` consume its APIs through a clearly owned boundary. Do not move all code-store logic into `core`. +For content-addressed code, `runa-core` owns stable source/implementation ID value types, and `runa-code` owns source hashing, compile fingerprints, storage, and compilation caching. The current prototype hashes exact source bytes without canonicalization and keeps the cache in memory. For persistence, begin with a concrete adapter wired by `runa`. Extract `runa-storage` only when its durable mailbox, state, outbox and artifact contracts are clear. Keep database-specific details out of actor semantics and out of `runa-core`. @@ -345,6 +350,7 @@ runa/ ├── crates/ │ ├── runa-core/ │ ├── runa-actor/ +│ ├── runa-code/ │ ├── runa-rune/ │ ├── runa-runtime/ │ ├── runa-image-api/ diff --git a/docs/rune-feasibility.md b/docs/rune-feasibility.md index 7283bc1..76a7e2e 100644 --- a/docs/rune-feasibility.md +++ b/docs/rune-feasibility.md @@ -86,7 +86,9 @@ The [worker-local execution decision](decisions/0001-worker-local-execution.md) now implements this direction using owned asynchronous instruction stepping, typed slice outcomes, and wake-driven scheduling. Follow-up tests cover budgets before/after a genuinely pending operation, wake/resume, cancellation cleanup, -and pure-Rune runaway fairness. See [the runtime guide](runtime.md). +and pure-Rune runaway fairness. The source and implementation identity baseline +is implemented by `runa-code`; see [the runtime guide](runtime.md) for its +current exact-source and cache-key scope. Use immutable compiled units and native contexts shared by independent actor execution stacks. Keep Rune values and active execution on their owning worker diff --git a/docs/runtime.md b/docs/runtime.md index 2d7e992..5047332 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -34,13 +34,33 @@ The default per-actor slice budget is 32 instructions. - `runa-actor` owns actor, mailbox and implementation IDs, structured messages, snapshots, statuses and lifecycle events. +- `runa-core` owns stable source and implementation identity types. +- `runa-code` preserves original source, derives identities, and caches + compiled artifacts for the process lifetime. - `runa-rune` owns compilation and typed bounded invocation execution. - `runa-runtime` owns implementation registration, queues, private committed state, scheduling, message effects and lifecycle coordination. - `runa` composes the experiment and demonstration. An implementation is published once under an ID. Actors reference that exact -implementation. IDs and incarnation counters use checked increments. +implementation. Implementation IDs use source content and the compile +fingerprint; actor and mailbox IDs remain process-local counters. Actor IDs and +incarnation counters use checked increments. + +The source identity hashes the exact original UTF-8 bytes with the versioned +`runa-source:v1` domain. Whitespace/comment canonicalization is intentionally +not attempted; textually different source has a different identity. The +implementation identity includes the source ID, pinned Rune version, compiler +settings/features, registered native-module manifest, runtime ABI version, and +Cargo target triple. The native-module manifest and runtime ABI identifier must +be updated when those compile inputs change. + +The cache is in-memory only. Invalid source is retained in the current +`CodeStore` for diagnostics, but does not produce a compiled artifact. Cache +identity is not a claim that Rune bytecode is portable or safe to persist. +`CodeStore::compile_with_module` requires an explicit stable module identity; +callers must change it when registered native functions or their contracts +change. The CLI currently creates a fresh `CodeStore` when it starts. Prototype state is an `i64`; messages contain integer `kind` and `value` fields. Rune handlers have this deliberately restricted interface: