feat!: connect the REPL to the localhost image

Add a reusable loopback TCP client, server-owned counter bootstrap, and shared-image reconnect coverage. Preserve actor state and behavior revisions across client disconnects without retrying uncertain requests.

BREAKING CHANGE: the REPL no longer starts an embedded image. Start runa-server with --listen before connecting; use --connect to select a loopback endpoint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-02 15:57:26 +01:00
parent 4f6e7ccfa2
commit 350ddba0ce
11 changed files with 371 additions and 60 deletions

View File

@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
### Added
- Reusable loopback TCP protocol client with bounded messages, correlation
checks, explicit connection failures, and no automatic request retries.
- Multi-client and reconnect tests proving that actors remain in the
server-owned image after development clients disconnect.
- Typed, instruction-sliced Rune invocations with preserved async execution,
wake-driven resumption, and cancellation tests.
- Minimal single-worker actor runtime with bounded FIFO mailboxes, private
@ -70,6 +74,10 @@ All notable changes to this project will be documented in this file.
### Changed
- The REPL now connects to the localhost server rather than embedding its own
image. Start the server first; `--connect` selects a loopback endpoint.
- TCP server startup publishes the bundled counter behavior once; connecting
or disconnecting clients does not reset behavior revisions or actor state.
- Interactive CLI commands now use the versioned protocol dispatcher instead
of calling the image API directly.
- Protocol version 2 replaces numeric actor IDs with NanoID actor references

1
Cargo.lock generated
View File

