refactor: route CLI through protocol dispatcher

Replace the CLI's direct ImageApi dependency with protocol commands dispatched through runa-server. Render typed protocol replies for behavior, actor, event, lifecycle, and scheduler operations, preserving the interactive workflow while exercising the same contract used by the local JSON-lines transport.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 14:30:22 +01:00
parent 3146423f78
commit ce7bb5602a
7 changed files with 251 additions and 135 deletions

View File

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

8
Cargo.lock generated
View File

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

View File

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

View File

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

View File

@ -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<dyn std::error::Error>> {
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<R: BufRead, W: Write>(
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<R: BufRead, W: Write>(
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<R: BufRead, W: Write>(
},
"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::<u64, 1>(&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<R: BufRead, W: Write>(
fn lifecycle_command<W: Write>(
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::<u64, 1>(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<W: Write>(
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<W: Write>(
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<W: Write>(
output: &mut W,
args: &[&str],
image: &mut ImageApi,
) -> io::Result<()> {
fn publish_command<W: Write>(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<W: Write>(
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<W: Write>(output: &mut W, args: &[&str], image: &ImageApi) -> io::Result<()> {
fn history_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> {
let [name] = args else {
return writeln!(output, "error: usage: history <behavior-name>");
};
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<W: Write>(output: &mut W, args: &[&str], image: &ImageApi) ->
fn rollback_command<W: Write>(
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<W: Write>(
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<W: Write>(
output: &mut W,
args: &[&str],
image: &mut ImageApi,
) -> io::Result<()> {
fn install_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> {
let [actor_id, name] = args else {
return writeln!(output, "error: usage: install <actor-id> <behavior-name>");
};
let actor_id = match actor_id.parse::<u64>() {
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<Reply, String> {
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<W: Write>(output: &mut W, reply: Reply) -> io::Result<()> {
writeln!(output, "error: unexpected protocol response: {reply:?}")
}
fn parse_exact<T: std::str::FromStr, const N: usize>(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<W: Write>(output: &mut W) -> io::Result<()> {
mod tests {
use super::*;
fn image() -> ExperimentResult<ImageApi> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
}

View File

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

View File

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