feat: add interactive local runtime CLI

Add a REPL for spawning actors, sending messages, ticking, inspection, events, stop, and restart. Expose ordered actor snapshots and document the local control surface.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 09:31:25 +01:00
parent e27a40cbf5
commit 2230b666ab
6 changed files with 297 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@ -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<T> = ExperimentResult<T>;
struct WakeFlag(AtomicBool);
impl Wake for WakeFlag {
@ -108,6 +109,17 @@ impl Runtime {
Ok(snapshot)
}
pub fn actors(&self) -> Vec<ActorSnapshot> {
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;

View File

@ -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(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,
)?;
Ok(())
}
fn run_repl<R: BufRead, W: Write>(
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::<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}"),
},
"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::<u64, 1>(&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<W: Write>(
output: &mut W,
args: &[&str],
operation: impl FnOnce(&mut Runtime, ActorId) -> runa_runtime::RuntimeResult<()>,
runtime: &mut Runtime,
) -> io::Result<()> {
match parse_exact::<u64, 1>(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<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()));
}
let parsed = args
.iter()
.enumerate()
.map(|(index, arg)| {
arg.parse::<T>()
.map_err(|_| format!("invalid argument {}: `{arg}`", index + 1))
})
.collect::<Result<Vec<_>, _>>()?;
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 <count> [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<W: Write>(output: &mut W) -> io::Result<()> {
writeln!(
output,
"Commands:\n\
spawn <initial-state>\n\
send <actor-id> <kind> <value>\n\
tick <count> [instruction-budget]\n\
inspect <actor-id>\n\
actors\n\
events\n\
stop <actor-id>\n\
restart <actor-id>\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 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)?,
},
)?;
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:#?}");
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(())
}
}

View File

@ -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 <count> [instruction-budget]` explicitly advances the scheduler.
The default per-actor slice budget is 32 instructions.
## Current contracts