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>
This commit is contained in:
Steve Biedermann 2026-11-03 18:52:10 +01:00
parent de69059fa3
commit c851fefa98
14 changed files with 479 additions and 86 deletions

View File

@ -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,

View File

@ -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.

View File

@ -1509,7 +1509,21 @@ mod tests {
#[test]
fn repl_drives_actor_messages_and_inspection() -> Result<(), Box<dyn std::error::Error>> {
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",
);

View File

@ -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,

View File

@ -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::<value::ActorReference>()?;
values.ty::<ActorContext>()?;
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)?;

View File

@ -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(())
}
}

View File

@ -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

View File

@ -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

View File

@ -22,6 +22,12 @@ export async function run(): Promise<void> {
["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<void> {
});
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<void> {
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<void> {
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",
);

View File

@ -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",

View File

@ -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<ActorSnapshot> {
}
}
interface DocumentSnapshot {
session: number;
endpoint: string;
actor: ActorSnapshot;
capturedAt: string;
connected: boolean;
}
class SnapshotDocuments implements vscode.TextDocumentContentProvider {
private readonly changes = new vscode.EventEmitter<vscode.Uri>();
readonly onDidChange = this.changes.event;
private readonly documents = new Map<string, DocumentSnapshot>();
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;

View File

@ -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<string, unknown> => {
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<string, unknown> => {
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 };
}

View File

@ -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<vscode.FileChangeEvent[]>();
readonly onDidChangeFile = this.changes.event;
private readonly documents = new Map<string, DocumentSnapshot>();
private readonly baselines = new Map<string, string>();
constructor(
private readonly save: (session: number, actor: string, edit: SnapshotEdit) => Promise<void>,
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<void> {
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<void> {
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();
}
}

View File

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