diff --git a/AGENTS.md b/AGENTS.md index 4b939cf..d2ca085 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ proposed capabilities as plans, not existing behavior. - `crates/runa-rune/`: Phase 0 Rune compilation and execution experiments. - `crates/runa-actor/`: actor value and lifecycle contracts. - `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. +- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI. - `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario. - `docs/decisions/`: architectural decisions. - `runa-std/`: default Rune scripts for an image. @@ -88,7 +89,8 @@ mise run pre-commit 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 and actor runtime invariants. +The workspace tests cover execution probes, actor runtime invariants, and the +interactive CLI. `cargo run -p runa` opens the local CLI. ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index cbdd4c3..7a9c046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. message effects, inspection, lifecycle events, stop, and restart. - Runnable two-actor counter demonstration and tests for runaway fairness, pending-message isolation, failures, backpressure, and all-or-nothing effects. +- Interactive local CLI for spawning actors, sending messages, advancing ticks, + inspecting actors/events, and stopping/restarting actors. - 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 c730372..df48965 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,26 @@ Run the current executable from the repository root: cargo run -p runa ``` -It prints the feasibility results and local compilation/VM creation measurements. -See [the feasibility report](docs/rune-feasibility.md) for verified capabilities, -limitations, and [the runtime guide](docs/runtime.md) for the two-actor demo. +This opens an interactive runtime session using the default counter behavior: + +```text +runa> spawn 0 +spawned ActorId(1) +runa> spawn 10 +spawned ActorId(2) +runa> send 1 1 2 +message queued +runa> tick 100 +runa> inspect 1 +runa> inspect 2 +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. +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. ## Development diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index 000408f..b8952c6 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -10,6 +10,7 @@ use runa_actor::{ActorId, ActorSnapshot, Event, ImplementationId, MailboxId, Mes use runa_rune::{CompiledProgram, ExperimentResult, Invocation, Slice}; type WireTransition = (i64, Vec<(u64, i64, i64)>); +pub type RuntimeResult = ExperimentResult; struct WakeFlag(AtomicBool); impl Wake for WakeFlag { @@ -108,6 +109,17 @@ impl Runtime { Ok(snapshot) } + pub fn actors(&self) -> Vec { + self.actors + .values() + .map(|actor| { + let mut snapshot = actor.snapshot.clone(); + snapshot.queued = actor.mailbox.iter().copied().collect(); + snapshot + }) + .collect() + } + pub fn stop(&mut self, id: ActorId) -> ExperimentResult<()> { let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; actor.active = None; diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index 8cc2743..0db6632 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -1,37 +1,244 @@ +use std::io::{self, BufRead, Write}; use std::num::NonZeroUsize; -use runa_actor::Message; -use runa_rune::CompiledProgram; +use runa_actor::{ActorId, Message}; +use runa_rune::{CompiledProgram, ExperimentResult}; use runa_runtime::Runtime; -fn main() -> runa_rune::ExperimentResult<()> { - let report = runa_rune::run_feasibility()?; - println!("Runa Phase 0 — Rune 0.14.2 feasibility"); - println!("{report:#?}"); +const MAILBOX_CAPACITY: usize = 64; +const DEFAULT_TICK_BUDGET: usize = 32; + +fn main() -> ExperimentResult<()> { + let program = CompiledProgram::compile(include_str!("../../../runa-std/actors/counter.rn"))?; let mut runtime = Runtime::default(); - let implementation = runtime.publish(CompiledProgram::compile(include_str!( - "../../../runa-std/actors/counter.rn" - ))?)?; - let capacity = NonZeroUsize::new(8).expect("nonzero mailbox capacity"); - let sender = runtime.spawn(implementation, 0, capacity)?; - let receiver = runtime.spawn(implementation, 10, capacity)?; - runtime.send( - sender, - Message { - kind: 1, - value: i64::try_from(receiver.0)?, - }, + let implementation = runtime.publish(program)?; + + println!("Runa — Rune Actors"); + println!("Loaded the default counter behavior as implementation {implementation:?}."); + println!("Type `help` for commands."); + run_repl( + io::stdin().lock(), + io::stdout().lock(), + &mut runtime, + implementation, )?; - for _ in 0..100 { - runtime.tick(NonZeroUsize::new(16).expect("nonzero slice budget")); - } - let sender = runtime.inspect(sender)?; - let receiver = runtime.inspect(receiver)?; - if sender.state != 1 || receiver.state != 15 { - return Err("two-actor demonstration did not complete as expected".into()); - } - println!("Runa Phase 1 — two-actor message exchange"); - println!("Sender: {sender:#?}"); - println!("Receiver: {receiver:#?}"); Ok(()) } + +fn run_repl( + mut input: R, + mut output: W, + runtime: &mut Runtime, + implementation: runa_actor::ImplementationId, +) -> io::Result<()> { + let mut line = String::new(); + loop { + write!(output, "runa> ")?; + output.flush()?; + line.clear(); + if input.read_line(&mut line)? == 0 { + writeln!(output)?; + break; + } + let mut words = line.split_whitespace(); + let Some(command) = words.next() else { + continue; + }; + 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}"), + }, + "actors" if args.is_empty() => { + for actor in runtime.actors() { + writeln!(output, "{actor:#?}")?; + } + Ok(()) + } + "actors" => writeln!(output, "error: usage: actors"), + "send" => match parse_send_args(&args) { + Ok((actor, kind, value)) => { + match runtime.send(ActorId(actor), Message { kind, value }) { + Ok(()) => writeln!(output, "message queued"), + Err(error) => writeln!(output, "error: {error}"), + } + } + Err(error) => writeln!(output, "error: {error}"), + }, + "tick" => match parse_tick_args(&args) { + Ok((count, budget)) => { + for _ in 0..count { + runtime.tick(budget); + } + writeln!(output, "ran {count} scheduler tick(s)") + } + Err(error) => writeln!(output, "error: {error}"), + }, + "inspect" => match parse_exact::(&args) { + Ok([id]) => match runtime.inspect(ActorId(id)) { + Ok(actor) => writeln!(output, "{actor:#?}"), + Err(error) => writeln!(output, "error: {error}"), + }, + Err(error) => writeln!(output, "error: {error}"), + }, + "events" if args.is_empty() => { + for event in runtime.drain_events() { + writeln!(output, "{event:#?}")?; + } + Ok(()) + } + "events" => writeln!(output, "error: usage: events"), + "stop" => { + lifecycle_command(&mut output, &args, |runtime, id| runtime.stop(id), runtime) + } + "restart" => lifecycle_command( + &mut output, + &args, + |runtime, id| runtime.restart(id), + runtime, + ), + "quit" | "exit" if args.is_empty() => break, + "quit" | "exit" => writeln!(output, "error: usage: quit"), + _ => writeln!(output, "error: unknown command `{command}`; type `help`"), + }; + result?; + } + Ok(()) +} + +fn lifecycle_command( + output: &mut W, + args: &[&str], + operation: impl FnOnce(&mut Runtime, ActorId) -> runa_runtime::RuntimeResult<()>, + runtime: &mut Runtime, +) -> io::Result<()> { + match parse_exact::(args) { + Ok([id]) => match operation(runtime, ActorId(id)) { + Ok(()) => writeln!(output, "ok"), + Err(error) => writeln!(output, "error: {error}"), + }, + 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())); + } + let parsed = args + .iter() + .enumerate() + .map(|(index, arg)| { + arg.parse::() + .map_err(|_| format!("invalid argument {}: `{arg}`", index + 1)) + }) + .collect::, _>>()?; + parsed + .try_into() + .map_err(|_| "invalid argument count".to_owned()) +} + +fn parse_tick_args(args: &[&str]) -> Result<(usize, NonZeroUsize), String> { + match args { + [count] => Ok(( + count + .parse() + .map_err(|_| format!("invalid tick count: `{count}`"))?, + NonZeroUsize::new(DEFAULT_TICK_BUDGET).expect("budget is nonzero"), + )), + [count, budget] => { + let count = count + .parse() + .map_err(|_| format!("invalid tick count: `{count}`"))?; + let budget = budget + .parse() + .map_err(|_| format!("invalid tick budget: `{budget}`"))?; + let budget = NonZeroUsize::new(budget).ok_or("tick budget must be positive")?; + Ok((count, budget)) + } + _ => Err("usage: tick [budget]".to_owned()), + } +} + +fn parse_send_args(args: &[&str]) -> Result<(u64, i64, i64), String> { + if args.len() != 3 { + return Err(format!("expected 3 arguments, got {}", args.len())); + } + let actor = args[0] + .parse() + .map_err(|_| format!("invalid actor ID: `{}`", args[0]))?; + let kind = args[1] + .parse() + .map_err(|_| format!("invalid message kind: `{}`", args[1]))?; + let value = args[2] + .parse() + .map_err(|_| format!("invalid message value: `{}`", args[2]))?; + Ok((actor, kind, value)) +} + +fn print_help(output: &mut W) -> io::Result<()> { + writeln!( + output, + "Commands:\n\ + spawn \n\ + send \n\ + tick [instruction-budget]\n\ + inspect \n\ + actors\n\ + events\n\ + stop \n\ + restart \n\ + help\n\ + quit" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let implementation = runtime.publish(CompiledProgram::compile(include_str!( + "../../../runa-std/actors/counter.rn" + ))?)?; + let 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)?; + let output = String::from_utf8(output)?; + assert!(output.contains("ActorId(\n 1,")); + assert!(output.contains("ActorId(\n 2,")); + assert!(output.contains("state: 15")); + assert!(output.contains("Committed")); + Ok(()) + } + + #[test] + fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let implementation = runtime.publish(CompiledProgram::compile(include_str!( + "../../../runa-std/actors/counter.rn" + ))?)?; + let 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)?; + let output = String::from_utf8(output)?; + assert!(output.contains("invalid argument 1")); + assert!(output.contains("unknown actor")); + assert!(output.contains("tick budget must be positive")); + assert!(output.contains("unknown command")); + Ok(()) + } +} diff --git a/docs/runtime.md b/docs/runtime.md index 01ff4dd..2d7e992 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -1,15 +1,34 @@ # Phase 1 actor prototype -Run the feasibility probes and two-actor demonstration with: +Start the interactive, in-memory runtime session with: ```sh cargo run -p runa -mise run pre-commit ``` -The executable loads [counter.rn](../runa-std/actors/counter.rn), spawns two -actors sharing one implementation, and sends a structured message. The sender -commits state 1 and sends a message that moves the receiver from state 10 to 15. +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. + +Example: + +```text +runa> spawn 0 +spawned ActorId(1) +runa> spawn 10 +spawned ActorId(2) +runa> send 1 1 2 +message queued +runa> tick 100 +runa> inspect 1 +runa> inspect 2 +``` + +The sender commits state 1 and sends a structured message; the receiver commits +state 15. `tick [instruction-budget]` explicitly advances the scheduler. +The default per-actor slice budget is 32 instructions. ## Current contracts