diff --git a/AGENTS.md b/AGENTS.md index f8b5646..70fccbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,7 @@ Run these from the repository root: mise run format mise run lint mise run test +mise run run mise run pre-commit ``` @@ -92,7 +93,7 @@ failure. Formatting uses the repository's Tombi and rustfmt configurations. Clippy checks all workspace targets and treats warnings as errors. The workspace tests cover execution probes, actor runtime invariants, and the -interactive CLI. `cargo run -p runa` opens the local CLI. +interactive CLI. `mise run run` opens the local CLI. ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf1b45..bb099f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ All notable changes to this project will be documented in this file. - In-memory named behavior revisions with optimistic publish checks, immutable history, rollback revisions, and runtime installation at idle handler boundaries. +- CLI commands for named behavior publishing, history, rollback, and + installation, plus `mise run run` to launch the local executable. - 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. diff --git a/README.md b/README.md index 921660d..1fdaacd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ You need a Rust toolchain that supports the workspace's Rust 2024 edition. Run the current executable from the repository root: ```sh -cargo run -p runa +mise run run ``` This opens an interactive runtime session using the default counter behavior: @@ -60,7 +60,10 @@ runa> events ``` The sender commits state `1` and sends a message to actor 2, which commits -state `15`. Use `help` in the session for the complete command list. +state `15`. The CLI also supports publishing Rune source files as named, +versioned behaviors, viewing history, rolling back a behavior reference, and +installing the current version on an idle actor. Use `help` in the session for +the complete command list. See [the feasibility report](docs/rune-feasibility.md) for verified Rune capabilities and limitations, and [the runtime guide](docs/runtime.md) for the runtime contract. diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index 3f801e1..eb02b5b 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -3,27 +3,38 @@ use std::num::NonZeroUsize; use runa_actor::{ActorId, Message}; 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; +const DEFAULT_BEHAVIOR: &str = "counter"; fn main() -> ExperimentResult<()> { let mut code = CodeStore::default(); - let artifact = code.compile(include_str!("../../../runa-std/actors/counter.rn"))?; + let behavior = code.publish_behavior( + DEFAULT_BEHAVIOR, + None, + 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(); - let implementation = runtime.publish(artifact)?; + runtime.publish(std::sync::Arc::clone(artifact))?; println!("Runa — Rune Actors"); - println!("Loaded the default counter behavior as implementation {implementation}."); + println!( + "Loaded behavior `{}` revision {}.", + behavior.name, behavior.revision + ); println!("Type `help` for commands."); run_repl( io::stdin().lock(), io::stdout().lock(), + &mut code, &mut runtime, - implementation, + DEFAULT_BEHAVIOR, )?; Ok(()) } @@ -31,8 +42,9 @@ fn main() -> ExperimentResult<()> { fn run_repl( mut input: R, mut output: W, + code: &mut CodeStore, runtime: &mut Runtime, - implementation: ImplementationId, + default_behavior: &str, ) -> io::Result<()> { let mut line = String::new(); loop { @@ -50,17 +62,22 @@ fn run_repl( let args: Vec<_> = words.collect(); let result = match command { "help" => print_help(&mut output), - "spawn" => match parse_exact::(&args) { - Ok([state]) => match runtime.spawn( - implementation, - state, - NonZeroUsize::new(MAILBOX_CAPACITY).expect("capacity is nonzero"), - ) { - Ok(id) => writeln!(output, "spawned {id:?}"), - Err(error) => writeln!(output, "error: {error}"), - }, - Err(error) => writeln!(output, "error: {error}"), - }, + "spawn" => spawn_command(&mut output, &args, default_behavior, code, runtime), + "behaviors" if args.is_empty() => { + for behavior in code.behaviors() { + writeln!( + output, + "{} revision {} ({})", + behavior.name, behavior.revision, behavior.current + )?; + } + Ok(()) + } + "behaviors" => writeln!(output, "error: usage: behaviors"), + "publish" => publish_command(&mut output, &args, code, runtime), + "history" => history_command(&mut output, &args, code), + "rollback" => rollback_command(&mut output, &args, code), + "install" => install_command(&mut output, &args, code, runtime), "actors" if args.is_empty() => { for actor in runtime.actors() { writeln!(output, "{actor:#?}")?; @@ -133,6 +150,165 @@ fn lifecycle_command( } } +fn spawn_command( + output: &mut W, + args: &[&str], + default_behavior: &str, + code: &CodeStore, + runtime: &mut Runtime, +) -> io::Result<()> { + let (state_arg, behavior_name) = match args { + [state] => (*state, default_behavior), + [state, behavior_name] => (*state, *behavior_name), + _ => { + return writeln!( + output, + "error: usage: spawn [behavior-name]" + ); + } + }; + let state = match state_arg.parse() { + Ok(state) => state, + Err(_) => return writeln!(output, "error: invalid initial state: `{state_arg}`"), + }; + let Some(behavior) = code.behavior(behavior_name) else { + return writeln!(output, "error: unknown behavior `{behavior_name}`"); + }; + match runtime.spawn( + behavior.current, + state, + NonZeroUsize::new(MAILBOX_CAPACITY).expect("capacity is nonzero"), + ) { + Ok(id) => writeln!( + output, + "spawned {id:?} with `{behavior_name}` revision {}", + behavior.revision + ), + Err(error) => writeln!(output, "error: {error}"), + } +} + +fn publish_command( + output: &mut W, + args: &[&str], + code: &mut CodeStore, + runtime: &mut Runtime, +) -> io::Result<()> { + let [name, expected_revision, source_path] = args else { + return writeln!( + output, + "error: usage: publish " + ); + }; + let expected_revision = if *expected_revision == "new" { + None + } else { + match expected_revision.parse::() { + Ok(revision) => Some(revision), + Err(_) => return writeln!(output, "error: expected `new` or a numeric revision"), + } + }; + let source = match std::fs::read_to_string(source_path) { + Ok(source) => source, + Err(error) => { + return writeln!(output, "error: could not read `{source_path}`: {error}"); + } + }; + let artifact = match code.compile(&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, + Err(error) => return writeln!(output, "error: {error}"), + }; + writeln!( + output, + "published `{}` revision {} ({})", + behavior.name, behavior.revision, behavior.current + ) +} + +fn history_command(output: &mut W, args: &[&str], code: &CodeStore) -> io::Result<()> { + let [name] = args else { + return writeln!(output, "error: usage: history "); + }; + let Some(behavior) = code.behavior(name) else { + return writeln!(output, "error: unknown behavior `{name}`"); + }; + for version in behavior.history { + let current = if version.revision == behavior.revision { + " (current)" + } else { + "" + }; + writeln!( + output, + "revision {}: {}{}", + version.revision, version.implementation_id, current + )?; + } + Ok(()) +} + +fn rollback_command( + output: &mut W, + args: &[&str], + code: &mut CodeStore, +) -> io::Result<()> { + let [name, expected_revision, target_revision] = args else { + return writeln!( + output, + "error: usage: rollback " + ); + }; + let expected_revision = match expected_revision.parse::() { + Ok(revision) => revision, + Err(_) => return writeln!(output, "error: invalid expected revision"), + }; + let target_revision = match target_revision.parse::() { + Ok(revision) => revision, + Err(_) => return writeln!(output, "error: invalid target revision"), + }; + match code.rollback_behavior(name, expected_revision, target_revision) { + Ok(behavior) => writeln!( + output, + "rolled back `{}` to implementation {} as revision {}", + behavior.name, behavior.current, behavior.revision + ), + Err(error) => writeln!(output, "error: {error}"), + } +} + +fn install_command( + output: &mut W, + args: &[&str], + code: &CodeStore, + runtime: &mut Runtime, +) -> io::Result<()> { + let [actor_id, name] = args else { + return writeln!(output, "error: usage: install "); + }; + let actor_id = match actor_id.parse::() { + Ok(id) => ActorId(id), + Err(_) => return writeln!(output, "error: invalid actor ID"), + }; + let Some(behavior) = code.behavior(name) else { + return writeln!(output, "error: unknown behavior `{name}`"); + }; + match runtime.install(actor_id, behavior.current) { + Ok(()) => writeln!( + output, + "installed `{}` revision {} on actor {}", + behavior.name, behavior.revision, actor_id.0 + ), + Err(error) => writeln!(output, "error: {error}"), + } +} + fn parse_exact(args: &[&str]) -> Result<[T; N], String> { if args.len() != N { return Err(format!("expected {N} argument(s), got {}", args.len())); @@ -192,7 +368,12 @@ fn print_help(output: &mut W) -> io::Result<()> { writeln!( output, "Commands:\n\ - spawn \n\ + spawn [behavior-name]\n\ + behaviors\n\ + publish \n\ + history \n\ + rollback \n\ + install \n\ send \n\ tick [instruction-budget]\n\ inspect \n\ @@ -213,13 +394,26 @@ mod tests { fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> { let mut runtime = Runtime::default(); let mut code = CodeStore::default(); - let implementation = - runtime.publish(code.compile(include_str!("../../../runa-std/actors/counter.rn"))?)?; + 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 0\nspawn 10\nsend 1 1 2\ntick 100\ninspect 1\ninspect 2\nevents\nquit\n", ); let mut output = Vec::new(); - run_repl(input, &mut output, &mut runtime, implementation)?; + run_repl( + input, + &mut output, + &mut code, + &mut runtime, + DEFAULT_BEHAVIOR, + )?; let output = String::from_utf8(output)?; assert!(output.contains("ActorId(\n 1,")); assert!(output.contains("ActorId(\n 2,")); @@ -232,16 +426,69 @@ mod tests { fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> { let mut runtime = Runtime::default(); let mut code = CodeStore::default(); - let implementation = - runtime.publish(code.compile(include_str!("../../../runa-std/actors/counter.rn"))?)?; + 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 mut output = Vec::new(); - run_repl(input, &mut output, &mut runtime, implementation)?; + run_repl( + input, + &mut output, + &mut code, + &mut runtime, + DEFAULT_BEHAVIOR, + )?; let output = String::from_utf8(output)?; - assert!(output.contains("invalid argument 1")); + assert!(output.contains("invalid initial state")); assert!(output.contains("unknown actor")); assert!(output.contains("tick budget must be positive")); assert!(output.contains("unknown command")); Ok(()) } + + #[test] + fn repl_manages_named_behavior_revisions_and_installs_them() -> ExperimentResult<()> { + 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; + let mut runtime = Runtime::default(); + runtime.publish(std::sync::Arc::clone( + code.artifact(implementation).unwrap(), + ))?; + let source_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../runa-std/actors/counter.rn" + ); + let input = io::Cursor::new(format!( + "behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nspawn 0 counter\ninstall 1 counter\ninspect 1\nquit\n" + )); + let mut output = Vec::new(); + run_repl( + input, + &mut output, + &mut code, + &mut runtime, + DEFAULT_BEHAVIOR, + )?; + let output = String::from_utf8(output)?; + assert!(output.contains("published `counter` revision 2")); + assert!(output.contains("revision 1:")); + assert!(output.contains("revision 2:")); + assert!(output.contains("rolled back `counter`")); + assert!(output.contains("spawned ActorId(1) with `counter` revision 3")); + assert!(output.contains("installed `counter` revision 3 on actor 1")); + assert_eq!(code.behavior(DEFAULT_BEHAVIOR).unwrap().revision, 3); + assert_eq!(runtime.inspect(ActorId(1))?.implementation, implementation); + Ok(()) + } } diff --git a/docs/runtime.md b/docs/runtime.md index b7fa8bd..adfaf1c 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -3,14 +3,20 @@ Start the interactive, in-memory runtime session with: ```sh -cargo run -p runa +mise run run ``` The executable loads [counter.rn](../runa-std/actors/counter.rn) as the default behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`, -`events`, `stop`, and `restart`; enter `help` to see usage. All commands operate -on the same process-local runtime. This CLI is for local development, not a -versioned external API. +`events`, `stop`, `restart`, `behaviors`, `publish`, `history`, `rollback`, and +`install`; enter `help` to see usage. `publish` reads a Rune source file and +takes `new` for a first revision or the expected current revision for an +update. `rollback` requires the expected current revision and a target +historical revision. `install` selects a named behavior's current +implementation and fails if the actor is not at an idle handler boundary. +`spawn` accepts an optional behavior name and otherwise uses `counter`. All +commands operate on the same process-local runtime and code store. This CLI is +for local development, not a versioned external API. Example: @@ -62,8 +68,7 @@ when it is idle, has no active invocation, and has no reserved input. Queued messages may remain; they will use the newly installed implementation. Active or awaiting handlers keep their original implementation, and installation attempts during execution fail explicitly so the caller can retry at the next -handler boundary. This is an in-memory library capability; the interactive CLI -does not yet expose behavior publishing, history, rollback, or installation. +handler boundary. The source identity hashes the exact original UTF-8 bytes with the versioned `runa-source:v1` domain. Whitespace/comment canonicalization is intentionally @@ -152,9 +157,10 @@ targets, aggregate capacity, and successful message effects. This is not yet a full image: no persistent behavior registry or mailboxes, transactional outbox, supervisors, API server, GUI, or automatic image -bootstrap. The initial implementation installation API is process-local and -requires an idle handler boundary; it does not upgrade active execution. -The example default script is bundled through `include_str!`. +bootstrap. The behavior registry and implementation installation are +process-local; installation requires an idle handler boundary and does not +upgrade active execution. The example default script is bundled through +`include_str!`. Messages are not accepted by stopped/failed actors. Native functions must be nonblocking. Dropping a future proves cleanup only diff --git a/mise.toml b/mise.toml index 8a3ab38..7070835 100644 --- a/mise.toml +++ b/mise.toml @@ -9,6 +9,10 @@ run = ["tombi format --offline", "cargo fmt --all"] description = "Run workspace tests" run = "cargo test --workspace" +[tasks.run] +description = "Run the local Runa CLI" +run = "cargo run -p runa" + [tasks.lint] description = "Lint all workspace targets with Clippy" run = "cargo clippy --workspace --all-targets -- -D warnings"