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:
parent
e27a40cbf5
commit
2230b666ab
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue