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:
parent
72a37b178c
commit
b63666bcc2
12
AGENTS.md
12
AGENTS.md
|
|
@ -60,7 +60,8 @@ proposed capabilities as plans, not existing behavior.
|
|||
## Repository structure
|
||||
|
||||
- `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-rune/`: Phase 0 Rune compilation and execution experiments.
|
||||
- `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-image-api/`: trusted in-process services for operating on an image.
|
||||
- `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/`: stdio child-process and runa-debug WebSocket clients.
|
||||
- `crates/runa-client/`: runa-debug WebSocket client.
|
||||
- `runa-std/actors/`: default Rune actor scripts loaded by the local CLI.
|
||||
- `docs/architecture.md`: architecture, roadmap, risks, and acceptance scenario.
|
||||
- `docs/decisions/`: architectural decisions.
|
||||
|
|
@ -90,7 +90,7 @@ mise run format
|
|||
mise run lint
|
||||
mise run test
|
||||
mise run start
|
||||
mise run serve
|
||||
mise run cli
|
||||
mise run test-extension-host
|
||||
mise run pre-commit
|
||||
```
|
||||
|
|
@ -104,7 +104,9 @@ are required; install the locked extension dependencies with
|
|||
|
||||
The workspace tests cover execution probes, actor runtime invariants, and the
|
||||
interactive CLI. Extension tests include a real Rust-server WebSocket round trip.
|
||||
`mise run start` 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
|
||||
`xvfb-run -a mise run test-extension-host` on headless Linux).
|
||||
|
||||
|
|
|
|||
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -90,6 +90,12 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### 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
|
||||
using the required `runa-debug` subprotocol in the server, REPL, and VS Code.
|
||||
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`.
|
||||
- Required agents to pass `mise run pre-commit` before every commit and document
|
||||
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.
|
||||
|
|
@ -23,6 +23,56 @@ dependencies = [
|
|||
"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]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
|
|
@ -97,6 +147,46 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.3"
|
||||
|
|
@ -116,6 +206,12 @@ dependencies = [
|
|||
"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]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
|
|
@ -302,6 +398,12 @@ dependencies = [
|
|||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.12"
|
||||
|
|
@ -327,6 +429,12 @@ version = "1.10.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
|
|
@ -549,6 +657,12 @@ dependencies = [
|
|||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.13"
|
||||
|
|
@ -704,13 +818,16 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||
name = "runa"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"futures-util",
|
||||
"runa-actor",
|
||||
"runa-client",
|
||||
"runa-core",
|
||||
"runa-code",
|
||||
"runa-image-api",
|
||||
"runa-protocol",
|
||||
"runa-server",
|
||||
"rustyline",
|
||||
"serde_json",
|
||||
"tungstenite",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -720,6 +837,20 @@ dependencies = [
|
|||
"runa-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runa-cli"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"runa",
|
||||
"runa-client",
|
||||
"runa-core",
|
||||
"runa-protocol",
|
||||
"rustyline",
|
||||
"serde_json",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "runa-client"
|
||||
version = "0.0.0"
|
||||
|
|
@ -785,21 +916,6 @@ dependencies = [
|
|||
"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]]
|
||||
name = "rune"
|
||||
version = "0.14.2"
|
||||
|
|
@ -1043,6 +1159,12 @@ dependencies = [
|
|||
"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]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ resolver = "3"
|
|||
members = [
|
||||
"crates/runa",
|
||||
"crates/runa-actor",
|
||||
"crates/runa-cli",
|
||||
"crates/runa-client",
|
||||
"crates/runa-code",
|
||||
"crates/runa-core",
|
||||
|
|
@ -10,5 +11,7 @@ members = [
|
|||
"crates/runa-protocol",
|
||||
"crates/runa-rune",
|
||||
"crates/runa-runtime",
|
||||
"crates/runa-server",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -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
|
||||
installation, pause/resume, and cancellation through a trusted in-process image
|
||||
API. A versioned transport-neutral protocol contract is now defined, but no
|
||||
authentication is implemented. The dispatcher is available through local
|
||||
JSON-lines stdio and an unauthenticated WebSocket listener restricted to localhost;
|
||||
both use a bounded request size. The interactive CLI and VS Code connect with
|
||||
authentication is implemented. The `runa` server provides an unauthenticated
|
||||
WebSocket listener restricted to localhost, with bounded request sizes.
|
||||
The `runa-cli` REPL and VS Code connect with
|
||||
the `runa-debug` WebSocket subprotocol, so clients share one server-owned image.
|
||||
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:
|
||||
|
||||
```sh
|
||||
mise run serve-network
|
||||
mise run start
|
||||
```
|
||||
|
||||
In another terminal, connect the REPL:
|
||||
|
||||
```sh
|
||||
mise run start
|
||||
mise run cli
|
||||
```
|
||||
|
||||
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
|
||||
client; actors and published behaviors survive until the server exits.
|
||||
Reconnect and use `actors` to recover their references. REPL bindings and
|
||||
command history are session-local and are not restored on reconnect.
|
||||
|
||||
For a local child-process protocol endpoint, run `mise run serve` and exchange
|
||||
one JSON request per line on stdin; responses are written to stdout. Request
|
||||
lines are limited to 1 MiB.
|
||||
Both executables use Clap for `--help`, `--version`, and argument validation.
|
||||
`runa` listens on `127.0.0.1:3737` without arguments. Set `RUNA_LISTEN` to change
|
||||
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
|
||||
simultaneous clients sharing one live image. The listener rejects non-loopback
|
||||
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.
|
||||
|
||||
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.
|
||||
Network connection establishment is limited to 5 seconds and response waits/writes to 30
|
||||
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,
|
||||
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
|
||||
actors created through the REPL or another client. Click an actor to inspect
|
||||
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
|
||||
├── 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-actor/ # Actor value contracts
|
||||
│ ├── 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-image-api/ # Trusted in-process image operations
|
||||
│ ├── runa-protocol/ # Versioned transport-neutral wire DTOs
|
||||
│ ├── runa-server/ # Protocol dispatcher and local JSON-lines transport
|
||||
│ └── runa-client/ # Stdio child-process and loopback WebSocket clients
|
||||
│ └── runa-client/ # Loopback WebSocket client
|
||||
├── docs/
|
||||
│ ├── architecture.md
|
||||
│ ├── protocol.md
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
@ -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::io::{self, BufRead, BufReader, BufWriter, Write};
|
||||
use std::io;
|
||||
use std::net::{Shutdown, SocketAddr, TcpStream};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use runa_protocol::{
|
||||
|
|
@ -14,18 +12,7 @@ use runa_protocol::{
|
|||
use tungstenite::client::IntoClientRequest;
|
||||
use tungstenite::{Message, WebSocket, protocol::WebSocketConfig};
|
||||
|
||||
pub const MAX_MESSAGE_LINE_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 const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
pub struct WebSocketClient {
|
||||
socket: WebSocket<TcpStream>,
|
||||
|
|
@ -54,9 +41,9 @@ impl WebSocketClient {
|
|||
WEBSOCKET_SUBPROTOCOL.parse().map_err(io::Error::other)?,
|
||||
);
|
||||
let config = WebSocketConfig::default()
|
||||
.max_message_size(Some(MAX_MESSAGE_LINE_BYTES))
|
||||
.max_frame_size(Some(MAX_MESSAGE_LINE_BYTES))
|
||||
.max_write_buffer_size(2 * MAX_MESSAGE_LINE_BYTES);
|
||||
.max_message_size(Some(MAX_MESSAGE_BYTES))
|
||||
.max_frame_size(Some(MAX_MESSAGE_BYTES))
|
||||
.max_write_buffer_size(2 * MAX_MESSAGE_BYTES);
|
||||
let (socket, response) =
|
||||
tungstenite::client::client_with_config(request, stream, Some(config))
|
||||
.map_err(io::Error::other)?;
|
||||
|
|
@ -114,7 +101,7 @@ impl WebSocketClient {
|
|||
request_id: request_id.clone(),
|
||||
command,
|
||||
})?;
|
||||
if encoded.len() > MAX_MESSAGE_LINE_BYTES {
|
||||
if encoded.len() > MAX_MESSAGE_BYTES {
|
||||
return Err(ClientError::RequestTooLarge);
|
||||
}
|
||||
self.socket
|
||||
|
|
@ -180,7 +167,6 @@ pub enum ClientError {
|
|||
Serialization(serde_json::Error),
|
||||
RequestIdsExhausted,
|
||||
RequestTooLarge,
|
||||
ResponseTooLarge,
|
||||
UnsupportedVersion(u16),
|
||||
CorrelationMismatch { expected: String, actual: String },
|
||||
}
|
||||
|
|
@ -193,11 +179,7 @@ impl fmt::Display for ClientError {
|
|||
Self::RequestIdsExhausted => write!(formatter, "protocol request IDs exhausted"),
|
||||
Self::RequestTooLarge => write!(
|
||||
formatter,
|
||||
"protocol request exceeds {MAX_MESSAGE_LINE_BYTES} bytes"
|
||||
),
|
||||
Self::ResponseTooLarge => write!(
|
||||
formatter,
|
||||
"protocol response exceeds {MAX_MESSAGE_LINE_BYTES} bytes"
|
||||
"protocol request exceeds {MAX_MESSAGE_BYTES} bytes"
|
||||
),
|
||||
Self::UnsupportedVersion(version) => {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use runa_protocol::{ProtocolError, Reply};
|
||||
use std::io::Cursor;
|
||||
use runa_protocol::Reply;
|
||||
|
||||
struct DebugProtocol;
|
||||
impl tungstenite::handshake::server::Callback for DebugProtocol {
|
||||
|
|
@ -487,76 +335,4 @@ mod tests {
|
|||
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::NotConnected));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -4,12 +4,15 @@ version = "0.0.0"
|
|||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
runa-client = { path = "../runa-client" }
|
||||
runa-core = { path = "../runa-core" }
|
||||
clap.workspace = true
|
||||
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" }
|
||||
rustyline = "15.0"
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "net", "rt", "sync", "time"] }
|
||||
tokio-tungstenite = "0.28"
|
||||
|
||||
[dev-dependencies]
|
||||
runa-server = { path = "../runa-server" }
|
||||
tungstenite = "0.28"
|
||||
runa-client = { path = "../runa-client" }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! 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::num::NonZeroUsize;
|
||||
|
||||
|
|
@ -14,24 +14,12 @@ use runa_protocol::{
|
|||
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 {
|
||||
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.
|
||||
pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> {
|
||||
if !address.ip().is_loopback() {
|
||||
|
|
@ -44,54 +32,6 @@ pub fn bind_loopback_listener(address: SocketAddr) -> io::Result<TcpListener> {
|
|||
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 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
|
@ -390,7 +330,6 @@ mod tests {
|
|||
}
|
||||
|
||||
use runa_protocol::{Command, PROTOCOL_VERSION};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn request(command: Command) -> 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]
|
||||
fn loopback_listener_rejects_non_loopback_addresses() {
|
||||
let error = bind_loopback_listener("0.0.0.0:0".parse().unwrap()).unwrap_err();
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,7 +15,7 @@ use tokio_tungstenite::tungstenite::handshake::server::{
|
|||
use tokio_tungstenite::tungstenite::http::{HeaderValue, StatusCode};
|
||||
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 WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
|
@ -42,7 +42,7 @@ struct Dispatch {
|
|||
|
||||
async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
||||
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 (changes, _) = watch::channel(0_u64);
|
||||
let mut revision = 0_u64;
|
||||
|
|
@ -52,14 +52,14 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
|
|||
accepted = listener.accept() => {
|
||||
let (stream, address) = accepted?;
|
||||
if clients.len() >= MAX_NETWORK_CLIENTS {
|
||||
eprintln!("runa-server: rejecting {address}; client limit reached");
|
||||
eprintln!("runa: rejecting {address}; client limit reached");
|
||||
continue;
|
||||
}
|
||||
let requests = requests.clone();
|
||||
let changes = changes.subscribe();
|
||||
clients.spawn(async move {
|
||||
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>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let config = WebSocketConfig::default()
|
||||
.max_message_size(Some(MAX_REQUEST_LINE_BYTES))
|
||||
.max_frame_size(Some(MAX_REQUEST_LINE_BYTES))
|
||||
.max_write_buffer_size(2 * MAX_REQUEST_LINE_BYTES);
|
||||
.max_message_size(Some(MAX_MESSAGE_BYTES))
|
||||
.max_frame_size(Some(MAX_MESSAGE_BYTES))
|
||||
.max_write_buffer_size(2 * MAX_MESSAGE_BYTES);
|
||||
let mut socket = timeout(
|
||||
Duration::from_secs(5),
|
||||
tokio_tungstenite::accept_hdr_async_with_config(stream, DebugProtocol, Some(config)),
|
||||
|
|
@ -142,7 +142,7 @@ async fn client(
|
|||
let (respond, response) = oneshot::channel();
|
||||
requests.send(Dispatch { request, respond }).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());
|
||||
}
|
||||
timeout(WRITE_TIMEOUT, socket.send(Message::Text(encoded.into()))).await??;
|
||||
|
|
@ -3,13 +3,19 @@ use std::io::{BufRead, BufReader};
|
|||
use std::net::SocketAddr;
|
||||
use std::process::{Child, Command as ProcessCommand, Stdio};
|
||||
|
||||
use runa_client::{LocalClient, WebSocketClient};
|
||||
use runa_client::WebSocketClient;
|
||||
use runa_protocol::{Command, Reply};
|
||||
use tokio_tungstenite::tungstenite::{self, Message, client::IntoClientRequest};
|
||||
|
||||
fn start_server() -> (ServerProcess, SocketAddr) {
|
||||
let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa-server"))
|
||||
.args(["--listen", "127.0.0.1:0"])
|
||||
start_server_with(&["--listen", "127.0.0.1:0"], "invalid")
|
||||
}
|
||||
|
||||
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())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
|
|
@ -19,7 +25,7 @@ fn start_server() -> (ServerProcess, SocketAddr) {
|
|||
let mut startup = String::new();
|
||||
stderr.read_line(&mut startup).unwrap();
|
||||
let address = startup
|
||||
.strip_prefix("runa-server: listening on ")
|
||||
.strip_prefix("runa: listening on ")
|
||||
.expect("server should report its bound address")
|
||||
.trim()
|
||||
.parse()
|
||||
|
|
@ -30,6 +36,43 @@ fn start_server() -> (ServerProcess, SocketAddr) {
|
|||
(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> {
|
||||
let mut request = format!("ws://{address}/").into_client_request().unwrap();
|
||||
request
|
||||
|
|
@ -195,7 +238,7 @@ fn websocket_rejects_binary_malformed_and_oversized_requests() {
|
|||
for message in [
|
||||
Message::Binary(b"{}".to_vec().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);
|
||||
// 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]
|
||||
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"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
|
|
@ -278,7 +273,7 @@ fn websocket_server_accepts_local_protocol_requests() {
|
|||
let mut startup = String::new();
|
||||
stderr.read_line(&mut startup).unwrap();
|
||||
let address = startup
|
||||
.strip_prefix("runa-server: listening on ")
|
||||
.strip_prefix("runa: listening on ")
|
||||
.expect("server should report its bound address")
|
||||
.trim()
|
||||
.parse::<SocketAddr>()
|
||||
|
|
@ -408,14 +403,3 @@ fn websocket_server_accepts_local_protocol_requests() {
|
|||
actor_ref: echo,
|
||||
}).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),
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ The graphical inspector should support selecting a component, locating its ownin
|
|||
|
||||
## 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):
|
||||
|
||||
|
|
@ -235,11 +235,11 @@ These are desired capabilities, not a requirement to implement every endpoint im
|
|||
|
||||
### 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
|
||||
|
||||
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
|
||||
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:
|
||||
|
||||
```text
|
||||
runa
|
||||
/ | \
|
||||
v v v
|
||||
runa-world runa-server
|
||||
| / \
|
||||
v v v
|
||||
runa-world runa runa-cli
|
||||
| / \ |
|
||||
v v v v
|
||||
runa-image-api runa-protocol
|
||||
| |
|
||||
v |
|
||||
|
|
@ -310,8 +307,9 @@ Arrows mean **"depends on"**, not runtime data flow:
|
|||
|
||||
runa-world also depends on runa-graphics (platform drawing/input
|
||||
contracts); runa-graphics depends only on runa-core when needed.
|
||||
runa-server depends on runa-image-api and runa-protocol.
|
||||
runa wires together world, server, runtime, storage and platform.
|
||||
runa depends on runa-image-api and runa-protocol.
|
||||
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.
|
||||
|
|
@ -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-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-server` | Protocol dispatch, JSON-lines stdio, 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` | Image startup, configuration, protocol dispatch, and loopback-only WebSocket transport | `runa-protocol`, `runa-image-api` |
|
||||
| `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-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
|
||||
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
|
||||
than reaching into the code store and runtime directly. `runa-protocol` now
|
||||
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
|
||||
protocol replies and typed errors. The dispatcher is exposed through local
|
||||
child-process JSON-lines stdio and an unauthenticated WebSocket listener restricted
|
||||
to IPv4 or IPv6 loopback; both enforce a 1 MiB request limit. The WebSocket
|
||||
protocol replies and typed errors. The dispatcher is exposed through an
|
||||
unauthenticated WebSocket listener restricted
|
||||
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
|
||||
client receives full development API access, so the endpoint is for trusted
|
||||
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
|
||||
network access will require authentication and authorization.
|
||||
|
||||
|
|
@ -393,7 +391,7 @@ runa/
|
|||
│ ├── runa-runtime/
|
||||
│ ├── runa-image-api/
|
||||
│ ├── runa-protocol/
|
||||
│ ├── runa-server/
|
||||
│ ├── runa-cli/
|
||||
│ ├── runa-client/
|
||||
│ ├── runa-graphics/
|
||||
│ ├── 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 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
|
||||
embedding a runtime. Multiple clients share actors and behavior revisions;
|
||||
client disconnect/reconnect leaves the running image intact. `runa-client`
|
||||
also launches the stdio server for external local tools. The server provides an
|
||||
client disconnect/reconnect leaves the running image intact. The `runa` server provides an
|
||||
unauthenticated runa-debug WebSocket endpoint restricted to loopback for local VS
|
||||
Code development. Remote access will require authentication and
|
||||
authorization; neither network transport nor client location changes the
|
||||
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
|
||||
|
||||
- Native window/rendering, input, scene manager, Rune-driven button and window.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
# Runa Protocol
|
||||
|
||||
`runa-protocol` defines versioned request, response, and event data contracts
|
||||
for clients and the `runa-server` dispatcher. It is independent of transport
|
||||
and runtime implementation. `runa-server` accepts JSON Lines over stdio or,
|
||||
when started with `--listen`, as WebSocket text messages bound to an IPv4 or
|
||||
IPv6 loopback address. The listener is unauthenticated and intended only for
|
||||
for clients and the `runa` dispatcher. It is independent of transport
|
||||
and runtime implementation. `runa` accepts WebSocket text messages bound to an IPv4 or
|
||||
IPv6 loopback address, defaulting to `127.0.0.1:3737` without arguments.
|
||||
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.
|
||||
|
||||
## Version 3
|
||||
|
|
@ -84,17 +86,17 @@ any of its sends.
|
|||
|
||||
`get_events` drains and returns the events currently accumulated by this
|
||||
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
|
||||
response is emitted when a request cannot be decoded. Stdio EOF terminates the
|
||||
process, while a WebSocket disconnect closes only that client connection. The listener
|
||||
response is emitted when a request cannot be decoded. A WebSocket disconnect
|
||||
closes only that client connection. The listener
|
||||
allows up to 16 simultaneous clients and serializes each dispatch through the
|
||||
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
|
||||
API; loopback binding is not authentication or authorization. Remote access
|
||||
must not be enabled until appropriate authentication and authorization exist.
|
||||
The `runa-client` crate launches the stdio server locally and validates
|
||||
response protocol versions and request IDs. Its `WebSocketClient` connects to
|
||||
The `runa-client` crate validates response protocol versions and request IDs.
|
||||
Its `WebSocketClient` connects to
|
||||
loopback endpoints using bounded WebSocket messages and correlation checks,
|
||||
validating interleaved notifications while waiting for responses.
|
||||
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
|
||||
retrieve them through `list_actors` and `get_behaviors`. The network executable
|
||||
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
|
||||
`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)
|
||||
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
|
||||
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
|
||||
notification, without newline framing. Binary messages and malformed requests
|
||||
terminate the connection. Fragmented text is supported with a 1 MiB aggregate
|
||||
|
|
|
|||
|
|
@ -3,11 +3,15 @@
|
|||
Start the server-owned, in-memory image:
|
||||
|
||||
```sh
|
||||
mise run serve-network
|
||||
mise run start
|
||||
```
|
||||
|
||||
Then connect from another terminal with `cargo run -p runa`, or use
|
||||
`cargo run -p runa -- --connect <loopback-address>` for a custom listener.
|
||||
Then connect from another terminal with `mise run cli`, or use
|
||||
`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
|
||||
inspect the same actors; local bindings and history do not survive a REPL
|
||||
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
|
||||
refreshes on non-destructive server change notifications. These notifications
|
||||
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:
|
||||
|
||||
|
|
@ -79,12 +83,12 @@ also offers references inside a `"$actor"` string.
|
|||
trusted in-process service boundary.
|
||||
- `runa-protocol` defines versioned transport-neutral request, response, and
|
||||
event DTOs; it does not dispatch requests or access runtime state.
|
||||
- `runa-server` dispatches those requests through `runa-image-api` and maps
|
||||
results to protocol replies; its executable accepts newline-delimited JSON
|
||||
over stdin/stdout or JSON text messages over loopback WebSockets, with a
|
||||
- `runa` dispatches those requests through `runa-image-api` and maps
|
||||
results to protocol replies; its executable accepts JSON text messages over
|
||||
loopback WebSockets, with a
|
||||
1 MiB request limit.
|
||||
- `runa-client` provides correlated, bounded stdio and loopback WebSocket clients.
|
||||
- `runa` provides an interactive CLI using the WebSocket client, without an embedded
|
||||
- `runa-client` provides a correlated, bounded loopback WebSocket client.
|
||||
- `runa-cli` provides an interactive CLI using the WebSocket client, without an embedded
|
||||
runtime. Connection errors are explicit; requests are never auto-retried.
|
||||
|
||||
The image API is process-local and intended for trusted callers. It has no
|
||||
|
|
|
|||
12
mise.toml
12
mise.toml
|
|
@ -18,16 +18,12 @@ description = "Test the extension in VS Code (requires a display or xvfb-run)"
|
|||
run = "npm --prefix vscode run test:host"
|
||||
|
||||
[tasks.start]
|
||||
description = "Start the local Runa CLI"
|
||||
description = "Start the Runa image server on localhost"
|
||||
run = "cargo run -p runa"
|
||||
|
||||
[tasks.serve]
|
||||
description = "Run the local JSON-lines protocol server"
|
||||
run = "cargo run -p runa-server"
|
||||
|
||||
[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.cli]
|
||||
description = "Connect the Runa CLI to the image server"
|
||||
run = "cargo run -p runa-cli"
|
||||
|
||||
[tasks.lint]
|
||||
description = "Check Rust targets and the TypeScript extension"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ From the repository root:
|
|||
|
||||
```sh
|
||||
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**
|
||||
|
|
@ -24,7 +24,7 @@ In that host:
|
|||
|
||||
1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`;
|
||||
`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.
|
||||
3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot.
|
||||
4. Run **Runa: Disconnect** to close only this client connection.
|
||||
|
|
|
|||
|
|
@ -32,9 +32,10 @@ test(
|
|||
const executable = resolve(
|
||||
__dirname,
|
||||
"../../../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"],
|
||||
});
|
||||
t.after(() => {
|
||||
|
|
@ -69,5 +70,16 @@ test(
|
|||
assert.deepEqual(actor.state, { type: "integer", data: 12n });
|
||||
assert.deepEqual(actor.queued, []);
|
||||
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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export async function startImage(): Promise<{ address: string; stop: () => Promi
|
|||
const executable = resolve(
|
||||
__dirname,
|
||||
"../../../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"], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
|
|
@ -41,7 +41,7 @@ export async function startImage(): Promise<{ address: string; stop: () => Promi
|
|||
child.once("exit", exited);
|
||||
lines.on("line", (line) => {
|
||||
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) {
|
||||
cleanup();
|
||||
resolve(address);
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@
|
|||
"lint": "tsc -p . --noEmit && prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"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:host": "cargo build --manifest-path ../Cargo.toml -p runa-server && npm run compile && node scripts/test-host.cjs"
|
||||
"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 && npm run compile && node scripts/test-host.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"lossless-json": "^4.3.0",
|
||||
|
|
|
|||
|
|
@ -176,8 +176,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
(await vscode.window.showInputBox({
|
||||
title: "Connect to a trusted local Runa image (no authentication)",
|
||||
value: vscode.workspace.getConfiguration("runa").get<string>("address", "127.0.0.1:3737"),
|
||||
prompt:
|
||||
"Start the server with mise run serve-network. The extension does not start an image.",
|
||||
prompt: "Start the server with mise run start. The extension does not start an image.",
|
||||
validateInput: (value) => {
|
||||
try {
|
||||
parseEndpoint(value);
|
||||
|
|
|
|||
Loading…
Reference in New Issue