diff --git a/CHANGELOG.md b/CHANGELOG.md index 0407993..576a992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file. ### Added +- 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. +- Lossless, size-bounded mutation requests and real-server/extension-host + coverage for actor controls. - Non-destructive `image_changed` server notifications with coalesced per-connection revisions and automatic VS Code tree/inspector refresh. - Tests for WebSocket subprotocol negotiation, invalid messages, multi-client diff --git a/README.md b/README.md index 663bdca..2ea9045 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,12 @@ 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. -The extension does not start, tick, or stop the image. Disconnecting leaves -actors running in the server. Editing, spawn/send controls, and detailed runtime -event subscriptions are not implemented yet. See the [extension guide](vscode/README.md) +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. +**Runa: Advance Scheduler** explicitly executes ticks; sending alone only queues +a message. The extension does not start or shut down the image. Disconnecting +leaves actors in the server. Behavior editing and detailed runtime event +subscriptions are not implemented yet. See the [extension guide](vscode/README.md) for connection restrictions, snapshot semantics, and tests. ## Development diff --git a/docs/architecture.md b/docs/architecture.md index 080d360..a4a51cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -248,7 +248,9 @@ scheduler ticks or event drains. Revisions and refreshes are coalesced rather than replayed, and changes during a fetch trigger a follow-up refresh. Cached open snapshots are marked disconnected on connection loss and isolated by connection session so reconnecting to another image cannot overwrite them. -The remaining UX below is planned; detailed runtime event subscriptions and +Spawn with explicit behavior/state, structured send, pause/resume/stop/restart, +and explicit scheduler ticks are also implemented. Remaining UX below is +planned; detailed runtime event subscriptions and durable replay still need design. Required extension UX: @@ -488,7 +490,8 @@ variables. Mise's `start` and `cli` tasks run the server and REPL respectively. - Virtual behavior documents, compilation diagnostics, hot upgrades, runtime events. Connection management, the actor explorer, and read-only snapshot inspection -are implemented in `vscode/`. Spawn/send and lifecycle controls, behavior editing, +are implemented in `vscode/`, along with spawn/send, lifecycle controls, and +explicit scheduler ticks. 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 212f378..a96291f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -110,8 +110,10 @@ retrieve them through `list_actors` and `get_behaviors`. The network executable 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 currently issues only `list_actors` and -`inspect_actor`. It enforces loopback endpoints, bounded UTF-8 text messages, +The TypeScript VS Code client issues `list_actors`, `inspect_actor`, +`get_behaviors`, `spawn`, `send`, `tick`, and pause/resume/stop/restart operations. +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. It decodes JSON integers as `bigint` and prints them losslessly, including signed 64-bit state and unsigned 64-bit telemetry. It uses the same 5-second diff --git a/vscode/README.md b/vscode/README.md index 0a7b755..80b457f 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -1,6 +1,6 @@ # Runa VS Code client -This is the first, read-only client for a trusted localhost Runa image. It uses +This is a development client for a trusted localhost Runa image. It uses the [version 3 protocol](../docs/protocol.md) over WebSockets with the `runa-debug` subprotocol, not Rust APIs, and never hosts actors or starts its own image. @@ -24,10 +24,26 @@ In that host: 1. Run **Runa: Connect to Local Image**. The default is `127.0.0.1:3737`; `runa.address` changes the suggested endpoint. -2. Expand **Runa Actors** in Explorer. Use the REPL (`mise run cli`) to - spawn actors in the same server. The tree updates automatically. +2. Expand **Runa Actors** in Explorer. Use its **+** button or **Runa: Spawn Actor** + to choose a published behavior, then enter JSON initial state (for example + `0` for `counter`). There is no default behavior or implicit state. + The command reports and returns the new actor reference. Mailbox capacity is 64. 3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot. -4. Run **Runa: Disconnect** to close only this client connection. +4. Right-click an actor to send a JSON message or pause/resume/stop/restart it. + These actions are also available in the Command Palette with an actor picker. +5. Use **Runa: Advance Scheduler** to execute an explicit number of ticks + (instruction budget 32 per actor). Send only queues a message; it does not + execute it. For the counter, send `{"op":"add","value":7}`, then advance. +6. Run **Runa: Disconnect** to close only this client connection. + +JSON input supports null, booleans, signed 64-bit integers, strings, arrays, +and objects. Floats and out-of-range integers are rejected rather than rounded. +Use `{"$actor":"@<21-character-reference>"}` for a typed actor reference and +`{"$record":{"$actor":"literal"}}` to escape a marker-shaped object. +REPL-local names are not shared with the extension. Input uses the same +depth/node/string limits as the runtime; the full wire request must also fit +1 MiB. Cancelling a prompt sends no mutation. Changing connections during a +prompt rejects the action instead of applying it to the new image. Connection prompts accept numeric IPv4 loopback addresses (`127.x.x.x`) or `[::1]:port`. DNS names, wildcard addresses, remote addresses, and IPv4-mapped @@ -56,13 +72,19 @@ machine running the extension host, not necessarily the desktop. Timeout, peer disconnect, malformed data, and version/correlation failures invalidate the connection and are surfaced in a notification and the **Runa** output channel. Server-reported operation errors leave the connection usable. -- The client does not tick the scheduler, drain `get_events`, spawn/send, or - change actor lifecycle/behavior. Use the REPL for those operations. +- The client never ticks the scheduler implicitly or drains `get_events`. + Behavior publication/installation still requires the REPL or another client. Notifications are non-destructive invalidations, not a replayable runtime event stream; reconnect fetches the current image rather than replaying history. For automation, the `runa.connect` command also accepts an explicit endpoint string, and `runa.inspect` accepts an object with an `actor_ref` field. +`runa.spawn` accepts `{ behavior: string, state: string }`, where state is +natural JSON text, and resolves to the new reference. `runa.send` accepts +`{ actor_ref: string }` and JSON message text. `runa.pause`, `runa.resume`, +`runa.stop`, and `runa.restart` accept `{ actor_ref: string }`. +`runa.tick` accepts a positive integer tick count. Command failures are reported +in the Runa output channel and an error notification; commands are never retried. ## Development and validation @@ -81,7 +103,7 @@ coalescing, timeout/disconnect behavior, exact 64-bit integer display, and interoperation with the real Rust server on an ephemeral loopback port. The separate host test downloads VS Code 1.96.4 to the ignored `.vscode-test/` -cache and runs connection, inspection, disconnect, and reconnect commands +cache and runs spawn/send/lifecycle/tick, connection, inspection, disconnect, and reconnect commands against a test-owned Rust server. It verifies automatic document updates after another client sends messages, pauses, and advances an actor into failure, as well as session isolation. On headless Linux: @@ -90,6 +112,6 @@ well as session isolation. On headless Linux: xvfb-run -a mise run test-extension-host ``` -Behavior/source browsing and editing, spawn/send and lifecycle controls, +Behavior/source browsing and editing, compilation diagnostics, detailed runtime event subscriptions, packaging for distribution, and debugging remain future work. diff --git a/vscode/integration/controls.test.ts b/vscode/integration/controls.test.ts new file mode 100644 index 0000000..7daea1a --- /dev/null +++ b/vscode/integration/controls.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ImageClient } from "../src/client"; +import { ProtocolError } from "../src/protocol"; +import { parseValue } from "../src/values"; +import { startImage } from "./server"; + +test( + "extension controls spawn explicit behaviors, send lossless values, and preserve lifecycle state", + { 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)); + assert.ok((await client.behaviors()).some((behavior) => behavior.name === "counter")); + await assert.rejects(client.spawn("missing", parseValue("0")), ProtocolError); + const ref = await client.spawn("counter", parseValue("5"), 2); + assert.match(ref, /^[A-Za-z0-9_-]{21}$/); + await client.send(ref, parseValue('{"op":"add","value":7}')); + await client.lifecycle("pause_actor", ref); + await client.tick(100); + let snapshot = await client.inspectActor(ref); + assert.equal(snapshot.status.status, "paused"); + assert.deepEqual(snapshot.state, parseValue("5")); + assert.equal(snapshot.queued.length, 1); + await client.lifecycle("resume_actor", ref); + await client.tick(100); + snapshot = await client.inspectActor(ref); + assert.deepEqual(snapshot.state, parseValue("12")); + assert.deepEqual(snapshot.queued, []); + await client.lifecycle("stop_actor", ref); + assert.equal((await client.inspectActor(ref)).status.status, "stopped"); + await client.lifecycle("restart_actor", ref); + snapshot = await client.inspectActor(ref); + assert.equal(snapshot.incarnation, 2n); + assert.deepEqual(snapshot.state, parseValue("12")); + const payload = parseValue(`{"nested":[9223372036854775807,{"$actor":"${ref}"}]}`); + await client.send(ref, payload); + assert.deepEqual((await client.inspectActor(ref)).queued, [payload]); + // Whole-request size includes tagged values and the envelope. + await assert.rejects( + client.send(ref, { type: "string", data: "x".repeat(1024 * 1024) }), + /1 MiB/, + ); + assert.equal((await client.listActors()).length, 1); + }, +); diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 15afdf2..7bb8314 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -5,6 +5,52 @@ import { control, spawnCounter, startImage } from "./server"; export async function run(): Promise { const image = await startImage(); try { + await vscode.commands.executeCommand("runa.connect", image.address); + const spawned = await vscode.commands.executeCommand("runa.spawn", { + behavior: "counter", + state: "5", + }); + assert.equal(typeof spawned, "string"); + const selection = { actor_ref: String(spawned) }; + await vscode.commands.executeCommand("runa.inspect", selection); + const controlled = vscode.window.activeTextEditor?.document; + assert.ok(controlled); + await vscode.commands.executeCommand("runa.send", selection, '{"op":"add","value":7}'); + const controlPaused = documentChanged(controlled, (text) => + text.includes('"status": "paused"'), + ); + await vscode.commands.executeCommand("runa.pause", selection); + await vscode.commands.executeCommand("runa.tick", 100); + await controlPaused; + let snapshot = JSON.parse(controlled.getText()).actor; + assert.equal(snapshot.status.status, "paused"); + assert.equal(snapshot.state.data, 5); + assert.equal(snapshot.queued.length, 1); + const controlExecuted = documentChanged( + controlled, + (text) => JSON.parse(text).actor.state.data === 12, + ); + await vscode.commands.executeCommand("runa.resume", selection); + await vscode.commands.executeCommand("runa.tick", 100); + await controlExecuted; + snapshot = JSON.parse(controlled.getText()).actor; + assert.equal(snapshot.state.data, 12); + assert.equal(snapshot.queued.length, 0); + const controlStopped = documentChanged(controlled, (text) => + text.includes('"status": "stopped"'), + ); + await vscode.commands.executeCommand("runa.stop", selection); + await controlStopped; + assert.equal(JSON.parse(controlled.getText()).actor.status.status, "stopped"); + const controlRestarted = documentChanged( + controlled, + (text) => JSON.parse(text).actor.incarnation === 2, + ); + await vscode.commands.executeCommand("runa.restart", selection); + await controlRestarted; + snapshot = JSON.parse(controlled.getText()).actor; + assert.equal(snapshot.incarnation, 2); + assert.equal(snapshot.state.data, 12); const actorRef = await spawnCounter(image.address); await vscode.commands.executeCommand("runa.connect", image.address); await vscode.commands.executeCommand("runa.refresh"); @@ -57,7 +103,7 @@ export async function run(): Promise { const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri)); assert.match(old.getText(), /"connected": false/); console.log( - "Runa extension host: connect, inspect, automatic refresh, disconnect, reconnect passed", + "Runa extension host: spawn, send, lifecycle, ticks, inspection, automatic refresh, reconnect passed", ); } finally { await vscode.commands.executeCommand("runa.disconnect"); diff --git a/vscode/package.json b/vscode/package.json index 3cb3a62..22232e1 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -1,7 +1,7 @@ { "name": "runa", "displayName": "Runa — Rune Actors", - "description": "Inspect actors in a trusted localhost Runa image.", + "description": "Inspect and control actors in a trusted localhost Runa image.", "version": "0.0.0", "private": true, "engines": { @@ -37,6 +37,42 @@ }, "contributes": { "commands": [ + { + "command": "runa.spawn", + "title": "Runa: Spawn Actor", + "icon": "$(add)", + "enablement": "runa.connected" + }, + { + "command": "runa.send", + "title": "Runa: Send Message", + "enablement": "runa.connected" + }, + { + "command": "runa.stop", + "title": "Runa: Stop Actor", + "enablement": "runa.connected" + }, + { + "command": "runa.restart", + "title": "Runa: Restart Actor", + "enablement": "runa.connected" + }, + { + "command": "runa.pause", + "title": "Runa: Pause Actor", + "enablement": "runa.connected" + }, + { + "command": "runa.resume", + "title": "Runa: Resume Actor", + "enablement": "runa.connected" + }, + { + "command": "runa.tick", + "title": "Runa: Advance Scheduler", + "enablement": "runa.connected" + }, { "command": "runa.connect", "title": "Runa: Connect to Local Image" @@ -73,6 +109,11 @@ ], "menus": { "view/title": [ + { + "command": "runa.spawn", + "when": "view == runa.actors && runa.connected", + "group": "navigation" + }, { "command": "runa.connect", "when": "view == runa.actors && !runa.connected", @@ -89,6 +130,31 @@ } ], "view/item/context": [ + { + "command": "runa.send", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@1" + }, + { + "command": "runa.pause", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@2" + }, + { + "command": "runa.resume", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@3" + }, + { + "command": "runa.stop", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@4" + }, + { + "command": "runa.restart", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "actor@5" + }, { "command": "runa.inspect", "when": "view == runa.actors && viewItem == runa.actor", diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 2ed15dd..2dfded3 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -1,16 +1,21 @@ import { isIP } from "node:net"; import { TextDecoder } from "node:util"; import WebSocket, { RawData } from "ws"; +import { stringify } from "lossless-json"; import { ActorSnapshot, + Behavior, decodeNotification, decodeResponse, + decodeValue, isActorRef, MAX_MESSAGE_BYTES, PROTOCOL_VERSION, ProtocolError, - ReadCommand, - ReadReply, + Command, + Reply, + LifecycleOperation, + Value, WEBSOCKET_SUBPROTOCOL, } from "./protocol"; @@ -32,8 +37,8 @@ export function parseEndpoint(endpoint: string): { host: string; port: number } interface Pending { requestId: string; - command: ReadCommand; - resolve: (reply: ReadReply) => void; + command: Command; + resolve: (reply: Reply) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; } @@ -112,20 +117,66 @@ export class ImageClient { return reply.data; } - private request(command: ReadCommand): Promise { + async behaviors(): Promise { + const reply = await this.request({ operation: "get_behaviors" }); + if (reply.kind !== "behaviors") throw new Error("Expected behavior listing"); + return reply.data; + } + + 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) + throw new Error("Mailbox capacity must be positive"); + const reply = await this.request({ + operation: "spawn", + parameters: { + behavior_name: behavior, + state: decodeValue(state), + mailbox_capacity: capacity, + }, + }); + if (reply.kind !== "actor_spawned") throw new Error("Expected spawned actor reference"); + return reply.data.actor_ref; + } + + async send(actorRef: string, message: Value): Promise { + if (!isActorRef(actorRef)) throw new Error("Invalid actor reference"); + await this.request({ + operation: "send", + parameters: { actor_ref: actorRef, message: decodeValue(message) }, + }); + } + + async lifecycle(operation: LifecycleOperation, actorRef: string): Promise { + if (!isActorRef(actorRef)) throw new Error("Invalid actor reference"); + await this.request({ operation, parameters: { actor_ref: actorRef } }); + } + + async tick(ticks: number, budget = 32): Promise { + if (!Number.isSafeInteger(ticks) || ticks < 1 || !Number.isSafeInteger(budget) || budget < 1) { + throw new Error("Ticks and instruction budget must be positive safe integers"); + } + await this.request({ operation: "tick", parameters: { ticks, instruction_budget: budget } }); + } + + private request(command: Command): Promise { const request = this.queue.then( () => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { if (this.closed) { reject(new Error("Not connected to an image")); return; } const requestId = `runa-vscode-${++this.sequence}`; - const message = JSON.stringify({ + const message = stringify({ version: PROTOCOL_VERSION, request_id: requestId, command, }); + if (message === undefined || Buffer.byteLength(message) > MAX_MESSAGE_BYTES) { + reject(new Error("Request exceeds 1 MiB")); + return; + } const timer = setTimeout( () => this.close(new Error("Image request timed out; connection closed without retry")), this.requestTimeout, diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index c2801cb..ac9aeda 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,7 +1,8 @@ import * as vscode from "vscode"; import { ImageClient, parseEndpoint } from "./client"; -import { ActorSnapshot, formatSnapshot } from "./protocol"; +import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol"; import { RefreshQueue } from "./refresh"; +import { parseValue } from "./values"; class ActorTree implements vscode.TreeDataProvider { private readonly changes = new vscode.EventEmitter(); @@ -165,9 +166,7 @@ export function activate(context: vscode.ExtensionContext): void { if (current !== client || currentSession !== session) return; tree.replace(actors); documents.refresh(session, endpoint, actors); - view.message = actors.length - ? undefined - : "No actors. Spawn one with the Runa REPL, then refresh."; + view.message = actors.length ? undefined : "No actors. Use Runa: Spawn Actor to create one."; }; const connect = async (target?: string) => { @@ -232,7 +231,7 @@ export function activate(context: vscode.ExtensionContext): void { connectionStatus(true); view.message = tree.actors.length ? undefined - : "No actors. Spawn one with the Runa REPL, then refresh."; + : "No actors. Use Runa: Spawn Actor to create one."; output.appendLine(`Connected to ${address} (protocol v3)`); }; @@ -262,13 +261,121 @@ export function activate(context: vscode.ExtensionContext): void { await vscode.window.showTextDocument(document, { preview: true }); }; - const command = ( + const chooseActor = async (selected?: { actor_ref: string }): Promise => { + if (selected) return selected.actor_ref; + const choice = await vscode.window.showQuickPick( + tree.actors.map((actor) => ({ + label: `@${actor.actor_ref}`, + description: actor.status.status, + actorRef: actor.actor_ref, + })), + { placeHolder: "Choose an actor" }, + ); + return choice?.actorRef; + }; + + const active = () => { + if (!client) throw new Error("Connect to a local image first"); + const current = client; + const currentSession = session; + return { + client: current, + check: () => { + if (current !== client || currentSession !== session) { + throw new Error("Connection changed while entering the command; try again"); + } + }, + }; + }; + + const jsonInput = (title: string) => + vscode.window.showInputBox({ + title, + prompt: 'JSON value; use {"$actor":"@reference"} for an actor reference', + validateInput: (source) => { + try { + parseValue(source); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + }); + + const spawn = async (options?: { behavior: string; state: string }) => { + const connection = active(); + const behaviors = await connection.client.behaviors(); + connection.check(); + const behavior = + options?.behavior ?? + ( + await vscode.window.showQuickPick( + behaviors.map((behavior) => ({ + label: behavior.name, + description: `revision ${behavior.revision}`, + })), + { placeHolder: "Select the behavior to spawn (required)" }, + ) + )?.label; + if (behavior === undefined) return; + const state = options?.state ?? (await jsonInput(`Initial state for ${behavior} (required)`)); + if (state === undefined) return; + connection.check(); + const actorRef = await connection.client.spawn(behavior, parseValue(state)); + output.appendLine(`Spawned @${actorRef} with behavior ${behavior}`); + if (client === connection.client) { + void vscode.window.showInformationMessage(`Runa: Spawned @${actorRef}`); + } + return actorRef; + }; + + const send = async (selected?: { actor_ref: string }, source?: string) => { + const connection = active(); + const actorRef = await chooseActor(selected); + if (actorRef === undefined) return; + const message = source ?? (await jsonInput(`Message for @${actorRef}`)); + if (message === undefined) return; + connection.check(); + await connection.client.send(actorRef, parseValue(message)); + output.appendLine(`Message queued for @${actorRef}; use Runa: Advance Scheduler to execute`); + void vscode.window.showInformationMessage(`Runa: Message queued for @${actorRef}`); + }; + + const lifecycle = async (operation: LifecycleOperation, selected?: { actor_ref: string }) => { + const connection = active(); + const actorRef = await chooseActor(selected); + if (actorRef === undefined) return; + connection.check(); + await connection.client.lifecycle(operation, actorRef); + output.appendLine(`${operation}: @${actorRef}`); + }; + + const tick = async (count?: number) => { + const connection = active(); + const input = + count === undefined + ? await vscode.window.showInputBox({ + title: "Advance scheduler", + value: "100", + prompt: "Explicit scheduler ticks; per-actor instruction budget is 32", + validateInput: (value) => + /^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value)) + ? undefined + : "Enter a positive safe integer", + }) + : String(count); + if (input === undefined) return; + connection.check(); + await connection.client.tick(Number(input)); + }; + + const command = ( name: string, - action: (...args: Args) => Promise | void, + action: (...args: Args) => Promise | Result, ) => vscode.commands.registerCommand(name, async (...args: Args) => { try { - await action(...args); + return await action(...args); } catch (error) { report(error); } @@ -286,6 +393,17 @@ export function activate(context: vscode.ExtensionContext): void { command("runa.disconnect", disconnect), command("runa.refresh", refresh), command("runa.inspect", inspect), + command("runa.spawn", spawn), + command("runa.send", send), + command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)), + command("runa.restart", (selected?: { actor_ref: string }) => + lifecycle("restart_actor", selected), + ), + command("runa.pause", (selected?: { actor_ref: string }) => lifecycle("pause_actor", selected)), + command("runa.resume", (selected?: { actor_ref: string }) => + lifecycle("resume_actor", selected), + ), + command("runa.tick", tick), { dispose: () => { disposed = true; diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index cf963e0..afc585a 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -32,8 +32,24 @@ export interface ActorSnapshot { reserved: Value | null; } -export type ReadCommand = - { operation: "list_actors" } | { operation: "inspect_actor"; parameters: { actor_ref: string } }; +export type LifecycleOperation = "stop_actor" | "restart_actor" | "pause_actor" | "resume_actor"; + +export interface Behavior { + name: string; + revision: bigint; + current_implementation_id: string; + history: { revision: bigint; implementation_id: string }[]; +} + +export type Command = + | { operation: "list_actors" | "get_behaviors" } + | { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: string } } + | { + operation: "spawn"; + parameters: { behavior_name: string; state: Value; mailbox_capacity: number }; + } + | { operation: "send"; parameters: { actor_ref: string; message: Value } } + | { operation: "tick"; parameters: { ticks: number; instruction_budget: number } }; export class ProtocolError extends Error { constructor( @@ -161,8 +177,13 @@ function decodeActor(input: unknown): ActorSnapshot { }; } -export type ReadReply = - { kind: "actors"; data: ActorSnapshot[] } | { kind: "actor"; data: ActorSnapshot }; +export type Reply = + | { kind: "actors"; data: ActorSnapshot[] } + | { kind: "actor"; data: ActorSnapshot } + | { kind: "behaviors"; data: Behavior[] } + | { kind: "actor_spawned"; data: { actor_ref: string } } + | { kind: "acknowledged" } + | { kind: "tick_completed"; data: { ticks: bigint } }; function decodeEnvelope(message: string): Record { const envelope = object( @@ -191,7 +212,7 @@ export function decodeNotification(message: string): bigint | undefined { return revision; } -export function decodeResponse(line: string, requestId: string, command: ReadCommand): ReadReply { +export function decodeResponse(line: string, requestId: string, command: Command): Reply { const response = decodeEnvelope(line); if (response.request_id !== requestId) throw new Error("Response request ID mismatch"); const result = object(response.result); @@ -214,6 +235,46 @@ export function decodeResponse(line: string, requestId: string, command: ReadCom throw new ProtocolError(code, text(error.message)); } const reply = object(result.Ok); + if (command.operation === "get_behaviors" && reply.kind === "behaviors") { + const data = array(reply.data).map((input): Behavior => { + const behavior = object(input); + const name = text(behavior.name); + const revision = uint(behavior.revision); + if (!name.trim() || revision === 0n) throw new Error("Invalid behavior snapshot"); + return { + name, + revision, + current_implementation_id: text(behavior.current_implementation_id), + history: array(behavior.history).map((input) => { + const version = object(input); + const revision = uint(version.revision); + if (revision === 0n) throw new Error("Invalid behavior revision"); + return { revision, implementation_id: text(version.implementation_id) }; + }), + }; + }); + if (new Set(data.map((behavior) => behavior.name)).size !== data.length) { + throw new Error("Duplicate behavior names"); + } + return { kind: "behaviors", data }; + } + if (command.operation === "spawn" && reply.kind === "actor_spawned") { + const data = object(reply.data); + if (!isActorRef(data.actor_ref)) throw new Error("Invalid spawned actor reference"); + return { kind: "actor_spawned", data: { actor_ref: data.actor_ref } }; + } + if (command.operation === "tick" && reply.kind === "tick_completed") { + const ticks = uint(object(reply.data).ticks); + if (ticks !== BigInt(command.parameters.ticks)) throw new Error("Tick count mismatch"); + return { kind: "tick_completed", data: { ticks } }; + } + if ( + ["send", "stop_actor", "restart_actor", "pause_actor", "resume_actor"].includes( + command.operation, + ) && + reply.kind === "acknowledged" + ) + return { kind: "acknowledged" }; if (command.operation === "list_actors" && reply.kind === "actors") { const data = array(reply.data).map(decodeActor); if (new Set(data.map((actor) => actor.actor_ref)).size !== data.length) { diff --git a/vscode/src/values.ts b/vscode/src/values.ts new file mode 100644 index 0000000..8636a42 --- /dev/null +++ b/vscode/src/values.ts @@ -0,0 +1,45 @@ +import { parse } from "lossless-json"; +import { decodeValue, isActorRef, MAX_MESSAGE_BYTES, Value } from "./protocol"; + +/** Natural JSON uses the same $actor/$record marker syntax as the REPL. */ +export function parseValue(source: string): Value { + if (Buffer.byteLength(source) > MAX_MESSAGE_BYTES) throw new Error("JSON input exceeds 1 MiB"); + const input = parse(source, undefined, (literal) => { + if (!/^-?(0|[1-9]\d*)$/.test(literal)) + throw new Error("Only signed 64-bit integers are supported"); + return BigInt(literal); + }); + let nodes = 0; + const record = (input: Record, depth: number): Value => ({ + type: "record", + data: Object.fromEntries( + Object.entries(input).map(([key, child]) => [key, visit(child, depth + 1)]), + ), + }); + const visit = (input: unknown, depth: number): Value => { + if (++nodes > 4096 || depth > 32) throw new Error("Value exceeds node/depth limits"); + if (input === null) return { type: "null" }; + if (typeof input === "boolean") return { type: "bool", data: input }; + if (typeof input === "bigint") return { type: "integer", data: input }; + if (typeof input === "string") return { type: "string", data: input }; + if (Array.isArray(input)) + return { type: "list", data: input.map((child) => visit(child, depth + 1)) }; + if (typeof input !== "object") throw new Error("Unsupported JSON value"); + const entries = Object.entries(input); + const marker = entries.length === 1 ? entries[0] : undefined; + if (marker?.[0] === "$actor") { + const reference = typeof marker[1] === "string" ? marker[1].replace(/^@/, "") : marker[1]; + if (!isActorRef(reference)) throw new Error("$actor requires a NanoID actor reference"); + return { type: "actor_ref", data: reference }; + } + if (marker?.[0] === "$record") { + const escaped: unknown = marker[1]; + if (typeof escaped !== "object" || escaped === null || Array.isArray(escaped)) { + throw new Error("$record requires a JSON object"); + } + return record(Object.fromEntries(Object.entries(escaped)), depth); + } + return record(Object.fromEntries(entries), depth); + }; + return decodeValue(visit(input, 0)); +} diff --git a/vscode/test/client.test.ts b/vscode/test/client.test.ts index e5a4a43..f74d556 100644 --- a/vscode/test/client.test.ts +++ b/vscode/test/client.test.ts @@ -61,6 +61,56 @@ test("protocol errors preserve the connection and are never retried", async (t) : response(request, { kind: "actors", data: [] }), ); }); + + test("outgoing mutations enforce the exact UTF-8 envelope limit without closing the connection", async (t) => { + const sizes: number[] = []; + const address = await fakeImage(t, (request, socket) => { + sizes.push(Buffer.byteLength(json(request))); + socket.send( + response( + request, + request.command.operation === "send" + ? { kind: "acknowledged" } + : { kind: "actors", data: [] }, + ), + ); + }); + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + t.after(() => client.close()); + const overhead = Buffer.byteLength( + json({ + version: 3, + request_id: "runa-vscode-1", + command: { + operation: "send", + parameters: { actor_ref: REF, message: { type: "string", data: "" } }, + }, + }), + ); + const available = MAX_MESSAGE_BYTES - overhead; + const data = "雪".repeat(Math.floor(available / 3)) + "x".repeat(available % 3); + await client.send(REF, { type: "string", data }); + await assert.rejects(client.send(REF, { type: "string", data: data + "x" }), /Request exceeds/); + await client.listActors(); + assert.equal(sizes[0], MAX_MESSAGE_BYTES); + assert.equal(sizes.length, 2); + }); + + test("a lost mutation response rejects queued mutations without retrying", async (t) => { + let requests = 0; + const address = await fakeImage(t, (_request, socket) => { + requests++; + socket.terminate(); + }); + const client = await ImageClient.connect(address, () => undefined); + t.after(() => client.close()); + const results = await Promise.allSettled([ + client.spawn("counter", { type: "integer", data: 0n }), + client.send(REF, { type: "null" }), + ]); + assert.ok(results.every((result) => result.status === "rejected")); + assert.equal(requests, 1); + }); const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); t.after(() => client.close()); await assert.rejects(client.inspectActor(REF), ProtocolError); diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts index 0795949..b98a2e3 100644 --- a/vscode/test/protocol.test.ts +++ b/vscode/test/protocol.test.ts @@ -7,11 +7,11 @@ import { decodeValue, formatSnapshot, ProtocolError, - ReadCommand, + Command, } from "../src/protocol"; import { actor, json, REF } from "./fixtures"; -const command: ReadCommand = { operation: "list_actors" }; +const command: Command = { operation: "list_actors" }; const envelope = (reply: unknown) => json({ version: 3, request_id: "test", result: { Ok: reply } }); @@ -113,6 +113,73 @@ test("server errors are explicit, typed, and not treated as empty actor lists", ); }); +test("mutation replies must match the requested operation and carry validated data", () => { + const spawn: Command = { + operation: "spawn", + parameters: { + behavior_name: "counter", + state: { type: "integer", data: 0n }, + mailbox_capacity: 64, + }, + }; + assert.deepEqual( + decodeResponse(envelope({ kind: "actor_spawned", data: { actor_ref: REF } }), "test", spawn), + { kind: "actor_spawned", data: { actor_ref: REF } }, + ); + assert.throws(() => + decodeResponse(envelope({ kind: "actor_spawned", data: { actor_ref: "bad" } }), "test", spawn), + ); + assert.throws(() => decodeResponse(envelope({ kind: "acknowledged" }), "test", spawn)); + const tick: Command = { operation: "tick", parameters: { ticks: 10, instruction_budget: 32 } }; + assert.deepEqual( + decodeResponse(envelope({ kind: "tick_completed", data: { ticks: 10 } }), "test", tick), + { kind: "tick_completed", data: { ticks: 10n } }, + ); + assert.throws(() => + decodeResponse(envelope({ kind: "tick_completed", data: { ticks: 11 } }), "test", tick), + ); + for (const operation of [ + "stop_actor", + "restart_actor", + "pause_actor", + "resume_actor", + "send", + ] as const) { + const command: Command = + operation === "send" + ? { operation, parameters: { actor_ref: REF, message: { type: "null" } } } + : { operation, parameters: { actor_ref: REF } }; + assert.deepEqual(decodeResponse(envelope({ kind: "acknowledged" }), "test", command), { + kind: "acknowledged", + }); + assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command)); + } +}); + +test("behavior listings validate names, revisions, history, and uniqueness", () => { + const behavior = { + name: "counter", + revision: 1n, + current_implementation_id: "id", + history: [{ revision: 1n, implementation_id: "id" }], + }; + assert.deepEqual( + decodeResponse(envelope({ kind: "behaviors", data: [behavior] }), "test", { + operation: "get_behaviors", + }), + { kind: "behaviors", data: [behavior] }, + ); + for (const data of [ + [{ ...behavior, name: "" }], + [{ ...behavior, revision: 0 }], + [{ ...behavior, history: [{ revision: 0, implementation_id: "id" }] }], + [behavior, behavior], + ]) + assert.throws(() => + decodeResponse(envelope({ kind: "behaviors", data }), "test", { operation: "get_behaviors" }), + ); +}); + test("malformed states and snapshots fail instead of being coerced", () => { for (const state of [ { type: "integer", data: 9223372036854775808n }, diff --git a/vscode/test/values.test.ts b/vscode/test/values.test.ts new file mode 100644 index 0000000..471c51c --- /dev/null +++ b/vscode/test/values.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseValue } from "../src/values"; +import { REF } from "./fixtures"; + +test("natural JSON preserves i64s, string whitespace, markers, and all value variants", () => { + assert.deepEqual( + parseValue( + `{"items":[null,true,-9223372036854775808,9223372036854775807,"two spaces",{"$actor":"@${REF}"}]}`, + ), + { + type: "record", + data: { + items: { + type: "list", + data: [ + { type: "null" }, + { type: "bool", data: true }, + { type: "integer", data: -9223372036854775808n }, + { type: "integer", data: 9223372036854775807n }, + { type: "string", data: "two spaces" }, + { type: "actor_ref", data: REF }, + ], + }, + }, + }, + ); + assert.deepEqual(parseValue(`"${REF}"`), { type: "string", data: REF }); + assert.deepEqual(parseValue('{"$record":{"$actor":"literal"}}'), { + type: "record", + data: { $actor: { type: "string", data: "literal" } }, + }); + assert.deepEqual(parseValue('{"__proto__":null}'), { + type: "record", + data: Object.fromEntries([["__proto__", { type: "null" }]]), + }); +}); + +test("invalid inputs and depth/node/byte overflow fail explicitly", () => { + for (const source of [ + "", + "undefined", + "1.5", + "1e2", + "9223372036854775808", + "-9223372036854775809", + '{"$actor":"binding"}', + '{"$actor":1}', + '{"$record":[]}', + '{"$record":null}', + "[".repeat(33) + "null" + "]".repeat(33), + JSON.stringify(Array(4096).fill(null)), + JSON.stringify("x".repeat(1024 * 1024)), + ]) + assert.throws(() => parseValue(source)); + assert.doesNotThrow(() => parseValue("[".repeat(32) + "null" + "]".repeat(32))); + assert.doesNotThrow(() => parseValue(JSON.stringify(Array(4095).fill(null)))); +});