feat!: add image source files and system messages

Separate editable image sources from generated commit revisions and loaded actor implementations. Add the source filesystem and context actions in VS Code, path-based REPL commands, sender-aware actor contexts, and privileged lifecycle controls.

BREAKING CHANGE: protocol v4 replaces published behavior commands with image source paths and commit operations. Actor handlers now use handle(ctx, state, sender, message) and return only state; outgoing messages use ctx.send or ctx.send_from.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 17:01:24 +01:00
parent 39b7443c79
commit efef8a93b9
46 changed files with 3982 additions and 967 deletions

View File

@ -6,6 +6,15 @@ All notable changes to this project will be documented in this file.
### Added ### 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, - VS Code behavior explorer with revision history, implementation identities,
and read-only Rune source documents isolated by connection and revision. and read-only Rune source documents isolated by connection and revision.
Image notifications refresh behavior listings without changing historical 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 ### 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 <path>` 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 - 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. 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 - Clap derive/env argument parsing for both executables, including generated

1
Cargo.lock generated
View File

@ -865,6 +865,7 @@ name = "runa-code"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"blake3", "blake3",
"nanoid",
"runa-core", "runa-core",
"runa-rune", "runa-rune",
"rune", "rune",

View File

@ -17,9 +17,9 @@ addresses with an in-memory compile cache. Persistence, canonicalization,
remote network access, and the graphical world are not implemented yet. 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 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 inspect state, queued/reserved messages, and execution telemetry in read-only
documents. The code library now supports an in-memory versioned behavior documents. The image owns mutable, directory-based Rune source files with
registry, and the CLI exposes publishing, history, rollback, safe-boundary independent commit history. The CLI exposes source writes, commit/history/rollback,
installation, pause/resume, and cancellation through a trusted in-process image 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 API. A versioned transport-neutral protocol contract is now defined, but no
authentication is implemented. The `runa` server provides an unauthenticated authentication is implemented. The `runa` server provides an unauthenticated
WebSocket listener restricted to localhost, with bounded request sizes. 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, 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 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 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 Reconnect and use `actors` to recover their references. REPL bindings and
command history are session-local and are not restored on reconnect. 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 the image regardless of how clients connect. Remote network access requires
authentication and authorization, which are not implemented. authentication and authorization, which are not implemented.
The WebSocket server publishes the bundled `counter` behavior once at startup; The server loads the bundled `source/actors/counter.rn` working file at startup.
connecting a client does not publish or reset behavior revisions: Connecting a client does not modify source or create a commit:
```text ```text
runa> let sender = spawn counter 0 runa> let sender = spawn actors/counter 0
sender = @<actor-reference> sender = @<actor-reference>
runa> let receiver = spawn counter 10 runa> let receiver = spawn actors/counter 10
receiver = @<actor-reference> receiver = @<actor-reference>
runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}} runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}}
message queued 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 `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 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 message to the receiver, which commits state `15`. The REPL provides Tab
completion and Up/Down command history. It also supports publishing Rune source completion and Up/Down command history. `write actors/counter <local-file>`
files as named, versioned behaviors, viewing history, rolling back a behavior updates the image's working source. `commit actors/counter` records a snapshot
reference, installing the current version on an idle actor, and cooperatively with an automatically generated readable revision ID; `history actors/counter`
pausing and resuming or cancelling actor invocations between scheduler ticks. lists commits and `rollback actors/counter <revision>` restores a working file.
Runtime users can also supply an explicit state migration closure during Committing is version control only: spawn always compiles the current image
installation; the CLI's install command preserves state as-is. Use `help` in file, including uncommitted changes. Invalid working code fails spawn without
creating an actor; commits can preserve invalid work in progress.
`reload <actor>` loads current source at its existing path, and
`become <actor> <path>` 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. the session for the complete command list.
State and messages support null, booleans, signed 64-bit integers, strings, State and messages support null, booleans, signed 64-bit integers, strings,
lists, records, and distinct actor-reference values. `spawn <behavior> <json>` lists, records, and distinct actor-reference values. `spawn <path> <json>`
sets initial state; `send <actor> <json>` sends any supported value. JSON sets initial state; `send <actor> <json>` sends any supported value. JSON
objects of the form `{"$actor": "name-or-@reference"}` embed an actor reference, 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) 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 for Rune conversions and value limits. Protocol version 4 uses
four-argument Rune handlers and kind/value/target message envelopes must be `handle(ctx, state, sender, message) -> new_state`; `ctx.send(recipient, message)`
updated to the two-argument handler contract. 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 See [the feasibility report](docs/rune-feasibility.md) for verified Rune
capabilities and limitations, and [the runtime guide](docs/runtime.md) for 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; it. Server notifications automatically refresh the tree and open snapshots;
**Runa: Refresh Image** is also available on demand. **Runa: Refresh Image** is also available on demand.
The **Runa Behaviors** view shows published behaviors and their revision The extension mounts a **source** workspace directory backed by the image,
history. Click a behavior for its current source, or expand it to open an older including `actors/counter.rn`. Save edits to update the working file without
revision. **Runa: Open Behavior Source** also offers a revision picker. committing. Right-click a source file to **Spawn** (null state) or **Spawn with
Source documents are read-only and remain unchanged when new revisions appear. 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. Use **Runa: Spawn Actor** to choose a source path and supply JSON initial state.
Actor context menus offer structured message sending and pause/resume/stop/restart. Actor context menus offer structured messages and system lifecycle commands.
**Runa: Advance Scheduler** explicitly executes ticks; sending alone only queues **Runa: Advance Scheduler** explicitly executes ticks; sending alone only queues
a message. The extension does not start or shut down the image. Disconnecting a message. The extension does not start or shut down the image. Disconnecting
leaves actors in the server. Behavior editing and detailed runtime event leaves actors in the server. Detailed runtime event subscriptions and structured
subscriptions are not implemented yet. See the [extension guide](vscode/README.md) compilation diagnostics are not implemented yet. See the [extension guide](vscode/README.md)
for connection restrictions, snapshot semantics, and tests. for connection restrictions, snapshot semantics, and tests.
## Development ## Development

View File

@ -8,6 +8,24 @@ pub struct MailboxId(pub u64);
pub type Message = Value; pub type Message = Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Envelope {
pub sender: Option<ActorRef>,
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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status { pub enum Status {
Idle, Idle,

View File

@ -9,7 +9,7 @@ use std::num::NonZeroUsize;
use runa::Server; use runa::Server;
use runa_client::WebSocketClient; use runa_client::WebSocketClient;
use runa_core::ActorRef; use runa_core::ActorRef;
use runa_protocol::{Command, Reply, Value}; use runa_protocol::{Command, Reply, SystemMessageDto, Value};
use rustyline::completion::{Completer, Pair}; use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError; use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter; use rustyline::highlight::Highlighter;
@ -21,7 +21,7 @@ use rustyline::{Context, Editor, Helper};
const MAILBOX_CAPACITY: usize = 64; const MAILBOX_CAPACITY: usize = 64;
const DEFAULT_TICK_BUDGET: usize = 32; const DEFAULT_TICK_BUDGET: usize = 32;
#[cfg(test)] #[cfg(test)]
const COUNTER_BEHAVIOR: &str = "counter"; const COUNTER_BEHAVIOR: &str = "actors/counter";
trait Connection { trait Connection {
fn dispatch(&mut self, command: Command) -> Result<Reply, String>; fn dispatch(&mut self, command: Command) -> Result<Reply, String>;
@ -74,26 +74,9 @@ impl ReplHelper {
fn candidates(&self, line: &str, position: usize) -> Vec<String> { fn candidates(&self, line: &str, position: usize) -> Vec<String> {
const COMMANDS: &[&str] = &[ const COMMANDS: &[&str] = &[
"actors", "actors", "sources", "cancel", "events", "exit", "help", "history", "inspect",
"behaviors", "become", "kill", "reload", "write", "let", "pause", "commit", "quit", "restart",
"cancel", "resume", "rollback", "send", "spawn", "stop", "tick",
"events",
"exit",
"help",
"history",
"inspect",
"install",
"let",
"pause",
"publish",
"quit",
"restart",
"resume",
"rollback",
"send",
"spawn",
"stop",
"tick",
]; ];
let (start, reference_value) = Self::completion_start(line, position); let (start, reference_value) = Self::completion_start(line, position);
if reference_value { if reference_value {
@ -119,8 +102,12 @@ impl ReplHelper {
| ["pause"] | ["pause"]
| ["resume"] | ["resume"]
| ["cancel"] | ["cancel"]
| ["install"] => self.actor_candidates(), | ["kill"]
["install", _] | ["history"] | ["rollback"] | ["publish"] => self.behaviors.clone(), | ["reload"]
| ["become"] => self.actor_candidates(),
["become", _] | ["history"] | ["rollback"] | ["commit"] | ["write"] => {
self.behaviors.clone()
}
_ => Vec::new(), _ => Vec::new(),
}; };
let prefix = &line[start..position]; let prefix = &line[start..position];
@ -240,10 +227,10 @@ fn run_interactive_repl(server: &mut dyn Connection) -> Result<(), Box<dyn std::
let mut state = ReplState::default(); let mut state = ReplState::default();
let mut output = io::stdout().lock(); let mut output = io::stdout().lock();
loop { loop {
let behaviors = match dispatch(server, Command::GetBehaviors)? { let behaviors = match dispatch(server, Command::ListSources)? {
Reply::Behaviors(behaviors) => behaviors Reply::Sources(behaviors) => behaviors
.into_iter() .into_iter()
.map(|behavior| behavior.name) .map(|behavior| behavior.path)
.collect(), .collect(),
other => return Err(format!("unexpected protocol response: {other:?}").into()), other => return Err(format!("unexpected protocol response: {other:?}").into()),
}; };
@ -306,25 +293,22 @@ fn execute_line<W: Write>(
let result = match command { let result = match command {
"help" => print_help(output), "help" => print_help(output),
"spawn" => spawn_command(output, line, server, state), "spawn" => spawn_command(output, line, server, state),
"behaviors" if args.is_empty() => match dispatch(server, Command::GetBehaviors) { "sources" if args.is_empty() => match dispatch(server, Command::ListSources) {
Ok(Reply::Behaviors(behaviors)) => { Ok(Reply::Sources(files)) => {
for behavior in behaviors { for file in files {
writeln!( writeln!(output, "source/{}.rn ({})", file.path, file.source_id)?;
output,
"{} revision {} ({})",
behavior.name, behavior.revision, behavior.current_implementation_id
)?;
} }
Ok(()) Ok(())
} }
Ok(other) => unexpected_reply(output, other), Ok(other) => unexpected_reply(output, other),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
}, },
"behaviors" => writeln!(output, "error: usage: behaviors"), "sources" => writeln!(output, "error: usage: sources"),
"publish" => publish_command(output, args, server), "commit" => commit_command(output, args, server),
"write" => write_command(output, args, server),
"history" => history_command(output, args, server), "history" => history_command(output, args, server),
"rollback" => rollback_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) { "actors" if args.is_empty() => match dispatch(server, Command::ListActors) {
Ok(Reply::Actors(actors)) => { Ok(Reply::Actors(actors)) => {
for actor in actors { for actor in actors {
@ -383,17 +367,29 @@ fn execute_line<W: Write>(
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
}, },
"events" => writeln!(output, "error: usage: events"), "events" => writeln!(output, "error: usage: events"),
"stop" => lifecycle_command(output, args, server, state, |actor_ref| { "stop" => lifecycle_command(output, args, server, state, |actor_ref| Command::System {
Command::StopActor { actor_ref } actor_ref,
message: SystemMessageDto::Stop,
}), }),
"restart" => lifecycle_command(output, args, server, state, |actor_ref| { "kill" => lifecycle_command(output, args, server, state, |actor_ref| Command::System {
Command::RestartActor { actor_ref } actor_ref,
message: SystemMessageDto::Kill,
}), }),
"pause" => lifecycle_command(output, args, server, state, |actor_ref| { "reload" => lifecycle_command(output, args, server, state, |actor_ref| Command::System {
Command::PauseActor { actor_ref } actor_ref,
message: SystemMessageDto::Reload,
}), }),
"resume" => lifecycle_command(output, args, server, state, |actor_ref| { "restart" => lifecycle_command(output, args, server, state, |actor_ref| Command::System {
Command::ResumeActor { actor_ref } 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| { "cancel" => lifecycle_command(output, args, server, state, |actor_ref| {
Command::CancelActor { actor_ref } Command::CancelActor { actor_ref }
@ -414,11 +410,24 @@ fn lifecycle_command<W: Write>(
operation: impl FnOnce(String) -> Command, operation: impl FnOnce(String) -> Command,
) -> io::Result<()> { ) -> io::Result<()> {
match parse_actor_argument(args, state) { match parse_actor_argument(args, state) {
Ok(actor_ref) => match dispatch(server, operation(actor_ref)) { Ok(actor_ref) => {
Ok(Reply::Acknowledged) => writeln!(output, "ok"), let command = operation(actor_ref);
Ok(other) => unexpected_reply(output, other), let scheduled = matches!(
Err(error) => writeln!(output, "error: {error}"), &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}"), Err(error) => writeln!(output, "error: {error}"),
} }
} }
@ -431,16 +440,10 @@ fn spawn_command<W: Write>(
) -> io::Result<()> { ) -> io::Result<()> {
let args: Vec<_> = line.split_whitespace().collect(); let args: Vec<_> = line.split_whitespace().collect();
let [_, behavior_name, initial_state @ ..] = args.as_slice() else { let [_, behavior_name, initial_state @ ..] = args.as_slice() else {
return writeln!( return writeln!(output, "error: usage: spawn <path> <initial-state>");
output,
"error: usage: spawn <behavior-name> <initial-state>"
);
}; };
if initial_state.is_empty() { if initial_state.is_empty() {
return writeln!( return writeln!(output, "error: usage: spawn <path> <initial-state>");
output,
"error: usage: spawn <behavior-name> <initial-state>"
);
} }
match spawn_actor(behavior_name, after_words(line, 2), server, state) { match spawn_actor(behavior_name, after_words(line, 2), server, state) {
Ok(actor_ref) => writeln!(output, "spawned @{actor_ref} from `{behavior_name}`"), 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()) && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
} }
fn publish_command<W: Write>( fn write_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
server: &mut dyn Connection, server: &mut dyn Connection,
) -> io::Result<()> { ) -> io::Result<()> {
let [name, expected_revision, source_path] = args else { let [path, source_path] = args else {
return writeln!( return writeln!(output, "error: usage: write <path> <local-source-file>");
output,
"error: usage: publish <behavior-name> <new|expected-revision> <source-file>"
);
}; };
let expected_revision = if *expected_revision == "new" { let expected_source_id = match dispatch(server, Command::ListSources) {
None Ok(Reply::Sources(files)) => files
} else { .into_iter()
match expected_revision.parse::<u64>() { .find(|file| file.path == *path)
Ok(revision) => Some(revision), .map(|file| file.source_id),
Err(_) => return writeln!(output, "error: expected `new` or a numeric revision"), Ok(other) => return unexpected_reply(output, other),
} Err(error) => return writeln!(output, "error: {error}"),
}; };
let source = match std::fs::read_to_string(source_path) { let source = match std::fs::read_to_string(source_path) {
Ok(source) => source, Ok(source) => source,
@ -520,23 +520,42 @@ fn publish_command<W: Write>(
return writeln!(output, "error: could not read `{source_path}`: {error}"); return writeln!(output, "error: could not read `{source_path}`: {error}");
} }
}; };
let behavior = match dispatch( match dispatch(
server, server,
Command::PublishBehavior { Command::WriteSource {
name: (*name).to_owned(), path: (*path).to_owned(),
expected_revision, expected_source_id,
source, source,
}, },
) { ) {
Ok(Reply::Behavior(behavior)) => behavior, Ok(Reply::Source(file)) => {
Ok(other) => return unexpected_reply(output, other), writeln!(output, "saved source/{}.rn ({})", file.path, file.source_id)
Err(error) => return writeln!(output, "error: {error}"), }
Ok(other) => unexpected_reply(output, other),
Err(error) => writeln!(output, "error: {error}"),
}
}
fn commit_command<W: Write>(
output: &mut W,
args: &[&str],
server: &mut dyn Connection,
) -> io::Result<()> {
let [path] = args else {
return writeln!(output, "error: usage: commit <path>");
}; };
writeln!( match dispatch(
output, server,
"published `{}` revision {} ({})", Command::CommitSource {
behavior.name, behavior.revision, behavior.current_implementation_id 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<W: Write>( fn history_command<W: Write>(
@ -544,29 +563,24 @@ fn history_command<W: Write>(
args: &[&str], args: &[&str],
server: &mut dyn Connection, server: &mut dyn Connection,
) -> io::Result<()> { ) -> io::Result<()> {
let [name] = args else { let [path] = args else {
return writeln!(output, "error: usage: history <behavior-name>"); return writeln!(output, "error: usage: history <path>");
}; };
let behavior = match dispatch( let history = match dispatch(
server, server,
Command::GetBehavior { Command::SourceHistory {
name: (*name).to_owned(), path: (*path).to_owned(),
}, },
) { ) {
Ok(Reply::Behavior(behavior)) => behavior, Ok(Reply::SourceHistory(history)) => history,
Ok(other) => return unexpected_reply(output, other), Ok(other) => return unexpected_reply(output, other),
Err(error) => return writeln!(output, "error: {error}"), Err(error) => return writeln!(output, "error: {error}"),
}; };
for version in behavior.history { for version in history {
let current = if version.revision == behavior.revision {
" (current)"
} else {
""
};
writeln!( writeln!(
output, output,
"revision {}: {}{}", "revision {}: {}",
version.revision, version.implementation_id, current version.revision, version.source_id
)?; )?;
} }
Ok(()) Ok(())
@ -577,78 +591,49 @@ fn rollback_command<W: Write>(
args: &[&str], args: &[&str],
server: &mut dyn Connection, server: &mut dyn Connection,
) -> io::Result<()> { ) -> io::Result<()> {
let [name, expected_revision, target_revision] = args else { let [path, revision] = args else {
return writeln!( return writeln!(output, "error: usage: rollback <path> <revision>");
output,
"error: usage: rollback <behavior-name> <expected-revision> <target-revision>"
);
};
let expected_revision = match expected_revision.parse::<u64>() {
Ok(revision) => revision,
Err(_) => return writeln!(output, "error: invalid expected revision"),
};
let target_revision = match target_revision.parse::<u64>() {
Ok(revision) => revision,
Err(_) => return writeln!(output, "error: invalid target revision"),
}; };
match dispatch( match dispatch(
server, server,
Command::RollbackBehavior { Command::RollbackSource {
name: (*name).to_owned(), path: (*path).to_owned(),
expected_revision, revision: (*revision).to_owned(),
target_revision,
}, },
) { ) {
Ok(Reply::Behavior(behavior)) => writeln!( Ok(Reply::Source(file)) => writeln!(
output, output,
"rolled back `{}` to implementation {} as revision {}", "restored `{}` from revision {revision} ({})",
behavior.name, behavior.current_implementation_id, behavior.revision file.path, file.source_id
), ),
Ok(other) => unexpected_reply(output, other), Ok(other) => unexpected_reply(output, other),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
} }
} }
fn install_command<W: Write>( fn become_command<W: Write>(
output: &mut W, output: &mut W,
args: &[&str], args: &[&str],
server: &mut dyn Connection, server: &mut dyn Connection,
state: &ReplState, state: &ReplState,
) -> io::Result<()> { ) -> io::Result<()> {
let [actor_arg, name] = args else { let [actor_arg, name] = args else {
return writeln!( return writeln!(output, "error: usage: become <actor-ref-or-name> <path>");
output,
"error: usage: install <actor-ref-or-name> <behavior-name>"
);
}; };
let actor_ref = match parse_actor_argument(&[*actor_arg], state) { let actor_ref = match parse_actor_argument(&[*actor_arg], state) {
Ok(actor_ref) => actor_ref, Ok(actor_ref) => actor_ref,
Err(error) => return writeln!(output, "error: {error}"), 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( match dispatch(
server, server,
Command::GetBehavior { Command::System {
name: (*name).to_owned(), actor_ref: actor_ref.clone(),
message: SystemMessageDto::Become {
path: (*name).to_owned(),
},
}, },
) { ) {
Ok(Reply::Behavior(behavior)) => { Ok(Reply::Acknowledged) => writeln!(output, "scheduled become `{name}` on @{actor_ref}"),
writeln!(
output,
"installed `{}` revision {} on actor @{} ({implementation})",
behavior.name, behavior.revision, actor_ref
)
}
Ok(other) => unexpected_reply(output, other), Ok(other) => unexpected_reply(output, other),
Err(error) => writeln!(output, "error: {error}"), Err(error) => writeln!(output, "error: {error}"),
} }
@ -770,13 +755,15 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
writeln!( writeln!(
output, output,
"Commands:\n\ "Commands:\n\
let <name> = spawn <behavior-name> <initial-state>\n\ let <name> = spawn <path> <initial-state>\n\
spawn <behavior-name> <initial-state>\n\ spawn <path> <initial-state>\n\
behaviors\n\ sources\n\
publish <behavior-name> <new|expected-revision> <source-file>\n\ write <path> <local-source-file>\n\
history <behavior-name>\n\ commit <path>\n\
rollback <behavior-name> <expected-revision> <target-revision>\n\ history <path>\n\
install <actor-ref-or-name> <behavior-name>\n\ rollback <path> <revision>\n\
become <actor-ref-or-name> <path>\n\
Paths exclude source/ and .rn, for example actors/counter.\n\
send <actor-ref-or-name> <message-json>\n\ send <actor-ref-or-name> <message-json>\n\
State/messages: JSON values; {{\"$actor\":\"name-or-@ref\"}} embeds an actor reference.\n\ State/messages: JSON values; {{\"$actor\":\"name-or-@ref\"}} embeds an actor reference.\n\
tick <count> [instruction-budget]\n\ tick <count> [instruction-budget]\n\
@ -784,6 +771,8 @@ fn print_help<W: Write>(output: &mut W) -> io::Result<()> {
actors\n\ actors\n\
events\n\ events\n\
stop <actor-ref-or-name>\n\ stop <actor-ref-or-name>\n\
kill <actor-ref-or-name>\n\
reload <actor-ref-or-name>\n\
restart <actor-ref-or-name>\n\ restart <actor-ref-or-name>\n\
pause <actor-ref-or-name>\n\ pause <actor-ref-or-name>\n\
resume <actor-ref-or-name>\n\ resume <actor-ref-or-name>\n\
@ -847,7 +836,7 @@ mod tests {
Command::PublishBehavior { Command::PublishBehavior {
name: "echo".to_owned(), name: "echo".to_owned(),
expected_revision: None, 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(); let mut state = ReplState::default();
@ -957,7 +946,7 @@ mod tests {
let mut output = Vec::new(); let mut output = Vec::new();
run_repl( run_repl(
io::Cursor::new( 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 output,
&mut client, &mut client,
@ -1000,9 +989,9 @@ mod tests {
let mut server = Server::new(); let mut server = Server::new();
dispatch( dispatch(
&mut server, &mut server,
Command::PublishBehavior { Command::WriteSource {
name: COUNTER_BEHAVIOR.to_owned(), path: COUNTER_BEHAVIOR.to_owned(),
expected_revision: None, expected_source_id: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), 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<dyn std::error::Error>> { fn repl_drives_actor_messages_and_inspection() -> Result<(), Box<dyn std::error::Error>> {
let mut server = server(); let mut server = server();
let input = io::Cursor::new( 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(); let mut output = Vec::new();
run_repl(input, &mut output, &mut server)?; run_repl(input, &mut output, &mut server)?;
@ -1042,12 +1031,12 @@ mod tests {
fn repl_reports_bad_input_and_continues() -> Result<(), Box<dyn std::error::Error>> { fn repl_reports_bad_input_and_continues() -> Result<(), Box<dyn std::error::Error>> {
let mut server = server(); let mut server = server();
let input = io::Cursor::new( 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(); let mut output = Vec::new();
run_repl(input, &mut output, &mut server)?; run_repl(input, &mut output, &mut server)?;
let output = String::from_utf8(output)?; let output = String::from_utf8(output)?;
assert!(output.contains("usage: spawn <behavior-name> <initial-state>")); assert!(output.contains("usage: spawn <path> <initial-state>"));
assert!(output.contains("invalid initial state")); assert!(output.contains("invalid initial state"));
assert!(output.contains("unknown actor")); assert!(output.contains("unknown actor"));
assert!(output.contains("tick budget must be positive")); assert!(output.contains("tick budget must be positive"));
@ -1056,38 +1045,42 @@ mod tests {
} }
#[test] #[test]
fn repl_manages_named_behavior_revisions_and_installs_them() fn repl_manages_source_commits_and_system_messages() -> Result<(), Box<dyn std::error::Error>> {
-> Result<(), Box<dyn std::error::Error>> {
let mut server = server(); let mut server = server();
let source_path = concat!( let source_path = concat!(
env!("CARGO_MANIFEST_DIR"), env!("CARGO_MANIFEST_DIR"),
"/../../runa-std/actors/counter.rn" "/../../runa-std/actors/counter.rn"
); );
let input = io::Cursor::new(format!( 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(); let mut output = Vec::new();
run_repl(input, &mut output, &mut server)?; run_repl(input, &mut output, &mut server)?;
let output = String::from_utf8(output)?; let output = String::from_utf8(output)?;
assert!(output.contains("published `counter` revision 2")); assert!(!output.contains("error:"), "{output}");
assert!(output.contains("revision 1:")); assert!(output.contains("committed `actors/counter` revision "));
assert!(output.contains("revision 2:"));
assert!(output.contains("rolled back `counter`"));
assert!(output.contains("actor = @")); 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("Paused {"));
assert!(output.contains("Resumed {")); assert!(output.contains("Resumed {"));
assert!(output.contains("InvocationCancelled {")); assert!(output.contains("InvocationCancelled {"));
let behavior = match dispatch( let history = match dispatch(
&mut server, &mut server,
Command::GetBehavior { Command::SourceHistory {
name: COUNTER_BEHAVIOR.to_owned(), path: COUNTER_BEHAVIOR.to_owned(),
}, },
)? { )? {
Reply::Behavior(behavior) => behavior, Reply::SourceHistory(history) => history,
other => return Err(format!("unexpected response: {other:?}").into()), 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)? { let actor_ref = match dispatch(&mut server, Command::ListActors)? {
Reply::Actors(actors) => actors[0].actor_ref.clone(), Reply::Actors(actors) => actors[0].actor_ref.clone(),
other => return Err(format!("unexpected response: {other:?}").into()), other => return Err(format!("unexpected response: {other:?}").into()),
@ -1096,7 +1089,7 @@ mod tests {
Reply::Actor(actor) => actor, Reply::Actor(actor) => actor,
other => return Err(format!("unexpected response: {other:?}").into()), 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(()) Ok(())
} }
@ -1105,10 +1098,13 @@ mod tests {
let mut helper = ReplHelper::default(); let mut helper = ReplHelper::default();
let mut bindings = BTreeMap::new(); let mut bindings = BTreeMap::new();
bindings.insert("sender".to_owned(), "xK8_pQ2-Lm5_012345678".to_owned()); 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("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"]); assert_eq!(helper.candidates("inspect se", 10), vec!["sender"]);
let unicode_whitespace = "inspect\u{2003}se"; let unicode_whitespace = "inspect\u{2003}se";
assert_eq!( assert_eq!(

View File

@ -294,10 +294,10 @@ mod tests {
for message in [ for message in [
serde_json::json!({"version": 2, "request_id": "runa-client-1", serde_json::json!({"version": 2, "request_id": "runa-client-1",
"result": {"Ok": {"kind": "actors", "data": []}}}), "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": []}}}), "result": {"Ok": {"kind": "actors", "data": []}}}),
serde_json::json!({"version": 3, "event": {"event": "image_changed", "data": {"revision": 0}}}), serde_json::json!({"version": 4, "event": {"event": "image_changed", "data": {"revision": 0}}}),
serde_json::json!({"version": 3, "event": {"event": "unknown"}}), serde_json::json!({"version": 4, "event": {"event": "unknown"}}),
] { ] {
let (address, peer) = websocket_peer(move |mut socket, _| { let (address, peer) = websocket_peer(move |mut socket, _| {
socket socket

View File

@ -5,6 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
blake3 = "1.8.7" blake3 = "1.8.7"
nanoid = "0.4"
runa-core = { path = "../runa-core" } runa-core = { path = "../runa-core" }
runa-rune = { path = "../runa-rune" } runa-rune = { path = "../runa-rune" }
rune = "=0.14.2" rune = "=0.14.2"

View File

@ -9,6 +9,10 @@ use rune::Module;
const SOURCE_DOMAIN: &[u8] = b"runa-source:v1\0"; const SOURCE_DOMAIN: &[u8] = b"runa-source:v1\0";
const IMPLEMENTATION_DOMAIN: &[u8] = b"runa-implementation: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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileFingerprint { pub struct CompileFingerprint {
@ -25,7 +29,7 @@ impl CompileFingerprint {
rune_version: runa_rune::RUNE_VERSION.to_owned(), rune_version: runa_rune::RUNE_VERSION.to_owned(),
compiler_settings: "default;features:default".to_owned(), compiler_settings: "default;features:default".to_owned(),
native_modules: runa_rune::COMPILER_CONFIGURATION_ID.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(), target: env!("RUNA_BUILD_TARGET").to_owned(),
} }
} }
@ -80,11 +84,28 @@ pub struct BehaviorSnapshot {
pub history: Vec<BehaviorVersion>, pub history: Vec<BehaviorVersion>,
} }
#[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)] #[derive(Default)]
pub struct CodeStore { pub struct CodeStore {
sources: BTreeMap<SourceId, Arc<str>>, sources: BTreeMap<SourceId, Arc<str>>,
artifacts: BTreeMap<ImplementationId, Arc<CompiledArtifact>>, artifacts: BTreeMap<ImplementationId, Arc<CompiledArtifact>>,
behaviors: BTreeMap<String, Vec<BehaviorVersion>>, behaviors: BTreeMap<String, Vec<BehaviorVersion>>,
working_sources: BTreeMap<String, SourceFile>,
source_commits: BTreeMap<String, Vec<SourceCommit>>,
} }
impl CodeStore { impl CodeStore {
@ -155,6 +176,96 @@ impl CodeStore {
self.artifacts.len() 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<SourceId>,
source: &str,
) -> ExperimentResult<SourceFile> {
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<SourceFile> {
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<SourceCommit> {
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<SourceFile> {
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. /// Compile and publish only if the caller's expected revision is current.
/// ///
/// A new behavior requires `expected_revision == None`; updating an /// A new behavior requires `expected_revision == None`; updating an
@ -255,6 +366,42 @@ fn validate_behavior_name(name: &str) -> ExperimentResult<()> {
Ok(()) 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<String, Vec<SourceCommit>>,
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 { fn snapshot(name: &str, history: &[BehaviorVersion]) -> BehaviorSnapshot {
let current = history.last().expect("published behavior has history"); let current = history.last().expect("published behavior has history");
BehaviorSnapshot { 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());
}
} }

View File

@ -1,16 +1,20 @@
//! Trusted, in-process application services for a running Runa image. //! Trusted, in-process application services for a running Runa image.
use std::collections::BTreeMap;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::sync::Arc; use std::sync::Arc;
use runa_actor::{ActorRef, ActorSnapshot, Event, Message}; use runa_actor::{ActorRef, ActorSnapshot, Event, Message, SystemMessage};
use runa_code::{BehaviorSnapshot, CodeStore}; use runa_code::{BehaviorSnapshot, CodeStore, SourceCommit, SourceFile};
use runa_core::{ImplementationId, Value}; use runa_core::{ImplementationId, Value};
use runa_runtime::{Runtime, RuntimeResult}; use runa_runtime::{Runtime, RuntimeResult};
pub struct ImageApi { pub struct ImageApi {
code: CodeStore, code: CodeStore,
runtime: Runtime, runtime: Runtime,
actor_paths: BTreeMap<ActorRef, String>,
pending_paths: BTreeMap<ActorRef, (String, ImplementationId)>,
events: Vec<Event>,
} }
impl Default for ImageApi { impl Default for ImageApi {
@ -24,6 +28,9 @@ impl ImageApi {
Self { Self {
code: CodeStore::default(), code: CodeStore::default(),
runtime: Runtime::default(), runtime: Runtime::default(),
actor_paths: BTreeMap::new(),
pending_paths: BTreeMap::new(),
events: Vec::new(),
} }
} }
@ -35,7 +42,14 @@ impl ImageApi {
) -> RuntimeResult<BehaviorSnapshot> { ) -> RuntimeResult<BehaviorSnapshot> {
let artifact = self.code.compile(source)?; let artifact = self.code.compile(source)?;
self.runtime.publish(Arc::clone(&artifact))?; 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( pub fn rollback_behavior(
@ -69,17 +83,95 @@ impl ImageApi {
.ok_or_else(|| "behavior artifact is unavailable".into()) .ok_or_else(|| "behavior artifact is unavailable".into())
} }
pub fn write_source(
&mut self,
path: &str,
expected_source_id: Option<runa_core::SourceId>,
source: &str,
) -> RuntimeResult<SourceFile> {
self.code.write_source(path, expected_source_id, source)
}
pub fn sources(&self) -> Vec<SourceFile> {
self.code.sources()
}
pub fn source(&self, path: &str) -> Option<&SourceFile> {
self.code.source(path)
}
pub fn commit_source(&mut self, path: &str) -> RuntimeResult<SourceCommit> {
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<SourceFile> {
self.code.rollback_source(path, revision)
}
pub fn spawn( pub fn spawn(
&mut self, &mut self,
behavior_name: &str, path: &str,
state: Value, state: Value,
capacity: NonZeroUsize, capacity: NonZeroUsize,
) -> RuntimeResult<ActorRef> { ) -> RuntimeResult<ActorRef> {
let behavior = self let source = self
.code .code
.behavior(behavior_name) .source(path)
.ok_or("unknown behavior")?; .ok_or("unknown source path")?
self.runtime.spawn(behavior.current, state, capacity) .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<ImplementationId> {
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<ImplementationId> {
self.schedule_source_install(actor, path)
}
fn schedule_source_install(
&mut self,
actor: ActorRef,
path: &str,
) -> RuntimeResult<ImplementationId> {
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<()> { 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) { pub fn tick(&mut self, ticks: usize, budget: NonZeroUsize) {
for _ in 0..ticks { for _ in 0..ticks {
self.runtime.tick(budget); self.runtime.tick(budget);
self.collect_runtime_events();
} }
} }
@ -101,23 +194,51 @@ impl ImageApi {
} }
pub fn events(&mut self) -> Vec<Event> { pub fn events(&mut self) -> Vec<Event> {
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<()> { 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<()> { 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<()> { 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<()> { 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<()> { pub fn cancel(&mut self, actor: ActorRef) -> RuntimeResult<()> {
@ -173,11 +294,11 @@ mod tests {
let behavior = image.publish_behavior( let behavior = image.publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, message) { (state + message.value, []) }", "pub fn handle(ctx, state, sender, message) { state + message.value }",
)?; )?;
assert_eq!( assert_eq!(
image.behavior_source("counter", behavior.revision)?, 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))?; let actor = image.spawn("counter", Value::Integer(5), capacity(2))?;
image.send(actor, test_message(0, 7, None))?; image.send(actor, test_message(0, 7, None))?;
@ -200,14 +321,14 @@ mod tests {
let first = image.publish_behavior( let first = image.publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, message) { (state + message.value, []) }", "pub fn handle(ctx, state, sender, message) { state + message.value }",
)?; )?;
assert!( assert!(
image image
.publish_behavior( .publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, message) { (state - message.value, []) }" "pub fn handle(ctx, state, sender, message) { state - message.value }"
) )
.is_err() .is_err()
); );
@ -221,12 +342,12 @@ mod tests {
let first = image.publish_behavior( let first = image.publish_behavior(
"counter", "counter",
None, None,
"pub fn handle(state, message) { (state + message.value, []) }", "pub fn handle(ctx, state, sender, message) { state + message.value }",
)?; )?;
image.publish_behavior( image.publish_behavior(
"counter", "counter",
Some(first.revision), 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 actor = image.spawn("counter", Value::Integer(5), capacity(2))?;
let implementation = image.install_with_migration(actor, "counter", |state| { let implementation = image.install_with_migration(actor, "counter", |state| {
@ -242,4 +363,207 @@ mod tests {
assert_eq!(image.inspect(actor)?.implementation, implementation); assert_eq!(image.inspect(actor)?.implementation, implementation);
Ok(()) 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(())
}
} }

View File

@ -3,7 +3,7 @@
pub use runa_core::Value; pub use runa_core::Value;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const PROTOCOL_VERSION: u16 = 3; pub const PROTOCOL_VERSION: u16 = 4;
pub const WEBSOCKET_SUBPROTOCOL: &str = "runa-debug"; pub const WEBSOCKET_SUBPROTOCOL: &str = "runa-debug";
/// Coalescible invalidation, not a drain of the image's runtime event queue. /// Coalescible invalidation, not a drain of the image's runtime event queue.
@ -44,6 +44,27 @@ impl Request {
} }
match &self.command { 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 { Command::PublishBehavior {
name, name,
expected_revision, expected_revision,
@ -111,25 +132,58 @@ impl Request {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "parameters", rename_all = "snake_case")] #[serde(tag = "operation", content = "parameters", rename_all = "snake_case")]
pub enum Command { pub enum Command {
ListSources,
ReadSource {
path: String,
},
WriteSource {
path: String,
expected_source_id: Option<String>,
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 { PublishBehavior {
name: String, name: String,
expected_revision: Option<u64>, expected_revision: Option<u64>,
source: String, source: String,
}, },
#[serde(skip)]
RollbackBehavior { RollbackBehavior {
name: String, name: String,
expected_revision: u64, expected_revision: u64,
target_revision: u64, target_revision: u64,
}, },
#[serde(skip)]
GetBehavior { GetBehavior {
name: String, name: String,
}, },
#[serde(skip)]
GetBehaviors, GetBehaviors,
#[serde(skip)]
GetBehaviorSource { GetBehaviorSource {
name: String, name: String,
revision: u64, revision: u64,
}, },
Spawn { Spawn {
#[serde(rename = "path")]
behavior_name: String, behavior_name: String,
state: Value, state: Value,
mailbox_capacity: usize, mailbox_capacity: usize,
@ -162,6 +216,7 @@ pub enum Command {
CancelActor { CancelActor {
actor_ref: String, actor_ref: String,
}, },
#[serde(skip)]
InstallBehavior { InstallBehavior {
actor_ref: String, actor_ref: String,
behavior_name: String, behavior_name: String,
@ -179,6 +234,10 @@ pub struct Response {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")] #[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum Reply { pub enum Reply {
Sources(Vec<SourceFileDto>),
Source(SourceFileDto),
SourceCommitted(SourceCommitDto),
SourceHistory(Vec<SourceCommitDto>),
Acknowledged, Acknowledged,
Behavior(BehaviorDto), Behavior(BehaviorDto),
Behaviors(Vec<BehaviorDto>), Behaviors(Vec<BehaviorDto>),
@ -191,6 +250,33 @@ pub enum Reply {
BehaviorInstalled { implementation_id: String }, 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ProtocolEvent { pub struct ProtocolEvent {

View File

@ -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<ActorRef>,
pub recipient: ActorRef,
pub message: Value,
}
#[derive(Any, Clone)]
#[rune(item = ::runa)]
pub struct ActorContext {
actor: ActorRef,
effects: Rc<RefCell<Vec<SendEffect>>>,
}
impl ActorContext {
pub fn new(actor: ActorRef) -> Self {
Self {
actor,
effects: Rc::default(),
}
}
pub fn take_effects(&self) -> Vec<SendEffect> {
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<ActorRef>,
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()),
}
}
}

View File

@ -36,7 +36,11 @@ pub struct Invocation<T> {
impl<T: FromValue + 'static> Invocation<T> { impl<T: FromValue + 'static> Invocation<T> {
pub fn start(mut vm: Vm, name: &str, args: impl Args) -> Result<Self, VmError> { pub fn start(mut vm: Vm, name: &str, args: impl Args) -> Result<Self, VmError> {
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 stepped = Rc::new(Cell::new(false));
let marker = Rc::clone(&stepped); let marker = Rc::clone(&stepped);
let progress = Rc::new(Cell::new(InvocationProgress::default())); let progress = Rc::new(Cell::new(InvocationProgress::default()));
@ -113,6 +117,30 @@ mod tests {
use std::sync::atomic::{AtomicBool, Ordering}; 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::<i64>(program.vm().call(["on_shutdown"], (1i64,))?)?,
101
);
let mut invocation = Invocation::<i64>::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 { struct Signal {
waker: Mutex<Option<Waker>>, waker: Mutex<Option<Waker>>,
ready: AtomicBool, ready: AtomicBool,

View File

@ -13,13 +13,14 @@ use rune::termcolor::{ColorChoice, StandardStream};
use rune::{Context, Diagnostics, Module, Source, Sources, Vm}; use rune::{Context, Diagnostics, Module, Source, Sources, Vm};
pub const RUNE_VERSION: &str = "0.14.2"; pub const RUNE_VERSION: &str = "0.14.2";
pub const COMPILER_CONFIGURATION_ID: &str = pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v1:send,send_from";
"default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1";
mod execution; mod execution;
pub use execution::{Invocation, InvocationProgress, Slice}; pub use execution::{Invocation, InvocationProgress, Slice};
mod value; 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. /// Errors remain visible to the caller; compilation also emits source diagnostics.
pub type ExperimentResult<T> = Result<T, Box<dyn Error>>; pub type ExperimentResult<T> = Result<T, Box<dyn Error>>;
@ -62,6 +63,9 @@ impl CompiledProgram {
let mut context = Context::with_default_modules()?; let mut context = Context::with_default_modules()?;
let mut values = Module::with_crate("runa")?; let mut values = Module::with_crate("runa")?;
values.ty::<value::ActorReference>()?; values.ty::<value::ActorReference>()?;
values.ty::<ActorContext>()?;
values.associated_function("send", ActorContext::send)?;
values.associated_function("send_from", ActorContext::send_from)?;
values.associated_function( values.associated_function(
&rune::runtime::Protocol::PARTIAL_EQ, &rune::runtime::Protocol::PARTIAL_EQ,
|left: &value::ActorReference, right: &value::ActorReference| left.0 == right.0, |left: &value::ActorReference, right: &value::ActorReference| left.0 == right.0,
@ -100,6 +104,10 @@ impl CompiledProgram {
pub fn vm(&self) -> Vm { pub fn vm(&self) -> Vm {
Vm::new(Arc::clone(&self.runtime), Arc::clone(&self.unit)) 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)] #[derive(Debug)]

View File

@ -4,8 +4,6 @@ use rune::{Any, TypeHash, Value as RuneValue};
use crate::ExperimentResult; use crate::ExperimentResult;
pub type WireTransition = (RuneValue, Vec<(RuneValue, RuneValue)>);
#[derive(Any, Clone)] #[derive(Any, Clone)]
#[rune(item = ::runa)] #[rune(item = ::runa)]
pub(crate) struct ActorReference(pub ActorRef); pub(crate) struct ActorReference(pub ActorRef);

View File

@ -8,6 +8,4 @@ runa-actor = { path = "../runa-actor" }
runa-code = { path = "../runa-code" } runa-code = { path = "../runa-code" }
runa-core = { path = "../runa-core" } runa-core = { path = "../runa-core" }
runa-rune = { path = "../runa-rune" } runa-rune = { path = "../runa-rune" }
[dev-dependencies]
rune = "=0.14.2" rune = "=0.14.2"

View File

@ -6,11 +6,14 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::task::{Context, Wake, Waker}; 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_code::CompiledArtifact;
use runa_core::{ImplementationId, MAX_VALUE_NODES, Value}; use runa_core::{ImplementationId, Value};
use runa_rune::{ 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<T> = ExperimentResult<T>; pub type RuntimeResult<T> = ExperimentResult<T>;
@ -28,9 +31,14 @@ impl Wake for WakeFlag {
struct Actor { struct Actor {
snapshot: ActorSnapshot, snapshot: ActorSnapshot,
mailbox: VecDeque<Message>, mailbox: VecDeque<Envelope>,
capacity: NonZeroUsize, capacity: NonZeroUsize,
active: Option<Invocation<WireTransition>>, active: Option<Invocation<rune::Value>>,
context: ActorContext,
reserved_sender: Option<ActorRef>,
stopping: bool,
shutdown: bool,
pending_install: Option<ImplementationId>,
wake: Arc<WakeFlag>, wake: Arc<WakeFlag>,
paused_from: Option<Status>, paused_from: Option<Status>,
} }
@ -94,6 +102,11 @@ impl Runtime {
mailbox: VecDeque::new(), mailbox: VecDeque::new(),
capacity, capacity,
active: None, active: None,
context: ActorContext::new(actor_ref),
reserved_sender: None,
stopping: false,
shutdown: false,
pending_install: None,
wake: Arc::new(WakeFlag(AtomicBool::new(true))), wake: Arc::new(WakeFlag(AtomicBool::new(true))),
paused_from: None, paused_from: None,
}, },
@ -103,9 +116,18 @@ impl Runtime {
} }
pub fn send(&mut self, id: ActorRef, message: Message) -> ExperimentResult<()> { pub fn send(&mut self, id: ActorRef, message: Message) -> ExperimentResult<()> {
self.send_from(None, id, message)
}
pub fn send_from(
&mut self,
sender: Option<ActorRef>,
id: ActorRef,
message: Message,
) -> ExperimentResult<()> {
message.validate()?; message.validate()?;
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; 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()); return Err("actor is not accepting messages".into());
} }
if actor.mailbox.len() + usize::from(actor.snapshot.reserved.is_some()) if actor.mailbox.len() + usize::from(actor.snapshot.reserved.is_some())
@ -113,7 +135,7 @@ impl Runtime {
{ {
return Err("mailbox full".into()); return Err("mailbox full".into());
} }
actor.mailbox.push_back(message); actor.mailbox.push_back(Envelope { sender, message });
Ok(()) Ok(())
} }
@ -128,7 +150,11 @@ impl Runtime {
call_depth: progress.call_depth, 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) Ok(snapshot)
} }
@ -145,21 +171,73 @@ impl Runtime {
call_depth: progress.call_depth, call_depth: progress.call_depth,
} }
}); });
snapshot.queued = actor.mailbox.iter().cloned().collect(); snapshot.queued = actor
.mailbox
.iter()
.map(|envelope| envelope.message.clone())
.collect();
snapshot snapshot
}) })
.collect() .collect()
} }
pub fn stop(&mut self, id: ActorRef) -> ExperimentResult<()> { 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")?; let actor = self.actors.get_mut(&id).ok_or("unknown actor")?;
actor.active = None; actor.active = None;
actor.context.take_effects();
actor.stopping = false;
actor.shutdown = false;
actor.pending_install = None;
actor.paused_from = None; actor.paused_from = None;
actor.snapshot.status = Status::Stopped; actor.snapshot.status = Status::Stopped;
self.events.push(Event::Stopped(id)); self.events.push(Event::Stopped(id));
Ok(()) 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. /// Pause scheduling between worker ticks without discarding an invocation.
pub fn pause(&mut self, id: ActorRef) -> ExperimentResult<()> { pub fn pause(&mut self, id: ActorRef) -> ExperimentResult<()> {
let actor = self.actors.get_mut(&id).ok_or("unknown actor")?; 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")?; .ok_or("active invocation has no reserved message")?;
actor.active = None; actor.active = None;
actor.paused_from = 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; actor.snapshot.status = Status::Idle;
self.events.push(Event::InvocationCancelled(id)); self.events.push(Event::InvocationCancelled(id));
Ok(()) Ok(())
@ -216,7 +298,10 @@ impl Runtime {
.checked_add(1) .checked_add(1)
.ok_or("incarnation exhausted")?; .ok_or("incarnation exhausted")?;
if let Some(message) = actor.snapshot.reserved.take() { 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.snapshot.status = Status::Idle;
actor.paused_from = None; actor.paused_from = None;
@ -275,32 +360,24 @@ impl Runtime {
fn fail(&mut self, id: &ActorRef, error: String) { fn fail(&mut self, id: &ActorRef, error: String) {
let actor = self.actors.get_mut(id).expect("scheduled actor exists"); let actor = self.actors.get_mut(id).expect("scheduled actor exists");
actor.active = None; actor.active = None;
actor.context.take_effects();
actor.snapshot.status = Status::Failed(error.clone()); actor.snapshot.status = Status::Failed(error.clone());
self.events.push(Event::Failed(*id, error)); self.events.push(Event::Failed(*id, error));
} }
fn commit(&mut self, id: &ActorRef, transition: WireTransition) -> ExperimentResult<()> { fn commit(&mut self, id: &ActorRef, state: rune::Value) -> ExperimentResult<()> {
let (state, effects) = transition;
let state = value_from_rune(&state)?; let state = value_from_rune(&state)?;
if effects.len() > MAX_VALUE_NODES { let effects = self.actors[id].context.take_effects();
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::<ExperimentResult<Vec<_>>>()?;
let mut counts = BTreeMap::<ActorRef, usize>::new(); let mut counts = BTreeMap::<ActorRef, usize>::new();
for (target, _) in &effects { for effect in &effects {
let target = &effect.recipient;
let actor = self let actor = self
.actors .actors
.get(target) .get(target)
.ok_or("effect targets unknown actor")?; .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()); return Err("effect targets unavailable actor".into());
} }
let count = counts.entry(*target).or_default(); let count = counts.entry(*target).or_default();
@ -315,12 +392,22 @@ impl Runtime {
actor.snapshot.reserved = None; actor.snapshot.reserved = None;
actor.snapshot.status = Status::Idle; actor.snapshot.status = Status::Idle;
actor.active = None; 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 self.actors
.get_mut(&target) .get_mut(&effect.recipient)
.expect("validated target") .expect("validated target")
.mailbox .mailbox
.push_back(message); .push_back(Envelope {
sender: effect.sender,
message: effect.message,
});
} }
self.events.push(Event::Committed(*id)); self.events.push(Event::Committed(*id));
Ok(()) Ok(())
@ -338,22 +425,63 @@ impl Runtime {
continue; continue;
} }
if actor.active.is_none() { if actor.active.is_none() {
let Some(message) = actor.mailbox.pop_front() else { if actor.stopping {
continue; let program = self.implementations[&actor.snapshot.implementation].program();
}; if !program.has_function("on_shutdown") {
actor.snapshot.reserved = Some(message.clone()); actor.snapshot.status = Status::Stopped;
let program = self.implementations[&actor.snapshot.implementation].program(); actor.stopping = false;
let invocation = (|| -> ExperimentResult<_> { self.events.push(Event::Stopped(id));
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());
continue; 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; actor.snapshot.status = Status::Running;
} }
@ -385,6 +513,201 @@ impl Runtime {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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<runa_core::ActorRef>) -> Value { fn test_message(kind: i64, value: i64, target: Option<runa_core::ActorRef>) -> Value {
Value::Record(std::collections::BTreeMap::from([ Value::Record(std::collections::BTreeMap::from([
@ -404,11 +727,11 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let receiver_code = publish( let receiver_code = publish(
&mut runtime, &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( let sender_code = publish(
&mut runtime, &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([( let initial = Value::Record(BTreeMap::from([(
"items".to_owned(), "items".to_owned(),
@ -453,11 +776,11 @@ mod tests {
fn mutated_drafts_and_invalid_structured_effects_never_partially_commit() -> ExperimentResult<()> fn mutated_drafts_and_invalid_structured_effects_never_partially_commit() -> ExperimentResult<()>
{ {
for source in [ for source in [
"pub fn handle(state, message) { state.items.push(2); panic(\"failed\"); (state, []) }", "pub fn handle(ctx, state, sender, 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(ctx, state, sender, message) { state.items.push(2); ctx.send(message, state); ctx.send(\"not a reference\", state); state }",
"pub fn handle(state, message) { state.items.push(2); (state, [(message, state), (message, 1.5)]) }", "pub fn handle(ctx, state, sender, message) { state.items.push(2); ctx.send(message, state); ctx.send(message, 1.5); state }",
"pub fn handle(state, message) { state.items.push(2); (Some(state), [(message, state)]) }", "pub fn handle(ctx, state, sender, message) { state.items.push(2); ctx.send(message, state); Some(state) }",
"pub fn handle(state, message) { state.items.push(state); (state, []) }", "pub fn handle(ctx, state, sender, message) { state.items.push(state); state }",
] { ] {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = publish(&mut runtime, source)?; let code = publish(&mut runtime, source)?;
@ -484,7 +807,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = publish( let code = publish(
&mut runtime, &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]); let invalid = Value::List(vec![Value::Null; MAX_VALUE_NODES]);
assert!(runtime.spawn(code, invalid.clone(), capacity(2)).is_err()); assert!(runtime.spawn(code, invalid.clone(), capacity(2)).is_err());
@ -515,7 +838,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = publish( let code = publish(
&mut runtime, &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 first = runtime.spawn(code, Value::Integer(0), capacity(4))?;
let second = runtime.spawn(code, Value::Integer(10), capacity(4))?; let second = runtime.spawn(code, Value::Integer(10), capacity(4))?;
@ -538,10 +861,13 @@ mod tests {
#[test] #[test]
fn runaway_does_not_starve_healthy_actor() -> ExperimentResult<()> { fn runaway_does_not_starve_healthy_actor() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); 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( let healthy = publish(
&mut runtime, &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 first = runtime.spawn(runaway, Value::Integer(0), capacity(1))?;
let second = runtime.spawn(healthy, Value::Integer(0), capacity(2))?; 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(first)?.state, Value::Integer(0));
assert_eq!(runtime.inspect(second)?.state, Value::Integer(42)); assert_eq!(runtime.inspect(second)?.state, Value::Integer(42));
assert!(runtime.send(first, test_message(0, 2, None)).is_err()); 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); assert_eq!(runtime.inspect(first)?.status, Status::Stopped);
runtime.restart(first)?; runtime.restart(first)?;
assert_eq!(runtime.inspect(first)?.incarnation, 2); 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<()> fn execution_snapshot_reports_live_progress_and_is_stable_while_paused() -> ExperimentResult<()>
{ {
let mut runtime = Runtime::default(); 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))?; let actor = runtime.spawn(implementation, Value::Integer(0), capacity(2))?;
runtime.send(actor, test_message(0, 1, None))?; runtime.send(actor, test_message(0, 1, None))?;
runtime.tick(capacity(3)); runtime.tick(capacity(3));
@ -599,7 +928,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = publish( let code = publish(
&mut runtime, &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))?; let actor = runtime.spawn(code, Value::Integer(5), capacity(4))?;
runtime.send(actor, test_message(0, 0, Some(actor)))?; runtime.send(actor, test_message(0, 0, Some(actor)))?;
@ -656,7 +985,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = runtime.publish( let code = runtime.publish(
CodeStore::default().compile_with_module( 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", "test-gate:v1",
module, module,
)?, )?,
@ -724,7 +1053,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let implementation = publish( let implementation = publish(
&mut runtime, &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))?; let actor = runtime.spawn(implementation, Value::Integer(10), capacity(2))?;
runtime.send(actor, test_message(0, 7, None))?; runtime.send(actor, test_message(0, 7, None))?;
@ -772,7 +1101,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = publish( let code = publish(
&mut runtime, &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))?; let actor = runtime.spawn(code, Value::Integer(5), capacity(1))?;
runtime.send(actor, test_message(0, 0, None))?; runtime.send(actor, test_message(0, 0, None))?;
@ -797,7 +1126,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = publish( let code = publish(
&mut runtime, &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))?; let actor = runtime.spawn(code, Value::Integer(5), capacity(1))?;
runtime.send(actor, test_message(0, 0, Some(actor)))?; runtime.send(actor, test_message(0, 0, Some(actor)))?;
@ -815,7 +1144,10 @@ mod tests {
#[test] #[test]
fn invalid_transition_shape_fails_without_committing() -> ExperimentResult<()> { fn invalid_transition_shape_fails_without_committing() -> ExperimentResult<()> {
let mut runtime = Runtime::default(); 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))?; let actor = runtime.spawn(code, Value::Integer(5), capacity(1))?;
runtime.send(actor, test_message(0, 1, None))?; runtime.send(actor, test_message(0, 1, None))?;
for _ in 0..100 { for _ in 0..100 {
@ -832,9 +1164,9 @@ mod tests {
fn behavior_installation_waits_for_handler_boundary() -> ExperimentResult<()> { fn behavior_installation_waits_for_handler_boundary() -> ExperimentResult<()> {
let mut code = CodeStore::default(); let mut code = CodeStore::default();
let first = let first =
code.compile("pub fn handle(state, message) { (state + message.value, []) }")?; code.compile("pub fn handle(ctx, state, sender, message) { state + message.value }")?;
let second = let second = code
code.compile("pub fn handle(state, message) { (state + message.value * 10, []) }")?; .compile("pub fn handle(ctx, state, sender, message) { state + message.value * 10 }")?;
let first_id = first.implementation_id; let first_id = first.implementation_id;
let second_id = second.implementation_id; let second_id = second.implementation_id;
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
@ -873,11 +1205,11 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let old_implementation = publish( let old_implementation = publish(
&mut runtime, &mut runtime,
"pub fn handle(state, message) { (state + message.value, []) }", "pub fn handle(ctx, state, sender, message) { state + message.value }",
)?; )?;
let new_implementation = publish( let new_implementation = publish(
&mut runtime, &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))?; let actor = runtime.spawn(old_implementation, Value::Integer(7), capacity(2))?;
@ -907,11 +1239,11 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let old_implementation = publish( let old_implementation = publish(
&mut runtime, &mut runtime,
"pub fn handle(state, message) { (state + message.value, []) }", "pub fn handle(ctx, state, sender, message) { state + message.value }",
)?; )?;
let new_implementation = publish( let new_implementation = publish(
&mut runtime, &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))?; let actor = runtime.spawn(old_implementation, Value::Integer(0), capacity(4))?;
runtime.send(actor, test_message(0, 1, None))?; runtime.send(actor, test_message(0, 1, None))?;
@ -951,11 +1283,11 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let old_implementation = publish( let old_implementation = publish(
&mut runtime, &mut runtime,
"pub fn handle(state, message) { (state + message.value, []) }", "pub fn handle(ctx, state, sender, message) { state + message.value }",
)?; )?;
let new_implementation = publish( let new_implementation = publish(
&mut runtime, &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))?; let actor = runtime.spawn(old_implementation, Value::Integer(7), capacity(2))?;
@ -986,11 +1318,11 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let old_implementation = publish( let old_implementation = publish(
&mut runtime, &mut runtime,
"pub fn handle(state, message) { (state + message.value, []) }", "pub fn handle(ctx, state, sender, message) { state + message.value }",
)?; )?;
let new_implementation = publish( let new_implementation = publish(
&mut runtime, &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))?; let actor = runtime.spawn(old_implementation, Value::Integer(7), capacity(2))?;
runtime.send(actor, test_message(0, 1, None))?; runtime.send(actor, test_message(0, 1, None))?;
@ -1028,7 +1360,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = runtime.publish( let code = runtime.publish(
CodeStore::default().compile_with_module( 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", "test-fail-once:v1",
module, module,
)?, )?,
@ -1081,7 +1413,7 @@ mod tests {
let mut runtime = Runtime::default(); let mut runtime = Runtime::default();
let code = publish( let code = publish(
&mut runtime, &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 actor = runtime.spawn(code, Value::Integer(0), capacity(3))?;
let first = test_message(0, 1, None); let first = test_message(0, 1, None);
@ -1098,7 +1430,7 @@ mod tests {
assert_eq!(running.reserved, Some(first.clone())); assert_eq!(running.reserved, Some(first.clone()));
assert_eq!(running.queued, vec![second.clone(), third.clone()]); assert_eq!(running.queued, vec![second.clone(), third.clone()]);
runtime.stop(actor)?; runtime.kill(actor)?;
let stopped = runtime.inspect(actor)?; let stopped = runtime.inspect(actor)?;
assert_eq!(stopped.status, Status::Stopped); assert_eq!(stopped.status, Status::Stopped);
assert_eq!(stopped.state, Value::Integer(0)); assert_eq!(stopped.state, Value::Integer(0));

View File

@ -7,11 +7,12 @@ use std::num::NonZeroUsize;
mod network; mod network;
pub use network::serve_websocket; 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_image_api::ImageApi;
use runa_protocol::{ use runa_protocol::{
ActorSnapshotDto, ActorStatusDto, BehaviorDto, BehaviorVersionDto, Command, ErrorCode, ActorSnapshotDto, ActorStatusDto, BehaviorDto, BehaviorVersionDto, Command, ErrorCode,
EventDto, ExecutionSnapshotDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, Response, EventDto, ExecutionSnapshotDto, PROTOCOL_VERSION, ProtocolError, Reply, Request, Response,
SourceCommitDto, SourceFileDto, SystemMessageDto,
}; };
pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024; pub const MAX_MESSAGE_BYTES: usize = 1024 * 1024;
@ -64,6 +65,81 @@ impl Server {
fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> { fn dispatch_command(&mut self, command: Command) -> Result<Reply, ProtocolError> {
match command { 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 { Command::PublishBehavior {
name, name,
expected_revision, 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 { fn behavior_dto(behavior: runa_code::BehaviorSnapshot) -> BehaviorDto {
BehaviorDto { BehaviorDto {
name: behavior.name, name: behavior.name,
@ -281,11 +372,15 @@ fn event_dto(event: &Event) -> EventDto {
fn api_error(error: impl std::fmt::Display) -> ProtocolError { fn api_error(error: impl std::fmt::Display) -> ProtocolError {
let message = error.to_string(); 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 ErrorCode::Conflict
} else if matches!( } else if matches!(
message.as_str(), message.as_str(),
"unknown actor" "unknown actor"
| "unknown source path"
| "unknown source revision"
| "unknown behavior" | "unknown behavior"
| "unknown behavior revision" | "unknown behavior revision"
| "unknown target 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" "mailbox full" | "effect exceeds mailbox capacity"
) { ) {
ErrorCode::CapacityExceeded ErrorCode::CapacityExceeded
} else if message == "invalid behavior name" { } else if matches!(
message.as_str(),
"invalid behavior name" | "invalid source path"
) {
ErrorCode::InvalidRequest ErrorCode::InvalidRequest
} else { } else {
ErrorCode::RuntimeFailure ErrorCode::RuntimeFailure
@ -316,6 +414,137 @@ fn parse_actor_ref(value: &str) -> Result<ActorRef, ProtocolError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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; use runa_protocol::Value;
fn test_message(kind: i64, value: i64, target: Option<ActorRef>) -> Value { fn test_message(kind: i64, value: i64, target: Option<ActorRef>) -> Value {
@ -343,7 +572,8 @@ mod tests {
let response = server.dispatch(request(Command::PublishBehavior { let response = server.dispatch(request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: None, 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() { match response.result.unwrap() {
Reply::Behavior(behavior) => behavior, Reply::Behavior(behavior) => behavior,
@ -422,7 +652,7 @@ mod tests {
let conflict = server.dispatch(request(Command::PublishBehavior { let conflict = server.dispatch(request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: Some(99), 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); assert_eq!(conflict.result.unwrap_err().code, ErrorCode::Conflict);
} }
@ -434,7 +664,8 @@ mod tests {
let updated = server.dispatch(request(Command::PublishBehavior { let updated = server.dispatch(request(Command::PublishBehavior {
name: "counter".to_owned(), name: "counter".to_owned(),
expected_revision: Some(first.revision), 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(_)))); assert!(matches!(updated.result, Ok(Reply::Behavior(_))));
@ -445,7 +676,8 @@ mod tests {
assert_eq!( assert_eq!(
source.result, source.result,
Ok(Reply::BehaviorSource { 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!( assert_eq!(
serde_json::to_value(notification).unwrap(), serde_json::to_value(notification).unwrap(),
serde_json::json!({ serde_json::json!({
"version": 3, "version": 4,
"event": {"event": "image_changed", "data": {"revision": 42}} "event": {"event": "image_changed", "data": {"revision": 42}}
}) })
); );

View File

@ -20,9 +20,9 @@ fn initial_image() -> io::Result<Server> {
.dispatch(Request { .dispatch(Request {
version: PROTOCOL_VERSION, version: PROTOCOL_VERSION,
request_id: "bootstrap-counter".to_owned(), request_id: "bootstrap-counter".to_owned(),
command: Command::PublishBehavior { command: Command::WriteSource {
name: "counter".to_owned(), path: "actors/counter".to_owned(),
expected_revision: None, expected_source_id: None,
source: include_str!("../../../runa-std/actors/counter.rn").to_owned(), source: include_str!("../../../runa-std/actors/counter.rn").to_owned(),
}, },
}) })

View File

@ -67,7 +67,9 @@ async fn serve(listener: TcpListener, mut server: Server) -> io::Result<()> {
let mutates = !matches!(dispatch.request.command, let mutates = !matches!(dispatch.request.command,
Command::GetBehavior { .. } | Command::GetBehaviors | Command::GetBehavior { .. } | Command::GetBehaviors |
Command::GetBehaviorSource { .. } | Command::InspectActor { .. } | 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 response = server.dispatch(dispatch.request);
let changed = mutates && response.result.is_ok(); let changed = mutates && response.result.is_ok();
// Dispatch may commit even if the initiating client has disconnected. // Dispatch may commit even if the initiating client has disconnected.

View File

@ -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() { fn server_clap_supports_env_overrides_help_and_explicit_errors() {
let (_server, address) = start_server_with(&[], "127.0.0.1:0"); let (_server, address) = start_server_with(&[], "127.0.0.1:0");
let mut client = WebSocketClient::connect(address).unwrap(); let mut client = WebSocketClient::connect(address).unwrap();
assert!( assert!(client.request(Command::ListSources).unwrap().result.is_ok());
client
.request(Command::GetBehaviors)
.unwrap()
.result
.is_ok()
);
for args in [ for args in [
vec!["--listen", "invalid"], vec!["--listen", "invalid"],
vec!["--listen"], 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 mut client = WebSocketClient::connect(address).unwrap();
let actor_ref = match client let actor_ref = match client
.request(Command::Spawn { .request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "actors/counter".to_owned(),
state: Value::Integer(1), state: Value::Integer(1),
mailbox_capacity: 4, mailbox_capacity: 4,
}) })
@ -184,7 +178,7 @@ fn websocket_broadcasts_non_destructive_invalidations_for_mailbox_state_and_life
first first
.send(Message::Text( .send(Message::Text(
serde_json::to_string(&runa_protocol::Request { serde_json::to_string(&runa_protocol::Request {
version: 3, version: 4,
request_id: "read-fence".to_owned(), request_id: "read-fence".to_owned(),
command: Command::ListActors, command: Command::ListActors,
}) })
@ -287,7 +281,7 @@ fn websocket_server_accepts_local_protocol_requests() {
let mut second = WebSocketClient::connect(address).unwrap(); let mut second = WebSocketClient::connect(address).unwrap();
let actor_ref = match first let actor_ref = match first
.request(Command::Spawn { .request(Command::Spawn {
behavior_name: "counter".to_owned(), behavior_name: "actors/counter".to_owned(),
state: Value::Integer(5), state: Value::Integer(5),
mailbox_capacity: 2, mailbox_capacity: 2,
}) })
@ -301,29 +295,36 @@ fn websocket_server_accepts_local_protocol_requests() {
assert!(matches!(second.request(Command::InspectActor { assert!(matches!(second.request(Command::InspectActor {
actor_ref: actor_ref.clone(), actor_ref: actor_ref.clone(),
}).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(5))); }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(5)));
assert!(matches!( let Reply::SourceCommitted(commit) = first
first .request(Command::CommitSource {
.request(Command::RollbackBehavior { path: "actors/counter".to_owned(),
name: "counter".to_owned(), })
expected_revision: 1, .unwrap()
target_revision: 1, .result
}) .unwrap()
.unwrap() else {
.result, panic!("expected source commit");
Ok(Reply::Behavior(behavior)) if behavior.revision == 2 };
)); first
.request(Command::RollbackSource {
path: "actors/counter".to_owned(),
revision: commit.revision.clone(),
})
.unwrap()
.result
.unwrap();
drop(first); drop(first);
drop(second); drop(second);
let mut reconnected = WebSocketClient::connect(address).unwrap(); let mut reconnected = WebSocketClient::connect(address).unwrap();
assert!(matches!( assert!(matches!(
reconnected reconnected
.request(Command::GetBehavior { .request(Command::SourceHistory {
name: "counter".to_owned(), path: "actors/counter".to_owned(),
}) })
.unwrap() .unwrap()
.result, .result,
Ok(Reply::Behavior(behavior)) if behavior.revision == 2 Ok(Reply::SourceHistory(history)) if history.len() == 1 && history[0] == commit
)); ));
reconnected reconnected
.request(Command::Send { .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))); }).unwrap().result, Ok(Reply::Actor(actor)) if actor.state == Value::Integer(12)));
reconnected reconnected
.request(Command::PublishBehavior { .request(Command::WriteSource {
name: "echo".to_owned(), path: "echo".to_owned(),
expected_revision: None, expected_source_id: None,
source: "pub fn handle(state, message) { (message, []) }".to_owned(), source: "pub fn handle(ctx, state, sender, message) { message }".to_owned(),
}) })
.unwrap() .unwrap()
.result .result

View File

@ -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, spawn, stop, restart, and supervise actors from both graphical world and VS Code.
- Inspect state, mailboxes, behavior versions, failures, and execution status. - Inspect state, mailboxes, behavior versions, failures, and execution status.
- Send messages and evaluate development operations interactively. - 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. - Support interruptible execution, pause/resume, and eventually source-level stepping.
- Preserve stable actor identity and optionally mailbox contents across actor incarnation failures. - Preserve stable actor identity and optionally mailbox contents across actor incarnation failures.
- Store canonical Rune source and immutable implementations by content identity; cache compilation. - 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: The handler is a stateless Rune function associated with an immutable implementation ID. Conceptually:
```text ```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 ### 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 ```text
Transition { ctx.send(recipient, data);
new_state, new_state
effects: [Send(...), ScheduleTimer(...), ...],
}
``` ```
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. 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:<digest-of-source-plus-toolchain-configuration>
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 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 ```text
behaviors/Counter -> implementation:abc123 source/actors/counter.rn -> current working source
history: [implementation:def456, implementation:abc123] 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 ### 6.1 Hot upgrade procedure
1. Accept source and expected behavior revision. 1. Save working source with an expected source identity; commit separately if desired.
2. Canonicalize, hash, and compile (or reuse cached artifact). 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. 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. 5. Validate state compatibility or execute explicit migration.
6. Install at a safe boundary (initially between message handlers). 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. Do not rewrite suspended execution frames in place.
@ -220,7 +238,7 @@ mailbox.retry mailbox.deadLetters
state.inspect state.snapshot state.history state.inspect state.snapshot state.history
behavior.list behavior.get behavior.compile behavior.list behavior.get behavior.compile
behavior.publish behavior.install behavior.rollback source.write source.commit source.rollback
behavior.history behavior.history
code.getSource code.getArtifact code.getMetadata 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. than replayed, and changes during a fetch trigger a follow-up refresh.
Cached open snapshots are marked disconnected on connection loss and isolated Cached open snapshots are marked disconnected on connection loss and isolated
by connection session so reconnecting to another image cannot overwrite them. by connection session so reconnecting to another image cannot overwrite them.
Spawn with explicit behavior/state, structured send, pause/resume/stop/restart, Spawn with explicit source path/state, structured send, system lifecycle commands,
explicit scheduler ticks, and a behavior explorer with revision history and and explicit scheduler ticks are implemented. The extension mounts an editable
read-only source documents are also implemented. Sources are fetched at explicit `source` directory with nested Rune files and file-context spawn actions.
revisions and cached by connection session; image notifications refresh listings Saving writes working source; committing and browsing history are separate
without replacing historical source text. Remaining UX below is 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 planned; detailed runtime event subscriptions and
durable replay still need design. durable replay still need design.
@ -261,8 +281,8 @@ Required extension UX:
- Connection management and runtime status. - Connection management and runtime status.
- Actor tree with spawn, stop, restart, pause, and resume actions. - Actor tree with spawn, stop, restart, pause, and resume actions.
- Actor state and mailbox inspector, including message sending. - Actor state and mailbox inspector, including message sending.
- Behavior tree and source navigation. - Directory-based image source navigation.
- Editable virtual documents for mutable behavior references. - Editable image source files with independent commit/history/rollback actions.
- Read-only virtual documents for exact content-addressed implementations. - Read-only virtual documents for exact content-addressed implementations.
- Compilation diagnostics, update conflicts, and runtime event stream. - Compilation diagnostics, update conflicts, and runtime event stream.
- Later: Debug Adapter Protocol integration for breakpoints, frames, and stepping. - Later: Debug Adapter Protocol integration for breakpoints, frames, and stepping.
@ -270,11 +290,14 @@ Required extension UX:
Suggested virtual URIs: Suggested virtual URIs:
```text ```text
liveworld:/behaviors/Counter/current.rn source/actors/counter.rn
liveworld:/implementations/<hash>/source.rn source-history:/<session>/actors/counter/<revision>.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 ## 10. Persistence
@ -456,7 +479,8 @@ explicit host-provided migration closure for structured actor state.
Actor snapshots expose active VM instruction progress, instruction pointer, Actor snapshots expose active VM instruction progress, instruction pointer,
and call-frame depth. State and messages are owned portable trees of null, and call-frame depth. State and messages are owned portable trees of null,
booleans, integers, strings, lists, records, and distinct actor references. 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, Rune heap objects never become shared committed actor data. State schemas,
version compatibility checks, Rune-authored migration functions, source version compatibility checks, Rune-authored migration functions, source
mapping, and local-variable inspection remain future work. 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 the CLI, a transport-neutral versioned protocol contract, and a dispatcher
exposed through bounded loopback WebSockets. The interactive `runa-cli` uses exposed through bounded loopback WebSockets. The interactive `runa-cli` uses
`runa-client` to connect over loopback WebSockets to a server-owned image, rather than `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 client disconnect/reconnect leaves the running image intact. The `runa` server provides an
unauthenticated runa-debug WebSocket endpoint restricted to loopback for local VS unauthenticated runa-debug WebSocket endpoint restricted to loopback for local VS
Code development. Remote access will require authentication and 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 Connection management, the actor explorer, and read-only snapshot inspection
are implemented in `vscode/`, along with spawn/send, lifecycle controls, and are implemented in `vscode/`, along with spawn/send, lifecycle controls, and
explicit scheduler ticks, behavior/revision browsing, and immutable source documents. Behavior editing, explicit scheduler ticks, editable image source files, commit history, and
diagnostics, hot upgrades, and detailed runtime event subscriptions remain planned. 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 Non-destructive image-change notifications and automatic snapshot refresh are
implemented over the shared `runa-debug` WebSocket transport. 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 ## 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. **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.

View File

@ -9,9 +9,9 @@ Clap parses `--listen` / `RUNA_LISTEN` on the server and `--connect` /
The listener is unauthenticated and intended only for The listener is unauthenticated and intended only for
trusted local development. Both the REPL and VS Code use WebSockets. 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 non-empty `request_id` used to correlate the response. A server must reject
unsupported versions with `unsupported_version` rather than interpreting a unsupported versions with `unsupported_version` rather than interpreting a
request using a different contract. Events carry the protocol version and do request using a different contract. Events carry the protocol version and do
@ -21,7 +21,7 @@ Requests have a tagged command object:
```json ```json
{ {
"version": 3, "version": 4,
"request_id": "req-1", "request_id": "req-1",
"command": { "command": {
"operation": "send", "operation": "send",
@ -40,14 +40,42 @@ Requests have a tagged command object:
} }
``` ```
The version 3 operations are behavior publication, rollback, lookup, source The version 4 operations are source listing/read/write, commit/history/revision
retrieval, actor spawn, send, tick, inspect, actor listing, event retrieval, retrieval/rollback, actor spawn, send, tick, inspect, actor listing, event
stop, restart, pause, resume, cancellation, and behavior installation. retrieval, privileged system messages, and cancellation.
Migration closures are intentionally excluded: they are host callbacks, not Migration closures are intentionally excluded: they are host callbacks, not
portable wire values. 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 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 are wire DTOs, not serialized runtime objects; implementation identities are
represented by their stable display strings. Public actor identity is a 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 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. operation failed to commit.
The interactive REPL uses WebSockets and never owns the image. Disconnecting clients The interactive REPL uses WebSockets and never owns the image. Disconnecting clients
does not stop actors or reset behavior revisions; reconnecting clients can does not stop actors or reset source history; reconnecting clients can
retrieve them through `list_actors` and `get_behaviors`. The network executable retrieve them through `list_actors` and `list_sources`. The network executable
publishes the bundled `counter` behavior on image startup, not on connection. loads `source/actors/counter.rn` on image startup, not on connection.
The stdio transport and child-process client have been removed. The stdio transport and child-process client have been removed.
The TypeScript VS Code client issues `list_actors`, `inspect_actor`, The TypeScript VS Code client issues `list_actors`, `inspect_actor`,
`get_behaviors`, `get_behavior_source`, `spawn`, `send`, `tick`, and source file/version-control operations, `spawn`, `send`, `tick`, and `system`.
pause/resume/stop/restart operations. VS Code mounts writable image working files under a `source` workspace folder,
Behavior source lookup uses an explicit immutable revision, not a mutable and caches read-only commit documents by connection, path, and revision.
latest-source alias. VS Code caches read-only source documents by connection,
behavior name, and revision while refreshing behavior listings on notifications.
It preserves i64 inputs and bounds the complete outgoing envelope as well as It preserves i64 inputs and bounds the complete outgoing envelope as well as
incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages, incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages,
version/request-ID/reply-kind checks, and portable snapshot value validation. 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) 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 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 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 Each WebSocket text message contains exactly one JSON request, response, or
notification, without newline framing. Binary messages and malformed requests notification, without newline framing. Binary messages and malformed requests
terminate the connection. Fragmented text is supported with a 1 MiB aggregate 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 ```json
{ {
"version": 3, "version": 4,
"event": { "event": {
"event": "image_changed", "event": "image_changed",
"data": { "revision": 42 } "data": { "revision": 42 }
@ -157,7 +183,7 @@ Every upgraded connection automatically receives non-destructive invalidations:
``` ```
Successful mutating requests (spawn, send, tick, lifecycle controls, 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, and notify all connected clients, including the initiator. Read-only requests,
failed mutations, and `get_events` do not generate invalidations. A tick failed mutations, and `get_events` do not generate invalidations. A tick
invalidates snapshots even when it only advances execution telemetry or causes 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. runtime events, a durable log, an atomic snapshot revision, or replay.
On connection/reconnection, fetch a full listing first. The VS Code client On connection/reconnection, fetch a full listing first. The VS Code client
coalesces bursts and schedules another refresh if an invalidation arrives 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 REPL's explicit `events` command still drains the separate runtime queue.
The current validation checks the protocol version, request ID, actor-reference The current validation checks the protocol version, request ID, actor-reference
shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox shape, structured-value limits, revision ID shape, non-empty paths, mailbox
capacity, and instruction budget. Behavior-name grammar, actor existence, capacity, and instruction budget. Canonical source paths, actor existence,
current behavior revisions, mailbox capacity at send time, and all runtime expected source identities, mailbox capacity at send time, and all runtime
invariants remain authoritative at the application-service boundary. invariants remain authoritative at the application-service boundary.

View File

@ -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. The test does not move an active VM, execution, or Rune value between threads.
- Dropping an execution cancels this controlled local future. Cancellation of - Dropping an execution cancels this controlled local future. Cancellation of
external I/O and rollback of native side effects are not established. 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 ## Proposed Phase 1 direction

View File

@ -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 inspect the same actors; local bindings and history do not survive a REPL
restart. Server exit still loses the entire image. restart. Server exit still loses the entire image.
The WebSocket server publishes [counter.rn](../runa-std/actors/counter.rn) as the The WebSocket server loads [counter.rn](../runa-std/actors/counter.rn) into the
`counter` behavior. Commands include `spawn`, `send`, `tick`, `inspect`, `actors`, image's working `source/actors/counter.rn`. Commands include `spawn`, `send`,
`events`, `stop`, `restart`, `pause`, `resume`, `cancel`, `behaviors`, `tick`, `inspect`, `actors`, `events`, `stop`, `kill`, `restart`, `pause`, `resume`,
`publish`, `history`, `rollback`, and `install`; enter `help` to see usage. `reload`, `become`, `cancel`, `sources`, `write`, `commit`, `history`, and
`publish` reads a Rune source file and takes `new` for a first revision or the `rollback`; enter `help` to see usage. Paths exclude the root and extension:
expected current revision for an update. `rollback` requires the expected `spawn actors/counter 1`. Spawn compiles the current working source, not a
current revision and a target historical revision. `install` selects a named committed revision. `write <path> <local-file>` updates working source using
behavior's current implementation and fails if the actor is not at an idle source-identity conflict checks. `commit <path>` captures it without compilation,
handler boundary. `spawn` requires a behavior name and initial state, in that and generates a readable revision ID. `history <path>` lists snapshots;
order. All commands operate on the same process-local runtime and code store. `rollback <path> <revision>` 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 CLI sends versioned requests to the loopback-only WebSocket listener using
the `runa-debug` subprotocol. VS Code uses the same protocol and automatically the `runa-debug` subprotocol. VS Code uses the same protocol and automatically
refreshes on non-destructive server change notifications. These notifications refreshes on non-destructive server change notifications. These notifications
@ -35,9 +36,9 @@ There is no stdio transport; all external clients use WebSockets.
Example: Example:
```text ```text
runa> let sender = spawn counter 0 runa> let sender = spawn actors/counter 0
sender = @<actor-reference> sender = @<actor-reference>
runa> let receiver = spawn counter 10 runa> let receiver = spawn actors/counter 10
receiver = @<actor-reference> receiver = @<actor-reference>
runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}} runa> send sender {"op": "send", "value": 5, "target": {"$actor": "receiver"}}
message queued message queued
@ -54,7 +55,7 @@ explicitly advances the scheduler. The default per-actor slice budget is 32
instructions. instructions.
State and messages use JSON literals in the REPL, preserving whitespace inside State and messages use JSON literals in the REPL, preserving whitespace inside
strings. `spawn <behavior> <json>` and `let <name> = spawn <behavior> <json>` strings. `spawn <path> <json>` and `let <name> = spawn <path> <json>`
require explicit initial state; `send <actor> <json>` takes one message value. require explicit initial state; `send <actor> <json>` takes one message value.
For example, `send receiver {"op": "add", "value": 7}` adds to the bundled For example, `send receiver {"op": "add", "value": 7}` adds to the bundled
counter. Its supported operations are `add` and `send`; unknown operations 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, - `runa-core` owns stable source/implementation identities, actor references,
and the portable owned value model shared by the runtime and protocol. and the portable owned value model shared by the runtime and protocol.
- `runa-code` preserves original source, derives identities, caches compiled - `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-rune` owns compilation and typed bounded invocation execution.
- `runa-runtime` owns implementation registration, queues, private committed - `runa-runtime` owns implementation registration, queues, private committed
state, scheduling, message effects and lifecycle coordination. 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 transport, authentication, or authorization layer; do not expose it directly
to untrusted clients. to untrusted clients.
Its install operation also supports the runtime's explicit host-provided Its install operation also supports the runtime's explicit host-provided
state-migration closure. `behavior_source` retrieves the exact original state-migration closure. Revision source retrieval returns the exact saved text.
published source for a selected behavior revision.
An implementation is published once under an ID. Actors reference that exact An implementation is published once under an ID. Actors reference that exact
implementation. Implementation IDs use source content and the compile implementation. Implementation IDs use source content and the compile
fingerprint; actor references are random NanoIDs and mailbox IDs remain fingerprint; actor references are random NanoIDs and mailbox IDs remain
process-local counters. Incarnation counters use checked increments. process-local counters. Incarnation counters use checked increments.
`CodeStore::publish_behavior` compiles source before appending a behavior Source files have canonical case-sensitive relative paths, without `source/`
revision. Creation expects no existing revision; updates require the caller's or `.rn`, rejecting traversal and empty components. Writes require the expected
expected current revision, so stale edits fail without changing the behavior current source identity (null means create). Conflicting saves do not overwrite
reference. Failed compilation also leaves the reference and history unchanged another client's work. Commits are version control only, independent of
(the exact source remains available for diagnostics). Rollback points the compilation and actor installation; invalid Rune source can be committed.
behavior at an earlier immutable implementation and appends a new monotonically Commit IDs are generated from a readable lowercase alphabet, not caller-chosen
increasing revision; it does not rewrite history. Behavior names are revision numbers. Source and history remain in memory until server exit.
case-sensitive paths limited to ASCII letters, digits, `_`, `-`, `.`, and `/`, The older named behavior registry survives as an internal compatibility helper,
with empty, `.` and `..` path components rejected. 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 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 messages may remain; they will use the newly installed implementation. Active
or awaiting handlers keep their original implementation, and installation or awaiting handlers keep their original implementation, and installation
attempts during execution fail explicitly so the caller can retry at the next attempts during execution fail explicitly so the caller can retry at the next
handler boundary. `install` preserves the current structured state unchanged. handler boundary. `install` preserves the current structured state unchanged.
Client-facing `reload` and `become <path>` 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 Runtime callers that need a state transformation can use
`install_with_migration`, which receives the committed state and computes its `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: separate internal counters. Rune handlers have this interface:
```text ```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 The runtime uses internal envelopes containing sender and data, then passes
their own message records. For example: them separately to the handler. Host-originated messages have unit/null sender.
Actors choose their own message data. For example:
```rune ```rune
pub fn handle(state, message) { pub fn handle(ctx, state, sender, message) {
state.items.push(message.text); 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 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 mutate its invocation-local draft but cannot mutate committed state, the
reserved input, or another actor's data. Copies made for separate effect reserved input, or another actor's data. Copies made for separate effect
messages do not share mutable Rune objects. 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 (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. 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. 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 ## 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 silently acknowledged or automatically retried. External sends return errors
for unknown, unavailable, or full actors. for unknown, unavailable, or full actors.
Stopping drops the invocation, retaining committed state, mailbox and reserved System messages are host-only commands, separate from ordinary data messages:
message. Restarting a stopped or failed actor preserves its actor reference, 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 mailbox ID, implementation ID, committed state, and queued-message order; it
increments its incarnation and puts the reserved message at the front of the increments its incarnation and puts the reserved message at the front of the
queue. 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 block later messages until an operator changes the implementation or otherwise
resolves it; there is no automatic poison-message policy or supervision. 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` | | 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` | | 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` | | 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` | | 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` | | 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 | | Resume a paused invocation | Retained until invocation completes | Retained | None until completion | Prior runnable/awaiting status |

View File

@ -1,8 +1,9 @@
pub fn handle(state, message) { pub fn handle(ctx, state, sender, message) {
if message.op == "send" { 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" { } else if message.op == "add" {
(state + message.value, []) state + message.value
} else { } else {
panic("unknown counter operation") panic("unknown counter operation")
} }

View File

@ -1,7 +1,7 @@
# Runa VS Code client # Runa VS Code client
This is a development client for a trusted localhost Runa image. It uses 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 subprotocol, not Rust APIs, and never
hosts actors or starts its own image. 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`; 1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`;
`runa.address` changes the suggested endpoint. `runa.address` changes the suggested endpoint.
2. Expand **Runa Actors** in Explorer. Use its **+** button or **Runa: Spawn Actor** 2. Open the image's `source` folder (see [Image source files](#image-source-files)).
to choose a published behavior, then enter JSON initial state (for example Right-click a `.rn` file and choose **Spawn Actor** (initial state `null`) or
`0` for `counter`). There is no default behavior or implicit state. **Spawn Actor with State** (enter JSON, for example `0` for
The command reports and returns the new actor reference. Mailbox capacity is 64. `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. 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. 4. Right-click an actor to send a JSON message or a system message: pause,
These actions are also available in the Command Palette with an actor picker. 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 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 (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. 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** invalidate the connection and are surfaced in a notification and the **Runa**
output channel. Server-reported operation errors leave the connection usable. output channel. Server-reported operation errors leave the connection usable.
- The client never ticks the scheduler implicitly or drains `get_events`. - 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 Notifications are non-destructive invalidations, not a replayable runtime
event stream; reconnect fetches the current image rather than replaying history. event stream; reconnect fetches the current image rather than replaying history.
For automation, the `runa.connect` command also accepts an explicit endpoint For automation, the `runa.connect` command also accepts an explicit endpoint
string, and `runa.inspect` accepts an object with an `actor_ref` field. string, and `runa.inspect` accepts an object with an `actor_ref` field.
`runa.spawn` accepts `{ behavior: string, state: string }`, where state is `runa.spawn` accepts `{ path: string, state: string }`, where state is
natural JSON text, and resolves to the new reference. `runa.send` accepts natural JSON text, and resolves to the new reference. `runa.spawnSource` and
`{ actor_ref: string }` and JSON message text. `runa.pause`, `runa.resume`, `runa.spawnSourceWithState` accept a source file URI (the latter also optional
`runa.stop`, and `runa.restart` accept `{ actor_ref: string }`. 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 `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. 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. The image owns actor source text. On connect, the extension mounts it as a
Each item shows the current revision; expand it for revision history, newest writable workspace folder named `source` (`runa-image://<endpoint>/source`).
first. Tooltips show the implementation identity and the current revision is In a saved multi-root workspace this happens automatically; otherwise accept the
marked. Click the behavior to open its current source, or a revision to open prompt or run **Runa: Add Image Source Folder** (VS Code may restart extensions
that exact source. **Runa: Open Behavior Source** offers the same choices in when a single-folder window becomes multi-root). Directories are derived from
the Command Palette. 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 - **Save** writes the image's working source with compare-and-swap against the
`.rn` virtual documents, not local workspace files. If a Rune language last observed `source_id`. If another client changed the file, the save fails
extension is installed, VS Code can associate its language support with `.rn`. instead of overwriting; reopen or revert to load the image's text. Saving
The source text is preserved exactly, without metadata headers. The URI identifies never commits. New `.rn` files are created in the image on first save.
the connection session, behavior name, and immutable revision. Documents remain Deleting and renaming are not supported.
cached on disconnect and never turn into the latest revision automatically. - **Spawn** uses the latest saved working source, committed or not. If the
Reconnect opens distinct URIs; closing a source document releases its cache. editor has unsaved changes, spawn and commit ask to save first; cancelling
Cached sources are not persisted across VS Code restarts. 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 Server notifications refresh the source tree and clean open editors. For
same on demand. Browsing does not publish code, install actor behavior, tick the automation, `runa.commitSource` accepts a source URI and returns the revision;
scheduler, or drain runtime events. Source editing and publication remain future `runa.sourceHistory` accepts a URI and optional revision and returns the opened
work. For automation, `runa.openBehaviorSource` accepts document URI; `runa.rollbackSource` accepts a URI and optional revision and
`{ name: string, revision: string }` with a positive decimal revision and returns returns the new `source_id` after confirmation.
the opened document URI.
## Development and validation ## 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. 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/` 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 against a test-owned Rust server. It verifies automatic document updates after
another client sends messages, pauses, and advances an actor into failure, as another client sends messages, pauses, and advances an actor into failure, as
well as session isolation. On headless Linux: 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 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. distribution, and debugging remain future work.

View File

@ -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);
},
);

View File

@ -6,7 +6,7 @@ import { parseValue } from "../src/values";
import { startImage } from "./server"; import { startImage } from "./server";
test( 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 }, { timeout: 10000 },
async (t) => { async (t) => {
const image = await startImage(); const image = await startImage();
@ -16,28 +16,52 @@ test(
await image.stop(); await image.stop();
}); });
client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined)); client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined));
assert.ok((await client.behaviors()).some((behavior) => behavior.name === "counter")); assert.deepEqual(
await assert.rejects(client.spawn("missing", parseValue("0")), ProtocolError); (await client.listSources()).map((file) => file.path),
const ref = await client.spawn("counter", parseValue("5"), 2); ["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}$/); assert.match(ref, /^[A-Za-z0-9_-]{21}$/);
await client.send(ref, parseValue('{"op":"add","value":7}')); 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); await client.tick(100);
let snapshot = await client.inspectActor(ref); let snapshot = await client.inspectActor(ref);
assert.equal(snapshot.status.status, "paused"); assert.equal(snapshot.status.status, "paused");
assert.deepEqual(snapshot.state, parseValue("5")); assert.deepEqual(snapshot.state, parseValue("5"));
assert.equal(snapshot.queued.length, 1); assert.equal(snapshot.queued.length, 1);
await client.lifecycle("resume_actor", ref); await client.system(ref, { kind: "resume" });
await client.tick(100); await client.tick(100);
snapshot = await client.inspectActor(ref); snapshot = await client.inspectActor(ref);
assert.deepEqual(snapshot.state, parseValue("12")); assert.deepEqual(snapshot.state, parseValue("12"));
assert.deepEqual(snapshot.queued, []); 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"); 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); snapshot = await client.inspectActor(ref);
assert.equal(snapshot.incarnation, 2n); assert.equal(snapshot.incarnation, 2n);
assert.deepEqual(snapshot.state, parseValue("12")); 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}"}]}`); const payload = parseValue(`{"nested":[9223372036854775807,{"$actor":"${ref}"}]}`);
await client.send(ref, payload); await client.send(ref, payload);
assert.deepEqual((await client.inspectActor(ref)).queued, [payload]); assert.deepEqual((await client.inspectActor(ref)).queued, [payload]);

View File

@ -1,16 +1,15 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import * as vscode from "vscode"; import * as vscode from "vscode";
import { control, spawnCounter, startImage } from "./server"; import { control, spawnCounter, startImage } from "./server";
import { BehaviorTree, SourceDocuments } from "../src/behaviors"; import { RevisionDocuments } from "../src/sourceProvider";
import { ImageClient } from "../src/client";
export async function run(): Promise<void> { export async function run(): Promise<void> {
const image = await startImage(); const image = await startImage();
try { try {
await vscode.commands.executeCommand("runa.connect", image.address); await vscode.commands.executeCommand("runa.connect", image.address);
await browseBehaviors(image.address); await editSources(image.address);
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", { const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
behavior: "counter", path: "actors/counter",
state: "5", state: "5",
}); });
assert.equal(typeof spawned, "string"); assert.equal(typeof spawned, "string");
@ -39,10 +38,14 @@ export async function run(): Promise<void> {
snapshot = JSON.parse(controlled.getText()).actor; snapshot = JSON.parse(controlled.getText()).actor;
assert.equal(snapshot.state.data, 12); assert.equal(snapshot.state.data, 12);
assert.equal(snapshot.queued.length, 0); 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) => const controlStopped = documentChanged(controlled, (text) =>
text.includes('"status": "stopped"'), 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; await controlStopped;
assert.equal(JSON.parse(controlled.getText()).actor.status.status, "stopped"); assert.equal(JSON.parse(controlled.getText()).actor.status.status, "stopped");
const controlRestarted = documentChanged( const controlRestarted = documentChanged(
@ -54,6 +57,19 @@ export async function run(): Promise<void> {
snapshot = JSON.parse(controlled.getText()).actor; snapshot = JSON.parse(controlled.getText()).actor;
assert.equal(snapshot.incarnation, 2); assert.equal(snapshot.incarnation, 2);
assert.equal(snapshot.state.data, 12); 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); const actorRef = await spawnCounter(image.address);
await vscode.commands.executeCommand("runa.connect", image.address); await vscode.commands.executeCommand("runa.connect", image.address);
await vscode.commands.executeCommand("runa.refresh"); await vscode.commands.executeCommand("runa.refresh");
@ -76,17 +92,20 @@ export async function run(): Promise<void> {
message: { type: "string", data: "queued from another client" }, 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; await refreshed;
assert.match(document.getText(), /queued from another client/); assert.match(document.getText(), /queued from another client/);
const paused = documentChanged(document, (text) => text.includes('"status": "paused"')); 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; await paused;
const failed = documentChanged(document, (text) => text.includes('"status": "failed"')); const failed = documentChanged(document, (text) => text.includes('"status": "failed"'));
await control(image.address, { await control(image.address, {
operation: "resume_actor", operation: "system",
parameters: { actor_ref: actorRef }, parameters: { actor_ref: actorRef, message: { kind: "resume" } },
}); });
await control(image.address, { await control(image.address, {
operation: "tick", operation: "tick",
@ -106,7 +125,7 @@ export async function run(): Promise<void> {
const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri)); const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri));
assert.match(old.getText(), /"connected": false/); assert.match(old.getText(), /"connected": false/);
console.log( 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 { } finally {
await vscode.commands.executeCommand("runa.disconnect"); await vscode.commands.executeCommand("runa.disconnect");
@ -114,98 +133,188 @@ export async function run(): Promise<void> {
} }
} }
async function browseBehaviors(address: string): Promise<void> { async function editSources(address: string): Promise<void> {
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); const folder = vscode.workspace.workspaceFolders?.find(
const tree = new BehaviorTree(); (candidate) => candidate.uri.scheme === "runa-image",
const sources = new SourceDocuments(); );
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 { try {
const source = await client.behaviorSource("counter", 1n); // Unsaved edits are never committed or spawned without an explicit save.
await control(address, { assert.equal(await vscode.commands.executeCommand("runa.commitSource", uri), undefined);
operation: "publish_behavior", assert.equal(await vscode.commands.executeCommand("runa.spawnSource", uri), undefined);
parameters: { assert.equal(prompts.length, 2);
name: "browse-test", assert.deepEqual(prompts[0]?.[1], { modal: true });
expected_revision: null, assert.equal(document.isDirty, true);
source, const unsaved = JSON.stringify(
}, await control(address, { operation: "read_source", parameters: { path: "actors/counter" } }),
});
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,
); );
const version = tree.getChildren(behavior)[0]; assert.ok(unsaved.includes(JSON.stringify(original)));
assert.ok(version); assert.match(
assert.equal(tree.getTreeItem(version).description, "current"); JSON.stringify(await control(address, { operation: "list_actors" })),
assert.deepEqual(tree.getChildren(version), []); /"data":\[\]/,
const item = tree.getTreeItem(behavior);
const opened = await vscode.commands.executeCommand<string>(
"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",
); );
answer = "Save and Spawn";
const saved = await vscode.commands.executeCommand<string>("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 { } finally {
client.close(); window.showWarningMessage = warning;
tree.dispose();
sources.dispose();
} }
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<string>("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<string>(
"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<string>("runa.commitSource", uri);
assert.match(String(revision), /^[A-Za-z0-9]{1,64}$/);
const opened = await vscode.commands.executeCommand<string>("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<string>(
"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<void> {
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( function documentChanged(

View File

@ -52,7 +52,7 @@ test(
const exited = once(repl, "exit"); const exited = once(repl, "exit");
repl.stdin.end( repl.stdin.end(
[ [
"let actor = spawn counter 5", "let actor = spawn actors/counter 5",
'send actor {"op":"add","value":7}', 'send actor {"op":"add","value":7}',
"tick 100", "tick 100",
"inspect actor", "inspect actor",

View File

@ -25,7 +25,7 @@ test(
operation: "send", operation: "send",
parameters: { actor_ref: actorRef, message: payload }, 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[] = []; const failures: Error[] = [];
@ -61,7 +61,7 @@ test(
// Reading from the extension must not drain the shared runtime event queue. // Reading from the extension must not drain the shared runtime event queue.
const events = await control(image.address, { operation: "get_events" }); const events = await control(image.address, { operation: "get_events" });
assert.deepEqual(events, { assert.deepEqual(events, {
version: 3, version: 4,
request_id: "integration-control", request_id: "integration-control",
result: { result: {
Ok: { Ok: {

View File

@ -82,7 +82,7 @@ export async function control(address: string, command: unknown): Promise<unknow
socket.once("close", () => reject(new Error("Control connection closed"))); socket.once("close", () => reject(new Error("Control connection closed")));
socket.once("error", reject); 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; return await response;
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
@ -94,7 +94,7 @@ export async function spawnCounter(address: string): Promise<string> {
const reply = await control(address, { const reply = await control(address, {
operation: "spawn", operation: "spawn",
parameters: { parameters: {
behavior_name: "counter", path: "actors/counter",
state: { type: "integer", data: 9223372036854775807n }, state: { type: "integer", data: 9223372036854775807n },
mailbox_capacity: 4, mailbox_capacity: 4,
}, },

View File

@ -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"'));
},
);

View File

@ -17,7 +17,7 @@ test(
await once(socket, "open"); await once(socket, "open");
assert.equal(socket.protocol, WEBSOCKET_SUBPROTOCOL); assert.equal(socket.protocol, WEBSOCKET_SUBPROTOCOL);
const request = json({ const request = json({
version: 3, version: 4,
request_id: "fragmented", request_id: "fragmented",
command: { operation: "list_actors" }, command: { operation: "list_actors" },
}); });
@ -27,7 +27,7 @@ test(
socket.send(text.slice(100), { fin: true }); socket.send(text.slice(100), { fin: true });
const [response] = await reply; const [response] = await reply;
assert.deepEqual(JSON.parse(String(response)), { assert.deepEqual(JSON.parse(String(response)), {
version: 3, version: 4,
request_id: "fragmented", request_id: "fragmented",
result: { Ok: { kind: "actors", data: [] } }, result: { Ok: { kind: "actors", data: [] } },
}); });
@ -52,7 +52,7 @@ test(
socket.send(" ", { fin: true }); socket.send(" ", { fin: true });
await closed; await closed;
assert.deepEqual(await control(image.address, { operation: "list_actors" }), { assert.deepEqual(await control(image.address, { operation: "list_actors" }), {
version: 3, version: 4,
request_id: "integration-control", request_id: "integration-control",
result: { Ok: { kind: "actors", data: [] } }, result: { Ok: { kind: "actors", data: [] } },
}); });

View File

@ -1,7 +1,7 @@
{ {
"name": "runa", "name": "runa",
"displayName": "Runa — Rune Actors", "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", "version": "0.0.0",
"private": true, "private": true,
"engines": { "engines": {
@ -14,7 +14,7 @@
"main": "./out/src/extension.js", "main": "./out/src/extension.js",
"activationEvents": [ "activationEvents": [
"onView:runa.actors", "onView:runa.actors",
"onView:runa.behaviors" "onFileSystem:runa-image"
], ],
"scripts": { "scripts": {
"compile": "tsc -p .", "compile": "tsc -p .",
@ -39,63 +39,122 @@
"contributes": { "contributes": {
"commands": [ "commands": [
{ {
"command": "runa.openBehaviorSource", "command": "runa.connect",
"title": "Runa: Open Behavior Source", "title": "Connect to Local Image",
"icon": "$(go-to-file)", "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" "enablement": "runa.connected"
}, },
{ {
"command": "runa.spawn", "command": "runa.spawn",
"title": "Runa: Spawn Actor", "title": "Spawn Actor",
"category": "Runa",
"icon": "$(add)", "icon": "$(add)",
"enablement": "runa.connected" "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", "command": "runa.send",
"title": "Runa: Send Message", "title": "Send Message",
"category": "Runa",
"enablement": "runa.connected" "enablement": "runa.connected"
}, },
{ {
"command": "runa.stop", "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" "enablement": "runa.connected"
}, },
{ {
"command": "runa.restart", "command": "runa.restart",
"title": "Runa: Restart Actor", "title": "Restart Actor",
"category": "Runa",
"enablement": "runa.connected" "enablement": "runa.connected"
}, },
{ {
"command": "runa.pause", "command": "runa.pause",
"title": "Runa: Pause Actor", "title": "Pause Actor",
"category": "Runa",
"enablement": "runa.connected" "enablement": "runa.connected"
}, },
{ {
"command": "runa.resume", "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" "enablement": "runa.connected"
}, },
{ {
"command": "runa.tick", "command": "runa.tick",
"title": "Runa: Advance Scheduler", "title": "Advance Scheduler",
"category": "Runa",
"enablement": "runa.connected" "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", "command": "runa.inspect",
"title": "Runa: Inspect Actor", "title": "Inspect Actor",
"category": "Runa",
"icon": "$(inspect)" "icon": "$(inspect)"
} }
], ],
@ -104,19 +163,10 @@
{ {
"id": "runa.actors", "id": "runa.actors",
"name": "Runa Actors" "name": "Runa Actors"
},
{
"id": "runa.behaviors",
"name": "Runa Behaviors"
} }
] ]
}, },
"viewsWelcome": [ "viewsWelcome": [
{
"view": "runa.behaviors",
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
"when": "!runa.connected"
},
{ {
"view": "runa.actors", "view": "runa.actors",
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)", "contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
@ -132,25 +182,24 @@
}, },
{ {
"command": "runa.connect", "command": "runa.connect",
"when": "(view == runa.actors || view == runa.behaviors) && !runa.connected", "when": "view == runa.actors && !runa.connected",
"group": "navigation" "group": "navigation"
}, },
{ {
"command": "runa.refresh", "command": "runa.refresh",
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected", "when": "view == runa.actors && runa.connected",
"group": "navigation" "group": "navigation"
}, },
{
"command": "runa.addSourceFolder",
"when": "view == runa.actors && runa.connected"
},
{ {
"command": "runa.disconnect", "command": "runa.disconnect",
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected" "when": "view == runa.actors && runa.connected"
} }
], ],
"view/item/context": [ "view/item/context": [
{
"command": "runa.openBehaviorSource",
"when": "view == runa.behaviors && viewItem == runa.behavior",
"group": "inline"
},
{ {
"command": "runa.send", "command": "runa.send",
"when": "view == runa.actors && viewItem == runa.actor", "when": "view == runa.actors && viewItem == runa.actor",
@ -172,15 +221,106 @@
"group": "actor@4" "group": "actor@4"
}, },
{ {
"command": "runa.restart", "command": "runa.kill",
"when": "view == runa.actors && viewItem == runa.actor", "when": "view == runa.actors && viewItem == runa.actor",
"group": "actor@5" "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", "command": "runa.inspect",
"when": "view == runa.actors && viewItem == runa.actor", "when": "view == runa.actors && viewItem == runa.actor",
"group": "inline" "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": { "configuration": {

View File

@ -1,11 +1,23 @@
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs");
const { runTests } = require("@vscode/test-electron"); 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({ runTests({
version: "1.96.4", version: "1.96.4",
extensionDevelopmentPath: path.resolve(__dirname, ".."), extensionDevelopmentPath: path.resolve(__dirname, ".."),
extensionTestsPath: path.resolve(__dirname, "../out/integration/host.js"), extensionTestsPath: path.resolve(__dirname, "../out/integration/host.js"),
launchArgs: [ launchArgs: [
workspaceFile,
"--disable-gpu", "--disable-gpu",
"--no-sandbox", "--no-sandbox",
"--skip-welcome", "--skip-welcome",

View File

@ -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<Behavior | BehaviorRevision> {
private readonly changes = new vscode.EventEmitter<void>();
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<string, string>();
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();
}
}

View File

@ -4,17 +4,20 @@ import WebSocket, { RawData } from "ws";
import { stringify } from "lossless-json"; import { stringify } from "lossless-json";
import { import {
ActorSnapshot, ActorSnapshot,
Behavior,
decodeNotification, decodeNotification,
decodeResponse, decodeResponse,
decodeValue, decodeValue,
isActorRef, isActorRef,
isSourcePath,
isSourceRevision,
MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES,
PROTOCOL_VERSION, PROTOCOL_VERSION,
ProtocolError, ProtocolError,
Command, Command,
Reply, Reply,
LifecycleOperation, SourceCommit,
SourceFile,
SystemMessage,
Value, Value,
WEBSOCKET_SUBPROTOCOL, WEBSOCKET_SUBPROTOCOL,
} from "./protocol"; } from "./protocol";
@ -35,6 +38,18 @@ export function parseEndpoint(endpoint: string): { host: string; port: number }
return { host, port }; 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 { interface Pending {
requestId: string; requestId: string;
command: Command; command: Command;
@ -117,36 +132,81 @@ export class ImageClient {
return reply.data; return reply.data;
} }
async behaviors(): Promise<Behavior[]> { async listSources(): Promise<SourceFile[]> {
const reply = await this.request({ operation: "get_behaviors" }); const reply = await this.request({ operation: "list_sources" });
if (reply.kind !== "behaviors") throw new Error("Expected behavior listing"); if (reply.kind !== "sources") throw new Error("Expected source listing");
return reply.data; return reply.data;
} }
async behaviorSource(name: string, revision: bigint): Promise<string> { async readSource(path: string): Promise<SourceFile> {
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");
}
const reply = await this.request({ const reply = await this.request({
operation: "get_behavior_source", operation: "read_source",
parameters: { name, revision }, 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<SourceFile> {
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<SourceCommit> {
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<SourceCommit[]> {
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<string> {
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; return reply.data.source;
} }
async spawn(behavior: string, state: Value, capacity = 64): Promise<string> { async rollbackSource(path: string, revision: string): Promise<SourceFile> {
if (!behavior.trim()) throw new Error("Specify a behavior to spawn"); 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<string> {
if (!Number.isSafeInteger(capacity) || capacity < 1) if (!Number.isSafeInteger(capacity) || capacity < 1)
throw new Error("Mailbox capacity must be positive"); throw new Error("Mailbox capacity must be positive");
const reply = await this.request({ const reply = await this.request({
operation: "spawn", operation: "spawn",
parameters: { parameters: { path: sourcePath(path), state: decodeValue(state), mailbox_capacity: capacity },
behavior_name: behavior,
state: decodeValue(state),
mailbox_capacity: capacity,
},
}); });
if (reply.kind !== "actor_spawned") throw new Error("Expected spawned actor reference"); if (reply.kind !== "actor_spawned") throw new Error("Expected spawned actor reference");
return reply.data.actor_ref; return reply.data.actor_ref;
@ -160,9 +220,20 @@ export class ImageClient {
}); });
} }
async lifecycle(operation: LifecycleOperation, actorRef: string): Promise<void> { async system(actorRef: string, message: SystemMessage): Promise<void> {
if (!isActorRef(actorRef)) throw new Error("Invalid actor reference"); 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<void> { async tick(ticks: number, budget = 32): Promise<void> {

View File

@ -1,9 +1,18 @@
import * as vscode from "vscode"; import * as vscode from "vscode";
import { ImageClient, parseEndpoint } from "./client"; import { ImageClient, parseEndpoint } from "./client";
import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol"; import { ActorSnapshot, formatSnapshot, isSourcePath, SystemKind, SystemMessage } from "./protocol";
import { RefreshQueue } from "./refresh"; import { RefreshQueue } from "./refresh";
import { parseValue } from "./values"; 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<ActorSnapshot> { class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
private readonly changes = new vscode.EventEmitter<void>(); private readonly changes = new vscode.EventEmitter<void>();
@ -115,14 +124,13 @@ class SnapshotDocuments implements vscode.TextDocumentContentProvider {
} }
} }
type SourceTarget = vscode.Uri | { path: string } | string | undefined;
export function activate(context: vscode.ExtensionContext): void { export function activate(context: vscode.ExtensionContext): void {
const tree = new ActorTree(); const tree = new ActorTree();
const documents = new SnapshotDocuments(); const documents = new SnapshotDocuments();
const behaviors = new BehaviorTree(); const sources = new ImageSourceFileSystem();
const sources = new SourceDocuments(); const revisions = new RevisionDocuments();
const behaviorView = vscode.window.createTreeView("runa.behaviors", {
treeDataProvider: behaviors,
});
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree }); const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
const output = vscode.window.createOutputChannel("Runa"); const output = vscode.window.createOutputChannel("Runa");
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); 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"; status.command = connected ? "runa.refresh" : "runa.connect";
view.description = connected ? endpoint : "Disconnected"; view.description = connected ? endpoint : "Disconnected";
view.message = connected ? undefined : "Not connected to an image."; 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 = () => { const disconnect = () => {
@ -160,10 +166,10 @@ export function activate(context: vscode.ExtensionContext): void {
const previous = client; const previous = client;
client = undefined; client = undefined;
documents.disconnect(session); documents.disconnect(session);
sources.store.detach();
session++; session++;
previous?.close(); previous?.close();
tree.replace([]); tree.replace([]);
behaviors.replace([]);
connectionStatus(false); connectionStatus(false);
}; };
@ -172,13 +178,53 @@ export function activate(context: vscode.ExtensionContext): void {
const currentSession = session; const currentSession = session;
if (!current) throw new Error("Connect to a local image first"); if (!current) throw new Error("Connect to a local image first");
const actors = await current.listActors(); 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; if (current !== client || currentSession !== session) return;
tree.replace(actors); tree.replace(actors);
behaviors.replace(listing);
behaviorView.message = listing.length ? undefined : "No published behaviors.";
documents.refresh(session, endpoint, actors); 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<boolean> => {
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<void>((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) => { const connect = async (target?: string) => {
@ -216,8 +262,8 @@ export function activate(context: vscode.ExtensionContext): void {
client = undefined; client = undefined;
updates.dispose(); updates.dispose();
tree.replace([]); tree.replace([]);
behaviors.replace([]);
documents.disconnect(currentSession); documents.disconnect(currentSession);
sources.store.detach();
connectionStatus(false); connectionStatus(false);
output.appendLine(`Disconnected from ${address}`); output.appendLine(`Disconnected from ${address}`);
if (error) report(error); if (error) report(error);
@ -234,6 +280,7 @@ export function activate(context: vscode.ExtensionContext): void {
return; return;
} }
client = connected; client = connected;
sources.store.attach(address, currentSession, connected);
try { try {
await refresh(); await refresh();
} catch (error) { } catch (error) {
@ -244,8 +291,9 @@ export function activate(context: vscode.ExtensionContext): void {
connectionStatus(true); connectionStatus(true);
view.message = tree.actors.length view.message = tree.actors.length
? undefined ? undefined
: "No actors. Use Runa: Spawn Actor to create one."; : "No actors. Spawn a .rn file from the source folder.";
output.appendLine(`Connected to ${address} (protocol v3)`); output.appendLine(`Connected to ${address} (protocol v4)`);
await offerSourceFolder(address);
}; };
const inspect = async (selected?: ActorSnapshot) => { 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"); if (!client) throw new Error("Connect to a local image first");
const current = client; const current = client;
const currentSession = session; const currentSession = session;
const currentEndpoint = endpoint;
return { return {
client: current, client: current,
session: currentSession,
endpoint: currentEndpoint,
check: () => { check: () => {
if (current !== client || currentSession !== session) { if (current !== client || currentSession !== session) {
throw new Error("Connection changed while entering the command; try again"); 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<string | undefined> => {
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<string | undefined> => {
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<typeof active>,
path: string,
action: string,
): Promise<boolean> => {
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 connection = active();
const currentSession = session; const path = options?.path ?? (await pickSource("Select the source file to spawn"));
let target = selected; if (path === undefined) return;
if (!target) { if (!isSourcePath(path)) throw new Error("Invalid source path");
const listing = await connection.client.behaviors(); const state = options?.state ?? (await jsonInput(`Initial state for ${path}`));
connection.check(); if (state === undefined) return;
const choice = await vscode.window.showQuickPick( return spawnPath(path, state, connection);
listing.flatMap((behavior) => };
[...behavior.history].reverse().map((version) => ({
label: behavior.name, const spawnSource = async (target?: SourceTarget) => {
description: `revision ${version.revision}${version.revision === behavior.revision ? " (current)" : ""}`, const connection = active();
target: { name: behavior.name, revision: String(version.revision) }, const path = await sourceTarget(target, connection.endpoint, "Select the source to spawn");
})), if (path === undefined) return;
), return spawnPath(path, "null", connection);
{ placeHolder: "Choose a behavior revision to view" }, };
);
target = choice?.target; const spawnSourceWithState = async (target?: SourceTarget, stateText?: string) => {
} const connection = active();
if (!target) return; const path = await sourceTarget(target, connection.endpoint, "Select the source to spawn");
if ( if (path === undefined) return;
(typeof target.revision !== "string" && typeof target.revision !== "bigint") || const state = stateText ?? (await jsonInput(`Initial state for ${path}`));
!/^[1-9]\d*$/.test(String(target.revision)) if (state === undefined) return;
) { return spawnPath(path, state, connection);
throw new Error("Specify a positive decimal behavior revision"); };
}
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(); connection.check();
const revision = BigInt(target.revision); const commit = await connection.client.commitSource(path);
const source = await connection.client.behaviorSource(target.name, revision); 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<typeof active>,
path: string,
placeHolder: string,
): Promise<string | undefined> => {
const history = await connection.client.sourceHistory(path);
connection.check(); 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); const document = await vscode.workspace.openTextDocument(uri);
connection.check(); connection.check();
await vscode.window.showTextDocument(document, { preview: true }); await vscode.window.showTextDocument(document, { preview: true });
return uri.toString(); return uri.toString();
}; };
const spawn = async (options?: { behavior: string; state: string }) => { const rollbackSource = async (target?: SourceTarget, revision?: string) => {
const connection = active(); 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(); connection.check();
const behavior = const file = await connection.client.rollbackSource(path, chosen);
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;
connection.check(); connection.check();
const actorRef = await connection.client.spawn(behavior, parseValue(state)); sources.store.apply(file);
output.appendLine(`Spawned @${actorRef} with behavior ${behavior}`); output.appendLine(`Rolled back working source ${path} to revision ${chosen}`);
if (client === connection.client) { return file.source_id;
void vscode.window.showInformationMessage(`Runa: Spawned @${actorRef}`);
}
return actorRef;
}; };
const send = async (selected?: { actor_ref: string }, source?: string) => { 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}`); 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 connection = active();
const actorRef = await chooseActor(selected); const actorRef = await chooseActor(selected);
if (actorRef === undefined) return; if (actorRef === undefined) return;
connection.check(); connection.check();
await connection.client.lifecycle(operation, actorRef); await connection.client.system(actorRef, { kind });
output.appendLine(`${operation}: @${actorRef}`); 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) => { 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( context.subscriptions.push(
tree, tree,
behaviors,
sources, sources,
behaviorView, revisions,
documents, documents,
view, view,
output, output,
status, status,
vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }),
vscode.workspace.registerTextDocumentContentProvider("runa", documents), vscode.workspace.registerTextDocumentContentProvider("runa", documents),
vscode.workspace.registerTextDocumentContentProvider("runa-source", sources), vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
vscode.workspace.onDidCloseTextDocument((document) => { vscode.workspace.onDidCloseTextDocument((document) => {
documents.remove(document.uri); documents.remove(document.uri);
sources.remove(document.uri); revisions.remove(document.uri);
}), }),
command("runa.connect", connect), command("runa.connect", connect),
command("runa.disconnect", disconnect), command("runa.disconnect", disconnect),
command("runa.refresh", refresh), command("runa.refresh", refresh),
command("runa.addSourceFolder", () => addSourceFolder()),
command("runa.inspect", inspect), command("runa.inspect", inspect),
command("runa.openBehaviorSource", openBehaviorSource),
command("runa.spawn", spawn), 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.send", send),
command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)), systemCommand("stop"),
command("runa.restart", (selected?: { actor_ref: string }) => systemCommand("kill"),
lifecycle("restart_actor", selected), systemCommand("restart"),
), systemCommand("pause"),
command("runa.pause", (selected?: { actor_ref: string }) => lifecycle("pause_actor", selected)), systemCommand("resume"),
command("runa.resume", (selected?: { actor_ref: string }) => systemCommand("reload"),
lifecycle("resume_actor", selected), command("runa.become", become),
),
command("runa.tick", tick), command("runa.tick", tick),
{ {
dispose: () => { dispose: () => {

View File

@ -1,6 +1,6 @@
import { parse, stringify } from "lossless-json"; 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 MAX_MESSAGE_BYTES = 1024 * 1024;
export const WEBSOCKET_SUBPROTOCOL = "runa-debug"; export const WEBSOCKET_SUBPROTOCOL = "runa-debug";
const ACTOR_REF = /^[A-Za-z0-9_-]{21}$/; const ACTOR_REF = /^[A-Za-z0-9_-]{21}$/;
@ -32,22 +32,36 @@ export interface ActorSnapshot {
reserved: Value | null; 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 { export interface SourceFile {
name: string; path: string;
revision: bigint; source_id: string;
current_implementation_id: string; source: string;
history: { revision: bigint; implementation_id: string }[]; }
export interface SourceCommit {
revision: string;
source_id: string;
} }
export type Command = export type Command =
| { operation: "list_actors" | "get_behaviors" } | { operation: "list_actors" | "list_sources" }
| { operation: "get_behavior_source"; parameters: { name: string; revision: bigint } } | { operation: "read_source" | "commit_source" | "source_history"; parameters: { path: string } }
| { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: 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"; 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: "send"; parameters: { actor_ref: string; message: Value } }
| { operation: "tick"; parameters: { ticks: number; instruction_budget: number } }; | { 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); 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<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); 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 = export type Reply =
| { kind: "actors"; data: ActorSnapshot[] } | { kind: "actors"; data: ActorSnapshot[] }
| { kind: "actor"; 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: "actor_spawned"; data: { actor_ref: string } }
| { kind: "acknowledged" } | { kind: "acknowledged" }
| { kind: "behavior_source"; data: { source: string } } | { 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)); throw new ProtocolError(code, text(error.message));
} }
const reply = object(result.Ok); 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) } }; return { kind: "behavior_source", data: { source: text(object(reply.data).source) } };
} }
if (command.operation === "get_behaviors" && reply.kind === "behaviors") { if (command.operation === "list_sources" && reply.kind === "sources") {
const data = array(reply.data).map((input): Behavior => { const data = array(reply.data).map(decodeSourceFile);
const behavior = object(input); if (new Set(data.map((file) => file.path)).size !== data.length) {
const name = text(behavior.name); throw new Error("Duplicate source paths");
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");
} }
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") { if (command.operation === "spawn" && reply.kind === "actor_spawned") {
const data = object(reply.data); const data = object(reply.data);
@ -274,9 +328,7 @@ export function decodeResponse(line: string, requestId: string, command: Command
return { kind: "tick_completed", data: { ticks } }; return { kind: "tick_completed", data: { ticks } };
} }
if ( if (
["send", "stop_actor", "restart_actor", "pause_actor", "resume_actor"].includes( (command.operation === "send" || command.operation === "system") &&
command.operation,
) &&
reply.kind === "acknowledged" reply.kind === "acknowledged"
) )
return { kind: "acknowledged" }; return { kind: "acknowledged" };

View File

@ -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://<endpoint>/source`. */
export class ImageSourceFileSystem implements vscode.FileSystemProvider {
private readonly changes = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
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<T>(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<T>(uri: vscode.Uri, action: () => Promise<T>): Promise<T> {
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<Uint8Array> {
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<void> {
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<string, string>();
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();
}
}

363
vscode/src/sources.ts Normal file
View File

@ -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<SourceFile[]>;
readSource(path: string): Promise<SourceFile>;
writeSource(path: string, expectedSourceId: string | null, source: string): Promise<SourceFile>;
}
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<string, Known>;
directories: Set<string>;
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<string, { source_id: string; mtime: number }>();
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<void> {
const session = this.require();
const listing = await session.client.listSources();
this.check(session);
const changes: SourceChange[] = [];
const seen = new Set<string>();
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<string, "file" | "directory">();
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<string> {
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<void> {
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));
}
}

View File

@ -29,7 +29,7 @@ test("fragmented UTF-8 replies and simultaneous reads preserve correlation and v
const operations: string[] = []; const operations: string[] = [];
const address = await fakeImage(t, (request, socket) => { const address = await fakeImage(t, (request, socket) => {
operations.push(request.command.operation); operations.push(request.command.operation);
assert.equal(request.version, 3); assert.equal(request.version, 4);
const reply = const reply =
request.command.operation === "list_actors" request.command.operation === "list_actors"
? { kind: "actors", data: [actor()] } ? { kind: "actors", data: [actor()] }
@ -54,7 +54,7 @@ test("protocol errors preserve the connection and are never retried", async (t)
socket.send( socket.send(
requests === 1 requests === 1
? json({ ? json({
version: 3, version: 4,
request_id: request.request_id, request_id: request.request_id,
result: { Err: { code: "not_found", message: "Missing actor" } }, 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()); t.after(() => client.close());
const overhead = Buffer.byteLength( const overhead = Buffer.byteLength(
json({ json({
version: 3, version: 4,
request_id: "runa-vscode-1", request_id: "runa-vscode-1",
command: { command: {
operation: "send", 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); const client = await ImageClient.connect(address, () => undefined);
t.after(() => client.close()); t.after(() => client.close());
const results = await Promise.allSettled([ 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" }), client.send(REF, { type: "null" }),
]); ]);
assert.ok(results.every((result) => result.status === "rejected")); 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.send('{"version":3', { fin: false });
socket.terminate(); socket.terminate();
} else if (mode === "binary") socket.send(Buffer.from("{}")); } 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 else
socket.send( socket.send(
json({ json({
@ -294,3 +294,73 @@ test("failed connection reports the error", async () => {
/ECONNREFUSED/, /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<string, unknown> = {
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 },
},
]);
});

View File

@ -38,11 +38,11 @@ export function json(value: unknown): string {
export interface TestRequest { export interface TestRequest {
version: number; version: number;
request_id: string; request_id: string;
command: { operation: string; parameters?: { actor_ref: string } }; command: { operation: string; parameters?: Record<string, unknown> };
} }
export function response(request: TestRequest, reply: unknown): string { 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( export async function fakeImage(
@ -74,5 +74,5 @@ export async function fakeImage(
} }
export function notification(revision: bigint): string { 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 } } });
} }

View File

@ -6,6 +6,8 @@ import {
decodeNotification, decodeNotification,
decodeValue, decodeValue,
formatSnapshot, formatSnapshot,
isSourcePath,
isSourceRevision,
ProtocolError, ProtocolError,
Command, Command,
} from "../src/protocol"; } from "../src/protocol";
@ -13,12 +15,12 @@ import { actor, json, REF } from "./fixtures";
const command: Command = { operation: "list_actors" }; const command: Command = { operation: "list_actors" };
const envelope = (reply: unknown) => 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", () => { test("notification envelopes preserve u64 revisions and reject malformed events", () => {
const notification = (data: unknown) => const notification = (data: unknown) =>
json({ json({
version: 3, version: 4,
event: { event: {
event: "image_changed", event: "image_changed",
data, 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 snapshot = actor();
const reply = decodeResponse(envelope({ kind: "actors", data: [snapshot] }), "test", command); const reply = decodeResponse(envelope({ kind: "actors", data: [snapshot] }), "test", command);
assert.deepEqual(reply, { kind: "actors", data: [snapshot] }); assert.deepEqual(reply, { kind: "actors", data: [snapshot] });
@ -76,7 +78,7 @@ test("version, correlation, result, reply kind, and actor identity are checked",
assert.throws(() => assert.throws(() =>
decodeResponse( decodeResponse(
json({ json({
version: 3, version: 4,
request_id: "test", request_id: "test",
result: { Ok: { kind: "actors", data: [] } }, result: { Ok: { kind: "actors", data: [] } },
...change, ...change,
@ -102,7 +104,7 @@ test("server errors are explicit, typed, and not treated as empty actor lists",
() => () =>
decodeResponse( decodeResponse(
json({ json({
version: 3, version: 4,
request_id: "test", request_id: "test",
result: { Err: { code: "not_found", message: "No actor" } }, 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 = { const spawn: Command = {
operation: "spawn", operation: "spawn",
parameters: { parameters: {
behavior_name: "counter", path: "actors/counter",
state: { type: "integer", data: 0n }, state: { type: "integer", data: 0n },
mailbox_capacity: 64, mailbox_capacity: 64,
}, },
@ -138,17 +140,18 @@ test("mutation replies must match the requested operation and carry validated da
assert.throws(() => assert.throws(() =>
decodeResponse(envelope({ kind: "tick_completed", data: { ticks: 11 } }), "test", tick), decodeResponse(envelope({ kind: "tick_completed", data: { ticks: 11 } }), "test", tick),
); );
for (const operation of [ const commands: Command[] = [
"stop_actor", { operation: "send", parameters: { actor_ref: REF, message: { type: "null" } } },
"restart_actor", ...(["stop", "kill", "restart", "pause", "resume", "reload"] as const).map((kind): Command => ({
"pause_actor", operation: "system",
"resume_actor", parameters: { actor_ref: REF, message: { kind } },
"send", })),
] as const) { {
const command: Command = operation: "system",
operation === "send" parameters: { actor_ref: REF, message: { kind: "become", path: "actors/counter" } },
? { operation, parameters: { actor_ref: REF, message: { type: "null" } } } },
: { operation, parameters: { actor_ref: REF } }; ];
for (const command of commands) {
assert.deepEqual(decodeResponse(envelope({ kind: "acknowledged" }), "test", command), { assert.deepEqual(decodeResponse(envelope({ kind: "acknowledged" }), "test", command), {
kind: "acknowledged", 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", () => { test("source paths are canonical extensionless relative paths", () => {
const behavior = { for (const path of ["actors/counter", "a", "x/y_z/W-1"]) assert.ok(isSourcePath(path), path);
name: "counter", for (const path of [
revision: 1n, "",
current_implementation_id: "id", "/actors/counter",
history: [{ revision: 1n, implementation_id: "id" }], "../counter",
}; "actors/../counter",
assert.deepEqual( "actors//counter",
decodeResponse(envelope({ kind: "behaviors", data: [behavior] }), "test", { "source/actors/counter",
operation: "get_behaviors", "actors/counter.rn",
}), "actors/my.counter",
{ kind: "behaviors", data: [behavior] }, "actors\\counter",
); "actors/",
for (const data of [ "x".repeat(129),
[{ ...behavior, name: "" }],
[{ ...behavior, revision: 0 }],
[{ ...behavior, history: [{ revision: 0, implementation_id: "id" }] }],
[behavior, behavior],
]) ])
assert.throws(() => assert.ok(!isSourcePath(path), path);
decodeResponse(envelope({ kind: "behaviors", data }), "test", { operation: "get_behaviors" }), 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", () => { test("source listings and files validate paths, identities, and uniqueness", () => {
const command: Command = { const file = {
operation: "get_behavior_source", path: "actors/counter",
parameters: { source_id: "source:1",
name: "counter", source: "pub fn handle() {} // 雪\n",
revision: 18446744073709551615n,
},
}; };
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( assert.deepEqual(
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", command), decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", command),
{ kind: "behavior_source", data: { source } }, { 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: "actors", data: [] }), "test", command));
assert.throws(() => assert.throws(() =>
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", { decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", {
operation: "get_behaviors", operation: "list_sources",
}), }),
); );
}); });

254
vscode/test/sources.test.ts Normal file
View File

@ -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<string, SourceFile>();
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<SourceFile[]> {
return [...this.files.values()];
}
async readSource(path: string): Promise<SourceFile> {
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<SourceFile> {
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);
});