feat: restore saved images at server startup
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
65c08b04c6
commit
99dc794b30
|
|
@ -56,6 +56,10 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Server startup restoration with `--restore <file>` / `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
|
||||
|
|
|
|||
15
README.md
15
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 <file>` and `restore <file>`. 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.
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
fn initial_image() -> io::Result<Server> {
|
||||
fn initial_image(restore: Option<&str>) -> io::Result<Server> {
|
||||
let mut server = Server::new();
|
||||
server
|
||||
.dispatch(Request {
|
||||
version: PROTOCOL_VERSION,
|
||||
request_id: "bootstrap-counter".to_owned(),
|
||||
command: Command::WriteSource {
|
||||
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-image".to_owned(),
|
||||
command,
|
||||
})
|
||||
.result
|
||||
.map_err(|error| io::Error::other(error.message))?;
|
||||
|
|
@ -34,7 +43,7 @@ fn initial_image() -> io::Result<Server> {
|
|||
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 = <Args as clap::CommandFactory>::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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<std::net::TcpStream> {
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -449,6 +449,15 @@ capacity, and successful message effects.
|
|||
|
||||
## Image save and restore
|
||||
|
||||
The server accepts `--restore <file>` 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 <new-file>` and `restore <file>` 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!`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
Loading…
Reference in New Issue