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:
parent
fcec43d44b
commit
750b709d35
|
|
@ -90,7 +90,7 @@ mise run format
|
||||||
mise run lint
|
mise run lint
|
||||||
mise run test
|
mise run test
|
||||||
mise run start
|
mise run start
|
||||||
mise run cli
|
mise run repl
|
||||||
mise run test-extension-host
|
mise run test-extension-host
|
||||||
mise run pre-commit
|
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
|
The workspace tests cover execution probes, actor runtime invariants, and the
|
||||||
interactive CLI. Extension tests include a real Rust-server WebSocket round trip.
|
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).
|
Both use Clap derive/env arguments (`RUNA_LISTEN` and `RUNA_CONNECT` respectively).
|
||||||
The separate `mise run test-extension-host`
|
The separate `mise run test-extension-host`
|
||||||
task verifies inspector commands in VS Code and requires a display (or
|
task verifies inspector commands in VS Code and requires a display (or
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### Fixed
|
||||||
|
|
||||||
- Complete the WebSocket close handshake when the CLI exits, preventing normal
|
- Complete the WebSocket close handshake when the CLI exits, preventing normal
|
||||||
|
|
|
||||||
26
README.md
26
README.md
|
|
@ -57,14 +57,14 @@ mise run start
|
||||||
In another terminal, connect the REPL:
|
In another terminal, connect the REPL:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mise run cli
|
mise run repl
|
||||||
```
|
```
|
||||||
|
|
||||||
The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint,
|
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
|
the corresponding `--listen` address. Quitting the REPL disconnects only that
|
||||||
client; actors and source files survive until the server exits.
|
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.
|
command history are session-local and are not restored on reconnect.
|
||||||
|
|
||||||
Both executables use Clap for `--help`, `--version`, and argument validation.
|
Both executables use Clap for `--help`, `--version`, and argument validation.
|
||||||
|
|
@ -75,9 +75,27 @@ For example:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
RUNA_LISTEN=127.0.0.1:4000 mise run start
|
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
|
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
|
`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
|
simultaneous clients sharing one live image. The listener rejects non-loopback
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,9 @@ impl ReplHelper {
|
||||||
|
|
||||||
fn candidates(&self, line: &str, position: usize) -> Vec<String> {
|
fn candidates(&self, line: &str, position: usize) -> Vec<String> {
|
||||||
const COMMANDS: &[&str] = &[
|
const COMMANDS: &[&str] = &[
|
||||||
"actors", "sources", "cancel", "events", "exit", "help", "history", "inspect",
|
"ps", "sources", "events", "exit", "help", "history", "inspect", "become", "kill",
|
||||||
"become", "kill", "reload", "write", "let", "pause", "commit", "quit", "restart",
|
"reload", "write", "let", "pause", "commit", "quit", "restart", "resume", "rollback",
|
||||||
"resume", "rollback", "send", "spawn", "stop", "tick",
|
"send", "spawn", "stop", "tick",
|
||||||
];
|
];
|
||||||
let (start, reference_value) = Self::completion_start(line, position);
|
let (start, reference_value) = Self::completion_start(line, position);
|
||||||
if reference_value {
|
if reference_value {
|
||||||
|
|
@ -101,7 +101,6 @@ impl ReplHelper {
|
||||||
| ["restart"]
|
| ["restart"]
|
||||||
| ["pause"]
|
| ["pause"]
|
||||||
| ["resume"]
|
| ["resume"]
|
||||||
| ["cancel"]
|
|
||||||
| ["kill"]
|
| ["kill"]
|
||||||
| ["reload"]
|
| ["reload"]
|
||||||
| ["become"] => self.actor_candidates(),
|
| ["become"] => self.actor_candidates(),
|
||||||
|
|
@ -183,18 +182,189 @@ struct Args {
|
||||||
/// Loopback address of the runa-debug WebSocket server.
|
/// Loopback address of the runa-debug WebSocket server.
|
||||||
#[arg(long, env = "RUNA_CONNECT", default_value = "127.0.0.1:3737")]
|
#[arg(long, env = "RUNA_CONNECT", default_value = "127.0.0.1:3737")]
|
||||||
connect: SocketAddr,
|
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>> {
|
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| {
|
let mut server = WebSocketClient::connect(address).map_err(|error| {
|
||||||
format!("cannot connect to {address}: {error}; start `mise run start` first")
|
format!("cannot connect to {address}: {error}; start `mise run start` first")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!("Runa — Rune Actors");
|
let result = if matches!(args.command, CliCommand::Repl) {
|
||||||
println!("Connected to {address}. The image remains running when you quit.");
|
println!("Runa — Rune Actors");
|
||||||
println!("Type `help` for commands. Use Up/Down for history and Tab for completion.");
|
println!("Connected to {address}. The image remains running when you quit.");
|
||||||
let result = run_interactive_repl(&mut server);
|
println!("Type `help` for commands. Use Up/Down for history and Tab for completion.");
|
||||||
|
run_interactive_repl(&mut server)
|
||||||
|
} else {
|
||||||
|
run_command(args.command, &mut server, &mut io::stdout().lock())
|
||||||
|
};
|
||||||
let close_result = server.close();
|
let close_result = server.close();
|
||||||
result?;
|
result?;
|
||||||
close_result?;
|
close_result?;
|
||||||
|
|
@ -312,7 +482,7 @@ fn execute_line<W: Write>(
|
||||||
"history" => history_command(output, args, server),
|
"history" => history_command(output, args, server),
|
||||||
"rollback" => rollback_command(output, args, server),
|
"rollback" => rollback_command(output, args, server),
|
||||||
"become" => become_command(output, args, server, state),
|
"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)) => {
|
Ok(Reply::Actors(actors)) => {
|
||||||
for actor in actors {
|
for actor in actors {
|
||||||
writeln!(output, "{actor:#?}")?;
|
writeln!(output, "{actor:#?}")?;
|
||||||
|
|
@ -322,7 +492,7 @@ fn execute_line<W: Write>(
|
||||||
Ok(other) => unexpected_reply(output, other),
|
Ok(other) => unexpected_reply(output, other),
|
||||||
Err(error) => writeln!(output, "error: {error}"),
|
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) {
|
"send" => match parse_send_args(line, state) {
|
||||||
Ok((actor_ref, message)) => {
|
Ok((actor_ref, message)) => {
|
||||||
match dispatch(server, Command::Send { actor_ref, message }) {
|
match dispatch(server, Command::Send { actor_ref, message }) {
|
||||||
|
|
@ -394,9 +564,6 @@ fn execute_line<W: Write>(
|
||||||
actor_ref,
|
actor_ref,
|
||||||
message: SystemMessageDto::Resume,
|
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" if args.is_empty() => return Ok(false),
|
||||||
"quit" | "exit" => writeln!(output, "error: usage: quit"),
|
"quit" | "exit" => writeln!(output, "error: usage: quit"),
|
||||||
_ => writeln!(output, "error: unknown command `{command}`; type `help`"),
|
_ => writeln!(output, "error: unknown command `{command}`; type `help`"),
|
||||||
|
|
@ -509,28 +676,11 @@ fn write_command<W: Write>(
|
||||||
let [path, source_path] = args else {
|
let [path, source_path] = args else {
|
||||||
return writeln!(output, "error: usage: write <path> <local-source-file>");
|
return writeln!(output, "error: usage: write <path> <local-source-file>");
|
||||||
};
|
};
|
||||||
let expected_source_id = match dispatch(server, Command::ListSources) {
|
let request = match source_write_request(path, std::path::Path::new(source_path), server) {
|
||||||
Ok(Reply::Sources(files)) => files
|
Ok(request) => request,
|
||||||
.into_iter()
|
|
||||||
.find(|file| file.path == *path)
|
|
||||||
.map(|file| file.source_id),
|
|
||||||
Ok(other) => return unexpected_reply(output, other),
|
|
||||||
Err(error) => return writeln!(output, "error: {error}"),
|
Err(error) => return writeln!(output, "error: {error}"),
|
||||||
};
|
};
|
||||||
let source = match std::fs::read_to_string(source_path) {
|
match dispatch(server, request) {
|
||||||
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,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Ok(Reply::Source(file)) => {
|
Ok(Reply::Source(file)) => {
|
||||||
writeln!(output, "saved source/{}.rn ({})", file.path, file.source_id)
|
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>(
|
fn commit_command<W: Write>(
|
||||||
output: &mut W,
|
output: &mut W,
|
||||||
args: &[&str],
|
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\
|
State/messages: JSON values; {{\"$actor\":\"name-or-@ref\"}} embeds an actor reference.\n\
|
||||||
tick <count> [instruction-budget]\n\
|
tick <count> [instruction-budget]\n\
|
||||||
inspect <actor-ref-or-name>\n\
|
inspect <actor-ref-or-name>\n\
|
||||||
actors\n\
|
ps\n\
|
||||||
events\n\
|
events\n\
|
||||||
stop <actor-ref-or-name>\n\
|
stop <actor-ref-or-name>\n\
|
||||||
kill <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\
|
restart <actor-ref-or-name>\n\
|
||||||
pause <actor-ref-or-name>\n\
|
pause <actor-ref-or-name>\n\
|
||||||
resume <actor-ref-or-name>\n\
|
resume <actor-ref-or-name>\n\
|
||||||
cancel <actor-ref-or-name>\n\
|
|
||||||
help\n\
|
help\n\
|
||||||
quit"
|
quit"
|
||||||
)
|
)
|
||||||
|
|
@ -789,6 +959,91 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn value_parser_preserves_json_shapes_and_distinguishes_references() {
|
fn value_parser_preserves_json_shapes_and_distinguishes_references() {
|
||||||
let reference = ActorRef::new();
|
let reference = ActorRef::new();
|
||||||
|
|
@ -893,7 +1148,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(connect.get_default_values(), ["127.0.0.1:3737"]);
|
assert_eq!(connect.get_default_values(), ["127.0.0.1:3737"]);
|
||||||
assert_eq!(
|
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()
|
.unwrap()
|
||||||
.connect,
|
.connect,
|
||||||
"[::1]:4000".parse::<SocketAddr>().unwrap()
|
"[::1]:4000".parse::<SocketAddr>().unwrap()
|
||||||
|
|
@ -972,7 +1227,7 @@ mod tests {
|
||||||
let mut client = WebSocketClient::connect(address)?;
|
let mut client = WebSocketClient::connect(address)?;
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
run_repl(
|
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 output,
|
||||||
&mut client,
|
&mut client,
|
||||||
)?;
|
)?;
|
||||||
|
|
@ -1062,7 +1317,7 @@ mod tests {
|
||||||
"/../../runa-std/actors/counter.rn"
|
"/../../runa-std/actors/counter.rn"
|
||||||
);
|
);
|
||||||
let input = io::Cursor::new(format!(
|
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();
|
let mut output = Vec::new();
|
||||||
run_repl(input, &mut output, &mut server)?;
|
run_repl(input, &mut output, &mut server)?;
|
||||||
|
|
@ -1073,7 +1328,7 @@ mod tests {
|
||||||
assert!(output.contains("scheduled become `actors/counter` on @"));
|
assert!(output.contains("scheduled become `actors/counter` on @"));
|
||||||
assert!(output.contains("Paused {"));
|
assert!(output.contains("Paused {"));
|
||||||
assert!(output.contains("Resumed {"));
|
assert!(output.contains("Resumed {"));
|
||||||
assert!(output.contains("InvocationCancelled {"));
|
assert!(output.contains("Stopped {"));
|
||||||
let history = match dispatch(
|
let history = match dispatch(
|
||||||
&mut server,
|
&mut server,
|
||||||
Command::SourceHistory {
|
Command::SourceHistory {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ fn cli_help_and_version_are_available_without_connecting() {
|
||||||
assert!(help.contains("--connect"));
|
assert!(help.contains("--connect"));
|
||||||
assert!(help.contains("RUNA_CONNECT"));
|
assert!(help.contains("RUNA_CONNECT"));
|
||||||
assert!(help.contains("127.0.0.1:3737"));
|
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"))
|
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
|
|
@ -32,6 +45,11 @@ fn clap_rejects_bad_flags_addresses_and_environment_values() {
|
||||||
vec!["--connect", "invalid"],
|
vec!["--connect", "invalid"],
|
||||||
vec!["--listen", "127.0.0.1:3737"],
|
vec!["--listen", "127.0.0.1:3737"],
|
||||||
vec!["extra"],
|
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"))
|
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
|
||||||
.args(args)
|
.args(args)
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,13 @@ Start the server-owned, in-memory image:
|
||||||
mise run start
|
mise run start
|
||||||
```
|
```
|
||||||
|
|
||||||
Then connect from another terminal with `mise run cli`, or use
|
Then connect from another terminal with `mise run repl`, or use
|
||||||
`cargo run -p runa-cli -- --connect <loopback-address>` for a custom listener.
|
`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`
|
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
|
select another loopback address. The REPL (`runa-cli`) accepts `--connect` or
|
||||||
`RUNA_CONNECT`, defaulting to the same address. Explicit flags override environment
|
`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
|
The WebSocket server loads [counter.rn](../runa-std/actors/counter.rn) into the
|
||||||
image's working `source/actors/counter.rn`. Commands include `spawn`, `send`,
|
image's working `source/actors/counter.rn`. Commands include `spawn`, `send`,
|
||||||
`tick`, `inspect`, `actors`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`,
|
`tick`, `inspect`, `ps`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`,
|
||||||
`reload`, `become`, `cancel`, `sources`, `write`, `commit`, `history`, and
|
`reload`, `become`, `sources`, `write`, `commit`, `history`, and
|
||||||
`rollback`; enter `help` to see usage. Paths exclude the root and extension:
|
`rollback`; enter `help` to see usage. Paths exclude the root and extension:
|
||||||
`spawn actors/counter 1`. Spawn compiles the current working source, not a
|
`spawn actors/counter 1`. Spawn compiles the current working source, not a
|
||||||
committed revision. `write <path> <local-file>` updates working source using
|
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
|
are execution-local diagnostics only; they do not map to source locations or
|
||||||
expose locals, and disappear when the invocation completes or is cancelled.
|
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
|
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
|
`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
|
messages that were already queued. Committed state is unchanged and no
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ run = "npm --prefix vscode run test:host"
|
||||||
description = "Start the Runa image server on localhost"
|
description = "Start the Runa image server on localhost"
|
||||||
run = "cargo run -p runa"
|
run = "cargo run -p runa"
|
||||||
|
|
||||||
[tasks.cli]
|
[tasks.repl]
|
||||||
description = "Connect the Runa CLI to the image server"
|
description = "Start the Runa REPL connected to the image server"
|
||||||
run = "cargo run -p runa-cli"
|
run = "cargo run -p runa-cli -- repl"
|
||||||
|
|
||||||
[tasks.lint]
|
[tasks.lint]
|
||||||
description = "Check Rust targets and the TypeScript extension"
|
description = "Check Rust targets and the TypeScript extension"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,72 @@
|
||||||
import assert from "node:assert/strict";
|
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 { once } from "node:events";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { promisify } from "node:util";
|
||||||
import { ImageClient } from "../src/client";
|
import { ImageClient } from "../src/client";
|
||||||
import { startImage } from "./server";
|
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(
|
test(
|
||||||
"the real REPL mutates the same WebSocket image and notifies the inspection client",
|
"the real REPL mutates the same WebSocket image and notifies the inspection client",
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
|
|
@ -34,7 +95,7 @@ test(
|
||||||
"../../../target/debug",
|
"../../../target/debug",
|
||||||
process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
|
process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
|
||||||
);
|
);
|
||||||
const repl = spawn(executable, [], {
|
const repl = spawn(executable, ["repl"], {
|
||||||
env: { ...process.env, RUNA_CONNECT: image.address },
|
env: { ...process.env, RUNA_CONNECT: image.address },
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
@ -72,8 +133,8 @@ test(
|
||||||
assert.deepEqual(actor.state, { type: "integer", data: 12n });
|
assert.deepEqual(actor.state, { type: "integer", data: 12n });
|
||||||
assert.deepEqual(actor.queued, []);
|
assert.deepEqual(actor.queued, []);
|
||||||
assert.equal(actor.reserved, null);
|
assert.equal(actor.reserved, null);
|
||||||
for (const input of ["actors\nquit\n", "exit\n", ""]) {
|
for (const input of ["ps\nquit\n", "exit\n", ""]) {
|
||||||
const overridden = spawn(executable, ["--connect", image.address], {
|
const overridden = spawn(executable, ["--connect", image.address, "repl"], {
|
||||||
env: { ...process.env, RUNA_CONNECT: "invalid" },
|
env: { ...process.env, RUNA_CONNECT: "invalid" },
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue