feat!: add CLI subcommands and an explicit REPL entry point

Expose image operations through Clap subcommands, rename actors to ps, remove REPL cancellation, and rename the mise cli task to repl. Preserve session bindings in the REPL and proper close handshakes for one-shot commands.

BREAKING CHANGE: runa-cli now requires a subcommand; use runa-cli repl or mise run repl for interactive use. The actors REPL command is now ps, and cancel is no longer available in the REPL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 17:13:15 +01:00
parent fcec43d44b
commit 750b709d35
8 changed files with 425 additions and 58 deletions

View File

@ -90,7 +90,7 @@ mise run format
mise run lint
mise run test
mise run start
mise run cli
mise run repl
mise run test-extension-host
mise run pre-commit
```
@ -104,7 +104,9 @@ are required; install the locked extension dependencies with
The workspace tests cover execution probes, actor runtime invariants, and the
interactive CLI. Extension tests include a real Rust-server WebSocket round trip.
`mise run start` starts the image server; `mise run cli` opens the REPL.
`mise run start` starts the image server; `mise run repl` opens the REPL.
The CLI requires a subcommand: `runa-cli repl` starts the REPL and image
operations such as `runa-cli ps` run once and exit.
Both use Clap derive/env arguments (`RUNA_LISTEN` and `RUNA_CONNECT` respectively).
The separate `mise run test-extension-host`
task verifies inspector commands in VS Code and requires a display (or

View File

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Changed
- Require an explicit `runa-cli` subcommand: `repl` starts the interactive
session, and each image operation is also available as a one-shot command.
- Rename the REPL actor listing command from `actors` to `ps`, remove its
`cancel` command, and rename `mise run cli` to `mise run repl`.
### Fixed
- Complete the WebSocket close handshake when the CLI exits, preventing normal

View File

@ -57,14 +57,14 @@ mise run start
In another terminal, connect the REPL:
```sh
mise run cli
mise run repl
```
The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint,
use `cargo run -p runa-cli -- --connect 127.0.0.1:4000` and start the server with
use `cargo run -p runa-cli -- --connect 127.0.0.1:4000 repl` and start the server with
the corresponding `--listen` address. Quitting the REPL disconnects only that
client; actors and source files survive until the server exits.
Reconnect and use `actors` to recover their references. REPL bindings and
Reconnect and use `ps` to recover their references. REPL bindings and
command history are session-local and are not restored on reconnect.
Both executables use Clap for `--help`, `--version`, and argument validation.
@ -75,9 +75,27 @@ For example:
```sh
RUNA_LISTEN=127.0.0.1:4000 mise run start
RUNA_CONNECT=127.0.0.1:4000 mise run cli
RUNA_CONNECT=127.0.0.1:4000 mise run repl
```
`runa-cli` requires a subcommand. Use `runa-cli repl` for an interactive session,
or run an image operation directly:
```sh
cargo run -p runa-cli -- ps
actor=$(cargo run -p runa-cli -- spawn actors/counter 1)
cargo run -p runa-cli -- send "$actor" '{"op":"add","value":2}'
cargo run -p runa-cli -- tick 100
```
Each image operation in the REPL has a matching subcommand; use
`runa-cli <command> --help` for arguments. Quote JSON as a single shell argument.
One-shot commands use actor references rather than session-local bindings,
print results without a REPL banner, and exit unsuccessfully on errors.
`let name = spawn ...` is session-local REPL syntax. `quit` (alias `exit`)
exits either interface without stopping the image. `cancel` is no longer a
REPL command; the underlying runtime cancellation API remains available.
The server is WebSocket-only; stdio transport has been removed. By default it listens on
`ws://127.0.0.1:3737/` and requires the `runa-debug` subprotocol, with up to 16
simultaneous clients sharing one live image. The listener rejects non-loopback

View File

