From c851fefa98f7fcedcaa74dcd841725761899ccb0 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 3 Nov 2026 18:52:10 +0100 Subject: [PATCH] feat: add editable actor inspection and context self references Add scheduler toolbar and copy-reference actions, guarded inspection JSON saves with protected-field warnings, and read-only ctx.this for self-messaging. Preserve exact actor values and unsaved edits across refreshes, and test the save lifecycle in the VS Code host. Isolate the CLI forwarding test from the editable counter example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + README.md | 11 +- crates/runa-cli/src/main.rs | 16 ++- crates/runa-rune/src/context.rs | 4 + crates/runa-rune/src/lib.rs | 3 +- crates/runa-runtime/src/lib.rs | 33 ++++++ docs/runtime.md | 3 + vscode/README.md | 20 +++- vscode/integration/host.ts | 53 +++++++++- vscode/package.json | 22 ++++ vscode/src/extension.ts | 110 +++++++------------- vscode/src/snapshotEdits.ts | 68 +++++++++++++ vscode/src/snapshotProvider.ts | 160 ++++++++++++++++++++++++++++++ vscode/test/snapshotEdits.test.ts | 59 +++++++++++ 14 files changed, 479 insertions(+), 86 deletions(-) create mode 100644 vscode/src/snapshotEdits.ts create mode 100644 vscode/src/snapshotProvider.ts create mode 100644 vscode/test/snapshotEdits.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c91f561..0a1355b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ All notable changes to this project will be documented in this file. ### Added +- Editable VS Code inspection JSON for inactive actors, restoring protected + fields with a warning; actor-reference copy actions and a scheduler toolbar button. +- Read-only `ctx.this` actor reference for transactional self-messaging. - Guarded actor state and reserved/queued message editing through the CLI, REPL, and VS Code for paused/stopped/failed actors without active execution. - Immediate actor logs via `ctx.log(level, message)` and severity helpers, diff --git a/README.md b/README.md index 4d10e28..4e33b0e 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ effects. Source and compiled implementation identities are BLAKE3 content addresses with an in-memory compile cache. Persistence, canonicalization, remote network access, and the graphical world are not implemented yet. A minimal VS Code extension can connect to the local image, list actors, and -inspect state, queued/reserved messages, and execution telemetry in read-only -documents. The image owns mutable, directory-based Rune source files with +inspect state, queued/reserved messages, and execution telemetry. Inspection +JSON is editable for inactive paused/stopped/failed actors; saving restores +protected fields with a warning. The image owns mutable, directory-based Rune source files with independent commit history. The CLI exposes source writes, commit/history/rollback, reload/become, graceful shutdown, kill, restart, pause/resume, and cancellation through a trusted image API. A versioned transport-neutral protocol contract is now defined, but no @@ -211,6 +212,9 @@ actors. `logs` prints retained history; `logs -f` additionally subscribes to future entries until Ctrl+C, which returns to the REPL or closes a one-shot CLI. In VS Code, actor context menus offer **Edit Actor State...**, **Edit Reserved and Queued Messages...**, and **Set Actor Log Level...**. +You can also edit those fields directly in inspection JSON and save. Protected +fields are restored with a warning. The Actors toolbar advances the scheduler, +and **Copy Actor Reference** copies a reference from the actor context menu. Rune handlers can call `ctx.log("info", "message")`, or `ctx.trace`, `ctx.debug`, `ctx.info`, `ctx.warn`, `ctx.error`, and `ctx.fatal` with a string message. @@ -222,6 +226,9 @@ survive handler failure and may repeat on retries. Each actor keeps its latest preserves logs and the threshold. Server exit loses them. Rebuild the server, CLI, and extension together for protocol v5. +The read-only `ctx.this` field is the current actor's typed reference: +`ctx.send(ctx.this, message)` queues a normal transactional message to itself. + ## Development Install [mise](https://mise.jdx.dev/) and run `mise install` to install Tombi. diff --git a/crates/runa-cli/src/main.rs b/crates/runa-cli/src/main.rs index 8ba3ea8..41d6885 100644 --- a/crates/runa-cli/src/main.rs +++ b/crates/runa-cli/src/main.rs @@ -1509,7 +1509,21 @@ mod tests { #[test] fn repl_drives_actor_messages_and_inspection() -> Result<(), Box> { - let mut server = server(); + let mut server = Server::new(); + dispatch( + &mut server, + Command::WriteSource { + path: COUNTER_BEHAVIOR.to_owned(), + expected_source_id: None, + source: r#"pub fn handle(ctx, state, sender, message) { + if message.op == "send" { + ctx.send(message.target, #{op: "add", value: message.value}); + state + 1 + } else { state + message.value } + }"# + .to_owned(), + }, + )?; let input = io::Cursor::new( "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", ); diff --git a/crates/runa-rune/src/context.rs b/crates/runa-rune/src/context.rs index ae94654..4367130 100644 --- a/crates/runa-rune/src/context.rs +++ b/crates/runa-rune/src/context.rs @@ -58,6 +58,10 @@ impl ActorLogs { } impl ActorContext { + pub(crate) fn this(&self) -> crate::value::ActorReference { + crate::value::ActorReference(self.actor) + } + pub fn new(actor: ActorRef) -> Self { Self { actor, diff --git a/crates/runa-rune/src/lib.rs b/crates/runa-rune/src/lib.rs index 770744d..c83f2e5 100644 --- a/crates/runa-rune/src/lib.rs +++ b/crates/runa-rune/src/lib.rs @@ -13,7 +13,7 @@ use rune::termcolor::{ColorChoice, StandardStream}; use rune::{Context, Diagnostics, Module, Source, Sources, Vm}; pub const RUNE_VERSION: &str = "0.14.2"; -pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v2:send,send_from,log,trace,debug,info,warn,error,fatal"; +pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v3:this,send,send_from,log,trace,debug,info,warn,error,fatal"; mod execution; pub use execution::{Invocation, InvocationProgress, Slice}; @@ -64,6 +64,7 @@ impl CompiledProgram { let mut values = Module::with_crate("runa")?; values.ty::()?; values.ty::()?; + values.field_function(&rune::runtime::Protocol::GET, "this", ActorContext::this)?; values.associated_function("send", ActorContext::send)?; values.associated_function("send_from", ActorContext::send_from)?; values.associated_function("log", ActorContext::log)?; diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index c51fd87..efc500f 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -1671,4 +1671,37 @@ mod tests { assert!(completed.reserved.is_none()); Ok(()) } + #[test] + fn context_this_sends_to_self_and_cannot_be_reassigned() -> ExperimentResult<()> { + let mut runtime = Runtime::default(); + let implementation = publish( + &mut runtime, + r#" + pub fn handle(ctx, state, sender, message) { + if message == 1 { ctx.send(ctx.this, 2); } + state + message + } + "#, + )?; + let actor = runtime.spawn(implementation, Value::Integer(0), capacity(4))?; + runtime.send(actor, Value::Integer(1))?; + for _ in 0..20 { + runtime.tick(capacity(32)); + } + assert_eq!(runtime.inspect(actor)?.state, Value::Integer(3)); + assert_eq!(runtime.inspect(actor)?.status, Status::Idle); + let invalid = publish( + &mut runtime, + r#" + pub fn handle(ctx, state, sender, message) { ctx.this = sender; state } + "#, + )?; + let actor = runtime.spawn(invalid, Value::Null, capacity(2))?; + runtime.send(actor, Value::Null)?; + for _ in 0..10 { + runtime.tick(capacity(32)); + } + assert!(matches!(runtime.inspect(actor)?.status, Status::Failed(_))); + Ok(()) + } } diff --git a/docs/runtime.md b/docs/runtime.md index ffcc57b..a30857e 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -237,6 +237,9 @@ pub fn handle(ctx, state, sender, message) { The runtime allocates a fresh Rune object graph for each state and message input, and copies portable data back out before committing. `ctx.send` copies message data and records the executing actor as sender. +The read-only `ctx.this` field returns the current actor's typed reference; +`ctx.send(ctx.this, message)` sends a normal transactional message to itself. +Assigning `ctx.this` is not supported and fails execution. `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 diff --git a/vscode/README.md b/vscode/README.md index 1100e7d..d8cb792 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -35,7 +35,8 @@ In that host: 4. Right-click an actor to send a JSON message or a system message: pause, resume, stop, kill, restart, reload, or become another source path. These actions are also available in the Command Palette with an actor picker. -5. Use **Runa: Advance Scheduler** to execute an explicit number of ticks +5. Use the **Advance Scheduler** button in the Actors toolbar (or + **Runa: Advance Scheduler**) to execute an explicit number of ticks (instruction budget 32 per actor). Send only queues a message; it does not execute it. For the counter, send `{"op":"add","value":7}`, then advance. 6. Run **Runa: Disconnect** to close only this client connection. @@ -63,7 +64,16 @@ machine running the extension host, not necessarily the desktop. committed state, FIFO queued messages, the reserved message, status, and active-execution telemetry. Tagged values distinguish actor references from strings and preserve signed 64-bit integers exactly. -- Documents are read-only snapshots, not editable actor state or live VM frames. +- Documents can be edited and saved while the actor is paused, stopped, or failed + without an active invocation. Edit the tagged `actor.state`, `actor.reserved`, + `actor.queued`, and `actor.log_level` fields. Saving applies changed writable + fields; changes to identity, status, telemetry, connection metadata, and other + protected fields are discarded with a warning. The editor then reloads the + canonical server snapshot. Invalid writable fields reject the save. + Running, idle, suspended, and disconnected actors are read-only. + Unsaved edits are not replaced by automatic refreshes; saving rechecks the + current actor status. State/message edits and log-level changes are separate + server operations, not one transaction. The timestamp records when this client received the data, not a runtime clock. Server `image_changed` notifications automatically update the tree and open documents. Bursts are coalesced, and changes arriving during a fetch trigger @@ -74,8 +84,8 @@ machine running the extension host, not necessarily the desktop. unresponsive peer is forcibly disconnected after five seconds. Protocol corruption and request failures still terminate the connection without retry. Each connection gets distinct document URIs; reconnecting never replaces - snapshots from an earlier connection or another image. Closing a document - releases its cache; snapshots are not persisted across VS Code restarts. + snapshots from an earlier connection or another image. Snapshots are kept + for the extension session, not persisted across VS Code restarts. - There is no auto-connect, polling, automatic reconnect, or request retry. Timeout, peer disconnect, malformed data, and version/correlation failures invalidate the connection and are surfaced in a notification and the **Runa** @@ -86,6 +96,8 @@ machine running the extension host, not necessarily the desktop. For automation, the `runa.connect` command also accepts an explicit endpoint string, and `runa.inspect` accepts an object with an `actor_ref` field. +**Copy Actor Reference** in the actor context menu or inline button copies the +raw reference to the clipboard. `runa.spawn` accepts `{ path: string, state: string }`, where state is natural JSON text, and resolves to the new reference. `runa.spawnSource` and `runa.spawnSourceWithState` accept a source file URI (the latter also optional diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 537eedf..6f1fe4e 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -22,6 +22,12 @@ export async function run(): Promise { ["runa.sources", "runa.actors"], ); assert.equal(contributions.views.explorer, undefined); + assert.ok( + contributions.menus["view/title"].some( + (item: { command: string; when: string }) => + item.command === "runa.tick" && item.when.includes("runa.actors"), + ), + ); await vscode.commands.executeCommand("workbench.view.extension.runa"); await vscode.commands.executeCommand("runa.connect", image.address); assert.ok(!(await vscode.commands.getCommands(true)).includes("runa.addSourceFolder")); @@ -36,6 +42,8 @@ export async function run(): Promise { }); assert.equal(typeof spawned, "string"); const selection = { actor_ref: String(spawned) }; + await vscode.commands.executeCommand("runa.copyActorReference", selection); + assert.equal(await vscode.env.clipboard.readText(), spawned); await vscode.commands.executeCommand("runa.inspect", selection); const controlled = vscode.window.activeTextEditor?.document; assert.ok(controlled); @@ -146,12 +154,40 @@ export async function run(): Promise { assert.match(document.getText(), /queued from another client/); const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri)); assert.match(old.getText(), /"connected": false/); - await vscode.commands.executeCommand("runa.editState", { actor_ref: actorRef }, "77"); - await vscode.commands.executeCommand( - "runa.editMessages", - { actor_ref: actorRef }, - '{"reserved":[],"queued":[{"op":"add","value":2}]}', + assert.equal((await vscode.workspace.fs.stat(document.uri)).permissions, undefined); + const updated = JSON.parse(document.getText()); + updated.actor.state = { type: "integer", data: 77 }; + updated.actor.reserved = null; + updated.actor.queued = [ + { + type: "record", + data: { op: { type: "string", data: "add" }, value: { type: "integer", data: 2 } }, + }, + ]; + updated.actor.log_level = "warn"; + updated.actor.incarnation = 999; + updated.connection.endpoint = "not the actual endpoint"; + const canonical = documentChanged(document, (text) => { + const saved = JSON.parse(text); + return ( + saved.actor.state.data === 77 && + saved.actor.incarnation !== 999 && + saved.actor.log_level === "warn" && + saved.connection.endpoint === image.address + ); + }); + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), + JSON.stringify(updated, null, 2), ); + assert.equal(await vscode.workspace.applyEdit(edit), true); + const unsaved = document.getText(); + await vscode.commands.executeCommand("runa.refresh"); + assert.equal(document.getText(), unsaved, "refresh must preserve unsaved inspection edits"); + assert.equal(await document.save(), true); + await canonical; const recovered = documentChanged(document, (text) => { const actor = JSON.parse(text).actor; return ( @@ -168,6 +204,13 @@ export async function run(): Promise { assert.equal(JSON.parse(repaired.getText()).actor.state.data, 79, repaired.getText()); assert.equal(JSON.parse(repaired.getText()).actor.log_level, "warn"); assert.equal(JSON.parse(repaired.getText()).actor.status.status, "idle"); + assert.equal( + (await vscode.workspace.fs.stat(repaired.uri)).permissions, + vscode.FilePermission.Readonly, + ); + await assert.rejects(async () => { + await vscode.workspace.fs.writeFile(repaired.uri, Buffer.from(repaired.getText())); + }, /paused, stopped, or failed/); console.log( "Runa extension host: spawn, send, system messages, ticks, inspection, automatic refresh, reconnect passed", ); diff --git a/vscode/package.json b/vscode/package.json index fef2472..287c01c 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -161,9 +161,16 @@ { "command": "runa.tick", "title": "Advance Scheduler", + "icon": "$(debug-step-over)", "category": "Runa", "enablement": "runa.connected" }, + { + "command": "runa.copyActorReference", + "title": "Copy Actor Reference", + "category": "Runa", + "icon": "$(copy)" + }, { "command": "runa.inspect", "title": "Inspect Actor", @@ -208,6 +215,11 @@ ], "menus": { "view/title": [ + { + "command": "runa.tick", + "when": "view == runa.actors && runa.connected", + "group": "navigation" + }, { "command": "runa.connect", "when": "view == runa.sources && !runa.connected", @@ -239,6 +251,16 @@ } ], "view/item/context": [ + { + "command": "runa.copyActorReference", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "inline@2" + }, + { + "command": "runa.copyActorReference", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "copy" + }, { "command": "runa.editState", "when": "view == runa.actors && viewItem == runa.actor", diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 19e324f..94b1aa7 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -2,7 +2,6 @@ import * as vscode from "vscode"; import { ImageClient, parseEndpoint } from "./client"; import { ActorSnapshot, - formatSnapshot, isSourcePath, SystemKind, SystemMessage, @@ -11,6 +10,8 @@ import { } from "./protocol"; import { RefreshQueue } from "./refresh"; import { parseValue, formatValue } from "./values"; +import { SnapshotDocuments } from "./snapshotProvider"; +import { canEditActor } from "./snapshotEdits"; import { ImageSourceFileSystem, ImageSourceTree, @@ -69,79 +70,36 @@ class ActorTree implements vscode.TreeDataProvider { } } -interface DocumentSnapshot { - session: number; - endpoint: string; - actor: ActorSnapshot; - capturedAt: string; - connected: boolean; -} - -class SnapshotDocuments implements vscode.TextDocumentContentProvider { - private readonly changes = new vscode.EventEmitter(); - readonly onDidChange = this.changes.event; - private readonly documents = new Map(); - - set(session: number, endpoint: string, actor: ActorSnapshot): vscode.Uri { - const uri = vscode.Uri.from({ - scheme: "runa", - authority: String(session), - path: `/actors/${actor.actor_ref}.json`, - }); - this.documents.set(uri.toString(), { - session, - endpoint, - actor, - capturedAt: new Date().toISOString(), - connected: true, - }); - this.changes.fire(uri); - return uri; - } - - refresh(session: number, endpoint: string, actors: ActorSnapshot[]): void { - const byRef = new Map(actors.map((actor) => [actor.actor_ref, actor])); - for (const snapshot of this.documents.values()) { - const actor = byRef.get(snapshot.actor.actor_ref); - if (snapshot.session === session && actor) this.set(session, endpoint, actor); - } - } - - disconnect(session: number): void { - for (const [key, snapshot] of this.documents) { - if (snapshot.session === session) { - snapshot.connected = false; - this.changes.fire(vscode.Uri.parse(key)); - } - } - } - - provideTextDocumentContent(uri: vscode.Uri): string { - const snapshot = this.documents.get(uri.toString()); - if (!snapshot) throw new Error("Snapshot is no longer available; inspect the actor again"); - return formatSnapshot( - snapshot.actor, - snapshot.endpoint, - snapshot.capturedAt, - snapshot.connected, - ); - } - - remove(uri: vscode.Uri): void { - this.documents.delete(uri.toString()); - } - - dispose(): void { - this.changes.dispose(); - this.documents.clear(); - } -} - type SourceTarget = vscode.Uri | { path: string } | { uri: vscode.Uri } | string | undefined; export function activate(context: vscode.ExtensionContext): { sourceTree: ImageSourceTree } { const tree = new ActorTree(); - const documents = new SnapshotDocuments(); + const documents = new SnapshotDocuments( + async (savedSession, actorRef, edit) => { + const connection = active(); + if (savedSession !== connection.session) + throw new Error("Inspection belongs to a disconnected session"); + const actor = await connection.client.inspectActor(actorRef); + if (!canEditActor(actor)) + throw new Error( + "Editing requires a paused, stopped, or failed actor without active execution", + ); + connection.check(); + if (edit.state !== null || edit.messages !== null) + await connection.client.editActor(actorRef, edit.state, edit.messages); + connection.check(); + if (edit.level !== undefined) await connection.client.setLogLevel(actorRef, edit.level); + connection.check(); + const updated = await connection.client.inspectActor(actorRef); + connection.check(); + documents.set(connection.session, connection.endpoint, updated); + await refresh(); + }, + (message) => { + output.appendLine(message); + void vscode.window.showWarningMessage(`Runa: ${message}`); + }, + ); const sources = new ImageSourceFileSystem(); const sourceTree = new ImageSourceTree(sources); const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree }); @@ -707,16 +665,22 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS output, status, vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }), - vscode.workspace.registerTextDocumentContentProvider("runa", documents), + vscode.workspace.registerFileSystemProvider("runa", documents, { isCaseSensitive: true }), + vscode.workspace.onDidSaveTextDocument((document) => { + void documents.didSave(document).catch(report); + }), vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions), vscode.workspace.onDidCloseTextDocument((document) => { - documents.remove(document.uri); revisions.remove(document.uri); }), command("runa.connect", connect), command("runa.disconnect", disconnect), command("runa.refresh", refresh), command("runa.inspect", inspect), + command("runa.copyActorReference", async (selected?: { actor_ref: string }) => { + const actorRef = await chooseActor(selected); + if (actorRef !== undefined) await vscode.env.clipboard.writeText(actorRef); + }), command("runa.spawn", spawn), command("runa.spawnSource", spawnSource), command("runa.spawnSourceWithState", spawnSourceWithState), @@ -738,7 +702,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS systemCommand("resume"), systemCommand("reload"), command("runa.become", become), - command("runa.tick", tick), + command("runa.tick", (count?: number) => tick(typeof count === "number" ? count : undefined)), { dispose: () => { disposed = true; diff --git a/vscode/src/snapshotEdits.ts b/vscode/src/snapshotEdits.ts new file mode 100644 index 0000000..99256aa --- /dev/null +++ b/vscode/src/snapshotEdits.ts @@ -0,0 +1,68 @@ +import { parse, stringify } from "lossless-json"; +import { + ActorSnapshot, + decodeValue, + LOG_LEVELS, + LogLevel, + MAX_MESSAGE_BYTES, + Value, +} from "./protocol"; + +export function canEditActor(actor: ActorSnapshot): boolean { + return actor.execution === null && ["paused", "stopped", "failed"].includes(actor.status.status); +} + +export interface SnapshotEdit { + state: Value | null; + messages: { reserved: Value | null; queued: Value[] } | null; + level: LogLevel | undefined; + discarded: string[]; +} + +export function parseSnapshotEdit(source: string, original: string): SnapshotEdit { + if (Buffer.byteLength(source) > MAX_MESSAGE_BYTES) throw new Error("Snapshot exceeds 1 MiB"); + const read = (text: string): Record => { + const value: unknown = parse(text, undefined, (literal) => + /^-?(0|[1-9]\d*)$/.test(literal) ? BigInt(literal) : Number(literal), + ); + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("Expected an inspection JSON object"); + return Object.fromEntries(Object.entries(value)); + }; + const input = read(source); + const baseline = read(original); + const object = (value: unknown): Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("Expected actor object"); + return Object.fromEntries(Object.entries(value)); + }; + const actor = object(input.actor); + const before = object(baseline.actor); + const same = (left: unknown, right: unknown) => stringify(left) === stringify(right); + const discarded: string[] = []; + for (const key of new Set([...Object.keys(input), ...Object.keys(baseline)])) { + if (key !== "actor" && !same(input[key], baseline[key])) discarded.push(key); + } + for (const key of new Set([...Object.keys(actor), ...Object.keys(before)])) { + if ( + !["state", "reserved", "queued", "log_level"].includes(key) && + !same(actor[key], before[key]) + ) + discarded.push(`actor.${key}`); + } + const state = same(actor.state, before.state) ? null : decodeValue(actor.state); + let messages: SnapshotEdit["messages"] = null; + if (!same(actor.reserved, before.reserved) || !same(actor.queued, before.queued)) { + if (!Array.isArray(actor.queued)) throw new Error("actor.queued must be an array"); + messages = { + reserved: actor.reserved === null ? null : decodeValue(actor.reserved), + queued: actor.queued.map(decodeValue), + }; + } + let level: LogLevel | undefined; + if (!same(actor.log_level, before.log_level)) { + level = LOG_LEVELS.find((level) => level === actor.log_level); + if (level === undefined) throw new Error("Invalid actor.log_level"); + } + return { state, messages, level, discarded }; +} diff --git a/vscode/src/snapshotProvider.ts b/vscode/src/snapshotProvider.ts new file mode 100644 index 0000000..6057eb3 --- /dev/null +++ b/vscode/src/snapshotProvider.ts @@ -0,0 +1,160 @@ +import * as vscode from "vscode"; +import { ActorSnapshot, formatSnapshot } from "./protocol"; +import { canEditActor, parseSnapshotEdit, SnapshotEdit } from "./snapshotEdits"; + +interface DocumentSnapshot { + session: number; + endpoint: string; + actor: ActorSnapshot; + capturedAt: string; + connected: boolean; + mtime: number; +} + +export class SnapshotDocuments implements vscode.FileSystemProvider { + private readonly changes = new vscode.EventEmitter(); + readonly onDidChangeFile = this.changes.event; + private readonly documents = new Map(); + private readonly baselines = new Map(); + + constructor( + private readonly save: (session: number, actor: string, edit: SnapshotEdit) => Promise, + private readonly warn: (message: string) => void, + ) {} + + set(session: number, endpoint: string, actor: ActorSnapshot): vscode.Uri { + const uri = vscode.Uri.from({ + scheme: "runa", + authority: String(session), + path: `/actors/${actor.actor_ref}.json`, + }); + const previous = this.documents.get(uri.toString()); + this.documents.set(uri.toString(), { + session, + endpoint, + actor, + capturedAt: new Date().toISOString(), + connected: true, + mtime: Math.max(Date.now(), (previous?.mtime ?? 0) + 1), + }); + this.changed(uri); + return uri; + } + + refresh(session: number, endpoint: string, actors: ActorSnapshot[]): void { + const byRef = new Map(actors.map((actor) => [actor.actor_ref, actor])); + for (const snapshot of this.documents.values()) { + const actor = byRef.get(snapshot.actor.actor_ref); + if (snapshot.session === session && actor) this.set(session, endpoint, actor); + } + } + + disconnect(session: number): void { + for (const [key, snapshot] of this.documents) { + if (snapshot.session === session) { + snapshot.connected = false; + snapshot.mtime++; + this.changed(vscode.Uri.parse(key)); + } + } + } + + private changed(uri: vscode.Uri): void { + this.changes.fire([{ type: vscode.FileChangeType.Changed, uri }]); + } + + private snapshot(uri: vscode.Uri): DocumentSnapshot { + const snapshot = this.documents.get(uri.toString()); + if (!snapshot) throw vscode.FileSystemError.FileNotFound(uri); + return snapshot; + } + + private text(snapshot: DocumentSnapshot): string { + return formatSnapshot( + snapshot.actor, + snapshot.endpoint, + snapshot.capturedAt, + snapshot.connected, + ); + } + + stat(uri: vscode.Uri): vscode.FileStat { + if (this.isDirectory(uri)) + return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 }; + const snapshot = this.snapshot(uri); + return { + type: vscode.FileType.File, + ctime: 0, + mtime: snapshot.mtime, + size: Buffer.byteLength(this.text(snapshot)), + permissions: + snapshot.connected && canEditActor(snapshot.actor) + ? undefined + : vscode.FilePermission.Readonly, + }; + } + + readFile(uri: vscode.Uri): Uint8Array { + const text = this.text(this.snapshot(uri)); + const dirty = vscode.workspace.textDocuments.some( + (document) => document.uri.toString() === uri.toString() && document.isDirty, + ); + if (!dirty) this.baselines.set(uri.toString(), text); + return Buffer.from(text); + } + + async writeFile(uri: vscode.Uri, content: Uint8Array): Promise { + const snapshot = this.snapshot(uri); + if (!snapshot.connected || !canEditActor(snapshot.actor)) + throw vscode.FileSystemError.NoPermissions( + "Actor must be paused, stopped, or failed with no active invocation", + ); + const baseline = this.baselines.get(uri.toString()); + if (baseline === undefined) + throw vscode.FileSystemError.Unavailable("Inspect the actor before editing"); + const edit = parseSnapshotEdit(Buffer.from(content).toString("utf8"), baseline); + await this.save(snapshot.session, snapshot.actor.actor_ref, edit); + if (edit.discarded.length) + this.warn(`Discarded read-only inspection fields: ${edit.discarded.join(", ")}`); + this.changed(uri); + } + + async didSave(document: vscode.TextDocument): Promise { + if (document.uri.scheme === "runa" && !document.isDirty) + await vscode.commands.executeCommand("workbench.action.files.revertResource", document.uri); + } + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + private isDirectory(uri: vscode.Uri): boolean { + return ( + uri.scheme === "runa" && + (uri.path === "/" || uri.path === "/actors") && + [...this.documents.values()].some((snapshot) => String(snapshot.session) === uri.authority) + ); + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + if (!this.isDirectory(uri)) throw vscode.FileSystemError.FileNotADirectory(uri); + if (uri.path === "/") return [["actors", vscode.FileType.Directory]]; + return [...this.documents.values()] + .filter((snapshot) => String(snapshot.session) === uri.authority) + .map((snapshot) => [`${snapshot.actor.actor_ref}.json`, vscode.FileType.File]); + } + createDirectory(uri: vscode.Uri): void { + if (!this.isDirectory(uri)) + throw vscode.FileSystemError.NoPermissions("Actor snapshot directories cannot be created"); + } + delete(): never { + throw vscode.FileSystemError.NoPermissions("Actor snapshots cannot be deleted"); + } + rename(): never { + throw vscode.FileSystemError.NoPermissions("Actor snapshots cannot be renamed"); + } + dispose(): void { + this.changes.dispose(); + this.documents.clear(); + this.baselines.clear(); + } +} diff --git a/vscode/test/snapshotEdits.test.ts b/vscode/test/snapshotEdits.test.ts new file mode 100644 index 0000000..cc23c94 --- /dev/null +++ b/vscode/test/snapshotEdits.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { formatSnapshot } from "../src/protocol"; +import { canEditActor, parseSnapshotEdit } from "../src/snapshotEdits"; +import { actor } from "./fixtures"; + +test("inspection edits retain exact values and separate protected fields", () => { + const snapshot = actor(); + snapshot.execution = null; + const original = formatSnapshot(snapshot, "127.0.0.1:3737", "captured", true); + const edited = { + ...snapshot, + actor_ref: "changed", + log_level: "warn" as const, + reserved: null, + queued: [{ type: "integer" as const, data: -9223372036854775808n }], + state: { type: "integer" as const, data: 9223372036854775807n }, + }; + const source = formatSnapshot(edited, "different", "captured", true); + const edit = parseSnapshotEdit(source, original); + assert.deepEqual(edit.state, edited.state); + assert.deepEqual(edit.messages, { reserved: null, queued: edited.queued }); + assert.equal(edit.level, "warn"); + assert.deepEqual(edit.discarded, ["connection", "actor.actor_ref"]); + const protectedNumber = parseSnapshotEdit( + source.replace('"incarnation": 1', '"incarnation": 1.5'), + original, + ); + assert.ok(protectedNumber.discarded.includes("actor.incarnation")); + assert.equal(canEditActor(snapshot), true); + snapshot.status = { status: "idle" }; + assert.equal(canEditActor(snapshot), false); + snapshot.status = { status: "paused" }; + snapshot.execution = { vm_steps: 1n, instruction_pointer: 1n, call_depth: 1n }; + assert.equal(canEditActor(snapshot), false); +}); + +test("inspection edits validate all writable fields before saving", () => { + const original = formatSnapshot(actor(), "endpoint", "captured", true); + assert.throws( + () => parseSnapshotEdit(original.replace('"info"', '"nope"'), original), + /log_level/, + ); + assert.throws(() => + parseSnapshotEdit(original.replace("9223372036854775807", "9223372036854775808"), original), + ); + assert.throws(() => parseSnapshotEdit("{}", original), /actor object/); + assert.throws( + () => + parseSnapshotEdit(original.replace('"queued": [', '"queued": null, "extra": ['), original), + /queued/, + ); + assert.deepEqual(parseSnapshotEdit(original, original), { + state: null, + messages: null, + level: undefined, + discarded: [], + }); +});