feat: expose behavior versions in CLI

Add a mise launcher for the Runa CLI and expose named behavior publish, history, rollback, install, and spawn operations. Update usage documentation and changelog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 10:24:43 +01:00
parent 3be18f2f53
commit c857853cf5
6 changed files with 300 additions and 37 deletions

View File

@ -84,6 +84,7 @@ Run these from the repository root:
mise run format mise run format
mise run lint mise run lint
mise run test mise run test
mise run run
mise run pre-commit 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. Clippy checks all workspace targets and treats warnings as errors.
The workspace tests cover execution probes, actor runtime invariants, and the 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 ## Conventions

View File

@ -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 - In-memory named behavior revisions with optimistic publish checks, immutable
history, rollback revisions, and runtime installation at idle handler history, rollback revisions, and runtime installation at idle handler
boundaries. 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. - 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.

View File

@ -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: Run the current executable from the repository root:
```sh ```sh
cargo run -p runa mise run run
``` ```
This opens an interactive runtime session using the default counter behavior: 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 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 See [the feasibility report](docs/rune-feasibility.md) for verified Rune
capabilities and limitations, and [the runtime guide](docs/runtime.md) for capabilities and limitations, and [the runtime guide](docs/runtime.md) for
the runtime contract. the runtime contract.

View File

@ -3,27 +3,38 @@ use std::num::NonZeroUsize;
use runa_actor::{ActorId, Message}; use runa_actor::{ActorId, Message};
use runa_code::CodeStore; use runa_code::CodeStore;
use runa_core::ImplementationId;
use runa_rune::ExperimentResult; use runa_rune::ExperimentResult;
use runa_runtime::Runtime; 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";
fn main() -> ExperimentResult<()> { fn main() -> ExperimentResult<()> {
let mut code = CodeStore::default(); 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 mut runtime = Runtime::default();
let implementation = runtime.publish(artifact)?; runtime.publish(std::sync::Arc::clone(artifact))?;
println!("Runa — Rune Actors"); 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."); println!("Type `help` for commands.");
run_repl( run_repl(
io::stdin().lock(), io::stdin().lock(),
io::stdout().lock(), io::stdout().lock(),
&mut code,
&mut runtime, &mut runtime,
implementation, DEFAULT_BEHAVIOR,
)?; )?;
Ok(()) Ok(())
} }
@ -31,8 +42,9 @@ 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,
runtime: &mut Runtime, runtime: &mut Runtime,
implementation: ImplementationId, default_behavior: &str,
) -> io::Result<()> { ) -> io::Result<()> {
let mut line = String::new(); let mut line = String::new();
loop { loop {
@ -50,17 +62,22 @@ 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" => match parse_exact::<i64, 1>(&args) { "spawn" => spawn_command(&mut output, &args, default_behavior, code, runtime),
Ok([state]) => match runtime.spawn( "behaviors" if args.is_empty() => {
implementation, for behavior in code.behaviors() {
state, writeln!(
NonZeroUsize::new(MAILBOX_CAPACITY).expect("capacity is nonzero"), output,
) { "{} revision {} ({})",
Ok(id) => writeln!(output, "spawned {id:?}"), behavior.name, behavior.revision, behavior.current
Err(error) => writeln!(output, "error: {error}"), )?;
}, }
Err(error) => writeln!(output, "error: {error}"), 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() => { "actors" if args.is_empty() => {
for actor in runtime.actors() { for actor in runtime.actors() {
writeln!(output, "{actor:#?}")?; writeln!(output, "{actor:#?}")?;
@ -133,6 +150,165 @@ fn lifecycle_command<W: Write>(
} }
} }
fn spawn_command<W: Write>(
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 <initial-state> [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<W: Write>(
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 <behavior-name> <new|expected-revision> <source-file>"
);
};
let expected_revision = if *expected_revision == "new" {
None
} else {
match expected_revision.parse::<u64>() {
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<W: Write>(output: &mut W, args: &[&str], code: &CodeStore) -> io::Result<()> {
let [name] = args else {
return writeln!(output, "error: usage: history <behavior-name>");
};
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<W: Write>(
output: &mut W,
args: &[&str],
code: &mut CodeStore,
) -> io::Result<()> {
let [name, expected_revision, target_revision] = args else {
return writeln!(
output,
"error: usage: rollback <behavior-name> <expected-revision> <target-revision>"
);
};
let expected_revision = match expected_revision.parse::<u64>() {
Ok(revision) => revision,
Err(_) => return writeln!(output, "error: invalid expected revision"),
};
let target_revision = match target_revision.parse::<u64>() {
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<W: Write>(
output: &mut W,
args: &[&str],
code: &CodeStore,
runtime: &mut Runtime,
) -> 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),
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<T: std::str::FromStr, const N: usize>(args: &[&str]) -> Result<[T; N], String> { fn parse_exact<T: std::str::FromStr, const N: usize>(args: &[&str]) -> Result<[T; N], String> {
if args.len() != N { if args.len() != N {
return Err(format!("expected {N} argument(s), got {}", args.len())); return Err(format!("expected {N} argument(s), got {}", args.len()));
@ -192,7 +368,12 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
writeln!( writeln!(
output, output,
"Commands:\n\ "Commands:\n\
spawn <initial-state>\n\ spawn <initial-state> [behavior-name]\n\
behaviors\n\
publish <behavior-name> <new|expected-revision> <source-file>\n\
history <behavior-name>\n\
rollback <behavior-name> <expected-revision> <target-revision>\n\
install <actor-id> <behavior-name>\n\
send <actor-id> <kind> <value>\n\ send <actor-id> <kind> <value>\n\
tick <count> [instruction-budget]\n\ tick <count> [instruction-budget]\n\
inspect <actor-id>\n\ inspect <actor-id>\n\
@ -213,13 +394,26 @@ mod tests {
fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> { fn repl_drives_actor_messages_and_inspection() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let mut code = CodeStore::default(); let mut code = CodeStore::default();
let implementation = code.publish_behavior(
runtime.publish(code.compile(include_str!("../../../runa-std/actors/counter.rn"))?)?; 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( 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(input, &mut output, &mut runtime, implementation)?; run_repl(
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,"));
@ -232,16 +426,69 @@ mod tests {
fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> { fn repl_reports_bad_input_and_continues() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let mut code = CodeStore::default(); let mut code = CodeStore::default();
let implementation = code.publish_behavior(
runtime.publish(code.compile(include_str!("../../../runa-std/actors/counter.rn"))?)?; 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(input, &mut output, &mut runtime, implementation)?; run_repl(
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 argument 1")); assert!(output.contains("invalid initial state"));
assert!(output.contains("unknown actor")); assert!(output.contains("unknown actor"));
assert!(output.contains("tick budget must be positive")); assert!(output.contains("tick budget must be positive"));
assert!(output.contains("unknown command")); assert!(output.contains("unknown command"));
Ok(()) 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(())
}
} }

View File

@ -3,14 +3,20 @@
Start the interactive, in-memory runtime session with: Start the interactive, in-memory runtime session with:
```sh ```sh
cargo run -p runa mise run run
``` ```
The executable loads [counter.rn](../runa-std/actors/counter.rn) as the default The executable loads [counter.rn](../runa-std/actors/counter.rn) as the default
behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`, behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`,
`events`, `stop`, and `restart`; enter `help` to see usage. All commands operate `events`, `stop`, `restart`, `behaviors`, `publish`, `history`, `rollback`, and
on the same process-local runtime. This CLI is for local development, not a `install`; enter `help` to see usage. `publish` reads a Rune source file and
versioned external API. 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: 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 messages may remain; they will use the newly installed implementation. Active
or awaiting handlers keep their original implementation, and installation or awaiting handlers keep their original implementation, and installation
attempts during execution fail explicitly so the caller can retry at the next 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 handler boundary.
does not yet expose behavior publishing, history, rollback, or installation.
The source identity hashes the exact original UTF-8 bytes with the versioned The source identity hashes the exact original UTF-8 bytes with the versioned
`runa-source:v1` domain. Whitespace/comment canonicalization is intentionally `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, This is not yet a full image: no persistent behavior registry or mailboxes,
transactional outbox, supervisors, API server, GUI, or automatic image transactional outbox, supervisors, API server, GUI, or automatic image
bootstrap. The initial implementation installation API is process-local and bootstrap. The behavior registry and implementation installation are
requires an idle handler boundary; it does not upgrade active execution. process-local; installation requires an idle handler boundary and does not
The example default script is bundled through `include_str!`. upgrade active execution. The example default script is bundled through
`include_str!`.
Messages are not accepted by stopped/failed actors. Messages are not accepted by stopped/failed actors.
Native functions must be nonblocking. Dropping a future proves cleanup only Native functions must be nonblocking. Dropping a future proves cleanup only

View File

@ -9,6 +9,10 @@ run = ["tombi format --offline", "cargo fmt --all"]
description = "Run workspace tests" description = "Run workspace tests"
run = "cargo test --workspace" run = "cargo test --workspace"
[tasks.run]
description = "Run the local Runa CLI"
run = "cargo run -p runa"
[tasks.lint] [tasks.lint]
description = "Lint all workspace targets with Clippy" description = "Lint all workspace targets with Clippy"
run = "cargo clippy --workspace --all-targets -- -D warnings" run = "cargo clippy --workspace --all-targets -- -D warnings"