feat!: rename server and CLI and adopt Clap arguments

Rename the server crate and executable to runa and the REPL to runa-cli. Parse executable arguments with Clap derive/env, default the server to loopback port 3737, and support RUNA_LISTEN and RUNA_CONNECT with explicit flag precedence.

Remove the stdio transport and child-process client. Make mise start launch the server and mise cli launch the REPL, updating extension integration runners and documentation.

BREAKING CHANGE: runa now starts the WebSocket image server rather than the REPL; use runa-cli for the REPL. The runa-server package, stdio transport, LocalClient, ProtocolClient, and serve/serve-network mise tasks are removed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 15:49:35 +01:00
parent 72a37b178c
commit b63666bcc2
25 changed files with 1585 additions and 1732 deletions

View File

@ -60,7 +60,8 @@ proposed capabilities as plans, not existing behavior.
## Repository structure ## Repository structure
- `Cargo.toml`: Rust workspace definition. - `Cargo.toml`: Rust workspace definition.
- `crates/runa/`: current executable crate and entry point. - `crates/runa/`: image server, protocol dispatch, and loopback WebSocket transport.
- `crates/runa-cli/`: interactive REPL executable.
- `crates/runa-core/`: stable identity types shared across layers. - `crates/runa-core/`: stable identity types shared across layers.
- `crates/runa-rune/`: Phase 0 Rune compilation and execution experiments. - `crates/runa-rune/`: Phase 0 Rune compilation and execution experiments.
- `crates/runa-code/`: source identities and in-memory Rune compile cache. - `crates/runa-code/`: source identities and in-memory Rune compile cache.
@ -68,8 +69,7 @@ proposed capabilities as plans, not existing behavior.
- `crates/runa-runtime/`: single-worker scheduling and in-memory transitions. - `crates/runa-runtime/`: single-worker scheduling and in-memory transitions.
- `crates/runa-image-api/`: trusted in-process services for operating on an image. - `crates/runa-image-api/`: trusted in-process services for operating on an image.
- `crates/runa-protocol/`: versioned transport-neutral request, response, and event DTOs. - `crates/runa-protocol/`: versioned transport-neutral request, response, and event DTOs.
- `crates/runa-server/`: protocol dispatch, JSON-lines stdio, and loopback WebSockets. - `crates/runa-client/`: runa-debug WebSocket client.
- `crates/runa-client/`: stdio child-process and runa-debug WebSocket clients.
- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI. - `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.
@ -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 serve mise run cli
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` opens the local CLI. The separate `mise run test-extension-host` `mise run start` starts the image server; `mise run cli` opens the REPL.
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 task verifies inspector commands in VS Code and requires a display (or
`xvfb-run -a mise run test-extension-host` on headless Linux). `xvfb-run -a mise run test-extension-host` on headless Linux).

View File

@ -90,6 +90,12 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- Renamed the server executable/crate from `runa-server` to `runa`, and the REPL
from `runa` to `runa-cli`. The server now listens on `127.0.0.1:3737` by default.
- Clap derive/env argument parsing for both executables, including generated
help/version, `RUNA_LISTEN` / `RUNA_CONNECT`, and flag-over-environment precedence.
- `mise run start` now starts the server; `mise run cli` launches the REPL.
- Replaced the plain TCP JSON-lines transport with loopback-only WebSockets - Replaced the plain TCP JSON-lines transport with loopback-only WebSockets
using the required `runa-debug` subprotocol in the server, REPL, and VS Code. using the required `runa-debug` subprotocol in the server, REPL, and VS Code.
Stdio framing and version 3 request/response shapes are unchanged. Stdio framing and version 3 request/response shapes are unchanged.
@ -114,3 +120,8 @@ All notable changes to this project will be documented in this file.
boundaries, and moved it to `docs/architecture.md`. boundaries, and moved it to `docs/architecture.md`.
- Required agents to pass `mise run pre-commit` before every commit and document - Required agents to pass `mise run pre-commit` before every commit and document
user-visible changes in this changelog. user-visible changes in this changelog.
### Removed
- Stdio protocol transport and child-process client; external clients now use
runa-debug WebSockets exclusively. Removed the `serve` and `serve-network` mise tasks.

160
Cargo.lock generated
View File

@ -23,6 +23,56 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@ -97,6 +147,46 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.3" version = "5.4.3"
@ -116,6 +206,12 @@ dependencies = [
"unicode-width 0.1.14", "unicode-width 0.1.14",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.4.2" version = "0.4.2"
@ -302,6 +398,12 @@ dependencies = [
"wasip2", "wasip2",
] ]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "home" name = "home"
version = "0.5.12" version = "0.5.12"
@ -327,6 +429,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@ -549,6 +657,12 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.13" version = "1.1.13"
@ -704,13 +818,16 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
name = "runa" name = "runa"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"clap",
"futures-util",
"runa-actor",
"runa-client", "runa-client",
"runa-core", "runa-code",
"runa-image-api",
"runa-protocol", "runa-protocol",
"runa-server",
"rustyline",
"serde_json", "serde_json",
"tungstenite", "tokio",
"tokio-tungstenite",
] ]
[[package]] [[package]]
@ -720,6 +837,20 @@ dependencies = [
"runa-core", "runa-core",
] ]
[[package]]
name = "runa-cli"
version = "0.0.0"
dependencies = [
"clap",
"runa",
"runa-client",
"runa-core",
"runa-protocol",
"rustyline",
"serde_json",
"tungstenite",
]
[[package]] [[package]]
name = "runa-client" name = "runa-client"
version = "0.0.0" version = "0.0.0"
@ -785,21 +916,6 @@ dependencies = [
"rune", "rune",
] ]
[[package]]
name = "runa-server"
version = "0.0.0"
dependencies = [
"futures-util",
"runa-actor",
"runa-client",
"runa-code",
"runa-image-api",
"runa-protocol",
"serde_json",
"tokio",
"tokio-tungstenite",
]
[[package]] [[package]]
name = "rune" name = "rune"
version = "0.14.2" version = "0.14.2"
@ -1043,6 +1159,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"

View File

