diff --git a/.gitignore b/.gitignore index 9f97022..c4ab605 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -target/ \ No newline at end of file +target/ +vscode/node_modules/ +vscode/out/ +vscode/.vscode-test/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7f1b572 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Runa Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}/vscode"], + "outFiles": ["${workspaceFolder}/vscode/out/**/*.js"], + "preLaunchTask": "Compile Runa extension" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..19797b6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Compile Runa extension", + "type": "shell", + "command": "npm --prefix vscode run compile", + "problemMatcher": "$tsc", + "group": "build" + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 4d271fa..74eae8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,8 @@ proposed capabilities as plans, not existing behavior. - `scripts/`: general-purpose development automation. - `tests/`: system and end-to-end tests; require an explicit runner rather than assuming Cargo discovers tests at a virtual workspace root. -- `examples/` and `vscode/`: reserved for examples and the VS Code extension. +- `examples/`: reserved for examples. +- `vscode/`: TypeScript localhost inspection client and extension tests. - `README.md`: project overview and current getting-started instructions. ## Commands @@ -88,17 +89,24 @@ Run these from the repository root: mise run format mise run lint mise run test -mise run run +mise run start mise run serve +mise run test-extension-host mise run pre-commit ``` `mise run pre-commit` runs format, lint, and test sequentially, stopping on -failure. Formatting uses the repository's Tombi and rustfmt configurations. -Clippy checks all workspace targets and treats warnings as errors. +failure. Formatting uses the repository's Tombi, rustfmt, and extension Prettier +configurations. Clippy checks all workspace targets and treats warnings as +errors. Extension checks use strict TypeScript and Prettier. Node.js 22+ and npm +are required; install the locked extension dependencies with +`npm --prefix vscode ci`. The workspace tests cover execution probes, actor runtime invariants, and the -interactive CLI. `mise run run` opens the local CLI. +interactive CLI. Extension tests include a real Rust-server TCP round trip. +`mise run start` opens the local CLI. The separate `mise run test-extension-host` +task verifies inspector commands in VS Code and requires a display (or +`xvfb-run -a mise run test-extension-host` on headless Linux). ## Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index c5333fd..2d59f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file. ### Added +- Initial TypeScript VS Code extension with explicit localhost connection + management, an actor explorer, and read-only state/mailbox/execution snapshots. +- Bounded protocol-v3 extension client with lossless 64-bit JSON integers, + correlation checks, disconnect handling, and no automatic retries. +- Extension transport tests, real Rust-server interoperability coverage, and a + VS Code host test for inspector refresh and reconnect isolation. - Portable structured actor values: null, booleans, signed 64-bit integers, strings, lists, records, and distinct NanoID actor references. - JSON state/message literals in the REPL, including named `$actor` references, diff --git a/README.md b/README.md index 035e91e..f8cfc12 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,10 @@ instruction budgets, stepping, and compiled-code sharing. It also runs a minimal single-worker actor demonstration with bounded mailboxes and explicit message effects. Source and compiled implementation identities are BLAKE3 content addresses with an in-memory compile cache. Persistence, canonicalization, -remote network access, graphical world, and VS Code extension are not -implemented yet. The code library now supports an in-memory versioned behavior +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 code library now supports an in-memory versioned behavior registry, and the CLI exposes publishing, history, rollback, safe-boundary installation, pause/resume, and cancellation through a trusted in-process image API. A versioned transport-neutral protocol contract is now defined, but no @@ -79,8 +81,7 @@ TCP and exchange typed protocol requests with request/response correlation. TCP connection establishment is limited to 5 seconds and reads/writes to 30 seconds. Transport failures disconnect the client and are never automatically retried: an operation may have committed even if its response was lost. -The VS Code extension is -intended to connect over the network to this same live image; actors remain in +The VS Code extension connects over the network to this same live image; actors remain in the image regardless of how clients connect. Remote network access requires authentication and authorization, which are not implemented. @@ -124,10 +125,25 @@ See [the feasibility report](docs/rune-feasibility.md) for verified Rune capabilities and limitations, and [the runtime guide](docs/runtime.md) for the runtime contract. +## VS Code client + +With Node.js 22+ and npm installed, run `npm --prefix vscode ci`. In VS Code, +open this repository and launch **Run Runa Extension** from Run and Debug +(F5). Start `mise run serve-network`, then use **Runa: Connect to Local Image** +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, and use **Runa: Refresh Actors** to fetch current snapshots. + +The extension does not start, tick, or stop the image. Disconnecting leaves +actors running in the server. Editing, spawn/send controls, and event +subscriptions are not implemented yet. See the [extension guide](vscode/README.md) +for connection restrictions, snapshot semantics, and tests. + ## Development Install [mise](https://mise.jdx.dev/) and run `mise install` to install Tombi. -Rust, rustfmt, and Clippy must also be available on your PATH. +Rust, rustfmt, Clippy, Node.js 22+, and npm must also be available on your PATH. +Install the extension's locked dependencies with `npm --prefix vscode ci`. ```sh mise run format @@ -140,9 +156,15 @@ The pre-commit task runs formatting, linting, and tests in that order, stopping on the first failure. Run it successfully before committing and review any formatter changes. This is a task, not an automatically installed Git hook. -Tombi formats TOML files offline; rustfmt formats all workspace Rust code. -Clippy checks all workspace targets with warnings treated as errors, and the -test task runs `cargo test --workspace`. +Tombi formats TOML files offline; rustfmt formats all workspace Rust code; +Prettier formats the extension. Clippy checks all workspace targets with +warnings treated as errors. Extension checks include strict TypeScript and +Prettier. The test task runs the Rust workspace tests, extension unit tests, +and a TypeScript-to-Rust TCP interoperability test. +`mise run test-extension-host` additionally downloads VS Code 1.96.4 into an +ignored test cache and tests the actual inspector commands in an isolated +Extension Development Host. It requires a graphical display; on headless Linux, +use `xvfb-run -a mise run test-extension-host`. ## Project structure diff --git a/docs/architecture.md b/docs/architecture.md index 574a0c7..f31f709 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -239,7 +239,15 @@ Normal actors should not automatically receive development privileges. Arbitrary ## 9. VS Code Extension -VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON Lines over TCP, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients. Connection lifecycle and event subscription semantics still need design. +VS Code is an external client of the same live image, not a separate runtime. The initial development transport is versioned JSON Lines over TCP, bound to loopback and unauthenticated; the extension must connect only to a trusted local image. Remote connections remain out of scope until authentication and authorization are implemented. The extension does not host actors or create a separate image. The local stdio protocol remains useful for CLI tools, tests, and other child-process clients. + +The first extension increment implements explicit connect/disconnect, an actor +explorer, and read-only state/mailbox/execution snapshots. Refresh and inspection +fetch current data on demand; no scheduler ticks or event drains are performed. +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; non-destructive event subscription semantics +still need design. Required extension UX: @@ -475,6 +483,10 @@ single-image actor model. - Connection, actor explorer, state/mailbox inspection, spawn/send controls. - 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, +diagnostics, hot upgrades, and event subscriptions remain planned. + ### Phase 8 — Persistence and advanced debugger - Durable mailboxes and state, snapshot/restore, supervised recovery. diff --git a/docs/protocol.md b/docs/protocol.md index 5e688f5..7d78df8 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -107,6 +107,16 @@ retrieve them through `list_actors` and `get_behaviors`. The TCP executable publishes the bundled `counter` behavior on image startup, not on connection. The stdio executable continues to start with an empty image. +The TypeScript VS Code client currently issues only `list_actors` and +`inspect_actor`. It enforces loopback endpoints, bounded UTF-8 response lines, +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 +connect and 30-second request timeouts, closes on transport/protocol corruption, +and never retries automatically. A well-formed server error is surfaced without +invalidating the stream. Refresh is manual; the extension does not call +`get_events`, because doing so would drain events needed by other clients. + The current validation checks the protocol version, request ID, actor-reference shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox capacity, and instruction budget. Behavior-name grammar, actor existence, diff --git a/mise.toml b/mise.toml index dec58e0..7340894 100644 --- a/mise.toml +++ b/mise.toml @@ -2,12 +2,20 @@ tombi = "latest" [tasks.format] -description = "Format TOML and Rust files" -run = ["tombi format --offline", "cargo fmt --all"] +description = "Format TOML, Rust, and extension files" +run = ["tombi format --offline", "cargo fmt --all", "npm --prefix vscode run format"] [tasks.test] -description = "Run workspace tests" -run = "cargo test --workspace" +description = "Run Rust and extension tests" +run = [ + "cargo test --workspace", + "npm --prefix vscode test", + "npm --prefix vscode run test:integration", +] + +[tasks.test-extension-host] +description = "Test the extension in VS Code (requires a display or xvfb-run)" +run = "npm --prefix vscode run test:host" [tasks.start] description = "Start the local Runa CLI" @@ -22,8 +30,8 @@ description = "Run the protocol server on localhost" run = "cargo run -p runa-server -- --listen 127.0.0.1:3737" [tasks.lint] -description = "Lint all workspace targets with Clippy" -run = "cargo clippy --workspace --all-targets -- -D warnings" +description = "Check Rust targets and the TypeScript extension" +run = ["cargo clippy --workspace --all-targets -- -D warnings", "npm --prefix vscode run lint"] [tasks.pre-commit] description = "Format, lint, and test before committing" diff --git a/vscode/.gitkeep b/vscode/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/vscode/.prettierignore b/vscode/.prettierignore new file mode 100644 index 0000000..2a07a19 --- /dev/null +++ b/vscode/.prettierignore @@ -0,0 +1,4 @@ +node_modules/ +out/ +package-lock.json +.vscode-test/ diff --git a/vscode/.prettierrc.json b/vscode/.prettierrc.json new file mode 100644 index 0000000..7e1cf5e --- /dev/null +++ b/vscode/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "printWidth": 100 +} diff --git a/vscode/README.md b/vscode/README.md new file mode 100644 index 0000000..cd24f52 --- /dev/null +++ b/vscode/README.md @@ -0,0 +1,88 @@ +# Runa VS Code client + +This is the first, read-only client for a trusted localhost Runa image. It uses +the [version 3 JSON-lines protocol](../docs/protocol.md), not Rust APIs, and never +hosts actors or starts its own image. + +## Run from source + +Requirements: VS Code 1.96+, Node.js 22+, npm, and the repository's Rust toolchain. + +From the repository root: + +```sh +npm --prefix vscode ci +mise run serve-network +``` + +Open the repository in VS Code. In Run and Debug, select **Run Runa Extension** +and press F5. The launch task compiles TypeScript before opening an Extension +Development Host. + +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 start`) to + spawn actors in the same server, then run **Runa: Refresh Actors**. +3. Click an actor or run **Runa: Inspect Actor** to open its JSON snapshot. +4. Run **Runa: Disconnect** to close only this client connection. + +Connection prompts accept numeric IPv4 loopback addresses (`127.x.x.x`) or +`[::1]:port`. DNS names, wildcard addresses, remote addresses, and IPv4-mapped +IPv6 addresses are rejected. There is no authentication: connect only to a +trusted local server. In a remote VS Code window, localhost refers to the +machine running the extension host, not necessarily the desktop. + +## Snapshot semantics + +- The tree shows actor references, lifecycle status, and queued/reserved counts. + Tooltips include implementation ID, incarnation, and failure text. +- Inspect fetches a fresh snapshot through `inspect_actor`. Documents show + 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. + The timestamp records when this client received the data, not a runtime clock. + **Refresh Actors** updates the tree and existing documents for that connection. +- 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 + releases its cache; snapshots are 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** + 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. + +For automation, the `runa.connect` command also accepts an explicit endpoint +string, and `runa.inspect` accepts an object with an `actor_ref` field. + +## Development and validation + +From the repository root: + +```sh +mise run format +mise run lint +mise run test +mise run test-extension-host +``` + +The normal test task covers portable-value validation, framing and UTF-8 +fragmentation, 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, refresh, disconnect, and reconnect +commands against a test-owned Rust server. It verifies actual virtual document +content and session isolation. On headless Linux: + +```sh +xvfb-run -a mise run test-extension-host +``` + +Behavior/source browsing and editing, spawn/send and lifecycle controls, +compilation diagnostics, non-destructive event subscriptions, packaging for +distribution, and debugging remain future work. diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts new file mode 100644 index 0000000..4da671d --- /dev/null +++ b/vscode/integration/host.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import * as vscode from "vscode"; +import { control, spawnCounter, startImage } from "./server"; + +export async function run(): Promise { + const image = await startImage(); + try { + const actorRef = await spawnCounter(image.address); + await vscode.commands.executeCommand("runa.connect", image.address); + await vscode.commands.executeCommand("runa.refresh"); + await vscode.commands.executeCommand("runa.inspect", { actor_ref: actorRef }); + let document = vscode.window.activeTextEditor?.document; + assert.ok(document); + assert.equal(document.uri.scheme, "runa"); + assert.equal(document.languageId, "json"); + assert.match(document.getText(), /9223372036854775807/); + assert.match(document.getText(), /"connected": true/); + const firstUri = document.uri.toString(); + assert.deepEqual( + await control(image.address, { + operation: "send", + parameters: { + actor_ref: actorRef, + message: { type: "string", data: "queued from another client" }, + }, + }), + { version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } }, + ); + const refreshed = documentChanged(document); + await vscode.commands.executeCommand("runa.refresh"); + await refreshed; + assert.match(document.getText(), /queued from another client/); + const disconnected = documentChanged(document); + await vscode.commands.executeCommand("runa.disconnect"); + await disconnected; + assert.match(document.getText(), /"connected": false/); + await vscode.commands.executeCommand("runa.connect", image.address); + await vscode.commands.executeCommand("runa.inspect", { actor_ref: actorRef }); + document = vscode.window.activeTextEditor?.document; + assert.ok(document); + assert.notEqual(document.uri.toString(), firstUri); + assert.match(document.getText(), /queued from another client/); + const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri)); + assert.match(old.getText(), /"connected": false/); + console.log("Runa extension host: connect, inspect, refresh, disconnect, reconnect passed"); + } finally { + await vscode.commands.executeCommand("runa.disconnect"); + await image.stop(); + } +} + +function documentChanged(document: vscode.TextDocument): Promise { + return new Promise((resolve, reject) => { + const subscription = vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.uri.toString() === document.uri.toString()) { + clearTimeout(timeout); + subscription.dispose(); + resolve(); + } + }); + const timeout = setTimeout(() => { + subscription.dispose(); + reject(new Error("Actor inspector did not refresh")); + }, 5000); + }); +} diff --git a/vscode/integration/server.test.ts b/vscode/integration/server.test.ts new file mode 100644 index 0000000..9dbbf30 --- /dev/null +++ b/vscode/integration/server.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ImageClient } from "../src/client"; +import { control, spawnCounter, startImage } from "./server"; + +test("TypeScript inspection shares the real Rust image across clients and reconnects", async (t) => { + const image = await startImage(); + t.after(() => image.stop()); + const actorRef = await spawnCounter(image.address); + const reference = { type: "actor_ref", data: actorRef }; + const payload = { + type: "record", + data: { + nested: { + type: "list", + data: [reference, { type: "integer", data: -9223372036854775808n }], + }, + }, + }; + assert.deepEqual( + await control(image.address, { + operation: "send", + parameters: { actor_ref: actorRef, message: payload }, + }), + { version: 3, request_id: "integration-control", result: { Ok: { kind: "acknowledged" } } }, + ); + + const failures: Error[] = []; + const disconnected = (error?: Error) => { + if (error) failures.push(error); + }; + const first = await ImageClient.connect(image.address, disconnected); + t.after(() => first.close()); + const [snapshot] = await first.listActors(); + assert.ok(snapshot); + assert.equal(snapshot.actor_ref, actorRef); + assert.deepEqual(snapshot.state, { type: "integer", data: 9223372036854775807n }); + assert.deepEqual(snapshot.queued, [payload]); + assert.equal(snapshot.reserved, null); + first.close(); + const second = await ImageClient.connect(image.address, disconnected); + t.after(() => second.close()); + assert.deepEqual(await second.inspectActor(actorRef), snapshot); + // Reading from the extension must not drain the shared runtime event queue. + const events = await control(image.address, { operation: "get_events" }); + assert.deepEqual(events, { + version: 3, + request_id: "integration-control", + result: { Ok: { kind: "events", data: [{ event: "spawned", data: { actor_ref: actorRef } }] } }, + }); + assert.deepEqual(failures, []); +}); diff --git a/vscode/integration/server.ts b/vscode/integration/server.ts new file mode 100644 index 0000000..69b085f --- /dev/null +++ b/vscode/integration/server.ts @@ -0,0 +1,102 @@ +import { ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { createConnection } from "node:net"; +import { resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { json } from "../test/fixtures"; + +export async function startImage(): Promise<{ address: string; stop: () => Promise }> { + const executable = resolve( + __dirname, + "../../../target/debug", + process.platform === "win32" ? "runa-server.exe" : "runa-server", + ); + const child = spawn(executable, ["--listen", "127.0.0.1:0"], { + stdio: ["ignore", "ignore", "pipe"], + }); + const stop = () => stopImage(child); + let address: string; + try { + address = await new Promise((resolve, reject) => { + const lines = createInterface({ input: child.stderr }); + let diagnostics = ""; + const cleanup = () => { + clearTimeout(timer); + child.off("error", fail); + child.off("exit", exited); + lines.close(); + child.stderr.resume(); + }; + const fail = (error: Error) => { + cleanup(); + reject(error); + }; + const exited = () => fail(new Error(`Image exited during startup: ${diagnostics}`)); + const timer = setTimeout( + () => fail(new Error(`Image startup timed out: ${diagnostics}`)), + 10000, + ); + child.once("error", fail); + child.once("exit", exited); + lines.on("line", (line) => { + diagnostics = (diagnostics + line + "\n").slice(-8192); + const address = line.match(/^runa-server: listening on (.+)$/)?.[1]; + if (address) { + cleanup(); + resolve(address); + } + }); + }); + } catch (error) { + await stop(); + throw error; + } + return { address, stop }; +} + +async function stopImage(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) return; + const exited = once(child, "exit"); + child.kill(); + await exited; +} + +export async function control(address: string, command: unknown): Promise { + const port = Number(address.split(":")[1]); + const socket = createConnection({ host: "127.0.0.1", port }); + socket.setTimeout(5000, () => socket.destroy(new Error("Control request timed out"))); + const lines = createInterface({ input: socket }); + try { + await once(socket, "connect"); + const response = Promise.race([ + once(lines, "line").then((args) => { + const line: unknown = args[0]; + if (typeof line !== "string") throw new Error("Missing control response"); + const decoded: unknown = JSON.parse(line); + return decoded; + }), + once(socket, "close").then(() => { + throw new Error("Control connection closed"); + }), + ]); + socket.write(json({ version: 3, request_id: "integration-control", command }) + "\n"); + return await response; + } finally { + lines.close(); + socket.destroy(); + } +} + +export async function spawnCounter(address: string): Promise { + const reply = await control(address, { + operation: "spawn", + parameters: { + behavior_name: "counter", + state: { type: "integer", data: 9223372036854775807n }, + mailbox_capacity: 4, + }, + }); + const ref = json(reply).match(/"actor_ref":"([A-Za-z0-9_-]{21})"/)?.[1]; + if (!ref) throw new Error(`Spawn failed: ${json(reply)}`); + return ref; +} diff --git a/vscode/package-lock.json b/vscode/package-lock.json new file mode 100644 index 0000000..58aa7ff --- /dev/null +++ b/vscode/package-lock.json @@ -0,0 +1,552 @@ +{ + "name": "runa", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "runa", + "version": "0.0.0", + "dependencies": { + "lossless-json": "^4.3.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/vscode": "~1.96.0", + "@vscode/test-electron": "^2.5.2", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22", + "vscode": "^1.96.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.96.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.96.0.tgz", + "integrity": "sha512-qvZbSZo+K4ZYmmDuaodMbAa67Pl6VDQzLKFka6rq+3WUTY4Kro7Bwoi0CuZLO/wema0ygcmpwow7zZfPJTs5jg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lossless-json": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.3.tgz", + "integrity": "sha512-FGFrGArHsv7BRiYOf37ZRieT9HlKw+Kpu9HvtpYjcoOEo8lqNkjny8A2YAfWNWWQC+9pcLIrLZ+IviKJhaHwGQ==", + "license": "MIT" + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/vscode/package.json b/vscode/package.json new file mode 100644 index 0000000..4f72697 --- /dev/null +++ b/vscode/package.json @@ -0,0 +1,109 @@ +{ + "name": "runa", + "displayName": "Runa — Rune Actors", + "description": "Inspect actors in a trusted localhost Runa image.", + "version": "0.0.0", + "private": true, + "engines": { + "vscode": "^1.96.0", + "node": ">=22" + }, + "categories": [ + "Other" + ], + "main": "./out/src/extension.js", + "activationEvents": [ + "onView:runa.actors" + ], + "scripts": { + "compile": "tsc -p .", + "lint": "tsc -p . --noEmit && prettier --check .", + "format": "prettier --write .", + "test": "npm run compile && node --test out/test/*.test.js", + "test:integration": "cargo build --manifest-path ../Cargo.toml -p runa-server && npm run compile && node --test out/integration/*.test.js", + "test:host": "cargo build --manifest-path ../Cargo.toml -p runa-server && npm run compile && node scripts/test-host.cjs" + }, + "dependencies": { + "lossless-json": "^4.3.0" + }, + "devDependencies": { + "@vscode/test-electron": "^2.5.2", + "@types/node": "^22.0.0", + "@types/vscode": "~1.96.0", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "contributes": { + "commands": [ + { + "command": "runa.connect", + "title": "Runa: Connect to Local Image" + }, + { + "command": "runa.disconnect", + "title": "Runa: Disconnect" + }, + { + "command": "runa.refresh", + "title": "Runa: Refresh Actors", + "icon": "$(refresh)" + }, + { + "command": "runa.inspect", + "title": "Runa: Inspect Actor", + "icon": "$(inspect)" + } + ], + "views": { + "explorer": [ + { + "id": "runa.actors", + "name": "Runa Actors" + } + ] + }, + "viewsWelcome": [ + { + "view": "runa.actors", + "contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)", + "when": "!runa.connected" + } + ], + "menus": { + "view/title": [ + { + "command": "runa.connect", + "when": "view == runa.actors && !runa.connected", + "group": "navigation" + }, + { + "command": "runa.refresh", + "when": "view == runa.actors && runa.connected", + "group": "navigation" + }, + { + "command": "runa.disconnect", + "when": "view == runa.actors && runa.connected" + } + ], + "view/item/context": [ + { + "command": "runa.inspect", + "when": "view == runa.actors && viewItem == runa.actor", + "group": "inline" + } + ] + }, + "configuration": { + "title": "Runa", + "properties": { + "runa.address": { + "type": "string", + "default": "127.0.0.1:3737", + "scope": "machine", + "description": "Trusted local image endpoint: numeric IPv4 loopback or [::1]:port. No authentication is available." + } + } + } + } +} diff --git a/vscode/scripts/test-host.cjs b/vscode/scripts/test-host.cjs new file mode 100644 index 0000000..9ca110c --- /dev/null +++ b/vscode/scripts/test-host.cjs @@ -0,0 +1,18 @@ +const path = require("node:path"); +const { runTests } = require("@vscode/test-electron"); + +runTests({ + version: "1.96.4", + extensionDevelopmentPath: path.resolve(__dirname, ".."), + extensionTestsPath: path.resolve(__dirname, "../out/integration/host.js"), + launchArgs: [ + "--disable-gpu", + "--no-sandbox", + "--skip-welcome", + "--disable-extensions", + "--disable-telemetry", + ], +}).catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/vscode/src/client.ts b/vscode/src/client.ts new file mode 100644 index 0000000..d97944b --- /dev/null +++ b/vscode/src/client.ts @@ -0,0 +1,178 @@ +import { createConnection, isIP, Socket } from "node:net"; +import { TextDecoder } from "node:util"; +import { + ActorSnapshot, + decodeResponse, + isActorRef, + MAX_LINE_BYTES, + PROTOCOL_VERSION, + ProtocolError, + ReadCommand, + ReadReply, +} from "./protocol"; + +export function parseEndpoint(endpoint: string): { host: string; port: number } { + const match = /^(?:\[([^\]]+)\]|([^:]+)):(\d+)$/.exec(endpoint); + if (!match) throw new Error("Use a numeric loopback address, e.g. 127.0.0.1:3737 or [::1]:3737"); + const host = match[1] ?? match[2]; + if ( + host === undefined || + !((isIP(host) === 4 && host.startsWith("127.")) || (isIP(host) === 6 && host === "::1")) + ) { + throw new Error("Only numeric IPv4 loopback or [::1] endpoints are supported"); + } + const port = Number(match[3]); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid TCP port"); + return { host, port }; +} + +interface Pending { + requestId: string; + command: ReadCommand; + resolve: (reply: ReadReply) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +} + +export class ImageClient { + private buffer = Buffer.alloc(0); + private pending?: Pending; + private queue: Promise = Promise.resolve(); + private sequence = 0; + private closed = false; + + private constructor( + private readonly socket: Socket, + private readonly onDisconnect: (error?: Error) => void, + private readonly requestTimeout: number, + ) { + socket.on("data", (chunk: Buffer) => this.receive(chunk)); + socket.on("error", (error) => this.close(error)); + socket.on("close", () => this.close(new Error("Image connection closed"))); + } + + static async connect( + endpoint: string, + onDisconnect: (error?: Error) => void, + timeouts = { connect: 5000, request: 30000 }, + ): Promise { + const address = parseEndpoint(endpoint); + const socket = createConnection(address); + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + socket.off("error", fail); + socket.off("connect", connected); + }; + const fail = (error: Error) => { + cleanup(); + socket.destroy(); + reject(error); + }; + const connected = () => { + cleanup(); + resolve(); + }; + const timer = setTimeout( + () => fail(new Error("Image connection timed out")), + timeouts.connect, + ); + socket.once("error", fail); + socket.once("connect", connected); + }); + return new ImageClient(socket, onDisconnect, timeouts.request); + } + + async listActors(): Promise { + const reply = await this.request({ operation: "list_actors" }); + if (reply.kind !== "actors") throw new Error("Expected actor listing"); + return reply.data; + } + + async inspectActor(actorRef: string): Promise { + if (!isActorRef(actorRef)) throw new Error("Invalid actor reference"); + const reply = await this.request({ + operation: "inspect_actor", + parameters: { actor_ref: actorRef }, + }); + if (reply.kind !== "actor") throw new Error("Expected actor snapshot"); + return reply.data; + } + + private request(command: ReadCommand): Promise { + const request = this.queue.then( + () => + new Promise((resolve, reject) => { + if (this.closed) { + reject(new Error("Not connected to an image")); + return; + } + const requestId = `runa-vscode-${++this.sequence}`; + const line = JSON.stringify({ + version: PROTOCOL_VERSION, + request_id: requestId, + command, + }); + const timer = setTimeout( + () => this.close(new Error("Image request timed out; connection closed without retry")), + this.requestTimeout, + ); + this.pending = { requestId, command, resolve, reject, timer }; + this.socket.write(line + "\n", (error) => { + if (error) this.close(error); + }); + }), + ); + // A failed request must not prevent later requests; each caller retains its own rejection. + this.queue = request.then( + () => undefined, + () => undefined, + ); + return request; + } + + private receive(chunk: Buffer): void { + if (this.closed) return; + let offset = 0; + while (offset < chunk.length && !this.closed) { + if (!this.pending) { + this.close(new Error("Unexpected unsolicited image response")); + return; + } + const newline = chunk.indexOf(10, offset); + const end = newline === -1 ? chunk.length : newline; + if (this.buffer.length + end - offset > MAX_LINE_BYTES) { + this.close(new Error("Image response exceeds the 1 MiB line limit")); + return; + } + this.buffer = Buffer.concat([this.buffer, chunk.subarray(offset, end)]); + if (newline === -1) return; + const frame = this.buffer; + this.buffer = Buffer.alloc(0); + offset = newline + 1; + const pending = this.pending; + this.pending = undefined; + clearTimeout(pending.timer); + try { + const line = new TextDecoder("utf-8", { fatal: true }).decode(frame); + pending.resolve(decodeResponse(line, pending.requestId, pending.command)); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + pending.reject(failure); + if (!(failure instanceof ProtocolError)) this.close(failure); + } + } + } + + close(error?: Error): void { + if (this.closed) return; + this.closed = true; + this.socket.destroy(); + if (this.pending) { + clearTimeout(this.pending.timer); + this.pending.reject(error ?? new Error("Disconnected from image")); + this.pending = undefined; + } + this.onDisconnect(error); + } +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts new file mode 100644 index 0000000..462cd2e --- /dev/null +++ b/vscode/src/extension.ts @@ -0,0 +1,285 @@ +import * as vscode from "vscode"; +import { ImageClient, parseEndpoint } from "./client"; +import { ActorSnapshot, formatSnapshot } from "./protocol"; + +class ActorTree implements vscode.TreeDataProvider { + private readonly changes = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.changes.event; + actors: ActorSnapshot[] = []; + + replace(actors: ActorSnapshot[]): void { + this.actors = actors; + this.changes.fire(); + } + + getChildren(): ActorSnapshot[] { + return this.actors; + } + + getTreeItem(actor: ActorSnapshot): vscode.TreeItem { + const item = new vscode.TreeItem(`@${actor.actor_ref}`); + item.id = actor.actor_ref; + item.contextValue = "runa.actor"; + item.description = `${actor.status.status}, queued ${actor.queued.length}${actor.reserved ? ", reserved 1" : ""}`; + item.tooltip = [ + `Actor: @${actor.actor_ref}`, + `Status: ${actor.status.status}`, + ...(actor.status.message ? [`Failure: ${actor.status.message}`] : []), + `Incarnation: ${actor.incarnation}`, + `Implementation: ${actor.implementation_id}`, + ].join("\n"); + item.iconPath = new vscode.ThemeIcon( + actor.status.status === "failed" ? "error" : "symbol-interface", + ); + item.command = { + command: "runa.inspect", + title: "Inspect Actor", + arguments: [actor], + }; + return item; + } + + dispose(): void { + this.changes.dispose(); + } +} + +interface DocumentSnapshot { + session: number; + endpoint: string; + actor: ActorSnapshot; + capturedAt: string; + connected: boolean; +} + +class SnapshotDocuments implements vscode.TextDocumentContentProvider { + private readonly changes = new vscode.EventEmitter(); + readonly onDidChange = this.changes.event; + private readonly documents = new Map(); + + set(session: number, endpoint: string, actor: ActorSnapshot): vscode.Uri { + const uri = vscode.Uri.from({ + scheme: "runa", + authority: String(session), + path: `/actors/${actor.actor_ref}.json`, + }); + this.documents.set(uri.toString(), { + session, + endpoint, + actor, + capturedAt: new Date().toISOString(), + connected: true, + }); + this.changes.fire(uri); + return uri; + } + + refresh(session: number, endpoint: string, actors: ActorSnapshot[]): void { + const byRef = new Map(actors.map((actor) => [actor.actor_ref, actor])); + for (const snapshot of this.documents.values()) { + const actor = byRef.get(snapshot.actor.actor_ref); + if (snapshot.session === session && actor) this.set(session, endpoint, actor); + } + } + + disconnect(session: number): void { + for (const [key, snapshot] of this.documents) { + if (snapshot.session === session) { + snapshot.connected = false; + this.changes.fire(vscode.Uri.parse(key)); + } + } + } + + provideTextDocumentContent(uri: vscode.Uri): string { + const snapshot = this.documents.get(uri.toString()); + if (!snapshot) throw new Error("Snapshot is no longer available; inspect the actor again"); + return formatSnapshot( + snapshot.actor, + snapshot.endpoint, + snapshot.capturedAt, + snapshot.connected, + ); + } + + remove(uri: vscode.Uri): void { + this.documents.delete(uri.toString()); + } + + dispose(): void { + this.changes.dispose(); + this.documents.clear(); + } +} + +export function activate(context: vscode.ExtensionContext): void { + const tree = new ActorTree(); + const documents = new SnapshotDocuments(); + const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree }); + const output = vscode.window.createOutputChannel("Runa"); + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); + const reported = new WeakSet(); + let client: ImageClient | undefined; + let session = 0; + let endpoint = ""; + let disposed = false; + + const report = (error: unknown) => { + const failure = error instanceof Error ? error : new Error(String(error)); + if (disposed || reported.has(failure)) return; + reported.add(failure); + output.appendLine(`Error: ${failure.message}`); + void vscode.window.showErrorMessage(`Runa: ${failure.message}`); + }; + + const connectionStatus = (connected: boolean) => { + 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.` + : "Connect to Runa"; + status.command = connected ? "runa.refresh" : "runa.connect"; + view.description = connected ? endpoint : "Disconnected"; + view.message = connected ? undefined : "Not connected to an image."; + }; + + const disconnect = () => { + const previous = client; + client = undefined; + documents.disconnect(session); + session++; + previous?.close(); + tree.replace([]); + connectionStatus(false); + }; + + const refresh = async () => { + const current = client; + const currentSession = session; + if (!current) throw new Error("Connect to a local image first"); + const actors = await current.listActors(); + 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."; + }; + + const connect = async (target?: string) => { + const address = + target ?? + (await vscode.window.showInputBox({ + title: "Connect to a trusted local Runa image (no authentication)", + value: vscode.workspace.getConfiguration("runa").get("address", "127.0.0.1:3737"), + prompt: + "Start the server with mise run serve-network. The extension does not start an image.", + validateInput: (value) => { + try { + parseEndpoint(value); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }, + })); + if (address === undefined || disposed) return; + parseEndpoint(address); + disconnect(); + endpoint = address; + const currentSession = session; + view.message = `Connecting to ${address}...`; + let connected: ImageClient; + try { + connected = await ImageClient.connect(address, (error) => { + if (currentSession !== session || disposed) return; + client = undefined; + tree.replace([]); + documents.disconnect(currentSession); + connectionStatus(false); + output.appendLine(`Disconnected from ${address}`); + if (error) report(error); + }); + } catch (error) { + if (currentSession === session) connectionStatus(false); + throw error; + } + if (currentSession !== session || disposed) { + connected.close(); + return; + } + client = connected; + try { + await refresh(); + } catch (error) { + if (currentSession === session) disconnect(); + throw error; + } + if (connected !== client) return; + connectionStatus(true); + view.message = tree.actors.length + ? undefined + : "No actors. Spawn one with the Runa REPL, then refresh."; + output.appendLine(`Connected to ${address} (protocol v3)`); + }; + + const inspect = async (selected?: ActorSnapshot) => { + const current = client; + const currentSession = session; + if (!current) throw new Error("Connect to a local image first"); + let actorRef = selected?.actor_ref; + if (!actorRef) { + 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 to inspect (refresh to discover newly spawned actors)" }, + ); + if (!choice) return; + actorRef = choice.actorRef; + } + if (current !== client || currentSession !== session) return; + const actor = await current.inspectActor(actorRef); + if (current !== client || currentSession !== session) return; + const uri = documents.set(session, endpoint, actor); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.languages.setTextDocumentLanguage(document, "json"); + await vscode.window.showTextDocument(document, { preview: true }); + }; + + const command = ( + name: string, + action: (...args: Args) => Promise | void, + ) => + vscode.commands.registerCommand(name, async (...args: Args) => { + try { + await action(...args); + } catch (error) { + report(error); + } + }); + + context.subscriptions.push( + tree, + documents, + view, + output, + status, + vscode.workspace.registerTextDocumentContentProvider("runa", documents), + vscode.workspace.onDidCloseTextDocument((document) => documents.remove(document.uri)), + command("runa.connect", connect), + command("runa.disconnect", disconnect), + command("runa.refresh", refresh), + command("runa.inspect", inspect), + { + dispose: () => { + disposed = true; + client?.close(); + }, + }, + ); + connectionStatus(false); + status.show(); +} diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts new file mode 100644 index 0000000..f91e6fb --- /dev/null +++ b/vscode/src/protocol.ts @@ -0,0 +1,223 @@ +import { parse, stringify } from "lossless-json"; + +export const PROTOCOL_VERSION = 3; +export const MAX_LINE_BYTES = 1024 * 1024; +const ACTOR_REF = /^[A-Za-z0-9_-]{21}$/; + +export type Value = + | { type: "null" } + | { type: "bool"; data: boolean } + | { type: "integer"; data: bigint } + | { type: "string" | "actor_ref"; data: string } + | { type: "list"; data: Value[] } + | { type: "record"; data: Record }; + +export interface ActorSnapshot { + actor_ref: string; + mailbox_id: bigint; + implementation_id: string; + incarnation: bigint; + state: Value; + status: { + status: "idle" | "running" | "awaiting" | "paused" | "stopped" | "failed"; + message?: string; + }; + execution: { + vm_steps: bigint; + instruction_pointer: bigint; + call_depth: bigint; + } | null; + queued: Value[]; + reserved: Value | null; +} + +export type ReadCommand = + { operation: "list_actors" } | { operation: "inspect_actor"; parameters: { actor_ref: string } }; + +export class ProtocolError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`${code}: ${message}`); + } +} + +export function isActorRef(value: unknown): value is string { + return typeof value === "string" && ACTOR_REF.test(value); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function object(value: unknown): Record { + if (!isObject(value)) { + throw new Error("Expected a protocol object"); + } + return value; +} + +function text(value: unknown): string { + if (typeof value !== "string") throw new Error("Expected a protocol string"); + return value; +} + +function uint(value: unknown): bigint { + if (typeof value !== "bigint" || value < 0n || value > 18446744073709551615n) { + throw new Error("Expected an unsigned 64-bit protocol integer"); + } + return value; +} + +function array(value: unknown): unknown[] { + if (!Array.isArray(value)) throw new Error("Expected a protocol array"); + return value; +} + +export function decodeValue(input: unknown): Value { + let nodes = 0; + let bytes = 0; + const visit = (input: unknown, depth: number): Value => { + if (++nodes > 4096 || depth > 32) throw new Error("Value exceeds node/depth limits"); + const value = object(input); + if (Object.keys(value).some((key) => key !== "type" && key !== "data")) { + throw new Error("Unknown portable value field"); + } + const type = value.type; + switch (type) { + case "null": + if ("data" in value) throw new Error("Null must not carry data"); + return { type }; + case "bool": + if (typeof value.data !== "boolean") throw new Error("Invalid boolean value"); + return { type, data: value.data }; + case "integer": + if ( + typeof value.data !== "bigint" || + value.data < -9223372036854775808n || + value.data > 9223372036854775807n + ) { + throw new Error("Invalid signed 64-bit value"); + } + return { type, data: value.data }; + case "string": { + const data = text(value.data); + bytes += Buffer.byteLength(data); + if (bytes > MAX_LINE_BYTES) throw new Error("Value exceeds string/key byte limit"); + return { type, data }; + } + case "actor_ref": + if (!isActorRef(value.data)) throw new Error("Invalid actor reference value"); + return { type, data: value.data }; + case "list": + return { type, data: array(value.data).map((child) => visit(child, depth + 1)) }; + case "record": { + const entries = Object.entries(object(value.data)).map(([key, child]) => { + bytes += Buffer.byteLength(key); + if (bytes > MAX_LINE_BYTES) throw new Error("Value exceeds string/key byte limit"); + return [key, visit(child, depth + 1)]; + }); + return { type, data: Object.fromEntries(entries) }; + } + default: + throw new Error(`Unsupported portable value type: ${String(type)}`); + } + }; + return visit(input, 0); +} + +function decodeActor(input: unknown): ActorSnapshot { + const actor = object(input); + if (!isActorRef(actor.actor_ref)) throw new Error("Invalid actor reference in snapshot"); + const status = object(actor.status); + const name = status.status; + if ( + name !== "idle" && + name !== "running" && + name !== "awaiting" && + name !== "paused" && + name !== "stopped" && + name !== "failed" + ) { + throw new Error("Unknown actor status"); + } + const execution = actor.execution === null ? null : object(actor.execution); + return { + actor_ref: actor.actor_ref, + mailbox_id: uint(actor.mailbox_id), + implementation_id: text(actor.implementation_id), + incarnation: uint(actor.incarnation), + state: decodeValue(actor.state), + status: name === "failed" ? { status: name, message: text(status.message) } : { status: name }, + execution: execution && { + vm_steps: uint(execution.vm_steps), + instruction_pointer: uint(execution.instruction_pointer), + call_depth: uint(execution.call_depth), + }, + queued: array(actor.queued).map(decodeValue), + reserved: actor.reserved === null ? null : decodeValue(actor.reserved), + }; +} + +export type ReadReply = + { kind: "actors"; data: ActorSnapshot[] } | { kind: "actor"; data: ActorSnapshot }; + +export function decodeResponse(line: string, requestId: string, command: ReadCommand): ReadReply { + const response = object( + parse(line, undefined, (literal) => { + if (!/^-?(0|[1-9]\d*)$/.test(literal)) throw new Error("Non-integer protocol number"); + return BigInt(literal); + }), + ); + if (response.version !== BigInt(PROTOCOL_VERSION)) throw new Error("Protocol version mismatch"); + if (response.request_id !== requestId) throw new Error("Response request ID mismatch"); + const result = object(response.result); + if (Object.keys(result).length !== 1) throw new Error("Invalid response result"); + if (Object.hasOwn(result, "Err")) { + const error = object(result.Err); + const code = text(error.code); + if ( + ![ + "invalid_request", + "unsupported_version", + "not_found", + "conflict", + "capacity_exceeded", + "runtime_failure", + ].includes(code) + ) { + throw new Error("Unknown protocol error code"); + } + throw new ProtocolError(code, text(error.message)); + } + const reply = object(result.Ok); + 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) { + throw new Error("Duplicate actor references in snapshot"); + } + return { kind: "actors", data }; + } + if (command.operation === "inspect_actor" && reply.kind === "actor") { + const data = decodeActor(reply.data); + if (data.actor_ref !== command.parameters.actor_ref) throw new Error("Snapshot actor mismatch"); + return { kind: "actor", data }; + } + throw new Error("Unexpected reply kind"); +} + +export function formatSnapshot( + actor: ActorSnapshot, + endpoint: string, + capturedAt: string, + connected: boolean, +): string { + const result = stringify( + { connection: { endpoint, connected }, captured_at: capturedAt, actor }, + null, + 2, + ); + if (result === undefined) throw new Error("Could not format actor snapshot"); + return result + "\n"; +} diff --git a/vscode/test/client.test.ts b/vscode/test/client.test.ts new file mode 100644 index 0000000..1c6f3a2 --- /dev/null +++ b/vscode/test/client.test.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import test from "node:test"; +import { ImageClient, parseEndpoint } from "../src/client"; +import { MAX_LINE_BYTES, ProtocolError } from "../src/protocol"; +import { actor, fakeImage, json, REF, response } from "./fixtures"; + +test("only numeric loopback endpoints and valid ports are accepted", () => { + assert.deepEqual(parseEndpoint("127.0.0.1:3737"), { host: "127.0.0.1", port: 3737 }); + assert.deepEqual(parseEndpoint("127.2.3.4:1"), { host: "127.2.3.4", port: 1 }); + assert.deepEqual(parseEndpoint("[::1]:65535"), { host: "::1", port: 65535 }); + for (const input of [ + "localhost:3737", + "0.0.0.0:3737", + "192.168.1.1:3737", + "[::]:3737", + "[::ffff:127.0.0.1]:3737", + "example.org:3737", + "127.0.0.1:0", + "127.0.0.1:65536", + "127.0.0.1:-1", + "127.0.0.1:1/path", + ]) { + assert.throws(() => parseEndpoint(input)); + } +}); + +test("fragmented UTF-8 replies and simultaneous reads preserve correlation and values", async (t) => { + const operations: string[] = []; + const address = await fakeImage(t, (request, socket) => { + operations.push(request.command.operation); + assert.equal(request.version, 3); + const reply = + request.command.operation === "list_actors" + ? { kind: "actors", data: [actor()] } + : { kind: "actor", data: actor() }; + const bytes = Buffer.from(response(request, reply)); + const split = bytes.indexOf(Buffer.from("雪")) + 1; + socket.write(bytes.subarray(0, split)); + setImmediate(() => socket.write(bytes.subarray(split))); + }); + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + t.after(() => client.close()); + const [actors, inspected] = await Promise.all([client.listActors(), client.inspectActor(REF)]); + assert.deepEqual(actors, [actor()]); + assert.deepEqual(inspected, actor()); + assert.deepEqual(operations, ["list_actors", "inspect_actor"]); +}); + +test("protocol errors preserve the connection and are never retried", async (t) => { + let requests = 0; + const address = await fakeImage(t, (request, socket) => { + requests++; + socket.write( + requests === 1 + ? json({ + version: 3, + request_id: request.request_id, + result: { Err: { code: "not_found", message: "Missing actor" } }, + }) + "\n" + : response(request, { kind: "actors", data: [] }), + ); + }); + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + t.after(() => client.close()); + await assert.rejects(client.inspectActor(REF), ProtocolError); + assert.deepEqual(await client.listActors(), []); + assert.equal(requests, 2); +}); + +test("bad correlation, malformed JSON/UTF-8, oversize, truncation, and wrong versions close the stream", async (t) => { + for (const mode of ["correlation", "json", "utf8", "size", "truncated", "version", "kind"]) { + await t.test(mode, async (t) => { + let requests = 0; + const failures: Error[] = []; + const address = await fakeImage(t, (request, socket) => { + requests++; + if (mode === "json") socket.write("{broken}\n"); + else if (mode === "utf8") socket.write(Buffer.from([0xff, 10])); + else if (mode === "size") socket.write(Buffer.alloc(MAX_LINE_BYTES + 1, 32)); + else if (mode === "truncated") socket.end('{"version":3'); + else + socket.write( + json({ + version: mode === "version" ? 2 : 3, + request_id: mode === "correlation" ? "wrong" : request.request_id, + result: { Ok: { kind: mode === "kind" ? "acknowledged" : "actors", data: [] } }, + }) + "\n", + ); + }); + const client = await ImageClient.connect(address, (error) => { + if (error) failures.push(error); + }); + t.after(() => client.close()); + await assert.rejects(client.listActors()); + await assert.rejects(client.listActors(), /Not connected/); + assert.equal(requests, 1); + assert.equal(failures.length, 1); + }); + } +}); + +test("request timeout rejects pending and queued reads without retry", async (t) => { + let requests = 0; + const address = await fakeImage(t, () => { + requests++; + }); + const client = await ImageClient.connect(address, () => {}, { connect: 1000, request: 30 }); + t.after(() => client.close()); + await Promise.all([ + assert.rejects(client.listActors(), /timed out/), + assert.rejects(client.inspectActor(REF), /Not connected/), + ]); + assert.equal(requests, 1); +}); + +test("the 1 MiB response limit is inclusive and framing survives the next request", async (t) => { + const address = await fakeImage(t, (request, socket) => { + const line = response(request, { kind: "actors", data: [] }).slice(0, -1); + socket.write(line + " ".repeat(MAX_LINE_BYTES - Buffer.byteLength(line)) + "\n"); + }); + const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined)); + t.after(() => client.close()); + assert.deepEqual(await client.listActors(), []); + assert.deepEqual(await client.listActors(), []); +}); + +test("unsolicited partial frames invalidate the connection", async (t) => { + const failures: Error[] = []; + const address = await fakeImage(t, (request, socket) => { + socket.write(response(request, { kind: "actors", data: [] }) + "{"); + }); + const client = await ImageClient.connect(address, (error) => { + if (error) failures.push(error); + }); + t.after(() => client.close()); + assert.deepEqual(await client.listActors(), []); + await assert.rejects(client.listActors(), /Not connected/); + assert.match(failures[0]?.message ?? "", /unsolicited/); +}); + +test("disconnect rejects pending reads and idle peer close is reported", async (t) => { + const address = await fakeImage(t, (_request, socket) => socket.end()); + let disconnected = false; + const client = await ImageClient.connect(address, (error) => { + assert.match(error?.message ?? "", /closed/); + disconnected = true; + }); + t.after(() => client.close()); + await assert.rejects(client.listActors(), /closed/); + assert.equal(disconnected, true); + + const idleAddress = await fakeImage(t, (request, socket) => { + socket.write(response(request, { kind: "actors", data: [] })); + setImmediate(() => socket.end()); + }); + let onClosed = () => {}; + const closed = new Promise((resolve) => { + onClosed = resolve; + }); + const idle = await ImageClient.connect(idleAddress, () => onClosed()); + t.after(() => idle.close()); + await idle.listActors(); + await closed; + await assert.rejects(idle.listActors(), /Not connected/); +}); + +test("failed connection reports the error", async () => { + const { createServer } = await import("node:net"); + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing address"); + await new Promise((resolve) => server.close(() => resolve())); + await assert.rejects( + ImageClient.connect(`127.0.0.1:${address.port}`, () => {}), + /ECONNREFUSED/, + ); +}); diff --git a/vscode/test/fixtures.ts b/vscode/test/fixtures.ts new file mode 100644 index 0000000..46c70f8 --- /dev/null +++ b/vscode/test/fixtures.ts @@ -0,0 +1,72 @@ +import { createServer, Socket } from "node:net"; +import { createInterface } from "node:readline"; +import { TestContext } from "node:test"; +import { stringify } from "lossless-json"; +import { ActorSnapshot } from "../src/protocol"; + +export const REF = "abcdefghijklmnopqrstu"; + +export function actor(): ActorSnapshot { + return { + actor_ref: REF, + mailbox_id: 18446744073709551615n, + implementation_id: "implementation:test", + incarnation: 1n, + state: { + type: "record", + data: { + minimum: { type: "integer", data: -9223372036854775808n }, + maximum: { type: "integer", data: 9223372036854775807n }, + reference: { type: "actor_ref", data: REF }, + text: { type: "string", data: `@${REF} "雪"` }, + list: { type: "list", data: [{ type: "null" }, { type: "bool", data: true }] }, + }, + }, + status: { status: "paused" }, + execution: { vm_steps: 20n, instruction_pointer: 12n, call_depth: 1n }, + queued: [{ type: "bool", data: false }], + reserved: { type: "null" }, + }; +} + +export function json(value: unknown): string { + const result = stringify(value); + if (result === undefined) throw new Error("Cannot encode test fixture"); + return result; +} + +export interface TestRequest { + version: number; + request_id: string; + command: { operation: string; parameters?: { actor_ref: string } }; +} + +export function response(request: TestRequest, reply: unknown): string { + return json({ version: 3, request_id: request.request_id, result: { Ok: reply } }) + "\n"; +} + +export async function fakeImage( + t: TestContext, + handle: (request: TestRequest, socket: Socket) => void, +): Promise { + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("error", () => { + // Transport-failure tests intentionally close a socket while the fixture is writing. + }); + const lines = createInterface({ input: socket }); + lines.on("line", (line) => handle(JSON.parse(line), socket)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test listener"); + return `127.0.0.1:${address.port}`; +} diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts new file mode 100644 index 0000000..97e7d4c --- /dev/null +++ b/vscode/test/protocol.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parse } from "lossless-json"; +import { + decodeResponse, + decodeValue, + formatSnapshot, + ProtocolError, + ReadCommand, +} from "../src/protocol"; +import { actor, json, REF } from "./fixtures"; + +const command: ReadCommand = { operation: "list_actors" }; +const envelope = (reply: unknown) => + json({ version: 3, request_id: "test", result: { Ok: reply } }); + +test("v3 snapshots preserve all value variants, i64 limits, u64 telemetry, and mailbox order", () => { + const snapshot = actor(); + const reply = decodeResponse(envelope({ kind: "actors", data: [snapshot] }), "test", command); + assert.deepEqual(reply, { kind: "actors", data: [snapshot] }); + const formatted = formatSnapshot(snapshot, "[::1]:3737", "2026-11-03T00:00:00Z", false); + assert.match(formatted, /9223372036854775807/); + assert.match(formatted, /-9223372036854775808/); + assert.match(formatted, /18446744073709551615/); + assert.deepEqual(parse(formatted, undefined, BigInt), { + connection: { endpoint: "[::1]:3737", connected: false }, + captured_at: "2026-11-03T00:00:00Z", + actor: snapshot, + }); +}); + +test("version, correlation, result, reply kind, and actor identity are checked", () => { + for (const change of [ + { version: 2 }, + { request_id: "wrong" }, + { result: { Ok: { kind: "actor", data: actor() } } }, + { result: { Ok: {}, Err: {} } }, + { result: {} }, + ]) { + assert.throws(() => + decodeResponse( + json({ + version: 3, + request_id: "test", + result: { Ok: { kind: "actors", data: [] } }, + ...change, + }), + "test", + command, + ), + ); + } + assert.throws(() => + decodeResponse(envelope({ kind: "actors", data: [actor(), actor()] }), "test", command), + ); + assert.throws(() => + decodeResponse(envelope({ kind: "actor", data: actor() }), "test", { + operation: "inspect_actor", + parameters: { actor_ref: "ABCDEFGHIJKLMNOPQRSTU" }, + }), + ); +}); + +test("server errors are explicit, typed, and not treated as empty actor lists", () => { + assert.throws( + () => + decodeResponse( + json({ + version: 3, + request_id: "test", + result: { Err: { code: "not_found", message: "No actor" } }, + }), + "test", + command, + ), + (error) => error instanceof ProtocolError && error.code === "not_found", + ); +}); + +test("malformed states and snapshots fail instead of being coerced", () => { + for (const state of [ + { type: "integer", data: 9223372036854775808n }, + { type: "integer", data: -9223372036854775809n }, + { type: "integer", data: 1.5 }, + { type: "actor_ref", data: "guessable" }, + { type: "string", data: REF, extra: true }, + { type: "null", data: null }, + { type: "float", data: 1 }, + ]) { + assert.throws(() => + decodeResponse(envelope({ kind: "actors", data: [{ ...actor(), state }] }), "test", command), + ); + } + for (const change of [ + { status: { status: "unknown" } }, + { status: { status: "failed" } }, + { incarnation: -1 }, + { mailbox_id: 18446744073709551616n }, + { execution: { vm_steps: "20" } }, + { reserved: undefined }, + { queued: null }, + { actor_ref: "@invalid" }, + ]) { + assert.throws(() => + decodeResponse( + envelope({ kind: "actors", data: [{ ...actor(), ...change }] }), + "test", + command, + ), + ); + } +}); + +test("portable value limits have inclusive depth, node, and UTF-8 byte boundaries", () => { + let nested: unknown = { type: "null" }; + for (let i = 0; i < 32; i++) nested = { type: "list", data: [nested] }; + assert.doesNotThrow(() => decodeValue(nested)); + assert.throws(() => decodeValue({ type: "list", data: [nested] }), /depth/); + const data = Array.from({ length: 4095 }, () => ({ type: "null" })); + assert.doesNotThrow(() => decodeValue({ type: "list", data })); + assert.throws(() => decodeValue({ type: "list", data: [...data, { type: "null" }] }), /node/); + assert.doesNotThrow(() => decodeValue({ type: "string", data: "é".repeat(524288) })); + assert.throws(() => decodeValue({ type: "string", data: "é".repeat(524288) + "a" }), /byte/); +}); diff --git a/vscode/tsconfig.json b/vscode/tsconfig.json new file mode 100644 index 0000000..af7eeb8 --- /dev/null +++ b/vscode/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "rootDir": ".", + "outDir": "out", + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "integration/**/*.ts"] +}