From 39b7443c79bb282b1de3edf910a8d26a4c7dde5a Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Tue, 3 Nov 2026 15:57:43 +0100 Subject: [PATCH] feat: add VS Code behavior and source browsing Show published behaviors and revision history, retrieve exact Rune source through the shared protocol, and cache read-only source documents by connection session and revision. Refresh behavior listings on image notifications and cover browsing against the real server and VS Code host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++ README.md | 7 +- docs/architecture.md | 7 +- docs/protocol.md | 6 +- vscode/README.md | 29 ++++++++- vscode/integration/behaviors.test.ts | 43 ++++++++++++ vscode/integration/host.ts | 97 ++++++++++++++++++++++++++++ vscode/package.json | 31 +++++++-- vscode/src/behaviors.ts | 91 ++++++++++++++++++++++++++ vscode/src/client.ts | 13 ++++ vscode/src/extension.ts | 64 +++++++++++++++++- vscode/src/protocol.ts | 5 ++ vscode/test/protocol.test.ts | 26 ++++++++ 13 files changed, 409 insertions(+), 14 deletions(-) create mode 100644 vscode/integration/behaviors.test.ts create mode 100644 vscode/src/behaviors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 576a992..a9d43ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file. ### Added +- VS Code behavior explorer with revision history, implementation identities, + and read-only Rune source documents isolated by connection and revision. + Image notifications refresh behavior listings without changing historical + source documents. - VS Code actor spawning with explicit behavior and initial state, structured message sending, pause/resume/stop/restart controls, and explicit scheduler ticks. JSON inputs preserve i64 integers and `$actor`/`$record` markers. diff --git a/README.md b/README.md index 2ea9045..4121a60 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,12 @@ open this repository and launch **Run Runa Extension** from Run and Debug in the Extension Development Host. The **Runa Actors** view in Explorer lists actors created through the REPL or another client. Click an actor to inspect it. Server notifications automatically refresh the tree and open snapshots; -**Runa: Refresh Actors** is also available on demand. +**Runa: Refresh Image** is also available on demand. + +The **Runa Behaviors** view shows published behaviors and their revision +history. Click a behavior for its current source, or expand it to open an older +revision. **Runa: Open Behavior Source** also offers a revision picker. +Source documents are read-only and remain unchanged when new revisions appear. Use **Runa: Spawn Actor** to choose a behavior and supply JSON initial state. Actor context menus offer structured message sending and pause/resume/stop/restart. diff --git a/docs/architecture.md b/docs/architecture.md index a4a51cd..d0e737f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -249,7 +249,10 @@ than replayed, and changes during a fetch trigger a follow-up refresh. Cached open snapshots are marked disconnected on connection loss and isolated by connection session so reconnecting to another image cannot overwrite them. Spawn with explicit behavior/state, structured send, pause/resume/stop/restart, -and explicit scheduler ticks are also implemented. Remaining UX below is +explicit scheduler ticks, and a behavior explorer with revision history and +read-only source documents are also implemented. Sources are fetched at explicit +revisions and cached by connection session; image notifications refresh listings +without replacing historical source text. Remaining UX below is planned; detailed runtime event subscriptions and durable replay still need design. @@ -491,7 +494,7 @@ variables. Mise's `start` and `cli` tasks run the server and REPL respectively. Connection management, the actor explorer, and read-only snapshot inspection are implemented in `vscode/`, along with spawn/send, lifecycle controls, and -explicit scheduler ticks. Behavior editing, +explicit scheduler ticks, behavior/revision browsing, and immutable source documents. Behavior editing, diagnostics, hot upgrades, and detailed runtime event subscriptions remain planned. Non-destructive image-change notifications and automatic snapshot refresh are implemented over the shared `runa-debug` WebSocket transport. diff --git a/docs/protocol.md b/docs/protocol.md index a96291f..042d520 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -111,7 +111,11 @@ publishes the bundled `counter` behavior on image startup, not on connection. The stdio transport and child-process client have been removed. The TypeScript VS Code client issues `list_actors`, `inspect_actor`, -`get_behaviors`, `spawn`, `send`, `tick`, and pause/resume/stop/restart operations. +`get_behaviors`, `get_behavior_source`, `spawn`, `send`, `tick`, and +pause/resume/stop/restart operations. +Behavior source lookup uses an explicit immutable revision, not a mutable +latest-source alias. VS Code caches read-only source documents by connection, +behavior name, and revision while refreshing behavior listings on notifications. It preserves i64 inputs and bounds the complete outgoing envelope as well as incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages, version/request-ID/reply-kind checks, and portable snapshot value validation. diff --git a/vscode/README.md b/vscode/README.md index 80b457f..0c8471c 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -63,7 +63,7 @@ machine running the extension host, not necessarily the desktop. 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 - a follow-up fetch. **Refresh Actors** is also available on demand. + a follow-up fetch. **Refresh Image** is also available on demand. - Open documents are retained and marked `connected: false` on disconnect. Each connection gets distinct document URIs; reconnecting never replaces snapshots from an earlier connection or another image. Closing a document @@ -86,6 +86,31 @@ natural JSON text, and resolves to the new reference. `runa.send` accepts `runa.tick` accepts a positive integer tick count. Command failures are reported in the Runa output channel and an error notification; commands are never retried. +## Behavior and source browsing + +The **Runa Behaviors** view in Explorer lists the image's published behaviors. +Each item shows the current revision; expand it for revision history, newest +first. Tooltips show the implementation identity and the current revision is +marked. Click the behavior to open its current source, or a revision to open +that exact source. **Runa: Open Behavior Source** offers the same choices in +the Command Palette. + +Sources are fetched through `get_behavior_source` and opened as read-only +`.rn` virtual documents, not local workspace files. If a Rune language +extension is installed, VS Code can associate its language support with `.rn`. +The source text is preserved exactly, without metadata headers. The URI identifies +the connection session, behavior name, and immutable revision. Documents remain +cached on disconnect and never turn into the latest revision automatically. +Reconnect opens distinct URIs; closing a source document releases its cache. +Cached sources are not persisted across VS Code restarts. + +Server notifications refresh both explorers. **Runa: Refresh Image** does the +same on demand. Browsing does not publish code, install actor behavior, tick the +scheduler, or drain runtime events. Source editing and publication remain future +work. For automation, `runa.openBehaviorSource` accepts +`{ name: string, revision: string }` with a positive decimal revision and returns +the opened document URI. + ## Development and validation From the repository root: @@ -112,6 +137,6 @@ well as session isolation. On headless Linux: xvfb-run -a mise run test-extension-host ``` -Behavior/source browsing and editing, +Behavior editing and publication, compilation diagnostics, detailed runtime event subscriptions, packaging for distribution, and debugging remain future work. diff --git a/vscode/integration/behaviors.test.ts b/vscode/integration/behaviors.test.ts new file mode 100644 index 0000000..a6d9ea9 --- /dev/null +++ b/vscode/integration/behaviors.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ImageClient } from "../src/client"; +import { ProtocolError } from "../src/protocol"; +import { control, startImage } from "./server"; + +test( + "behavior browsing retrieves immutable revision source and preserves connection after missing revisions", + { timeout: 10000 }, + async (t) => { + const image = await startImage(); + let client: ImageClient | undefined; + t.after(async () => { + client?.close(); + await image.stop(); + }); + client = await ImageClient.connect(image.address, (error) => assert.equal(error, undefined)); + const original = await client.behaviorSource("counter", 1n); + assert.match(original, /pub fn handle/); + const source = "pub fn handle(state, message) { (state + 2, []) }\n"; + const published = await control(image.address, { + operation: "publish_behavior", + parameters: { name: "counter", expected_revision: 1, source }, + }); + assert.match(JSON.stringify(published), /"kind":"behavior"/); + const behavior = (await client.behaviors()).find((behavior) => behavior.name === "counter"); + assert.ok(behavior); + assert.equal(behavior.revision, 2n); + assert.deepEqual( + behavior.history.map((version) => version.revision), + [1n, 2n], + ); + assert.equal(await client.behaviorSource("counter", 2n), source); + assert.equal(await client.behaviorSource("counter", 1n), original); + await assert.rejects(client.behaviorSource("counter", 3n), ProtocolError); + await assert.rejects(client.behaviorSource("missing", 1n), ProtocolError); + for (const revision of [0n, -1n, 18446744073709551616n]) { + await assert.rejects(client.behaviorSource("counter", revision), /positive u64/); + } + await assert.rejects(client.behaviorSource(" ", 1n), /behavior name/); + assert.equal(await client.behaviorSource("counter", 1n), original); + }, +); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 7bb8314..daeda9c 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; import * as vscode from "vscode"; import { control, spawnCounter, startImage } from "./server"; +import { BehaviorTree, SourceDocuments } from "../src/behaviors"; +import { ImageClient } from "../src/client"; export async function run(): Promise { const image = await startImage(); try { await vscode.commands.executeCommand("runa.connect", image.address); + await browseBehaviors(image.address); const spawned = await vscode.commands.executeCommand("runa.spawn", { behavior: "counter", state: "5", @@ -111,6 +114,100 @@ export async function run(): Promise { } } +async function browseBehaviors(address: string): Promise { + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + const tree = new BehaviorTree(); + const sources = new SourceDocuments(); + try { + const source = await client.behaviorSource("counter", 1n); + await control(address, { + operation: "publish_behavior", + parameters: { + name: "browse-test", + expected_revision: null, + source, + }, + }); + const listing = await client.behaviors(); + tree.replace(listing); + const behavior = tree.getChildren().find((node) => node.name === "browse-test"); + assert.ok(behavior); + assert.equal( + tree.getTreeItem(behavior).collapsibleState, + vscode.TreeItemCollapsibleState.Collapsed, + ); + const version = tree.getChildren(behavior)[0]; + assert.ok(version); + assert.equal(tree.getTreeItem(version).description, "current"); + assert.deepEqual(tree.getChildren(version), []); + const item = tree.getTreeItem(behavior); + const opened = await vscode.commands.executeCommand( + "runa.openBehaviorSource", + ...item.command!.arguments!, + ); + assert.ok(opened); + const original = vscode.window.activeTextEditor?.document; + assert.ok(original); + assert.equal(original.uri.scheme, "runa-source"); + assert.match(original.uri.path, /\/1\.rn$/); + assert.equal(original.getText(), source); + const originalText = original.getText(); + await vscode.commands.executeCommand("type", { text: "// cannot edit\n" }); + assert.equal(original.getText(), originalText); + const newerSource = "pub fn handle(state, message) { (state + 2, []) }\n"; + await control(address, { + operation: "publish_behavior", + parameters: { + name: "browse-test", + expected_revision: 1, + source: newerSource, + }, + }); + await vscode.commands.executeCommand("runa.refresh"); + // Context-menu arguments are raw tree nodes, unlike item command arguments. + const updated = (await client.behaviors()).find((node) => node.name === "browse-test"); + assert.ok(updated); + tree.replace([updated]); + assert.deepEqual( + tree.getChildren(updated).map((node) => node.revision), + [2n, 1n], + ); + await vscode.commands.executeCommand("runa.openBehaviorSource", updated); + const newer = vscode.window.activeTextEditor?.document; + assert.ok(newer); + assert.equal(newer.getText(), newerSource); + assert.notEqual(newer.uri.toString(), original.uri.toString()); + assert.equal(original.getText(), originalText); + const uri = sources.set(1, "name/with?#% spaces", 1n, originalText); + assert.equal(sources.provideTextDocumentContent(uri), originalText); + assert.notEqual( + sources.set(2, "name/with?#% spaces", 1n, newerSource).toString(), + uri.toString(), + ); + assert.throws(() => sources.set(1, "name/with?#% spaces", 1n, newerSource), /Immutable/); + sources.remove(uri); + assert.throws(() => sources.provideTextDocumentContent(uri), /no longer cached/); + await vscode.commands.executeCommand("runa.disconnect"); + assert.equal(original.getText(), originalText); + await vscode.commands.executeCommand("runa.connect", address); + await vscode.commands.executeCommand("runa.openBehaviorSource", { + name: "browse-test", + revision: "1", + }); + const reconnected = vscode.window.activeTextEditor?.document; + assert.ok(reconnected); + assert.equal(reconnected.getText(), originalText); + assert.notEqual(reconnected.uri.toString(), original.uri.toString()); + console.log( + "Runa behavior browsing: revision tree, immutable source, read-only documents, session isolation passed", + ); + } finally { + client.close(); + tree.dispose(); + sources.dispose(); + } +} + function documentChanged( document: vscode.TextDocument, expected: (text: string) => boolean, diff --git a/vscode/package.json b/vscode/package.json index 22232e1..ef38fc2 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -13,7 +13,8 @@ ], "main": "./out/src/extension.js", "activationEvents": [ - "onView:runa.actors" + "onView:runa.actors", + "onView:runa.behaviors" ], "scripts": { "compile": "tsc -p .", @@ -37,6 +38,12 @@ }, "contributes": { "commands": [ + { + "command": "runa.openBehaviorSource", + "title": "Runa: Open Behavior Source", + "icon": "$(go-to-file)", + "enablement": "runa.connected" + }, { "command": "runa.spawn", "title": "Runa: Spawn Actor", @@ -83,7 +90,7 @@ }, { "command": "runa.refresh", - "title": "Runa: Refresh Actors", + "title": "Runa: Refresh Image", "icon": "$(refresh)" }, { @@ -97,10 +104,19 @@ { "id": "runa.actors", "name": "Runa Actors" + }, + { + "id": "runa.behaviors", + "name": "Runa Behaviors" } ] }, "viewsWelcome": [ + { + "view": "runa.behaviors", + "contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)", + "when": "!runa.connected" + }, { "view": "runa.actors", "contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)", @@ -116,20 +132,25 @@ }, { "command": "runa.connect", - "when": "view == runa.actors && !runa.connected", + "when": "(view == runa.actors || view == runa.behaviors) && !runa.connected", "group": "navigation" }, { "command": "runa.refresh", - "when": "view == runa.actors && runa.connected", + "when": "(view == runa.actors || view == runa.behaviors) && runa.connected", "group": "navigation" }, { "command": "runa.disconnect", - "when": "view == runa.actors && runa.connected" + "when": "(view == runa.actors || view == runa.behaviors) && runa.connected" } ], "view/item/context": [ + { + "command": "runa.openBehaviorSource", + "when": "view == runa.behaviors && viewItem == runa.behavior", + "group": "inline" + }, { "command": "runa.send", "when": "view == runa.actors && viewItem == runa.actor", diff --git a/vscode/src/behaviors.ts b/vscode/src/behaviors.ts new file mode 100644 index 0000000..a81d366 --- /dev/null +++ b/vscode/src/behaviors.ts @@ -0,0 +1,91 @@ +import * as vscode from "vscode"; +import { Behavior } from "./protocol"; + +export interface BehaviorRevision { + name: string; + revision: bigint; + implementation_id: string; + current: boolean; +} + +export class BehaviorTree implements vscode.TreeDataProvider { + private readonly changes = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changes.event; + behaviors: Behavior[] = []; + + replace(behaviors: Behavior[]): void { + this.behaviors = behaviors; + this.changes.fire(); + } + + getChildren(parent?: Behavior | BehaviorRevision): (Behavior | BehaviorRevision)[] { + if (!parent) return this.behaviors; + if (!("history" in parent)) return []; + return [...parent.history].reverse().map((version) => ({ + name: parent.name, + ...version, + current: version.revision === parent.revision, + })); + } + + getTreeItem(behavior: Behavior | BehaviorRevision): vscode.TreeItem { + const root = "history" in behavior; + const item = new vscode.TreeItem( + root ? behavior.name : `Revision ${behavior.revision}`, + root ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None, + ); + item.id = JSON.stringify(root ? [behavior.name] : [behavior.name, String(behavior.revision)]); + item.contextValue = "runa.behavior"; + item.description = root + ? `revision ${behavior.revision}` + : behavior.current + ? "current" + : undefined; + item.tooltip = `Behavior: ${behavior.name}\nRevision: ${behavior.revision}\nImplementation: ${ + root ? behavior.current_implementation_id : behavior.implementation_id + }`; + item.iconPath = new vscode.ThemeIcon(root ? "symbol-class" : "git-commit"); + item.command = { + command: "runa.openBehaviorSource", + title: "Open Rune Source", + arguments: [{ name: behavior.name, revision: String(behavior.revision) }], + }; + return item; + } + + dispose(): void { + this.changes.dispose(); + } +} + +export class SourceDocuments implements vscode.TextDocumentContentProvider { + private readonly sources = new Map(); + + set(session: number, name: string, revision: bigint, source: string): vscode.Uri { + const uri = vscode.Uri.from({ + scheme: "runa-source", + authority: String(session), + path: `/behaviors/${encodeURIComponent(name)}/${revision}.rn`, + }); + const previous = this.sources.get(uri.toString()); + if (previous !== undefined && previous !== source) { + throw new Error("Immutable behavior revision changed source"); + } + this.sources.set(uri.toString(), source); + return uri; + } + + provideTextDocumentContent(uri: vscode.Uri): string { + const source = this.sources.get(uri.toString()); + if (source === undefined) + throw new Error("Source is no longer cached; open the behavior revision again"); + return source; + } + + remove(uri: vscode.Uri): void { + this.sources.delete(uri.toString()); + } + dispose(): void { + this.sources.clear(); + } +} diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 2dfded3..b832ba7 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -123,6 +123,19 @@ export class ImageClient { return reply.data; } + async behaviorSource(name: string, revision: bigint): Promise { + if (typeof name !== "string" || !name.trim()) throw new Error("Specify a behavior name"); + if (typeof revision !== "bigint" || revision < 1n || revision > 18446744073709551615n) { + throw new Error("Behavior revision must be a positive u64 integer"); + } + const reply = await this.request({ + operation: "get_behavior_source", + parameters: { name, revision }, + }); + if (reply.kind !== "behavior_source") throw new Error("Expected behavior source"); + return reply.data.source; + } + async spawn(behavior: string, state: Value, capacity = 64): Promise { if (!behavior.trim()) throw new Error("Specify a behavior to spawn"); if (!Number.isSafeInteger(capacity) || capacity < 1) diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index ac9aeda..61968e6 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -3,6 +3,7 @@ import { ImageClient, parseEndpoint } from "./client"; import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol"; import { RefreshQueue } from "./refresh"; import { parseValue } from "./values"; +import { BehaviorTree, SourceDocuments } from "./behaviors"; class ActorTree implements vscode.TreeDataProvider { private readonly changes = new vscode.EventEmitter(); @@ -117,6 +118,11 @@ class SnapshotDocuments implements vscode.TextDocumentContentProvider { export function activate(context: vscode.ExtensionContext): void { const tree = new ActorTree(); const documents = new SnapshotDocuments(); + const behaviors = new BehaviorTree(); + const sources = new SourceDocuments(); + const behaviorView = vscode.window.createTreeView("runa.behaviors", { + treeDataProvider: behaviors, + }); const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree }); const output = vscode.window.createOutputChannel("Runa"); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); @@ -139,11 +145,13 @@ export function activate(context: vscode.ExtensionContext): void { void vscode.commands.executeCommand("setContext", "runa.connected", connected); status.text = connected ? "$(plug) Runa" : "$(debug-disconnect) Runa"; status.tooltip = connected - ? `Connected to ${endpoint}. Click to refresh actors.` + ? `Connected to ${endpoint}. Click to refresh the image.` : "Connect to Runa"; status.command = connected ? "runa.refresh" : "runa.connect"; view.description = connected ? endpoint : "Disconnected"; view.message = connected ? undefined : "Not connected to an image."; + behaviorView.description = connected ? endpoint : "Disconnected"; + behaviorView.message = connected ? undefined : "Not connected to an image."; }; const disconnect = () => { @@ -155,6 +163,7 @@ export function activate(context: vscode.ExtensionContext): void { session++; previous?.close(); tree.replace([]); + behaviors.replace([]); connectionStatus(false); }; @@ -163,8 +172,11 @@ export function activate(context: vscode.ExtensionContext): void { const currentSession = session; if (!current) throw new Error("Connect to a local image first"); const actors = await current.listActors(); + const listing = await current.behaviors(); if (current !== client || currentSession !== session) return; tree.replace(actors); + behaviors.replace(listing); + behaviorView.message = listing.length ? undefined : "No published behaviors."; documents.refresh(session, endpoint, actors); view.message = actors.length ? undefined : "No actors. Use Runa: Spawn Actor to create one."; }; @@ -204,6 +216,7 @@ export function activate(context: vscode.ExtensionContext): void { client = undefined; updates.dispose(); tree.replace([]); + behaviors.replace([]); documents.disconnect(currentSession); connectionStatus(false); output.appendLine(`Disconnected from ${address}`); @@ -247,7 +260,7 @@ export function activate(context: vscode.ExtensionContext): void { description: actor.status.status, actorRef: actor.actor_ref, })), - { placeHolder: "Choose an actor to inspect (refresh to discover newly spawned actors)" }, + { placeHolder: "Choose an actor to inspect" }, ); if (!choice) return; actorRef = choice.actorRef; @@ -302,6 +315,43 @@ export function activate(context: vscode.ExtensionContext): void { }, }); + const openBehaviorSource = async (selected?: { name: string; revision: string | bigint }) => { + const connection = active(); + const currentSession = session; + let target = selected; + if (!target) { + const listing = await connection.client.behaviors(); + connection.check(); + const choice = await vscode.window.showQuickPick( + listing.flatMap((behavior) => + [...behavior.history].reverse().map((version) => ({ + label: behavior.name, + description: `revision ${version.revision}${version.revision === behavior.revision ? " (current)" : ""}`, + target: { name: behavior.name, revision: String(version.revision) }, + })), + ), + { placeHolder: "Choose a behavior revision to view" }, + ); + target = choice?.target; + } + if (!target) return; + if ( + (typeof target.revision !== "string" && typeof target.revision !== "bigint") || + !/^[1-9]\d*$/.test(String(target.revision)) + ) { + throw new Error("Specify a positive decimal behavior revision"); + } + connection.check(); + const revision = BigInt(target.revision); + const source = await connection.client.behaviorSource(target.name, revision); + connection.check(); + const uri = sources.set(currentSession, target.name, revision, source); + const document = await vscode.workspace.openTextDocument(uri); + connection.check(); + await vscode.window.showTextDocument(document, { preview: true }); + return uri.toString(); + }; + const spawn = async (options?: { behavior: string; state: string }) => { const connection = active(); const behaviors = await connection.client.behaviors(); @@ -383,16 +433,24 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push( tree, + behaviors, + sources, + behaviorView, documents, view, output, status, vscode.workspace.registerTextDocumentContentProvider("runa", documents), - vscode.workspace.onDidCloseTextDocument((document) => documents.remove(document.uri)), + vscode.workspace.registerTextDocumentContentProvider("runa-source", sources), + vscode.workspace.onDidCloseTextDocument((document) => { + documents.remove(document.uri); + sources.remove(document.uri); + }), command("runa.connect", connect), command("runa.disconnect", disconnect), command("runa.refresh", refresh), command("runa.inspect", inspect), + command("runa.openBehaviorSource", openBehaviorSource), command("runa.spawn", spawn), command("runa.send", send), command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)), diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index afc585a..3a032df 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -43,6 +43,7 @@ export interface Behavior { export type Command = | { operation: "list_actors" | "get_behaviors" } + | { operation: "get_behavior_source"; parameters: { name: string; revision: bigint } } | { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: string } } | { operation: "spawn"; @@ -183,6 +184,7 @@ export type Reply = | { kind: "behaviors"; data: Behavior[] } | { kind: "actor_spawned"; data: { actor_ref: string } } | { kind: "acknowledged" } + | { kind: "behavior_source"; data: { source: string } } | { kind: "tick_completed"; data: { ticks: bigint } }; function decodeEnvelope(message: string): Record { @@ -235,6 +237,9 @@ export function decodeResponse(line: string, requestId: string, command: Command throw new ProtocolError(code, text(error.message)); } const reply = object(result.Ok); + if (command.operation === "get_behavior_source" && reply.kind === "behavior_source") { + return { kind: "behavior_source", data: { source: text(object(reply.data).source) } }; + } if (command.operation === "get_behaviors" && reply.kind === "behaviors") { const data = array(reply.data).map((input): Behavior => { const behavior = object(input); diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts index b98a2e3..0b00f47 100644 --- a/vscode/test/protocol.test.ts +++ b/vscode/test/protocol.test.ts @@ -180,6 +180,32 @@ test("behavior listings validate names, revisions, history, and uniqueness", () ); }); +test("behavior source replies require text and the matching operation", () => { + const command: Command = { + operation: "get_behavior_source", + parameters: { + name: "counter", + revision: 18446744073709551615n, + }, + }; + const source = "pub fn handle(state, message) {\n (state, []) // 雪\n}\n"; + assert.deepEqual( + decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", command), + { kind: "behavior_source", data: { source } }, + ); + for (const data of [{}, { source: null }, { source: 12 }, { source: [] }]) { + assert.throws(() => + decodeResponse(envelope({ kind: "behavior_source", data }), "test", command), + ); + } + assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command)); + assert.throws(() => + decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", { + operation: "get_behaviors", + }), + ); +}); + test("malformed states and snapshots fail instead of being coerced", () => { for (const state of [ { type: "integer", data: 9223372036854775808n },