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-rune/`: Phase 0 Rune compilation and execution experiments.
|
||||||
- `crates/runa-actor/`: actor value and lifecycle contracts.
|
- `crates/runa-actor/`: actor value and lifecycle contracts.
|
||||||
- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions.
|
- `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/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
|
||||||
- `docs/decisions/`: architectural decisions.
|
- `docs/decisions/`: architectural decisions.
|
||||||
- `runa-std/`: default Rune scripts for an image.
|
- `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.
|
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 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
|
## 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.
|
message effects, inspection, lifecycle events, stop, and restart.
|
||||||
- Runnable two-actor counter demonstration and tests for runaway fairness,
|
- Runnable two-actor counter demonstration and tests for runaway fairness,
|
||||||
pending-message isolation, failures, backpressure, and all-or-nothing effects.
|
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.
|
- 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.
|
||||||
|
|
|
||||||
23
README.md
23
README.md
|
|
@ -40,9 +40,26 @@ Run the current executable from the repository root:
|
||||||
cargo run -p runa
|
cargo run -p runa
|
||||||
```
|
```
|
||||||
|
|
||||||
It prints the feasibility results and local compilation/VM creation measurements.
|
This opens an interactive runtime session using the default counter behavior:
|
||||||
See [the feasibility report](docs/rune-feasibility.md) for verified capabilities,
|
|
||||||
limitations, and [the runtime guide](docs/runtime.md) for the two-actor demo.
|
```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
|
## Development
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use runa_actor::{ActorId, ActorSnapshot, Event, ImplementationId, MailboxId, Mes
|
||||||
use runa_rune::{CompiledProgram, ExperimentResult, Invocation, Slice};
|
use runa_rune::{CompiledProgram, ExperimentResult, Invocation, Slice};
|
||||||
|
|
||||||
type WireTransition = (i64, Vec<(u64, i64, i64)>);
|
type WireTransition = (i64, Vec<(u64, i64, i64)>);
|
||||||
|
pub type RuntimeResult<T> = ExperimentResult<T>;
|
||||||
|
|
||||||
struct WakeFlag(AtomicBool);
|
struct WakeFlag(AtomicBool);
|
||||||
impl Wake for WakeFlag {
|
impl Wake for WakeFlag {
|
||||||
|
|
@ -108,6 +109,17 @@ impl Runtime {
|
||||||
Ok(snapshot)
|
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<()> {
|
pub fn stop(&mut self, id: ActorId) -> ExperimentResult<()> {
|
||||||
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
|
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
|
||||||
actor.active = None;
|
actor.active = None;
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,244 @@
|
||||||
|
use std::io::{self, BufRead, Write};
|
||||||
use std::num::NonZeroUsize;
|
use std::num::NonZeroUsize;
|
||||||
|
|
||||||
use runa_actor::Message;
|
use runa_actor::{ActorId, Message};
|
||||||
use runa_rune::CompiledProgram;
|
use runa_rune::{CompiledProgram, ExperimentResult};
|
||||||
use runa_runtime::Runtime;
|
use runa_runtime::Runtime;
|
||||||
|
|
||||||
fn main() -> runa_rune::ExperimentResult<()> {
|
const MAILBOX_CAPACITY: usize = 64;
|
||||||
let report = runa_rune::run_feasibility()?;
|
const DEFAULT_TICK_BUDGET: usize = 32;
|
||||||
println!("Runa Phase 0 — Rune 0.14.2 feasibility");
|
|
||||||
println!("{report:#?}");
|
fn main() -> ExperimentResult<()> {
|
||||||
|
let program = CompiledProgram::compile(include_str!("../../../runa-std/actors/counter.rn"))?;
|
||||||
let mut runtime = Runtime::default();
|
let mut runtime = Runtime::default();
|
||||||
let implementation = runtime.publish(CompiledProgram::compile(include_str!(
|
let implementation = runtime.publish(program)?;
|
||||||
"../../../runa-std/actors/counter.rn"
|
|
||||||
))?)?;
|
println!("Runa — Rune Actors");
|
||||||
let capacity = NonZeroUsize::new(8).expect("nonzero mailbox capacity");
|
println!("Loaded the default counter behavior as implementation {implementation:?}.");
|
||||||
let sender = runtime.spawn(implementation, 0, capacity)?;
|
println!("Type `help` for commands.");
|
||||||
let receiver = runtime.spawn(implementation, 10, capacity)?;
|
run_repl(
|
||||||
runtime.send(
|
io::stdin().lock(),
|
||||||
sender,
|
io::stdout().lock(),
|
||||||
Message {
|
&mut runtime,
|
||||||
kind: 1,
|
implementation,
|
||||||
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:#?}");
|
|
||||||
Ok(())
|
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 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
|
# Phase 1 actor prototype
|
||||||
|
|
||||||
Run the feasibility probes and two-actor demonstration with:
|
Start the interactive, in-memory runtime session with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p runa
|
cargo run -p runa
|
||||||
mise run pre-commit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The executable loads [counter.rn](../runa-std/actors/counter.rn), spawns two
|
The executable loads [counter.rn](../runa-std/actors/counter.rn) as the default
|
||||||
actors sharing one implementation, and sends a structured message. The sender
|
behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`,
|
||||||
commits state 1 and sends a message that moves the receiver from state 10 to 15.
|
`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
|
## Current contracts
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue