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>
This commit is contained in:
Steve Biedermann 2026-11-02 10:08:49 +01:00
parent 2230b666ab
commit c04a7ed12f
20 changed files with 474 additions and 58 deletions

View File

@ -61,7 +61,9 @@ proposed capabilities as plans, not existing behavior.
- `Cargo.toml`: Rust workspace definition. - `Cargo.toml`: Rust workspace definition.
- `crates/runa/`: current executable crate and entry point. - `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-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-actor/`: actor value and lifecycle contracts.
- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. - `crates/runa-runtime/`: single-worker scheduling and in-memory transitions.
- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI. - `runa-std/actors/`: default Rune actor scripts loaded by the local CLI.

View File

@ -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. pending-message isolation, failures, backpressure, and all-or-nothing effects.
- Interactive local CLI for spawning actors, sending messages, advancing ticks, - Interactive local CLI for spawning actors, sending messages, advancing ticks,
inspecting actors/events, and stopping/restarting actors. 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. - Execution-model decision and Phase 1 runtime documentation.
- Lifecycle tests proving failed-message retry from committed state and - Lifecycle tests proving failed-message retry from committed state and
stop/restart preservation of reserved-first FIFO order. 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 ### 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 - Expanded the architecture specification with crate ownership and dependency
boundaries, and moved it to `docs/architecture.md`. boundaries, and moved it to `docs/architecture.md`.
- Required agents to pass `mise run pre-commit` before every commit and document - Required agents to pass `mise run pre-commit` before every commit and document

55
Cargo.lock generated
View File

@ -29,12 +29,31 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" 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]] [[package]]
name = "cc" name = "cc"
version = "1.10.0" version = "1.10.0"
@ -61,6 +80,21 @@ dependencies = [
"unicode-width", "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]] [[package]]
name = "critical-section" name = "critical-section"
version = "1.2.0" version = "1.2.0"
@ -359,6 +393,8 @@ name = "runa"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"runa-actor", "runa-actor",
"runa-code",
"runa-core",
"runa-rune", "runa-rune",
"runa-runtime", "runa-runtime",
] ]
@ -366,6 +402,23 @@ dependencies = [
[[package]] [[package]]
name = "runa-actor" name = "runa-actor"
version = "0.0.0" 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]] [[package]]
name = "runa-rune" name = "runa-rune"
@ -379,6 +432,8 @@ name = "runa-runtime"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"runa-actor", "runa-actor",
"runa-code",
"runa-core",
"runa-rune", "runa-rune",
"rune", "rune",
] ]

View File

@ -3,6 +3,8 @@ resolver = "3"
members = [ members = [
"crates/runa", "crates/runa",
"crates/runa-actor", "crates/runa-actor",
"crates/runa-code",
"crates/runa-core",
"crates/runa-rune", "crates/runa-rune",
"crates/runa-runtime", "crates/runa-runtime",
] ]

View File

@ -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, experiment using Rune 0.14.2: script execution, native calls, async suspension,
instruction budgets, stepping, and compiled-code sharing. It also runs a minimal instruction budgets, stepping, and compiled-code sharing. It also runs a minimal
single-worker actor demonstration with bounded mailboxes and explicit message single-worker actor demonstration with bounded mailboxes and explicit message
effects. Persistence, the graphical world, and development API are not effects. Source and compiled implementation identities are BLAKE3 content
implemented yet. 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 The prototype uses worker-local invocation stacks and cooperative instruction
slices. Native callbacks are not preemptible. slices. Native callbacks are not preemptible.
@ -88,7 +90,9 @@ test task runs `cargo test --workspace`.
├── Cargo.toml ├── Cargo.toml
├── crates/ ├── crates/
│ ├── runa/ # Main binary │ ├── runa/ # Main binary
│ ├── runa-core/ # Shared stable identity types
│ ├── runa-actor/ # Actor value contracts │ ├── runa-actor/ # Actor value contracts
│ ├── runa-code/ # Source identities and compile cache
│ ├── runa-rune/ # Phase 0 Rune experiments │ ├── runa-rune/ # Phase 0 Rune experiments
│ └── runa-runtime/ # Single-worker actor prototype │ └── runa-runtime/ # Single-worker actor prototype
├── docs/ ├── docs/