@ -74,9 +74,9 @@ impl ReplHelper {
fn candidates(&self, line: &str, position: usize) -> Vec<String> {
const COMMANDS: &[&str] = &[
"actors", "sources", "cancel", "events", "exit", "help", "history", "inspect",
"become", "kill", "reload", "write", "let", "pause", "commit", "quit", "restart",
"resume", "rollback", "send", "spawn", "stop", "tick",
"ps", "sources", "events", "exit", "help", "history", "inspect", "become", "kill",
"reload", "write", "let", "pause", "commit", "quit", "restart", "resume", "rollback",
"send", "spawn", "stop", "tick",
];
let (start, reference_value) = Self::completion_start(line, position);
if reference_value {
@ -101,7 +101,6 @@ impl ReplHelper {
| ["restart"]
| ["pause"]
| ["resume"]
| ["cancel"]
| ["kill"]
| ["reload"]
| ["become"] => self.actor_candidates(),
@ -183,18 +182,189 @@ struct Args {
/// Loopback address of the runa-debug WebSocket server.
#[arg(long, env = "RUNA_CONNECT", default_value = "127.0.0.1:3737")]
connect: SocketAddr,
#[command(subcommand)]
command: CliCommand,
}
#[derive(Debug, clap::Subcommand)]
enum CliCommand {
/// Start the interactive REPL.
Repl,
/// List actors in the image.
Ps,
/// List image source files.
Sources,
/// Spawn an actor from the current image source.
Spawn {
path: String,
#[arg(allow_hyphen_values = true)]
state: String,
},
/// Send a JSON message to an actor.
Send {
actor: String,
#[arg(allow_hyphen_values = true)]
message: String,
},
/// Advance the scheduler.
Tick {
count: usize,
#[arg(default_value_t = NonZeroUsize::new(DEFAULT_TICK_BUDGET).expect("nonzero budget"))]
budget: NonZeroUsize,
},
/// Inspect an actor.
Inspect { actor: String },
/// Drain runtime events.
Events,
/// Save a local UTF-8 file into the image working sources.
Write {
path: String,
file: std::path::PathBuf,
},
/// Commit the current image source.
Commit { path: String },
/// List source commits.
History { path: String },
/// Restore a source commit into the working file.
Rollback { path: String, revision: String },
/// Stop gracefully at a handler boundary.
Stop { actor: String },
/// Kill the current invocation without shutdown.
Kill { actor: String },
/// Restart with the same state and loaded script.
Restart { actor: String },
/// Pause an actor.
Pause { actor: String },
/// Resume an actor.
Resume { actor: String },
/// Load the current working source at the actor's path.
Reload { actor: String },
/// Switch the actor to another source path.
Become { actor: String, path: String },
/// Exit without connecting (also available as exit).
#[command(visible_alias = "exit")]
Quit,
}
impl CliCommand {
fn request(self, server: &mut dyn Connection) -> Result<Command, String> {
let state = ReplState::default();
let actor_ref = |actor: &str| parse_actor_argument(&[actor], &state);
let system = |actor: &str, message| {
Ok(Command::System {
actor_ref: actor_ref(actor)?,
message,
})
};
Ok(match self {
Self::Ps => Command::ListActors,
Self::Sources => Command::ListSources,
Self::Spawn { path, state: json } => Command::Spawn {
behavior_name: path,
state: parse_value(&json, &state)?,
mailbox_capacity: MAILBOX_CAPACITY,
},
Self::Send { actor, message } => Command::Send {
actor_ref: actor_ref(&actor)?,
message: parse_value(&message, &state)?,
},
Self::Tick { count, budget } => Command::Tick {
ticks: count,
instruction_budget: budget.get(),
},
Self::Inspect { actor } => Command::InspectActor {
actor_ref: actor_ref(&actor)?,
},
Self::Events => Command::GetEvents,
Self::Write { path, file } => return source_write_request(&path, &file, server),
Self::Commit { path } => Command::CommitSource { path },
Self::History { path } => Command::SourceHistory { path },
Self::Rollback { path, revision } => Command::RollbackSource { path, revision },
Self::Stop { actor } => return system(&actor, SystemMessageDto::Stop),
Self::Kill { actor } => return system(&actor, SystemMessageDto::Kill),
Self::Restart { actor } => return system(&actor, SystemMessageDto::Restart),
Self::Pause { actor } => return system(&actor, SystemMessageDto::Pause),
Self::Resume { actor } => return system(&actor, SystemMessageDto::Resume),
Self::Reload { actor } => return system(&actor, SystemMessageDto::Reload),
Self::Become { actor, path } => {
return system(&actor, SystemMessageDto::Become { path });
}
Self::Repl | Self::Quit => return Err("not an image operation".to_owned()),
})
}
}
fn run_command(
command: CliCommand,
server: &mut dyn Connection,
output: &mut impl Write,
) -> Result<(), Box<dyn std::error::Error>> {
let request = command.request(server)?;
let scheduled = matches!(
&request,
Command::System {
message: SystemMessageDto::Stop
| SystemMessageDto::Reload
| SystemMessageDto::Become { .. },
..
}
);
let reply = dispatch(server, request)?;
match reply {
Reply::ActorSpawned { actor_ref } => writeln!(output, "@{actor_ref}")?,
Reply::Acknowledged if scheduled => {
writeln!(output, "requested; advance scheduler ticks to complete")?;
}
Reply::Acknowledged => writeln!(output, "ok")?,
Reply::Actors(actors) => {
for actor in actors {
writeln!(output, "{actor:#?}")?;
}
}
Reply::Sources(files) => {
for file in files {
writeln!(output, "source/{}.rn ({})", file.path, file.source_id)?;
}
}
Reply::Actor(actor) => writeln!(output, "{actor:#?}")?,
Reply::Events(events) => {
for event in events {
writeln!(output, "{event:#?}")?;
}
}
Reply::TickCompleted { ticks } => writeln!(output, "ran {ticks} scheduler tick(s)")?,
Reply::Source(file) => {
writeln!(output, "saved source/{}.rn ({})", file.path, file.source_id)?;
}
Reply::SourceCommitted(commit) => writeln!(output, "revision {}", commit.revision)?,
Reply::SourceHistory(history) => {
for commit in history {
writeln!(output, "revision {}: {}", commit.revision, commit.source_id)?;
}
}
other => return Err(format!("unexpected protocol response: {other:?}").into()),
}
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let address = <Args as clap::Parser>::parse().connect;
let args = <Args as clap::Parser>::parse();
if matches!(args.command, CliCommand::Quit) {
return Ok(());
}
let address = args.connect;
let mut server = WebSocketClient::connect(address).map_err(|error| {
format!("cannot connect to {address}: {error}; start `mise run start` first")
})?;
let result = if matches!(args.command, CliCommand::Repl) {
println!("Runa — Rune Actors");
println!("Connected to {address}. The image remains running when you quit.");
println!("Type `help` for commands. Use Up/Down for history and Tab for completion.");
let result = run_interactive_repl(&mut server);
run_interactive_repl(&mut server)
} else {
run_command(args.command, &mut server, &mut io::stdout().lock())
};
let close_result = server.close();
result?;
close_result?;
@ -312,7 +482,7 @@ fn execute_line<W: Write>(
"history" => history_command(output, args, server),
"rollback" => rollback_command(output, args, server),
"become" => become_command(output, args, server, state),
"actors" if args.is_empty() => match dispatch(server, Command::ListActors) {
"ps" if args.is_empty() => match dispatch(server, Command::ListActors) {
Ok(Reply::Actors(actors)) => {
for actor in actors {
writeln!(output, "{actor:#?}")?;
@ -322,7 +492,7 @@ fn execute_line<W: Write>(
Ok(other) => unexpected_reply(output, other),
Err(error) => writeln!(output, "error: {error}"),
},
"actors" => writeln!(output, "error: usage: actors"),
"ps" => writeln!(output, "error: usage: ps"),
"send" => match parse_send_args(line, state) {
Ok((actor_ref, message)) => {
match dispatch(server, Command::Send { actor_ref, message }) {
@ -394,9 +564,6 @@ fn execute_line<W: Write>(
actor_ref,
message: SystemMessageDto::Resume,
}),
"cancel" => lifecycle_command(output, args, server, state, |actor_ref| {
Command::CancelActor { actor_ref }
}),
"quit" | "exit" if args.is_empty() => return Ok(false),
"quit" | "exit" => writeln!(output, "error: usage: quit"),
_ => writeln!(output, "error: unknown command `{command}`; type `help`"),
@ -509,28 +676,11 @@ fn write_command<W: Write>(
let [path, source_path] = args else {
return writeln!(output, "error: usage: write <path> <local-source-file>");
};
let expected_source_id = match dispatch(server, Command::ListSources) {
Ok(Reply::Sources(files)) => files
.into_iter()
.find(|file| file.path == *path)
.map(|file| file.source_id),
Ok(other) => return unexpected_reply(output, other),
let request = match source_write_request(path, std::path::Path::new(source_path), server) {
Ok(request) => request,
Err(error) => return writeln!(output, "error: {error}"),
};
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}");
}
};
match dispatch(
server,
Command::WriteSource {
path: (*path).to_owned(),
expected_source_id,
source,
},
) {
match dispatch(server, request) {
Ok(Reply::Source(file)) => {
writeln!(output, "saved source/{}.rn ({})", file.path, file.source_id)
}
@ -539,6 +689,27 @@ fn write_command<W: Write>(
}
}
fn source_write_request(
path: &str,
source_path: &std::path::Path,
server: &mut dyn Connection,
) -> Result<Command, String> {
let expected_source_id = match dispatch(server, Command::ListSources)? {
Reply::Sources(files) => files
.into_iter()
.find(|file| file.path == path)
.map(|file| file.source_id),
other => return Err(format!("unexpected protocol response: {other:?}")),
};
let source = std::fs::read_to_string(source_path)
.map_err(|error| format!("could not read `{}`: {error}", source_path.display()))?;
Ok(Command::WriteSource {
path: path.to_owned(),
expected_source_id,
source,
})
}
fn commit_command<W: Write>(
output: &mut W,
args: &[&str],
@ -771,7 +942,7 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
State/messages: JSON values; {{\"$actor\":\"name-or-@ref\"}} embeds an actor reference.\n\
tick <count> [instruction-budget]\n\
inspect <actor-ref-or-name>\n\
actors\n\
ps\n\
events\n\
stop <actor-ref-or-name>\n\
kill <actor-ref-or-name>\n\
@ -779,7 +950,6 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
restart <actor-ref-or-name>\n\
pause <actor-ref-or-name>\n\
resume <actor-ref-or-name>\n\
cancel <actor-ref-or-name>\n\
help\n\
quit"
)
@ -789,6 +959,91 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
mod tests {
use super::*;
#[test]
fn removed_commands_are_not_completed_or_dispatched() -> Result<(), Box<dyn std::error::Error>>
{
let helper = ReplHelper::default();
let commands = helper.candidates("", 0);
assert!(commands.contains(&"ps".to_owned()));
assert!(!commands.contains(&"actors".to_owned()));
assert!(!commands.contains(&"cancel".to_owned()));
let mut output = Vec::new();
run_repl(
io::Cursor::new("actors\ncancel nobody\nps\nquit\n"),
&mut output,
&mut server(),
)?;
let output = String::from_utf8(output)?;
assert!(output.contains("unknown command `actors`"));
assert!(output.contains("unknown command `cancel`"));
assert!(!output.contains("unknown command `ps`"));
Ok(())
}
#[test]
fn subcommands_parse_typed_arguments_and_preserve_json() {
let args = <Args as clap::Parser>::try_parse_from([
"runa-cli",
"--connect",
"[::1]:4000",
"spawn",
"actors/counter",
"-42",
])
.unwrap();
assert_eq!(args.connect, "[::1]:4000".parse::<SocketAddr>().unwrap());
assert!(matches!(
args.command.request(&mut server()).unwrap(),
Command::Spawn {
state: Value::Integer(-42),
..
}
));
let args = <Args as clap::Parser>::try_parse_from([
"runa-cli",
"spawn",
"actors/counter",
r#"{"text":"two spaces"}"#,
])
.unwrap();
assert!(matches!(
args.command.request(&mut server()).unwrap(),
Command::Spawn { state: Value::Record(values), .. }
if values["text"] == Value::String("two spaces".to_owned())
));
for args in [
vec!["runa-cli"],
vec!["runa-cli", "actors"],
vec!["runa-cli", "cancel", "nobody"],
vec!["runa-cli", "spawn", "actors/counter"],
vec!["runa-cli", "tick", "1", "0"],
] {
assert!(<Args as clap::Parser>::try_parse_from(args).is_err());
}
}
#[test]
fn one_shot_errors_are_returned_instead_of_printed_as_success() {
let mut image = server();
for command in [
CliCommand::Spawn {
path: "actors/missing".to_owned(),
state: "0".to_owned(),
},
CliCommand::Spawn {
path: "actors/counter".to_owned(),
state: "invalid".to_owned(),
},
CliCommand::Inspect {
actor: "not-an-actor".to_owned(),
},
] {
let mut output = Vec::new();
assert!(run_command(command, &mut image, &mut output).is_err());
assert!(output.is_empty());
}
}
#[test]
fn value_parser_preserves_json_shapes_and_distinguishes_references() {
let reference = ActorRef::new();
@ -893,7 +1148,7 @@ mod tests {
.unwrap();
assert_eq!(connect.get_default_values(), ["127.0.0.1:3737"]);
assert_eq!(
<Args as clap::Parser>::try_parse_from(["runa-cli", "--connect", "[::1]:4000"])
<Args as clap::Parser>::try_parse_from(["runa-cli", "--connect", "[::1]:4000", "repl"])
.unwrap()
.connect,
"[::1]:4000".parse::<SocketAddr>().unwrap()
@ -972,7 +1227,7 @@ mod tests {
let mut client = WebSocketClient::connect(address)?;
let mut output = Vec::new();
run_repl(
io::Cursor::new(format!("actors\ninspect @{actor_ref}\nquit\n")),
io::Cursor::new(format!("ps\ninspect @{actor_ref}\nquit\n")),
&mut output,
&mut client,
)?;
@ -1062,7 +1317,7 @@ mod tests {
"/../../runa-std/actors/counter.rn"
);
let input = io::Cursor::new(format!(
"sources\nwrite actors/counter {source_path}\ncommit actors/counter\nhistory actors/counter\nlet actor = spawn actors/counter 0\nbecome actor actors/counter\nreload actor\nsend actor {{\"op\":\"add\",\"value\":1}}\ntick 1 1\ncancel actor\npause actor\nresume actor\nstop actor\ntick 100\nrestart actor\nkill actor\ninspect actor\nevents\nquit\n"
"sources\nwrite actors/counter {source_path}\ncommit actors/counter\nhistory actors/counter\nlet actor = spawn actors/counter 0\nbecome actor actors/counter\nreload actor\nsend actor {{\"op\":\"add\",\"value\":1}}\ntick 1 1\npause actor\nresume actor\nstop actor\ntick 100\nrestart actor\nkill actor\ninspect actor\nevents\nquit\n"
));
let mut output = Vec::new();
run_repl(input, &mut output, &mut server)?;
@ -1073,7 +1328,7 @@ mod tests {
assert!(output.contains("scheduled become `actors/counter` on @"));
assert!(output.contains("Paused {"));
assert!(output.contains("Resumed {"));
assert!(output.contains("InvocationCancelled {"));
assert!(output.contains("Stopped {"));
let history = match dispatch(
&mut server,
Command::SourceHistory {

View File

@ -13,6 +13,19 @@ fn cli_help_and_version_are_available_without_connecting() {
assert!(help.contains("--connect"));
assert!(help.contains("RUNA_CONNECT"));
assert!(help.contains("127.0.0.1:3737"));
for subcommand in [
"repl", "ps", "sources", "spawn", "send", "tick", "inspect", "events", "write", "commit",
"history", "rollback", "stop", "kill", "restart", "pause", "resume", "reload", "become",
"quit",
] {
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
.args([subcommand, "--help"])
.env_remove("RUNA_CONNECT")
.output()
.unwrap();
assert!(output.status.success(), "{subcommand}");
assert!(String::from_utf8(output.stdout).unwrap().contains("Usage:"));
}
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
.arg("--version")
@ -32,6 +45,11 @@ fn clap_rejects_bad_flags_addresses_and_environment_values() {
vec!["--connect", "invalid"],
vec!["--listen", "127.0.0.1:3737"],
vec!["extra"],
vec!["actors"],
vec!["cancel", "actor"],
vec!["spawn", "actors/counter"],
vec!["tick", "1", "0"],
vec![],
] {
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
.args(args)

View File

@ -6,8 +6,13 @@ Start the server-owned, in-memory image:
mise run start
```
Then connect from another terminal with `mise run cli`, or use
`cargo run -p runa-cli -- --connect <loopback-address>` for a custom listener.
Then connect from another terminal with `mise run repl`, or use
`cargo run -p runa-cli -- --connect <loopback-address> repl` for a custom listener.
The CLI requires a subcommand: `repl` starts the interactive session, while
image operations such as `ps`, `spawn`, and `send` run once and exit.
Use `runa-cli --help` or `runa-cli <command> --help` for argument details.
One-shot JSON values must be quoted as a single shell argument; actor names
and `let` bindings exist only within a REPL session.
The server (`runa`) defaults to `127.0.0.1:3737`; `--listen` or `RUNA_LISTEN`
select another loopback address. The REPL (`runa-cli`) accepts `--connect` or
`RUNA_CONNECT`, defaulting to the same address. Explicit flags override environment
@ -18,8 +23,8 @@ restart. Server exit still loses the entire image.
The WebSocket server loads [counter.rn](../runa-std/actors/counter.rn) into the
image's working `source/actors/counter.rn`. Commands include `spawn`, `send`,
`tick`, `inspect`, `actors`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`,
`reload`, `become`, `cancel`, `sources`, `write`, `commit`, `history`, and
`tick`, `inspect`, `ps`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`,
`reload`, `become`, `sources`, `write`, `commit`, `history`, and
`rollback`; enter `help` to see usage. Paths exclude the root and extension:
`spawn actors/counter 1`. Spawn compiles the current working source, not a
committed revision. `write <path> <local-file>` updates working source using
@ -149,7 +154,8 @@ call-frame depth. Paused invocations retain the same telemetry. These values
are execution-local diagnostics only; they do not map to source locations or
expose locals, and disappear when the invocation completes or is cancelled.
`cancel` is distinct from `stop`: it drops an actor's active invocation, moves
Runtime invocation cancellation (not exposed as a CLI/REPL command) is distinct
from `stop`: it drops an actor's active invocation, moves
its reserved message to the front of the mailbox, and returns the actor to
`Idle`. The input is therefore eligible for retry on the next tick, ahead of
messages that were already queued. Committed state is unchanged and no

View File

@ -21,9 +21,9 @@ run = "npm --prefix vscode run test:host"
description = "Start the Runa image server on localhost"
run = "cargo run -p runa"
[tasks.cli]
description = "Connect the Runa CLI to the image server"
run = "cargo run -p runa-cli"
[tasks.repl]
description = "Start the Runa REPL connected to the image server"
run = "cargo run -p runa-cli -- repl"
[tasks.lint]
description = "Check Rust targets and the TypeScript extension"

View File

@ -1,11 +1,72 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { once } from "node:events";
import { resolve } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { ImageClient } from "../src/client";
import { startImage } from "./server";
test(
"one-shot CLI commands share the image, report errors, and close cleanly",
{ timeout: 15000 },
async (t) => {
const image = await startImage();
t.after(() => image.stop());
const executable = resolve(
__dirname,
"../../../target/debug",
process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
);
const execute = promisify(execFile);
const run = async (...args: string[]) => {
const result = await execute(executable, ["--connect", image.address, ...args], {
env: { ...process.env, RUNA_CONNECT: "invalid" },
timeout: 5000,
});
assert.equal(result.stderr, "");
assert.doesNotMatch(result.stdout, /runa>|Connected to/);
return result.stdout.trim();
};
assert.match(await run("sources"), /source\/actors\/counter\.rn/);
await run("write", "actors/copy", resolve(__dirname, "../../../runa-std/actors/counter.rn"));
const committed = await run("commit", "actors/copy");
const revision = committed.match(/^revision ([a-z0-9]+)$/)?.[1];
assert.ok(revision);
assert.match(await run("history", "actors/copy"), new RegExp(revision));
assert.match(await run("rollback", "actors/copy", revision), /saved source\/actors\/copy\.rn/);
const actor = await run("spawn", "actors/copy", "-5");
assert.match(actor, /^@[A-Za-z0-9_-]{21}$/);
assert.match(await run("ps"), new RegExp(actor.slice(1)));
assert.equal(await run("send", actor, '{"op":"add","value":7}'), "ok");
await run("tick", "100", "32");
assert.match(await run("inspect", actor), /Integer\(\s*2/);
for (const command of ["pause", "resume", "reload"]) {
await run(command, actor);
}
await run("become", actor, "actors/counter");
await run("tick", "100");
await run("stop", actor);
await run("tick", "100");
assert.match(await run("inspect", actor), /Stopped/);
await run("restart", actor);
await run("kill", actor);
assert.match(await run("events"), /Spawned/);
for (const args of [
["spawn", "actors/missing", "0"],
["spawn", "actors/counter", "invalid"],
["inspect", "not-an-actor"],
["actors"],
["cancel", actor],
]) {
await assert.rejects(run(...args));
}
assert.equal(await run("quit"), "");
assert.equal(await run("exit"), "");
assert.doesNotMatch(image.diagnostics(), /protocol error|reset without closing handshake/i);
},
);
test(
"the real REPL mutates the same WebSocket image and notifies the inspection client",
{ timeout: 10000 },
@ -34,7 +95,7 @@ test(
"../../../target/debug",
process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
);
const repl = spawn(executable, [], {
const repl = spawn(executable, ["repl"], {
env: { ...process.env, RUNA_CONNECT: image.address },
stdio: ["pipe", "pipe", "pipe"],
});
@ -72,8 +133,8 @@ test(
assert.deepEqual(actor.state, { type: "integer", data: 12n });
assert.deepEqual(actor.queued, []);
assert.equal(actor.reserved, null);
for (const input of ["actors\nquit\n", "exit\n", ""]) {
const overridden = spawn(executable, ["--connect", image.address], {
for (const input of ["ps\nquit\n", "exit\n", ""]) {
const overridden = spawn(executable, ["--connect", image.address, "repl"], {
env: { ...process.env, RUNA_CONNECT: "invalid" },
stdio: ["pipe", "pipe", "pipe"],
});