diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a547d..07d39cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ All notable changes to this project will be documented in this file. ### Changed +- Interactive CLI commands now use the versioned protocol dispatcher instead + of calling the image API directly. - 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 diff --git a/Cargo.lock b/Cargo.lock index d777d87..6ee1d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,12 +392,8 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" name = "runa" version = "0.0.0" dependencies = [ - "runa-actor", - "runa-code", - "runa-core", - "runa-image-api", - "runa-rune", - "runa-runtime", + "runa-protocol", + "runa-server", ] [[package]] diff --git a/README.md b/README.md index ab888d1..571fd8c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ registry, and the CLI exposes publishing, history, rollback, safe-boundary installation, pause/resume, and cancellation through a trusted in-process image API. A versioned transport-neutral protocol contract is now defined, but no network listener or authentication is implemented. The dispatcher is available -through a local JSON-lines stdio process, with a bounded request size. +through a local JSON-lines stdio process, with a bounded request size. The +interactive CLI now uses the versioned protocol and dispatcher rather than +calling the image API directly. The prototype uses worker-local invocation stacks and cooperative instruction slices, and actor snapshots report basic active-execution progress. Native diff --git a/crates/runa/Cargo.toml b/crates/runa/Cargo.toml index a8e2b77..efffb7e 100644 --- a/crates/runa/Cargo.toml +++ b/crates/runa/Cargo.toml @@ -4,9 +4,5 @@ version = "0.0.0" edition = "2024" [dependencies] -runa-actor = { path = "../runa-actor" } -runa-code = { path = "../runa-code" } -runa-core = { path = "../runa-core" } -runa-image-api = { path = "../runa-image-api" } -runa-rune = { path = "../runa-rune" } -runa-runtime = { path = "../runa-runtime" } +runa-protocol = { path = "../runa-protocol" } +runa-server = { path = "../runa-server" } diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index 538f7f8..ac10122 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -1,21 +1,26 @@ use std::io::{self, BufRead, Write}; use std::num::NonZeroUsize; -use runa_actor::{ActorId, Message}; -use runa_image_api::ImageApi; -use runa_rune::ExperimentResult; +use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response}; +use runa_server::Server; const MAILBOX_CAPACITY: usize = 64; const DEFAULT_TICK_BUDGET: usize = 32; const DEFAULT_BEHAVIOR: &str = "counter"; -fn main() -> ExperimentResult<()> { - let mut image = ImageApi::new(); - let behavior = image.publish_behavior( - DEFAULT_BEHAVIOR, - None, - include_str!("../../../runa-std/actors/counter.rn"), - )?; +fn main() -> Result<(), Box> { + let mut server = Server::new(); + let behavior = match dispatch( + &mut server, + Command::PublishBehavior { + name: DEFAULT_BEHAVIOR.to_owned(), + expected_revision: None, + source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), + }, + )? { + Reply::Behavior(behavior) => behavior, + other => return Err(format!("unexpected publish response: {other:?}").into()), + }; println!("Runa — Rune Actors"); println!( @@ -26,7 +31,7 @@ fn main() -> ExperimentResult<()> { run_repl( io::stdin().lock(), io::stdout().lock(), - &mut image, + &mut server, DEFAULT_BEHAVIOR, )?; Ok(()) @@ -35,7 +40,7 @@ fn main() -> ExperimentResult<()> { fn run_repl( mut input: R, mut output: W, - image: &mut ImageApi, + server: &mut Server, default_behavior: &str, ) -> io::Result<()> { let mut line = String::new(); @@ -54,33 +59,48 @@ fn run_repl( let args: Vec<_> = words.collect(); let result = match command { "help" => print_help(&mut output), - "spawn" => spawn_command(&mut output, &args, default_behavior, image), - "behaviors" if args.is_empty() => { - for behavior in image.behaviors() { - writeln!( - output, - "{} revision {} ({})", - behavior.name, behavior.revision, behavior.current - )?; + "spawn" => spawn_command(&mut output, &args, default_behavior, server), + "behaviors" if args.is_empty() => match dispatch(server, Command::GetBehaviors) { + Ok(Reply::Behaviors(behaviors)) => { + for behavior in behaviors { + writeln!( + output, + "{} revision {} ({})", + behavior.name, behavior.revision, behavior.current_implementation_id + )?; + } + Ok(()) } - Ok(()) - } + Ok(other) => unexpected_reply(&mut output, other), + Err(error) => writeln!(output, "error: {error}"), + }, "behaviors" => writeln!(output, "error: usage: behaviors"), - "publish" => publish_command(&mut output, &args, image), - "history" => history_command(&mut output, &args, image), - "rollback" => rollback_command(&mut output, &args, image), - "install" => install_command(&mut output, &args, image), - "actors" if args.is_empty() => { - for actor in image.actors() { - writeln!(output, "{actor:#?}")?; + "publish" => publish_command(&mut output, &args, server), + "history" => history_command(&mut output, &args, server), + "rollback" => rollback_command(&mut output, &args, server), + "install" => install_command(&mut output, &args, server), + "actors" if args.is_empty() => match dispatch(server, Command::ListActors) { + Ok(Reply::Actors(actors)) => { + for actor in actors { + writeln!(output, "{actor:#?}")?; + } + Ok(()) } - Ok(()) - } + Ok(other) => unexpected_reply(&mut output, other), + Err(error) => writeln!(output, "error: {error}"), + }, "actors" => writeln!(output, "error: usage: actors"), "send" => match parse_send_args(&args) { Ok((actor, kind, value)) => { - match image.send(ActorId(actor), Message { kind, value }) { - Ok(()) => writeln!(output, "message queued"), + match dispatch( + server, + Command::Send { + actor_id: actor, + message: MessageDto { kind, value }, + }, + ) { + Ok(Reply::Acknowledged) => writeln!(output, "message queued"), + Ok(other) => unexpected_reply(&mut output, other), Err(error) => writeln!(output, "error: {error}"), } } @@ -88,32 +108,56 @@ fn run_repl( }, "tick" => match parse_tick_args(&args) { Ok((count, budget)) => { - image.tick(count, budget); - writeln!(output, "ran {count} scheduler tick(s)") + match dispatch( + server, + Command::Tick { + ticks: count, + instruction_budget: budget.get(), + }, + ) { + Ok(Reply::TickCompleted { ticks }) => { + writeln!(output, "ran {ticks} scheduler tick(s)") + } + Ok(other) => unexpected_reply(&mut output, other), + Err(error) => writeln!(output, "error: {error}"), + } } Err(error) => writeln!(output, "error: {error}"), }, "inspect" => match parse_exact::(&args) { - Ok([id]) => match image.inspect(ActorId(id)) { - Ok(actor) => writeln!(output, "{actor:#?}"), + Ok([id]) => match dispatch(server, Command::InspectActor { actor_id: id }) { + Ok(Reply::Actor(actor)) => writeln!(output, "{actor:#?}"), + Ok(other) => unexpected_reply(&mut output, other), Err(error) => writeln!(output, "error: {error}"), }, Err(error) => writeln!(output, "error: {error}"), }, - "events" if args.is_empty() => { - for event in image.events() { - writeln!(output, "{event:#?}")?; + "events" if args.is_empty() => match dispatch(server, Command::GetEvents) { + Ok(Reply::Events(events)) => { + for event in events { + writeln!(output, "{event:#?}")?; + } + Ok(()) } - Ok(()) - } + Ok(other) => unexpected_reply(&mut output, other), + Err(error) => writeln!(output, "error: {error}"), + }, "events" => writeln!(output, "error: usage: events"), - "stop" => lifecycle_command(&mut output, &args, |image, id| image.stop(id), image), - "restart" => { - lifecycle_command(&mut output, &args, |image, id| image.restart(id), image) - } - "pause" => lifecycle_command(&mut output, &args, |image, id| image.pause(id), image), - "resume" => lifecycle_command(&mut output, &args, |image, id| image.resume(id), image), - "cancel" => lifecycle_command(&mut output, &args, |image, id| image.cancel(id), image), + "stop" => lifecycle_command(&mut output, &args, server, |actor_id| { + Command::StopActor { actor_id } + }), + "restart" => lifecycle_command(&mut output, &args, server, |actor_id| { + Command::RestartActor { actor_id } + }), + "pause" => lifecycle_command(&mut output, &args, server, |actor_id| { + Command::PauseActor { actor_id } + }), + "resume" => lifecycle_command(&mut output, &args, server, |actor_id| { + Command::ResumeActor { actor_id } + }), + "cancel" => lifecycle_command(&mut output, &args, server, |actor_id| { + Command::CancelActor { actor_id } + }), "quit" | "exit" if args.is_empty() => break, "quit" | "exit" => writeln!(output, "error: usage: quit"), _ => writeln!(output, "error: unknown command `{command}`; type `help`"), @@ -126,12 +170,13 @@ fn run_repl( fn lifecycle_command( output: &mut W, args: &[&str], - operation: impl FnOnce(&mut ImageApi, ActorId) -> ExperimentResult<()>, - image: &mut ImageApi, + server: &mut Server, + operation: impl FnOnce(u64) -> Command, ) -> io::Result<()> { match parse_exact::(args) { - Ok([id]) => match operation(image, ActorId(id)) { - Ok(()) => writeln!(output, "ok"), + Ok([id]) => match dispatch(server, operation(id)) { + Ok(Reply::Acknowledged) => writeln!(output, "ok"), + Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), }, Err(error) => writeln!(output, "error: {error}"), @@ -142,7 +187,7 @@ fn spawn_command( output: &mut W, args: &[&str], default_behavior: &str, - image: &mut ImageApi, + server: &mut Server, ) -> io::Result<()> { let (state_arg, behavior_name) = match args { [state] => (*state, default_behavior), @@ -158,28 +203,37 @@ fn spawn_command( Ok(state) => state, Err(_) => return writeln!(output, "error: invalid initial state: `{state_arg}`"), }; - match image.spawn( - behavior_name, - state, - NonZeroUsize::new(MAILBOX_CAPACITY).expect("capacity is nonzero"), - ) { - Ok(id) => writeln!( - output, - "spawned {id:?} with `{behavior_name}` revision {}", - image - .behavior(behavior_name) - .expect("spawn resolved behavior") - .revision - ), + let response = dispatch( + server, + Command::Spawn { + behavior_name: behavior_name.to_owned(), + state, + mailbox_capacity: MAILBOX_CAPACITY, + }, + ); + match response { + Ok(Reply::ActorSpawned { actor_id }) => { + let revision = match dispatch( + server, + Command::GetBehavior { + name: behavior_name.to_owned(), + }, + ) { + Ok(Reply::Behavior(behavior)) => behavior.revision, + Ok(other) => return unexpected_reply(output, other), + Err(error) => return writeln!(output, "error: {error}"), + }; + writeln!( + output, + "spawned ActorId({actor_id}) with `{behavior_name}` revision {revision}" + ) + } + Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), } } -fn publish_command( - output: &mut W, - args: &[&str], - image: &mut ImageApi, -) -> io::Result<()> { +fn publish_command(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { let [name, expected_revision, source_path] = args else { return writeln!( output, @@ -200,23 +254,38 @@ fn publish_command( return writeln!(output, "error: could not read `{source_path}`: {error}"); } }; - let behavior = match image.publish_behavior(name, expected_revision, &source) { - Ok(behavior) => behavior, + let behavior = match dispatch( + server, + Command::PublishBehavior { + name: (*name).to_owned(), + expected_revision, + source, + }, + ) { + Ok(Reply::Behavior(behavior)) => behavior, + Ok(other) => return unexpected_reply(output, other), Err(error) => return writeln!(output, "error: {error}"), }; writeln!( output, "published `{}` revision {} ({})", - behavior.name, behavior.revision, behavior.current + behavior.name, behavior.revision, behavior.current_implementation_id ) } -fn history_command(output: &mut W, args: &[&str], image: &ImageApi) -> io::Result<()> { +fn history_command(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { let [name] = args else { return writeln!(output, "error: usage: history "); }; - let Some(behavior) = image.behavior(name) else { - return writeln!(output, "error: unknown behavior `{name}`"); + let behavior = match dispatch( + server, + Command::GetBehavior { + name: (*name).to_owned(), + }, + ) { + Ok(Reply::Behavior(behavior)) => behavior, + Ok(other) => return unexpected_reply(output, other), + Err(error) => return writeln!(output, "error: {error}"), }; for version in behavior.history { let current = if version.revision == behavior.revision { @@ -236,7 +305,7 @@ fn history_command(output: &mut W, args: &[&str], image: &ImageApi) -> fn rollback_command( output: &mut W, args: &[&str], - image: &mut ImageApi, + server: &mut Server, ) -> io::Result<()> { let [name, expected_revision, target_revision] = args else { return writeln!( @@ -252,41 +321,74 @@ fn rollback_command( Ok(revision) => revision, Err(_) => return writeln!(output, "error: invalid target revision"), }; - match image.rollback_behavior(name, expected_revision, target_revision) { - Ok(behavior) => writeln!( + match dispatch( + server, + Command::RollbackBehavior { + name: (*name).to_owned(), + expected_revision, + target_revision, + }, + ) { + Ok(Reply::Behavior(behavior)) => writeln!( output, "rolled back `{}` to implementation {} as revision {}", - behavior.name, behavior.current, behavior.revision + behavior.name, behavior.current_implementation_id, behavior.revision ), + Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), } } -fn install_command( - output: &mut W, - args: &[&str], - image: &mut ImageApi, -) -> io::Result<()> { +fn install_command(output: &mut W, args: &[&str], server: &mut Server) -> 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), + Ok(id) => id, Err(_) => return writeln!(output, "error: invalid actor ID"), }; - match image.install(actor_id, name) { - Ok(implementation) => { - let behavior = image.behavior(name).expect("install resolved behavior"); + let implementation = match dispatch( + server, + Command::InstallBehavior { + actor_id, + behavior_name: (*name).to_owned(), + }, + ) { + Ok(Reply::BehaviorInstalled { implementation_id }) => implementation_id, + Ok(other) => return unexpected_reply(output, other), + Err(error) => return writeln!(output, "error: {error}"), + }; + match dispatch( + server, + Command::GetBehavior { + name: (*name).to_owned(), + }, + ) { + Ok(Reply::Behavior(behavior)) => { writeln!( output, "installed `{}` revision {} on actor {} ({implementation})", - behavior.name, behavior.revision, actor_id.0 + behavior.name, behavior.revision, actor_id ) } + Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), } } +fn dispatch(server: &mut Server, command: Command) -> Result { + let response: Response = server.dispatch(Request { + version: PROTOCOL_VERSION, + request_id: "runa-cli".to_owned(), + command, + }); + response.result.map_err(|error| error.message) +} + +fn unexpected_reply(output: &mut W, reply: Reply) -> io::Result<()> { + writeln!(output, "error: unexpected protocol response: {reply:?}") +} + fn parse_exact(args: &[&str]) -> Result<[T; N], String> { if args.len() != N { return Err(format!("expected {N} argument(s), got {}", args.len())); @@ -371,38 +473,43 @@ fn print_help(output: &mut W) -> io::Result<()> { mod tests { use super::*; - fn image() -> ExperimentResult { - let mut image = ImageApi::new(); - image.publish_behavior( - DEFAULT_BEHAVIOR, - None, - include_str!("../../../runa-std/actors/counter.rn"), - )?; - Ok(image) + fn server() -> Server { + let mut server = Server::new(); + dispatch( + &mut server, + Command::PublishBehavior { + name: DEFAULT_BEHAVIOR.to_owned(), + expected_revision: None, + source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), + }, + ) + .expect("default behavior publishes"); + server } #[test] - fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> { - let mut image = image()?; + fn repl_drives_actor_messages_and_inspection() -> Result<(), Box> { + let mut server = server(); 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 image, DEFAULT_BEHAVIOR)?; + run_repl(input, &mut output, &mut server, DEFAULT_BEHAVIOR)?; let output = String::from_utf8(output)?; - assert!(output.contains("ActorId(\n 1,")); - assert!(output.contains("ActorId(\n 2,")); + assert!(output.contains("actor_id: 1")); + assert!(output.contains("actor_id: 2")); assert!(output.contains("state: 15")); - assert!(output.contains("Committed")); + assert!(output.contains("Committed {")); + assert!(output.contains("actor_id: 2")); Ok(()) } #[test] - fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> { - let mut image = image()?; + fn repl_reports_bad_input_and_continues() -> Result<(), Box> { + let mut server = server(); 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 image, DEFAULT_BEHAVIOR)?; + run_repl(input, &mut output, &mut server, DEFAULT_BEHAVIOR)?; let output = String::from_utf8(output)?; assert!(output.contains("invalid initial state")); assert!(output.contains("unknown actor")); @@ -412,8 +519,9 @@ mod tests { } #[test] - fn repl_manages_named_behavior_revisions_and_installs_them() -> ExperimentResult<()> { - let mut image = image()?; + fn repl_manages_named_behavior_revisions_and_installs_them() + -> Result<(), Box> { + let mut server = server(); let source_path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../runa-std/actors/counter.rn" @@ -422,7 +530,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" )); let mut output = Vec::new(); - run_repl(input, &mut output, &mut image, DEFAULT_BEHAVIOR)?; + run_repl(input, &mut output, &mut server, DEFAULT_BEHAVIOR)?; let output = String::from_utf8(output)?; assert!(output.contains("published `counter` revision 2")); assert!(output.contains("revision 1:")); @@ -430,14 +538,24 @@ mod tests { 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!(output.contains("Paused(")); - assert!(output.contains("Resumed(")); - assert!(output.contains("InvocationCancelled(")); - assert_eq!(image.behavior(DEFAULT_BEHAVIOR).unwrap().revision, 3); - assert_eq!( - image.inspect(ActorId(1))?.implementation, - image.behavior(DEFAULT_BEHAVIOR).unwrap().current - ); + assert!(output.contains("Paused {")); + assert!(output.contains("Resumed {")); + assert!(output.contains("InvocationCancelled {")); + let behavior = match dispatch( + &mut server, + Command::GetBehavior { + name: DEFAULT_BEHAVIOR.to_owned(), + }, + )? { + Reply::Behavior(behavior) => behavior, + other => return Err(format!("unexpected response: {other:?}").into()), + }; + assert_eq!(behavior.revision, 3); + let actor = match dispatch(&mut server, Command::InspectActor { actor_id: 1 })? { + Reply::Actor(actor) => actor, + other => return Err(format!("unexpected response: {other:?}").into()), + }; + assert_eq!(actor.implementation_id, behavior.current_implementation_id); Ok(()) } } diff --git a/docs/architecture.md b/docs/architecture.md index f0a52e5..993489a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -438,7 +438,8 @@ inspection remain future work. The first Phase 5 increments are a trusted in-process application API used by the CLI, a transport-neutral versioned protocol contract, and a dispatcher -exposed through bounded local JSON-lines stdio. A network server, sessions, +exposed through bounded local JSON-lines stdio. The interactive CLI uses the +same protocol commands and dispatcher in-process. A network server, sessions, authentication, and authorization are not yet implemented. ### Phase 6 — Graphical world diff --git a/docs/runtime.md b/docs/runtime.md index d2356ac..cb27449 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -54,7 +54,8 @@ The default per-actor slice budget is 32 instructions. - `runa-server` dispatches those requests through `runa-image-api` and maps results to protocol replies; its executable uses newline-delimited JSON over stdin/stdout with a 1 MiB request-line limit, not a network listener. -- `runa` provides the local CLI client for that boundary. +- `runa` provides an interactive CLI client using protocol requests through the + dispatcher. The image API is process-local and intended for trusted callers. It has no transport, authentication, or authorization layer; do not expose it directly