View File

@ -4,3 +4,4 @@ version = "0.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
runa-core = { path = "../runa-core" }

View File

@ -1,12 +1,11 @@
//! Value contracts for the single-worker actor prototype. //! Value contracts for the single-worker actor prototype.
use runa_core::ImplementationId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ActorId(pub u64); pub struct ActorId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct ImplementationId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MailboxId(pub u64); pub struct MailboxId(pub u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@ -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"

View File

@ -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}");
}

238
crates/runa-code/src/lib.rs Normal file
View File

@ -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<str>,
program: Arc<CompiledProgram>,
fingerprint: CompileFingerprint,
}
impl CompiledArtifact {
pub fn original_source(&self) -> &str {
&self.original_source
}
pub fn program(&self) -> &Arc<CompiledProgram> {
&self.program
}
pub fn fingerprint(&self) -> &CompileFingerprint {
&self.fingerprint
}
}
#[derive(Default)]
pub struct CodeStore {
sources: BTreeMap<SourceId, Arc<str>>,
artifacts: BTreeMap<ImplementationId, Arc<CompiledArtifact>>,
}
impl CodeStore {
pub fn compile(&mut self, source: &str) -> ExperimentResult<Arc<CompiledArtifact>> {
self.compile_using(source, CompileFingerprint::current(), || {
CompiledProgram::compile(source)
})
}
pub fn compile_with_module(
&mut self,
source: &str,
module_id: &str,
module: Module,
) -> ExperimentResult<Arc<CompiledArtifact>> {
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<CompiledProgram>,
) -> ExperimentResult<Arc<CompiledArtifact>> {
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<str> = 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<CompiledArtifact>> {
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(())
}
}

View File

@ -0,0 +1,6 @@
[package]
name = "runa-core"
version = "0.0.0"
edition = "2024"
[dependencies]

View File

@ -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");

View File

@ -12,6 +12,10 @@ use rune::runtime::{RuntimeContext, Unit, budget};
use rune::termcolor::{ColorChoice, StandardStream}; use rune::termcolor::{ColorChoice, StandardStream};
use rune::{Context, Diagnostics, Module, Source, Sources, Vm}; 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; mod execution;
pub use execution::{Invocation, Slice}; pub use execution::{Invocation, Slice};

View File

@ -5,6 +5,8 @@ edition = "2024"
[dependencies] [dependencies]
runa-actor = { path = "../runa-actor" } runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" }
runa-core = { path = "../runa-core" }
runa-rune = { path = "../runa-rune" } runa-rune = { path = "../runa-rune" }
[dev-dependencies] [dev-dependencies]

View File

@ -6,8 +6,10 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Wake, Waker}; use std::task::{Context, Wake, Waker};
use runa_actor::{ActorId, ActorSnapshot, Event, ImplementationId, MailboxId, Message, Status}; use runa_actor::{ActorId, ActorSnapshot, Event, MailboxId, Message, Status};
use runa_rune::{CompiledProgram, ExperimentResult, Invocation, Slice}; use runa_code::CompiledArtifact;
use runa_core::ImplementationId;
use runa_rune::{ExperimentResult, Invocation, Slice};
type WireTransition = (i64, Vec<(u64, i64, i64)>); type WireTransition = (i64, Vec<(u64, i64, i64)>);
pub type RuntimeResult<T> = ExperimentResult<T>; pub type RuntimeResult<T> = ExperimentResult<T>;
@ -34,20 +36,24 @@ struct Actor {
#[derive(Default)] #[derive(Default)]
pub struct Runtime { pub struct Runtime {
actors: BTreeMap<ActorId, Actor>, actors: BTreeMap<ActorId, Actor>,
implementations: BTreeMap<ImplementationId, CompiledProgram>, implementations: BTreeMap<ImplementationId, Arc<CompiledArtifact>>,
next_actor: u64, next_actor: u64,
next_implementation: u64,
events: Vec<Event>, events: Vec<Event>,
} }
impl Runtime { impl Runtime {
pub fn publish(&mut self, program: CompiledProgram) -> ExperimentResult<ImplementationId> { pub fn publish(
self.next_implementation = self &mut self,
.next_implementation artifact: Arc<CompiledArtifact>,
.checked_add(1) ) -> ExperimentResult<ImplementationId> {
.ok_or("implementation IDs exhausted")?; let id = artifact.implementation_id;
let id = ImplementationId(self.next_implementation); if let Some(existing) = self.implementations.get(&id) {
self.implementations.insert(id, program); if existing.source_id != artifact.source_id {
return Err("implementation identity collision".into());
}
return Ok(id);
}
self.implementations.insert(id, artifact);
Ok(id) Ok(id)
} }
@ -205,7 +211,7 @@ impl Runtime {
continue; continue;
}; };
actor.snapshot.reserved = Some(message); actor.snapshot.reserved = Some(message);
let program = &self.implementations[&actor.snapshot.implementation]; let program = self.implementations[&actor.snapshot.implementation].program();
match Invocation::start( match Invocation::start(
program.vm(), program.vm(),
"handle", "handle",
@ -247,17 +253,23 @@ impl Runtime {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use runa_code::CodeStore;
fn capacity(n: usize) -> NonZeroUsize { fn capacity(n: usize) -> NonZeroUsize {
NonZeroUsize::new(n).unwrap() NonZeroUsize::new(n).unwrap()
} }
fn publish(runtime: &mut Runtime, source: &str) -> ExperimentResult<ImplementationId> {
runtime.publish(CodeStore::default().compile(source)?)
}
#[test] #[test]
fn two_actors_isolation_and_effects() -> ExperimentResult<()> { fn two_actors_isolation_and_effects() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); 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, []) } }", "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 first = runtime.spawn(code, 0, capacity(4))?;
let second = runtime.spawn(code, 10, capacity(4))?; let second = runtime.spawn(code, 10, capacity(4))?;
runtime.send( runtime.send(
@ -285,12 +297,14 @@ mod tests {
#[test] #[test]
fn runaway_does_not_starve_healthy_actor() -> ExperimentResult<()> { fn runaway_does_not_starve_healthy_actor() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let runaway = runtime.publish(CompiledProgram::compile( let runaway = publish(
&mut runtime,
"pub fn handle(state, kind, value) { loop {} }", "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, []) }", "pub fn handle(state, kind, value) { (state + value, []) }",
)?)?; )?;
let first = runtime.spawn(runaway, 0, capacity(1))?; let first = runtime.spawn(runaway, 0, capacity(1))?;
let second = runtime.spawn(healthy, 0, capacity(2))?; let second = runtime.spawn(healthy, 0, capacity(2))?;
runtime.send(first, Message { kind: 0, value: 1 })?; runtime.send(first, Message { kind: 0, value: 1 })?;
@ -313,9 +327,10 @@ mod tests {
#[test] #[test]
fn invalid_effect_does_not_commit_state_or_partial_sends() -> ExperimentResult<()> { fn invalid_effect_does_not_commit_state_or_partial_sends() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); 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)]) }", "pub fn handle(state, kind, value) { (99, [(value, 0, 1), (999, 0, 2)]) }",
)?)?; )?;
let actor = runtime.spawn(code, 5, capacity(4))?; let actor = runtime.spawn(code, 5, capacity(4))?;
runtime.send( runtime.send(
actor, actor,
@ -375,10 +390,13 @@ mod tests {
.function("wait", move || Wait(Arc::clone(&native_gate))) .function("wait", move || Wait(Arc::clone(&native_gate)))
.build()?; .build()?;
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = runtime.publish(CompiledProgram::compile_with_module( let code = runtime.publish(
"pub async fn handle(state, kind, value) { if kind == 1 { let x = wait().await; (state + x, []) } else { (state + value, []) } }", CodeStore::default().compile_with_module(
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))?; let actor = runtime.spawn(code, 10, capacity(2))?;
runtime.send(actor, Message { kind: 1, value: 0 })?; runtime.send(actor, Message { kind: 1, value: 0 })?;
runtime.send(actor, Message { kind: 0, value: 3 })?; runtime.send(actor, Message { kind: 0, value: 3 })?;
@ -432,9 +450,10 @@ mod tests {
#[test] #[test]
fn mailbox_capacity_and_runtime_failures_are_explicit() -> ExperimentResult<()> { fn mailbox_capacity_and_runtime_failures_are_explicit() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = runtime.publish(CompiledProgram::compile( let code = publish(
&mut runtime,
"pub fn handle(state, kind, value) { (state / value, []) }", "pub fn handle(state, kind, value) { (state / value, []) }",
)?)?; )?;
let actor = runtime.spawn(code, 5, capacity(1))?; let actor = runtime.spawn(code, 5, capacity(1))?;
runtime.send(actor, Message { kind: 0, value: 0 })?; runtime.send(actor, Message { kind: 0, value: 0 })?;
assert!(runtime.send(actor, Message { kind: 0, value: 1 }).is_err()); assert!(runtime.send(actor, Message { kind: 0, value: 1 }).is_err());
@ -456,9 +475,10 @@ mod tests {
#[test] #[test]
fn aggregate_self_sends_respect_capacity_without_partial_commit() -> ExperimentResult<()> { fn aggregate_self_sends_respect_capacity_without_partial_commit() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); 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)]) }", "pub fn handle(state, kind, value) { (99, [(value, 0, 1), (value, 0, 2)]) }",
)?)?; )?;
let actor = runtime.spawn(code, 5, capacity(1))?; let actor = runtime.spawn(code, 5, capacity(1))?;
runtime.send( runtime.send(
actor, actor,
@ -481,9 +501,7 @@ mod tests {
#[test] #[test]
fn invalid_transition_shape_fails_without_committing() -> ExperimentResult<()> { fn invalid_transition_shape_fails_without_committing() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = runtime.publish(CompiledProgram::compile( let code = publish(&mut runtime, "pub fn handle(state, kind, value) { 99 }")?;
"pub fn handle(state, kind, value) { 99 }",
)?)?;
let actor = runtime.spawn(code, 5, capacity(1))?; let actor = runtime.spawn(code, 5, capacity(1))?;
runtime.send(actor, Message { kind: 0, value: 1 })?; runtime.send(actor, Message { kind: 0, value: 1 })?;
for _ in 0..100 { for _ in 0..100 {
@ -509,10 +527,13 @@ mod tests {
}) })
.build()?; .build()?;
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = runtime.publish(CompiledProgram::compile_with_module( let code = runtime.publish(
"fn check() { if fail_once() { 1 / 0 } else { 0 } } pub fn handle(state, kind, value) { let _ = check(); (state + value, []) }", CodeStore::default().compile_with_module(
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 actor = runtime.spawn(code, 10, capacity(3))?;
let first = Message { kind: 1, value: 2 }; let first = Message { kind: 1, value: 2 };
let second = Message { kind: 1, value: 3 }; let second = Message { kind: 1, value: 3 };
@ -559,9 +580,10 @@ mod tests {
#[test] #[test]
fn stop_and_restart_preserve_reserved_and_queued_fifo_order() -> ExperimentResult<()> { fn stop_and_restart_preserve_reserved_and_queued_fifo_order() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); 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, []) }", "pub fn handle(state, kind, value) { (state * 10 + value, []) }",
)?)?; )?;
let actor = runtime.spawn(code, 0, capacity(3))?; let actor = runtime.spawn(code, 0, capacity(3))?;
let first = Message { kind: 0, value: 1 }; let first = Message { kind: 0, value: 1 };
let second = Message { kind: 0, value: 2 }; let second = Message { kind: 0, value: 2 };

View File

@ -5,5 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
runa-actor = { path = "../runa-actor" } runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" }
runa-core = { path = "../runa-core" }
runa-rune = { path = "../runa-rune" } runa-rune = { path = "../runa-rune" }
runa-runtime = { path = "../runa-runtime" } runa-runtime = { path = "../runa-runtime" }

View File

@ -2,19 +2,22 @@ use std::io::{self, BufRead, Write};
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use runa_actor::{ActorId, Message}; 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; use runa_runtime::Runtime;
const MAILBOX_CAPACITY: usize = 64; const MAILBOX_CAPACITY: usize = 64;
const DEFAULT_TICK_BUDGET: usize = 32; const DEFAULT_TICK_BUDGET: usize = 32;
fn main() -> ExperimentResult<()> { 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 mut runtime = Runtime::default();
let implementation = runtime.publish(program)?; let implementation = runtime.publish(artifact)?;
println!("Runa — Rune Actors"); 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."); println!("Type `help` for commands.");
run_repl( run_repl(
io::stdin().lock(), io::stdin().lock(),
@ -29,7 +32,7 @@ fn run_repl<R: BufRead, W: Write>(
mut input: R, mut input: R,
mut output: W, mut output: W,
runtime: &mut Runtime, runtime: &mut Runtime,
implementation: runa_actor::ImplementationId, implementation: ImplementationId,
) -> io::Result<()> { ) -> io::Result<()> {
let mut line = String::new(); let mut line = String::new();
loop { loop {
@ -209,9 +212,9 @@ mod tests {
#[test] #[test]
fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> { fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let implementation = runtime.publish(CompiledProgram::compile(include_str!( let mut code = CodeStore::default();
"../../../runa-std/actors/counter.rn" let implementation =
))?)?; runtime.publish(code.compile(include_str!("../../../runa-std/actors/counter.rn"))?)?;
let input = io::Cursor::new( let input = io::Cursor::new(
"spawn 0\nspawn 10\nsend 1 1 2\ntick 100\ninspect 1\ninspect 2\nevents\nquit\n", "spawn 0\nspawn 10\nsend 1 1 2\ntick 100\ninspect 1\ninspect 2\nevents\nquit\n",
); );
@ -228,9 +231,9 @@ mod tests {
#[test] #[test]
fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> { fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let implementation = runtime.publish(CompiledProgram::compile(include_str!( let mut code = CodeStore::default();
"../../../runa-std/actors/counter.rn" 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 input = io::Cursor::new("spawn nope\nsend 999 0 1\ntick 1 0\nunknown\nquit\n");
let mut output = Vec::new(); let mut output = Vec::new();
run_repl(input, &mut output, &mut runtime, implementation)?; run_repl(input, &mut output, &mut runtime, implementation)?;

View File

@ -303,10 +303,11 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
| Crate | Owns | May depend on | | 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-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-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-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-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` | | `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-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 | | `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. **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 ### 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. - `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`. - 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`. 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/ ├── crates/
│ ├── runa-core/ │ ├── runa-core/
│ ├── runa-actor/ │ ├── runa-actor/
│ ├── runa-code/
│ ├── runa-rune/ │ ├── runa-rune/
│ ├── runa-runtime/ │ ├── runa-runtime/
│ ├── runa-image-api/ │ ├── runa-image-api/

View File

@ -86,7 +86,9 @@ The [worker-local execution decision](decisions/0001-worker-local-execution.md)
now implements this direction using owned asynchronous instruction stepping, now implements this direction using owned asynchronous instruction stepping,
typed slice outcomes, and wake-driven scheduling. Follow-up tests cover budgets typed slice outcomes, and wake-driven scheduling. Follow-up tests cover budgets
before/after a genuinely pending operation, wake/resume, cancellation cleanup, 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 Use immutable compiled units and native contexts shared by independent actor
execution stacks. Keep Rune values and active execution on their owning worker execution stacks. Keep Rune values and active execution on their owning worker

View File

@ -34,13 +34,33 @@ The default per-actor slice budget is 32 instructions.
- `runa-actor` owns actor, mailbox and implementation IDs, structured messages, - `runa-actor` owns actor, mailbox and implementation IDs, structured messages,
snapshots, statuses and lifecycle events. 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-rune` owns compilation and typed bounded invocation execution.
- `runa-runtime` owns implementation registration, queues, private committed - `runa-runtime` owns implementation registration, queues, private committed
state, scheduling, message effects and lifecycle coordination. state, scheduling, message effects and lifecycle coordination.
- `runa` composes the experiment and demonstration. - `runa` composes the experiment and demonstration.
An implementation is published once under an ID. Actors reference that exact 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` Prototype state is an `i64`; messages contain integer `kind` and `value`
fields. Rune handlers have this deliberately restricted interface: fields. Rune handlers have this deliberately restricted interface: