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:
parent
3be18f2f53
commit
c857853cf5
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<R: BufRead, W: Write>(
|
||||
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<R: BufRead, W: Write>(
|
|||
let args: Vec<_> = words.collect();
|
||||
let result = match command {
|
||||
"help" => print_help(&mut output),
|
||||
"spawn" => match parse_exact::<i64, 1>(&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<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> {
|
||||
if args.len() != N {
|
||||
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!(
|
||||
output,
|
||||
"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\
|
||||
tick <count> [instruction-budget]\n\
|
||||
inspect <actor-id>\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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue