feat: add trusted in-process image API

Introduce the runa-image-api service boundary for behavior source retrieval, publishing, history, rollback, actor lifecycle, messaging, scheduling, snapshots, and events. Route the local CLI through the API and document that it is trusted and process-local, with no network authentication.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 13:20:45 +01:00
parent 8d3496c621
commit 515a6439b5
11 changed files with 355 additions and 148 deletions

View File

@ -66,6 +66,7 @@ proposed capabilities as plans, not existing behavior.
- `crates/runa-code/`: source identities and in-memory Rune compile cache. - `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.
- `crates/runa-image-api/`: trusted in-process services for operating on an image.
- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI. - `runa-std/actors/`: default Rune actor scripts loaded by the local CLI.
- `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario. - `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
- `docs/decisions/`: architectural decisions. - `docs/decisions/`: architectural decisions.

View File

@ -31,6 +31,8 @@ All notable changes to this project will be documented in this file.
retry, exposed in the CLI and documented with native side-effect caveats. retry, exposed in the CLI and documented with native side-effect caveats.
- Active actor execution telemetry in snapshots: completed VM steps, - Active actor execution telemetry in snapshots: completed VM steps,
instruction pointer, and call-frame depth. instruction pointer, and call-frame depth.
- Trusted in-process `runa-image-api` service boundary for code and actor
operations, now used by the local CLI.
- 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.

11
Cargo.lock generated
View File

@ -395,6 +395,7 @@ dependencies = [
"runa-actor", "runa-actor",
"runa-code", "runa-code",
"runa-core", "runa-core",
"runa-image-api",
"runa-rune", "runa-rune",
"runa-runtime", "runa-runtime",
] ]
@ -420,6 +421,16 @@ dependencies = [
name = "runa-core" name = "runa-core"
version = "0.0.0" version = "0.0.0"
[[package]]
name = "runa-image-api"
version = "0.0.0"
dependencies = [
"runa-actor",
"runa-code",
"runa-core",
"runa-runtime",
]
[[package]] [[package]]
name = "runa-rune" name = "runa-rune"
version = "0.0.0" version = "0.0.0"

View File

@ -5,6 +5,7 @@ members = [
"crates/runa-actor", "crates/runa-actor",
"crates/runa-code", "crates/runa-code",
"crates/runa-core", "crates/runa-core",
"crates/runa-image-api",
"crates/runa-rune", "crates/runa-rune",
"crates/runa-runtime", "crates/runa-runtime",
] ]

View File

@ -13,11 +13,12 @@ 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. Source and compiled implementation identities are BLAKE3 content effects. Source and compiled implementation identities are BLAKE3 content
addresses with an in-memory compile cache. Persistence, interactive behavior addresses with an in-memory compile cache. Persistence, canonicalization, the
editing, canonicalization, the graphical world, and development API are not network development API, graphical world, and VS Code extension are not
implemented yet. The code library now supports an in-memory versioned behavior implemented yet. The code library now supports an in-memory versioned behavior
registry, and the CLI exposes publishing, history, rollback, safe-boundary registry, and the CLI exposes publishing, history, rollback, safe-boundary
installation, pause/resume, and cancellation. installation, pause/resume, and cancellation through a trusted in-process image
API. It is not a network API and does not provide authentication.
The prototype uses worker-local invocation stacks and cooperative instruction The prototype uses worker-local invocation stacks and cooperative instruction
slices, and actor snapshots report basic active-execution progress. Native slices, and actor snapshots report basic active-execution progress. Native
@ -104,7 +105,8 @@ test task runs `cargo test --workspace`.
│ ├── runa-actor/ # Actor value contracts │ ├── runa-actor/ # Actor value contracts
│ ├── runa-code/ # Source identities and compile cache │ ├── 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
│ └── runa-image-api/ # Trusted in-process image operations
├── docs/ ├── docs/
│ ├── architecture.md │ ├── architecture.md
│ └── decisions/ │ └── decisions/

View File

@ -0,0 +1,10 @@
[package]
name = "runa-image-api"
version = "0.0.0"
edition = "2024"
[dependencies]
runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" }
runa-core = { path = "../runa-core" }
runa-runtime = { path = "../runa-runtime" }

View File

@ -0,0 +1,229 @@
//! Trusted, in-process application services for a running Runa image.
use std::num::NonZeroUsize;
use std::sync::Arc;
use runa_actor::{ActorId, ActorSnapshot, Event, Message};
use runa_code::{BehaviorSnapshot, CodeStore};
use runa_core::ImplementationId;
use runa_runtime::{Runtime, RuntimeResult};
pub struct ImageApi {
code: CodeStore,
runtime: Runtime,
}
impl Default for ImageApi {
fn default() -> Self {
Self::new()
}
}
impl ImageApi {
pub fn new() -> Self {
Self {
code: CodeStore::default(),
runtime: Runtime::default(),
}
}
pub fn publish_behavior(
&mut self,
name: &str,
expected_revision: Option<u64>,
source: &str,
) -> RuntimeResult<BehaviorSnapshot> {
let artifact = self.code.compile(source)?;
self.runtime.publish(Arc::clone(&artifact))?;
self.code.publish_behavior(name, expected_revision, source)
}
pub fn rollback_behavior(
&mut self,
name: &str,
expected_revision: u64,
target_revision: u64,
) -> RuntimeResult<BehaviorSnapshot> {
self.code
.rollback_behavior(name, expected_revision, target_revision)
}
pub fn behavior(&self, name: &str) -> Option<BehaviorSnapshot> {
self.code.behavior(name)
}
pub fn behaviors(&self) -> Vec<BehaviorSnapshot> {
self.code.behaviors()
}
pub fn behavior_source(&self, name: &str, revision: u64) -> RuntimeResult<&str> {
let behavior = self.code.behavior(name).ok_or("unknown behavior")?;
let version = behavior
.history
.iter()
.find(|version| version.revision == revision)
.ok_or("unknown behavior revision")?;
self.code
.artifact(version.implementation_id)
.map(|artifact| artifact.original_source())
.ok_or_else(|| "behavior artifact is unavailable".into())
}
pub fn spawn(
&mut self,
behavior_name: &str,
state: i64,
capacity: NonZeroUsize,
) -> RuntimeResult<ActorId> {
let behavior = self
.code
.behavior(behavior_name)
.ok_or("unknown behavior")?;
self.runtime.spawn(behavior.current, state, capacity)
}
pub fn send(&mut self, actor: ActorId, message: Message) -> RuntimeResult<()> {
self.runtime.send(actor, message)
}
pub fn tick(&mut self, ticks: usize, budget: NonZeroUsize) {
for _ in 0..ticks {
self.runtime.tick(budget);
}
}
pub fn inspect(&self, actor: ActorId) -> RuntimeResult<ActorSnapshot> {
self.runtime.inspect(actor)
}
pub fn actors(&self) -> Vec<ActorSnapshot> {
self.runtime.actors()
}
pub fn events(&mut self) -> Vec<Event> {
self.runtime.drain_events()
}
pub fn stop(&mut self, actor: ActorId) -> RuntimeResult<()> {
self.runtime.stop(actor)
}
pub fn restart(&mut self, actor: ActorId) -> RuntimeResult<()> {
self.runtime.restart(actor)
}
pub fn pause(&mut self, actor: ActorId) -> RuntimeResult<()> {
self.runtime.pause(actor)
}
pub fn resume(&mut self, actor: ActorId) -> RuntimeResult<()> {
self.runtime.resume(actor)
}
pub fn cancel(&mut self, actor: ActorId) -> RuntimeResult<()> {
self.runtime.cancel(actor)
}
pub fn install(
&mut self,
actor: ActorId,
behavior_name: &str,
) -> RuntimeResult<ImplementationId> {
self.install_with_migration(actor, behavior_name, Ok)
}
pub fn install_with_migration(
&mut self,
actor: ActorId,
behavior_name: &str,
migrate: impl FnOnce(i64) -> RuntimeResult<i64>,
) -> RuntimeResult<ImplementationId> {
let behavior = self
.code
.behavior(behavior_name)
.ok_or("unknown behavior")?;
self.runtime
.install_with_migration(actor, behavior.current, migrate)?;
Ok(behavior.current)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn capacity(n: usize) -> NonZeroUsize {
NonZeroUsize::new(n).expect("capacity must be positive")
}
#[test]
fn image_api_publishes_spawns_inspects_and_exposes_events() -> RuntimeResult<()> {
let mut image = ImageApi::new();
let behavior = image.publish_behavior(
"counter",
None,
"pub fn handle(state, kind, value) { (state + value, []) }",
)?;
assert_eq!(
image.behavior_source("counter", behavior.revision)?,
"pub fn handle(state, kind, value) { (state + value, []) }"
);
let actor = image.spawn("counter", 5, capacity(2))?;
image.send(actor, Message { kind: 0, value: 7 })?;
image.tick(100, capacity(10));
assert_eq!(image.inspect(actor)?.state, 12);
assert_eq!(image.inspect(actor)?.implementation, behavior.current);
assert!(
image
.events()
.iter()
.any(|event| matches!(event, Event::Committed(id) if *id == actor))
);
Ok(())
}
#[test]
fn image_api_rejects_stale_behavior_updates_without_changing_current() -> RuntimeResult<()> {
let mut image = ImageApi::new();
let first = image.publish_behavior(
"counter",
None,
"pub fn handle(state, kind, value) { (state + value, []) }",
)?;
assert!(
image
.publish_behavior(
"counter",
None,
"pub fn handle(state, kind, value) { (state - value, []) }"
)
.is_err()
);
assert_eq!(image.behavior("counter").unwrap().current, first.current);
Ok(())
}
#[test]
fn image_api_installs_behavior_with_atomic_state_migration() -> RuntimeResult<()> {
let mut image = ImageApi::new();
let first = image.publish_behavior(
"counter",
None,
"pub fn handle(state, kind, value) { (state + value, []) }",
)?;
image.publish_behavior(
"counter",
Some(first.revision),
"pub fn handle(state, kind, value) { (state * value, []) }",
)?;
let actor = image.spawn("counter", 5, capacity(2))?;
let implementation = image.install_with_migration(actor, "counter", |state| {
Ok(state.checked_mul(10).ok_or("migration overflow")?)
})?;
assert_eq!(image.inspect(actor)?.state, 50);
assert_eq!(image.inspect(actor)?.implementation, implementation);
Ok(())
}
}

View File

@ -7,5 +7,6 @@ edition = "2024"
runa-actor = { path = "../runa-actor" } runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" } runa-code = { path = "../runa-code" }
runa-core = { path = "../runa-core" } runa-core = { path = "../runa-core" }
runa-image-api = { path = "../runa-image-api" }
runa-rune = { path = "../runa-rune" } runa-rune = { path = "../runa-rune" }
runa-runtime = { path = "../runa-runtime" } runa-runtime = { path = "../runa-runtime" }

View File

@ -2,26 +2,20 @@ 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_code::CodeStore; use runa_image_api::ImageApi;
use runa_rune::ExperimentResult; use runa_rune::ExperimentResult;
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;
const DEFAULT_BEHAVIOR: &str = "counter"; const DEFAULT_BEHAVIOR: &str = "counter";
fn main() -> ExperimentResult<()> { fn main() -> ExperimentResult<()> {
let mut code = CodeStore::default(); let mut image = ImageApi::new();
let behavior = code.publish_behavior( let behavior = image.publish_behavior(
DEFAULT_BEHAVIOR, DEFAULT_BEHAVIOR,
None, None,
include_str!("../../../runa-std/actors/counter.rn"), include_str!("../../../runa-std/actors/counter.rn"),
)?; )?;
let artifact = code
.artifact(behavior.current)
.ok_or("published behavior artifact is missing")?;
let mut runtime = Runtime::default();
runtime.publish(std::sync::Arc::clone(artifact))?;
println!("Runa — Rune Actors"); println!("Runa — Rune Actors");
println!( println!(
@ -32,8 +26,7 @@ fn main() -> ExperimentResult<()> {
run_repl( run_repl(
io::stdin().lock(), io::stdin().lock(),
io::stdout().lock(), io::stdout().lock(),
&mut code, &mut image,
&mut runtime,
DEFAULT_BEHAVIOR, DEFAULT_BEHAVIOR,
)?; )?;
Ok(()) Ok(())
@ -42,8 +35,7 @@ fn main() -> ExperimentResult<()> {
fn run_repl<R: BufRead, W: Write>( fn run_repl<R: BufRead, W: Write>(
mut input: R, mut input: R,
mut output: W, mut output: W,
code: &mut CodeStore, image: &mut ImageApi,
runtime: &mut Runtime,
default_behavior: &str, default_behavior: &str,
) -> io::Result<()> { ) -> io::Result<()> {
let mut line = String::new(); let mut line = String::new();
@ -62,9 +54,9 @@ fn run_repl<R: BufRead, W: Write>(
let args: Vec<_> = words.collect(); let args: Vec<_> = words.collect();
let result = match command { let result = match command {
"help" => print_help(&mut output), "help" => print_help(&mut output),
"spawn" => spawn_command(&mut output, &args, default_behavior, code, runtime), "spawn" => spawn_command(&mut output, &args, default_behavior, image),
"behaviors" if args.is_empty() => { "behaviors" if args.is_empty() => {
for behavior in code.behaviors() { for behavior in image.behaviors() {
writeln!( writeln!(
output, output,
"{} revision {} ({})", "{} revision {} ({})",
@ -74,12 +66,12 @@ fn run_repl<R: BufRead, W: Write>(
Ok(()) Ok(())
} }
"behaviors" => writeln!(output, "error: usage: behaviors"), "behaviors" => writeln!(output, "error: usage: behaviors"),
"publish" => publish_command(&mut output, &args, code, runtime), "publish" => publish_command(&mut output, &args, image),
"history" => history_command(&mut output, &args, code), "history" => history_command(&mut output, &args, image),
"rollback" => rollback_command(&mut output, &args, code), "rollback" => rollback_command(&mut output, &args, image),
"install" => install_command(&mut output, &args, code, runtime), "install" => install_command(&mut output, &args, image),
"actors" if args.is_empty() => { "actors" if args.is_empty() => {
for actor in runtime.actors() { for actor in image.actors() {
writeln!(output, "{actor:#?}")?; writeln!(output, "{actor:#?}")?;
} }
Ok(()) Ok(())
@ -87,7 +79,7 @@ fn run_repl<R: BufRead, W: Write>(
"actors" => writeln!(output, "error: usage: actors"), "actors" => writeln!(output, "error: usage: actors"),
"send" => match parse_send_args(&args) { "send" => match parse_send_args(&args) {
Ok((actor, kind, value)) => { Ok((actor, kind, value)) => {
match runtime.send(ActorId(actor), Message { kind, value }) { match image.send(ActorId(actor), Message { kind, value }) {
Ok(()) => writeln!(output, "message queued"), Ok(()) => writeln!(output, "message queued"),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
} }
@ -96,51 +88,32 @@ fn run_repl<R: BufRead, W: Write>(
}, },
"tick" => match parse_tick_args(&args) { "tick" => match parse_tick_args(&args) {
Ok((count, budget)) => { Ok((count, budget)) => {
for _ in 0..count { image.tick(count, budget);
runtime.tick(budget);
}
writeln!(output, "ran {count} scheduler tick(s)") writeln!(output, "ran {count} scheduler tick(s)")
} }
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
}, },
"inspect" => match parse_exact::<u64, 1>(&args) { "inspect" => match parse_exact::<u64, 1>(&args) {
Ok([id]) => match runtime.inspect(ActorId(id)) { Ok([id]) => match image.inspect(ActorId(id)) {
Ok(actor) => writeln!(output, "{actor:#?}"), Ok(actor) => writeln!(output, "{actor:#?}"),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
}, },
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
}, },
"events" if args.is_empty() => { "events" if args.is_empty() => {
for event in runtime.drain_events() { for event in image.events() {
writeln!(output, "{event:#?}")?; writeln!(output, "{event:#?}")?;
} }
Ok(()) Ok(())
} }
"events" => writeln!(output, "error: usage: events"), "events" => writeln!(output, "error: usage: events"),
"stop" => { "stop" => lifecycle_command(&mut output, &args, |image, id| image.stop(id), image),
lifecycle_command(&mut output, &args, |runtime, id| runtime.stop(id), runtime) "restart" => {
lifecycle_command(&mut output, &args, |image, id| image.restart(id), image)
} }
"restart" => lifecycle_command( "pause" => lifecycle_command(&mut output, &args, |image, id| image.pause(id), image),
&mut output, "resume" => lifecycle_command(&mut output, &args, |image, id| image.resume(id), image),
&args, "cancel" => lifecycle_command(&mut output, &args, |image, id| image.cancel(id), image),
|runtime, id| runtime.restart(id),
runtime,
),
"pause" => {
lifecycle_command(&mut output, &args, |runtime, id| runtime.pause(id), runtime)
}
"resume" => lifecycle_command(
&mut output,
&args,
|runtime, id| runtime.resume(id),
runtime,
),
"cancel" => lifecycle_command(
&mut output,
&args,
|runtime, id| runtime.cancel(id),
runtime,
),
"quit" | "exit" if args.is_empty() => break, "quit" | "exit" if args.is_empty() => break,
"quit" | "exit" => writeln!(output, "error: usage: quit"), "quit" | "exit" => writeln!(output, "error: usage: quit"),
_ => writeln!(output, "error: unknown command `{command}`; type `help`"), _ => writeln!(output, "error: unknown command `{command}`; type `help`"),
@ -153,11 +126,11 @@ fn run_repl<R: BufRead, W: Write>(
fn lifecycle_command<W: Write>( fn lifecycle_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
operation: impl FnOnce(&mut Runtime, ActorId) -> runa_runtime::RuntimeResult<()>, operation: impl FnOnce(&mut ImageApi, ActorId) -> ExperimentResult<()>,
runtime: &mut Runtime, image: &mut ImageApi,
) -> io::Result<()> { ) -> io::Result<()> {
match parse_exact::<u64, 1>(args) { match parse_exact::<u64, 1>(args) {
Ok([id]) => match operation(runtime, ActorId(id)) { Ok([id]) => match operation(image, ActorId(id)) {
Ok(()) => writeln!(output, "ok"), Ok(()) => writeln!(output, "ok"),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
}, },
@ -169,8 +142,7 @@ fn spawn_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
default_behavior: &str, default_behavior: &str,
code: &CodeStore, image: &mut ImageApi,
runtime: &mut Runtime,
) -> io::Result<()> { ) -> io::Result<()> {
let (state_arg, behavior_name) = match args { let (state_arg, behavior_name) = match args {
[state] => (*state, default_behavior), [state] => (*state, default_behavior),
@ -186,18 +158,18 @@ fn spawn_command<W: Write>(
Ok(state) => state, Ok(state) => state,
Err(_) => return writeln!(output, "error: invalid initial state: `{state_arg}`"), Err(_) => return writeln!(output, "error: invalid initial state: `{state_arg}`"),
}; };
let Some(behavior) = code.behavior(behavior_name) else { match image.spawn(
return writeln!(output, "error: unknown behavior `{behavior_name}`"); behavior_name,
};
match runtime.spawn(
behavior.current,
state, state,
NonZeroUsize::new(MAILBOX_CAPACITY).expect("capacity is nonzero"), NonZeroUsize::new(MAILBOX_CAPACITY).expect("capacity is nonzero"),
) { ) {
Ok(id) => writeln!( Ok(id) => writeln!(
output, output,
"spawned {id:?} with `{behavior_name}` revision {}", "spawned {id:?} with `{behavior_name}` revision {}",
behavior.revision image
.behavior(behavior_name)
.expect("spawn resolved behavior")
.revision
), ),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
} }
@ -206,8 +178,7 @@ fn spawn_command<W: Write>(
fn publish_command<W: Write>( fn publish_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
code: &mut CodeStore, image: &mut ImageApi,
runtime: &mut Runtime,
) -> io::Result<()> { ) -> io::Result<()> {
let [name, expected_revision, source_path] = args else { let [name, expected_revision, source_path] = args else {
return writeln!( return writeln!(
@ -229,14 +200,7 @@ fn publish_command<W: Write>(
return writeln!(output, "error: could not read `{source_path}`: {error}"); return writeln!(output, "error: could not read `{source_path}`: {error}");
} }
}; };
let artifact = match code.compile(&source) { let behavior = match image.publish_behavior(name, expected_revision, &source) {
Ok(artifact) => artifact,
Err(error) => return writeln!(output, "error: {error}"),
};
if let Err(error) = runtime.publish(std::sync::Arc::clone(&artifact)) {
return writeln!(output, "error: {error}");
}
let behavior = match code.publish_behavior(name, expected_revision, &source) {
Ok(behavior) => behavior, Ok(behavior) => behavior,
Err(error) => return writeln!(output, "error: {error}"), Err(error) => return writeln!(output, "error: {error}"),
}; };
@ -247,11 +211,11 @@ fn publish_command<W: Write>(
) )
} }
fn history_command<W: Write>(output: &mut W, args: &[&str], code: &CodeStore) -> io::Result<()> { fn history_command<W: Write>(output: &mut W, args: &[&str], image: &ImageApi) -> io::Result<()> {
let [name] = args else { let [name] = args else {
return writeln!(output, "error: usage: history <behavior-name>"); return writeln!(output, "error: usage: history <behavior-name>");
}; };
let Some(behavior) = code.behavior(name) else { let Some(behavior) = image.behavior(name) else {
return writeln!(output, "error: unknown behavior `{name}`"); return writeln!(output, "error: unknown behavior `{name}`");
}; };
for version in behavior.history { for version in behavior.history {
@ -272,7 +236,7 @@ fn history_command<W: Write>(output: &mut W, args: &[&str], code: &CodeStore) ->
fn rollback_command<W: Write>( fn rollback_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
code: &mut CodeStore, image: &mut ImageApi,
) -> io::Result<()> { ) -> io::Result<()> {
let [name, expected_revision, target_revision] = args else { let [name, expected_revision, target_revision] = args else {
return writeln!( return writeln!(
@ -288,7 +252,7 @@ fn rollback_command<W: Write>(
Ok(revision) => revision, Ok(revision) => revision,
Err(_) => return writeln!(output, "error: invalid target revision"), Err(_) => return writeln!(output, "error: invalid target revision"),
}; };
match code.rollback_behavior(name, expected_revision, target_revision) { match image.rollback_behavior(name, expected_revision, target_revision) {
Ok(behavior) => writeln!( Ok(behavior) => writeln!(
output, output,
"rolled back `{}` to implementation {} as revision {}", "rolled back `{}` to implementation {} as revision {}",
@ -301,8 +265,7 @@ fn rollback_command<W: Write>(
fn install_command<W: Write>( fn install_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
code: &CodeStore, image: &mut ImageApi,
runtime: &mut Runtime,
) -> io::Result<()> { ) -> io::Result<()> {
let [actor_id, name] = args else { let [actor_id, name] = args else {
return writeln!(output, "error: usage: install <actor-id> <behavior-name>"); return writeln!(output, "error: usage: install <actor-id> <behavior-name>");
@ -311,15 +274,15 @@ fn install_command<W: Write>(
Ok(id) => ActorId(id), Ok(id) => ActorId(id),
Err(_) => return writeln!(output, "error: invalid actor ID"), Err(_) => return writeln!(output, "error: invalid actor ID"),
}; };
let Some(behavior) = code.behavior(name) else { match image.install(actor_id, name) {
return writeln!(output, "error: unknown behavior `{name}`"); Ok(implementation) => {
}; let behavior = image.behavior(name).expect("install resolved behavior");
match runtime.install(actor_id, behavior.current) { writeln!(
Ok(()) => writeln!(
output, output,
"installed `{}` revision {} on actor {}", "installed `{}` revision {} on actor {} ({implementation})",
behavior.name, behavior.revision, actor_id.0 behavior.name, behavior.revision, actor_id.0
), )
}
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
} }
} }
@ -408,30 +371,24 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[test] fn image() -> ExperimentResult<ImageApi> {
fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> { let mut image = ImageApi::new();
let mut runtime = Runtime::default(); image.publish_behavior(
let mut code = CodeStore::default();
code.publish_behavior(
DEFAULT_BEHAVIOR, DEFAULT_BEHAVIOR,
None, None,
include_str!("../../../runa-std/actors/counter.rn"), include_str!("../../../runa-std/actors/counter.rn"),
)?; )?;
let implementation = code.behavior(DEFAULT_BEHAVIOR).unwrap().current; Ok(image)
runtime.publish(std::sync::Arc::clone( }
code.artifact(implementation).unwrap(),
))?; #[test]
fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> {
let mut image = image()?;
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",
); );
let mut output = Vec::new(); let mut output = Vec::new();
run_repl( run_repl(input, &mut output, &mut image, DEFAULT_BEHAVIOR)?;
input,
&mut output,
&mut code,
&mut runtime,
DEFAULT_BEHAVIOR,
)?;
let output = String::from_utf8(output)?; let output = String::from_utf8(output)?;
assert!(output.contains("ActorId(\n 1,")); assert!(output.contains("ActorId(\n 1,"));
assert!(output.contains("ActorId(\n 2,")); assert!(output.contains("ActorId(\n 2,"));
@ -442,26 +399,10 @@ 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 image = image()?;
let mut code = CodeStore::default();
code.publish_behavior(
DEFAULT_BEHAVIOR,
None,
include_str!("../../../runa-std/actors/counter.rn"),
)?;
let implementation = code.behavior(DEFAULT_BEHAVIOR).unwrap().current;
runtime.publish(std::sync::Arc::clone(
code.artifact(implementation).unwrap(),
))?;
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( run_repl(input, &mut output, &mut image, DEFAULT_BEHAVIOR)?;
input,
&mut output,
&mut code,
&mut runtime,
DEFAULT_BEHAVIOR,
)?;
let output = String::from_utf8(output)?; let output = String::from_utf8(output)?;
assert!(output.contains("invalid initial state")); assert!(output.contains("invalid initial state"));
assert!(output.contains("unknown actor")); assert!(output.contains("unknown actor"));
@ -472,17 +413,7 @@ mod tests {
#[test] #[test]
fn repl_manages_named_behavior_revisions_and_installs_them() -> ExperimentResult<()> { fn repl_manages_named_behavior_revisions_and_installs_them() -> ExperimentResult<()> {
let mut code = CodeStore::default(); let mut image = image()?;
code.publish_behavior(
DEFAULT_BEHAVIOR,
None,
include_str!("../../../runa-std/actors/counter.rn"),
)?;
let implementation = code.behavior(DEFAULT_BEHAVIOR).unwrap().current;
let mut runtime = Runtime::default();
runtime.publish(std::sync::Arc::clone(
code.artifact(implementation).unwrap(),
))?;
let source_path = concat!( let source_path = concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/../../runa-std/actors/counter.rn" "/../../runa-std/actors/counter.rn"
@ -491,13 +422,7 @@ mod tests {
"behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nspawn 0 counter\ninstall 1 counter\nsend 1 0 1\ntick 1 1\ncancel 1\npause 1\nresume 1\ninspect 1\nevents\nquit\n" "behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nspawn 0 counter\ninstall 1 counter\nsend 1 0 1\ntick 1 1\ncancel 1\npause 1\nresume 1\ninspect 1\nevents\nquit\n"
)); ));
let mut output = Vec::new(); let mut output = Vec::new();
run_repl( run_repl(input, &mut output, &mut image, DEFAULT_BEHAVIOR)?;
input,
&mut output,
&mut code,
&mut runtime,
DEFAULT_BEHAVIOR,
)?;
let output = String::from_utf8(output)?; let output = String::from_utf8(output)?;
assert!(output.contains("published `counter` revision 2")); assert!(output.contains("published `counter` revision 2"));
assert!(output.contains("revision 1:")); assert!(output.contains("revision 1:"));
@ -508,8 +433,11 @@ mod tests {
assert!(output.contains("Paused(")); assert!(output.contains("Paused("));
assert!(output.contains("Resumed(")); assert!(output.contains("Resumed("));
assert!(output.contains("InvocationCancelled(")); assert!(output.contains("InvocationCancelled("));
assert_eq!(code.behavior(DEFAULT_BEHAVIOR).unwrap().revision, 3); assert_eq!(image.behavior(DEFAULT_BEHAVIOR).unwrap().revision, 3);
assert_eq!(runtime.inspect(ActorId(1))?.implementation, implementation); assert_eq!(
image.inspect(ActorId(1))?.implementation,
image.behavior(DEFAULT_BEHAVIOR).unwrap().current
);
Ok(()) Ok(())
} }
} }

View File

@ -308,7 +308,7 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
| `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-code`, `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` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `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` |
| `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary | | `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary |
@ -316,8 +316,17 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
| `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 The current prototype has added `runa-code` for exact-source identity and
in-memory compilation caching. It deliberately does not canonicalize source in-memory compilation caching, and `runa-image-api` as the trusted,
yet; canonicalization needs a semantics-preserving, source-map-aware policy. in-process application-service boundary used by the local CLI. It deliberately
does not canonicalize source yet; canonicalization needs a semantics-preserving,
source-map-aware policy. The image API is not yet a network API and performs no
authentication or authorization; callers must be trusted by the host.
The initial `runa-image-api` provides process-local behavior source retrieval,
publication, history and rollback, actor lifecycle and messaging, snapshots,
ticks, and event retrieval. The CLI uses these application services rather
than reaching into the code store and runtime directly. Versioned wire DTOs
and transport are the next separate layer.
**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.
@ -326,7 +335,7 @@ yet; canonicalization needs a semantics-preserving, source-map-aware policy.
- `runa-actor` defines actor rules and data contracts; it does **not** spawn threads or call Rune. - `runa-actor` defines actor rules and data contracts; it does **not** spawn threads or call Rune.
- `runa-rune` executes Rune code but does **not** own actor scheduling, mailboxes, or image APIs. - `runa-rune` executes Rune code but does **not** own actor scheduling, mailboxes, or image APIs.
- `runa-runtime` coordinates both. Initially it can depend directly on `runa-rune`; introduce an execution-adapter trait only when a second implementation or test double justifies it. Place the trait at the lowest layer that actually consumes it, rather than automatically in `runa-core`. - `runa-runtime` coordinates both. Initially it can depend directly on `runa-rune`; introduce an execution-adapter trait only when a second implementation or test double justifies it. Place the trait at the lowest layer that actually consumes it, rather than automatically in `runa-core`.
- `runa-image-api` is the single application-service entry point for in-world tools and external clients. Its methods enforce permissions, lifecycle invariants, and revision checks. - `runa-image-api` is the single application-service entry point for in-world tools and external clients. It centralizes lifecycle invariants and revision checks; host-level permissions and authentication remain required at any external boundary.
- `runa-protocol` holds wire DTOs, **not** runtime service implementations. Translate DTOs at the server boundary; never serialize internal actor locks or VM references. - `runa-protocol` holds wire DTOs, **not** runtime service implementations. Translate DTOs at the server boundary; never serialize internal actor locks or VM references.
- `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`.
@ -421,6 +430,10 @@ inspection remain future work.
- Headless server, versioned protocol, actor and mailbox operations, source editing, events. - Headless server, versioned protocol, actor and mailbox operations, source editing, events.
- CLI client that can spawn, inspect, send messages, and replace behaviors. - CLI client that can spawn, inspect, send messages, and replace behaviors.
The first Phase 5 increment is an in-process trusted application API used by
the CLI. A transport-neutral versioned protocol and network server are not yet
implemented.
### Phase 6 — Graphical world ### Phase 6 — Graphical world
- Native window/rendering, input, scene manager, Rune-driven button and window. - Native window/rendering, input, scene manager, Rune-driven button and window.

View File

@ -47,7 +47,16 @@ The default per-actor slice budget is 32 instructions.
- `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-image-api` composes code publication and runtime operations behind a
trusted in-process service boundary.
- `runa` provides the local CLI client for that boundary.
The image API is process-local and intended for trusted callers. It has no
transport, authentication, or authorization layer; do not expose it directly
to untrusted clients.
Its install operation also supports the runtime's explicit host-provided
state-migration closure. `behavior_source` retrieves the exact original
published source for a selected behavior revision.
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. Implementation IDs use source content and the compile implementation. Implementation IDs use source content and the compile