diff --git a/CHANGELOG.md b/CHANGELOG.md index 73e034f..7c612f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 6318daf..6fb2cfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" name = "runa" version = "0.0.0" dependencies = [ + "runa-client", "runa-protocol", "runa-server", "rustyline", diff --git a/README.md b/README.md index 11ab684..48a3911 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/runa-client/src/lib.rs b/crates/runa-client/src/lib.rs index f652999..ca99699 100644 --- a/crates/runa-client/src/lib.rs +++ b/crates/runa-client/src/lib.rs @@ -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 { next_request_id: u64, } +pub struct TcpClient { + client: ProtocolClient, BufWriter>, + disconnected: bool, +} + +impl TcpClient { + /// Connect only to loopback, without authentication or automatic retries. + pub fn connect(address: SocketAddr) -> io::Result { + 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 { + 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 ProtocolClient { 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 { let response = Response { version: PROTOCOL_VERSION, diff --git a/crates/runa-server/src/main.rs b/crates/runa-server/src/main.rs index 594f9df..aee5be3 100644 --- a/crates/runa-server/src/main.rs +++ b/crates/runa-server/src/main.rs @@ -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 { + 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, diff --git a/crates/runa-server/tests/local_client.rs b/crates/runa-server/tests/local_client.rs index 08e7591..a6cc489 100644 --- a/crates/runa-server/tests/local_client.rs +++ b/crates/runa-server/tests/local_client.rs @@ -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)); } diff --git a/crates/runa/Cargo.toml b/crates/runa/Cargo.toml index 751f324..64eaef6 100644 --- a/crates/runa/Cargo.toml +++ b/crates/runa/Cargo.toml @@ -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" } diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index 23616c2..69d1707 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -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; +} + +impl Connection for TcpClient { + fn dispatch(&mut self, command: Command) -> Result { + 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 { + 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, @@ -134,34 +167,34 @@ impl Validator for ReplHelper {} impl Helper for ReplHelper {} fn main() -> Result<(), Box> { - 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::>())?; + 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 { + let address = match args { + [] => "127.0.0.1:3737", + [flag, address] if flag == "--connect" => address, + _ => return Err("usage: runa [--connect ]".to_owned()), + }; + address + .parse() + .map_err(|error| format!("invalid server address: {error}")) +} + #[cfg(test)] fn run_repl( 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( Ok(()) } -fn run_interactive_repl(server: &mut Server) -> Result<(), Box> { +fn run_interactive_repl(server: &mut dyn Connection) -> Result<(), Box> { let mut editor = Editor::::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( line: &str, output: &mut W, - server: &mut Server, + server: &mut dyn Connection, state: &mut ReplState, ) -> io::Result { let words: Vec<_> = line.split_whitespace().collect(); @@ -363,7 +396,7 @@ fn execute_line( fn lifecycle_command( 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( } } -fn spawn_command(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { +fn spawn_command( + 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(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 { 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(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { +fn publish_command( + 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(output: &mut W, args: &[&str], server: &mut Server) ) } -fn history_command(output: &mut W, args: &[&str], server: &mut Server) -> io::Result<()> { +fn history_command( + output: &mut W, + args: &[&str], + server: &mut dyn Connection, +) -> io::Result<()> { let [name] = args else { return writeln!(output, "error: usage: history "); }; @@ -509,7 +554,7 @@ fn history_command(output: &mut W, args: &[&str], server: &mut Server) fn rollback_command( 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( fn install_command( 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( } } -fn dispatch(server: &mut Server, command: Command) -> Result { - 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 { + server.dispatch(command) } fn unexpected_reply(output: &mut W, reply: Reply) -> io::Result<()> { @@ -675,6 +715,68 @@ fn print_help(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::().unwrap() + ); + assert_eq!( + parse_address(&["--connect".to_owned(), "[::1]:4000".to_owned()]).unwrap(), + "[::1]:4000".parse::().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> { + 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( diff --git a/docs/architecture.md b/docs/architecture.md index 3d488f0..75556ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/protocol.md b/docs/protocol.md index fda597f..ce46352 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/docs/runtime.md b/docs/runtime.md index 58d2bd3..80ad181 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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 ` 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