@ -551,6 +551,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
name = "runa"
version = "0.0.0"
dependencies = [
"runa-client",
"runa-protocol",
"runa-server",
"rustyline",

View File

@ -21,8 +21,8 @@ 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 TCP listener restricted to localhost;
both use a bounded request size. The interactive CLI now uses the versioned
protocol and dispatcher rather than calling the image API directly.
both use a bounded request size. The interactive CLI connects to the localhost
server over TCP, so multiple clients operate on the same server-owned image.
The prototype uses worker-local invocation stacks and cooperative instruction
slices, and actor snapshots report basic active-execution progress. Native
@ -45,12 +45,25 @@ architecture, roadmap, and open questions.
You need a Rust toolchain that supports the workspace's Rust 2024 edition.
Run the current executable from the repository root:
Start the live image from the repository root:
```sh
mise run run
mise run serve-network
```
In another terminal, connect the REPL:
```sh
cargo run -p runa
```
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
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.
@ -61,14 +74,18 @@ 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 and exchange typed
protocol requests with request/response correlation. The VS Code extension is
The `runa-client` crate can launch the stdio process or connect over loopback
TCP and exchange typed protocol requests with request/response correlation.
TCP connection establishment is limited to 5 seconds and reads/writes to 30
seconds. Transport failures disconnect the client and are never automatically
retried: an operation may have committed even if its response was lost.
The VS Code extension is
intended to connect over the network to this same live image; actors remain in
the image regardless of how clients connect. Remote network access requires
authentication and authorization, which are not implemented.
The CLI opens an interactive runtime session with the `counter` behavior
published:
The TCP server publishes the bundled `counter` behavior once at startup;
connecting a client does not publish or reset behavior revisions:
```text
runa> let sender = spawn counter 0
@ -133,7 +150,7 @@ test task runs `cargo test --workspace`.
│ ├── 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/ # Local child-process protocol client
│ └── runa-client/ # Stdio child-process and loopback TCP clients
├── docs/
│ ├── architecture.md
│ ├── protocol.md

View File

@ -1,9 +1,11 @@
//! Local child-process client for the versioned JSON-lines protocol.
//! Stdio and loopback TCP clients for the versioned JSON-lines protocol.
use std::ffi::OsStr;
use std::fmt;
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::net::{Shutdown, SocketAddr, TcpStream};
use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
use std::time::Duration;
use runa_protocol::{Command as ProtocolCommand, PROTOCOL_VERSION, Request, Response};
@ -20,6 +22,51 @@ pub struct ProtocolClient<R, W> {
next_request_id: u64,
}
pub struct TcpClient {
client: ProtocolClient<BufReader<TcpStream>, BufWriter<TcpStream>>,
disconnected: bool,
}
impl TcpClient {
/// Connect only to loopback, without authentication or automatic retries.
pub fn connect(address: SocketAddr) -> io::Result<Self> {
if !address.ip().is_loopback() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"server address must be a loopback address",
));
}
let stream = TcpStream::connect_timeout(&address, Duration::from_secs(5))?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(30)))?;
Ok(Self {
client: ProtocolClient::new(
BufReader::new(stream.try_clone()?),
BufWriter::new(stream),
),
disconnected: false,
})
}
pub fn request(&mut self, command: ProtocolCommand) -> Result<Response, ClientError> {
if self.disconnected {
return Err(io::Error::new(
io::ErrorKind::NotConnected,
"TCP client is disconnected; reconnect explicitly",
)
.into());
}
let result = self.client.request(command);
if result.is_err() {
self.disconnected = true;
// A lost response may follow a committed operation. Never retry it
// or reuse a stream whose request/response alignment is uncertain.
let _ = self.client.writer.get_ref().shutdown(Shutdown::Both);
}
result
}
}
#[derive(Debug)]
pub enum ClientError {
Io(io::Error),
@ -34,7 +81,7 @@ pub enum ClientError {
impl fmt::Display for ClientError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "local protocol transport failed: {error}"),
Self::Io(error) => write!(formatter, "protocol transport failed: {error}"),
Self::Serialization(error) => write!(formatter, "protocol JSON failed: {error}"),
Self::RequestIdsExhausted => write!(formatter, "protocol request IDs exhausted"),
Self::RequestTooLarge => write!(
@ -162,7 +209,7 @@ impl<R: BufRead, W: Write> ProtocolClient<R, W> {
if !has_line {
return Err(ClientError::Io(io::Error::new(
io::ErrorKind::UnexpectedEof,
"server closed stdout before responding",
"server closed the connection before responding",
)));
}
let response: Response = serde_json::from_slice(&line)?;
@ -220,6 +267,35 @@ mod tests {
use runa_protocol::{ProtocolError, Reply};
use std::io::Cursor;
#[test]
fn tcp_client_rejects_non_loopback_addresses() {
let error = TcpClient::connect("192.0.2.1:3737".parse().unwrap())
.err()
.expect("non-loopback connection must be rejected");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn tcp_client_reports_disconnect_and_does_not_retry() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let peer = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let request: Request = serde_json::from_str(&line).unwrap();
assert_eq!(request.command, ProtocolCommand::ListActors);
// Close after receiving the request, without sending a response.
});
let mut client = TcpClient::connect(address).unwrap();
assert!(matches!(client.request(ProtocolCommand::ListActors),
Err(ClientError::Io(error)) if error.kind() == io::ErrorKind::UnexpectedEof));
assert!(matches!(client.request(ProtocolCommand::ListActors),
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,

View File

@ -2,8 +2,26 @@ 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_tcp};
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,
@ -37,7 +55,7 @@ fn run() -> io::Result<()> {
format!("invalid listener address: {error}"),
)
})?;
serve_tcp(bind_loopback_listener(address)?, Server::new())
serve_tcp(bind_loopback_listener(address)?, initial_image()?)
}
Some(_) => Err(io::Error::new(
io::ErrorKind::InvalidInput,

View File

@ -3,7 +3,7 @@ use std::net::{Shutdown, SocketAddr, TcpStream};
use std::process::{Child, Command as ProcessCommand, Stdio};
use std::time::Duration;
use runa_client::LocalClient;
use runa_client::{LocalClient, TcpClient};
use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response};
struct ServerProcess(Child);
@ -104,4 +104,70 @@ fn tcp_server_accepts_local_protocol_requests() {
let response: Response = serde_json::from_str(&response_line).unwrap();
assert_eq!(response.request_id, "tcp-process-test");
assert_eq!(response.result, Ok(Reply::Actors(Vec::new())));
let mut first = TcpClient::connect(address).unwrap();
let mut second = TcpClient::connect(address).unwrap();
let actor_ref = match first
.request(Command::Spawn {
behavior_name: "counter".to_owned(),
state: 5,
mailbox_capacity: 2,
})
.unwrap()
.result
.unwrap()
{
Reply::ActorSpawned { actor_ref } => actor_ref,
other => panic!("unexpected spawn response: {other:?}"),
};
assert!(matches!(second.request(Command::InspectActor {
actor_ref: actor_ref.clone(),
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == 5));
assert!(matches!(
first
.request(Command::RollbackBehavior {
name: "counter".to_owned(),
expected_revision: 1,
target_revision: 1,
})
.unwrap()
.result,
Ok(Reply::Behavior(behavior)) if behavior.revision == 2
));
drop(first);
drop(second);
let mut reconnected = TcpClient::connect(address).unwrap();
assert!(matches!(
reconnected
.request(Command::GetBehavior {
name: "counter".to_owned(),
})
.unwrap()
.result,
Ok(Reply::Behavior(behavior)) if behavior.revision == 2
));
reconnected
.request(Command::Send {
actor_ref: actor_ref.clone(),
message: MessageDto {
kind: 0,
value: 7,
target_ref: None,
},
})
.unwrap()
.result
.unwrap();
reconnected
.request(Command::Tick {
ticks: 100,
instruction_budget: 10,
})
.unwrap()
.result
.unwrap();
assert!(matches!(reconnected.request(Command::InspectActor {
actor_ref,
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == 12));
}

View File

@ -4,6 +4,9 @@ version = "0.0.0"
edition = "2024"
[dependencies]
runa-client = { path = "../runa-client" }
runa-protocol = { path = "../runa-protocol" }
runa-server = { path = "../runa-server" }
rustyline = "15.0"
[dev-dependencies]
runa-server = { path = "../runa-server" }

View File

@ -2,9 +2,12 @@ use std::collections::BTreeMap;
#[cfg(test)]
use std::io::BufRead;
use std::io::{self, Write};
use std::net::SocketAddr;
use std::num::NonZeroUsize;
use runa_protocol::{Command, MessageDto, PROTOCOL_VERSION, Reply, Request, Response};
use runa_client::TcpClient;
use runa_protocol::{Command, MessageDto, Reply};
#[cfg(test)]
use runa_server::Server;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
@ -16,8 +19,38 @@ use rustyline::{Context, Editor, Helper};
const MAILBOX_CAPACITY: usize = 64;
const DEFAULT_TICK_BUDGET: usize = 32;
#[cfg(test)]
const COUNTER_BEHAVIOR: &str = "counter";
trait Connection {
fn dispatch(&mut self, command: Command) -> Result<Reply, String>;
}
impl Connection for TcpClient {
fn dispatch(&mut self, command: Command) -> Result<Reply, String> {
self.request(command)
.map_err(|error| error.to_string())?
.result
.map_err(|error| error.message)
}
}
#[cfg(test)]
impl Connection for Server {
fn dispatch(&mut self, command: Command) -> Result<Reply, String> {
Server::dispatch(
self,
runa_protocol::Request {
version: runa_protocol::PROTOCOL_VERSION,
request_id: "runa-cli-test".to_owned(),
command,
},
)
.result
.map_err(|error| error.message)
}
}
#[derive(Default)]
struct ReplState {
bindings: BTreeMap<String, String>,
@ -134,34 +167,34 @@ impl Validator for ReplHelper {}
impl Helper for ReplHelper {}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut server = Server::new();
let behavior = match dispatch(
&mut server,
Command::PublishBehavior {
name: COUNTER_BEHAVIOR.to_owned(),
expected_revision: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
},
)? {
Reply::Behavior(behavior) => behavior,
other => return Err(format!("unexpected publish response: {other:?}").into()),
};
let address = parse_address(&std::env::args().skip(1).collect::<Vec<_>>())?;
let mut server = TcpClient::connect(address).map_err(|error| {
format!("cannot connect to {address}: {error}; start `mise run serve-network` first")
})?;
println!("Runa — Rune Actors");
println!(
"Loaded behavior `{}` revision {}.",
behavior.name, behavior.revision
);
println!("Connected to {address}. The image remains running when you quit.");
println!("Type `help` for commands. Use Up/Down for history and Tab for completion.");
run_interactive_repl(&mut server)?;
Ok(())
}
fn parse_address(args: &[String]) -> Result<SocketAddr, String> {
let address = match args {
[] => "127.0.0.1:3737",
[flag, address] if flag == "--connect" => address,
_ => return Err("usage: runa [--connect <loopback-address>]".to_owned()),
};
address
.parse()
.map_err(|error| format!("invalid server address: {error}"))
}
#[cfg(test)]
fn run_repl<R: BufRead, W: Write>(
mut input: R,
mut output: W,
server: &mut Server,
server: &mut dyn Connection,
) -> io::Result<()> {
let mut state = ReplState::default();
let mut line = String::new();
@ -180,7 +213,7 @@ fn run_repl<R: BufRead, W: Write>(
Ok(())
}
fn run_interactive_repl(server: &mut Server) -> Result<(), Box<dyn std::error::Error>> {
fn run_interactive_repl(server: &mut dyn Connection) -> Result<(), Box<dyn std::error::Error>> {
let mut editor = Editor::<ReplHelper, DefaultHistory>::new()?;
editor.set_helper(Some(ReplHelper::default()));
let mut state = ReplState::default();
@ -220,7 +253,7 @@ fn run_interactive_repl(server: &mut Server) -> Result<(), Box<dyn std::error::E
fn execute_line<W: Write>(
line: &str,
output: &mut W,
server: &mut Server,
server: &mut dyn Connection,
state: &mut ReplState,
) -> io::Result<bool> {
let words: Vec<_> = line.split_whitespace().collect();
@ -363,7 +396,7 @@ fn execute_line<W: Write>(
fn lifecycle_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut Server,
server: &mut dyn Connection,
state: &ReplState,
operation: impl FnOnce(String) -> Command,
) -> io::Result<()> {
@ -377,7 +410,11 @@ fn lifecycle_command<W: Write>(
}
}
fn spawn_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> {
fn spawn_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut dyn Connection,
) -> io::Result<()> {
let [behavior_name, initial_state] = args else {
return writeln!(
output,
@ -393,7 +430,7 @@ fn spawn_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -
fn spawn_actor(
behavior_name: &str,
initial_state: &str,
server: &mut Server,
server: &mut dyn Connection,
) -> Result<String, String> {
let state = initial_state
.parse()
@ -437,7 +474,11 @@ fn is_binding_name(name: &str) -> bool {
&& characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
}
fn publish_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> {
fn publish_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut dyn Connection,
) -> io::Result<()> {
let [name, expected_revision, source_path] = args else {
return writeln!(
output,
@ -477,7 +518,11 @@ fn publish_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server)
)
}
fn history_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> {
fn history_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut dyn Connection,
) -> io::Result<()> {
let [name] = args else {
return writeln!(output, "error: usage: history <behavior-name>");
};
@ -509,7 +554,7 @@ fn history_command<W: Write>(output: &mut W, args: &[&str], server: &mut Server)
fn rollback_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut Server,
server: &mut dyn Connection,
) -> io::Result<()> {
let [name, expected_revision, target_revision] = args else {
return writeln!(
@ -546,7 +591,7 @@ fn rollback_command<W: Write>(
fn install_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut Server,
server: &mut dyn Connection,
state: &ReplState,
) -> io::Result<()> {
let [actor_arg, name] = args else {
@ -588,13 +633,8 @@ fn install_command<W: Write>(
}
}
fn dispatch(server: &mut Server, command: Command) -> Result<Reply, String> {
let response: Response = server.dispatch(Request {
version: PROTOCOL_VERSION,
request_id: "runa-cli".to_owned(),
command,
});
response.result.map_err(|error| error.message)
fn dispatch(server: &mut dyn Connection, command: Command) -> Result<Reply, String> {
server.dispatch(command)
}
fn unexpected_reply<W: Write>(output: &mut W, reply: Reply) -> io::Result<()> {
@ -675,6 +715,68 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
mod tests {
use super::*;
#[test]
fn connection_arguments_require_an_explicit_socket_address() {
assert_eq!(
parse_address(&[]).unwrap(),
"127.0.0.1:3737".parse::<SocketAddr>().unwrap()
);
assert_eq!(
parse_address(&["--connect".to_owned(), "[::1]:4000".to_owned()]).unwrap(),
"[::1]:4000".parse::<SocketAddr>().unwrap()
);
assert!(parse_address(&["--connect".to_owned()]).is_err());
assert!(parse_address(&["--connect".to_owned(), "invalid".to_owned()]).is_err());
}
#[test]
fn tcp_repl_reconnects_to_the_same_image() -> Result<(), Box<dyn std::error::Error>> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let address = listener.local_addr()?;
let worker = std::thread::spawn(move || {
let mut image = server();
for _ in 0..2 {
let (stream, _) = listener.accept().unwrap();
runa_server::serve_json_lines(
&mut image,
io::BufReader::new(stream.try_clone().unwrap()),
stream,
)
.unwrap();
}
});
let mut client = TcpClient::connect(address)?;
let mut output = Vec::new();
run_repl(
io::Cursor::new("let actor = spawn counter 5\nsend actor 0 7\ntick 100\nquit\n"),
&mut output,
&mut client,
)?;
let output = String::from_utf8(output)?;
let actor_ref = output
.lines()
.find_map(|line| line.strip_prefix("runa> actor = @"))
.ok_or("spawn did not return an actor reference")?;
assert_eq!(actor_ref.len(), 21);
drop(client);
let mut client = TcpClient::connect(address)?;
let mut output = Vec::new();
run_repl(
io::Cursor::new(format!("actors\ninspect @{actor_ref}\nquit\n")),
&mut output,
&mut client,
)?;
let output = String::from_utf8(output)?;
assert!(output.contains(actor_ref));
assert!(output.contains("state: 12"));
assert!(!output.contains("error:"));
drop(client);
worker.join().expect("TCP image worker completed");
Ok(())
}
fn server() -> Server {
let mut server = Server::new();
dispatch(

View File

@ -452,9 +452,11 @@ 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 uses the
same protocol commands and dispatcher in-process. `runa-client` launches the
stdio server for external local tools. The server also provides an
exposed through bounded local JSON-lines stdio. The interactive CLI now uses
`runa-client` to connect over loopback TCP 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
unauthenticated TCP JSON-lines endpoint restricted to loopback for local VS
Code development. Remote access will require authentication and
authorization; neither network transport nor client location changes the

View File

@ -62,7 +62,18 @@ response queued per client. 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.
response protocol versions and request IDs. Its `TcpClient` also connects to
loopback endpoints using the same bounded framing and correlation checks.
Connection establishment has a 5-second timeout; reads and writes have
30-second timeouts. A failed TCP request invalidates that client connection.
No automatic retry is performed: loss of a response does not imply that the
operation failed to commit.
The interactive REPL uses TCP and never owns the image. Disconnecting clients
does not stop actors or reset behavior revisions; reconnecting clients can
retrieve them through `list_actors` and `get_behaviors`. The TCP executable
publishes the bundled `counter` behavior on image startup, not on connection.
The stdio executable continues to start with an empty image.
The current validation checks the protocol version, request ID, actor-reference
shape, positive revisions where required, non-empty behavior names, mailbox

View File

@ -1,12 +1,18 @@
# Phase 1 actor prototype
Start the interactive, in-memory runtime session with:
Start the server-owned, in-memory image:
```sh
mise run run
mise run serve-network
```
The executable publishes [counter.rn](../runa-std/actors/counter.rn) as the
Then connect from another terminal with `cargo run -p runa`, or use
`cargo run -p runa -- --connect <loopback-address>` for a custom listener.
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.
The TCP server publishes [counter.rn](../runa-std/actors/counter.rn) as the
`counter` behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`,
`events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`,
`publish`, `history`, `rollback`, and `install`; enter `help` to see usage.
@ -16,8 +22,8 @@ current revision and a target historical revision. `install` selects a named
behavior's current implementation and fails if the actor is not at an idle
handler boundary. `spawn` requires a behavior name and initial state, in that
order. All commands operate on the same process-local runtime and code store.
The CLI uses the versioned protocol dispatcher, also available over stdio or
the loopback-only TCP listener for local development.
The CLI sends versioned protocol requests to the loopback-only TCP listener.
The separate stdio endpoint starts an empty image for child-process clients.
Example:
@ -57,8 +63,9 @@ instructions.
- `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 TCP bound to loopback, with a 1 MiB request-line limit.
- `runa` provides an interactive CLI client using protocol requests through the
dispatcher.
- `runa-client` provides correlated, bounded stdio and loopback TCP clients.
- `runa` provides an interactive CLI using the TCP 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
transport, authentication, or authorization layer; do not expose it directly