diff --git a/CHANGELOG.md b/CHANGELOG.md index fb6ff7d..a18212d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,10 @@ All notable changes to this project will be documented in this file. ### Added +- Server startup restoration with `--restore ` / `RUNA_RESTORE`, reusing + snapshot validation and actor scheduling rules without merging bundled + sources or falling back to a fresh image on errors. + - Explicit versioned disk image save/restore through the CLI, REPL, protocol and VS Code commands. Preserve source/history, actor identities, committed state, mailbox envelopes and retained logs; reject unfinished handlers, pause runnable diff --git a/README.md b/README.md index 1050005..2c75c06 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,21 @@ cargo run -p runa-cli -- save /path/to/image.runa.json cargo run -p runa-cli -- restore /path/to/image.runa.json ``` +To open the snapshot when starting the server, use: + +```sh +cargo run -p runa -- --restore /path/to/image.runa.json +# Alternatively: +RUNA_RESTORE=/path/to/image.runa.json mise run start +``` + +`--restore` overrides `RUNA_RESTORE`. Startup restoration validates the snapshot +before accepting clients and does not add or overwrite bundled sources. +Missing, malformed or incompatible snapshots fail startup rather than silently +starting a fresh image. Without either option, startup loads the bundled counter +source as before. Restored actors follow the same status rules as live restore; +the server does not execute messages until scheduler ticks are requested. + The REPL also accepts `save ` and `restore `. VS Code provides **Runa: Save Image...** and **Runa: Restore Image...**. Paths refer to the server's filesystem, not the client's. Saving requires a new destination file. diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index 5e1fc9d..5363cd8 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -12,19 +12,28 @@ struct Args { /// Loopback WebSocket listen address (port 0 selects an ephemeral port). #[arg(long, env = "RUNA_LISTEN", default_value = "127.0.0.1:3737")] listen: SocketAddr, + /// Restore a server-local image snapshot instead of loading bundled sources. + #[arg(long, env = "RUNA_RESTORE")] + restore: Option, } -fn initial_image() -> io::Result { +fn initial_image(restore: Option<&str>) -> io::Result { let mut server = Server::new(); + let command = match restore { + Some(file) => Command::RestoreImage { + file: file.to_owned(), + }, + None => Command::WriteSource { + path: "actors/counter".to_owned(), + expected_source_id: None, + source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), + }, + }; server .dispatch(Request { version: PROTOCOL_VERSION, - request_id: "bootstrap-counter".to_owned(), - command: Command::WriteSource { - path: "actors/counter".to_owned(), - expected_source_id: None, - source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), - }, + request_id: "bootstrap-image".to_owned(), + command, }) .result .map_err(|error| io::Error::other(error.message))?; @@ -34,7 +43,7 @@ fn initial_image() -> io::Result { fn main() -> ExitCode { let args = Args::parse(); match bind_loopback_listener(args.listen) - .and_then(|listener| serve_websocket(listener, initial_image()?)) + .and_then(|listener| serve_websocket(listener, initial_image(args.restore.as_deref())?)) { Ok(()) => ExitCode::SUCCESS, Err(error) => { @@ -67,4 +76,20 @@ mod tests { assert!(Args::try_parse_from(["runa", "--stdio"]).is_err()); assert!(Args::try_parse_from(["runa", "extra"]).is_err()); } + + #[test] + fn clap_accepts_restore_and_rejects_missing_file() { + let args = Args::try_parse_from(["runa", "--restore", "/images/saved image.json"]).unwrap(); + assert_eq!(args.restore.as_deref(), Some("/images/saved image.json")); + assert!(Args::try_parse_from(["runa", "--restore"]).is_err()); + let command = ::command(); + let restore = command + .get_arguments() + .find(|argument| argument.get_id() == "restore") + .unwrap(); + assert_eq!( + restore.get_env(), + Some(std::ffi::OsStr::new("RUNA_RESTORE")) + ); + } } diff --git a/crates/runa/tests/local_client.rs b/crates/runa/tests/local_client.rs index 7d40008..7710298 100644 --- a/crates/runa/tests/local_client.rs +++ b/crates/runa/tests/local_client.rs @@ -39,9 +39,23 @@ fn install_counter_fixture(client: &mut WebSocketClient) { } fn start_server_with(args: &[&str], listen_env: &str) -> (ServerProcess, SocketAddr) { - let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa")) + start_server_with_restore(args, listen_env, None) +} + +fn start_server_with_restore( + args: &[&str], + listen_env: &str, + restore: Option<&str>, +) -> (ServerProcess, SocketAddr) { + let mut command = ProcessCommand::new(env!("CARGO_BIN_EXE_runa")); + command .args(args) .env("RUNA_LISTEN", listen_env) + .env_remove("RUNA_RESTORE"); + if let Some(file) = restore { + command.env("RUNA_RESTORE", file); + } + let child = command .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -78,6 +92,7 @@ fn server_clap_supports_env_overrides_help_and_explicit_errors() { let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa")) .args(args) .env_remove("RUNA_LISTEN") + .env_remove("RUNA_RESTORE") .output() .unwrap(); assert!(!output.status.success()); @@ -86,12 +101,167 @@ fn server_clap_supports_env_overrides_help_and_explicit_errors() { let help = ProcessCommand::new(env!("CARGO_BIN_EXE_runa")) .arg("--help") .env_remove("RUNA_LISTEN") + .env_remove("RUNA_RESTORE") .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")); + assert!(help.contains("RUNA_RESTORE")); +} + +#[test] +fn startup_restore_uses_saved_image_without_bundled_sources_and_honors_env_overrides() { + use runa_image_api::ImageApi; + use std::num::NonZeroUsize; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("saved image.json"); + let file = path.to_str().unwrap(); + let mut image = ImageApi::new(); + image + .write_source( + "tests/startup", + None, + "pub fn handle(ctx, state, sender, message) { state + message }", + ) + .unwrap(); + let actor = image + .spawn( + "tests/startup", + Value::Integer(8), + NonZeroUsize::new(2).unwrap(), + ) + .unwrap(); + let paused = image + .spawn( + "tests/startup", + Value::Integer(9), + NonZeroUsize::new(2).unwrap(), + ) + .unwrap(); + image.pause(paused).unwrap(); + image.send(actor, Value::Integer(3)).unwrap(); + let mut server = runa::Server::with_image_api(image); + server + .dispatch(runa_protocol::Request { + version: runa_protocol::PROTOCOL_VERSION, + request_id: "save-startup".into(), + command: Command::SaveImage { file: file.into() }, + }) + .result + .unwrap(); + let saved_bytes = std::fs::read(&path).unwrap(); + + for (args, env) in [ + ( + vec!["--listen", "127.0.0.1:0", "--restore", file], + Some("missing.json"), + ), + (vec!["--listen", "127.0.0.1:0"], Some(file)), + ] { + let (_server, address) = start_server_with_restore(&args, "invalid", env); + let mut client = WebSocketClient::connect(address).unwrap(); + let Reply::Sources(sources) = client + .request(Command::ListSources) + .unwrap() + .result + .unwrap() + else { + panic!("expected sources") + }; + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].path, "tests/startup"); + let Reply::Actor(before) = client + .request(Command::InspectActor { + actor_ref: actor.to_string(), + }) + .unwrap() + .result + .unwrap() + else { + panic!("expected actor") + }; + assert_eq!(before.state, Value::Integer(8)); + assert_eq!(before.incarnation, 2); + assert_eq!(before.status, runa_protocol::ActorStatusDto::Idle); + client + .request(Command::Tick { + ticks: 1, + instruction_budget: 1000, + }) + .unwrap() + .result + .unwrap(); + let Reply::Actor(after) = client + .request(Command::InspectActor { + actor_ref: actor.to_string(), + }) + .unwrap() + .result + .unwrap() + else { + panic!("expected actor") + }; + assert_eq!(after.state, Value::Integer(11)); + let Reply::Actor(after) = client + .request(Command::InspectActor { + actor_ref: paused.to_string(), + }) + .unwrap() + .result + .unwrap() + else { + panic!("expected actor") + }; + assert_eq!(after.state, Value::Integer(9)); + assert_eq!(after.status, runa_protocol::ActorStatusDto::Paused); + client.close().unwrap(); + } + // The file and its original lifecycle statuses are not changed by startup. + assert_eq!(std::fs::read(path).unwrap(), saved_bytes); +} + +#[test] +fn startup_restore_failures_exit_without_advertising_a_live_image() { + let directory = tempfile::tempdir().unwrap(); + let invalid = directory.path().join("invalid.json"); + std::fs::write(&invalid, "{").unwrap(); + let missing = directory.path().join("missing.json"); + let incompatible = directory.path().join("incompatible.json"); + let mut snapshot = + serde_json::to_value(runa_image_api::ImageApi::new().snapshot().unwrap()).unwrap(); + snapshot["version"] = 999.into(); + std::fs::write(&incompatible, serde_json::to_vec(&snapshot).unwrap()).unwrap(); + for file in [ + invalid.to_str().unwrap(), + missing.to_str().unwrap(), + incompatible.to_str().unwrap(), + "", + ] { + let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa")) + .args(["--listen", "127.0.0.1:0", "--restore", file]) + .env_remove("RUNA_LISTEN") + .env_remove("RUNA_RESTORE") + .output() + .unwrap(); + assert!(!output.status.success()); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains("runa:"), "{error}"); + assert!(!error.contains("listening on"), "{error}"); + } + let output = ProcessCommand::new(env!("CARGO_BIN_EXE_runa")) + .args(["--listen", "127.0.0.1:0"]) + .env("RUNA_RESTORE", missing) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + !String::from_utf8(output.stderr) + .unwrap() + .contains("listening on") + ); } fn watcher(address: SocketAddr) -> tungstenite::WebSocket { @@ -286,6 +456,7 @@ impl Drop for ServerProcess { fn websocket_server_accepts_local_protocol_requests() { let child = ProcessCommand::new(env!("CARGO_BIN_EXE_runa")) .args(["--listen", "127.0.0.1:0"]) + .env_remove("RUNA_RESTORE") .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() diff --git a/docs/architecture.md b/docs/architecture.md index 4f6b09c..e46073b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -324,6 +324,10 @@ mailbox envelopes/capacities and retained logs are included. See [runtime guarantees](runtime.md#image-save-and-restore) for exact scope. SQLite-backed per-transition durability, supervised recovery and execution-stack restoration remain planned. +The server can explicitly open a snapshot on startup using `--restore` or +`RUNA_RESTORE`, with the same validation and scheduling rules as live restore. +Restored images are not merged with bundled sources, and failed restoration +does not fall back to a new image or serve clients. ## 11. Workspace Boundaries and Dependency Direction diff --git a/docs/runtime.md b/docs/runtime.md index 7c237fe..e126c53 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -449,6 +449,15 @@ capacity, and successful message effects. ## Image save and restore +The server accepts `--restore ` or `RUNA_RESTORE` to open an image at +startup; the explicit flag takes precedence. Startup uses the same restore +validation and incarnation/status rules as the protocol operation, without +bootstrapping bundled sources into the restored image. The listener is not +advertised and clients are not served until restoration succeeds. File, +validation or compilation failures exit with an error; there is no fallback +to a fresh image. Without a restore option, the bundled counter source is loaded +as before. No automatic scheduler ticks or failed-message retries are introduced. + `save ` and `restore ` are available in the CLI/REPL and through the trusted image protocol. Files are server-local, versioned JSON with a 64 MiB limit. Saving writes a temporary file in the destination directory, @@ -497,7 +506,7 @@ changes after the last successful save can be lost on process exit. ## Limits This is not yet a full image: no per-transition durable mailboxes, -transactional outbox, supervisors, GUI, or automatic image bootstrap. +transactional outbox, supervisors, GUI, or automatic checkpoint recovery. Behavior registry and mailbox data survive process exit only in explicit saves; installation requires an idle handler boundary and does not upgrade active execution. The example counter script is bundled through `include_str!`. diff --git a/vscode/README.md b/vscode/README.md index e0e78e1..6359ca3 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -63,6 +63,11 @@ machine running the extension host, not necessarily the desktop. ### Disk image save/restore +To reconnect to a saved image after stopping the server, start it with +`cargo run -p runa -- --restore /path/to/image.runa.json` or set `RUNA_RESTORE` +when using `mise run start`, then connect normally. Startup restoration adds +no bundled sources and executes no automatic ticks. + **Runa: Save Image...** prompts for a new file on the server's filesystem. It saves the whole image at a handler boundary and leaves runnable actors paused. Unfinished handlers, including paused/awaiting execution, reject saving diff --git a/vscode/integration/server.ts b/vscode/integration/server.ts index 3cd0a56..248c49a 100644 --- a/vscode/integration/server.ts +++ b/vscode/integration/server.ts @@ -28,6 +28,7 @@ export async function startImage(): Promise<{ process.platform === "win32" ? "runa.exe" : "runa", ); const child = spawn(executable, ["--listen", "127.0.0.1:0"], { + env: { ...process.env, RUNA_RESTORE: undefined }, stdio: ["ignore", "ignore", "pipe"], }); let diagnostics = "";