diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d43ba..ed54baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ All notable changes to this project will be documented in this file. ### Added +- Mutable image-owned source files organized beneath `source/`, with independent + commit history and generated readable revision IDs. Spawn compiles the current + working file whether committed or not. +- Internal sender/data envelopes and actor contexts with `send` and `send_from`; + sends remain transactional and actors cannot construct privileged system commands. +- Graceful stop with optional `on_shutdown(state)`, force kill, restart, + pause/resume, and safe-boundary reload/become. +- VS Code image source filesystem, file-context spawn actions, and source + commit/history/rollback controls. - VS Code behavior explorer with revision history, implementation identities, and read-only Rune source documents isolated by connection and revision. Image notifications refresh behavior listings without changing historical @@ -99,6 +108,13 @@ All notable changes to this project will be documented in this file. ### Changed +- **Breaking:** Protocol v4 spawns by source path (for example `actors/counter`). + Rune handlers are now `handle(ctx, state, sender, message) -> state`, with sends + through the context instead of returned transition tuples. +- **Breaking:** CLI `commit ` replaces publishing; history and rollback + use file paths and generated revision IDs. Source saves, commits, and actor + reloads are separate operations. + - 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 diff --git a/Cargo.lock b/Cargo.lock index 94f102c..9123dfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -865,6 +865,7 @@ name = "runa-code" version = "0.0.0" dependencies = [ "blake3", + "nanoid", "runa-core", "runa-rune", "rune", diff --git a/README.md b/README.md index 4121a60..dc60f6c 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ addresses with an in-memory compile cache. Persistence, canonicalization, remote network access, and the graphical world are not implemented yet. A minimal VS Code extension can connect to the local image, list actors, and inspect state, queued/reserved messages, and execution telemetry in read-only -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 +documents. The image owns mutable, directory-based Rune source files with +independent commit history. The CLI exposes source writes, commit/history/rollback, +reload/become, graceful shutdown, kill, restart, pause/resume, and cancellation through a trusted image API. A versioned transport-neutral protocol contract is now defined, but no authentication is implemented. The `runa` server provides an unauthenticated WebSocket listener restricted to localhost, with bounded request sizes. @@ -63,7 +63,7 @@ mise run cli The REPL defaults to `127.0.0.1:3737`. To select another loopback endpoint, 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. +client; actors and source files 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. @@ -93,13 +93,13 @@ The VS Code extension connects over the network to this same live image; actors the image regardless of how clients connect. Remote network access requires authentication and authorization, which are not implemented. -The WebSocket server publishes the bundled `counter` behavior once at startup; -connecting a client does not publish or reset behavior revisions: +The server loads the bundled `source/actors/counter.rn` working file at startup. +Connecting a client does not modify source or create a commit: ```text -runa> let sender = spawn counter 0 +runa> let sender = spawn actors/counter 0 sender = @ -runa> let receiver = spawn counter 10 +runa> let receiver = spawn actors/counter 10 receiver = @ runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}} message queued @@ -113,21 +113,32 @@ Actor references are generated NanoIDs rather than sequential numbers. The `let` form binds each spawned actor to a local name; references can also be entered directly with an `@` prefix. The sender commits state `1` and sends a message to the receiver, which commits state `15`. The REPL provides Tab -completion and Up/Down command history. It also supports publishing Rune source -files as named, versioned behaviors, viewing history, rolling back a behavior -reference, installing the current version on an idle actor, and cooperatively -pausing and resuming or cancelling actor invocations between scheduler ticks. -Runtime users can also supply an explicit state migration closure during -installation; the CLI's install command preserves state as-is. Use `help` in +completion and Up/Down command history. `write actors/counter ` +updates the image's working source. `commit actors/counter` records a snapshot +with an automatically generated readable revision ID; `history actors/counter` +lists commits and `rollback actors/counter ` restores a working file. +Committing is version control only: spawn always compiles the current image +file, including uncommitted changes. Invalid working code fails spawn without +creating an actor; commits can preserve invalid work in progress. +`reload ` loads current source at its existing path, and +`become ` switches scripts, preserving state and queued messages +after the active handler completes. `stop` runs optional `on_shutdown(state)` +after the active handler; `kill` discards it without shutdown. `restart` creates +a new incarnation with the same state and loaded script, without reloading. +Pause/resume remain cooperative controls between scheduler slices. Use `help` in the session for the complete command list. State and messages support null, booleans, signed 64-bit integers, strings, -lists, records, and distinct actor-reference values. `spawn ` +lists, records, and distinct actor-reference values. `spawn ` sets initial state; `send ` sends any supported value. JSON objects of the form `{"$actor": "name-or-@reference"}` embed an actor reference, including inside a record or list. See the [runtime guide](docs/runtime.md) -for Rune conversions and value limits. This is protocol version 3; old -four-argument Rune handlers and kind/value/target message envelopes must be -updated to the two-argument handler contract. +for Rune conversions and value limits. Protocol version 4 uses +`handle(ctx, state, sender, message) -> new_state`; `ctx.send(recipient, message)` +records sends with the calling actor as sender, and +`ctx.send_from(sender, recipient, message)` supports forwarding. +Sender metadata is separate from message data; host-originated messages have +unit/null sender. System messages are trusted runtime commands, not data an +actor can construct. See [the feasibility report](docs/rune-feasibility.md) for verified Rune capabilities and limitations, and [the runtime guide](docs/runtime.md) for @@ -143,17 +154,18 @@ actors created through the REPL or another client. Click an actor to inspect it. Server notifications automatically refresh the tree and open snapshots; **Runa: Refresh Image** is also available on demand. -The **Runa Behaviors** view shows published behaviors and their revision -history. Click a behavior for its current source, or expand it to open an older -revision. **Runa: Open Behavior Source** also offers a revision picker. -Source documents are read-only and remain unchanged when new revisions appear. +The extension mounts a **source** workspace directory backed by the image, +including `actors/counter.rn`. Save edits to update the working file without +committing. Right-click a source file to **Spawn** (null state) or **Spawn with +State**, or to commit, browse history, and roll back. Historical revisions +remain read-only. -Use **Runa: Spawn Actor** to choose a behavior and supply JSON initial state. -Actor context menus offer structured message sending and pause/resume/stop/restart. +Use **Runa: Spawn Actor** to choose a source path and supply JSON initial state. +Actor context menus offer structured messages and system lifecycle commands. **Runa: Advance Scheduler** explicitly executes ticks; sending alone only queues a message. The extension does not start or shut down the image. Disconnecting -leaves actors in the server. Behavior editing and detailed runtime event -subscriptions are not implemented yet. See the [extension guide](vscode/README.md) +leaves actors in the server. Detailed runtime event subscriptions and structured +compilation diagnostics are not implemented yet. See the [extension guide](vscode/README.md) for connection restrictions, snapshot semantics, and tests. ## Development diff --git a/crates/runa-actor/src/lib.rs b/crates/runa-actor/src/lib.rs index a03f4c9..e54ece7 100644 --- a/crates/runa-actor/src/lib.rs +++ b/crates/runa-actor/src/lib.rs @@ -8,6 +8,24 @@ pub struct MailboxId(pub u64); pub type Message = Value; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Envelope { + pub sender: Option, + pub message: Message, +} + +/// Privileged worker commands; never representable as actor message data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SystemMessage { + Stop, + Kill, + Restart, + Pause, + Resume, + Reload(ImplementationId), + Become(ImplementationId), +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Status { Idle, diff --git a/crates/runa-cli/src/main.rs b/crates/runa-cli/src/main.rs index 82c76b5..91a667f 100644 --- a/crates/runa-cli/src/main.rs +++ b/crates/runa-cli/src/main.rs @@ -9,7 +9,7 @@ use std::num::NonZeroUsize; use runa::Server; use runa_client::WebSocketClient; use runa_core::ActorRef; -use runa_protocol::{Command, Reply, Value}; +use runa_protocol::{Command, Reply, SystemMessageDto, Value}; use rustyline::completion::{Completer, Pair}; use rustyline::error::ReadlineError; use rustyline::highlight::Highlighter; @@ -21,7 +21,7 @@ use rustyline::{Context, Editor, Helper}; const MAILBOX_CAPACITY: usize = 64; const DEFAULT_TICK_BUDGET: usize = 32; #[cfg(test)] -const COUNTER_BEHAVIOR: &str = "counter"; +const COUNTER_BEHAVIOR: &str = "actors/counter"; trait Connection { fn dispatch(&mut self, command: Command) -> Result; @@ -74,26 +74,9 @@ impl ReplHelper { fn candidates(&self, line: &str, position: usize) -> Vec { const COMMANDS: &[&str] = &[ - "actors", - "behaviors", - "cancel", - "events", - "exit", - "help", - "history", - "inspect", - "install", - "let", - "pause", - "publish", - "quit", - "restart", - "resume", - "rollback", - "send", - "spawn", - "stop", - "tick", + "actors", "sources", "cancel", "events", "exit", "help", "history", "inspect", + "become", "kill", "reload", "write", "let", "pause", "commit", "quit", "restart", + "resume", "rollback", "send", "spawn", "stop", "tick", ]; let (start, reference_value) = Self::completion_start(line, position); if reference_value { @@ -119,8 +102,12 @@ impl ReplHelper { | ["pause"] | ["resume"] | ["cancel"] - | ["install"] => self.actor_candidates(), - ["install", _] | ["history"] | ["rollback"] | ["publish"] => self.behaviors.clone(), + | ["kill"] + | ["reload"] + | ["become"] => self.actor_candidates(), + ["become", _] | ["history"] | ["rollback"] | ["commit"] | ["write"] => { + self.behaviors.clone() + } _ => Vec::new(), }; let prefix = &line[start..position]; @@ -240,10 +227,10 @@ fn run_interactive_repl(server: &mut dyn Connection) -> Result<(), Box behaviors + let behaviors = match dispatch(server, Command::ListSources)? { + Reply::Sources(behaviors) => behaviors .into_iter() - .map(|behavior| behavior.name) + .map(|behavior| behavior.path) .collect(), other => return Err(format!("unexpected protocol response: {other:?}").into()), }; @@ -306,25 +293,22 @@ fn execute_line( let result = match command { "help" => print_help(output), "spawn" => spawn_command(output, line, server, state), - "behaviors" if args.is_empty() => match dispatch(server, Command::GetBehaviors) { - Ok(Reply::Behaviors(behaviors)) => { - for behavior in behaviors { - writeln!( - output, - "{} revision {} ({})", - behavior.name, behavior.revision, behavior.current_implementation_id - )?; + "sources" if args.is_empty() => match dispatch(server, Command::ListSources) { + Ok(Reply::Sources(files)) => { + for file in files { + writeln!(output, "source/{}.rn ({})", file.path, file.source_id)?; } Ok(()) } Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), }, - "behaviors" => writeln!(output, "error: usage: behaviors"), - "publish" => publish_command(output, args, server), + "sources" => writeln!(output, "error: usage: sources"), + "commit" => commit_command(output, args, server), + "write" => write_command(output, args, server), "history" => history_command(output, args, server), "rollback" => rollback_command(output, args, server), - "install" => install_command(output, args, server, state), + "become" => become_command(output, args, server, state), "actors" if args.is_empty() => match dispatch(server, Command::ListActors) { Ok(Reply::Actors(actors)) => { for actor in actors { @@ -383,17 +367,29 @@ fn execute_line( Err(error) => writeln!(output, "error: {error}"), }, "events" => writeln!(output, "error: usage: events"), - "stop" => lifecycle_command(output, args, server, state, |actor_ref| { - Command::StopActor { actor_ref } + "stop" => lifecycle_command(output, args, server, state, |actor_ref| Command::System { + actor_ref, + message: SystemMessageDto::Stop, }), - "restart" => lifecycle_command(output, args, server, state, |actor_ref| { - Command::RestartActor { actor_ref } + "kill" => lifecycle_command(output, args, server, state, |actor_ref| Command::System { + actor_ref, + message: SystemMessageDto::Kill, }), - "pause" => lifecycle_command(output, args, server, state, |actor_ref| { - Command::PauseActor { actor_ref } + "reload" => lifecycle_command(output, args, server, state, |actor_ref| Command::System { + actor_ref, + message: SystemMessageDto::Reload, }), - "resume" => lifecycle_command(output, args, server, state, |actor_ref| { - Command::ResumeActor { actor_ref } + "restart" => lifecycle_command(output, args, server, state, |actor_ref| Command::System { + actor_ref, + message: SystemMessageDto::Restart, + }), + "pause" => lifecycle_command(output, args, server, state, |actor_ref| Command::System { + actor_ref, + message: SystemMessageDto::Pause, + }), + "resume" => lifecycle_command(output, args, server, state, |actor_ref| Command::System { + actor_ref, + message: SystemMessageDto::Resume, }), "cancel" => lifecycle_command(output, args, server, state, |actor_ref| { Command::CancelActor { actor_ref } @@ -414,11 +410,24 @@ fn lifecycle_command( operation: impl FnOnce(String) -> Command, ) -> io::Result<()> { match parse_actor_argument(args, state) { - Ok(actor_ref) => match dispatch(server, operation(actor_ref)) { - Ok(Reply::Acknowledged) => writeln!(output, "ok"), - Ok(other) => unexpected_reply(output, other), - Err(error) => writeln!(output, "error: {error}"), - }, + Ok(actor_ref) => { + let command = operation(actor_ref); + let scheduled = matches!( + &command, + Command::System { + message: SystemMessageDto::Stop | SystemMessageDto::Reload, + .. + } + ); + match dispatch(server, command) { + Ok(Reply::Acknowledged) if scheduled => { + writeln!(output, "requested; advance scheduler ticks to complete") + } + Ok(Reply::Acknowledged) => writeln!(output, "ok"), + Ok(other) => unexpected_reply(output, other), + Err(error) => writeln!(output, "error: {error}"), + } + } Err(error) => writeln!(output, "error: {error}"), } } @@ -431,16 +440,10 @@ fn spawn_command( ) -> io::Result<()> { let args: Vec<_> = line.split_whitespace().collect(); let [_, behavior_name, initial_state @ ..] = args.as_slice() else { - return writeln!( - output, - "error: usage: spawn " - ); + return writeln!(output, "error: usage: spawn "); }; if initial_state.is_empty() { - return writeln!( - output, - "error: usage: spawn " - ); + return writeln!(output, "error: usage: spawn "); } match spawn_actor(behavior_name, after_words(line, 2), server, state) { Ok(actor_ref) => writeln!(output, "spawned @{actor_ref} from `{behavior_name}`"), @@ -495,24 +498,21 @@ fn is_binding_name(name: &str) -> bool { && characters.all(|character| character == '_' || character.is_ascii_alphanumeric()) } -fn publish_command( +fn write_command( output: &mut W, args: &[&str], server: &mut dyn Connection, ) -> io::Result<()> { - let [name, expected_revision, source_path] = args else { - return writeln!( - output, - "error: usage: publish " - ); + let [path, source_path] = args else { + return writeln!(output, "error: usage: write "); }; - let expected_revision = if *expected_revision == "new" { - None - } else { - match expected_revision.parse::() { - Ok(revision) => Some(revision), - Err(_) => return writeln!(output, "error: expected `new` or a numeric revision"), - } + let expected_source_id = match dispatch(server, Command::ListSources) { + Ok(Reply::Sources(files)) => files + .into_iter() + .find(|file| file.path == *path) + .map(|file| file.source_id), + Ok(other) => return unexpected_reply(output, other), + Err(error) => return writeln!(output, "error: {error}"), }; let source = match std::fs::read_to_string(source_path) { Ok(source) => source, @@ -520,23 +520,42 @@ fn publish_command( return writeln!(output, "error: could not read `{source_path}`: {error}"); } }; - let behavior = match dispatch( + match dispatch( server, - Command::PublishBehavior { - name: (*name).to_owned(), - expected_revision, + Command::WriteSource { + path: (*path).to_owned(), + expected_source_id, source, }, ) { - Ok(Reply::Behavior(behavior)) => behavior, - Ok(other) => return unexpected_reply(output, other), - Err(error) => return writeln!(output, "error: {error}"), + Ok(Reply::Source(file)) => { + writeln!(output, "saved source/{}.rn ({})", file.path, file.source_id) + } + Ok(other) => unexpected_reply(output, other), + Err(error) => writeln!(output, "error: {error}"), + } +} + +fn commit_command( + output: &mut W, + args: &[&str], + server: &mut dyn Connection, +) -> io::Result<()> { + let [path] = args else { + return writeln!(output, "error: usage: commit "); }; - writeln!( - output, - "published `{}` revision {} ({})", - behavior.name, behavior.revision, behavior.current_implementation_id - ) + match dispatch( + server, + Command::CommitSource { + path: (*path).to_owned(), + }, + ) { + Ok(Reply::SourceCommitted(commit)) => { + writeln!(output, "committed `{path}` revision {}", commit.revision) + } + Ok(other) => unexpected_reply(output, other), + Err(error) => writeln!(output, "error: {error}"), + } } fn history_command( @@ -544,29 +563,24 @@ fn history_command( args: &[&str], server: &mut dyn Connection, ) -> io::Result<()> { - let [name] = args else { - return writeln!(output, "error: usage: history "); + let [path] = args else { + return writeln!(output, "error: usage: history "); }; - let behavior = match dispatch( + let history = match dispatch( server, - Command::GetBehavior { - name: (*name).to_owned(), + Command::SourceHistory { + path: (*path).to_owned(), }, ) { - Ok(Reply::Behavior(behavior)) => behavior, + Ok(Reply::SourceHistory(history)) => history, Ok(other) => return unexpected_reply(output, other), Err(error) => return writeln!(output, "error: {error}"), }; - for version in behavior.history { - let current = if version.revision == behavior.revision { - " (current)" - } else { - "" - }; + for version in history { writeln!( output, - "revision {}: {}{}", - version.revision, version.implementation_id, current + "revision {}: {}", + version.revision, version.source_id )?; } Ok(()) @@ -577,78 +591,49 @@ fn rollback_command( args: &[&str], server: &mut dyn Connection, ) -> io::Result<()> { - let [name, expected_revision, target_revision] = args else { - return writeln!( - output, - "error: usage: rollback " - ); - }; - let expected_revision = match expected_revision.parse::() { - Ok(revision) => revision, - Err(_) => return writeln!(output, "error: invalid expected revision"), - }; - let target_revision = match target_revision.parse::() { - Ok(revision) => revision, - Err(_) => return writeln!(output, "error: invalid target revision"), + let [path, revision] = args else { + return writeln!(output, "error: usage: rollback "); }; match dispatch( server, - Command::RollbackBehavior { - name: (*name).to_owned(), - expected_revision, - target_revision, + Command::RollbackSource { + path: (*path).to_owned(), + revision: (*revision).to_owned(), }, ) { - Ok(Reply::Behavior(behavior)) => writeln!( + Ok(Reply::Source(file)) => writeln!( output, - "rolled back `{}` to implementation {} as revision {}", - behavior.name, behavior.current_implementation_id, behavior.revision + "restored `{}` from revision {revision} ({})", + file.path, file.source_id ), Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), } } -fn install_command( +fn become_command( output: &mut W, args: &[&str], server: &mut dyn Connection, state: &ReplState, ) -> io::Result<()> { let [actor_arg, name] = args else { - return writeln!( - output, - "error: usage: install " - ); + return writeln!(output, "error: usage: become "); }; let actor_ref = match parse_actor_argument(&[*actor_arg], state) { Ok(actor_ref) => actor_ref, Err(error) => return writeln!(output, "error: {error}"), }; - let implementation = match dispatch( - server, - Command::InstallBehavior { - actor_ref: actor_ref.clone(), - behavior_name: (*name).to_owned(), - }, - ) { - Ok(Reply::BehaviorInstalled { implementation_id }) => implementation_id, - Ok(other) => return unexpected_reply(output, other), - Err(error) => return writeln!(output, "error: {error}"), - }; match dispatch( server, - Command::GetBehavior { - name: (*name).to_owned(), + Command::System { + actor_ref: actor_ref.clone(), + message: SystemMessageDto::Become { + path: (*name).to_owned(), + }, }, ) { - Ok(Reply::Behavior(behavior)) => { - writeln!( - output, - "installed `{}` revision {} on actor @{} ({implementation})", - behavior.name, behavior.revision, actor_ref - ) - } + Ok(Reply::Acknowledged) => writeln!(output, "scheduled become `{name}` on @{actor_ref}"), Ok(other) => unexpected_reply(output, other), Err(error) => writeln!(output, "error: {error}"), } @@ -770,13 +755,15 @@ fn print_help(output: &mut W) -> io::Result<()> { writeln!( output, "Commands:\n\ - let = spawn \n\ - spawn \n\ - behaviors\n\ - publish \n\ - history \n\ - rollback \n\ - install \n\ + let = spawn \n\ + spawn \n\ + sources\n\ + write \n\ + commit \n\ + history \n\ + rollback \n\ + become \n\ + Paths exclude source/ and .rn, for example actors/counter.\n\ send \n\ State/messages: JSON values; {{\"$actor\":\"name-or-@ref\"}} embeds an actor reference.\n\ tick [instruction-budget]\n\ @@ -784,6 +771,8 @@ fn print_help(output: &mut W) -> io::Result<()> { actors\n\ events\n\ stop \n\ + kill \n\ + reload \n\ restart \n\ pause \n\ resume \n\ @@ -847,7 +836,7 @@ mod tests { Command::PublishBehavior { name: "echo".to_owned(), expected_revision: None, - source: "pub fn handle(state, message) { (message, []) }".to_owned(), + source: "pub fn handle(ctx, state, sender, message) { message }".to_owned(), }, )?; let mut state = ReplState::default(); @@ -957,7 +946,7 @@ mod tests { let mut output = Vec::new(); run_repl( io::Cursor::new( - "let actor = spawn counter 5\nsend actor {\"op\":\"add\",\"value\":7}\ntick 100\nquit\n", + "let actor = spawn actors/counter 5\nsend actor {\"op\":\"add\",\"value\":7}\ntick 100\nquit\n", ), &mut output, &mut client, @@ -1000,9 +989,9 @@ mod tests { let mut server = Server::new(); dispatch( &mut server, - Command::PublishBehavior { - name: COUNTER_BEHAVIOR.to_owned(), - expected_revision: None, + Command::WriteSource { + path: COUNTER_BEHAVIOR.to_owned(), + expected_source_id: None, source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), }, ) @@ -1014,7 +1003,7 @@ mod tests { fn repl_drives_actor_messages_and_inspection() -> Result<(), Box> { let mut server = server(); let input = io::Cursor::new( - "let sender = spawn counter 0\nlet receiver = spawn counter 10\nsend sender {\"op\": \"send\", \"value\": 5, \"target\": {\"$actor\": \"receiver\"}}\ntick 100\ninspect sender\ninspect receiver\nevents\nquit\n", + "let sender = spawn actors/counter 0\nlet receiver = spawn actors/counter 10\nsend sender {\"op\": \"send\", \"value\": 5, \"target\": {\"$actor\": \"receiver\"}}\ntick 100\ninspect sender\ninspect receiver\nevents\nquit\n", ); let mut output = Vec::new(); run_repl(input, &mut output, &mut server)?; @@ -1042,12 +1031,12 @@ mod tests { fn repl_reports_bad_input_and_continues() -> Result<(), Box> { let mut server = server(); let input = io::Cursor::new( - "spawn 0\nspawn counter nope\nsend 000000000000000000000 {\"op\":\"add\",\"value\":1}\ntick 1 0\nunknown\nquit\n", + "spawn 0\nspawn actors/counter nope\nsend 000000000000000000000 {\"op\":\"add\",\"value\":1}\ntick 1 0\nunknown\nquit\n", ); let mut output = Vec::new(); run_repl(input, &mut output, &mut server)?; let output = String::from_utf8(output)?; - assert!(output.contains("usage: spawn ")); + assert!(output.contains("usage: spawn ")); assert!(output.contains("invalid initial state")); assert!(output.contains("unknown actor")); assert!(output.contains("tick budget must be positive")); @@ -1056,38 +1045,42 @@ mod tests { } #[test] - fn repl_manages_named_behavior_revisions_and_installs_them() - -> Result<(), Box> { + fn repl_manages_source_commits_and_system_messages() -> Result<(), Box> { let mut server = server(); let source_path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../runa-std/actors/counter.rn" ); let input = io::Cursor::new(format!( - "behaviors\npublish counter 1 {source_path}\nhistory counter\nrollback counter 2 1\nlet actor = spawn counter 0\ninstall actor counter\nsend actor {{\"op\":\"add\",\"value\":1}}\ntick 1 1\ncancel actor\npause actor\nresume actor\ninspect actor\nevents\nquit\n" + "sources\nwrite actors/counter {source_path}\ncommit actors/counter\nhistory actors/counter\nlet actor = spawn actors/counter 0\nbecome actor actors/counter\nreload actor\nsend actor {{\"op\":\"add\",\"value\":1}}\ntick 1 1\ncancel actor\npause actor\nresume actor\nstop actor\ntick 100\nrestart actor\nkill actor\ninspect actor\nevents\nquit\n" )); let mut output = Vec::new(); run_repl(input, &mut output, &mut server)?; let output = String::from_utf8(output)?; - assert!(output.contains("published `counter` revision 2")); - assert!(output.contains("revision 1:")); - assert!(output.contains("revision 2:")); - assert!(output.contains("rolled back `counter`")); + assert!(!output.contains("error:"), "{output}"); + assert!(output.contains("committed `actors/counter` revision ")); assert!(output.contains("actor = @")); - assert!(output.contains("installed `counter` revision 3 on actor @")); + assert!(output.contains("scheduled become `actors/counter` on @")); assert!(output.contains("Paused {")); assert!(output.contains("Resumed {")); assert!(output.contains("InvocationCancelled {")); - let behavior = match dispatch( + let history = match dispatch( &mut server, - Command::GetBehavior { - name: COUNTER_BEHAVIOR.to_owned(), + Command::SourceHistory { + path: COUNTER_BEHAVIOR.to_owned(), }, )? { - Reply::Behavior(behavior) => behavior, + Reply::SourceHistory(history) => history, other => return Err(format!("unexpected response: {other:?}").into()), }; - assert_eq!(behavior.revision, 3); + assert_eq!(history.len(), 1); + let mut output = Vec::new(); + rollback_command( + &mut output, + &[COUNTER_BEHAVIOR, &history[0].revision], + &mut server, + )?; + assert!(String::from_utf8(output)?.contains("restored `actors/counter`")); let actor_ref = match dispatch(&mut server, Command::ListActors)? { Reply::Actors(actors) => actors[0].actor_ref.clone(), other => return Err(format!("unexpected response: {other:?}").into()), @@ -1096,7 +1089,7 @@ mod tests { Reply::Actor(actor) => actor, other => return Err(format!("unexpected response: {other:?}").into()), }; - assert_eq!(actor.implementation_id, behavior.current_implementation_id); + assert_eq!(actor.status, runa_protocol::ActorStatusDto::Stopped); Ok(()) } @@ -1105,10 +1098,13 @@ mod tests { let mut helper = ReplHelper::default(); let mut bindings = BTreeMap::new(); bindings.insert("sender".to_owned(), "xK8_pQ2-Lm5_012345678".to_owned()); - helper.set_context(vec!["counter".to_owned()], &bindings); + helper.set_context(vec!["actors/counter".to_owned()], &bindings); assert_eq!(helper.candidates("sp", 2), vec!["spawn"]); - assert_eq!(helper.candidates("spawn co", 8), vec!["counter"]); + assert_eq!( + helper.candidates("spawn actors/co", 15), + vec!["actors/counter"] + ); assert_eq!(helper.candidates("inspect se", 10), vec!["sender"]); let unicode_whitespace = "inspect\u{2003}se"; assert_eq!( diff --git a/crates/runa-client/src/lib.rs b/crates/runa-client/src/lib.rs index d12b821..587007e 100644 --- a/crates/runa-client/src/lib.rs +++ b/crates/runa-client/src/lib.rs @@ -294,10 +294,10 @@ mod tests { for message in [ serde_json::json!({"version": 2, "request_id": "runa-client-1", "result": {"Ok": {"kind": "actors", "data": []}}}), - serde_json::json!({"version": 3, "request_id": "wrong", + serde_json::json!({"version": 4, "request_id": "wrong", "result": {"Ok": {"kind": "actors", "data": []}}}), - serde_json::json!({"version": 3, "event": {"event": "image_changed", "data": {"revision": 0}}}), - serde_json::json!({"version": 3, "event": {"event": "unknown"}}), + serde_json::json!({"version": 4, "event": {"event": "image_changed", "data": {"revision": 0}}}), + serde_json::json!({"version": 4, "event": {"event": "unknown"}}), ] { let (address, peer) = websocket_peer(move |mut socket, _| { socket diff --git a/crates/runa-code/Cargo.toml b/crates/runa-code/Cargo.toml index 13abede..5278607 100644 --- a/crates/runa-code/Cargo.toml +++ b/crates/runa-code/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] blake3 = "1.8.7" +nanoid = "0.4" runa-core = { path = "../runa-core" } runa-rune = { path = "../runa-rune" } rune = "=0.14.2" diff --git a/crates/runa-code/src/lib.rs b/crates/runa-code/src/lib.rs index ac6c6f8..96af56d 100644 --- a/crates/runa-code/src/lib.rs +++ b/crates/runa-code/src/lib.rs @@ -9,6 +9,10 @@ use rune::Module; const SOURCE_DOMAIN: &[u8] = b"runa-source:v1\0"; const IMPLEMENTATION_DOMAIN: &[u8] = b"runa-implementation:v1\0"; +const REVISION_ALPHABET: &[char] = &[ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', '2', '3', '4', '5', '6', '7', '8', '9', +]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompileFingerprint { @@ -25,7 +29,7 @@ impl CompileFingerprint { rune_version: runa_rune::RUNE_VERSION.to_owned(), compiler_settings: "default;features:default".to_owned(), native_modules: runa_rune::COMPILER_CONFIGURATION_ID.to_owned(), - runtime_abi: "runa-runtime-abi:v3:structured-values".to_owned(), + runtime_abi: "runa-runtime-abi:v4:context-state-only".to_owned(), target: env!("RUNA_BUILD_TARGET").to_owned(), } } @@ -80,11 +84,28 @@ pub struct BehaviorSnapshot { pub history: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceFile { + pub path: String, + pub source_id: SourceId, + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceCommit { + pub path: String, + pub revision: String, + pub source_id: SourceId, + pub source: String, +} + #[derive(Default)] pub struct CodeStore { sources: BTreeMap>, artifacts: BTreeMap>, behaviors: BTreeMap>, + working_sources: BTreeMap, + source_commits: BTreeMap>, } impl CodeStore { @@ -155,6 +176,96 @@ impl CodeStore { self.artifacts.len() } + /// Replace the mutable working source if its current identity matches the + /// caller's expectation. A new path requires `None`. + pub fn write_source( + &mut self, + path: &str, + expected_source_id: Option, + source: &str, + ) -> ExperimentResult { + validate_source_path(path)?; + let current_source_id = self.working_sources.get(path).map(|file| file.source_id); + if current_source_id != expected_source_id { + return Err(format!( + "source identity conflict: expected {expected_source_id:?}, current {current_source_id:?}" + ) + .into()); + } + + let file = SourceFile { + path: path.to_owned(), + source_id: source_identity(source), + source: source.to_owned(), + }; + self.working_sources.insert(path.to_owned(), file.clone()); + Ok(file) + } + + pub fn sources(&self) -> Vec { + self.working_sources.values().cloned().collect() + } + + pub fn source(&self, path: &str) -> Option<&SourceFile> { + self.working_sources.get(path) + } + + /// Capture the current working source as an immutable commit without + /// compiling it. + pub fn commit_source(&mut self, path: &str) -> ExperimentResult { + validate_source_path(path)?; + let file = self + .working_sources + .get(path) + .ok_or("unknown source path")?; + let commit = SourceCommit { + path: path.to_owned(), + revision: new_source_revision(&self.source_commits, || { + nanoid::nanoid!(12, REVISION_ALPHABET) + }), + source_id: file.source_id, + source: file.source.clone(), + }; + self.source_commits + .entry(path.to_owned()) + .or_default() + .push(commit.clone()); + Ok(commit) + } + + pub fn source_history(&self, path: &str) -> Option<&[SourceCommit]> { + self.working_sources.get(path)?; + Some(self.source_commits.get(path).map_or(&[], Vec::as_slice)) + } + + pub fn source_commit(&self, path: &str, revision: &str) -> Option<&SourceCommit> { + self.source_commits + .get(path)? + .iter() + .find(|commit| commit.revision == revision) + } + + pub fn source_commit_source(&self, path: &str, revision: &str) -> Option<&str> { + self.source_commit(path, revision) + .map(|commit| commit.source.as_str()) + } + + /// Restore a committed source to the mutable working tree without + /// creating another commit. + pub fn rollback_source(&mut self, path: &str, revision: &str) -> ExperimentResult { + validate_source_path(path)?; + let commit = self + .source_commit(path, revision) + .ok_or("unknown source revision")?; + let file = SourceFile { + path: commit.path.clone(), + source_id: commit.source_id, + source: commit.source.clone(), + }; + self.working_sources.insert(path.to_owned(), file.clone()); + Ok(file) + } + /// Compile and publish only if the caller's expected revision is current. /// /// A new behavior requires `expected_revision == None`; updating an @@ -255,6 +366,42 @@ fn validate_behavior_name(name: &str) -> ExperimentResult<()> { Ok(()) } +pub fn validate_source_path(path: &str) -> ExperimentResult<()> { + if path.is_empty() + || path.len() > 128 + || path.starts_with("source/") + || path.ends_with(".rn") + || path.starts_with('/') + || !path.split('/').all(|part| { + !part.is_empty() + && part != "." + && part != ".." + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }) + { + return Err("invalid source path".into()); + } + Ok(()) +} + +fn new_source_revision( + commits: &BTreeMap>, + mut generate: impl FnMut() -> String, +) -> String { + loop { + let revision = generate(); + if !commits + .values() + .flatten() + .any(|commit| commit.revision == revision) + { + return revision; + } + } +} + fn snapshot(name: &str, history: &[BehaviorVersion]) -> BehaviorSnapshot { let current = history.last().expect("published behavior has history"); BehaviorSnapshot { @@ -425,4 +572,119 @@ mod tests { ); } } + + #[test] + fn working_sources_use_cas_and_commits_preserve_uncompiled_history() -> ExperimentResult<()> { + let mut store = CodeStore::default(); + let first_source = "pub fn handle( {"; + let first = store.write_source("actors/counter", None, first_source)?; + assert_eq!(first.source_id, source_identity(first_source)); + assert!( + store + .write_source("actors/counter", None, "pub fn handle() {}") + .is_err() + ); + assert_eq!( + store + .write_source( + "actors/counter", + Some(first.source_id), + "pub fn handle() {}" + )? + .source, + "pub fn handle() {}" + ); + + let commit = store.commit_source("actors/counter")?; + assert_eq!(commit.source, "pub fn handle() {}"); + assert_eq!(commit.revision.len(), 12); + assert!( + commit + .revision + .chars() + .all(|character| REVISION_ALPHABET.contains(&character)) + ); + assert_eq!( + store.source_commit_source("actors/counter", &commit.revision), + Some("pub fn handle() {}") + ); + assert_eq!(store.source_history("actors/counter").unwrap().len(), 1); + Ok(()) + } + + #[test] + fn source_rollback_restores_working_tree_without_committing() -> ExperimentResult<()> { + let mut store = CodeStore::default(); + let first = store.write_source("actors/counter", None, "first")?; + let commit = store.commit_source("actors/counter")?; + store.write_source("actors/counter", Some(first.source_id), "second")?; + let restored = store.rollback_source("actors/counter", &commit.revision)?; + assert_eq!(restored, first); + assert_eq!(store.source_history("actors/counter").unwrap().len(), 1); + Ok(()) + } + + #[test] + fn source_lookup_distinguishes_missing_paths_from_empty_history() -> ExperimentResult<()> { + let mut store = CodeStore::default(); + assert!(store.source("actors/missing").is_none()); + assert!(store.source_history("actors/missing").is_none()); + assert!(store.source_commit("actors/missing", "missing").is_none()); + assert!(store.commit_source("actors/missing").is_err()); + assert!(store.rollback_source("actors/missing", "missing").is_err()); + store.write_source("actors/broken", None, "pub fn broken( {")?; + assert_eq!(store.source_history("actors/broken"), Some([].as_slice())); + let commit = store.commit_source("actors/broken")?; + assert_eq!(commit.source, "pub fn broken( {"); + assert_eq!(store.artifact_count(), 0); + assert_eq!(store.source_history("actors/broken").unwrap().len(), 1); + assert_eq!( + CompileFingerprint::current().runtime_abi, + "runa-runtime-abi:v4:context-state-only" + ); + Ok(()) + } + + #[test] + fn revisions_retry_collisions_across_all_source_paths() -> ExperimentResult<()> { + let mut store = CodeStore::default(); + store.write_source("actors/first", None, "first")?; + store.write_source("actors/second", None, "second")?; + let first = store.commit_source("actors/first")?; + let second = store.commit_source("actors/second")?; + assert_ne!(first.revision, second.revision); + let fresh = "abcdefghjkmn"; + let mut candidates = [first.revision.as_str(), second.revision.as_str(), fresh].into_iter(); + let revision = new_source_revision(&store.source_commits, || { + candidates + .next() + .expect("revision generator exhausted") + .to_owned() + }); + assert_eq!(revision, fresh); + assert!(candidates.next().is_none()); + Ok(()) + } + + #[test] + fn source_paths_are_canonical_extensionless_relative_paths() { + for invalid in [ + "", + "/actors/counter", + "../counter", + "actors/../counter", + "actors//counter", + "source/actors/counter", + "actors/counter.rn", + "actors/counter.rune", + "actors/my.counter", + "actors\\counter", + ] { + assert!( + validate_source_path(invalid).is_err(), + "accepted invalid source path: {invalid}" + ); + } + assert!(validate_source_path("actors/counter").is_ok()); + } } diff --git a/crates/runa-image-api/src/lib.rs b/crates/runa-image-api/src/lib.rs index 4effc6e..7436aa6 100644 --- a/crates/runa-image-api/src/lib.rs +++ b/crates/runa-image-api/src/lib.rs @@ -1,16 +1,20 @@ //! Trusted, in-process application services for a running Runa image. +use std::collections::BTreeMap; use std::num::NonZeroUsize; use std::sync::Arc; -use runa_actor::{ActorRef, ActorSnapshot, Event, Message}; -use runa_code::{BehaviorSnapshot, CodeStore}; +use runa_actor::{ActorRef, ActorSnapshot, Event, Message, SystemMessage}; +use runa_code::{BehaviorSnapshot, CodeStore, SourceCommit, SourceFile}; use runa_core::{ImplementationId, Value}; use runa_runtime::{Runtime, RuntimeResult}; pub struct ImageApi { code: CodeStore, runtime: Runtime, + actor_paths: BTreeMap, + pending_paths: BTreeMap, + events: Vec, } impl Default for ImageApi { @@ -24,6 +28,9 @@ impl ImageApi { Self { code: CodeStore::default(), runtime: Runtime::default(), + actor_paths: BTreeMap::new(), + pending_paths: BTreeMap::new(), + events: Vec::new(), } } @@ -35,7 +42,14 @@ impl ImageApi { ) -> RuntimeResult { let artifact = self.code.compile(source)?; self.runtime.publish(Arc::clone(&artifact))?; - self.code.publish_behavior(name, expected_revision, source) + let behavior = self + .code + .publish_behavior(name, expected_revision, source)?; + if runa_code::validate_source_path(name).is_ok() { + let expected_source_id = self.code.source(name).map(|file| file.source_id); + self.code.write_source(name, expected_source_id, source)?; + } + Ok(behavior) } pub fn rollback_behavior( @@ -69,17 +83,95 @@ impl ImageApi { .ok_or_else(|| "behavior artifact is unavailable".into()) } + pub fn write_source( + &mut self, + path: &str, + expected_source_id: Option, + source: &str, + ) -> RuntimeResult { + self.code.write_source(path, expected_source_id, source) + } + + pub fn sources(&self) -> Vec { + self.code.sources() + } + + pub fn source(&self, path: &str) -> Option<&SourceFile> { + self.code.source(path) + } + + pub fn commit_source(&mut self, path: &str) -> RuntimeResult { + self.code.commit_source(path) + } + + pub fn source_history(&self, path: &str) -> Option<&[SourceCommit]> { + self.code.source_history(path) + } + + pub fn source_commit(&self, path: &str, revision: &str) -> Option<&SourceCommit> { + self.code.source_commit(path, revision) + } + + pub fn source_commit_source(&self, path: &str, revision: &str) -> Option<&str> { + self.code.source_commit_source(path, revision) + } + + pub fn rollback_source(&mut self, path: &str, revision: &str) -> RuntimeResult { + self.code.rollback_source(path, revision) + } + pub fn spawn( &mut self, - behavior_name: &str, + path: &str, state: Value, capacity: NonZeroUsize, ) -> RuntimeResult { - let behavior = self + let source = self .code - .behavior(behavior_name) - .ok_or("unknown behavior")?; - self.runtime.spawn(behavior.current, state, capacity) + .source(path) + .ok_or("unknown source path")? + .source + .clone(); + let artifact = self.code.compile(&source)?; + self.runtime.publish(Arc::clone(&artifact))?; + let actor = self + .runtime + .spawn(artifact.implementation_id, state, capacity)?; + self.actor_paths.insert(actor, path.to_owned()); + Ok(actor) + } + + pub fn reload(&mut self, actor: ActorRef) -> RuntimeResult { + let path = self + .actor_paths + .get(&actor) + .cloned() + .ok_or("actor has no source path")?; + self.schedule_source_install(actor, &path) + } + + pub fn r#become(&mut self, actor: ActorRef, path: &str) -> RuntimeResult { + self.schedule_source_install(actor, path) + } + + fn schedule_source_install( + &mut self, + actor: ActorRef, + path: &str, + ) -> RuntimeResult { + let source = self + .code + .source(path) + .ok_or("unknown source path")? + .source + .clone(); + let artifact = self.code.compile(&source)?; + self.runtime.publish(Arc::clone(&artifact))?; + self.runtime + .schedule_install(actor, artifact.implementation_id)?; + self.pending_paths + .insert(actor, (path.to_owned(), artifact.implementation_id)); + Ok(artifact.implementation_id) } pub fn send(&mut self, actor: ActorRef, message: Message) -> RuntimeResult<()> { @@ -89,6 +181,7 @@ impl ImageApi { pub fn tick(&mut self, ticks: usize, budget: NonZeroUsize) { for _ in 0..ticks { self.runtime.tick(budget); + self.collect_runtime_events(); } } @@ -101,23 +194,51 @@ impl ImageApi { } pub fn events(&mut self) -> Vec { - self.runtime.drain_events() + self.collect_runtime_events(); + std::mem::take(&mut self.events) + } + + fn collect_runtime_events(&mut self) { + for event in self.runtime.drain_events() { + if let Event::BehaviorInstalled(actor, implementation) = &event + && self + .pending_paths + .get(actor) + .is_some_and(|(_, pending)| pending == implementation) + { + let (path, _) = self + .pending_paths + .remove(actor) + .expect("pending path exists"); + self.actor_paths.insert(*actor, path); + } + self.events.push(event); + } + } + + pub fn system(&mut self, actor: ActorRef, message: SystemMessage) -> RuntimeResult<()> { + self.collect_runtime_events(); + self.runtime.system(actor, message)?; + if matches!(message, SystemMessage::Kill | SystemMessage::Restart) { + self.pending_paths.remove(&actor); + } + Ok(()) } pub fn stop(&mut self, actor: ActorRef) -> RuntimeResult<()> { - self.runtime.stop(actor) + self.system(actor, SystemMessage::Stop) } pub fn restart(&mut self, actor: ActorRef) -> RuntimeResult<()> { - self.runtime.restart(actor) + self.system(actor, SystemMessage::Restart) } pub fn pause(&mut self, actor: ActorRef) -> RuntimeResult<()> { - self.runtime.pause(actor) + self.system(actor, SystemMessage::Pause) } pub fn resume(&mut self, actor: ActorRef) -> RuntimeResult<()> { - self.runtime.resume(actor) + self.system(actor, SystemMessage::Resume) } pub fn cancel(&mut self, actor: ActorRef) -> RuntimeResult<()> { @@ -173,11 +294,11 @@ mod tests { let behavior = image.publish_behavior( "counter", None, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; assert_eq!( image.behavior_source("counter", behavior.revision)?, - "pub fn handle(state, message) { (state + message.value, []) }" + "pub fn handle(ctx, state, sender, message) { state + message.value }" ); let actor = image.spawn("counter", Value::Integer(5), capacity(2))?; image.send(actor, test_message(0, 7, None))?; @@ -200,14 +321,14 @@ mod tests { let first = image.publish_behavior( "counter", None, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; assert!( image .publish_behavior( "counter", None, - "pub fn handle(state, message) { (state - message.value, []) }" + "pub fn handle(ctx, state, sender, message) { state - message.value }" ) .is_err() ); @@ -221,12 +342,12 @@ mod tests { let first = image.publish_behavior( "counter", None, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; image.publish_behavior( "counter", Some(first.revision), - "pub fn handle(state, message) { (state * message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state * message.value }", )?; let actor = image.spawn("counter", Value::Integer(5), capacity(2))?; let implementation = image.install_with_migration(actor, "counter", |state| { @@ -242,4 +363,207 @@ mod tests { assert_eq!(image.inspect(actor)?.implementation, implementation); Ok(()) } + + #[test] + fn spawn_compiles_current_uncommitted_source() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + let file = image.write_source( + "actors/counter", + None, + "pub fn handle(ctx, state, sender, message) { state + message.value }", + )?; + let actor = image.spawn("actors/counter", Value::Integer(3), capacity(2))?; + assert_eq!( + image.inspect(actor)?.implementation, + image.code.compile(&file.source)?.implementation_id + ); + Ok(()) + } + + #[test] + fn invalid_uncommitted_source_fails_spawn_without_creating_actor() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + image.write_source("actors/broken", None, "pub fn handle( {")?; + assert!(image.commit_source("actors/broken").is_ok()); + assert!( + image + .spawn("actors/broken", Value::Null, capacity(2)) + .is_err() + ); + assert!(image.actors().is_empty()); + Ok(()) + } + + #[test] + fn compatibility_behavior_seeds_only_its_exact_working_path() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + image.publish_behavior( + "counter", + None, + "pub fn handle(ctx, state, sender, message) { state + message.value }", + )?; + assert!(image.source("counter").is_some()); + assert!(image.source("actors/counter").is_none()); + Ok(()) + } + + #[test] + fn killed_pending_become_preserves_the_installed_path() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + image.write_source( + "actors/first", + None, + "pub fn handle(ctx, state, sender, message) { state + 1 }", + )?; + image.write_source( + "actors/second", + None, + "pub fn handle(ctx, state, sender, message) { state + 10 }", + )?; + let actor = image.spawn("actors/first", Value::Integer(0), capacity(2))?; + let original = image.inspect(actor)?.implementation; + image.r#become(actor, "actors/second")?; + assert_eq!(image.actor_paths[&actor], "actors/first"); + image.system(actor, SystemMessage::Kill)?; + image.restart(actor)?; + assert_eq!(image.reload(actor)?, original); + image.tick(1, capacity(100)); + assert_eq!(image.inspect(actor)?.implementation, original); + assert_eq!(image.actor_paths[&actor], "actors/first"); + + image.r#become(actor, "actors/second")?; + image.tick(1, capacity(100)); + assert_eq!(image.actor_paths[&actor], "actors/second"); + let current = image.inspect(actor)?.implementation; + assert_eq!(image.reload(actor)?, current); + Ok(()) + } + + #[test] + fn restart_forces_a_new_incarnation_during_active_execution() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + image.write_source( + "actors/loop", + None, + "pub fn handle(ctx, state, sender, message) { loop {} }", + )?; + let actor = image.spawn("actors/loop", Value::Null, capacity(2))?; + image.send(actor, Value::Null)?; + image.tick(1, capacity(100)); + let before = image.inspect(actor)?; + assert!(before.execution.is_some()); + image.restart(actor)?; + let after = image.inspect(actor)?; + assert_eq!(after.incarnation, before.incarnation + 1); + assert_eq!(after.state, before.state); + assert!(after.execution.is_none()); + Ok(()) + } + + #[test] + fn reload_compiles_latest_working_source_and_preserves_state_and_mailbox() -> RuntimeResult<()> + { + let mut image = ImageApi::new(); + let file = image.write_source( + "actors/counter", + None, + "pub fn handle(ctx, state, sender, message) { state + message }", + )?; + image.commit_source("actors/counter")?; + let actor = image.spawn("actors/counter", Value::Integer(3), capacity(2))?; + image.send(actor, Value::Integer(2))?; + let before = image.inspect(actor)?; + image.write_source( + "actors/counter", + Some(file.source_id), + "pub fn handle(ctx, state, sender, message) { state + message * 10 }", + )?; + let implementation = image.reload(actor)?; + let scheduled = image.inspect(actor)?; + assert_eq!(scheduled.implementation, before.implementation); + assert_eq!(scheduled.state, before.state); + assert_eq!(scheduled.queued, before.queued); + image.tick(100, capacity(100)); + let after = image.inspect(actor)?; + assert_eq!(after.implementation, implementation); + assert_eq!(after.state, Value::Integer(23)); + assert_eq!(after.incarnation, before.incarnation); + assert_eq!(after.mailbox, before.mailbox); + assert_eq!(image.actor_paths[&actor], "actors/counter"); + Ok(()) + } + + #[test] + fn become_binds_only_after_install_and_reload_follows_the_new_path() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + image.write_source( + "actors/first", + None, + "pub fn handle(ctx, state, sender, message) { state + message }", + )?; + let second = image.write_source( + "actors/second", + None, + "pub fn handle(ctx, state, sender, message) { state * message }", + )?; + let actor = image.spawn("actors/first", Value::Integer(3), capacity(2))?; + image.send(actor, Value::Integer(2))?; + let before = image.inspect(actor)?; + let implementation = image.r#become(actor, "actors/second")?; + assert_eq!(image.actor_paths[&actor], "actors/first"); + assert_eq!(image.inspect(actor)?.queued, before.queued); + image.tick(100, capacity(100)); + assert_eq!(image.actor_paths[&actor], "actors/second"); + assert_eq!(image.inspect(actor)?.implementation, implementation); + assert_eq!(image.inspect(actor)?.state, Value::Integer(6)); + image.write_source( + "actors/second", + Some(second.source_id), + "pub fn handle(ctx, state, sender, message) { state * message * 10 }", + )?; + let updated = image.reload(actor)?; + assert_ne!(updated, implementation); + image.send(actor, Value::Integer(2))?; + image.tick(100, capacity(100)); + assert_eq!(image.inspect(actor)?.implementation, updated); + assert_eq!(image.inspect(actor)?.state, Value::Integer(120)); + assert_eq!(image.inspect(actor)?.mailbox, before.mailbox); + Ok(()) + } + + #[test] + fn restart_and_superseding_reload_cancel_pending_become_path_changes() -> RuntimeResult<()> { + let mut image = ImageApi::new(); + image.write_source( + "actors/first", + None, + "pub fn handle(ctx, state, sender, message) { state + message }", + )?; + image.write_source( + "actors/second", + None, + "pub fn handle(ctx, state, sender, message) { state * message }", + )?; + let actor = image.spawn("actors/first", Value::Integer(3), capacity(2))?; + let before = image.inspect(actor)?; + image.r#become(actor, "actors/second")?; + image.restart(actor)?; + image.tick(1, capacity(100)); + assert_eq!(image.actor_paths[&actor], "actors/first"); + assert_eq!(image.inspect(actor)?.implementation, before.implementation); + assert_eq!(image.inspect(actor)?.incarnation, before.incarnation + 1); + assert!(!image.pending_paths.contains_key(&actor)); + + image.r#become(actor, "actors/second")?; + assert_eq!(image.reload(actor)?, before.implementation); + image.tick(1, capacity(100)); + assert_eq!(image.actor_paths[&actor], "actors/first"); + assert_eq!(image.inspect(actor)?.implementation, before.implementation); + image.write_source("actors/broken", None, "pub fn broken( {")?; + assert!(image.r#become(actor, "actors/broken").is_err()); + assert_eq!(image.actor_paths[&actor], "actors/first"); + assert!(image.r#become(actor, "actors/missing").is_err()); + assert_eq!(image.actor_paths[&actor], "actors/first"); + Ok(()) + } } diff --git a/crates/runa-protocol/src/lib.rs b/crates/runa-protocol/src/lib.rs index 4756979..216c811 100644 --- a/crates/runa-protocol/src/lib.rs +++ b/crates/runa-protocol/src/lib.rs @@ -3,7 +3,7 @@ pub use runa_core::Value; use serde::{Deserialize, Serialize}; -pub const PROTOCOL_VERSION: u16 = 3; +pub const PROTOCOL_VERSION: u16 = 4; pub const WEBSOCKET_SUBPROTOCOL: &str = "runa-debug"; /// Coalescible invalidation, not a drain of the image's runtime event queue. @@ -44,6 +44,27 @@ impl Request { } match &self.command { + Command::ListSources => {} + Command::ReadSource { path } + | Command::CommitSource { path } + | Command::SourceHistory { path } => validate_name(path)?, + Command::WriteSource { path, .. } => validate_name(path)?, + Command::RevisionSource { path, revision } + | Command::RollbackSource { path, revision } => { + validate_name(path)?; + if revision.is_empty() + || revision.len() > 64 + || !revision.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return Err(invalid_request("invalid source revision")); + } + } + Command::System { actor_ref, message } => { + validate_actor_ref(actor_ref)?; + if let SystemMessageDto::Become { path } = message { + validate_name(path)?; + } + } Command::PublishBehavior { name, expected_revision, @@ -111,25 +132,58 @@ impl Request { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "operation", content = "parameters", rename_all = "snake_case")] pub enum Command { + ListSources, + ReadSource { + path: String, + }, + WriteSource { + path: String, + expected_source_id: Option, + source: String, + }, + CommitSource { + path: String, + }, + SourceHistory { + path: String, + }, + RevisionSource { + path: String, + revision: String, + }, + RollbackSource { + path: String, + revision: String, + }, + System { + actor_ref: String, + message: SystemMessageDto, + }, + #[serde(skip)] PublishBehavior { name: String, expected_revision: Option, source: String, }, + #[serde(skip)] RollbackBehavior { name: String, expected_revision: u64, target_revision: u64, }, + #[serde(skip)] GetBehavior { name: String, }, + #[serde(skip)] GetBehaviors, + #[serde(skip)] GetBehaviorSource { name: String, revision: u64, }, Spawn { + #[serde(rename = "path")] behavior_name: String, state: Value, mailbox_capacity: usize, @@ -162,6 +216,7 @@ pub enum Command { CancelActor { actor_ref: String, }, + #[serde(skip)] InstallBehavior { actor_ref: String, behavior_name: String, @@ -179,6 +234,10 @@ pub struct Response { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "data", rename_all = "snake_case")] pub enum Reply { + Sources(Vec), + Source(SourceFileDto), + SourceCommitted(SourceCommitDto), + SourceHistory(Vec), Acknowledged, Behavior(BehaviorDto), Behaviors(Vec), @@ -191,6 +250,33 @@ pub enum Reply { BehaviorInstalled { implementation_id: String }, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceFileDto { + pub path: String, + pub source_id: String, + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceCommitDto { + pub revision: String, + pub source_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum SystemMessageDto { + Stop, + Kill, + Restart, + Pause, + Resume, + Reload, + Become { path: String }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProtocolEvent { diff --git a/crates/runa-rune/src/context.rs b/crates/runa-rune/src/context.rs new file mode 100644 index 0000000..5be58de --- /dev/null +++ b/crates/runa-rune/src/context.rs @@ -0,0 +1,82 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use runa_core::{ActorRef, MAX_VALUE_NODES, Value}; +use rune::runtime::VmResult; +use rune::{Any, Value as RuneValue}; + +use crate::{ExperimentResult, value_from_rune}; + +#[derive(Debug, Clone)] +pub struct SendEffect { + pub sender: Option, + pub recipient: ActorRef, + pub message: Value, +} + +#[derive(Any, Clone)] +#[rune(item = ::runa)] +pub struct ActorContext { + actor: ActorRef, + effects: Rc>>, +} + +impl ActorContext { + pub fn new(actor: ActorRef) -> Self { + Self { + actor, + effects: Rc::default(), + } + } + + pub fn take_effects(&self) -> Vec { + std::mem::take(&mut *self.effects.borrow_mut()) + } + + pub(crate) fn send(&self, recipient: RuneValue, message: RuneValue) -> VmResult<()> { + self.enqueue(Some(self.actor), recipient, message) + } + + pub(crate) fn send_from( + &self, + sender: RuneValue, + recipient: RuneValue, + message: RuneValue, + ) -> VmResult<()> { + let sender = match value_from_rune(&sender) { + Ok(Value::Null) => None, + Ok(Value::ActorRef(sender)) => Some(sender), + Ok(_) => return VmResult::panic("sender must be an actor reference or unit"), + Err(error) => return VmResult::panic(error.to_string()), + }; + self.enqueue(sender, recipient, message) + } + + fn enqueue( + &self, + sender: Option, + recipient: RuneValue, + message: RuneValue, + ) -> VmResult<()> { + let effect = (|| -> ExperimentResult<_> { + let Value::ActorRef(recipient) = value_from_rune(&recipient)? else { + return Err("recipient must be an actor reference".into()); + }; + if self.effects.borrow().len() >= MAX_VALUE_NODES { + return Err("transition exceeds effect limit".into()); + } + Ok(SendEffect { + sender, + recipient, + message: value_from_rune(&message)?, + }) + })(); + match effect { + Ok(effect) => { + self.effects.borrow_mut().push(effect); + VmResult::Ok(()) + } + Err(error) => VmResult::panic(error.to_string()), + } + } +} diff --git a/crates/runa-rune/src/execution.rs b/crates/runa-rune/src/execution.rs index b877507..436a0db 100644 --- a/crates/runa-rune/src/execution.rs +++ b/crates/runa-rune/src/execution.rs @@ -36,7 +36,11 @@ pub struct Invocation { impl Invocation { pub fn start(mut vm: Vm, name: &str, args: impl Args) -> Result { - let mut execution = vm.execute([name], args)?.into_owned(); + let execution = vm.execute([name], args)?; + let entrypoint = execution.vm().ip(); + let mut execution = execution.into_owned(); + // Rune 0.14.2's into_owned transfers the stack but resets the VM IP. + execution.vm_mut().set_ip(entrypoint); let stepped = Rc::new(Cell::new(false)); let marker = Rc::clone(&stepped); let progress = Rc::new(Cell::new(InvocationProgress::default())); @@ -113,6 +117,30 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; + #[test] + fn shutdown_invocation_preserves_state_argument() -> ExperimentResult<()> { + let program = CompiledProgram::compile( + "pub fn handle(ctx, state, sender, message) { let n = 0; while n < 20 { n += 1; } state + message } pub fn on_shutdown(state) { state + 100 }", + )?; + assert_eq!( + rune::from_value::(program.vm().call(["on_shutdown"], (1i64,))?)?, + 101 + ); + let mut invocation = Invocation::::start(program.vm(), "on_shutdown", (1i64,))?; + let mut cx = Context::from_waker(Waker::noop()); + loop { + match invocation.poll_slice(&mut cx, NonZeroUsize::new(32).unwrap()) { + Slice::Completed(value) => { + assert_eq!(value, 101); + break; + } + Slice::Failed(error) => return Err(error), + _ => {} + } + } + Ok(()) + } + struct Signal { waker: Mutex>, ready: AtomicBool, diff --git a/crates/runa-rune/src/lib.rs b/crates/runa-rune/src/lib.rs index 9ff85db..ed30ffc 100644 --- a/crates/runa-rune/src/lib.rs +++ b/crates/runa-rune/src/lib.rs @@ -13,13 +13,14 @@ use rune::termcolor::{ColorChoice, StandardStream}; use rune::{Context, Diagnostics, Module, Source, Sources, Vm}; pub const RUNE_VERSION: &str = "0.14.2"; -pub const COMPILER_CONFIGURATION_ID: &str = - "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1"; +pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v1:send,send_from"; mod execution; pub use execution::{Invocation, InvocationProgress, Slice}; mod value; -pub use value::{WireTransition, value_from_rune, value_to_rune}; +pub use value::{value_from_rune, value_to_rune}; +mod context; +pub use context::{ActorContext, SendEffect}; /// Errors remain visible to the caller; compilation also emits source diagnostics. pub type ExperimentResult = Result>; @@ -62,6 +63,9 @@ impl CompiledProgram { let mut context = Context::with_default_modules()?; let mut values = Module::with_crate("runa")?; values.ty::()?; + values.ty::()?; + values.associated_function("send", ActorContext::send)?; + values.associated_function("send_from", ActorContext::send_from)?; values.associated_function( &rune::runtime::Protocol::PARTIAL_EQ, |left: &value::ActorReference, right: &value::ActorReference| left.0 == right.0, @@ -100,6 +104,10 @@ impl CompiledProgram { pub fn vm(&self) -> Vm { Vm::new(Arc::clone(&self.runtime), Arc::clone(&self.unit)) } + + pub fn has_function(&self, name: &str) -> bool { + self.vm().lookup_function([name]).is_ok() + } } #[derive(Debug)] diff --git a/crates/runa-rune/src/value.rs b/crates/runa-rune/src/value.rs index 7277d4b..ac35d6d 100644 --- a/crates/runa-rune/src/value.rs +++ b/crates/runa-rune/src/value.rs @@ -4,8 +4,6 @@ use rune::{Any, TypeHash, Value as RuneValue}; use crate::ExperimentResult; -pub type WireTransition = (RuneValue, Vec<(RuneValue, RuneValue)>); - #[derive(Any, Clone)] #[rune(item = ::runa)] pub(crate) struct ActorReference(pub ActorRef); diff --git a/crates/runa-runtime/Cargo.toml b/crates/runa-runtime/Cargo.toml index 7a1fee3..21f5379 100644 --- a/crates/runa-runtime/Cargo.toml +++ b/crates/runa-runtime/Cargo.toml @@ -8,6 +8,4 @@ runa-actor = { path = "../runa-actor" } runa-code = { path = "../runa-code" } runa-core = { path = "../runa-core" } runa-rune = { path = "../runa-rune" } - -[dev-dependencies] rune = "=0.14.2" diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index a31e568..47c7d29 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -6,11 +6,14 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::task::{Context, Wake, Waker}; -use runa_actor::{ActorRef, ActorSnapshot, Event, ExecutionSnapshot, MailboxId, Message, Status}; +use runa_actor::{ + ActorRef, ActorSnapshot, Envelope, Event, ExecutionSnapshot, MailboxId, Message, Status, + SystemMessage, +}; use runa_code::CompiledArtifact; -use runa_core::{ImplementationId, MAX_VALUE_NODES, Value}; +use runa_core::{ImplementationId, Value}; use runa_rune::{ - ExperimentResult, Invocation, Slice, WireTransition, value_from_rune, value_to_rune, + ActorContext, ExperimentResult, Invocation, Slice, value_from_rune, value_to_rune, }; pub type RuntimeResult = ExperimentResult; @@ -28,9 +31,14 @@ impl Wake for WakeFlag { struct Actor { snapshot: ActorSnapshot, - mailbox: VecDeque, + mailbox: VecDeque, capacity: NonZeroUsize, - active: Option>, + active: Option>, + context: ActorContext, + reserved_sender: Option, + stopping: bool, + shutdown: bool, + pending_install: Option, wake: Arc, paused_from: Option, } @@ -94,6 +102,11 @@ impl Runtime { mailbox: VecDeque::new(), capacity, active: None, + context: ActorContext::new(actor_ref), + reserved_sender: None, + stopping: false, + shutdown: false, + pending_install: None, wake: Arc::new(WakeFlag(AtomicBool::new(true))), paused_from: None, }, @@ -103,9 +116,18 @@ impl Runtime { } pub fn send(&mut self, id: ActorRef, message: Message) -> ExperimentResult<()> { + self.send_from(None, id, message) + } + + pub fn send_from( + &mut self, + sender: Option, + id: ActorRef, + message: Message, + ) -> ExperimentResult<()> { message.validate()?; let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; - if matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + if actor.stopping || matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { return Err("actor is not accepting messages".into()); } if actor.mailbox.len() + usize::from(actor.snapshot.reserved.is_some()) @@ -113,7 +135,7 @@ impl Runtime { { return Err("mailbox full".into()); } - actor.mailbox.push_back(message); + actor.mailbox.push_back(Envelope { sender, message }); Ok(()) } @@ -128,7 +150,11 @@ impl Runtime { call_depth: progress.call_depth, } }); - snapshot.queued = actor.mailbox.iter().cloned().collect(); + snapshot.queued = actor + .mailbox + .iter() + .map(|envelope| envelope.message.clone()) + .collect(); Ok(snapshot) } @@ -145,21 +171,73 @@ impl Runtime { call_depth: progress.call_depth, } }); - snapshot.queued = actor.mailbox.iter().cloned().collect(); + snapshot.queued = actor + .mailbox + .iter() + .map(|envelope| envelope.message.clone()) + .collect(); snapshot }) .collect() } pub fn stop(&mut self, id: ActorRef) -> ExperimentResult<()> { + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + if matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + return Err("actor is not running".into()); + } + actor.stopping = true; + if let Some(status) = actor.paused_from.take() { + actor.snapshot.status = status; + } + Ok(()) + } + + pub fn kill(&mut self, id: ActorRef) -> ExperimentResult<()> { let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; actor.active = None; + actor.context.take_effects(); + actor.stopping = false; + actor.shutdown = false; + actor.pending_install = None; actor.paused_from = None; actor.snapshot.status = Status::Stopped; self.events.push(Event::Stopped(id)); Ok(()) } + pub fn system(&mut self, id: ActorRef, message: SystemMessage) -> ExperimentResult<()> { + match message { + SystemMessage::Stop => self.stop(id), + SystemMessage::Kill => self.kill(id), + SystemMessage::Restart => { + self.kill(id)?; + self.restart(id) + } + SystemMessage::Pause => self.pause(id), + SystemMessage::Resume => self.resume(id), + SystemMessage::Reload(implementation) | SystemMessage::Become(implementation) => { + self.schedule_install(id, implementation) + } + } + } + + pub fn schedule_install( + &mut self, + id: ActorRef, + implementation: ImplementationId, + ) -> ExperimentResult<()> { + if !self.implementations.contains_key(&implementation) { + return Err("unknown implementation".into()); + } + let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; + if actor.stopping || matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + return Err("actor is not running".into()); + } + actor.pending_install = Some(implementation); + Ok(()) + } + /// Pause scheduling between worker ticks without discarding an invocation. pub fn pause(&mut self, id: ActorRef) -> ExperimentResult<()> { let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; @@ -199,7 +277,11 @@ impl Runtime { .ok_or("active invocation has no reserved message")?; actor.active = None; actor.paused_from = None; - actor.mailbox.push_front(message); + actor.context.take_effects(); + actor.mailbox.push_front(Envelope { + sender: actor.reserved_sender.take(), + message, + }); actor.snapshot.status = Status::Idle; self.events.push(Event::InvocationCancelled(id)); Ok(()) @@ -216,7 +298,10 @@ impl Runtime { .checked_add(1) .ok_or("incarnation exhausted")?; if let Some(message) = actor.snapshot.reserved.take() { - actor.mailbox.push_front(message); + actor.mailbox.push_front(Envelope { + sender: actor.reserved_sender.take(), + message, + }); } actor.snapshot.status = Status::Idle; actor.paused_from = None; @@ -275,32 +360,24 @@ impl Runtime { fn fail(&mut self, id: &ActorRef, error: String) { let actor = self.actors.get_mut(id).expect("scheduled actor exists"); actor.active = None; + actor.context.take_effects(); actor.snapshot.status = Status::Failed(error.clone()); self.events.push(Event::Failed(*id, error)); } - fn commit(&mut self, id: &ActorRef, transition: WireTransition) -> ExperimentResult<()> { - let (state, effects) = transition; + fn commit(&mut self, id: &ActorRef, state: rune::Value) -> ExperimentResult<()> { let state = value_from_rune(&state)?; - if effects.len() > MAX_VALUE_NODES { - return Err("transition exceeds effect limit".into()); - } - let effects = effects - .iter() - .map(|(target, message)| { - let Value::ActorRef(target) = value_from_rune(target)? else { - return Err("effect target must be an actor-reference value".into()); - }; - Ok((target, value_from_rune(message)?)) - }) - .collect::>>()?; + let effects = self.actors[id].context.take_effects(); let mut counts = BTreeMap::::new(); - for (target, _) in &effects { + for effect in &effects { + let target = &effect.recipient; let actor = self .actors .get(target) .ok_or("effect targets unknown actor")?; - if matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) { + if (actor.stopping && target != id) + || matches!(actor.snapshot.status, Status::Stopped | Status::Failed(_)) + { return Err("effect targets unavailable actor".into()); } let count = counts.entry(*target).or_default(); @@ -315,12 +392,22 @@ impl Runtime { actor.snapshot.reserved = None; actor.snapshot.status = Status::Idle; actor.active = None; - for (target, message) in effects { + actor.reserved_sender = None; + if actor.shutdown { + actor.shutdown = false; + actor.stopping = false; + actor.snapshot.status = Status::Stopped; + self.events.push(Event::Stopped(*id)); + } + for effect in effects { self.actors - .get_mut(&target) + .get_mut(&effect.recipient) .expect("validated target") .mailbox - .push_back(message); + .push_back(Envelope { + sender: effect.sender, + message: effect.message, + }); } self.events.push(Event::Committed(*id)); Ok(()) @@ -338,22 +425,63 @@ impl Runtime { continue; } if actor.active.is_none() { - let Some(message) = actor.mailbox.pop_front() else { - continue; - }; - actor.snapshot.reserved = Some(message.clone()); - let program = self.implementations[&actor.snapshot.implementation].program(); - let invocation = (|| -> ExperimentResult<_> { - let state = value_to_rune(&actor.snapshot.state)?; - let message = value_to_rune(&message)?; - Ok(Invocation::start(program.vm(), "handle", (state, message))?) - })(); - match invocation { - Ok(invocation) => actor.active = Some(invocation), - Err(error) => { - self.fail(&id, error.to_string()); + if actor.stopping { + let program = self.implementations[&actor.snapshot.implementation].program(); + if !program.has_function("on_shutdown") { + actor.snapshot.status = Status::Stopped; + actor.stopping = false; + self.events.push(Event::Stopped(id)); continue; } + let invocation = (|| -> ExperimentResult<_> { + Ok(Invocation::start( + program.vm(), + "on_shutdown", + (value_to_rune(&actor.snapshot.state)?,), + )?) + })(); + match invocation { + Ok(invocation) => { + actor.active = Some(invocation); + actor.shutdown = true; + actor.context = ActorContext::new(id); + } + Err(error) => { + self.fail(&id, error.to_string()); + continue; + } + } + } else { + if let Some(implementation) = actor.pending_install.take() { + actor.snapshot.implementation = implementation; + self.events + .push(Event::BehaviorInstalled(id, implementation)); + } + let Some(message) = actor.mailbox.pop_front() else { + continue; + }; + actor.snapshot.reserved = Some(message.message.clone()); + actor.reserved_sender = message.sender; + actor.context = ActorContext::new(id); + let program = self.implementations[&actor.snapshot.implementation].program(); + let invocation = (|| -> ExperimentResult<_> { + let state = value_to_rune(&actor.snapshot.state)?; + let sender = + value_to_rune(&message.sender.map_or(Value::Null, Value::ActorRef))?; + let message = value_to_rune(&message.message)?; + Ok(Invocation::start( + program.vm(), + "handle", + (actor.context.clone(), state, sender, message), + )?) + })(); + match invocation { + Ok(invocation) => actor.active = Some(invocation), + Err(error) => { + self.fail(&id, error.to_string()); + continue; + } + } } actor.snapshot.status = Status::Running; } @@ -385,6 +513,201 @@ impl Runtime { #[cfg(test)] mod tests { use super::*; + use runa_core::MAX_VALUE_NODES; + + #[test] + fn context_sends_copy_at_call_and_are_discarded_on_kill() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let receiver_code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { message }", + )?; + let receiver = runtime.spawn(receiver_code, Value::Null, capacity(4))?; + let code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { let data = #{items: []}; ctx.send(state, data); data.items.push(7); data }", + )?; + let actor = runtime.spawn(code, Value::ActorRef(receiver), capacity(2))?; + runtime.send(actor, Value::Null)?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + assert_eq!( + runtime.inspect(receiver)?.state, + Value::Record(BTreeMap::from([("items".to_owned(), Value::List(vec![])),])) + ); + let runaway = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { ctx.send(state, 999); loop {} }", + )?; + let killed = runtime.spawn(runaway, Value::ActorRef(receiver), capacity(2))?; + runtime.send(killed, Value::Null)?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + runtime.system(killed, SystemMessage::Kill)?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + assert_eq!( + runtime.inspect(receiver)?.state, + Value::Record(BTreeMap::from([("items".to_owned(), Value::List(vec![])),])) + ); + Ok(()) + } + + #[test] + fn sender_envelopes_support_replies_and_forwarding_without_message_metadata() + -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let sink = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { #{sender: sender, data: message} }", + )?; + let forward = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { ctx.send_from(sender, state, message); state }", + )?; + let origin = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { ctx.send(state, message); state }", + )?; + let sink_ref = runtime.spawn(sink, Value::Null, capacity(4))?; + let forward_ref = runtime.spawn(forward, Value::ActorRef(sink_ref), capacity(4))?; + let origin_ref = runtime.spawn(origin, Value::ActorRef(forward_ref), capacity(4))?; + runtime.send(origin_ref, Value::String("stop".to_owned()))?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + assert_eq!( + runtime.inspect(sink_ref)?.state, + Value::Record(BTreeMap::from([ + ("sender".to_owned(), Value::ActorRef(origin_ref)), + ("data".to_owned(), Value::String("stop".to_owned())), + ])) + ); + assert_eq!(runtime.inspect(sink_ref)?.status, Status::Idle); + let reply = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { ctx.send(sender, message); state }", + )?; + let reply_ref = runtime.spawn(reply, Value::Null, capacity(4))?; + runtime.send_from(Some(sink_ref), reply_ref, Value::Integer(42))?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + assert_eq!( + runtime.inspect(sink_ref)?.state, + Value::Record(BTreeMap::from([ + ("sender".to_owned(), Value::ActorRef(reply_ref)), + ("data".to_owned(), Value::Integer(42)), + ])) + ); + Ok(()) + } + + #[test] + fn graceful_stop_finishes_handler_then_runs_shutdown_without_draining_mailbox() + -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let implementation = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { let n = 0; while n < 20 { n += 1; } state + message } pub fn on_shutdown(state) { state + 100 }", + )?; + let actor = runtime.spawn(implementation, Value::Integer(0), capacity(3))?; + runtime.send(actor, Value::Integer(1))?; + runtime.send(actor, Value::Integer(2))?; + runtime.tick(capacity(1)); + runtime.system(actor, SystemMessage::Stop)?; + assert!(runtime.send(actor, Value::Null).is_err()); + for _ in 0..200 { + runtime.tick(capacity(32)); + } + let snapshot = runtime.inspect(actor)?; + assert_eq!(snapshot.state, Value::Integer(101), "{snapshot:?}"); + assert_eq!(snapshot.status, Status::Stopped); + assert_eq!(snapshot.queued, vec![Value::Integer(2)]); + assert_eq!(snapshot.reserved, None); + runtime.system(actor, SystemMessage::Restart)?; + for _ in 0..200 { + runtime.tick(capacity(32)); + } + assert_eq!(runtime.inspect(actor)?.state, Value::Integer(103)); + let self_send = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { ctx.send(message, 42); state + 1 } pub fn on_shutdown(state) { state + 100 }", + )?; + let actor = runtime.spawn(self_send, Value::Integer(0), capacity(2))?; + runtime.send(actor, Value::ActorRef(actor))?; + runtime.tick(capacity(1)); + runtime.system(actor, SystemMessage::Stop)?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + let snapshot = runtime.inspect(actor)?; + assert_eq!(snapshot.state, Value::Integer(101)); + assert_eq!(snapshot.status, Status::Stopped); + assert_eq!(snapshot.queued, vec![Value::Integer(42)]); + Ok(()) + } + + #[test] + fn kill_skips_shutdown_and_reload_waits_for_active_handler() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let first = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { state + message } pub fn on_shutdown(state) { panic(\"must not run\") }", + )?; + let second = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { state + message * 10 }", + )?; + let actor = runtime.spawn(first, Value::Integer(0), capacity(3))?; + runtime.send(actor, Value::Integer(1))?; + runtime.send(actor, Value::Integer(2))?; + runtime.tick(capacity(1)); + runtime.system(actor, SystemMessage::Reload(second))?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + assert_eq!(runtime.inspect(actor)?.state, Value::Integer(21)); + runtime.system(actor, SystemMessage::Kill)?; + assert_eq!(runtime.inspect(actor)?.status, Status::Stopped); + assert_eq!(runtime.inspect(actor)?.state, Value::Integer(21)); + runtime.system(actor, SystemMessage::Restart)?; + assert_eq!(runtime.inspect(actor)?.implementation, second); + Ok(()) + } + + #[test] + fn absent_and_failing_shutdown_hooks_are_explicit() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + for (hook, failed) in [ + ("", false), + ( + "pub fn on_shutdown(state) { panic(\"shutdown failed\") }", + true, + ), + ] { + let implementation = publish( + &mut runtime, + &format!("pub fn handle(ctx, state, sender, message) {{ state }} {hook}"), + )?; + let actor = runtime.spawn(implementation, Value::Integer(7), capacity(1))?; + runtime.system(actor, SystemMessage::Stop)?; + for _ in 0..100 { + runtime.tick(capacity(32)); + } + let snapshot = runtime.inspect(actor)?; + assert_eq!(snapshot.state, Value::Integer(7)); + if failed { + assert!(matches!(snapshot.status, Status::Failed(_))); + } else { + assert_eq!(snapshot.status, Status::Stopped); + } + } + Ok(()) + } fn test_message(kind: i64, value: i64, target: Option) -> Value { Value::Record(std::collections::BTreeMap::from([ @@ -404,11 +727,11 @@ mod tests { let mut runtime = Runtime::default(); let receiver_code = publish( &mut runtime, - "pub fn handle(state, message) { message.items.push(9); (message, []) }", + "pub fn handle(ctx, state, sender, message) { message.items.push(9); message }", )?; let sender_code = publish( &mut runtime, - "pub fn handle(state, message) { state.items.push(message.text); (state, [(message.target, state)]) }", + "pub fn handle(ctx, state, sender, message) { state.items.push(message.text); ctx.send(message.target, state); state }", )?; let initial = Value::Record(BTreeMap::from([( "items".to_owned(), @@ -453,11 +776,11 @@ mod tests { fn mutated_drafts_and_invalid_structured_effects_never_partially_commit() -> ExperimentResult<()> { for source in [ - "pub fn handle(state, message) { state.items.push(2); panic(\"failed\"); (state, []) }", - "pub fn handle(state, message) { state.items.push(2); (state, [(message, state), (\"not a reference\", state)]) }", - "pub fn handle(state, message) { state.items.push(2); (state, [(message, state), (message, 1.5)]) }", - "pub fn handle(state, message) { state.items.push(2); (Some(state), [(message, state)]) }", - "pub fn handle(state, message) { state.items.push(state); (state, []) }", + "pub fn handle(ctx, state, sender, message) { state.items.push(2); panic(\"failed\"); state }", + "pub fn handle(ctx, state, sender, message) { state.items.push(2); ctx.send(message, state); ctx.send(\"not a reference\", state); state }", + "pub fn handle(ctx, state, sender, message) { state.items.push(2); ctx.send(message, state); ctx.send(message, 1.5); state }", + "pub fn handle(ctx, state, sender, message) { state.items.push(2); ctx.send(message, state); Some(state) }", + "pub fn handle(ctx, state, sender, message) { state.items.push(state); state }", ] { let mut runtime = Runtime::default(); let code = publish(&mut runtime, source)?; @@ -484,7 +807,7 @@ mod tests { let mut runtime = Runtime::default(); let code = publish( &mut runtime, - "pub fn handle(state, message) { (message, []) }", + "pub fn handle(ctx, state, sender, message) { message }", )?; let invalid = Value::List(vec![Value::Null; MAX_VALUE_NODES]); assert!(runtime.spawn(code, invalid.clone(), capacity(2)).is_err()); @@ -515,7 +838,7 @@ mod tests { let mut runtime = Runtime::default(); let code = publish( &mut runtime, - "pub fn handle(state, message) { if message.kind == 1 { (state + 1, [(message.target, #{kind: 0, value: 5, target: ()})]) } else { (state + message.value, []) } }", + "pub fn handle(ctx, state, sender, message) { if message.kind == 1 { ctx.send(message.target, #{kind: 0, value: 5, target: ()}); state + 1 } else { state + message.value } }", )?; let first = runtime.spawn(code, Value::Integer(0), capacity(4))?; let second = runtime.spawn(code, Value::Integer(10), capacity(4))?; @@ -538,10 +861,13 @@ mod tests { #[test] fn runaway_does_not_starve_healthy_actor() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let runaway = publish(&mut runtime, "pub fn handle(state, message) { loop {} }")?; + let runaway = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { loop {} }", + )?; let healthy = publish( &mut runtime, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; let first = runtime.spawn(runaway, Value::Integer(0), capacity(1))?; let second = runtime.spawn(healthy, Value::Integer(0), capacity(2))?; @@ -554,7 +880,7 @@ mod tests { assert_eq!(runtime.inspect(first)?.state, Value::Integer(0)); assert_eq!(runtime.inspect(second)?.state, Value::Integer(42)); assert!(runtime.send(first, test_message(0, 2, None)).is_err()); - runtime.stop(first)?; + runtime.kill(first)?; assert_eq!(runtime.inspect(first)?.status, Status::Stopped); runtime.restart(first)?; assert_eq!(runtime.inspect(first)?.incarnation, 2); @@ -566,7 +892,10 @@ mod tests { fn execution_snapshot_reports_live_progress_and_is_stable_while_paused() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let implementation = publish(&mut runtime, "pub fn handle(state, message) { loop {} }")?; + let implementation = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { loop {} }", + )?; let actor = runtime.spawn(implementation, Value::Integer(0), capacity(2))?; runtime.send(actor, test_message(0, 1, None))?; runtime.tick(capacity(3)); @@ -599,7 +928,7 @@ mod tests { let mut runtime = Runtime::default(); let code = publish( &mut runtime, - "pub fn handle(state, message) { (99, [(message.target, #{kind: 0, value: 1, target: ()}), (\"invalid\", #{kind: 0, value: 2, target: ()})]) }", + "pub fn handle(ctx, state, sender, message) { ctx.send(message.target, #{kind: 0, value: 1, target: ()}); ctx.send(\"invalid\", #{kind: 0, value: 2, target: ()}); 99 }", )?; let actor = runtime.spawn(code, Value::Integer(5), capacity(4))?; runtime.send(actor, test_message(0, 0, Some(actor)))?; @@ -656,7 +985,7 @@ mod tests { let mut runtime = Runtime::default(); let code = runtime.publish( CodeStore::default().compile_with_module( - "pub async fn handle(state, message) { if message.kind == 1 { let x = wait().await; (state + x, []) } else { (state + message.value, []) } }", + "pub async fn handle(ctx, state, sender, message) { if message.kind == 1 { let x = wait().await; state + x } else { state + message.value } }", "test-gate:v1", module, )?, @@ -724,7 +1053,7 @@ mod tests { let mut runtime = Runtime::default(); let implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; let actor = runtime.spawn(implementation, Value::Integer(10), capacity(2))?; runtime.send(actor, test_message(0, 7, None))?; @@ -772,7 +1101,7 @@ mod tests { let mut runtime = Runtime::default(); let code = publish( &mut runtime, - "pub fn handle(state, message) { (state / message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state / message.value }", )?; let actor = runtime.spawn(code, Value::Integer(5), capacity(1))?; runtime.send(actor, test_message(0, 0, None))?; @@ -797,7 +1126,7 @@ mod tests { let mut runtime = Runtime::default(); let code = publish( &mut runtime, - "pub fn handle(state, message) { (99, [(message.target, #{kind: 0, value: 1, target: ()}), (message.target, #{kind: 0, value: 2, target: ()})]) }", + "pub fn handle(ctx, state, sender, message) { ctx.send(message.target, #{kind: 0, value: 1, target: ()}); ctx.send(message.target, #{kind: 0, value: 2, target: ()}); 99 }", )?; let actor = runtime.spawn(code, Value::Integer(5), capacity(1))?; runtime.send(actor, test_message(0, 0, Some(actor)))?; @@ -815,7 +1144,10 @@ mod tests { #[test] fn invalid_transition_shape_fails_without_committing() -> ExperimentResult<()> { let mut runtime = Runtime::default(); - let code = publish(&mut runtime, "pub fn handle(state, message) { 99 }")?; + let code = publish( + &mut runtime, + "pub fn handle(ctx, state, sender, message) { Some(99) }", + )?; let actor = runtime.spawn(code, Value::Integer(5), capacity(1))?; runtime.send(actor, test_message(0, 1, None))?; for _ in 0..100 { @@ -832,9 +1164,9 @@ mod tests { fn behavior_installation_waits_for_handler_boundary() -> ExperimentResult<()> { let mut code = CodeStore::default(); let first = - code.compile("pub fn handle(state, message) { (state + message.value, []) }")?; - let second = - code.compile("pub fn handle(state, message) { (state + message.value * 10, []) }")?; + code.compile("pub fn handle(ctx, state, sender, message) { state + message.value }")?; + let second = code + .compile("pub fn handle(ctx, state, sender, message) { state + message.value * 10 }")?; let first_id = first.implementation_id; let second_id = second.implementation_id; let mut runtime = Runtime::default(); @@ -873,11 +1205,11 @@ mod tests { let mut runtime = Runtime::default(); let old_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; let new_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state * message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state * message.value }", )?; let actor = runtime.spawn(old_implementation, Value::Integer(7), capacity(2))?; @@ -907,11 +1239,11 @@ mod tests { let mut runtime = Runtime::default(); let old_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; let new_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state * 10 + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state * 10 + message.value }", )?; let actor = runtime.spawn(old_implementation, Value::Integer(0), capacity(4))?; runtime.send(actor, test_message(0, 1, None))?; @@ -951,11 +1283,11 @@ mod tests { let mut runtime = Runtime::default(); let old_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; let new_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state * message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state * message.value }", )?; let actor = runtime.spawn(old_implementation, Value::Integer(7), capacity(2))?; @@ -986,11 +1318,11 @@ mod tests { let mut runtime = Runtime::default(); let old_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state + message.value }", )?; let new_implementation = publish( &mut runtime, - "pub fn handle(state, message) { (state * message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state * message.value }", )?; let actor = runtime.spawn(old_implementation, Value::Integer(7), capacity(2))?; runtime.send(actor, test_message(0, 1, None))?; @@ -1028,7 +1360,7 @@ mod tests { let mut runtime = Runtime::default(); let code = runtime.publish( CodeStore::default().compile_with_module( - "fn check() { if fail_once() { 1 / 0 } else { 0 } } pub fn handle(state, message) { let _ = check(); (state + message.value, []) }", + "fn check() { if fail_once() { 1 / 0 } else { 0 } } pub fn handle(ctx, state, sender, message) { let _ = check(); state + message.value }", "test-fail-once:v1", module, )?, @@ -1081,7 +1413,7 @@ mod tests { let mut runtime = Runtime::default(); let code = publish( &mut runtime, - "pub fn handle(state, message) { (state * 10 + message.value, []) }", + "pub fn handle(ctx, state, sender, message) { state * 10 + message.value }", )?; let actor = runtime.spawn(code, Value::Integer(0), capacity(3))?; let first = test_message(0, 1, None); @@ -1098,7 +1430,7 @@ mod tests { assert_eq!(running.reserved, Some(first.clone())); assert_eq!(running.queued, vec![second.clone(), third.clone()]); - runtime.stop(actor)?; + runtime.kill(actor)?; let stopped = runtime.inspect(actor)?; assert_eq!(stopped.status, Status::Stopped); assert_eq!(stopped.state, Value::Integer(0)); diff --git a/crates/runa/src/lib.rs b/crates/runa/src/lib.rs index 7031677..a7b3b9f 100644 --- a/crates/runa/src/lib.rs +++ b/crates/runa/src/lib.rs @@ -7,11 +7,12 @@ use std::num::NonZeroUsize; mod network; pub use network::serve_websocket; -use runa_actor::{ActorRef, Event, Status}; +use runa_actor::{ActorRef, Event, Status, SystemMessage}; use runa_image_api::ImageApi; use runa_protocol::{ ActorSnapshotDto, ActorStatusDto, BehaviorDto, BehaviorVersionDto, Command, ErrorCode, EventDto, ExecutionSnapshotDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, Response, + SourceCommitDto, SourceFileDto, SystemMessageDto, }; pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024; @@ -64,6 +65,81 @@ impl Server { fn dispatch_command(&mut self, command: Command) -> Result { match command { + Command::ListSources => Ok(Reply::Sources( + self.image.sources().into_iter().map(source_dto).collect(), + )), + Command::ReadSource { path } => self + .image + .source(&path) + .cloned() + .map(source_dto) + .map(Reply::Source) + .ok_or_else(|| ProtocolError::new(ErrorCode::NotFound, "unknown source file")), + Command::WriteSource { + path, + expected_source_id, + source, + } => { + let expected = self.image.source(&path).map(|file| file.source_id); + if expected.map(|id| id.to_string()) != expected_source_id { + return Err(ProtocolError::new( + ErrorCode::Conflict, + "source identity conflict", + )); + } + self.image + .write_source(&path, expected, &source) + .map(source_dto) + .map(Reply::Source) + .map_err(api_error) + } + Command::CommitSource { path } => self + .image + .commit_source(&path) + .map(source_commit_dto) + .map(Reply::SourceCommitted) + .map_err(api_error), + Command::SourceHistory { path } => self + .image + .source_history(&path) + .map(|history| { + Reply::SourceHistory(history.iter().cloned().map(source_commit_dto).collect()) + }) + .ok_or_else(|| ProtocolError::new(ErrorCode::NotFound, "unknown source file")), + Command::RevisionSource { path, revision } => self + .image + .source_commit_source(&path, &revision) + .map(|source| Reply::BehaviorSource { + source: source.to_owned(), + }) + .ok_or_else(|| ProtocolError::new(ErrorCode::NotFound, "unknown source revision")), + Command::RollbackSource { path, revision } => self + .image + .rollback_source(&path, &revision) + .map(source_dto) + .map(Reply::Source) + .map_err(api_error), + Command::System { actor_ref, message } => { + let actor = parse_actor_ref(&actor_ref)?; + let result = match message { + SystemMessageDto::Reload => self.image.reload(actor).map(|_| ()), + SystemMessageDto::Become { path } => { + self.image.r#become(actor, &path).map(|_| ()) + } + other => { + let system = match other { + SystemMessageDto::Stop => SystemMessage::Stop, + SystemMessageDto::Kill => SystemMessage::Kill, + SystemMessageDto::Restart => SystemMessage::Restart, + SystemMessageDto::Pause => SystemMessage::Pause, + SystemMessageDto::Resume => SystemMessage::Resume, + _ => unreachable!("source operations handled above"), + }; + self.image.system(actor, system) + } + }; + result.map(|()| Reply::Acknowledged).map_err(api_error) + } Command::PublishBehavior { name, expected_revision, @@ -199,6 +275,21 @@ impl Server { } } +fn source_dto(file: runa_code::SourceFile) -> SourceFileDto { + SourceFileDto { + path: file.path, + source_id: file.source_id.to_string(), + source: file.source, + } +} + +fn source_commit_dto(commit: runa_code::SourceCommit) -> SourceCommitDto { + SourceCommitDto { + revision: commit.revision, + source_id: commit.source_id.to_string(), + } +} + fn behavior_dto(behavior: runa_code::BehaviorSnapshot) -> BehaviorDto { BehaviorDto { name: behavior.name, @@ -281,11 +372,15 @@ fn event_dto(event: &Event) -> EventDto { fn api_error(error: impl std::fmt::Display) -> ProtocolError { let message = error.to_string(); - let code = if message.starts_with("behavior revision conflict:") { + let code = if message.starts_with("behavior revision conflict:") + || message.starts_with("source identity conflict:") + { ErrorCode::Conflict } else if matches!( message.as_str(), "unknown actor" + | "unknown source path" + | "unknown source revision" | "unknown behavior" | "unknown behavior revision" | "unknown target behavior revision" @@ -297,7 +392,10 @@ fn api_error(error: impl std::fmt::Display) -> ProtocolError { "mailbox full" | "effect exceeds mailbox capacity" ) { ErrorCode::CapacityExceeded - } else if message == "invalid behavior name" { + } else if matches!( + message.as_str(), + "invalid behavior name" | "invalid source path" + ) { ErrorCode::InvalidRequest } else { ErrorCode::RuntimeFailure @@ -316,6 +414,137 @@ fn parse_actor_ref(value: &str) -> Result { #[cfg(test)] mod tests { use super::*; + + #[test] + fn working_source_commit_rollback_spawn_and_system_dispatch() { + let mut server = Server::new(); + let path = "actors/counter".to_owned(); + let original = "pub fn handle(ctx, state, sender, message) { state + message } pub fn on_shutdown(state) { state + 100 }".to_owned(); + let response = server.dispatch(request(Command::WriteSource { + path: path.clone(), + expected_source_id: None, + source: original.clone(), + })); + let Ok(Reply::Source(file)) = response.result else { + panic!("source write failed"); + }; + assert!( + server + .dispatch(request(Command::WriteSource { + path: path.clone(), + expected_source_id: None, + source: original.clone() + })) + .result + .is_err() + ); + let Ok(Reply::SourceCommitted(commit)) = server + .dispatch(request(Command::CommitSource { path: path.clone() })) + .result + else { + panic!("commit failed"); + }; + assert!( + commit + .revision + .bytes() + .all(|byte| byte.is_ascii_alphanumeric()) + ); + let updated = + "pub fn handle(ctx, state, sender, message) { state + message * 2 }".to_owned(); + assert!( + server + .dispatch(request(Command::WriteSource { + path: path.clone(), + expected_source_id: Some(file.source_id), + source: updated + })) + .result + .is_ok() + ); + let Ok(Reply::ActorSpawned { actor_ref }) = server + .dispatch(request(Command::Spawn { + behavior_name: path.clone(), + state: Value::Integer(1), + mailbox_capacity: 4, + })) + .result + else { + panic!("spawn failed"); + }; + assert!( + server + .dispatch(request(Command::Send { + actor_ref: actor_ref.clone(), + message: Value::Integer(3) + })) + .result + .is_ok() + ); + server.dispatch(request(Command::Tick { + ticks: 100, + instruction_budget: 32, + })); + let Ok(Reply::Actor(snapshot)) = server + .dispatch(request(Command::InspectActor { + actor_ref: actor_ref.clone(), + })) + .result + else { + panic!("inspect failed"); + }; + assert_eq!(snapshot.state, Value::Integer(7)); + assert!( + server + .dispatch(request(Command::RollbackSource { + path: path.clone(), + revision: commit.revision + })) + .result + .is_ok() + ); + assert!( + server + .dispatch(request(Command::System { + actor_ref: actor_ref.clone(), + message: SystemMessageDto::Reload + })) + .result + .is_ok() + ); + server.dispatch(request(Command::Tick { + ticks: 1, + instruction_budget: 32, + })); + assert!( + server + .dispatch(request(Command::System { + actor_ref: actor_ref.clone(), + message: SystemMessageDto::Stop + })) + .result + .is_ok() + ); + server.dispatch(request(Command::Tick { + ticks: 100, + instruction_budget: 32, + })); + let Ok(Reply::Actor(snapshot)) = server + .dispatch(request(Command::InspectActor { actor_ref })) + .result + else { + panic!("inspect failed"); + }; + assert_eq!(snapshot.status, ActorStatusDto::Stopped); + assert_eq!(snapshot.state, Value::Integer(107)); + let Ok(Reply::SourceHistory(history)) = server + .dispatch(request(Command::SourceHistory { path })) + .result + else { + panic!("history failed"); + }; + assert_eq!(history.len(), 1); + } use runa_protocol::Value; fn test_message(kind: i64, value: i64, target: Option) -> Value { @@ -343,7 +572,8 @@ mod tests { let response = server.dispatch(request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: None, - source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned(), + source: "pub fn handle(ctx, state, sender, message) { state + message.value }" + .to_owned(), })); match response.result.unwrap() { Reply::Behavior(behavior) => behavior, @@ -422,7 +652,7 @@ mod tests { let conflict = server.dispatch(request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: Some(99), - source: "pub fn handle(state, message) { (state, []) }".to_owned(), + source: "pub fn handle(ctx, state, sender, message) { state }".to_owned(), })); assert_eq!(conflict.result.unwrap_err().code, ErrorCode::Conflict); } @@ -434,7 +664,8 @@ mod tests { let updated = server.dispatch(request(Command::PublishBehavior { name: "counter".to_owned(), expected_revision: Some(first.revision), - source: "pub fn handle(state, message) { (state * message.value, []) }".to_owned(), + source: "pub fn handle(ctx, state, sender, message) { state * message.value }" + .to_owned(), })); assert!(matches!(updated.result, Ok(Reply::Behavior(_)))); @@ -445,7 +676,8 @@ mod tests { assert_eq!( source.result, Ok(Reply::BehaviorSource { - source: "pub fn handle(state, message) { (state + message.value, []) }".to_owned() + source: "pub fn handle(ctx, state, sender, message) { state + message.value }" + .to_owned() }) ); @@ -489,7 +721,7 @@ mod tests { assert_eq!( serde_json::to_value(notification).unwrap(), serde_json::json!({ - "version": 3, + "version": 4, "event": {"event": "image_changed", "data": {"revision": 42}} }) ); diff --git a/crates/runa/src/main.rs b/crates/runa/src/main.rs index 125fe6d..5e1fc9d 100644 --- a/crates/runa/src/main.rs +++ b/crates/runa/src/main.rs @@ -20,9 +20,9 @@ fn initial_image() -> io::Result { .dispatch(Request { version: PROTOCOL_VERSION, request_id: "bootstrap-counter".to_owned(), - command: Command::PublishBehavior { - name: "counter".to_owned(), - expected_revision: None, + command: Command::WriteSource { + path: "actors/counter".to_owned(), + expected_source_id: None, source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), }, }) diff --git a/crates/runa/src/network.rs b/crates/runa/src/network.rs index cc6694e..4be30dd 100644 --- a/crates/runa/src/network.rs +++ b/crates/runa/src/network.rs @@ -67,7 +67,9 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> { let mutates = !matches!(dispatch.request.command, Command::GetBehavior { .. } | Command::GetBehaviors | Command::GetBehaviorSource { .. } | Command::InspectActor { .. } | - Command::ListActors | Command::GetEvents); + Command::ListActors | Command::GetEvents | Command::ListSources | + Command::ReadSource { .. } | Command::SourceHistory { .. } | + Command::RevisionSource { .. }); let response = server.dispatch(dispatch.request); let changed = mutates && response.result.is_ok(); // Dispatch may commit even if the initiating client has disconnected. diff --git a/crates/runa/tests/local_client.rs b/crates/runa/tests/local_client.rs index cd20f3d..86a292d 100644 --- a/crates/runa/tests/local_client.rs +++ b/crates/runa/tests/local_client.rs @@ -40,13 +40,7 @@ fn start_server_with(args: &[&str], listen_env: &str) -> (ServerProcess, SocketA 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() - ); + assert!(client.request(Command::ListSources).unwrap().result.is_ok()); for args in [ vec!["--listen", "invalid"], vec!["--listen"], @@ -104,7 +98,7 @@ fn websocket_broadcasts_non_destructive_invalidations_for_mailbox_state_and_life let mut client = WebSocketClient::connect(address).unwrap(); let actor_ref = match client .request(Command::Spawn { - behavior_name: "counter".to_owned(), + behavior_name: "actors/counter".to_owned(), state: Value::Integer(1), mailbox_capacity: 4, }) @@ -184,7 +178,7 @@ fn websocket_broadcasts_non_destructive_invalidations_for_mailbox_state_and_life first .send(Message::Text( serde_json::to_string(&runa_protocol::Request { - version: 3, + version: 4, request_id: "read-fence".to_owned(), command: Command::ListActors, }) @@ -287,7 +281,7 @@ fn websocket_server_accepts_local_protocol_requests() { let mut second = WebSocketClient::connect(address).unwrap(); let actor_ref = match first .request(Command::Spawn { - behavior_name: "counter".to_owned(), + behavior_name: "actors/counter".to_owned(), state: Value::Integer(5), mailbox_capacity: 2, }) @@ -301,29 +295,36 @@ fn websocket_server_accepts_local_protocol_requests() { assert!(matches!(second.request(Command::InspectActor { actor_ref: actor_ref.clone(), }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(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 - )); + let Reply::SourceCommitted(commit) = first + .request(Command::CommitSource { + path: "actors/counter".to_owned(), + }) + .unwrap() + .result + .unwrap() + else { + panic!("expected source commit"); + }; + first + .request(Command::RollbackSource { + path: "actors/counter".to_owned(), + revision: commit.revision.clone(), + }) + .unwrap() + .result + .unwrap(); drop(first); drop(second); let mut reconnected = WebSocketClient::connect(address).unwrap(); assert!(matches!( reconnected - .request(Command::GetBehavior { - name: "counter".to_owned(), + .request(Command::SourceHistory { + path: "actors/counter".to_owned(), }) .unwrap() .result, - Ok(Reply::Behavior(behavior)) if behavior.revision == 2 + Ok(Reply::SourceHistory(history)) if history.len() == 1 && history[0] == commit )); reconnected .request(Command::Send { @@ -349,10 +350,10 @@ fn websocket_server_accepts_local_protocol_requests() { }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12))); reconnected - .request(Command::PublishBehavior { - name: "echo".to_owned(), - expected_revision: None, - source: "pub fn handle(state, message) { (message, []) }".to_owned(), + .request(Command::WriteSource { + path: "echo".to_owned(), + expected_source_id: None, + source: "pub fn handle(ctx, state, sender, message) { message }".to_owned(), }) .unwrap() .result diff --git a/docs/architecture.md b/docs/architecture.md index d0e737f..38dcac0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,7 +18,8 @@ Rust supplies a minimal, reliable substrate; increasingly much of the actor fram - Inspect, spawn, stop, restart, and supervise actors from both graphical world and VS Code. - Inspect state, mailboxes, behavior versions, failures, and execution status. - Send messages and evaluate development operations interactively. -- Edit, compile, publish, install, and roll back behaviors without restarting the image. +- Edit image source files, commit and roll back their history, and reload or + become another script without restarting the image. - Support interruptible execution, pause/resume, and eventually source-level stepping. - Preserve stable actor identity and optionally mailbox contents across actor incarnation failures. - Store canonical Rune source and immutable implementations by content identity; cache compilation. @@ -106,20 +107,32 @@ State is owned by the actor runtime, logically private, and supplied to each sch The handler is a stateless Rune function associated with an immutable implementation ID. Conceptually: ```text -handle(state, message, context) -> Transition(new_state, effects) +handle(ctx, state, sender, message) -> new_state +on_shutdown(state) -> new_state // optional ``` -`context` provides restricted runtime capabilities, such as actor identity and correlation IDs. Actor-specific state must not be stored in global variables or implicit mutable closures. The handler may await approved asynchronous operations. This API is conceptual, not verified Rune syntax. +`ctx.send(recipient, data)` records an ordinary send with the calling actor as +sender. `ctx.send_from(sender, recipient, data)` supports forwarding. +Sender metadata lives in a runtime envelope and is passed separately from data; +host-originated messages have unit sender. Actor-specific state must not be +stored in globals or implicit mutable closures. These handler and send APIs are +implemented against Rune 0.14.2; the handler may await approved operations. ### 4.5 Effects -Prefer explicit effect descriptions for sends, timers, external requests, and lifecycle operations rather than arbitrary irreversible side effects during a state transition. +Context sends buffer owned effects instead of delivering immediately. State and +all validated sends commit together; later mutation of a Rune value does not +change a buffered message. Timers and durable external requests remain planned. +System lifecycle messages are a separate host-only control path: actors cannot +construct stop, kill, restart, pause, resume, reload, or become commands through +ordinary sends. Stop completes the current handler then invokes optional +`on_shutdown(state)` before stopping; kill skips shutdown. Restart retains the +loaded script and state. Reload/become switch immutable implementations only +after an active handler completes. ```text -Transition { - new_state, - effects: [Send(...), ScheduleTimer(...), ...], -} +ctx.send(recipient, data); +new_state ``` On durable paths, commit the state transition, input-message acknowledgment, and transactional outbox records atomically. Delivery of outbox effects occurs afterward and may be retried. Exactly-once external side effects are **not** guaranteed; use idempotency keys and deduplication where needed. @@ -168,24 +181,29 @@ compiled: A compiled artifact cache key must include source identity, Rune compiler version, compiler settings, enabled native modules, runtime ABI, relevant features, and target constraints. Do not assume artifacts are portable between Rune versions or host configurations. -A mutable behavior registry maps names to immutable implementations: +Mutable image files are organized beneath a `source` directory: ```text -behaviors/Counter -> implementation:abc123 -history: [implementation:def456, implementation:abc123] +source/actors/counter.rn -> current working source +actors/counter -> commit history [readable-generated-revision, ...] ``` -Active frames retain references to their exact version. Garbage collection must respect references from active invocations, actor state, persisted snapshots, and retained history. Revisions prevent conflicting editor updates. +Spawn takes `actors/counter` and compiles the current working source, even +uncommitted. Commit snapshots are version control only and can retain invalid +source. Rollback restores working source, not a running actor's frames. +Saves use expected source identities to prevent conflicting editor updates. +Active frames retain exact immutable implementations independently of source +editing and commit history. Garbage collection must respect those references. ### 6.1 Hot upgrade procedure -1. Accept source and expected behavior revision. -2. Canonicalize, hash, and compile (or reuse cached artifact). +1. Save working source with an expected source identity; commit separately if desired. +2. Reload the current actor path or become another path; hash and compile current text (or reuse cache). 3. Report diagnostics without changing the running behavior on failure. -4. Publish an immutable implementation. +4. Register an immutable implementation. 5. Validate state compatibility or execute explicit migration. 6. Install at a safe boundary (initially between message handlers). -7. Emit change events and retain rollback history. +7. Emit change events. Source commit history remains independent. Do not rewrite suspended execution frames in place. @@ -220,7 +238,7 @@ mailbox.retry mailbox.deadLetters state.inspect state.snapshot state.history behavior.list behavior.get behavior.compile -behavior.publish behavior.install behavior.rollback +source.write source.commit source.rollback behavior.history code.getSource code.getArtifact code.getMetadata @@ -248,11 +266,13 @@ scheduler ticks or event drains. Revisions and refreshes are coalesced rather than replayed, and changes during a fetch trigger a follow-up refresh. Cached open snapshots are marked disconnected on connection loss and isolated by connection session so reconnecting to another image cannot overwrite them. -Spawn with explicit behavior/state, structured send, pause/resume/stop/restart, -explicit scheduler ticks, and a behavior explorer with revision history and -read-only source documents are also implemented. Sources are fetched at explicit -revisions and cached by connection session; image notifications refresh listings -without replacing historical source text. Remaining UX below is +Spawn with explicit source path/state, structured send, system lifecycle commands, +and explicit scheduler ticks are implemented. The extension mounts an editable +`source` directory with nested Rune files and file-context spawn actions. +Saving writes working source; committing and browsing history are separate +commands. Commit source documents are read-only and isolated by connection +session. Image notifications refresh listings without replacing historical +source text. Remaining UX below is planned; detailed runtime event subscriptions and durable replay still need design. @@ -261,8 +281,8 @@ Required extension UX: - Connection management and runtime status. - Actor tree with spawn, stop, restart, pause, and resume actions. - Actor state and mailbox inspector, including message sending. -- Behavior tree and source navigation. -- Editable virtual documents for mutable behavior references. +- Directory-based image source navigation. +- Editable image source files with independent commit/history/rollback actions. - Read-only virtual documents for exact content-addressed implementations. - Compilation diagnostics, update conflicts, and runtime event stream. - Later: Debug Adapter Protocol integration for breakpoints, frames, and stepping. @@ -270,11 +290,14 @@ Required extension UX: Suggested virtual URIs: ```text -liveworld:/behaviors/Counter/current.rn -liveworld:/implementations//source.rn +source/actors/counter.rn +source-history://actors/counter/.rn ``` -Saving the first compiles/publishes a new implementation and conditionally updates the behavior reference. The second is immutable and read-only. Avoid silently overwriting edits from another client. +Saving the working file does not compile, commit, or install it. Spawn compiles +current content; reload/become install at a handler boundary. The historical +document is immutable and read-only. Avoid silently overwriting another client's +working source. ## 10. Persistence @@ -456,7 +479,8 @@ explicit host-provided migration closure for structured actor state. Actor snapshots expose active VM instruction progress, instruction pointer, and call-frame depth. State and messages are owned portable trees of null, booleans, integers, strings, lists, records, and distinct actor references. -Rune handlers receive `(state, message)` and return state plus explicit sends; +Rune handlers receive `(ctx, state, sender, message)` and return only state; +context sends are buffered and committed atomically with state. Rune heap objects never become shared committed actor data. State schemas, version compatibility checks, Rune-authored migration functions, source mapping, and local-variable inspection remain future work. @@ -470,7 +494,7 @@ 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 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; +embedding a runtime. Multiple clients share actors, working source, and commit history; 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 @@ -494,8 +518,9 @@ variables. Mise's `start` and `cli` tasks run the server and REPL respectively. Connection management, the actor explorer, and read-only snapshot inspection are implemented in `vscode/`, along with spawn/send, lifecycle controls, and -explicit scheduler ticks, behavior/revision browsing, and immutable source documents. Behavior editing, -diagnostics, hot upgrades, and detailed runtime event subscriptions remain planned. +explicit scheduler ticks, editable image source files, commit history, and +immutable revision documents. Reload/become install scripts at safe boundaries. +Structured compilation diagnostics and detailed runtime event subscriptions remain planned. Non-destructive image-change notifications and automatic snapshot refresh are implemented over the shared `runa-debug` WebSocket transport. @@ -532,6 +557,13 @@ Act as the principal systems architect and implementation partner. Work incremen ## 16. Long-Term Acceptance Scenario -A developer launches the graphical world, spawns a counter actor, sends it a message, inspects its mailbox and committed state, opens its exact content-addressed Rune handler, edits and publishes a new version, installs it at a safe point, and sees subsequent messages use the new behavior. The same operations are possible through VS Code using the image API. A failed actor incarnation can be restarted with the same logical identity and retained mailbox, and the world can eventually save and restore a meaningful development session. +A developer launches the graphical world, spawns `actors/counter`, sends it a +message, inspects its mailbox and committed state, edits +`source/actors/counter.rn`, optionally commits a source snapshot, then reloads the +actor at a handler boundary and sees subsequent messages use the new script. +New spawns use working source regardless of commit status. The same operations +are possible through VS Code using the image API. A failed incarnation can be +restarted with the same identity and retained mailbox, and the world can +eventually save and restore a meaningful development session. **North star:** The world is the program; actors are its living components; development is continuous interaction with that world rather than a separate edit-build-run cycle. diff --git a/docs/protocol.md b/docs/protocol.md index 042d520..3039f41 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -9,9 +9,9 @@ Clap parses `--listen` / `RUNA_LISTEN` on the server and `--connect` / The listener is unauthenticated and intended only for trusted local development. Both the REPL and VS Code use WebSockets. -## Version 3 +## Version 4 -Every request and response carries `version: 3`. Requests also carry a +Every request and response carries `version: 4`. Requests also carry a non-empty `request_id` used to correlate the response. A server must reject unsupported versions with `unsupported_version` rather than interpreting a request using a different contract. Events carry the protocol version and do @@ -21,7 +21,7 @@ Requests have a tagged command object: ```json { - "version": 3, + "version": 4, "request_id": "req-1", "command": { "operation": "send", @@ -40,14 +40,42 @@ Requests have a tagged command object: } ``` -The version 3 operations are behavior publication, rollback, lookup, source -retrieval, actor spawn, send, tick, inspect, actor listing, event retrieval, -stop, restart, pause, resume, cancellation, and behavior installation. +The version 4 operations are source listing/read/write, commit/history/revision +retrieval/rollback, actor spawn, send, tick, inspect, actor listing, event +retrieval, privileged system messages, and cancellation. Migration closures are intentionally excluded: they are host callbacks, not portable wire values. +Source paths are extensionless relative paths such as `actors/counter`, not +behavior aliases. They exclude the `source/` root and `.rn` extension. + +| Operation | Parameters | Reply kind/data | +| --- | --- | --- | +| `list_sources` | none | `sources`: file array | +| `read_source` | `path` | `source`: file | +| `write_source` | `path`, `expected_source_id` (null to create), `source` | `source`: file | +| `commit_source` | `path` | `source_committed`: commit | +| `source_history` | `path` | `source_history`: commit array | +| `revision_source` | `path`, `revision` | `behavior_source`: `{source}` | +| `rollback_source` | `path`, `revision` | `source`: restored file | +| `spawn` | `path`, tagged `state`, positive `mailbox_capacity` | `actor_spawned`: `{actor_ref}` | +| `system` | `actor_ref`, `message` | `acknowledged` | + +A file is `{path, source_id, source}` and a commit is +`{revision, source_id}`. Commits generate readable random revision strings and +capture working text without compilation. Rollback restores working source +without appending history. Spawn compiles current working source even when +uncommitted; compilation failures do not spawn an actor. + +System message shape is `{kind: "stop"|"kill"|"restart"|"pause"|"resume"|"reload"}` +or `{kind: "become", path: "actors/other"}`. This is a trusted host request; +actor `ctx.send` and `ctx.send_from` accept only ordinary data. Stop runs optional +`on_shutdown` after the active handler, kill drops it without shutdown, restart +retains the loaded script and state, and reload/become defer installation to +the next handler boundary. The scheduler still advances only through `tick`. + Responses echo the request ID and carry either a typed reply or a protocol -error. Events use a separate versioned envelope. Actor and behavior snapshots +error. Events use a separate versioned envelope. Actor and source snapshots are wire DTOs, not serialized runtime objects; implementation identities are represented by their stable display strings. Public actor identity is a 21-character URL-safe NanoID string carried in `actor_ref`. The server maps known service @@ -105,17 +133,15 @@ No automatic retry is performed: loss of a response does not imply that the operation failed to commit. The interactive REPL uses WebSockets 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 network executable -publishes the bundled `counter` behavior on image startup, not on connection. +does not stop actors or reset source history; reconnecting clients can +retrieve them through `list_actors` and `list_sources`. The network executable +loads `source/actors/counter.rn` on image startup, not on connection. The stdio transport and child-process client have been removed. The TypeScript VS Code client issues `list_actors`, `inspect_actor`, -`get_behaviors`, `get_behavior_source`, `spawn`, `send`, `tick`, and -pause/resume/stop/restart operations. -Behavior source lookup uses an explicit immutable revision, not a mutable -latest-source alias. VS Code caches read-only source documents by connection, -behavior name, and revision while refreshing behavior listings on notifications. +source file/version-control operations, `spawn`, `send`, `tick`, and `system`. +VS Code mounts writable image working files under a `source` workspace folder, +and caches read-only commit documents by connection, path, and revision. It preserves i64 inputs and bounds the complete outgoing envelope as well as incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages, version/request-ID/reply-kind checks, and portable snapshot value validation. @@ -131,7 +157,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. +listener. The request/response contract is version 4. 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 @@ -148,7 +174,7 @@ Every upgraded connection automatically receives non-destructive invalidations: ```json { - "version": 3, + "version": 4, "event": { "event": "image_changed", "data": { "revision": 42 } @@ -157,7 +183,7 @@ Every upgraded connection automatically receives non-destructive invalidations: ``` Successful mutating requests (spawn, send, tick, lifecycle controls, -publication/rollback, and installation) increment a server-session revision +source writes/commits/rollback, and system messages) increment a server-session revision and notify all connected clients, including the initiator. Read-only requests, failed mutations, and `get_events` do not generate invalidations. A tick invalidates snapshots even when it only advances execution telemetry or causes @@ -171,11 +197,11 @@ These notifications are hints to re-fetch authoritative data, not individual runtime events, a durable log, an atomic snapshot revision, or replay. On connection/reconnection, fetch a full listing first. The VS Code client coalesces bursts and schedules another refresh if an invalidation arrives -during an in-flight read. It never advances the scheduler or calls `get_events`; +during an in-flight read. Refresh never advances the scheduler or calls `get_events`; the REPL's explicit `events` command still drains the separate runtime queue. The current validation checks the protocol version, request ID, actor-reference -shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox -capacity, and instruction budget. Behavior-name grammar, actor existence, -current behavior revisions, mailbox capacity at send time, and all runtime +shape, structured-value limits, revision ID shape, non-empty paths, mailbox +capacity, and instruction budget. Canonical source paths, actor existence, +expected source identities, mailbox capacity at send time, and all runtime invariants remain authoritative at the application-service boundary. diff --git a/docs/rune-feasibility.md b/docs/rune-feasibility.md index 76a7e2e..03a9843 100644 --- a/docs/rune-feasibility.md +++ b/docs/rune-feasibility.md @@ -79,6 +79,10 @@ The implementation was checked against the downloaded 0.14.2 source: The test does not move an active VM, execution, or Rune value between threads. - Dropping an execution cancels this controlled local future. Cancellation of external I/O and rollback of native side effects are not established. +- Rune 0.14.2's `VmExecution::into_owned` transfers the stack but resets its VM + instruction pointer. The invocation wrapper preserves the selected entrypoint + with `vm_mut().set_ip(...)`; regression coverage invokes a non-first + `on_shutdown` function so it cannot accidentally execute the first handler. ## Proposed Phase 1 direction diff --git a/docs/runtime.md b/docs/runtime.md index 2799c4d..96dee1a 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -16,16 +16,17 @@ 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 WebSocket 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. -`publish` reads a Rune source file and takes `new` for a first revision or the -expected current revision for an update. `rollback` requires the expected -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 WebSocket server loads [counter.rn](../runa-std/actors/counter.rn) into the +image's working `source/actors/counter.rn`. Commands include `spawn`, `send`, +`tick`, `inspect`, `actors`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`, +`reload`, `become`, `cancel`, `sources`, `write`, `commit`, `history`, and +`rollback`; enter `help` to see usage. Paths exclude the root and extension: +`spawn actors/counter 1`. Spawn compiles the current working source, not a +committed revision. `write ` updates working source using +source-identity conflict checks. `commit ` captures it without compilation, +and generates a readable revision ID. `history ` lists snapshots; +`rollback ` restores working source without making a commit. +Saving, committing, spawning, and reloading are distinct operations. 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 @@ -35,9 +36,9 @@ There is no stdio transport; all external clients use WebSockets. Example: ```text -runa> let sender = spawn counter 0 +runa> let sender = spawn actors/counter 0 sender = @ -runa> let receiver = spawn counter 10 +runa> let receiver = spawn actors/counter 10 receiver = @ runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}} message queued @@ -54,7 +55,7 @@ explicitly advances the scheduler. The default per-actor slice budget is 32 instructions. State and messages use JSON literals in the REPL, preserving whitespace inside -strings. `spawn ` and `let = spawn ` +strings. `spawn ` and `let = spawn ` require explicit initial state; `send ` takes one message value. For example, `send receiver {"op": "add", "value": 7}` adds to the bundled counter. Its supported operations are `add` and `send`; unknown operations @@ -75,7 +76,7 @@ also offers references inside a `"$actor"` string. - `runa-core` owns stable source/implementation identities, actor references, and the portable owned value model shared by the runtime and protocol. - `runa-code` preserves original source, derives identities, caches compiled - artifacts, and provides an in-memory named behavior registry with revisions. + artifacts, mutable path-based working source, and independent commit snapshots. - `runa-rune` owns compilation and typed bounded invocation execution. - `runa-runtime` owns implementation registration, queues, private committed state, scheduling, message effects and lifecycle coordination. @@ -95,30 +96,33 @@ The image API is process-local and intended for trusted callers. It has no transport, authentication, or authorization layer; do not expose it directly to untrusted clients. Its install operation also supports the runtime's explicit host-provided -state-migration closure. `behavior_source` retrieves the exact original -published source for a selected behavior revision. +state-migration closure. Revision source retrieval returns the exact saved text. An implementation is published once under an ID. Actors reference that exact implementation. Implementation IDs use source content and the compile fingerprint; actor references are random NanoIDs and mailbox IDs remain process-local counters. Incarnation counters use checked increments. -`CodeStore::publish_behavior` compiles source before appending a behavior -revision. Creation expects no existing revision; updates require the caller's -expected current revision, so stale edits fail without changing the behavior -reference. Failed compilation also leaves the reference and history unchanged -(the exact source remains available for diagnostics). Rollback points the -behavior at an earlier immutable implementation and appends a new monotonically -increasing revision; it does not rewrite history. Behavior names are -case-sensitive paths limited to ASCII letters, digits, `_`, `-`, `.`, and `/`, -with empty, `.` and `..` path components rejected. +Source files have canonical case-sensitive relative paths, without `source/` +or `.rn`, rejecting traversal and empty components. Writes require the expected +current source identity (null means create). Conflicting saves do not overwrite +another client's work. Commits are version control only, independent of +compilation and actor installation; invalid Rune source can be committed. +Commit IDs are generated from a readable lowercase alphabet, not caller-chosen +revision numbers. Source and history remain in memory until server exit. +The older named behavior registry survives as an internal compatibility helper, +but its publish/history/install commands are not part of the v4 wire contract. -The runtime's `install` operation switches an actor's implementation only +The runtime's low-level `install` operation switches an actor's implementation only when it is idle, has no active invocation, and has no reserved input. Queued messages may remain; they will use the newly installed implementation. Active or awaiting handlers keep their original implementation, and installation attempts during execution fail explicitly so the caller can retry at the next handler boundary. `install` preserves the current structured state unchanged. +Client-facing `reload` and `become ` instead queue installation for the +next handler boundary, keeping active frames immutable. Reload compiles the +latest working source at the actor's current path; become chooses another path. +Compilation failure preserves its current implementation and source binding. Runtime callers that need a state transformation can use `install_with_migration`, which receives the committed state and computes its @@ -175,21 +179,27 @@ actors are spawned and remain stable across stop/restart; mailbox IDs are separate internal counters. Rune handlers have this interface: ```text -handle(state, message) -> (new_state, [(target_actor_ref, message), ...]) +handle(ctx, state, sender, message) -> new_state +on_shutdown(state) -> new_state // optional ``` -The former `kind`, `value`, and `target_ref` envelope is removed. Actors choose -their own message records. For example: +The runtime uses internal envelopes containing sender and data, then passes +them separately to the handler. Host-originated messages have unit/null sender. +Actors choose their own message data. For example: ```rune -pub fn handle(state, message) { +pub fn handle(ctx, state, sender, message) { state.items.push(message.text); - (state, [(message.reply_to, #{accepted: true})]) + ctx.send(sender, #{accepted: true}); + state } ``` The runtime allocates a fresh Rune object graph for each state and message -input, and copies portable data back out before committing. A Rune handler can +input, and copies portable data back out before committing. `ctx.send` copies +message data and records the executing actor as sender. +`ctx.send_from(sender, recipient, message)` permits forwarding the original +sender (including unit for host-originated input). A Rune handler can mutate its invocation-local draft but cannot mutate committed state, the reserved input, or another actor's data. Copies made for separate effect messages do not share mutable Rune objects. @@ -207,7 +217,8 @@ Each state/message is limited to depth 32 (root depth 0), 4,096 value nodes (including the root), and 1 MiB of UTF-8 string/key bytes. A transition permits at most 4,096 send effects. Transport line-size limits apply separately. These are conversion limits, not a Rune heap quota or hostile-code sandbox. -Schema/version policy and capability-bearing contexts remain future work. +Schema/version policy remains future work. Actor contexts expose only ordinary +data sends, never privileged system messages. ## Scheduling and lifecycle @@ -232,12 +243,21 @@ observable failed status, and emit a failure event. The failed message is not silently acknowledged or automatically retried. External sends return errors for unknown, unavailable, or full actors. -Stopping drops the invocation, retaining committed state, mailbox and reserved -message. Restarting a stopped or failed actor preserves its actor reference, +System messages are host-only commands, separate from ordinary data messages: +stop, kill, restart, pause, resume, reload, and become. A record containing +`kind: "stop"` sent by an actor is just application data. +Graceful stop finishes the active handler, then runs optional `on_shutdown(state)` +with cooperative instruction slices and commits its returned state. It does not +drain queued input. A missing hook stops normally; a failing hook marks the actor +failed and retains its last committed state. Stop unpauses a paused actor so +shutdown can progress; explicit scheduler ticks are still needed. +Kill drops the invocation immediately between slices, skips shutdown, and +retains committed state, mailbox and reserved message. Restart preserves its actor reference, mailbox ID, implementation ID, committed state, and queued-message order; it increments its incarnation and puts the reserved message at the front of the queue. -Restart is explicit. Repeated failure of the same reserved message can therefore +It can also restart a running actor, killing the current invocation first. +It does not reload working source. Repeated failure of the same reserved message can therefore block later messages until an operator changes the implementation or otherwise resolves it; there is no automatic poison-message policy or supervision. @@ -248,7 +268,8 @@ resolves it; there is no automatic poison-message policy or supervision. | Handler returns a valid transition | New state commits | Acknowledged | All validated sends enqueue | `Idle` | | Rune execution or transition decoding fails | Last committed state retained | Retained | None from the returned transition | `Failed` | | Any target is unknown, stopped, failed, or over capacity | Last committed state retained | Retained | No sends from the transition enqueue | `Failed` | -| Stop during an invocation | Last committed state retained | Retained | None from the incomplete transition | `Stopped` | +| Graceful stop | Active handler then shutdown state commits | Active input acknowledged | Valid active-handler sends commit | `Stopped` after shutdown | +| Kill during an invocation | Last committed state retained | Retained | None from incomplete invocation | `Stopped` | | Restart after stop or failure | Retained until retried | Moved to queue front | None until retry completes | `Idle` | | Pause between ticks | Last committed state retained | Retained | None while paused | `Paused` | | Resume a paused invocation | Retained until invocation completes | Retained | None until completion | Prior runnable/awaiting status | diff --git a/runa-std/actors/counter.rn b/runa-std/actors/counter.rn index d36bd99..808780f 100644 --- a/runa-std/actors/counter.rn +++ b/runa-std/actors/counter.rn @@ -1,8 +1,9 @@ -pub fn handle(state, message) { +pub fn handle(ctx, state, sender, message) { if message.op == "send" { - (state + 1, [(message.target, #{op: "add", value: message.value})]) + ctx.send(message.target, #{op: "add", value: message.value}); + state + 1 } else if message.op == "add" { - (state + message.value, []) + state + message.value } else { panic("unknown counter operation") } diff --git a/vscode/README.md b/vscode/README.md index 0c8471c..d363643 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -1,7 +1,7 @@ # Runa VS Code client This is a development client for a trusted localhost Runa image. It uses -the [version 3 protocol](../docs/protocol.md) over WebSockets with the `runa-debug` +the [version 4 protocol](../docs/protocol.md) over WebSockets with the `runa-debug` subprotocol, not Rust APIs, and never hosts actors or starts its own image. @@ -24,13 +24,16 @@ 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 its **+** button or **Runa: Spawn Actor** - to choose a published behavior, then enter JSON initial state (for example - `0` for `counter`). There is no default behavior or implicit state. - The command reports and returns the new actor reference. Mailbox capacity is 64. +2. Open the image's `source` folder (see [Image source files](#image-source-files)). + Right-click a `.rn` file and choose **Spawn Actor** (initial state `null`) or + **Spawn Actor with State** (enter JSON, for example `0` for + `actors/counter`). **Runa: Spawn Actor** in **Runa Actors** prompts for a source + path and state. The command reports and returns the new actor reference. + Mailbox capacity is 64. 3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot. -4. Right-click an actor to send a JSON message or pause/resume/stop/restart it. - These actions are also available in the Command Palette with an actor picker. +4. Right-click an actor to send a JSON message or a system message: pause, + resume, stop, kill, restart, reload, or become another source path. These + actions are also available in the Command Palette with an actor picker. 5. Use **Runa: Advance Scheduler** to execute an explicit number of ticks (instruction budget 32 per actor). Send only queues a message; it does not execute it. For the counter, send `{"op":"add","value":7}`, then advance. @@ -73,43 +76,53 @@ machine running the extension host, not necessarily the desktop. invalidate the connection and are surfaced in a notification and the **Runa** output channel. Server-reported operation errors leave the connection usable. - The client never ticks the scheduler implicitly or drains `get_events`. - Behavior publication/installation still requires the REPL or another client. Notifications are non-destructive invalidations, not a replayable runtime event stream; reconnect fetches the current image rather than replaying history. For automation, the `runa.connect` command also accepts an explicit endpoint string, and `runa.inspect` accepts an object with an `actor_ref` field. -`runa.spawn` accepts `{ behavior: string, state: string }`, where state is -natural JSON text, and resolves to the new reference. `runa.send` accepts -`{ actor_ref: string }` and JSON message text. `runa.pause`, `runa.resume`, -`runa.stop`, and `runa.restart` accept `{ actor_ref: string }`. +`runa.spawn` accepts `{ path: string, state: string }`, where state is +natural JSON text, and resolves to the new reference. `runa.spawnSource` and +`runa.spawnSourceWithState` accept a source file URI (the latter also optional +JSON state text). `runa.send` accepts `{ actor_ref: string }` and JSON message +text. `runa.pause`, `runa.resume`, `runa.stop`, `runa.kill`, `runa.restart`, and +`runa.reload` accept `{ actor_ref: string }`; `runa.become` also accepts a +target source path. `runa.tick` accepts a positive integer tick count. Command failures are reported in the Runa output channel and an error notification; commands are never retried. -## Behavior and source browsing +## Image source files -The **Runa Behaviors** view in Explorer lists the image's published behaviors. -Each item shows the current revision; expand it for revision history, newest -first. Tooltips show the implementation identity and the current revision is -marked. Click the behavior to open its current source, or a revision to open -that exact source. **Runa: Open Behavior Source** offers the same choices in -the Command Palette. +The image owns actor source text. On connect, the extension mounts it as a +writable workspace folder named `source` (`runa-image:///source`). +In a saved multi-root workspace this happens automatically; otherwise accept the +prompt or run **Runa: Add Image Source Folder** (VS Code may restart extensions +when a single-folder window becomes multi-root). Directories are derived from +source paths: `actors/counter` appears as `source/actors/counter.rn`. Source +paths are canonical and exclude the `source/` prefix and `.rn` suffix. -Sources are fetched through `get_behavior_source` and opened as read-only -`.rn` virtual documents, not local workspace files. If a Rune language -extension is installed, VS Code can associate its language support with `.rn`. -The source text is preserved exactly, without metadata headers. The URI identifies -the connection session, behavior name, and immutable revision. Documents remain -cached on disconnect and never turn into the latest revision automatically. -Reconnect opens distinct URIs; closing a source document releases its cache. -Cached sources are not persisted across VS Code restarts. +- **Save** writes the image's working source with compare-and-swap against the + last observed `source_id`. If another client changed the file, the save fails + instead of overwriting; reopen or revert to load the image's text. Saving + never commits. New `.rn` files are created in the image on first save. + Deleting and renaming are not supported. +- **Spawn** uses the latest saved working source, committed or not. If the + editor has unsaved changes, spawn and commit ask to save first; cancelling + does nothing. Unsaved editor text is never sent implicitly. +- **Runa: Commit Source** records the saved working source as a new revision + and reports its revision string. +- **Runa: Source History** lists revisions for the file and opens the selected + one as a read-only `runa-source:` document. Revision documents are cached per + connection session, never change, and remain open after disconnect. +- **Runa: Roll Back Source** asks for confirmation and replaces the working + source with the selected revision. It does not change running actors; use + **Reload** or **Become** to install source into an actor. -Server notifications refresh both explorers. **Runa: Refresh Image** does the -same on demand. Browsing does not publish code, install actor behavior, tick the -scheduler, or drain runtime events. Source editing and publication remain future -work. For automation, `runa.openBehaviorSource` accepts -`{ name: string, revision: string }` with a positive decimal revision and returns -the opened document URI. +Server notifications refresh the source tree and clean open editors. For +automation, `runa.commitSource` accepts a source URI and returns the revision; +`runa.sourceHistory` accepts a URI and optional revision and returns the opened +document URI; `runa.rollbackSource` accepts a URI and optional revision and +returns the new `source_id` after confirmation. ## Development and validation @@ -128,7 +141,9 @@ coalescing, timeout/disconnect behavior, exact 64-bit integer display, and interoperation with the real Rust server on an ephemeral loopback port. The separate host test downloads VS Code 1.96.4 to the ignored `.vscode-test/` -cache and runs spawn/send/lifecycle/tick, connection, inspection, disconnect, and reconnect commands +cache in a temporary multi-root workspace and runs source folder save/spawn/ +commit/history/rollback, spawn/send/system-message/tick, connection, inspection, +disconnect, and reconnect commands against a test-owned Rust server. It verifies automatic document updates after another client sends messages, pauses, and advances an actor into failure, as well as session isolation. On headless Linux: @@ -137,6 +152,5 @@ well as session isolation. On headless Linux: xvfb-run -a mise run test-extension-host ``` -Behavior editing and publication, -compilation diagnostics, detailed runtime event subscriptions, packaging for +Compilation diagnostics, detailed runtime event subscriptions, packaging for distribution, and debugging remain future work. diff --git a/vscode/integration/behaviors.test.ts b/vscode/integration/behaviors.test.ts deleted file mode 100644 index a6d9ea9..0000000 --- a/vscode/integration/behaviors.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { ImageClient } from "../src/client"; -import { ProtocolError } from "../src/protocol"; -import { control, startImage } from "./server"; - -test( - "behavior browsing retrieves immutable revision source and preserves connection after missing revisions", - { timeout: 10000 }, - async (t) => { - const image = await startImage(); - let client: ImageClient | undefined; - t.after(async () => { - client?.close(); - await image.stop(); - }); - client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined)); - const original = await client.behaviorSource("counter", 1n); - assert.match(original, /pub fn handle/); - const source = "pub fn handle(state, message) { (state + 2, []) }\n"; - const published = await control(image.address, { - operation: "publish_behavior", - parameters: { name: "counter", expected_revision: 1, source }, - }); - assert.match(JSON.stringify(published), /"kind":"behavior"/); - const behavior = (await client.behaviors()).find((behavior) => behavior.name === "counter"); - assert.ok(behavior); - assert.equal(behavior.revision, 2n); - assert.deepEqual( - behavior.history.map((version) => version.revision), - [1n, 2n], - ); - assert.equal(await client.behaviorSource("counter", 2n), source); - assert.equal(await client.behaviorSource("counter", 1n), original); - await assert.rejects(client.behaviorSource("counter", 3n), ProtocolError); - await assert.rejects(client.behaviorSource("missing", 1n), ProtocolError); - for (const revision of [0n, -1n, 18446744073709551616n]) { - await assert.rejects(client.behaviorSource("counter", revision), /positive u64/); - } - await assert.rejects(client.behaviorSource(" ", 1n), /behavior name/); - assert.equal(await client.behaviorSource("counter", 1n), original); - }, -); diff --git a/vscode/integration/controls.test.ts b/vscode/integration/controls.test.ts index 7daea1a..59b021f 100644 --- a/vscode/integration/controls.test.ts +++ b/vscode/integration/controls.test.ts @@ -6,7 +6,7 @@ import { parseValue } from "../src/values"; import { startImage } from "./server"; test( - "extension controls spawn explicit behaviors, send lossless values, and preserve lifecycle state", + "extension controls spawn source paths, send lossless values, and apply system messages", { timeout: 10000 }, async (t) => { const image = await startImage(); @@ -16,28 +16,52 @@ test( await image.stop(); }); client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined)); - assert.ok((await client.behaviors()).some((behavior) => behavior.name === "counter")); - await assert.rejects(client.spawn("missing", parseValue("0")), ProtocolError); - const ref = await client.spawn("counter", parseValue("5"), 2); + assert.deepEqual( + (await client.listSources()).map((file) => file.path), + ["actors/counter"], + ); + await assert.rejects(client.spawn("actors/missing", parseValue("0")), ProtocolError); + const ref = await client.spawn("actors/counter", parseValue("5"), 2); assert.match(ref, /^[A-Za-z0-9_-]{21}$/); await client.send(ref, parseValue('{"op":"add","value":7}')); - await client.lifecycle("pause_actor", ref); + await client.system(ref, { kind: "pause" }); await client.tick(100); let snapshot = await client.inspectActor(ref); assert.equal(snapshot.status.status, "paused"); assert.deepEqual(snapshot.state, parseValue("5")); assert.equal(snapshot.queued.length, 1); - await client.lifecycle("resume_actor", ref); + await client.system(ref, { kind: "resume" }); await client.tick(100); snapshot = await client.inspectActor(ref); assert.deepEqual(snapshot.state, parseValue("12")); assert.deepEqual(snapshot.queued, []); - await client.lifecycle("stop_actor", ref); + // Graceful stop completes only when the scheduler advances. + await client.system(ref, { kind: "stop" }); + assert.notEqual((await client.inspectActor(ref)).status.status, "stopped"); + await client.tick(10); assert.equal((await client.inspectActor(ref)).status.status, "stopped"); - await client.lifecycle("restart_actor", ref); + await client.system(ref, { kind: "restart" }); snapshot = await client.inspectActor(ref); assert.equal(snapshot.incarnation, 2n); assert.deepEqual(snapshot.state, parseValue("12")); + const implementation = snapshot.implementation_id; + // Restart forces a new incarnation even when running, keeping implementation and state. + await client.system(ref, { kind: "restart" }); + snapshot = await client.inspectActor(ref); + assert.equal(snapshot.incarnation, 3n); + assert.equal(snapshot.implementation_id, implementation); + assert.deepEqual(snapshot.state, parseValue("12")); + await client.system(ref, { kind: "reload" }); + await client.tick(10); + assert.equal((await client.inspectActor(ref)).status.status, "idle"); + await assert.rejects( + client.system(ref, { kind: "become", path: "actors/missing" }), + ProtocolError, + ); + await client.system(ref, { kind: "kill" }); + assert.equal((await client.inspectActor(ref)).status.status, "stopped"); + await client.system(ref, { kind: "restart" }); + assert.equal((await client.inspectActor(ref)).incarnation, 4n); const payload = parseValue(`{"nested":[9223372036854775807,{"$actor":"${ref}"}]}`); await client.send(ref, payload); assert.deepEqual((await client.inspectActor(ref)).queued, [payload]); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index daeda9c..0cdda2b 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -1,16 +1,15 @@ import assert from "node:assert/strict"; import * as vscode from "vscode"; import { control, spawnCounter, startImage } from "./server"; -import { BehaviorTree, SourceDocuments } from "../src/behaviors"; -import { ImageClient } from "../src/client"; +import { RevisionDocuments } from "../src/sourceProvider"; export async function run(): Promise { const image = await startImage(); try { await vscode.commands.executeCommand("runa.connect", image.address); - await browseBehaviors(image.address); + await editSources(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { - behavior: "counter", + path: "actors/counter", state: "5", }); assert.equal(typeof spawned, "string"); @@ -39,10 +38,14 @@ export async function run(): Promise { snapshot = JSON.parse(controlled.getText()).actor; assert.equal(snapshot.state.data, 12); assert.equal(snapshot.queued.length, 0); + await vscode.commands.executeCommand("runa.stop", selection); + await vscode.commands.executeCommand("runa.inspect", selection); + assert.notEqual(JSON.parse(controlled.getText()).actor.status.status, "stopped"); const controlStopped = documentChanged(controlled, (text) => text.includes('"status": "stopped"'), ); - await vscode.commands.executeCommand("runa.stop", selection); + // Graceful stop completes only when the scheduler advances. + await vscode.commands.executeCommand("runa.tick", 10); await controlStopped; assert.equal(JSON.parse(controlled.getText()).actor.status.status, "stopped"); const controlRestarted = documentChanged( @@ -54,6 +57,19 @@ export async function run(): Promise { snapshot = JSON.parse(controlled.getText()).actor; assert.equal(snapshot.incarnation, 2); assert.equal(snapshot.state.data, 12); + const controlKilled = documentChanged(controlled, (text) => + text.includes('"status": "stopped"'), + ); + await vscode.commands.executeCommand("runa.kill", selection); + await controlKilled; + const controlReloaded = documentChanged( + controlled, + (text) => JSON.parse(text).actor.incarnation === 3, + ); + await vscode.commands.executeCommand("runa.restart", selection); + await controlReloaded; + await vscode.commands.executeCommand("runa.reload", selection); + await vscode.commands.executeCommand("runa.tick", 10); const actorRef = await spawnCounter(image.address); await vscode.commands.executeCommand("runa.connect", image.address); await vscode.commands.executeCommand("runa.refresh"); @@ -76,17 +92,20 @@ export async function run(): Promise { message: { type: "string", data: "queued from another client" }, }, }), - { version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } }, + { version: 4, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } }, ); await refreshed; assert.match(document.getText(), /queued from another client/); const paused = documentChanged(document, (text) => text.includes('"status": "paused"')); - await control(image.address, { operation: "pause_actor", parameters: { actor_ref: actorRef } }); + await control(image.address, { + operation: "system", + parameters: { actor_ref: actorRef, message: { kind: "pause" } }, + }); await paused; const failed = documentChanged(document, (text) => text.includes('"status": "failed"')); await control(image.address, { - operation: "resume_actor", - parameters: { actor_ref: actorRef }, + operation: "system", + parameters: { actor_ref: actorRef, message: { kind: "resume" } }, }); await control(image.address, { operation: "tick", @@ -106,7 +125,7 @@ export async function run(): Promise { const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri)); assert.match(old.getText(), /"connected": false/); console.log( - "Runa extension host: spawn, send, lifecycle, ticks, inspection, automatic refresh, reconnect passed", + "Runa extension host: spawn, send, system messages, ticks, inspection, automatic refresh, reconnect passed", ); } finally { await vscode.commands.executeCommand("runa.disconnect"); @@ -114,98 +133,188 @@ export async function run(): Promise { } } -async function browseBehaviors(address: string): Promise { - const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); - const tree = new BehaviorTree(); - const sources = new SourceDocuments(); +async function editSources(address: string): Promise { + const folder = vscode.workspace.workspaceFolders?.find( + (candidate) => candidate.uri.scheme === "runa-image", + ); + assert.ok(folder, "connect mounts the image source folder"); + assert.equal(folder.name, "source"); + assert.equal(folder.uri.toString(), `runa-image://${address}/source`); + assert.deepEqual(await vscode.workspace.fs.readDirectory(folder.uri), [ + ["actors", vscode.FileType.Directory], + ]); + const actors = vscode.Uri.joinPath(folder.uri, "actors"); + assert.deepEqual(await vscode.workspace.fs.readDirectory(actors), [ + ["counter.rn", vscode.FileType.File], + ]); + const uri = vscode.Uri.joinPath(actors, "counter.rn"); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document); + const original = document.getText(); + assert.match(original, /pub fn handle\(ctx, state, sender, message\)/); + + const edited = "pub fn handle(ctx, state, sender, message) {\n state + 2\n}\n"; + await replaceText(document, edited); + const window = vscode.window as { showWarningMessage: (...args: unknown[]) => unknown }; + const warning = window.showWarningMessage; + const prompts: unknown[][] = []; + let answer: string | undefined; + window.showWarningMessage = async (...args: unknown[]) => { + prompts.push(args); + return answer; + }; try { - const source = await client.behaviorSource("counter", 1n); - await control(address, { - operation: "publish_behavior", - parameters: { - name: "browse-test", - expected_revision: null, - source, - }, - }); - const listing = await client.behaviors(); - tree.replace(listing); - const behavior = tree.getChildren().find((node) => node.name === "browse-test"); - assert.ok(behavior); - assert.equal( - tree.getTreeItem(behavior).collapsibleState, - vscode.TreeItemCollapsibleState.Collapsed, + // Unsaved edits are never committed or spawned without an explicit save. + assert.equal(await vscode.commands.executeCommand("runa.commitSource", uri), undefined); + assert.equal(await vscode.commands.executeCommand("runa.spawnSource", uri), undefined); + assert.equal(prompts.length, 2); + assert.deepEqual(prompts[0]?.[1], { modal: true }); + assert.equal(document.isDirty, true); + const unsaved = JSON.stringify( + await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), ); - const version = tree.getChildren(behavior)[0]; - assert.ok(version); - assert.equal(tree.getTreeItem(version).description, "current"); - assert.deepEqual(tree.getChildren(version), []); - const item = tree.getTreeItem(behavior); - const opened = await vscode.commands.executeCommand( - "runa.openBehaviorSource", - ...item.command!.arguments!, - ); - assert.ok(opened); - const original = vscode.window.activeTextEditor?.document; - assert.ok(original); - assert.equal(original.uri.scheme, "runa-source"); - assert.match(original.uri.path, /\/1\.rn$/); - assert.equal(original.getText(), source); - const originalText = original.getText(); - await vscode.commands.executeCommand("type", { text: "// cannot edit\n" }); - assert.equal(original.getText(), originalText); - const newerSource = "pub fn handle(state, message) { (state + 2, []) }\n"; - await control(address, { - operation: "publish_behavior", - parameters: { - name: "browse-test", - expected_revision: 1, - source: newerSource, - }, - }); - await vscode.commands.executeCommand("runa.refresh"); - // Context-menu arguments are raw tree nodes, unlike item command arguments. - const updated = (await client.behaviors()).find((node) => node.name === "browse-test"); - assert.ok(updated); - tree.replace([updated]); - assert.deepEqual( - tree.getChildren(updated).map((node) => node.revision), - [2n, 1n], - ); - await vscode.commands.executeCommand("runa.openBehaviorSource", updated); - const newer = vscode.window.activeTextEditor?.document; - assert.ok(newer); - assert.equal(newer.getText(), newerSource); - assert.notEqual(newer.uri.toString(), original.uri.toString()); - assert.equal(original.getText(), originalText); - const uri = sources.set(1, "name/with?#% spaces", 1n, originalText); - assert.equal(sources.provideTextDocumentContent(uri), originalText); - assert.notEqual( - sources.set(2, "name/with?#% spaces", 1n, newerSource).toString(), - uri.toString(), - ); - assert.throws(() => sources.set(1, "name/with?#% spaces", 1n, newerSource), /Immutable/); - sources.remove(uri); - assert.throws(() => sources.provideTextDocumentContent(uri), /no longer cached/); - await vscode.commands.executeCommand("runa.disconnect"); - assert.equal(original.getText(), originalText); - await vscode.commands.executeCommand("runa.connect", address); - await vscode.commands.executeCommand("runa.openBehaviorSource", { - name: "browse-test", - revision: "1", - }); - const reconnected = vscode.window.activeTextEditor?.document; - assert.ok(reconnected); - assert.equal(reconnected.getText(), originalText); - assert.notEqual(reconnected.uri.toString(), original.uri.toString()); - console.log( - "Runa behavior browsing: revision tree, immutable source, read-only documents, session isolation passed", + assert.ok(unsaved.includes(JSON.stringify(original))); + assert.match( + JSON.stringify(await control(address, { operation: "list_actors" })), + /"data":\[\]/, ); + answer = "Save and Spawn"; + const saved = await vscode.commands.executeCommand("runa.spawnSource", uri); + assert.match(String(saved), /^[A-Za-z0-9_-]{21}$/); + assert.equal(prompts.length, 3); + assert.equal(document.isDirty, false, "the explicit choice saves before spawning"); } finally { - client.close(); - tree.dispose(); - sources.dispose(); + window.showWarningMessage = warning; } + assert.equal(document.isDirty, false); + assert.equal(document.isDirty, false); + let reply = JSON.stringify( + await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), + ); + assert.ok(reply.includes(JSON.stringify(edited)), reply); + reply = JSON.stringify( + await control(address, { operation: "source_history", parameters: { path: "actors/counter" } }), + ); + assert.match(reply, /"kind":"source_history","data":\[\]/); + + // Spawn context-menu actions receive the explorer URI and use the saved, uncommitted source. + const nullActor = await vscode.commands.executeCommand("runa.spawnSource", uri); + assert.match(String(nullActor), /^[A-Za-z0-9_-]{21}$/); + reply = JSON.stringify( + await control(address, { operation: "inspect_actor", parameters: { actor_ref: nullActor } }), + ); + assert.match(reply, /"state":\{"type":"null"\}/); + const actor = await vscode.commands.executeCommand( + "runa.spawnSourceWithState", + uri, + "40", + ); + await vscode.commands.executeCommand("runa.send", { actor_ref: actor }, "null"); + await vscode.commands.executeCommand("runa.tick", 10); + reply = JSON.stringify( + await control(address, { operation: "inspect_actor", parameters: { actor_ref: actor } }), + ); + assert.match(reply, /"state":\{"type":"integer","data":42\}/); + + const revision = await vscode.commands.executeCommand("runa.commitSource", uri); + assert.match(String(revision), /^[A-Za-z0-9]{1,64}$/); + const opened = await vscode.commands.executeCommand("runa.sourceHistory", uri, revision); + const historical = vscode.window.activeTextEditor?.document; + assert.ok(historical); + assert.equal(historical.uri.toString(), opened); + assert.equal(historical.uri.scheme, "runa-source"); + assert.ok(historical.uri.path.endsWith(`/source/actors/counter@${revision}.rn`)); + assert.equal(historical.getText(), edited); + await vscode.commands.executeCommand("type", { text: "// cannot edit\n" }); + assert.equal(historical.getText(), edited); + + // Saving again changes only the working source; history is unchanged. + await vscode.window.showTextDocument(document); + await replaceText(document, original); + assert.ok(await document.save()); + assert.equal(historical.getText(), edited); + + prompts.length = 0; + answer = undefined; + window.showWarningMessage = async (...args: unknown[]) => { + prompts.push(args); + return answer; + }; + try { + await vscode.commands.executeCommand("runa.rollbackSource", uri, revision); + assert.equal(prompts.length, 1); + assert.deepEqual(prompts[0]?.[1], { modal: true }); + assert.equal(document.getText(), original, "cancelled rollback changes nothing"); + answer = "Roll Back"; + const reloaded = documentChanged(document, (text) => text === edited); + await vscode.commands.executeCommand("runa.rollbackSource", uri, revision); + await reloaded; + } finally { + window.showWarningMessage = warning; + } + assert.equal(document.isDirty, false); + reply = JSON.stringify( + await control(address, { operation: "source_history", parameters: { path: "actors/counter" } }), + ); + assert.equal((reply.match(/"revision"/g) ?? []).length, 1); + + // Saves from another client reload clean editors through image notifications. + const current = JSON.parse( + JSON.stringify( + await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }), + ), + ).result.Ok.data; + const external = documentChanged(document, (text) => text === original); + await control(address, { + operation: "write_source", + parameters: { path: "actors/counter", expected_source_id: current.source_id, source: original }, + }); + await external; + + // Revision documents stay isolated per connection session. + await vscode.commands.executeCommand("runa.disconnect"); + assert.equal(historical.getText(), edited); + await vscode.commands.executeCommand("runa.connect", address); + assert.equal( + vscode.workspace.workspaceFolders?.filter((candidate) => candidate.uri.scheme === "runa-image") + .length, + 1, + ); + const reopened = await vscode.commands.executeCommand( + "runa.sourceHistory", + uri, + revision, + ); + assert.ok(reopened); + assert.notEqual(reopened, opened); + assert.equal(vscode.window.activeTextEditor?.document.getText(), edited); + const revisions = new RevisionDocuments(); + try { + const cached = revisions.set(1, "actors/counter", "abc", edited); + assert.equal(revisions.provideTextDocumentContent(cached), edited); + assert.notEqual( + revisions.set(2, "actors/counter", "abc", original).toString(), + cached.toString(), + ); + assert.throws(() => revisions.set(1, "actors/counter", "abc", original), /Immutable/); + revisions.remove(cached); + assert.throws(() => revisions.provideTextDocumentContent(cached), /no longer cached/); + } finally { + revisions.dispose(); + } + console.log( + "Runa sources: mounted folder, save, uncommitted spawn, commit, history, rollback, session isolation passed", + ); +} + +async function replaceText(document: vscode.TextDocument, text: string): Promise { + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), + text, + ); + assert.ok(await vscode.workspace.applyEdit(edit)); } function documentChanged( diff --git a/vscode/integration/repl.test.ts b/vscode/integration/repl.test.ts index 6e4da28..8701c5b 100644 --- a/vscode/integration/repl.test.ts +++ b/vscode/integration/repl.test.ts @@ -52,7 +52,7 @@ test( const exited = once(repl, "exit"); repl.stdin.end( [ - "let actor = spawn counter 5", + "let actor = spawn actors/counter 5", 'send actor {"op":"add","value":7}', "tick 100", "inspect actor", diff --git a/vscode/integration/server.test.ts b/vscode/integration/server.test.ts index 73fffdc..bdc67c0 100644 --- a/vscode/integration/server.test.ts +++ b/vscode/integration/server.test.ts @@ -25,7 +25,7 @@ test( operation: "send", parameters: { actor_ref: actorRef, message: payload }, }), - { version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } }, + { version: 4, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } }, ); const failures: Error[] = []; @@ -61,7 +61,7 @@ test( // Reading from the extension must not drain the shared runtime event queue. const events = await control(image.address, { operation: "get_events" }); assert.deepEqual(events, { - version: 3, + version: 4, request_id: "integration-control", result: { Ok: { diff --git a/vscode/integration/server.ts b/vscode/integration/server.ts index da9b1ee..cf3dc1b 100644 --- a/vscode/integration/server.ts +++ b/vscode/integration/server.ts @@ -82,7 +82,7 @@ export async function control(address: string, command: unknown): Promise reject(new Error("Control connection closed"))); socket.once("error", reject); }); - socket.send(json({ version: 3, request_id: "integration-control", command })); + socket.send(json({ version: 4, request_id: "integration-control", command })); return await response; } finally { clearTimeout(timer); @@ -94,7 +94,7 @@ export async function spawnCounter(address: string): Promise { const reply = await control(address, { operation: "spawn", parameters: { - behavior_name: "counter", + path: "actors/counter", state: { type: "integer", data: 9223372036854775807n }, mailbox_capacity: 4, }, diff --git a/vscode/integration/sources.test.ts b/vscode/integration/sources.test.ts new file mode 100644 index 0000000..672302c --- /dev/null +++ b/vscode/integration/sources.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ImageClient } from "../src/client"; +import { ProtocolError } from "../src/protocol"; +import { parseValue } from "../src/values"; +import { startImage } from "./server"; + +const conflict = (error: unknown) => error instanceof ProtocolError && error.code === "conflict"; + +test( + "image sources are saved with compare-and-swap, spawned uncommitted, committed, and rolled back", + { timeout: 10000 }, + async (t) => { + const image = await startImage(); + let client: ImageClient | undefined; + t.after(async () => { + client?.close(); + await image.stop(); + }); + client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined)); + const [counter, ...others] = await client.listSources(); + assert.ok(counter); + assert.deepEqual(others, []); + assert.equal(counter.path, "actors/counter"); + assert.match(counter.source, /pub fn handle\(ctx, state, sender, message\)/); + assert.deepEqual(await client.readSource("actors/counter"), counter); + assert.deepEqual(await client.sourceHistory("actors/counter"), []); + await assert.rejects(client.readSource("actors/missing"), ProtocolError); + + const edited = "pub fn handle(ctx, state, sender, message) {\n state + 2 // 雪\n}\n"; + await assert.rejects(client.writeSource("actors/counter", null, edited), conflict); + await assert.rejects(client.writeSource("actors/counter", "stale", edited), conflict); + const saved = await client.writeSource("actors/counter", counter.source_id, edited); + assert.equal(saved.source, edited); + assert.notEqual(saved.source_id, counter.source_id); + await assert.rejects( + client.writeSource("actors/counter", counter.source_id, counter.source), + conflict, + ); + assert.deepEqual(await client.sourceHistory("actors/counter"), []); + + // Spawn uses the latest saved working source, independent of history. + const actor = await client.spawn("actors/counter", parseValue("0")); + await client.send(actor, parseValue("null")); + await client.tick(100); + assert.deepEqual((await client.inspectActor(actor)).state, parseValue("2")); + + const commit = await client.commitSource("actors/counter"); + assert.match(commit.revision, /^[A-Za-z0-9]{1,64}$/); + assert.equal(commit.source_id, saved.source_id); + assert.deepEqual(await client.sourceHistory("actors/counter"), [commit]); + assert.equal(await client.revisionSource("actors/counter", commit.revision), edited); + await assert.rejects(client.revisionSource("actors/counter", "missing0"), ProtocolError); + + const later = await client.writeSource("actors/counter", saved.source_id, counter.source); + assert.equal((await client.readSource("actors/counter")).source, counter.source); + const rolled = await client.rollbackSource("actors/counter", commit.revision); + assert.deepEqual(rolled, { + path: "actors/counter", + source_id: saved.source_id, + source: edited, + }); + assert.notEqual(rolled.source_id, later.source_id); + assert.deepEqual(await client.sourceHistory("actors/counter"), [commit]); + assert.equal(await client.revisionSource("actors/counter", commit.revision), edited); + + const created = await client.writeSource( + "actors/echo", + null, + "pub fn handle(ctx, state, sender, message) { message }\n", + ); + assert.deepEqual((await client.listSources()).map((file) => file.path).sort(), [ + "actors/counter", + "actors/echo", + ]); + assert.deepEqual(await client.sourceHistory("actors/echo"), []); + await client.system(actor, { kind: "become", path: created.path }); + await client.send(actor, parseValue('"hello"')); + await client.tick(100); + assert.deepEqual((await client.inspectActor(actor)).state, parseValue('"hello"')); + }, +); diff --git a/vscode/integration/websocket.test.ts b/vscode/integration/websocket.test.ts index 9b21758..fd66cbd 100644 --- a/vscode/integration/websocket.test.ts +++ b/vscode/integration/websocket.test.ts @@ -17,7 +17,7 @@ test( await once(socket, "open"); assert.equal(socket.protocol, WEBSOCKET_SUBPROTOCOL); const request = json({ - version: 3, + version: 4, request_id: "fragmented", command: { operation: "list_actors" }, }); @@ -27,7 +27,7 @@ test( socket.send(text.slice(100), { fin: true }); const [response] = await reply; assert.deepEqual(JSON.parse(String(response)), { - version: 3, + version: 4, request_id: "fragmented", result: { Ok: { kind: "actors", data: [] } }, }); @@ -52,7 +52,7 @@ test( socket.send(" ", { fin: true }); await closed; assert.deepEqual(await control(image.address, { operation: "list_actors" }), { - version: 3, + version: 4, request_id: "integration-control", result: { Ok: { kind: "actors", data: [] } }, }); diff --git a/vscode/package.json b/vscode/package.json index ef38fc2..a7908e4 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -1,7 +1,7 @@ { "name": "runa", "displayName": "Runa — Rune Actors", - "description": "Inspect and control actors in a trusted localhost Runa image.", + "description": "Edit image source files and inspect and control actors in a trusted localhost Runa image.", "version": "0.0.0", "private": true, "engines": { @@ -14,7 +14,7 @@ "main": "./out/src/extension.js", "activationEvents": [ "onView:runa.actors", - "onView:runa.behaviors" + "onFileSystem:runa-image" ], "scripts": { "compile": "tsc -p .", @@ -39,63 +39,122 @@ "contributes": { "commands": [ { - "command": "runa.openBehaviorSource", - "title": "Runa: Open Behavior Source", - "icon": "$(go-to-file)", + "command": "runa.connect", + "title": "Connect to Local Image", + "category": "Runa" + }, + { + "command": "runa.disconnect", + "title": "Disconnect", + "category": "Runa" + }, + { + "command": "runa.refresh", + "title": "Refresh Image", + "category": "Runa", + "icon": "$(refresh)" + }, + { + "command": "runa.addSourceFolder", + "title": "Add Source Folder to Workspace", + "category": "Runa", "enablement": "runa.connected" }, { "command": "runa.spawn", - "title": "Runa: Spawn Actor", + "title": "Spawn Actor", + "category": "Runa", "icon": "$(add)", "enablement": "runa.connected" }, + { + "command": "runa.spawnSource", + "title": "Spawn", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.spawnSourceWithState", + "title": "Spawn with State...", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.commitSource", + "title": "Commit Source", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.sourceHistory", + "title": "Source History...", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.rollbackSource", + "title": "Roll Back Source...", + "category": "Runa", + "enablement": "runa.connected" + }, { "command": "runa.send", - "title": "Runa: Send Message", + "title": "Send Message", + "category": "Runa", "enablement": "runa.connected" }, { "command": "runa.stop", - "title": "Runa: Stop Actor", + "title": "Stop Actor (Graceful)", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.kill", + "title": "Kill Actor", + "category": "Runa", "enablement": "runa.connected" }, { "command": "runa.restart", - "title": "Runa: Restart Actor", + "title": "Restart Actor", + "category": "Runa", "enablement": "runa.connected" }, { "command": "runa.pause", - "title": "Runa: Pause Actor", + "title": "Pause Actor", + "category": "Runa", "enablement": "runa.connected" }, { "command": "runa.resume", - "title": "Runa: Resume Actor", + "title": "Resume Actor", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.reload", + "title": "Reload Actor Source", + "category": "Runa", + "enablement": "runa.connected" + }, + { + "command": "runa.become", + "title": "Become Source...", + "category": "Runa", "enablement": "runa.connected" }, { "command": "runa.tick", - "title": "Runa: Advance Scheduler", + "title": "Advance Scheduler", + "category": "Runa", "enablement": "runa.connected" }, - { - "command": "runa.connect", - "title": "Runa: Connect to Local Image" - }, - { - "command": "runa.disconnect", - "title": "Runa: Disconnect" - }, - { - "command": "runa.refresh", - "title": "Runa: Refresh Image", - "icon": "$(refresh)" - }, { "command": "runa.inspect", - "title": "Runa: Inspect Actor", + "title": "Inspect Actor", + "category": "Runa", "icon": "$(inspect)" } ], @@ -104,19 +163,10 @@ { "id": "runa.actors", "name": "Runa Actors" - }, - { - "id": "runa.behaviors", - "name": "Runa Behaviors" } ] }, "viewsWelcome": [ - { - "view": "runa.behaviors", - "contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)", - "when": "!runa.connected" - }, { "view": "runa.actors", "contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)", @@ -132,25 +182,24 @@ }, { "command": "runa.connect", - "when": "(view == runa.actors || view == runa.behaviors) && !runa.connected", + "when": "view == runa.actors && !runa.connected", "group": "navigation" }, { "command": "runa.refresh", - "when": "(view == runa.actors || view == runa.behaviors) && runa.connected", + "when": "view == runa.actors && runa.connected", "group": "navigation" }, + { + "command": "runa.addSourceFolder", + "when": "view == runa.actors && runa.connected" + }, { "command": "runa.disconnect", - "when": "(view == runa.actors || view == runa.behaviors) && runa.connected" + "when": "view == runa.actors && runa.connected" } ], "view/item/context": [ - { - "command": "runa.openBehaviorSource", - "when": "view == runa.behaviors && viewItem == runa.behavior", - "group": "inline" - }, { "command": "runa.send", "when": "view == runa.actors && viewItem == runa.actor", @@ -172,15 +221,106 @@ "group": "actor@4" }, { - "command": "runa.restart", + "command": "runa.kill", "when": "view == runa.actors && viewItem == runa.actor", "group": "actor@5" }, + { + "command": "runa.restart", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@6" + }, + { + "command": "runa.reload", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@7" + }, + { + "command": "runa.become", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@8" + }, { "command": "runa.inspect", "when": "view == runa.actors && viewItem == runa.actor", "group": "inline" } + ], + "explorer/context": [ + { + "command": "runa.spawnSource", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_1_spawn@1" + }, + { + "command": "runa.spawnSourceWithState", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_1_spawn@2" + }, + { + "command": "runa.commitSource", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_2_source@1" + }, + { + "command": "runa.sourceHistory", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_2_source@2" + }, + { + "command": "runa.rollbackSource", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_2_source@3" + } + ], + "editor/title/context": [ + { + "command": "runa.spawnSource", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_1_spawn@1" + }, + { + "command": "runa.spawnSourceWithState", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_1_spawn@2" + }, + { + "command": "runa.commitSource", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_2_source@1" + }, + { + "command": "runa.sourceHistory", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_2_source@2" + }, + { + "command": "runa.rollbackSource", + "when": "resourceScheme == runa-image && resourceExtname == .rn && runa.connected", + "group": "runa_2_source@3" + } + ], + "commandPalette": [ + { + "command": "runa.spawnSource", + "when": "runa.connected" + }, + { + "command": "runa.spawnSourceWithState", + "when": "runa.connected" + }, + { + "command": "runa.commitSource", + "when": "runa.connected" + }, + { + "command": "runa.sourceHistory", + "when": "runa.connected" + }, + { + "command": "runa.rollbackSource", + "when": "runa.connected" + } ] }, "configuration": { diff --git a/vscode/scripts/test-host.cjs b/vscode/scripts/test-host.cjs index 9ca110c..8f21bfb 100644 --- a/vscode/scripts/test-host.cjs +++ b/vscode/scripts/test-host.cjs @@ -1,11 +1,23 @@ const path = require("node:path"); +const fs = require("node:fs"); const { runTests } = require("@vscode/test-electron"); +// Adding the image source folder to an empty or single-folder window restarts the +// extension host, so tests start in a multi-root workspace with one local folder. +const root = path.resolve(__dirname, ".."); +const workspaceFile = path.join(root, ".vscode-test", "runa-host.code-workspace"); +fs.mkdirSync(path.dirname(workspaceFile), { recursive: true }); +fs.writeFileSync( + workspaceFile, + JSON.stringify({ folders: [{ path: path.join(root, "integration") }] }, null, 2), +); + runTests({ version: "1.96.4", extensionDevelopmentPath: path.resolve(__dirname, ".."), extensionTestsPath: path.resolve(__dirname, "../out/integration/host.js"), launchArgs: [ + workspaceFile, "--disable-gpu", "--no-sandbox", "--skip-welcome", diff --git a/vscode/src/behaviors.ts b/vscode/src/behaviors.ts deleted file mode 100644 index a81d366..0000000 --- a/vscode/src/behaviors.ts +++ /dev/null @@ -1,91 +0,0 @@ -import * as vscode from "vscode"; -import { Behavior } from "./protocol"; - -export interface BehaviorRevision { - name: string; - revision: bigint; - implementation_id: string; - current: boolean; -} - -export class BehaviorTree implements vscode.TreeDataProvider { - private readonly changes = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this.changes.event; - behaviors: Behavior[] = []; - - replace(behaviors: Behavior[]): void { - this.behaviors = behaviors; - this.changes.fire(); - } - - getChildren(parent?: Behavior | BehaviorRevision): (Behavior | BehaviorRevision)[] { - if (!parent) return this.behaviors; - if (!("history" in parent)) return []; - return [...parent.history].reverse().map((version) => ({ - name: parent.name, - ...version, - current: version.revision === parent.revision, - })); - } - - getTreeItem(behavior: Behavior | BehaviorRevision): vscode.TreeItem { - const root = "history" in behavior; - const item = new vscode.TreeItem( - root ? behavior.name : `Revision ${behavior.revision}`, - root ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None, - ); - item.id = JSON.stringify(root ? [behavior.name] : [behavior.name, String(behavior.revision)]); - item.contextValue = "runa.behavior"; - item.description = root - ? `revision ${behavior.revision}` - : behavior.current - ? "current" - : undefined; - item.tooltip = `Behavior: ${behavior.name}\nRevision: ${behavior.revision}\nImplementation: ${ - root ? behavior.current_implementation_id : behavior.implementation_id - }`; - item.iconPath = new vscode.ThemeIcon(root ? "symbol-class" : "git-commit"); - item.command = { - command: "runa.openBehaviorSource", - title: "Open Rune Source", - arguments: [{ name: behavior.name, revision: String(behavior.revision) }], - }; - return item; - } - - dispose(): void { - this.changes.dispose(); - } -} - -export class SourceDocuments implements vscode.TextDocumentContentProvider { - private readonly sources = new Map(); - - set(session: number, name: string, revision: bigint, source: string): vscode.Uri { - const uri = vscode.Uri.from({ - scheme: "runa-source", - authority: String(session), - path: `/behaviors/${encodeURIComponent(name)}/${revision}.rn`, - }); - const previous = this.sources.get(uri.toString()); - if (previous !== undefined && previous !== source) { - throw new Error("Immutable behavior revision changed source"); - } - this.sources.set(uri.toString(), source); - return uri; - } - - provideTextDocumentContent(uri: vscode.Uri): string { - const source = this.sources.get(uri.toString()); - if (source === undefined) - throw new Error("Source is no longer cached; open the behavior revision again"); - return source; - } - - remove(uri: vscode.Uri): void { - this.sources.delete(uri.toString()); - } - dispose(): void { - this.sources.clear(); - } -} diff --git a/vscode/src/client.ts b/vscode/src/client.ts index b832ba7..9550295 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -4,17 +4,20 @@ import WebSocket, { RawData } from "ws"; import { stringify } from "lossless-json"; import { ActorSnapshot, - Behavior, decodeNotification, decodeResponse, decodeValue, isActorRef, + isSourcePath, + isSourceRevision, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, ProtocolError, Command, Reply, - LifecycleOperation, + SourceCommit, + SourceFile, + SystemMessage, Value, WEBSOCKET_SUBPROTOCOL, } from "./protocol"; @@ -35,6 +38,18 @@ export function parseEndpoint(endpoint: string): { host: string; port: number } return { host, port }; } +function sourcePath(path: string): string { + if (!isSourcePath(path)) { + throw new Error("Source paths look like actors/counter (letters, digits, _ and -; no .rn)"); + } + return path; +} + +function sourceRevision(revision: string): string { + if (!isSourceRevision(revision)) throw new Error("Invalid source revision"); + return revision; +} + interface Pending { requestId: string; command: Command; @@ -117,36 +132,81 @@ export class ImageClient { return reply.data; } - async behaviors(): Promise { - const reply = await this.request({ operation: "get_behaviors" }); - if (reply.kind !== "behaviors") throw new Error("Expected behavior listing"); + async listSources(): Promise { + const reply = await this.request({ operation: "list_sources" }); + if (reply.kind !== "sources") throw new Error("Expected source listing"); return reply.data; } - async behaviorSource(name: string, revision: bigint): Promise { - if (typeof name !== "string" || !name.trim()) throw new Error("Specify a behavior name"); - if (typeof revision !== "bigint" || revision < 1n || revision > 18446744073709551615n) { - throw new Error("Behavior revision must be a positive u64 integer"); - } + async readSource(path: string): Promise { const reply = await this.request({ - operation: "get_behavior_source", - parameters: { name, revision }, + operation: "read_source", + parameters: { path: sourcePath(path) }, }); - if (reply.kind !== "behavior_source") throw new Error("Expected behavior source"); + if (reply.kind !== "source") throw new Error("Expected source file"); + return reply.data; + } + + /** Saves working source only if the image still holds `expectedSourceId` (null creates). */ + async writeSource( + path: string, + expectedSourceId: string | null, + source: string, + ): Promise { + if (expectedSourceId !== null && (typeof expectedSourceId !== "string" || !expectedSourceId)) { + throw new Error("Expected source identity must be a non-empty string or null"); + } + if (typeof source !== "string") throw new Error("Source must be text"); + const reply = await this.request({ + operation: "write_source", + parameters: { path: sourcePath(path), expected_source_id: expectedSourceId, source }, + }); + if (reply.kind !== "source") throw new Error("Expected source file"); + return reply.data; + } + + async commitSource(path: string): Promise { + const reply = await this.request({ + operation: "commit_source", + parameters: { path: sourcePath(path) }, + }); + if (reply.kind !== "source_committed") throw new Error("Expected source commit"); + return reply.data; + } + + async sourceHistory(path: string): Promise { + const reply = await this.request({ + operation: "source_history", + parameters: { path: sourcePath(path) }, + }); + if (reply.kind !== "source_history") throw new Error("Expected source history"); + return reply.data; + } + + async revisionSource(path: string, revision: string): Promise { + const reply = await this.request({ + operation: "revision_source", + parameters: { path: sourcePath(path), revision: sourceRevision(revision) }, + }); + if (reply.kind !== "behavior_source") throw new Error("Expected revision source"); return reply.data.source; } - async spawn(behavior: string, state: Value, capacity = 64): Promise { - if (!behavior.trim()) throw new Error("Specify a behavior to spawn"); + async rollbackSource(path: string, revision: string): Promise { + const reply = await this.request({ + operation: "rollback_source", + parameters: { path: sourcePath(path), revision: sourceRevision(revision) }, + }); + if (reply.kind !== "source") throw new Error("Expected source file"); + return reply.data; + } + + async spawn(path: string, state: Value, capacity = 64): Promise { if (!Number.isSafeInteger(capacity) || capacity < 1) throw new Error("Mailbox capacity must be positive"); const reply = await this.request({ operation: "spawn", - parameters: { - behavior_name: behavior, - state: decodeValue(state), - mailbox_capacity: capacity, - }, + parameters: { path: sourcePath(path), state: decodeValue(state), mailbox_capacity: capacity }, }); if (reply.kind !== "actor_spawned") throw new Error("Expected spawned actor reference"); return reply.data.actor_ref; @@ -160,9 +220,20 @@ export class ImageClient { }); } - async lifecycle(operation: LifecycleOperation, actorRef: string): Promise { + async system(actorRef: string, message: SystemMessage): Promise { if (!isActorRef(actorRef)) throw new Error("Invalid actor reference"); - await this.request({ operation, parameters: { actor_ref: actorRef } }); + const kinds = ["stop", "kill", "restart", "pause", "resume", "reload", "become"]; + if (typeof message !== "object" || message === null || !kinds.includes(message.kind)) { + throw new Error("Unsupported system message"); + } + const checked: SystemMessage = + message.kind === "become" + ? { kind: "become", path: sourcePath(message.path) } + : { kind: message.kind }; + await this.request({ + operation: "system", + parameters: { actor_ref: actorRef, message: checked }, + }); } async tick(ticks: number, budget = 32): Promise { diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 61968e6..57c1e73 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,9 +1,18 @@ import * as vscode from "vscode"; import { ImageClient, parseEndpoint } from "./client"; -import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol"; +import { ActorSnapshot, formatSnapshot, isSourcePath, SystemKind, SystemMessage } from "./protocol"; import { RefreshQueue } from "./refresh"; import { parseValue } from "./values"; -import { BehaviorTree, SourceDocuments } from "./behaviors"; +import { + ImageSourceFileSystem, + RevisionDocuments, + REVISION_SCHEME, + SOURCE_SCHEME, + sourceFileUri, + sourceFolderUri, + sourcePathOf, +} from "./sourceProvider"; +import { SOURCE_ROOT } from "./sources"; class ActorTree implements vscode.TreeDataProvider { private readonly changes = new vscode.EventEmitter(); @@ -115,14 +124,13 @@ class SnapshotDocuments implements vscode.TextDocumentContentProvider { } } +type SourceTarget = vscode.Uri | { path: string } | string | undefined; + export function activate(context: vscode.ExtensionContext): void { const tree = new ActorTree(); const documents = new SnapshotDocuments(); - const behaviors = new BehaviorTree(); - const sources = new SourceDocuments(); - const behaviorView = vscode.window.createTreeView("runa.behaviors", { - treeDataProvider: behaviors, - }); + const sources = new ImageSourceFileSystem(); + const revisions = new RevisionDocuments(); const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree }); const output = vscode.window.createOutputChannel("Runa"); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); @@ -150,8 +158,6 @@ export function activate(context: vscode.ExtensionContext): void { status.command = connected ? "runa.refresh" : "runa.connect"; view.description = connected ? endpoint : "Disconnected"; view.message = connected ? undefined : "Not connected to an image."; - behaviorView.description = connected ? endpoint : "Disconnected"; - behaviorView.message = connected ? undefined : "Not connected to an image."; }; const disconnect = () => { @@ -160,10 +166,10 @@ export function activate(context: vscode.ExtensionContext): void { const previous = client; client = undefined; documents.disconnect(session); + sources.store.detach(); session++; previous?.close(); tree.replace([]); - behaviors.replace([]); connectionStatus(false); }; @@ -172,13 +178,53 @@ export function activate(context: vscode.ExtensionContext): void { const currentSession = session; if (!current) throw new Error("Connect to a local image first"); const actors = await current.listActors(); - const listing = await current.behaviors(); + if (current !== client || currentSession !== session) return; + await sources.store.refresh(); if (current !== client || currentSession !== session) return; tree.replace(actors); - behaviors.replace(listing); - behaviorView.message = listing.length ? undefined : "No published behaviors."; documents.refresh(session, endpoint, actors); - view.message = actors.length ? undefined : "No actors. Use Runa: Spawn Actor to create one."; + view.message = actors.length + ? undefined + : "No actors. Spawn a .rn file from the source folder."; + }; + + const addSourceFolder = async (target?: string): Promise => { + const address = target ?? endpoint; + if (!address) throw new Error("Connect to a local image first"); + const uri = sourceFolderUri(address); + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.some((folder) => folder.uri.toString() === uri.toString())) return true; + const previous = folders.findIndex((folder) => folder.uri.scheme === SOURCE_SCHEME); + const changed = new Promise((resolve) => { + const subscription = vscode.workspace.onDidChangeWorkspaceFolders(() => { + subscription.dispose(); + resolve(); + }); + }); + const folder = { uri, name: SOURCE_ROOT }; + // Replacing the first folder would restart extensions, so earlier images stay mounted there. + const updated = + previous > 0 + ? vscode.workspace.updateWorkspaceFolders(previous, 1, folder) + : vscode.workspace.updateWorkspaceFolders(folders.length, 0, folder); + if (!updated) throw new Error("VS Code rejected adding the image source folder"); + await changed; + output.appendLine(`Mounted ${uri.toString(true)} as workspace folder "${SOURCE_ROOT}"`); + return true; + }; + + const offerSourceFolder = async (address: string) => { + const uri = sourceFolderUri(address); + if (vscode.workspace.workspaceFolders?.some((f) => f.uri.toString() === uri.toString())) return; + if (vscode.workspace.workspaceFile !== undefined) { + await addSourceFolder(address); + return; + } + const choice = await vscode.window.showInformationMessage( + "Runa: Add the image's source folder to the workspace? VS Code restarts extensions when a window becomes a multi-root workspace; reconnect afterwards.", + "Add Source Folder", + ); + if (choice && client && endpoint === address) await addSourceFolder(address); }; const connect = async (target?: string) => { @@ -216,8 +262,8 @@ export function activate(context: vscode.ExtensionContext): void { client = undefined; updates.dispose(); tree.replace([]); - behaviors.replace([]); documents.disconnect(currentSession); + sources.store.detach(); connectionStatus(false); output.appendLine(`Disconnected from ${address}`); if (error) report(error); @@ -234,6 +280,7 @@ export function activate(context: vscode.ExtensionContext): void { return; } client = connected; + sources.store.attach(address, currentSession, connected); try { await refresh(); } catch (error) { @@ -244,8 +291,9 @@ export function activate(context: vscode.ExtensionContext): void { connectionStatus(true); view.message = tree.actors.length ? undefined - : "No actors. Use Runa: Spawn Actor to create one."; - output.appendLine(`Connected to ${address} (protocol v3)`); + : "No actors. Spawn a .rn file from the source folder."; + output.appendLine(`Connected to ${address} (protocol v4)`); + await offerSourceFolder(address); }; const inspect = async (selected?: ActorSnapshot) => { @@ -291,8 +339,11 @@ export function activate(context: vscode.ExtensionContext): void { if (!client) throw new Error("Connect to a local image first"); const current = client; const currentSession = session; + const currentEndpoint = endpoint; return { client: current, + session: currentSession, + endpoint: currentEndpoint, check: () => { if (current !== client || currentSession !== session) { throw new Error("Connection changed while entering the command; try again"); @@ -315,68 +366,186 @@ export function activate(context: vscode.ExtensionContext): void { }, }); - const openBehaviorSource = async (selected?: { name: string; revision: string | bigint }) => { + const pickSource = async (placeHolder: string): Promise => { + const paths = sources.store.paths(); + if (!paths.length) throw new Error("The image has no source files"); + return ( + await vscode.window.showQuickPick( + paths.map((path) => ({ label: path })), + { placeHolder }, + ) + )?.label; + }; + + /** Resolves a context-menu URI, explicit path, or the active editor to a source path. */ + const sourceTarget = async ( + target: SourceTarget, + connectionEndpoint: string, + placeHolder: string, + ): Promise => { + let selected = target; + if (selected === undefined) { + const uri = vscode.window.activeTextEditor?.document.uri; + if (uri?.scheme === SOURCE_SCHEME) selected = uri; + } + if (selected === undefined) return pickSource(placeHolder); + if (selected instanceof vscode.Uri) { + if (selected.authority !== connectionEndpoint) { + throw new Error(`${selected.toString(true)} belongs to another image; connect to it first`); + } + return sourcePathOf(selected); + } + const path = typeof selected === "string" ? selected : selected.path; + if (!isSourcePath(path)) { + throw new Error("Source paths look like actors/counter (letters, digits, _ and -; no .rn)"); + } + return path; + }; + + const dirtyDocument = (connectionEndpoint: string, path: string) => + vscode.workspace.textDocuments.find( + (document) => + document.isDirty && + document.uri.toString() === sourceFileUri(connectionEndpoint, path).toString(), + ); + + /** Unsaved editor text is never used implicitly; the user must save it to the image first. */ + const ensureSaved = async ( + connection: ReturnType, + path: string, + action: string, + ): Promise => { + const document = dirtyDocument(connection.endpoint, path); + if (!document) return true; + const choice = await vscode.window.showWarningMessage( + `${path}.rn has unsaved changes. ${action} uses the source saved in the image.`, + { modal: true }, + `Save and ${action}`, + ); + if (choice !== `Save and ${action}`) return false; + connection.check(); + if (!(await document.save()) || document.isDirty) { + throw new Error(`Could not save ${path}.rn; nothing was done`); + } + connection.check(); + return true; + }; + + const spawnPath = async (path: string, stateText: string, connection = active()) => { + const state = parseValue(stateText); + connection.check(); + if (!(await ensureSaved(connection, path, "Spawn"))) return; + connection.check(); + const actorRef = await connection.client.spawn(path, state); + output.appendLine(`Spawned @${actorRef} from ${path}`); + if (client === connection.client) { + void vscode.window.showInformationMessage(`Runa: Spawned @${actorRef} from ${path}`); + } + return actorRef; + }; + + const spawn = async (options?: { path: string; state: string }) => { const connection = active(); - const currentSession = session; - let target = selected; - if (!target) { - const listing = await connection.client.behaviors(); - connection.check(); - const choice = await vscode.window.showQuickPick( - listing.flatMap((behavior) => - [...behavior.history].reverse().map((version) => ({ - label: behavior.name, - description: `revision ${version.revision}${version.revision === behavior.revision ? " (current)" : ""}`, - target: { name: behavior.name, revision: String(version.revision) }, - })), - ), - { placeHolder: "Choose a behavior revision to view" }, - ); - target = choice?.target; - } - if (!target) return; - if ( - (typeof target.revision !== "string" && typeof target.revision !== "bigint") || - !/^[1-9]\d*$/.test(String(target.revision)) - ) { - throw new Error("Specify a positive decimal behavior revision"); - } + const path = options?.path ?? (await pickSource("Select the source file to spawn")); + if (path === undefined) return; + if (!isSourcePath(path)) throw new Error("Invalid source path"); + const state = options?.state ?? (await jsonInput(`Initial state for ${path}`)); + if (state === undefined) return; + return spawnPath(path, state, connection); + }; + + const spawnSource = async (target?: SourceTarget) => { + const connection = active(); + const path = await sourceTarget(target, connection.endpoint, "Select the source to spawn"); + if (path === undefined) return; + return spawnPath(path, "null", connection); + }; + + const spawnSourceWithState = async (target?: SourceTarget, stateText?: string) => { + const connection = active(); + const path = await sourceTarget(target, connection.endpoint, "Select the source to spawn"); + if (path === undefined) return; + const state = stateText ?? (await jsonInput(`Initial state for ${path}`)); + if (state === undefined) return; + return spawnPath(path, state, connection); + }; + + const commitSource = async (target?: SourceTarget) => { + const connection = active(); + const path = await sourceTarget(target, connection.endpoint, "Select the source to commit"); + if (path === undefined) return; + if (!(await ensureSaved(connection, path, "Commit"))) return; connection.check(); - const revision = BigInt(target.revision); - const source = await connection.client.behaviorSource(target.name, revision); + const commit = await connection.client.commitSource(path); + output.appendLine(`Committed ${path} as revision ${commit.revision}`); + void vscode.window.showInformationMessage(`Runa: Committed ${path} as ${commit.revision}`); + return commit.revision; + }; + + const chooseRevision = async ( + connection: ReturnType, + path: string, + placeHolder: string, + ): Promise => { + const history = await connection.client.sourceHistory(path); connection.check(); - const uri = sources.set(currentSession, target.name, revision, source); + if (!history.length) throw new Error(`${path} has no committed revisions`); + const working = sources.store.sourceId(path); + const choice = await vscode.window.showQuickPick( + [...history].reverse().map((commit, index) => ({ + label: commit.revision, + description: [ + index === 0 ? "latest commit" : undefined, + commit.source_id === working ? "matches working source" : undefined, + ] + .filter(Boolean) + .join(", "), + revision: commit.revision, + })), + { placeHolder }, + ); + return choice?.revision; + }; + + const sourceHistory = async (target?: SourceTarget, revision?: string) => { + const connection = active(); + const path = await sourceTarget(target, connection.endpoint, "Select a source"); + if (path === undefined) return; + const chosen = + revision ?? (await chooseRevision(connection, path, `Open a committed revision of ${path}`)); + if (chosen === undefined) return; + connection.check(); + const source = await connection.client.revisionSource(path, chosen); + connection.check(); + const uri = revisions.set(connection.session, path, chosen, source); const document = await vscode.workspace.openTextDocument(uri); connection.check(); await vscode.window.showTextDocument(document, { preview: true }); return uri.toString(); }; - const spawn = async (options?: { behavior: string; state: string }) => { + const rollbackSource = async (target?: SourceTarget, revision?: string) => { const connection = active(); - const behaviors = await connection.client.behaviors(); + const path = await sourceTarget(target, connection.endpoint, "Select the source to roll back"); + if (path === undefined) return; + const chosen = + revision ?? (await chooseRevision(connection, path, `Roll back ${path} to a revision`)); + if (chosen === undefined) return; + const unsaved = dirtyDocument(connection.endpoint, path) + ? " The open editor has unsaved changes that will conflict." + : ""; + const confirmed = await vscode.window.showWarningMessage( + `Replace the working source of ${path} with revision ${chosen}? Saved, uncommitted changes are discarded; history is kept and nothing is committed.${unsaved}`, + { modal: true }, + "Roll Back", + ); + if (confirmed !== "Roll Back") return; connection.check(); - const behavior = - options?.behavior ?? - ( - await vscode.window.showQuickPick( - behaviors.map((behavior) => ({ - label: behavior.name, - description: `revision ${behavior.revision}`, - })), - { placeHolder: "Select the behavior to spawn (required)" }, - ) - )?.label; - if (behavior === undefined) return; - const state = options?.state ?? (await jsonInput(`Initial state for ${behavior} (required)`)); - if (state === undefined) return; + const file = await connection.client.rollbackSource(path, chosen); connection.check(); - const actorRef = await connection.client.spawn(behavior, parseValue(state)); - output.appendLine(`Spawned @${actorRef} with behavior ${behavior}`); - if (client === connection.client) { - void vscode.window.showInformationMessage(`Runa: Spawned @${actorRef}`); - } - return actorRef; + sources.store.apply(file); + output.appendLine(`Rolled back working source ${path} to revision ${chosen}`); + return file.source_id; }; const send = async (selected?: { actor_ref: string }, source?: string) => { @@ -391,13 +560,33 @@ export function activate(context: vscode.ExtensionContext): void { void vscode.window.showInformationMessage(`Runa: Message queued for @${actorRef}`); }; - const lifecycle = async (operation: LifecycleOperation, selected?: { actor_ref: string }) => { + const system = async (kind: SystemKind, selected?: { actor_ref: string }) => { const connection = active(); const actorRef = await chooseActor(selected); if (actorRef === undefined) return; connection.check(); - await connection.client.lifecycle(operation, actorRef); - output.appendLine(`${operation}: @${actorRef}`); + await connection.client.system(actorRef, { kind }); + output.appendLine( + kind === "stop" + ? `stop: @${actorRef} (graceful; completes when the scheduler advances)` + : `${kind}: @${actorRef}`, + ); + }; + + const become = async (selected?: { actor_ref: string }, target?: SourceTarget) => { + const connection = active(); + const actorRef = await chooseActor(selected); + if (actorRef === undefined) return; + const prompt = `Select the source @${actorRef} should become`; + const path = + target === undefined + ? await pickSource(prompt) + : await sourceTarget(target, connection.endpoint, prompt); + if (path === undefined) return; + connection.check(); + const message: SystemMessage = { kind: "become", path }; + await connection.client.system(actorRef, message); + output.appendLine(`become: @${actorRef} -> ${path}`); }; const tick = async (count?: number) => { @@ -431,36 +620,43 @@ export function activate(context: vscode.ExtensionContext): void { } }); + const systemCommand = (kind: SystemKind) => + command(`runa.${kind}`, (selected?: { actor_ref: string }) => system(kind, selected)); + context.subscriptions.push( tree, - behaviors, sources, - behaviorView, + revisions, documents, view, output, status, + vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }), vscode.workspace.registerTextDocumentContentProvider("runa", documents), - vscode.workspace.registerTextDocumentContentProvider("runa-source", sources), + vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions), vscode.workspace.onDidCloseTextDocument((document) => { documents.remove(document.uri); - sources.remove(document.uri); + revisions.remove(document.uri); }), command("runa.connect", connect), command("runa.disconnect", disconnect), command("runa.refresh", refresh), + command("runa.addSourceFolder", () => addSourceFolder()), command("runa.inspect", inspect), - command("runa.openBehaviorSource", openBehaviorSource), command("runa.spawn", spawn), + command("runa.spawnSource", spawnSource), + command("runa.spawnSourceWithState", spawnSourceWithState), + command("runa.commitSource", commitSource), + command("runa.sourceHistory", sourceHistory), + command("runa.rollbackSource", rollbackSource), command("runa.send", send), - command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)), - command("runa.restart", (selected?: { actor_ref: string }) => - lifecycle("restart_actor", selected), - ), - command("runa.pause", (selected?: { actor_ref: string }) => lifecycle("pause_actor", selected)), - command("runa.resume", (selected?: { actor_ref: string }) => - lifecycle("resume_actor", selected), - ), + systemCommand("stop"), + systemCommand("kill"), + systemCommand("restart"), + systemCommand("pause"), + systemCommand("resume"), + systemCommand("reload"), + command("runa.become", become), command("runa.tick", tick), { dispose: () => { diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index 3a032df..869e802 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -1,6 +1,6 @@ import { parse, stringify } from "lossless-json"; -export const PROTOCOL_VERSION = 3; +export const PROTOCOL_VERSION = 4; export const MAX_MESSAGE_BYTES = 1024 * 1024; export const WEBSOCKET_SUBPROTOCOL = "runa-debug"; const ACTOR_REF = /^[A-Za-z0-9_-]{21}$/; @@ -32,22 +32,36 @@ export interface ActorSnapshot { reserved: Value | null; } -export type LifecycleOperation = "stop_actor" | "restart_actor" | "pause_actor" | "resume_actor"; +export type SystemKind = "stop" | "kill" | "restart" | "pause" | "resume" | "reload"; +export type SystemMessage = { kind: SystemKind } | { kind: "become"; path: string }; -export interface Behavior { - name: string; - revision: bigint; - current_implementation_id: string; - history: { revision: bigint; implementation_id: string }[]; +export interface SourceFile { + path: string; + source_id: string; + source: string; +} + +export interface SourceCommit { + revision: string; + source_id: string; } export type Command = - | { operation: "list_actors" | "get_behaviors" } - | { operation: "get_behavior_source"; parameters: { name: string; revision: bigint } } - | { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: string } } + | { operation: "list_actors" | "list_sources" } + | { operation: "read_source" | "commit_source" | "source_history"; parameters: { path: string } } + | { + operation: "write_source"; + parameters: { path: string; expected_source_id: string | null; source: string }; + } + | { + operation: "revision_source" | "rollback_source"; + parameters: { path: string; revision: string }; + } + | { operation: "system"; parameters: { actor_ref: string; message: SystemMessage } } + | { operation: "inspect_actor"; parameters: { actor_ref: string } } | { operation: "spawn"; - parameters: { behavior_name: string; state: Value; mailbox_capacity: number }; + parameters: { path: string; state: Value; mailbox_capacity: number }; } | { operation: "send"; parameters: { actor_ref: string; message: Value } } | { operation: "tick"; parameters: { ticks: number; instruction_budget: number } }; @@ -65,6 +79,20 @@ export function isActorRef(value: unknown): value is string { return typeof value === "string" && ACTOR_REF.test(value); } +/** Canonical image source path, e.g. `actors/counter` (no `source/` root, no `.rn`). */ +export function isSourcePath(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= 128 && + !value.startsWith("source/") && + value.split("/").every((part) => /^[A-Za-z0-9_-]+$/.test(part)) + ); +} + +export function isSourceRevision(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9]{1,64}$/.test(value); +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -178,10 +206,29 @@ function decodeActor(input: unknown): ActorSnapshot { }; } +function decodeSourceFile(input: unknown): SourceFile { + const file = object(input); + if (!isSourcePath(file.path)) throw new Error("Invalid source path"); + const source_id = text(file.source_id); + if (!source_id) throw new Error("Invalid source identity"); + return { path: file.path, source_id, source: text(file.source) }; +} + +function decodeSourceCommit(input: unknown): SourceCommit { + const commit = object(input); + if (!isSourceRevision(commit.revision)) throw new Error("Invalid source revision"); + const source_id = text(commit.source_id); + if (!source_id) throw new Error("Invalid source identity"); + return { revision: commit.revision, source_id }; +} + export type Reply = | { kind: "actors"; data: ActorSnapshot[] } | { kind: "actor"; data: ActorSnapshot } - | { kind: "behaviors"; data: Behavior[] } + | { kind: "sources"; data: SourceFile[] } + | { kind: "source"; data: SourceFile } + | { kind: "source_committed"; data: SourceCommit } + | { kind: "source_history"; data: SourceCommit[] } | { kind: "actor_spawned"; data: { actor_ref: string } } | { kind: "acknowledged" } | { kind: "behavior_source"; data: { source: string } } @@ -237,31 +284,38 @@ export function decodeResponse(line: string, requestId: string, command: Command throw new ProtocolError(code, text(error.message)); } const reply = object(result.Ok); - if (command.operation === "get_behavior_source" && reply.kind === "behavior_source") { + if (command.operation === "revision_source" && reply.kind === "behavior_source") { return { kind: "behavior_source", data: { source: text(object(reply.data).source) } }; } - if (command.operation === "get_behaviors" && reply.kind === "behaviors") { - const data = array(reply.data).map((input): Behavior => { - const behavior = object(input); - const name = text(behavior.name); - const revision = uint(behavior.revision); - if (!name.trim() || revision === 0n) throw new Error("Invalid behavior snapshot"); - return { - name, - revision, - current_implementation_id: text(behavior.current_implementation_id), - history: array(behavior.history).map((input) => { - const version = object(input); - const revision = uint(version.revision); - if (revision === 0n) throw new Error("Invalid behavior revision"); - return { revision, implementation_id: text(version.implementation_id) }; - }), - }; - }); - if (new Set(data.map((behavior) => behavior.name)).size !== data.length) { - throw new Error("Duplicate behavior names"); + if (command.operation === "list_sources" && reply.kind === "sources") { + const data = array(reply.data).map(decodeSourceFile); + if (new Set(data.map((file) => file.path)).size !== data.length) { + throw new Error("Duplicate source paths"); } - return { kind: "behaviors", data }; + return { kind: "sources", data }; + } + if ( + (command.operation === "read_source" || + command.operation === "write_source" || + command.operation === "rollback_source") && + reply.kind === "source" + ) { + const data = decodeSourceFile(reply.data); + if (data.path !== command.parameters.path) throw new Error("Source path mismatch"); + if (command.operation === "write_source" && data.source !== command.parameters.source) { + throw new Error("Written source mismatch"); + } + return { kind: "source", data }; + } + if (command.operation === "commit_source" && reply.kind === "source_committed") { + return { kind: "source_committed", data: decodeSourceCommit(reply.data) }; + } + if (command.operation === "source_history" && reply.kind === "source_history") { + const data = array(reply.data).map(decodeSourceCommit); + if (new Set(data.map((commit) => commit.revision)).size !== data.length) { + throw new Error("Duplicate source revisions"); + } + return { kind: "source_history", data }; } if (command.operation === "spawn" && reply.kind === "actor_spawned") { const data = object(reply.data); @@ -274,9 +328,7 @@ export function decodeResponse(line: string, requestId: string, command: Command return { kind: "tick_completed", data: { ticks } }; } if ( - ["send", "stop_actor", "restart_actor", "pause_actor", "resume_actor"].includes( - command.operation, - ) && + (command.operation === "send" || command.operation === "system") && reply.kind === "acknowledged" ) return { kind: "acknowledged" }; diff --git a/vscode/src/sourceProvider.ts b/vscode/src/sourceProvider.ts new file mode 100644 index 0000000..1ddb2ab --- /dev/null +++ b/vscode/src/sourceProvider.ts @@ -0,0 +1,196 @@ +import * as vscode from "vscode"; +import { isSourcePath, isSourceRevision } from "./protocol"; +import { + parseSourceUriPath, + SourceChange, + SourceFsError, + SourceStore, + SOURCE_ROOT, + sourceUriPath, +} from "./sources"; + +export const SOURCE_SCHEME = "runa-image"; +export const REVISION_SCHEME = "runa-source"; + +/** Workspace folder URI for an image endpoint; stable across reconnects. */ +export function sourceFolderUri(endpoint: string): vscode.Uri { + return vscode.Uri.from({ scheme: SOURCE_SCHEME, authority: endpoint, path: `/${SOURCE_ROOT}` }); +} + +export function sourceFileUri(endpoint: string, path: string): vscode.Uri { + return vscode.Uri.from({ scheme: SOURCE_SCHEME, authority: endpoint, path: sourceUriPath(path) }); +} + +/** Canonical image path (`actors/counter`) for a `runa-image` `.rn` URI. */ +export function sourcePathOf(uri: vscode.Uri): string { + if (uri.scheme !== SOURCE_SCHEME) throw new Error("Choose a .rn file in the Runa source folder"); + const entry = parseSourceUriPath(uri.path); + if (entry.kind !== "file") throw new Error("Choose a .rn file in the Runa source folder"); + return entry.path; +} + +/** Writable working files of the connected image under `runa-image:///source`. */ +export class ImageSourceFileSystem implements vscode.FileSystemProvider { + private readonly changes = new vscode.EventEmitter(); + readonly onDidChangeFile = this.changes.event; + readonly store: SourceStore; + + constructor() { + this.store = new SourceStore((changes) => this.fire(changes)); + } + + private fire(changes: SourceChange[]): void { + const image = this.store.image; + if (image === undefined) return; + this.changes.fire( + changes.map((change) => ({ + type: + change.type === "created" + ? vscode.FileChangeType.Created + : change.type === "deleted" + ? vscode.FileChangeType.Deleted + : vscode.FileChangeType.Changed, + uri: vscode.Uri.from({ scheme: SOURCE_SCHEME, authority: image, path: change.uriPath }), + })), + ); + } + + private run(uri: vscode.Uri, action: () => T): T { + try { + if (uri.authority !== this.store.image || !this.store.connected) { + const root = parseSourceUriPath(uri.path); + if (root.kind === "root" || (root.kind === "directory" && root.path === "")) { + return action(); + } + throw new SourceFsError( + "Unavailable", + `Not connected to ${uri.authority}; run Runa: Connect to edit its sources`, + ); + } + return action(); + } catch (error) { + throw this.translate(error, uri); + } + } + + private async runAsync(uri: vscode.Uri, action: () => Promise): Promise { + try { + return await this.run(uri, action); + } catch (error) { + throw this.translate(error, uri); + } + } + + private translate(error: unknown, uri: vscode.Uri): unknown { + if (!(error instanceof SourceFsError)) return error; + const message = `${error.message} (${uri.toString(true)})`; + switch (error.code) { + case "FileNotFound": + return vscode.FileSystemError.FileNotFound(message); + case "FileExists": + return vscode.FileSystemError.FileExists(message); + case "FileIsADirectory": + return vscode.FileSystemError.FileIsADirectory(message); + case "FileNotADirectory": + return vscode.FileSystemError.FileNotADirectory(message); + case "NoPermissions": + return vscode.FileSystemError.NoPermissions(message); + case "Unavailable": + return vscode.FileSystemError.Unavailable(message); + } + } + + watch(): vscode.Disposable { + // Changes are reported from image notifications for every URI. + return new vscode.Disposable(() => {}); + } + + stat(uri: vscode.Uri): vscode.FileStat { + return this.run(uri, () => { + const stat = this.store.stat(uri.path); + return { + type: stat.type === "file" ? vscode.FileType.File : vscode.FileType.Directory, + ctime: stat.ctime, + mtime: stat.mtime, + size: stat.size, + }; + }); + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + return this.run(uri, () => { + const entries = this.store.image === uri.authority ? this.store.readDirectory(uri.path) : []; + return entries.map(([name, type]) => [ + name, + type === "file" ? vscode.FileType.File : vscode.FileType.Directory, + ]); + }); + } + + createDirectory(uri: vscode.Uri): void { + this.run(uri, () => this.store.createDirectory(uri.path)); + } + + async readFile(uri: vscode.Uri): Promise { + const source = await this.runAsync(uri, () => this.store.read(uri.path)); + return Buffer.from(source, "utf8"); + } + + async writeFile( + uri: vscode.Uri, + content: Uint8Array, + options: { readonly create: boolean; readonly overwrite: boolean }, + ): Promise { + await this.runAsync(uri, () => this.store.write(uri.path, content, options)); + } + + delete(uri: vscode.Uri): void { + this.run(uri, () => this.store.rejectMutation("Deleting")); + } + + rename(oldUri: vscode.Uri): void { + this.run(oldUri, () => this.store.rejectMutation("Renaming")); + } + + dispose(): void { + this.changes.dispose(); + } +} + +/** Read-only, immutable committed source revisions, isolated per connection session. */ +export class RevisionDocuments implements vscode.TextDocumentContentProvider { + private readonly sources = new Map(); + + set(session: number, path: string, revision: string, source: string): vscode.Uri { + if (!isSourcePath(path) || !isSourceRevision(revision)) { + throw new Error("Invalid source path or revision"); + } + const uri = vscode.Uri.from({ + scheme: REVISION_SCHEME, + authority: String(session), + path: `/${SOURCE_ROOT}/${path}@${revision}.rn`, + }); + const previous = this.sources.get(uri.toString()); + if (previous !== undefined && previous !== source) { + throw new Error("Immutable source revision changed"); + } + this.sources.set(uri.toString(), source); + return uri; + } + + provideTextDocumentContent(uri: vscode.Uri): string { + const source = this.sources.get(uri.toString()); + if (source === undefined) { + throw new Error("Revision source is no longer cached; open the history entry again"); + } + return source; + } + + remove(uri: vscode.Uri): void { + this.sources.delete(uri.toString()); + } + + dispose(): void { + this.sources.clear(); + } +} diff --git a/vscode/src/sources.ts b/vscode/src/sources.ts new file mode 100644 index 0000000..8d55554 --- /dev/null +++ b/vscode/src/sources.ts @@ -0,0 +1,363 @@ +import { TextDecoder } from "node:util"; +import { isSourcePath, ProtocolError, SourceFile } from "./protocol"; + +/** Name of the workspace root directory that mirrors image source files. */ +export const SOURCE_ROOT = "source"; +export const SOURCE_EXTENSION = ".rn"; + +export type SourceErrorCode = + | "FileNotFound" + | "FileExists" + | "FileNotADirectory" + | "FileIsADirectory" + | "NoPermissions" + | "Unavailable"; + +export class SourceFsError extends Error { + constructor( + public readonly code: SourceErrorCode, + message: string, + ) { + super(message); + } +} + +export type SourceEntry = + { kind: "root" } | { kind: "directory"; path: string } | { kind: "file"; path: string }; + +/** Maps a provider URI path (`/source/actors/counter.rn`) to an entry; throws on invalid paths. */ +export function parseSourceUriPath(uriPath: string): SourceEntry { + if (uriPath === "/" || uriPath === "") return { kind: "root" }; + const prefix = `/${SOURCE_ROOT}`; + if (uriPath === prefix || uriPath === `${prefix}/`) return { kind: "directory", path: "" }; + if (!uriPath.startsWith(`${prefix}/`)) { + throw new SourceFsError("FileNotFound", `Image sources live under /${SOURCE_ROOT}`); + } + const relative = uriPath.slice(prefix.length + 1).replace(/\/$/, ""); + if (relative.endsWith(SOURCE_EXTENSION)) { + const path = relative.slice(0, -SOURCE_EXTENSION.length); + if (isSourcePath(path)) return { kind: "file", path }; + } else if (isSourcePath(relative)) { + return { kind: "directory", path: relative }; + } + throw new SourceFsError( + "NoPermissions", + `Invalid image source name ${JSON.stringify(relative)}; use letters, digits, _ and - with .rn files`, + ); +} + +/** Lookups of names the image cannot hold report "not found" (VS Code probes `.vscode/*`). */ +function existing(uriPath: string): SourceEntry { + try { + return parseSourceUriPath(uriPath); + } catch (error) { + if (error instanceof SourceFsError && error.code === "NoPermissions") { + throw new SourceFsError("FileNotFound", error.message); + } + throw error; + } +} + +export function sourceUriPath(path: string): string { + if (!isSourcePath(path)) throw new Error(`Invalid source path ${JSON.stringify(path)}`); + return `/${SOURCE_ROOT}/${path}${SOURCE_EXTENSION}`; +} + +export function directoryUriPath(path: string): string { + return path ? `/${SOURCE_ROOT}/${path}` : `/${SOURCE_ROOT}`; +} + +export interface SourceClient { + listSources(): Promise; + readSource(path: string): Promise; + writeSource(path: string, expectedSourceId: string | null, source: string): Promise; +} + +export type SourceChangeType = "created" | "changed" | "deleted"; +export interface SourceChange { + type: SourceChangeType; + uriPath: string; +} + +export interface SourceStat { + type: "file" | "directory"; + ctime: number; + mtime: number; + size: number; +} + +interface Known { + source_id: string; + size: number; + mtime: number; +} + +interface Session { + id: number; + client: SourceClient; + files: Map; + directories: Set; + loaded: boolean; +} + +function parents(path: string): string[] { + const parts = path.split("/"); + return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join("/")); +} + +/** + * Connection-scoped view of the image's working source files. The image owns + * the text; this store only tracks identities so saves use compare-and-swap. + */ +export class SourceStore { + private session?: Session; + private imageKey?: string; + // Modification times survive reconnects to the same image so VS Code can + // detect sources that changed while an editor was dirty. + private mtimes = new Map(); + + constructor( + private readonly emit: (changes: SourceChange[]) => void, + private readonly now: () => number = Date.now, + ) {} + + get connected(): boolean { + return this.session !== undefined; + } + + get sessionId(): number | undefined { + return this.session?.id; + } + + get image(): string | undefined { + return this.imageKey; + } + + attach(image: string, id: number, client: SourceClient): void { + this.detach(); + if (image !== this.imageKey) this.mtimes.clear(); + this.imageKey = image; + this.session = { id, client, files: new Map(), directories: new Set(), loaded: false }; + } + + detach(): void { + const previous = this.session; + this.session = undefined; + if (!previous) return; + // Open editors keep their text; the source root shows as empty until reconnect. + this.emit([{ type: "changed", uriPath: directoryUriPath("") }]); + } + + /** Paths of the current working source files, sorted. */ + paths(): string[] { + return [...(this.session?.files.keys() ?? [])].sort(); + } + + sourceId(path: string): string | undefined { + return this.session?.files.get(path)?.source_id; + } + + async refresh(): Promise { + const session = this.require(); + const listing = await session.client.listSources(); + this.check(session); + const changes: SourceChange[] = []; + const seen = new Set(); + for (const file of listing) { + seen.add(file.path); + changes.push(...this.remember(session, file)); + } + for (const path of [...session.files.keys()]) { + if (seen.has(path)) continue; + session.files.delete(path); + changes.push({ type: "deleted", uriPath: sourceUriPath(path) }); + } + if (!session.loaded) { + session.loaded = true; + changes.push({ type: "changed", uriPath: directoryUriPath("") }); + } + if (changes.length) this.emit(changes); + } + + /** Records a file returned by the image (for example after rollback). */ + apply(file: SourceFile): void { + const session = this.require(); + const changes = this.remember(session, file); + if (changes.length) this.emit(changes); + } + + stat(uriPath: string): SourceStat { + const entry = existing(uriPath); + if (entry.kind === "root" || (entry.kind === "directory" && entry.path === "")) { + return { type: "directory", ctime: 0, mtime: 0, size: 0 }; + } + const session = this.require(); + if (entry.kind === "file") { + const known = session.files.get(entry.path); + if (known) return { type: "file", ctime: 0, mtime: known.mtime, size: known.size }; + } else if (this.isDirectory(session, entry.path)) { + return { type: "directory", ctime: 0, mtime: 0, size: 0 }; + } + throw new SourceFsError("FileNotFound", `No image source at ${uriPath}`); + } + + readDirectory(uriPath: string): [string, "file" | "directory"][] { + const entry = existing(uriPath); + if (entry.kind === "root") return [[SOURCE_ROOT, "directory"]]; + if (entry.kind === "file") throw new SourceFsError("FileNotADirectory", `${uriPath} is a file`); + // Unconnected workspaces show an empty source root rather than an error. + if (!this.session && entry.path === "") return []; + const session = this.require(); + if (entry.path && !this.isDirectory(session, entry.path)) { + throw new SourceFsError("FileNotFound", `No image directory at ${uriPath}`); + } + const prefix = entry.path ? `${entry.path}/` : ""; + const children = new Map(); + const consider = (path: string, kind: "file" | "directory") => { + if (!path.startsWith(prefix)) return; + const [name, ...rest] = path.slice(prefix.length).split("/"); + if (!name) return; + if (rest.length || kind === "directory") children.set(name, "directory"); + else children.set(name + SOURCE_EXTENSION, "file"); + }; + for (const path of session.files.keys()) consider(path, "file"); + for (const path of session.directories) consider(path, "directory"); + return [...children].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + } + + createDirectory(uriPath: string): void { + const entry = parseSourceUriPath(uriPath); + if (entry.kind !== "directory" || entry.path === "") { + throw new SourceFsError("FileExists", `${uriPath} already exists`); + } + const session = this.require(); + if (this.isDirectory(session, entry.path)) { + throw new SourceFsError("FileExists", `${uriPath} already exists`); + } + session.directories.add(entry.path); + this.emit([{ type: "created", uriPath }]); + } + + async read(uriPath: string): Promise { + const path = this.filePath(uriPath, existing); + const session = this.require(); + let file: SourceFile; + try { + file = await session.client.readSource(path); + } catch (error) { + throw this.translate(error, uriPath); + } + this.check(session); + const changes = this.remember(session, file); + if (changes.some((change) => change.type === "created")) this.emit(changes); + return file.source; + } + + async write( + uriPath: string, + content: Uint8Array, + options: { create: boolean; overwrite: boolean }, + ): Promise { + const path = this.filePath(uriPath); + const session = this.require(); + let source: string; + try { + source = new TextDecoder("utf-8", { fatal: true }).decode(content); + } catch { + throw new SourceFsError("NoPermissions", "Image sources must be UTF-8 text"); + } + const known = session.files.get(path); + if (!known && !options.create) { + throw new SourceFsError("FileNotFound", `No image source at ${uriPath}`); + } + if (known && !options.overwrite) { + throw new SourceFsError("FileExists", `${uriPath} already exists`); + } + let file: SourceFile; + try { + file = await session.client.writeSource(path, known?.source_id ?? null, source); + } catch (error) { + throw this.translate(error, uriPath); + } + this.check(session); + const changes = this.remember(session, file); + if (changes.length) this.emit(changes); + } + + rejectMutation(action: string): never { + throw new SourceFsError( + "NoPermissions", + `${action} image sources is not supported; the image keeps every source path`, + ); + } + + private filePath(uriPath: string, parse = parseSourceUriPath): string { + const entry = parse(uriPath); + if (entry.kind !== "file") { + throw new SourceFsError("FileIsADirectory", `${uriPath} is not an image source file`); + } + return entry.path; + } + + private remember(session: Session, file: SourceFile): SourceChange[] { + const previous = session.files.get(file.path); + const uriPath = sourceUriPath(file.path); + if (previous?.source_id === file.source_id) return []; + const recorded = this.mtimes.get(file.path); + let mtime = recorded?.mtime ?? this.now(); + if (recorded && recorded.source_id !== file.source_id) { + mtime = Math.max(this.now(), recorded.mtime + 1); + } + this.mtimes.set(file.path, { source_id: file.source_id, mtime }); + session.files.set(file.path, { + source_id: file.source_id, + size: Buffer.byteLength(file.source), + mtime, + }); + const created = parents(file.path).filter((directory) => !session.directories.has(directory)); + for (const directory of created) session.directories.add(directory); + return [ + ...created.map((directory) => ({ + type: "created" as const, + uriPath: directoryUriPath(directory), + })), + { type: previous ? "changed" : "created", uriPath }, + ]; + } + + private isDirectory(session: Session, path: string): boolean { + if (session.directories.has(path)) return true; + const prefix = `${path}/`; + return [...session.files.keys()].some((file) => file.startsWith(prefix)); + } + + private require(): Session { + if (!this.session) { + throw new SourceFsError("Unavailable", "Not connected to an image; run Runa: Connect"); + } + return this.session; + } + + private check(session: Session): void { + if (this.session !== session) { + throw new SourceFsError( + "Unavailable", + "Image connection changed; the operation was not applied locally", + ); + } + } + + private translate(error: unknown, uriPath: string): Error { + if (error instanceof ProtocolError) { + if (error.code === "not_found") + return new SourceFsError("FileNotFound", `No image source at ${uriPath}`); + if (error.code === "conflict") { + return new SourceFsError( + "Unavailable", + `${uriPath} changed in the image since it was loaded; revert or compare before saving again (${error.message})`, + ); + } + } + return error instanceof Error ? error : new Error(String(error)); + } +} diff --git a/vscode/test/client.test.ts b/vscode/test/client.test.ts index f74d556..21b7053 100644 --- a/vscode/test/client.test.ts +++ b/vscode/test/client.test.ts @@ -29,7 +29,7 @@ test("fragmented UTF-8 replies and simultaneous reads preserve correlation and v const operations: string[] = []; const address = await fakeImage(t, (request, socket) => { operations.push(request.command.operation); - assert.equal(request.version, 3); + assert.equal(request.version, 4); const reply = request.command.operation === "list_actors" ? { kind: "actors", data: [actor()] } @@ -54,7 +54,7 @@ test("protocol errors preserve the connection and are never retried", async (t) socket.send( requests === 1 ? json({ - version: 3, + version: 4, request_id: request.request_id, result: { Err: { code: "not_found", message: "Missing actor" } }, }) @@ -79,7 +79,7 @@ test("protocol errors preserve the connection and are never retried", async (t) t.after(() => client.close()); const overhead = Buffer.byteLength( json({ - version: 3, + version: 4, request_id: "runa-vscode-1", command: { operation: "send", @@ -105,7 +105,7 @@ test("protocol errors preserve the connection and are never retried", async (t) const client = await ImageClient.connect(address, () => undefined); t.after(() => client.close()); const results = await Promise.allSettled([ - client.spawn("counter", { type: "integer", data: 0n }), + client.spawn("actors/counter", { type: "integer", data: 0n }), client.send(REF, { type: "null" }), ]); assert.ok(results.every((result) => result.status === "rejected")); @@ -142,7 +142,7 @@ test("bad correlation, malformed JSON/UTF-8, oversize, truncation, and wrong ver socket.send('{"version":3', { fin: false }); socket.terminate(); } else if (mode === "binary") socket.send(Buffer.from("{}")); - else if (mode === "event") socket.send(json({ version: 3, event: { event: "unknown" } })); + else if (mode === "event") socket.send(json({ version: 4, event: { event: "unknown" } })); else socket.send( json({ @@ -294,3 +294,73 @@ test("failed connection reports the error", async () => { /ECONNREFUSED/, ); }); + +test("source and system commands use the exact v4 wire contract", async (t) => { + const requests: unknown[] = []; + const file = { path: "actors/counter", source_id: "source:2", source: "pub fn handle() {}\n" }; + const address = await fakeImage(t, (request, socket) => { + requests.push(request.command); + const replies: Record = { + list_sources: { kind: "sources", data: [file] }, + read_source: { kind: "source", data: file }, + write_source: { kind: "source", data: file }, + rollback_source: { kind: "source", data: file }, + commit_source: { kind: "source_committed", data: { revision: "r1", source_id: "source:2" } }, + source_history: { kind: "source_history", data: [{ revision: "r1", source_id: "source:2" }] }, + revision_source: { kind: "behavior_source", data: { source: file.source } }, + system: { kind: "acknowledged" }, + spawn: { kind: "actor_spawned", data: { actor_ref: REF } }, + }; + socket.send(response(request, replies[request.command.operation])); + }); + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + t.after(() => client.close()); + assert.deepEqual(await client.listSources(), [file]); + assert.deepEqual(await client.readSource(file.path), file); + assert.deepEqual(await client.writeSource(file.path, null, file.source), file); + assert.deepEqual(await client.writeSource(file.path, "source:1", file.source), file); + assert.deepEqual(await client.commitSource(file.path), { revision: "r1", source_id: "source:2" }); + assert.equal((await client.sourceHistory(file.path)).length, 1); + assert.equal(await client.revisionSource(file.path, "r1"), file.source); + assert.deepEqual(await client.rollbackSource(file.path, "r1"), file); + await client.system(REF, { kind: "stop" }); + await client.system(REF, { kind: "become", path: "actors/other" }); + assert.equal(await client.spawn(file.path, { type: "null" }), REF); + for (const invalid of [ + () => client.readSource("actors/counter.rn"), + () => client.readSource("source/actors/counter"), + () => client.writeSource(file.path, "", "x"), + () => client.revisionSource(file.path, "r.1"), + () => client.rollbackSource(file.path, ""), + () => client.spawn("../counter", { type: "null" }), + () => client.system(REF, { kind: "become", path: "actors/x.rn" }), + () => client.system(REF, { kind: "explode" } as never), + () => client.system("bad", { kind: "stop" }), + ]) + await assert.rejects(invalid()); + assert.deepEqual(requests, [ + { operation: "list_sources" }, + { operation: "read_source", parameters: { path: file.path } }, + { + operation: "write_source", + parameters: { path: file.path, expected_source_id: null, source: file.source }, + }, + { + operation: "write_source", + parameters: { path: file.path, expected_source_id: "source:1", source: file.source }, + }, + { operation: "commit_source", parameters: { path: file.path } }, + { operation: "source_history", parameters: { path: file.path } }, + { operation: "revision_source", parameters: { path: file.path, revision: "r1" } }, + { operation: "rollback_source", parameters: { path: file.path, revision: "r1" } }, + { operation: "system", parameters: { actor_ref: REF, message: { kind: "stop" } } }, + { + operation: "system", + parameters: { actor_ref: REF, message: { kind: "become", path: "actors/other" } }, + }, + { + operation: "spawn", + parameters: { path: file.path, state: { type: "null" }, mailbox_capacity: 64 }, + }, + ]); +}); diff --git a/vscode/test/fixtures.ts b/vscode/test/fixtures.ts index 44d22fa..d51001e 100644 --- a/vscode/test/fixtures.ts +++ b/vscode/test/fixtures.ts @@ -38,11 +38,11 @@ export function json(value: unknown): string { export interface TestRequest { version: number; request_id: string; - command: { operation: string; parameters?: { actor_ref: string } }; + command: { operation: string; parameters?: Record }; } export function response(request: TestRequest, reply: unknown): string { - return json({ version: 3, request_id: request.request_id, result: { Ok: reply } }); + return json({ version: 4, request_id: request.request_id, result: { Ok: reply } }); } export async function fakeImage( @@ -74,5 +74,5 @@ export async function fakeImage( } export function notification(revision: bigint): string { - return json({ version: 3, event: { event: "image_changed", data: { revision } } }); + return json({ version: 4, event: { event: "image_changed", data: { revision } } }); } diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts index 0b00f47..74cdba2 100644 --- a/vscode/test/protocol.test.ts +++ b/vscode/test/protocol.test.ts @@ -6,6 +6,8 @@ import { decodeNotification, decodeValue, formatSnapshot, + isSourcePath, + isSourceRevision, ProtocolError, Command, } from "../src/protocol"; @@ -13,12 +15,12 @@ import { actor, json, REF } from "./fixtures"; const command: Command = { operation: "list_actors" }; const envelope = (reply: unknown) => - json({ version: 3, request_id: "test", result: { Ok: reply } }); + json({ version: 4, request_id: "test", result: { Ok: reply } }); test("notification envelopes preserve u64 revisions and reject malformed events", () => { const notification = (data: unknown) => json({ - version: 3, + version: 4, event: { event: "image_changed", data, @@ -50,7 +52,7 @@ test("notification envelopes preserve u64 revisions and reject malformed events" ); }); -test("v3 snapshots preserve all value variants, i64 limits, u64 telemetry, and mailbox order", () => { +test("v4 snapshots preserve all value variants, i64 limits, u64 telemetry, and mailbox order", () => { const snapshot = actor(); const reply = decodeResponse(envelope({ kind: "actors", data: [snapshot] }), "test", command); assert.deepEqual(reply, { kind: "actors", data: [snapshot] }); @@ -76,7 +78,7 @@ test("version, correlation, result, reply kind, and actor identity are checked", assert.throws(() => decodeResponse( json({ - version: 3, + version: 4, request_id: "test", result: { Ok: { kind: "actors", data: [] } }, ...change, @@ -102,7 +104,7 @@ test("server errors are explicit, typed, and not treated as empty actor lists", () => decodeResponse( json({ - version: 3, + version: 4, request_id: "test", result: { Err: { code: "not_found", message: "No actor" } }, }), @@ -117,7 +119,7 @@ test("mutation replies must match the requested operation and carry validated da const spawn: Command = { operation: "spawn", parameters: { - behavior_name: "counter", + path: "actors/counter", state: { type: "integer", data: 0n }, mailbox_capacity: 64, }, @@ -138,17 +140,18 @@ test("mutation replies must match the requested operation and carry validated da assert.throws(() => decodeResponse(envelope({ kind: "tick_completed", data: { ticks: 11 } }), "test", tick), ); - for (const operation of [ - "stop_actor", - "restart_actor", - "pause_actor", - "resume_actor", - "send", - ] as const) { - const command: Command = - operation === "send" - ? { operation, parameters: { actor_ref: REF, message: { type: "null" } } } - : { operation, parameters: { actor_ref: REF } }; + const commands: Command[] = [ + { operation: "send", parameters: { actor_ref: REF, message: { type: "null" } } }, + ...(["stop", "kill", "restart", "pause", "resume", "reload"] as const).map((kind): Command => ({ + operation: "system", + parameters: { actor_ref: REF, message: { kind } }, + })), + { + operation: "system", + parameters: { actor_ref: REF, message: { kind: "become", path: "actors/counter" } }, + }, + ]; + for (const command of commands) { assert.deepEqual(decodeResponse(envelope({ kind: "acknowledged" }), "test", command), { kind: "acknowledged", }); @@ -156,39 +159,121 @@ test("mutation replies must match the requested operation and carry validated da } }); -test("behavior listings validate names, revisions, history, and uniqueness", () => { - const behavior = { - name: "counter", - revision: 1n, - current_implementation_id: "id", - history: [{ revision: 1n, implementation_id: "id" }], - }; - assert.deepEqual( - decodeResponse(envelope({ kind: "behaviors", data: [behavior] }), "test", { - operation: "get_behaviors", - }), - { kind: "behaviors", data: [behavior] }, - ); - for (const data of [ - [{ ...behavior, name: "" }], - [{ ...behavior, revision: 0 }], - [{ ...behavior, history: [{ revision: 0, implementation_id: "id" }] }], - [behavior, behavior], +test("source paths are canonical extensionless relative paths", () => { + for (const path of ["actors/counter", "a", "x/y_z/W-1"]) assert.ok(isSourcePath(path), path); + for (const path of [ + "", + "/actors/counter", + "../counter", + "actors/../counter", + "actors//counter", + "source/actors/counter", + "actors/counter.rn", + "actors/my.counter", + "actors\\counter", + "actors/", + "x".repeat(129), ]) - assert.throws(() => - decodeResponse(envelope({ kind: "behaviors", data }), "test", { operation: "get_behaviors" }), - ); + assert.ok(!isSourcePath(path), path); + assert.ok(isSourceRevision("abc123DEF")); + for (const revision of ["", "1.2", "a-b", "x".repeat(65)]) assert.ok(!isSourceRevision(revision)); }); -test("behavior source replies require text and the matching operation", () => { - const command: Command = { - operation: "get_behavior_source", - parameters: { - name: "counter", - revision: 18446744073709551615n, - }, +test("source listings and files validate paths, identities, and uniqueness", () => { + const file = { + path: "actors/counter", + source_id: "source:1", + source: "pub fn handle() {} // 雪\n", }; - const source = "pub fn handle(state, message) {\n (state, []) // 雪\n}\n"; + assert.deepEqual( + decodeResponse(envelope({ kind: "sources", data: [file] }), "test", { + operation: "list_sources", + }), + { + kind: "sources", + data: [file], + }, + ); + for (const data of [ + [{ ...file, path: "actors/counter.rn" }], + [{ ...file, source_id: "" }], + [{ ...file, source: 1 }], + [file, file], + ]) + assert.throws(() => + decodeResponse(envelope({ kind: "sources", data }), "test", { operation: "list_sources" }), + ); + const read: Command = { operation: "read_source", parameters: { path: file.path } }; + assert.deepEqual(decodeResponse(envelope({ kind: "source", data: file }), "test", read), { + kind: "source", + data: file, + }); + assert.throws(() => + decodeResponse( + envelope({ kind: "source", data: { ...file, path: "actors/other" } }), + "test", + read, + ), + ); + const write: Command = { + operation: "write_source", + parameters: { path: file.path, expected_source_id: null, source: file.source }, + }; + assert.equal( + decodeResponse(envelope({ kind: "source", data: file }), "test", write).kind, + "source", + ); + assert.throws(() => + decodeResponse(envelope({ kind: "source", data: { ...file, source: "other" } }), "test", write), + ); + const rollback: Command = { + operation: "rollback_source", + parameters: { path: file.path, revision: "abc" }, + }; + assert.equal( + decodeResponse(envelope({ kind: "source", data: file }), "test", rollback).kind, + "source", + ); + assert.throws(() => decodeResponse(envelope({ kind: "sources", data: [] }), "test", read)); +}); + +test("source commits and history carry readable revision strings", () => { + const commit = { revision: "k3x9q2", source_id: "source:1" }; + const path = { path: "actors/counter" }; + assert.deepEqual( + decodeResponse(envelope({ kind: "source_committed", data: commit }), "test", { + operation: "commit_source", + parameters: path, + }), + { kind: "source_committed", data: commit }, + ); + const history: Command = { operation: "source_history", parameters: path }; + assert.deepEqual( + decodeResponse(envelope({ kind: "source_history", data: [] }), "test", history), + { + kind: "source_history", + data: [], + }, + ); + for (const data of [ + [{ ...commit, revision: "a.b" }], + [{ ...commit, source_id: 1 }], + [commit, commit], + ]) + assert.throws(() => + decodeResponse(envelope({ kind: "source_history", data }), "test", history), + ); + assert.throws(() => + decodeResponse(envelope({ kind: "source_committed", data: commit }), "test", history), + ); +}); + +test("revision source replies require text and the matching operation", () => { + const command: Command = { + operation: "revision_source", + parameters: { path: "actors/counter", revision: "k3x9q2" }, + }; + const source = "pub fn handle(ctx, state, sender, message) {\n state // 雪\n}\n"; assert.deepEqual( decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", command), { kind: "behavior_source", data: { source } }, @@ -201,7 +286,7 @@ test("behavior source replies require text and the matching operation", () => { assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command)); assert.throws(() => decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", { - operation: "get_behaviors", + operation: "list_sources", }), ); }); diff --git a/vscode/test/sources.test.ts b/vscode/test/sources.test.ts new file mode 100644 index 0000000..46e9134 --- /dev/null +++ b/vscode/test/sources.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ProtocolError, SourceFile } from "../src/protocol"; +import { + parseSourceUriPath, + SourceChange, + SourceClient, + SourceFsError, + SourceStore, + sourceUriPath, +} from "../src/sources"; + +class FakeImage implements SourceClient { + files = new Map(); + writes: [string, string | null, string][] = []; + private next = 1; + + put(path: string, source: string): SourceFile { + const file = { path, source, source_id: `id${this.next++}` }; + this.files.set(path, file); + return file; + } + async listSources(): Promise { + return [...this.files.values()]; + } + async readSource(path: string): Promise { + const file = this.files.get(path); + if (!file) throw new ProtocolError("not_found", "unknown source"); + return file; + } + async writeSource(path: string, expected: string | null, source: string): Promise { + this.writes.push([path, expected, source]); + const current = this.files.get(path)?.source_id ?? null; + if (current !== expected) throw new ProtocolError("conflict", "source identity conflict"); + return this.put(path, source); + } +} + +function setup(now = () => 1000) { + const image = new FakeImage(); + const events: SourceChange[][] = []; + const store = new SourceStore((changes) => events.push(changes), now); + return { image, events, store }; +} + +const bytes = (text: string) => new TextEncoder().encode(text); + +function code(expected: string) { + return (error: unknown) => error instanceof SourceFsError && error.code === expected; +} + +test("source URI paths map to canonical image paths under the source root", () => { + assert.deepEqual(parseSourceUriPath("/"), { kind: "root" }); + assert.deepEqual(parseSourceUriPath("/source"), { kind: "directory", path: "" }); + assert.deepEqual(parseSourceUriPath("/source/actors"), { kind: "directory", path: "actors" }); + assert.deepEqual(parseSourceUriPath("/source/actors/counter.rn"), { + kind: "file", + path: "actors/counter", + }); + assert.equal(sourceUriPath("actors/counter"), "/source/actors/counter.rn"); + assert.throws(() => parseSourceUriPath("/other/x.rn"), code("FileNotFound")); + for (const invalid of [ + "/source/actors/my.counter.rn", + "/source/actors/counter.txt", + "/source/actors/../x.rn", + "/source/source/x.rn", + "/source/a b.rn", + ]) + assert.throws(() => parseSourceUriPath(invalid), code("NoPermissions"), invalid); + assert.throws(() => sourceUriPath("actors/counter.rn")); +}); + +test("lookups of names the image cannot hold report not found; writes report invalid names", async () => { + const { image, store } = setup(); + store.attach("127.0.0.1:1", 1, image); + await store.refresh(); + assert.throws(() => store.stat("/source/.vscode/settings.json"), code("FileNotFound")); + assert.throws(() => store.readDirectory("/source/.vscode"), code("FileNotFound")); + await assert.rejects(store.read("/source/.vscode/settings.json"), code("FileNotFound")); + await assert.rejects( + store.write("/source/.vscode/settings.json", bytes("{}"), { create: true, overwrite: true }), + code("NoPermissions"), + ); + assert.throws(() => store.createDirectory("/source/.vscode"), code("NoPermissions")); +}); + +test("disconnected stores show an empty root and reject file access", async () => { + const { store } = setup(); + assert.deepEqual(store.readDirectory("/source"), []); + assert.deepEqual(store.readDirectory("/"), [["source", "directory"]]); + assert.equal(store.stat("/source").type, "directory"); + assert.throws(() => store.stat("/source/actors/counter.rn"), code("Unavailable")); + await assert.rejects(store.read("/source/actors/counter.rn"), code("Unavailable")); +}); + +test("listing derives directories and reports created, changed, and deleted files", async () => { + let clock = 1000; + const { image, events, store } = setup(() => clock); + image.put("actors/counter", "a"); + store.attach("127.0.0.1:1", 1, image); + await store.refresh(); + assert.deepEqual(events.pop(), [ + { type: "created", uriPath: "/source/actors" }, + { type: "created", uriPath: "/source/actors/counter.rn" }, + { type: "changed", uriPath: "/source" }, + ]); + assert.deepEqual(store.readDirectory("/source"), [["actors", "directory"]]); + assert.deepEqual(store.readDirectory("/source/actors"), [["counter.rn", "file"]]); + assert.deepEqual(store.stat("/source/actors/counter.rn"), { + type: "file", + ctime: 0, + mtime: 1000, + size: 1, + }); + assert.throws(() => store.readDirectory("/source/missing"), code("FileNotFound")); + assert.throws(() => store.readDirectory("/source/actors/counter.rn"), code("FileNotADirectory")); + await store.refresh(); + assert.equal(events.length, 0, "unchanged listings emit nothing"); + image.put("actors/counter", "abc"); + image.put("lib/util", "u"); + await store.refresh(); + assert.deepEqual(events.pop(), [ + { type: "changed", uriPath: "/source/actors/counter.rn" }, + { type: "created", uriPath: "/source/lib" }, + { type: "created", uriPath: "/source/lib/util.rn" }, + ]); + // Changed identities always advance mtime, even if the clock does not. + assert.equal(store.stat("/source/actors/counter.rn").mtime, 1001); + assert.equal(store.stat("/source/actors/counter.rn").size, 3); + image.files.delete("lib/util"); + clock = 5000; + await store.refresh(); + assert.deepEqual(events.pop(), [{ type: "deleted", uriPath: "/source/lib/util.rn" }]); + assert.deepEqual(store.paths(), ["actors/counter"]); +}); + +test("saves compare-and-swap against the last observed source identity", async () => { + const { image, events, store } = setup(); + const original = image.put("actors/counter", "a"); + store.attach("127.0.0.1:1", 1, image); + await store.refresh(); + events.length = 0; + assert.equal(await store.read("/source/actors/counter.rn"), "a"); + await store.write("/source/actors/counter.rn", bytes("b 雪"), { create: true, overwrite: true }); + assert.deepEqual(image.writes.pop(), ["actors/counter", original.source_id, "b 雪"]); + assert.deepEqual(events.pop(), [{ type: "changed", uriPath: "/source/actors/counter.rn" }]); + const saved = store.sourceId("actors/counter"); + // Another client saves; this store has not observed it yet. + image.put("actors/counter", "other"); + await assert.rejects( + store.write("/source/actors/counter.rn", bytes("c"), { create: true, overwrite: true }), + (error: unknown) => + error instanceof SourceFsError && + error.code === "Unavailable" && + /changed in the image/.test(error.message), + ); + assert.deepEqual(image.writes.pop(), ["actors/counter", saved, "c"]); + assert.equal(image.files.get("actors/counter")?.source, "other"); + assert.equal(await store.read("/source/actors/counter.rn"), "other"); + await store.write("/source/actors/counter.rn", bytes("d"), { create: false, overwrite: true }); + assert.equal(image.files.get("actors/counter")?.source, "d"); +}); + +test("new files are created with a null expected identity; invalid writes are rejected", async () => { + const { image, events, store } = setup(); + store.attach("127.0.0.1:1", 1, image); + await store.refresh(); + events.length = 0; + store.createDirectory("/source/actors"); + assert.deepEqual(events.pop(), [{ type: "created", uriPath: "/source/actors" }]); + assert.deepEqual(store.readDirectory("/source/actors"), []); + assert.throws(() => store.createDirectory("/source/actors"), code("FileExists")); + await assert.rejects( + store.write("/source/actors/new.rn", bytes("x"), { create: false, overwrite: true }), + code("FileNotFound"), + ); + await store.write("/source/actors/new.rn", bytes("x"), { create: true, overwrite: false }); + assert.deepEqual(image.writes.pop(), ["actors/new", null, "x"]); + await assert.rejects( + store.write("/source/actors/new.rn", bytes("y"), { create: true, overwrite: false }), + code("FileExists"), + ); + await assert.rejects( + store.write("/source/actors/bad.rn", new Uint8Array([0xff]), { create: true, overwrite: true }), + code("NoPermissions"), + ); + await assert.rejects( + store.write("/source/notes.txt", bytes("x"), { create: true, overwrite: true }), + code("NoPermissions"), + ); + await assert.rejects(store.read("/source/actors/missing.rn"), code("FileNotFound")); + assert.throws(() => store.rejectMutation("Deleting"), code("NoPermissions")); + assert.equal(image.writes.length, 0); +}); + +test("results from a previous connection are not applied to a new session", async () => { + const { image, store } = setup(); + image.put("actors/counter", "a"); + let release = () => {}; + const slow: SourceClient = { + listSources: () => image.listSources(), + readSource: (path) => + new Promise((resolve) => { + release = () => resolve(image.readSource(path)); + }), + writeSource: (...args) => image.writeSource(...args), + }; + store.attach("127.0.0.1:1", 1, slow); + await store.refresh(); + const read = store.read("/source/actors/counter.rn"); + store.attach("127.0.0.1:1", 2, image); + release(); + await assert.rejects(read, code("Unavailable")); + assert.equal(store.sourceId("actors/counter"), undefined); + store.detach(); + assert.throws(() => store.stat("/source/actors/counter.rn"), code("Unavailable")); +}); + +test("a directory and Rune file can share a stem without sharing a filesystem path", async () => { + const { image, store } = setup(); + image.put("actors/counter", "root"); + store.attach("127.0.0.1:1", 1, image); + await store.refresh(); + store.createDirectory("/source/actors/counter"); + await store.write("/source/actors/counter/child.rn", bytes("child"), { + create: true, + overwrite: false, + }); + assert.deepEqual(store.readDirectory("/source/actors"), [ + ["counter", "directory"], + ["counter.rn", "file"], + ]); + store.createDirectory("/source/actors/other"); + await store.write("/source/actors/other.rn", bytes("other"), { create: true, overwrite: false }); + assert.equal(await store.read("/source/actors/other.rn"), "other"); +}); + +test("apply records rollback results and preserves mtimes across reconnects to one image", async () => { + let clock = 10; + const { image, events, store } = setup(() => clock); + image.put("actors/counter", "a"); + store.attach("127.0.0.1:1", 1, image); + await store.refresh(); + const mtime = store.stat("/source/actors/counter.rn").mtime; + clock = 20; + store.attach("127.0.0.1:1", 2, image); + await store.refresh(); + assert.equal(store.stat("/source/actors/counter.rn").mtime, mtime); + events.length = 0; + store.apply({ path: "actors/counter", source_id: "rolled", source: "old" }); + assert.deepEqual(events.pop(), [{ type: "changed", uriPath: "/source/actors/counter.rn" }]); + assert.equal(store.sourceId("actors/counter"), "rolled"); + assert.equal(store.stat("/source/actors/counter.rn").mtime, 20); +});