@ -3,6 +3,7 @@ resolver = "3"
members = [ members = [
"crates/runa", "crates/runa",
"crates/runa-actor", "crates/runa-actor",
"crates/runa-cli",
"crates/runa-client", "crates/runa-client",
"crates/runa-code", "crates/runa-code",
"crates/runa-core", "crates/runa-core",
@ -10,5 +11,7 @@ members = [
"crates/runa-protocol", "crates/runa-protocol",
"crates/runa-rune", "crates/runa-rune",
"crates/runa-runtime", "crates/runa-runtime",
"crates/runa-server",
] ]
[workspace.dependencies]
clap = { version = "4.5", features = ["derive", "env"] }

View File

@ -21,9 +21,9 @@ documents. The code library now supports an in-memory versioned behavior
registry, and the CLI exposes publishing, history, rollback, safe-boundary registry, and the CLI exposes publishing, history, rollback, safe-boundary
installation, pause/resume, and cancellation through a trusted in-process image installation, pause/resume, and cancellation through a trusted in-process image
API. A versioned transport-neutral protocol contract is now defined, but no API. A versioned transport-neutral protocol contract is now defined, but no
authentication is implemented. The dispatcher is available through local authentication is implemented. The `runa` server provides an unauthenticated
JSON-lines stdio and an unauthenticated WebSocket listener restricted to localhost; WebSocket listener restricted to localhost, with bounded request sizes.
both use a bounded request size. The interactive CLI and VS Code connect with The `runa-cli` REPL and VS Code connect with
the `runa-debug` WebSocket subprotocol, so clients share one server-owned image. the `runa-debug` WebSocket subprotocol, so clients share one server-owned image.
Server change notifications automatically refresh VS Code's actor tree and inspectors. Server change notifications automatically refresh VS Code's actor tree and inspectors.
@ -51,33 +51,40 @@ You need a Rust toolchain that supports the workspace's Rust 2024 edition.
Start the live image from the repository root: Start the live image from the repository root:
```sh ```sh
mise run serve-network mise run start
``` ```
In another terminal, connect the REPL: In another terminal, connect the REPL:
```sh ```sh
mise run start mise run cli
``` ```
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 -- --connect 127.0.0.1:4000` and start the server with use `cargo run -p runa-cli -- --connect 127.0.0.1:4000` 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 published behaviors survive until the server exits. client; actors and published behaviors survive until the server exits.
Reconnect and use `actors` to recover their references. REPL bindings and Reconnect and use `actors` 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.
For a local child-process protocol endpoint, run `mise run serve` and exchange Both executables use Clap for `--help`, `--version`, and argument validation.
one JSON request per line on stdin; responses are written to stdout. Request `runa` listens on `127.0.0.1:3737` without arguments. Set `RUNA_LISTEN` to change
lines are limited to 1 MiB. the server address and `RUNA_CONNECT` to change the REPL address; explicit
`--listen` and `--connect` flags override their respective environment variables.
For example:
For a WebSocket endpoint, run `mise run serve-network`. It listens on ```sh
RUNA_LISTEN=127.0.0.1:4000 mise run start
RUNA_CONNECT=127.0.0.1:4000 mise run cli
```
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
addresses and has no authentication; any local process that can connect has addresses and has no authentication; any local process that can connect has
full access to the development API. Use it only on a trusted local machine. full access to the development API. Use it only on a trusted local machine.
The `runa-client` crate can launch the stdio process or connect over loopback The `runa-client` crate connects over loopback
WebSockets and exchange typed protocol requests with request/response correlation. WebSockets and exchange typed protocol requests with request/response correlation.
Network connection establishment is limited to 5 seconds and response waits/writes to 30 Network connection establishment is limited to 5 seconds and response waits/writes to 30
seconds. Transport failures disconnect the client and are never automatically seconds. Transport failures disconnect the client and are never automatically
@ -130,7 +137,7 @@ the runtime contract.
With Node.js 22+ and npm installed, run `npm --prefix vscode ci`. In VS Code, With Node.js 22+ and npm installed, run `npm --prefix vscode ci`. In VS Code,
open this repository and launch **Run Runa Extension** from Run and Debug open this repository and launch **Run Runa Extension** from Run and Debug
(F5). Start `mise run serve-network`, then use **Runa: Connect to Local Image** (F5). Start `mise run start`, then use **Runa: Connect to Local Image**
in the Extension Development Host. The **Runa Actors** view in Explorer lists in the Extension Development Host. The **Runa Actors** view in Explorer lists
actors created through the REPL or another client. Click an actor to inspect actors created through the REPL or another client. Click an actor to inspect
it. Server notifications automatically refresh the tree and open snapshots; it. Server notifications automatically refresh the tree and open snapshots;
@ -174,7 +181,8 @@ use `xvfb-run -a mise run test-extension-host`.
. .
├── Cargo.toml ├── Cargo.toml
├── crates/ ├── crates/
│ ├── runa/ # Main binary │ ├── runa/ # Image server, protocol dispatcher, WebSocket transport
│ ├── runa-cli/ # Interactive REPL client
│ ├── runa-core/ # Shared stable identities and portable actor values │ ├── runa-core/ # Shared stable identities and portable actor values
│ ├── runa-actor/ # Actor value contracts │ ├── runa-actor/ # Actor value contracts
│ ├── runa-code/ # Source identities and compile cache │ ├── runa-code/ # Source identities and compile cache
@ -182,8 +190,7 @@ use `xvfb-run -a mise run test-extension-host`.
│ ├── runa-runtime/ # Single-worker actor prototype │ ├── runa-runtime/ # Single-worker actor prototype
│ ├── runa-image-api/ # Trusted in-process image operations │ ├── runa-image-api/ # Trusted in-process image operations
│ ├── runa-protocol/ # Versioned transport-neutral wire DTOs │ ├── runa-protocol/ # Versioned transport-neutral wire DTOs
│ ├── runa-server/ # Protocol dispatcher and local JSON-lines transport │ └── runa-client/ # Loopback WebSocket client
│ └── runa-client/ # Stdio child-process and loopback WebSocket clients
├── docs/ ├── docs/
│ ├── architecture.md │ ├── architecture.md
│ ├── protocol.md │ ├── protocol.md

View File

@ -0,0 +1,16 @@
[package]
name = "runa-cli"
version = "0.0.0"
edition = "2024"
[dependencies]
clap.workspace = true
runa-client = { path = "../runa-client" }
runa-core = { path = "../runa-core" }
runa-protocol = { path = "../runa-protocol" }
rustyline = "15.0"
serde_json = "1"
[dev-dependencies]
runa = { path = "../runa" }
tungstenite = "0.28"

1125
crates/runa-cli/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
use std::process::Command;
#[test]
fn cli_help_and_version_are_available_without_connecting() {
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
.arg("--help")
.env_remove("RUNA_CONNECT")
.output()
.unwrap();
assert!(output.status.success());
let help = String::from_utf8(output.stdout).unwrap();
assert!(help.contains("runa-cli"));
assert!(help.contains("--connect"));
assert!(help.contains("RUNA_CONNECT"));
assert!(help.contains("127.0.0.1:3737"));
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
.arg("--version")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
String::from_utf8(output.stdout).unwrap().trim(),
"runa-cli 0.0.0"
);
}
#[test]
fn clap_rejects_bad_flags_addresses_and_environment_values() {
for args in [
vec!["--connect"],
vec!["--connect", "invalid"],
vec!["--listen", "127.0.0.1:3737"],
vec!["extra"],
] {
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
.args(args)
.env_remove("RUNA_CONNECT")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(!output.stderr.is_empty());
}
let output = Command::new(env!("CARGO_BIN_EXE_runa-cli"))
.env("RUNA_CONNECT", "invalid")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
assert!(
String::from_utf8(output.stderr)
.unwrap()
.contains("--connect")
);
}

View File

@ -1,10 +1,8 @@
//! Stdio and loopback WebSocket clients for the versioned protocol. //! Loopback WebSocket client for the versioned protocol.
use std::ffi::OsStr;
use std::fmt; use std::fmt;
use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::io;
use std::net::{Shutdown, SocketAddr, TcpStream}; use std::net::{Shutdown, SocketAddr, TcpStream};
use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use runa_protocol::{ use runa_protocol::{
@ -14,18 +12,7 @@ use runa_protocol::{
use tungstenite::client::IntoClientRequest; use tungstenite::client::IntoClientRequest;
use tungstenite::{Message, WebSocket, protocol::WebSocketConfig}; use tungstenite::{Message, WebSocket, protocol::WebSocketConfig};
pub const MAX_MESSAGE_LINE_BYTES: usize = 1024 * 1024; pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
pub struct LocalClient {
child: Child,
client: Option<ProtocolClient<BufReader<ChildStdout>, BufWriter<ChildStdin>>>,
}
pub struct ProtocolClient<R, W> {
reader: R,
writer: W,
next_request_id: u64,
}
pub struct WebSocketClient { pub struct WebSocketClient {
socket: WebSocket<TcpStream>, socket: WebSocket<TcpStream>,
@ -54,9 +41,9 @@ impl WebSocketClient {
WEBSOCKET_SUBPROTOCOL.parse().map_err(io::Error::other)?, WEBSOCKET_SUBPROTOCOL.parse().map_err(io::Error::other)?,
); );
let config = WebSocketConfig::default() let config = WebSocketConfig::default()
.max_message_size(Some(MAX_MESSAGE_LINE_BYTES)) .max_message_size(Some(MAX_MESSAGE_BYTES))
.max_frame_size(Some(MAX_MESSAGE_LINE_BYTES)) .max_frame_size(Some(MAX_MESSAGE_BYTES))
.max_write_buffer_size(2 * MAX_MESSAGE_LINE_BYTES); .max_write_buffer_size(2 * MAX_MESSAGE_BYTES);
let (socket, response) = let (socket, response) =
tungstenite::client::client_with_config(request, stream, Some(config)) tungstenite::client::client_with_config(request, stream, Some(config))
.map_err(io::Error::other)?; .map_err(io::Error::other)?;
@ -114,7 +101,7 @@ impl WebSocketClient {
request_id: request_id.clone(), request_id: request_id.clone(),
command, command,
})?; })?;
if encoded.len() > MAX_MESSAGE_LINE_BYTES { if encoded.len() > MAX_MESSAGE_BYTES {
return Err(ClientError::RequestTooLarge); return Err(ClientError::RequestTooLarge);
} }
self.socket self.socket
@ -180,7 +167,6 @@ pub enum ClientError {
Serialization(serde_json::Error), Serialization(serde_json::Error),
RequestIdsExhausted, RequestIdsExhausted,
RequestTooLarge, RequestTooLarge,
ResponseTooLarge,
UnsupportedVersion(u16), UnsupportedVersion(u16),
CorrelationMismatch { expected: String, actual: String }, CorrelationMismatch { expected: String, actual: String },
} }
@ -193,11 +179,7 @@ impl fmt::Display for ClientError {
Self::RequestIdsExhausted => write!(formatter, "protocol request IDs exhausted"), Self::RequestIdsExhausted => write!(formatter, "protocol request IDs exhausted"),
Self::RequestTooLarge => write!( Self::RequestTooLarge => write!(
formatter, formatter,
"protocol request exceeds {MAX_MESSAGE_LINE_BYTES} bytes" "protocol request exceeds {MAX_MESSAGE_BYTES} bytes"
),
Self::ResponseTooLarge => write!(
formatter,
"protocol response exceeds {MAX_MESSAGE_LINE_BYTES} bytes"
), ),
Self::UnsupportedVersion(version) => { Self::UnsupportedVersion(version) => {
write!( write!(
@ -235,144 +217,10 @@ impl From<serde_json::Error> for ClientError {
} }
} }
impl LocalClient {
pub fn spawn(program: impl AsRef<OsStr>, args: &[impl AsRef<OsStr>]) -> io::Result<Self> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take().ok_or_else(|| {
io::Error::other("child stdin was not available after enabling piped stdin")
})?;
let stdout = child.stdout.take().ok_or_else(|| {
io::Error::other("child stdout was not available after enabling piped stdout")
})?;
Ok(Self {
child,
client: Some(ProtocolClient::new(
BufReader::new(stdout),
BufWriter::new(stdin),
)),
})
}
pub fn request(&mut self, command: ProtocolCommand) -> Result<Response, ClientError> {
self.client
.as_mut()
.ok_or_else(|| {
ClientError::Io(io::Error::new(
io::ErrorKind::BrokenPipe,
"client is shut down",
))
})?
.request(command)
}
pub fn shutdown(mut self) -> io::Result<ExitStatus> {
self.client.take();
self.child.wait()
}
}
impl<R: BufRead, W: Write> ProtocolClient<R, W> {
pub fn new(reader: R, writer: W) -> Self {
Self {
reader,
writer,
next_request_id: 0,
}
}
pub fn request(&mut self, command: ProtocolCommand) -> Result<Response, ClientError> {
self.next_request_id = self
.next_request_id
.checked_add(1)
.ok_or(ClientError::RequestIdsExhausted)?;
let request_id = format!("runa-client-{}", self.next_request_id);
let request = Request {
version: PROTOCOL_VERSION,
request_id: request_id.clone(),
command,
};
let encoded = serde_json::to_vec(&request)?;
if encoded.len() > MAX_MESSAGE_LINE_BYTES {
return Err(ClientError::RequestTooLarge);
}
self.writer.write_all(&encoded)?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
let mut line = Vec::new();
let has_line = read_bounded_line(&mut self.reader, &mut line).map_err(|error| {
if error.kind() == io::ErrorKind::InvalidData {
ClientError::ResponseTooLarge
} else {
ClientError::Io(error)
}
})?;
if !has_line {
return Err(ClientError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
"server closed the connection before responding",
)));
}
let response: Response = serde_json::from_slice(&line)?;
if response.version != PROTOCOL_VERSION {
return Err(ClientError::UnsupportedVersion(response.version));
}
if response.request_id != request_id {
return Err(ClientError::CorrelationMismatch {
expected: request_id,
actual: response.request_id,
});
}
Ok(response)
}
}
impl Drop for LocalClient {
fn drop(&mut self) {
if self.child.try_wait().ok().flatten().is_none() {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
fn read_bounded_line<R: BufRead>(reader: &mut R, line: &mut Vec<u8>) -> io::Result<bool> {
line.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(!line.is_empty());
}
let newline = available.iter().position(|byte| *byte == b'\n');
let payload_len = newline.unwrap_or(available.len());
if line.len().saturating_add(payload_len) > MAX_MESSAGE_LINE_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("response line exceeds {MAX_MESSAGE_LINE_BYTES} bytes"),
));
}
line.extend_from_slice(&available[..payload_len]);
let consumed = payload_len + usize::from(newline.is_some());
reader.consume(consumed);
if newline.is_some() {
return Ok(true);
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use runa_protocol::{ProtocolError, Reply}; use runa_protocol::Reply;
use std::io::Cursor;
struct DebugProtocol; struct DebugProtocol;
impl tungstenite::handshake::server::Callback for DebugProtocol { impl tungstenite::handshake::server::Callback for DebugProtocol {
@ -487,76 +335,4 @@ mod tests {
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::NotConnected)); Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::NotConnected));
peer.join().unwrap(); peer.join().unwrap();
} }
fn response(request_id: &str) -> Vec<u8> {
let response = Response {
version: PROTOCOL_VERSION,
request_id: request_id.to_owned(),
result: Ok(Reply::Acknowledged),
};
let mut bytes = serde_json::to_vec(&response).unwrap();
bytes.push(b'\n');
bytes
}
#[test]
fn client_writes_correlated_requests_and_validates_responses() {
let mut written = Vec::new();
let mut client = ProtocolClient::new(Cursor::new(response("runa-client-1")), &mut written);
let result = client.request(ProtocolCommand::GetBehaviors).unwrap();
assert_eq!(result.request_id, "runa-client-1");
assert_eq!(result.result, Ok(Reply::Acknowledged));
let request: Request = serde_json::from_slice(
written
.strip_suffix(b"\n")
.expect("client writes a newline"),
)
.unwrap();
assert_eq!(request.request_id, "runa-client-1");
assert_eq!(request.command, ProtocolCommand::GetBehaviors);
}
#[test]
fn client_rejects_mismatched_response_ids_and_versions() {
let mut client = ProtocolClient::new(Cursor::new(response("wrong-id")), Vec::new());
assert!(matches!(
client.request(ProtocolCommand::GetBehaviors),
Err(ClientError::CorrelationMismatch { .. })
));
let mut unsupported = serde_json::to_vec(&Response {
version: PROTOCOL_VERSION + 1,
request_id: "runa-client-1".to_owned(),
result: Err(ProtocolError::new(
runa_protocol::ErrorCode::UnsupportedVersion,
"unsupported",
)),
})
.unwrap();
unsupported.push(b'\n');
let mut client = ProtocolClient::new(Cursor::new(unsupported), Vec::new());
assert!(matches!(
client.request(ProtocolCommand::GetBehaviors),
Err(ClientError::UnsupportedVersion(version)) if version == PROTOCOL_VERSION + 1
));
}
#[test]
fn client_enforces_request_and_response_size_limits() {
let mut client = ProtocolClient::new(Cursor::new(response("runa-client-1")), Vec::new());
let error = client
.request(ProtocolCommand::PublishBehavior {
name: "large".to_owned(),
expected_revision: None,
source: "x".repeat(MAX_MESSAGE_LINE_BYTES),
})
.unwrap_err();
assert!(matches!(error, ClientError::RequestTooLarge));
let oversized_response = vec![b'x'; MAX_MESSAGE_LINE_BYTES + 1];
let mut client = ProtocolClient::new(Cursor::new(oversized_response), Vec::new());
let error = client.request(ProtocolCommand::GetBehaviors).unwrap_err();
assert!(matches!(error, ClientError::ResponseTooLarge));
}
} }

View File

@ -1,17 +0,0 @@
[package]
name = "runa-server"
version = "0.0.0"
edition = "2024"
[dependencies]
futures-util = { version = "0.3", features = ["sink"] }
runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" }
runa-image-api = { path = "../runa-image-api" }
runa-protocol = { path = "../runa-protocol" }
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt", "sync", "time"] }
tokio-tungstenite = "0.28"
[dev-dependencies]
runa-client = { path = "../runa-client" }

View File

@ -1,71 +0,0 @@
use std::io;
use std::net::SocketAddr;
use std::process::ExitCode;
use runa_protocol::{Command, PROTOCOL_VERSION, Request};
use runa_server::{Server, bind_loopback_listener, serve_json_lines, serve_websocket};
fn initial_image() -> io::Result<Server> {
let mut server = Server::new();
server
.dispatch(Request {
version: PROTOCOL_VERSION,
request_id: "bootstrap-counter".to_owned(),
command: Command::PublishBehavior {
name: "counter".to_owned(),
expected_revision: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
},
})
.result
.map_err(|error| io::Error::other(error.message))?;
Ok(server)
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("runa-server: {error}");
ExitCode::FAILURE
}
}
}
fn run() -> io::Result<()> {
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
None => serve_stdio(),
Some("--listen") => {
let address = args.next().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"usage: runa-server [--listen <loopback-address>]",
)
})?;
if args.next().is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: runa-server [--listen <loopback-address>]",
));
}
let address = address.parse::<SocketAddr>().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid listener address: {error}"),
)
})?;
serve_websocket(bind_loopback_listener(address)?, initial_image()?)
}
Some(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"usage: runa-server [--listen <loopback-address>]",
)),
}
}
fn serve_stdio() -> io::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
serve_json_lines(&mut Server::new(), stdin.lock(), stdout.lock())
}

View File

@ -4,12 +4,15 @@ version = "0.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
runa-client = { path = "../runa-client" } clap.workspace = true
runa-core = { path = "../runa-core" } futures-util = { version = "0.3", features = ["sink"] }
runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" }
runa-image-api = { path = "../runa-image-api" }
runa-protocol = { path = "../runa-protocol" } runa-protocol = { path = "../runa-protocol" }
rustyline = "15.0"
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt", "sync", "time"] }
tokio-tungstenite = "0.28"
[dev-dependencies] [dev-dependencies]
runa-server = { path = "../runa-server" } runa-client = { path = "../runa-client" }
tungstenite = "0.28"

View File

@ -1,6 +1,6 @@
//! Transport-neutral dispatch from protocol requests to image API operations. //! Transport-neutral dispatch from protocol requests to image API operations.
use std::io::{self, BufRead, Write}; use std::io;
use std::net::{SocketAddr, TcpListener}; use std::net::{SocketAddr, TcpListener};
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
@ -14,24 +14,12 @@ use runa_protocol::{
EventDto, ExecutionSnapshotDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, Response, EventDto, ExecutionSnapshotDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, Response,
}; };
pub const MAX_REQUEST_LINE_BYTES: usize = 1024 * 1024; pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
pub struct Server { pub struct Server {
image: ImageApi, image: ImageApi,
} }
/// Serve newline-delimited protocol requests over an already-connected stream.
///
/// Each valid request line receives one JSON response line. Malformed or
/// oversized input terminates the stream with `InvalidData`.
pub fn serve_json_lines<R: BufRead, W: Write>(
server: &mut Server,
reader: R,
writer: W,
) -> io::Result<()> {
serve_json_lines_with(reader, writer, |request| Ok(server.dispatch(request)))
}
/// Bind the protocol listener to an IPv4 or IPv6 loopback address only. /// Bind the protocol listener to an IPv4 or IPv6 loopback address only.
pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> { pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> {
if !address.ip().is_loopback() { if !address.ip().is_loopback() {
@ -44,54 +32,6 @@ pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> {
TcpListener::bind(address) TcpListener::bind(address)
} }
fn invalid_data(message: String) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
fn serve_json_lines_with<R, W, F>(mut reader: R, mut writer: W, mut dispatch: F) -> io::Result<()>
where
R: BufRead,
W: Write,
F: FnMut(Request) -> io::Result<Response>,
{
let mut line = Vec::new();
while read_bounded_line(&mut reader, &mut line)? {
let request: Request = serde_json::from_slice(&line)
.map_err(|error| invalid_data(format!("invalid protocol request JSON: {error}")))?;
let response = dispatch(request)?;
serde_json::to_writer(&mut writer, &response).map_err(io::Error::other)?;
writer.write_all(b"\n")?;
writer.flush()?;
}
Ok(())
}
fn read_bounded_line<R: BufRead>(reader: &mut R, line: &mut Vec<u8>) -> io::Result<bool> {
line.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(!line.is_empty());
}
let newline = available.iter().position(|byte| *byte == b'\n');
let payload_len = newline.unwrap_or(available.len());
if line.len().saturating_add(payload_len) > MAX_REQUEST_LINE_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("request line exceeds {MAX_REQUEST_LINE_BYTES} bytes"),
));
}
line.extend_from_slice(&available[..payload_len]);
let consumed = payload_len + usize::from(newline.is_some());
reader.consume(consumed);
if newline.is_some() {
return Ok(true);
}
}
}
impl Default for Server { impl Default for Server {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@ -390,7 +330,6 @@ mod tests {
} }
use runa_protocol::{Command, PROTOCOL_VERSION}; use runa_protocol::{Command, PROTOCOL_VERSION};
use std::io::Cursor;
fn request(command: Command) -> Request { fn request(command: Command) -> Request {
Request { Request {
@ -532,71 +471,6 @@ mod tests {
)); ));
} }
#[test]
fn json_lines_transport_dispatches_multiple_requests_and_accepts_final_line_without_newline() {
let publish_request = request(Command::PublishBehavior {
name: "counter".to_owned(),
expected_revision: None,
source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(),
});
let mut spawn_request = request(Command::Spawn {
behavior_name: "counter".to_owned(),
state: Value::Integer(4),
mailbox_capacity: 2,
});
spawn_request.request_id = "spawn-request".to_owned();
let mut list_request = request(Command::ListActors);
list_request.request_id = "list-request".to_owned();
let mut input = [publish_request, spawn_request]
.iter()
.map(serde_json::to_string)
.collect::<Result<Vec<_>, _>>()
.unwrap()
.join("\n");
input.push('\n');
input.push_str(&serde_json::to_string(&list_request).unwrap());
let mut output = Vec::new();
serve_json_lines(&mut Server::new(), Cursor::new(input), &mut output).unwrap();
let responses = output
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
.map(serde_json::from_slice::<Response>)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(responses.len(), 3);
assert_eq!(
responses
.iter()
.map(|response| response.request_id.as_str())
.collect::<Vec<_>>(),
vec!["test-request", "spawn-request", "list-request"]
);
assert!(matches!(responses[0].result, Ok(Reply::Behavior(_))));
assert!(matches!(
&responses[1].result,
Ok(Reply::ActorSpawned { actor_ref }) if actor_ref.len() == 21
));
assert!(matches!(
&responses[2].result,
Ok(Reply::Actors(actors)) if actors.len() == 1 && actors[0].state == Value::Integer(4)
));
}
#[test]
fn json_lines_transport_rejects_malformed_and_oversized_input() {
let mut server = Server::new();
let malformed =
serve_json_lines(&mut server, Cursor::new(b"{not-json}\n"), Vec::new()).unwrap_err();
assert_eq!(malformed.kind(), io::ErrorKind::InvalidData);
let mut oversized = vec![b'a'; MAX_REQUEST_LINE_BYTES + 1];
oversized.push(b'\n');
let error = serve_json_lines(&mut server, Cursor::new(oversized), Vec::new()).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[test] #[test]
fn loopback_listener_rejects_non_loopback_addresses() { fn loopback_listener_rejects_non_loopback_addresses() {
let error = bind_loopback_listener("0.0.0.0:0".parse().unwrap()).unwrap_err(); let error = bind_loopback_listener("0.0.0.0:0".parse().unwrap()).unwrap_err();

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ use tokio_tungstenite::tungstenite::handshake::server::{
use tokio_tungstenite::tungstenite::http::{HeaderValue, StatusCode}; use tokio_tungstenite::tungstenite::http::{HeaderValue, StatusCode};
use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig}; use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};
use crate::{MAX_REQUEST_LINE_BYTES, Server}; use crate::{MAX_MESSAGE_BYTES, Server};
const MAX_NETWORK_CLIENTS: usize = 16; const MAX_NETWORK_CLIENTS: usize = 16;
const WRITE_TIMEOUT: Duration = Duration::from_secs(30); const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
@ -42,7 +42,7 @@ struct Dispatch {
async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> { async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
let listener = tokio::net::TcpListener::from_std(listener)?; let listener = tokio::net::TcpListener::from_std(listener)?;
eprintln!("runa-server: listening on {}", listener.local_addr()?); eprintln!("runa: listening on {}", listener.local_addr()?);
let (requests, mut pending) = mpsc::channel::<Dispatch>(MAX_NETWORK_CLIENTS); let (requests, mut pending) = mpsc::channel::<Dispatch>(MAX_NETWORK_CLIENTS);
let (changes, _) = watch::channel(0_u64); let (changes, _) = watch::channel(0_u64);
let mut revision = 0_u64; let mut revision = 0_u64;
@ -52,14 +52,14 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
accepted = listener.accept() => { accepted = listener.accept() => {
let (stream, address) = accepted?; let (stream, address) = accepted?;
if clients.len() >= MAX_NETWORK_CLIENTS { if clients.len() >= MAX_NETWORK_CLIENTS {
eprintln!("runa-server: rejecting {address}; client limit reached"); eprintln!("runa: rejecting {address}; client limit reached");
continue; continue;
} }
let requests = requests.clone(); let requests = requests.clone();
let changes = changes.subscribe(); let changes = changes.subscribe();
clients.spawn(async move { clients.spawn(async move {
if let Err(error) = client(stream, requests, changes).await { if let Err(error) = client(stream, requests, changes).await {
eprintln!("runa-server: client {address}: {error}"); eprintln!("runa: client {address}: {error}");
} }
}); });
} }
@ -125,9 +125,9 @@ async fn client(
mut changes: watch::Receiver<u64>, mut changes: watch::Receiver<u64>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let config = WebSocketConfig::default() let config = WebSocketConfig::default()
.max_message_size(Some(MAX_REQUEST_LINE_BYTES)) .max_message_size(Some(MAX_MESSAGE_BYTES))
.max_frame_size(Some(MAX_REQUEST_LINE_BYTES)) .max_frame_size(Some(MAX_MESSAGE_BYTES))
.max_write_buffer_size(2 * MAX_REQUEST_LINE_BYTES); .max_write_buffer_size(2 * MAX_MESSAGE_BYTES);
let mut socket = timeout( let mut socket = timeout(
Duration::from_secs(5), Duration::from_secs(5),
tokio_tungstenite::accept_hdr_async_with_config(stream, DebugProtocol, Some(config)), tokio_tungstenite::accept_hdr_async_with_config(stream, DebugProtocol, Some(config)),
@ -142,7 +142,7 @@ async fn client(
let (respond, response) = oneshot::channel(); let (respond, response) = oneshot::channel();
requests.send(Dispatch { request, respond }).await?; requests.send(Dispatch { request, respond }).await?;
let encoded = serde_json::to_string(&response.await?)?; let encoded = serde_json::to_string(&response.await?)?;
if encoded.len() > MAX_REQUEST_LINE_BYTES { if encoded.len() > MAX_MESSAGE_BYTES {
return Err("WebSocket response exceeds 1 MiB".into()); return Err("WebSocket response exceeds 1 MiB".into());
} }
timeout(WRITE_TIMEOUT, socket.send(Message::Text(encoded.into()))).await??; timeout(WRITE_TIMEOUT, socket.send(Message::Text(encoded.into()))).await??;

View File

@ -3,13 +3,19 @@ use std::io::{BufRead, BufReader};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::process::{Child, Command as ProcessCommand, Stdio}; use std::process::{Child, Command as ProcessCommand, Stdio};
use runa_client::{LocalClient, WebSocketClient}; use runa_client::WebSocketClient;
use runa_protocol::{Command, Reply}; use runa_protocol::{Command, Reply};
use tokio_tungstenite::tungstenite::{self, Message, client::IntoClientRequest}; use tokio_tungstenite::tungstenite::{self, Message, client::IntoClientRequest};
fn start_server() -> (ServerProcess, SocketAddr) { fn start_server() -> (ServerProcess, SocketAddr) {
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server")) start_server_with(&["--listen", "127.0.0.1:0"], "invalid")
.args(["--listen", "127.0.0.1:0"]) }
fn start_server_with(args: &[&str], listen_env: &str) -> (ServerProcess, SocketAddr) {
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.args(args)
.env("RUNA_LISTEN", listen_env)
.stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.spawn() .spawn()
@ -19,7 +25,7 @@ fn start_server() -> (ServerProcess, SocketAddr) {
let mut startup = String::new(); let mut startup = String::new();
stderr.read_line(&mut startup).unwrap(); stderr.read_line(&mut startup).unwrap();
let address = startup let address = startup
.strip_prefix("runa-server: listening on ") .strip_prefix("runa: listening on ")
.expect("server should report its bound address") .expect("server should report its bound address")
.trim() .trim()
.parse() .parse()
@ -30,6 +36,43 @@ fn start_server() -> (ServerProcess, SocketAddr) {
(server, address) (server, address)
} }
#[test]
fn server_clap_supports_env_overrides_help_and_explicit_errors() {
let (_server, address) = start_server_with(&[], "127.0.0.1:0");
let mut client = WebSocketClient::connect(address).unwrap();
assert!(
client
.request(Command::GetBehaviors)
.unwrap()
.result
.is_ok()
);
for args in [
vec!["--listen", "invalid"],
vec!["--listen"],
vec!["--stdio"],
vec!["extra"],
vec!["--listen", "0.0.0.0:0"],
] {
let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.args(args)
.env_remove("RUNA_LISTEN")
.output()
.unwrap();
assert!(!output.status.success());
assert!(!output.stderr.is_empty());
}
let help = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.arg("--help")
.env_remove("RUNA_LISTEN")
.output()
.unwrap();
assert!(help.status.success());
let help = String::from_utf8(help.stdout).unwrap();
assert!(help.contains("127.0.0.1:3737"));
assert!(help.contains("RUNA_LISTEN"));
}
fn watcher(address: SocketAddr) -> tungstenite::WebSocket<std::net::TcpStream> { fn watcher(address: SocketAddr) -> tungstenite::WebSocket<std::net::TcpStream> {
let mut request = format!("ws://{address}/").into_client_request().unwrap(); let mut request = format!("ws://{address}/").into_client_request().unwrap();
request request
@ -195,7 +238,7 @@ fn websocket_rejects_binary_malformed_and_oversized_requests() {
for message in [ for message in [
Message::Binary(b"{}".to_vec().into()), Message::Binary(b"{}".to_vec().into()),
Message::Text("{not-json}".into()), Message::Text("{not-json}".into()),
Message::Text(" ".repeat(runa_server::MAX_REQUEST_LINE_BYTES + 1).into()), Message::Text(" ".repeat(runa::MAX_MESSAGE_BYTES + 1).into()),
] { ] {
let mut socket = watcher(address); let mut socket = watcher(address);
// An oversized frame may be rejected as soon as its header arrives, // An oversized frame may be rejected as soon as its header arrives,
@ -216,57 +259,9 @@ impl Drop for ServerProcess {
} }
} }
#[test]
fn local_client_launches_server_and_operates_the_same_live_image() {
let mut client = LocalClient::spawn(env!("CARGO_BIN_EXE_runa-server"), &[] as &[&str]).unwrap();
let published = client
.request(Command::PublishBehavior {
name: "counter".to_owned(),
expected_revision: None,
source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(),
})
.unwrap();
assert_eq!(published.request_id, "runa-client-1");
assert!(matches!(published.result, Ok(Reply::Behavior(_))));
let spawned = client
.request(Command::Spawn {
behavior_name: "counter".to_owned(),
state: Value::Integer(5),
mailbox_capacity: 2,
})
.unwrap();
assert_eq!(spawned.request_id, "runa-client-2");
let actor_ref = match spawned.result {
Ok(Reply::ActorSpawned { actor_ref }) => actor_ref,
other => panic!("unexpected spawn response: {other:?}"),
};
assert_eq!(actor_ref.len(), 21);
client
.request(Command::Send {
actor_ref: actor_ref.clone(),
message: test_message(0, 7, None),
})
.unwrap();
client
.request(Command::Tick {
ticks: 100,
instruction_budget: 10,
})
.unwrap();
let inspected = client.request(Command::InspectActor { actor_ref }).unwrap();
assert!(matches!(
inspected.result,
Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12)
));
assert!(client.shutdown().unwrap().success());
}
#[test] #[test]
fn websocket_server_accepts_local_protocol_requests() { fn websocket_server_accepts_local_protocol_requests() {
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server")) let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa"))
.args(["--listen", "127.0.0.1:0"]) .args(["--listen", "127.0.0.1:0"])
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
@ -278,7 +273,7 @@ fn websocket_server_accepts_local_protocol_requests() {
let mut startup = String::new(); let mut startup = String::new();
stderr.read_line(&mut startup).unwrap(); stderr.read_line(&mut startup).unwrap();
let address = startup let address = startup
.strip_prefix("runa-server: listening on ") .strip_prefix("runa: listening on ")
.expect("server should report its bound address") .expect("server should report its bound address")
.trim() .trim()
.parse::<SocketAddr>() .parse::<SocketAddr>()
@ -408,14 +403,3 @@ fn websocket_server_accepts_local_protocol_requests() {
actor_ref: echo, actor_ref: echo,
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == nested && actor.reserved.is_none())); }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == nested && actor.reserved.is_none()));
} }
fn test_message(kind: i64, value: i64, target: Option<runa_actor::ActorRef>) -> Value {
Value::Record(std::collections::BTreeMap::from([
("kind".to_owned(), Value::Integer(kind)),
("value".to_owned(), Value::Integer(value)),
(
"target".to_owned(),
target.map_or(Value::Null, Value::ActorRef),
),
]))
}

View File

@ -63,7 +63,7 @@ Rust supplies a minimal, reliable substrate; increasingly much of the actor fram
+-----------------------------------------------------------+ +-----------------------------------------------------------+
``` ```
The graphical world should use internal service calls to the image API. The VS Code extension connects to the same live image through a versioned network transport. CLI tools, tests, and local automation can use in-process calls or a child-process protocol over stdio. The initial WebSocket transport is unauthenticated and must bind only to loopback; a remote-capable network boundary must authenticate and authorize clients. Neither transport creates a separate actor runtime or image. The graphical world should use internal service calls to the image API. The VS Code extension, CLI tools, tests, and local automation connect to the same live image through the versioned runa-debug WebSocket transport; trusted embedded callers can use in-process calls. The initial WebSocket transport is unauthenticated and must bind only to loopback; a remote-capable network boundary must authenticate and authorize clients. Client connections do not create a separate actor runtime or image.
## 4. Fundamental Actor Model ## 4. Fundamental Actor Model
@ -201,7 +201,7 @@ The graphical inspector should support selecting a component, locating its ownin
## 8. Unified Programmatic Image API ## 8. Unified Programmatic Image API
The running image exposes a versioned semantic API, used by graphical tools, CLI tools, tests, automation, coding agents, and the VS Code extension. The extension uses a network transport to reach the same running image; local stdio remains available for child-process integrations. Neither transport changes actor placement or message-passing semantics. The running image exposes a versioned semantic API, used by graphical tools, CLI tools, tests, automation, coding agents, and the VS Code extension. External clients use WebSockets to reach the same running image; there is no stdio transport. Client location does not change actor placement or message-passing semantics.
Suggested operation families (provisional names): Suggested operation families (provisional names):
@ -235,11 +235,11 @@ These are desired capabilities, not a requirement to implement every endpoint im
### 8.1 Security ### 8.1 Security
Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The stdio bridge is intended for a trusted parent process. The initial WebSocket bridge accepts unauthenticated connections only on IPv4 or IPv6 loopback and grants each connection the full development API; any local process may connect. It is suitable only for trusted local development. Never bind this endpoint to a non-loopback address or expose it remotely until authentication and authorization are implemented. Normal actors should not automatically receive development privileges. Arbitrary evaluation, state mutation, code replacement, and process control require explicit capabilities. The initial WebSocket bridge accepts unauthenticated connections only on IPv4 or IPv6 loopback and grants each connection the full development API; any local process may connect. It is suitable only for trusted local development. Never bind this endpoint to a non-loopback address or expose it remotely until authentication and authorization are implemented.
## 9. VS Code Extension ## 9. VS Code Extension
VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON over runa-debug WebSockets, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients. VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON over runa-debug WebSockets, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The REPL uses the same WebSocket protocol.
The first extension increment implements explicit connect/disconnect, an actor The first extension increment implements explicit connect/disconnect, an actor
explorer, and read-only state/mailbox/execution snapshots. Server `image_changed` explorer, and read-only state/mailbox/execution snapshots. Server `image_changed`
@ -291,12 +291,9 @@ The Rust workspace uses **acyclic, inward-pointing dependencies**. The executabl
Arrows mean **"depends on"**, not runtime data flow: Arrows mean **"depends on"**, not runtime data flow:
```text ```text
runa runa-world runa runa-cli
/ | \ | / \ |
v v v v v v v
runa-world runa-server
| / \
v v v
runa-image-api runa-protocol runa-image-api runa-protocol
| | | |
v | v |
@ -310,8 +307,9 @@ Arrows mean **"depends on"**, not runtime data flow:
runa-world also depends on runa-graphics (platform drawing/input runa-world also depends on runa-graphics (platform drawing/input
contracts); runa-graphics depends only on runa-core when needed. contracts); runa-graphics depends only on runa-core when needed.
runa-server depends on runa-image-api and runa-protocol. runa depends on runa-image-api and runa-protocol.
runa wires together world, server, runtime, storage and platform. runa-cli uses runa-client and runa-protocol to connect to the image.
World, storage and platform integration remain planned.
``` ```
The diagram is conceptual; Cargo dependencies should be kept minimal. A dependency is permitted only when the importing crate actually uses that crate's public contracts. The VS Code extension is a separate TypeScript project and depends on the **wire protocol**, not Rust crates. The diagram is conceptual; Cargo dependencies should be kept minimal. A dependency is permitted only when the importing crate actually uses that crate's public contracts. The VS Code extension is a separate TypeScript project and depends on the **wire protocol**, not Rust crates.
@ -327,11 +325,11 @@ The diagram is conceptual; Cargo dependencies should be kept minimal. A dependen
| `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` | | `runa-runtime` | Scheduling, actor execution coordination, state commits, crash recovery, binding actor semantics to Rune execution | `runa-core`, `runa-actor`, `runa-code`, `runa-rune` |
| `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` | | `runa-image-api` | Trusted in-process application services for spawning, inspecting, sending, editing, and upgrading | `runa-core`, `runa-actor`, `runa-code`, `runa-runtime` |
| `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` | | `runa-protocol` | Versioned transport-neutral request/response/event DTOs and validation | `serde` |
| `runa-server` | Protocol dispatch, JSON-lines stdio, and loopback-only WebSocket transport | `runa-protocol`, `runa-image-api` | | `runa` | Image startup, configuration, protocol dispatch, and loopback-only WebSocket transport | `runa-protocol`, `runa-image-api` |
| `runa-client` | Local child-process stdio and loopback runa-debug WebSocket clients | `runa-protocol` | | `runa-cli` | Interactive REPL and connection argument parsing | `runa-client`, `runa-protocol` |
| `runa-client` | Loopback runa-debug WebSocket client | `runa-protocol` |
| `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary | | `runa-graphics` | Native window, rendering and input mechanisms; no actor policy | `runa-core` if necessary |
| `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` | | `runa-world` | Morphic-style graphical composition and in-world development UI | `runa-image-api`, `runa-graphics` |
| `runa` | Startup, configuration, resource wiring, shutdown | Top-level crates it instantiates |
The current prototype has added `runa-code` for exact-source identity and The current prototype has added `runa-code` for exact-source identity and
in-memory compilation caching, and `runa-image-api` as the trusted, in-memory compilation caching, and `runa-image-api` as the trusted,
@ -345,15 +343,15 @@ publication, history and rollback, actor lifecycle and messaging, snapshots,
ticks, and event retrieval. The CLI uses these application services rather ticks, and event retrieval. The CLI uses these application services rather
than reaching into the code store and runtime directly. `runa-protocol` now than reaching into the code store and runtime directly. `runa-protocol` now
defines versioned, serializable request, response, and event DTOs without defines versioned, serializable request, response, and event DTOs without
depending on a transport or the runtime implementation. `runa-server` now depending on a transport or the runtime implementation. `runa` now
dispatches those commands through the image API and maps service results into dispatches those commands through the image API and maps service results into
protocol replies and typed errors. The dispatcher is exposed through local protocol replies and typed errors. The dispatcher is exposed through an
child-process JSON-lines stdio and an unauthenticated WebSocket listener restricted unauthenticated WebSocket listener restricted
to IPv4 or IPv6 loopback; both enforce a 1 MiB request limit. The WebSocket to IPv4 or IPv6 loopback, enforcing a 1 MiB request limit. The WebSocket
listener shares one image across at most 16 concurrent clients. Every local listener shares one image across at most 16 concurrent clients. Every local
client receives full development API access, so the endpoint is for trusted client receives full development API access, so the endpoint is for trusted
local development only. In-process consumers call `runa-image-api`, and local development only. In-process consumers call `runa-image-api`, and
external local tools can use `runa-client` to launch the stdio server. Actors external local tools can use `runa-client` to connect to the server. Actors
do not depend on network transport to coexist in one live image. Remote do not depend on network transport to coexist in one live image. Remote
network access will require authentication and authorization. network access will require authentication and authorization.
@ -393,7 +391,7 @@ runa/
│ ├── runa-runtime/ │ ├── runa-runtime/
│ ├── runa-image-api/ │ ├── runa-image-api/
│ ├── runa-protocol/ │ ├── runa-protocol/
│ ├── runa-server/ │ ├── runa-cli/
│ ├── runa-client/ │ ├── runa-client/
│ ├── runa-graphics/ │ ├── runa-graphics/
│ ├── runa-world/ │ ├── runa-world/
@ -465,16 +463,20 @@ mapping, and local-variable inspection remain future work.
The first Phase 5 increments are a trusted in-process application API used by The first Phase 5 increments are a trusted in-process application API used by
the CLI, a transport-neutral versioned protocol contract, and a dispatcher the CLI, a transport-neutral versioned protocol contract, and a dispatcher
exposed through bounded local JSON-lines stdio. The interactive CLI now uses exposed through bounded loopback WebSockets. The interactive `runa-cli` uses
`runa-client` to connect over loopback WebSockets to a server-owned image, rather than `runa-client` to connect over loopback WebSockets to a server-owned image, rather than
embedding a runtime. Multiple clients share actors and behavior revisions; embedding a runtime. Multiple clients share actors and behavior revisions;
client disconnect/reconnect leaves the running image intact. `runa-client` client disconnect/reconnect leaves the running image intact. The `runa` server provides an
also launches the stdio server for external local tools. The server provides an
unauthenticated runa-debug WebSocket endpoint restricted to loopback for local VS unauthenticated runa-debug WebSocket endpoint restricted to loopback for local VS
Code development. Remote access will require authentication and Code development. Remote access will require authentication and
authorization; neither network transport nor client location changes the authorization; neither network transport nor client location changes the
single-image actor model. single-image actor model.
Both binaries use Clap derive/env argument parsing. `runa` starts the server
at `127.0.0.1:3737` by default (`--listen` / `RUNA_LISTEN`), and `runa-cli`
connects to that address (`--connect` / `RUNA_CONNECT`). Flags override environment
variables. Mise's `start` and `cli` tasks run the server and REPL respectively.
### Phase 6 — Graphical world ### Phase 6 — Graphical world
- Native window/rendering, input, scene manager, Rune-driven button and window. - Native window/rendering, input, scene manager, Rune-driven button and window.

View File

@ -1,10 +1,12 @@
# Runa Protocol # Runa Protocol
`runa-protocol` defines versioned request, response, and event data contracts `runa-protocol` defines versioned request, response, and event data contracts
for clients and the `runa-server` dispatcher. It is independent of transport for clients and the `runa` dispatcher. It is independent of transport
and runtime implementation. `runa-server` accepts JSON Lines over stdio or, and runtime implementation. `runa` accepts WebSocket text messages bound to an IPv4 or
when started with `--listen`, as WebSocket text messages bound to an IPv4 or IPv6 loopback address, defaulting to `127.0.0.1:3737` without arguments.
IPv6 loopback address. The listener is unauthenticated and intended only for Clap parses `--listen` / `RUNA_LISTEN` on the server and `--connect` /
`RUNA_CONNECT` on `runa-cli`; explicit flags override environment variables.
The listener is unauthenticated and intended only for
trusted local development. Both the REPL and VS Code use WebSockets. trusted local development. Both the REPL and VS Code use WebSockets.
## Version 3 ## Version 3
@ -84,17 +86,17 @@ any of its sends.
`get_events` drains and returns the events currently accumulated by this `get_events` drains and returns the events currently accumulated by this
single-worker image. It is not a durable subscription or replayable event log. single-worker image. It is not a durable subscription or replayable event log.
Both transports limit each request to 1 MiB before parsing. Malformed The transport limits each request to 1 MiB before parsing. Malformed
JSON or an oversized message terminates that connection with an error; no protocol JSON or an oversized message terminates that connection with an error; no protocol
response is emitted when a request cannot be decoded. Stdio EOF terminates the response is emitted when a request cannot be decoded. A WebSocket disconnect
process, while a WebSocket disconnect closes only that client connection. The listener closes only that client connection. The listener
allows up to 16 simultaneous clients and serializes each dispatch through the allows up to 16 simultaneous clients and serializes each dispatch through the
same image. WebSocket response payloads are capped at 1 MiB, with one same image. WebSocket response payloads are capped at 1 MiB, with one
in-flight dispatch per client and a bounded shared dispatch queue. Every local client can use the full development in-flight dispatch per client and a bounded shared dispatch queue. Every local client can use the full development
API; loopback binding is not authentication or authorization. Remote access API; loopback binding is not authentication or authorization. Remote access
must not be enabled until appropriate authentication and authorization exist. must not be enabled until appropriate authentication and authorization exist.
The `runa-client` crate launches the stdio server locally and validates The `runa-client` crate validates response protocol versions and request IDs.
response protocol versions and request IDs. Its `WebSocketClient` connects to Its `WebSocketClient` connects to
loopback endpoints using bounded WebSocket messages and correlation checks, loopback endpoints using bounded WebSocket messages and correlation checks,
validating interleaved notifications while waiting for responses. validating interleaved notifications while waiting for responses.
Connection establishment has a 5-second timeout; WebSocket writes and response Connection establishment has a 5-second timeout; WebSocket writes and response
@ -106,7 +108,7 @@ The interactive REPL uses WebSockets and never owns the image. Disconnecting cli
does not stop actors or reset behavior revisions; reconnecting clients can does not stop actors or reset behavior revisions; reconnecting clients can
retrieve them through `list_actors` and `get_behaviors`. The network executable retrieve them through `list_actors` and `get_behaviors`. The network executable
publishes the bundled `counter` behavior on image startup, not on connection. publishes the bundled `counter` behavior on image startup, not on connection.
The stdio executable continues to start with an empty image. The stdio transport and child-process client have been removed.
The TypeScript VS Code client currently issues only `list_actors` and The TypeScript VS Code client currently issues only `list_actors` and
`inspect_actor`. It enforces loopback endpoints, bounded UTF-8 text messages, `inspect_actor`. It enforces loopback endpoints, bounded UTF-8 text messages,
@ -123,7 +125,7 @@ the actor tree and open snapshot documents. Manual refresh remains available.
Connect to `ws://127.0.0.1:3737/` (or a configured numeric loopback endpoint) Connect to `ws://127.0.0.1:3737/` (or a configured numeric loopback endpoint)
and offer the `runa-debug` WebSocket subprotocol. The server requires and echoes and offer the `runa-debug` WebSocket subprotocol. The server requires and echoes
that subprotocol. Plain TCP JSON Lines is no longer accepted by the network that subprotocol. Plain TCP JSON Lines is no longer accepted by the network
listener. The request/response contract remains version 3; stdio is unchanged. listener. The request/response contract remains version 3.
Each WebSocket text message contains exactly one JSON request, response, or Each WebSocket text message contains exactly one JSON request, response, or
notification, without newline framing. Binary messages and malformed requests notification, without newline framing. Binary messages and malformed requests
terminate the connection. Fragmented text is supported with a 1 MiB aggregate terminate the connection. Fragmented text is supported with a 1 MiB aggregate

View File

@ -3,11 +3,15 @@
Start the server-owned, in-memory image: Start the server-owned, in-memory image:
```sh ```sh
mise run serve-network mise run start
``` ```
Then connect from another terminal with `cargo run -p runa`, or use Then connect from another terminal with `mise run cli`, or use
`cargo run -p runa -- --connect <loopback-address>` for a custom listener. `cargo run -p runa-cli -- --connect <loopback-address>` for a custom listener.
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
variables. Both executables provide Clap-generated `--help` and `--version`.
Quitting the REPL leaves the image running. Reconnecting clients can list and Quitting the REPL leaves the image running. Reconnecting clients can list and
inspect the same actors; local bindings and history do not survive a REPL inspect the same actors; local bindings and history do not survive a REPL
restart. Server exit still loses the entire image. restart. Server exit still loses the entire image.
@ -26,7 +30,7 @@ The CLI sends versioned requests to the loopback-only WebSocket listener using
the `runa-debug` subprotocol. VS Code uses the same protocol and automatically the `runa-debug` subprotocol. VS Code uses the same protocol and automatically
refreshes on non-destructive server change notifications. These notifications refreshes on non-destructive server change notifications. These notifications
do not tick the image or drain the REPL's explicit `events` queue. do not tick the image or drain the REPL's explicit `events` queue.
The separate stdio endpoint starts an empty image for child-process clients. There is no stdio transport; all external clients use WebSockets.
Example: Example:
@ -79,12 +83,12 @@ also offers references inside a `"$actor"` string.
trusted in-process service boundary. trusted in-process service boundary.
- `runa-protocol` defines versioned transport-neutral request, response, and - `runa-protocol` defines versioned transport-neutral request, response, and
event DTOs; it does not dispatch requests or access runtime state. event DTOs; it does not dispatch requests or access runtime state.
- `runa-server` dispatches those requests through `runa-image-api` and maps - `runa` dispatches those requests through `runa-image-api` and maps
results to protocol replies; its executable accepts newline-delimited JSON results to protocol replies; its executable accepts JSON text messages over
over stdin/stdout or JSON text messages over loopback WebSockets, with a loopback WebSockets, with a
1 MiB request limit. 1 MiB request limit.
- `runa-client` provides correlated, bounded stdio and loopback WebSocket clients. - `runa-client` provides a correlated, bounded loopback WebSocket client.
- `runa` provides an interactive CLI using the WebSocket client, without an embedded - `runa-cli` provides an interactive CLI using the WebSocket client, without an embedded
runtime. Connection errors are explicit; requests are never auto-retried. runtime. Connection errors are explicit; requests are never auto-retried.
The image API is process-local and intended for trusted callers. It has no The image API is process-local and intended for trusted callers. It has no

View File

@ -18,16 +18,12 @@ description = "Test the extension in VS Code (requires a display or xvfb-run)"
run = "npm --prefix vscode run test:host" run = "npm --prefix vscode run test:host"
[tasks.start] [tasks.start]
description = "Start the local Runa CLI" description = "Start the Runa image server on localhost"
run = "cargo run -p runa" run = "cargo run -p runa"
[tasks.serve] [tasks.cli]
description = "Run the local JSON-lines protocol server" description = "Connect the Runa CLI to the image server"
run = "cargo run -p runa-server" run = "cargo run -p runa-cli"
[tasks.serve-network]
description = "Run the runa-debug WebSocket server on localhost"
run = "cargo run -p runa-server -- --listen 127.0.0.1:3737"
[tasks.lint] [tasks.lint]
description = "Check Rust targets and the TypeScript extension" description = "Check Rust targets and the TypeScript extension"

View File

@ -13,7 +13,7 @@ From the repository root:
```sh ```sh
npm --prefix vscode ci npm --prefix vscode ci
mise run serve-network mise run start
``` ```
Open the repository in VS Code. In Run and Debug, select **Run Runa Extension** Open the repository in VS Code. In Run and Debug, select **Run Runa Extension**
@ -24,7 +24,7 @@ In that host:
1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`; 1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`;
`runa.address` changes the suggested endpoint. `runa.address` changes the suggested endpoint.
2. Expand **Runa Actors** in Explorer. Use the REPL (`mise run start`) to 2. Expand **Runa Actors** in Explorer. Use the REPL (`mise run cli`) to
spawn actors in the same server. The tree updates automatically. spawn actors in the same server. The tree updates automatically.
3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot. 3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot.
4. Run **Runa: Disconnect** to close only this client connection. 4. Run **Runa: Disconnect** to close only this client connection.

View File

@ -32,9 +32,10 @@ test(
const executable = resolve( const executable = resolve(
__dirname, __dirname,
"../../../target/debug", "../../../target/debug",
process.platform === "win32" ? "runa.exe" : "runa", process.platform === "win32" ? "runa-cli.exe" : "runa-cli",
); );
const repl = spawn(executable, ["--connect", image.address], { const repl = spawn(executable, [], {
env: { ...process.env, RUNA_CONNECT: image.address },
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); });
t.after(() => { t.after(() => {
@ -69,5 +70,16 @@ 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);
const overridden = spawn(executable, ["--connect", image.address], {
env: { ...process.env, RUNA_CONNECT: "invalid" },
stdio: ["pipe", "pipe", "pipe"],
});
t.after(() => {
if (overridden.exitCode === null && overridden.signalCode === null) overridden.kill();
});
const overriddenExit = once(overridden, "exit");
overridden.stdin.end("actors\nquit\n");
const [overrideCode] = await overriddenExit;
assert.equal(overrideCode, 0);
}, },
); );

View File

@ -10,7 +10,7 @@ export async function startImage(): Promise<{ address: string; stop: () => Promi
const executable = resolve( const executable = resolve(
__dirname, __dirname,
"../../../target/debug", "../../../target/debug",
process.platform === "win32" ? "runa-server.exe" : "runa-server", process.platform === "win32" ? "runa.exe" : "runa",
); );
const child = spawn(executable, ["--listen", "127.0.0.1:0"], { const child = spawn(executable, ["--listen", "127.0.0.1:0"], {
stdio: ["ignore", "ignore", "pipe"], stdio: ["ignore", "ignore", "pipe"],
@ -41,7 +41,7 @@ export async function startImage(): Promise<{ address: string; stop: () => Promi
child.once("exit", exited); child.once("exit", exited);
lines.on("line", (line) => { lines.on("line", (line) => {
diagnostics = (diagnostics + line + "\n").slice(-8192); diagnostics = (diagnostics + line + "\n").slice(-8192);
const address = line.match(/^runa-server: listening on (.+)$/)?.[1]; const address = line.match(/^runa: listening on (.+)$/)?.[1];
if (address) { if (address) {
cleanup(); cleanup();
resolve(address); resolve(address);

View File

@ -20,8 +20,8 @@
"lint": "tsc -p . --noEmit && prettier --check .", "lint": "tsc -p . --noEmit && prettier --check .",
"format": "prettier --write .", "format": "prettier --write .",
"test": "npm run compile && node --test out/test/*.test.js", "test": "npm run compile && node --test out/test/*.test.js",
"test:integration": "cargo build --manifest-path ../Cargo.toml -p runa-server -p runa && npm run compile && node --test out/integration/*.test.js", "test:integration": "cargo build --manifest-path ../Cargo.toml -p runa -p runa-cli && npm run compile && node --test out/integration/*.test.js",
"test:host": "cargo build --manifest-path ../Cargo.toml -p runa-server && npm run compile && node scripts/test-host.cjs" "test:host": "cargo build --manifest-path ../Cargo.toml -p runa && npm run compile && node scripts/test-host.cjs"
}, },
"dependencies": { "dependencies": {
"lossless-json": "^4.3.0", "lossless-json": "^4.3.0",

View File

@ -176,8 +176,7 @@ export function activate(context: vscode.ExtensionContext): void {
(await vscode.window.showInputBox({ (await vscode.window.showInputBox({
title: "Connect to a trusted local Runa image (no authentication)", title: "Connect to a trusted local Runa image (no authentication)",
value: vscode.workspace.getConfiguration("runa").get<string>("address", "127.0.0.1:3737"), value: vscode.workspace.getConfiguration("runa").get<string>("address", "127.0.0.1:3737"),
prompt: prompt: "Start the server with mise run start. The extension does not start an image.",
"Start the server with mise run serve-network. The extension does not start an image.",
validateInput: (value) => { validateInput: (value) => {
try { try {
parseEndpoint(value); parseEndpoint(value);