feat: add a localhost VS Code actor inspector

Add explicit connection management, an actor explorer, and session-isolated read-only snapshots over protocol v3. Preserve 64-bit values and validate bounded framing, correlation, and snapshot contracts without draining events or advancing the image.

Wire TypeScript formatting and checks into mise, add real Rust-server interoperability tests, and verify inspection and reconnect behavior in a VS Code extension host.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 14:52:28 +01:00
parent 7a368c3944
commit 8ce6ca9a2a
26 changed files with 2190 additions and 21 deletions

3
.gitignore vendored
View File

@ -1 +1,4 @@
target/
vscode/node_modules/
vscode/out/
vscode/.vscode-test/

13
.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

12
.vscode/tasks.json vendored Normal file
View File

@ -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"
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

4
vscode/.prettierignore Normal file
View File

@ -0,0 +1,4 @@
node_modules/
out/
package-lock.json
.vscode-test/

4
vscode/.prettierrc.json Normal file
View File

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"printWidth": 100
}

88
vscode/README.md Normal file
View File

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

View File

@ -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<void> {
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<void> {
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);
});
}

View File

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

View File

@ -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<void> }> {
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<string>((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<void> {
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<unknown> {
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<string> {
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;
}

552
vscode/package-lock.json generated Normal file
View File

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

109
vscode/package.json Normal file
View File

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

View File

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

178
vscode/src/client.ts Normal file
View File

@ -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<unknown> = 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<ImageClient> {
const address = parseEndpoint(endpoint);
const socket = createConnection(address);
await new Promise<void>((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<ActorSnapshot[]> {
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<ActorSnapshot> {
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<ReadReply> {
const request = this.queue.then(
() =>
new Promise<ReadReply>((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);
}
}

285
vscode/src/extension.ts Normal file
View File

@ -0,0 +1,285 @@
import * as vscode from "vscode";
import { ImageClient, parseEndpoint } from "./client";
import { ActorSnapshot, formatSnapshot } from "./protocol";
class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
private readonly changes = new vscode.EventEmitter<void>();
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<vscode.Uri>();
readonly onDidChange = this.changes.event;
private readonly documents = new Map<string, DocumentSnapshot>();
set(session: number, endpoint: string, actor: ActorSnapshot): vscode.Uri {
const uri = vscode.Uri.from({
scheme: "runa",
authority: String(session),
path: `/actors/${actor.actor_ref}.json`,
});
this.documents.set(uri.toString(), {
session,
endpoint,
actor,
capturedAt: new Date().toISOString(),
connected: true,
});
this.changes.fire(uri);
return uri;
}
refresh(session: number, endpoint: string, actors: ActorSnapshot[]): void {
const byRef = new Map(actors.map((actor) => [actor.actor_ref, actor]));
for (const snapshot of this.documents.values()) {
const actor = byRef.get(snapshot.actor.actor_ref);
if (snapshot.session === session && actor) this.set(session, endpoint, actor);
}
}
disconnect(session: number): void {
for (const [key, snapshot] of this.documents) {
if (snapshot.session === session) {
snapshot.connected = false;
this.changes.fire(vscode.Uri.parse(key));
}
}
}
provideTextDocumentContent(uri: vscode.Uri): string {
const snapshot = this.documents.get(uri.toString());
if (!snapshot) throw new Error("Snapshot is no longer available; inspect the actor again");
return formatSnapshot(
snapshot.actor,
snapshot.endpoint,
snapshot.capturedAt,
snapshot.connected,
);
}
remove(uri: vscode.Uri): void {
this.documents.delete(uri.toString());
}
dispose(): void {
this.changes.dispose();
this.documents.clear();
}
}
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<Error>();
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<string>("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 = <Args extends unknown[]>(
name: string,
action: (...args: Args) => Promise<void> | 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();
}

223
vscode/src/protocol.ts Normal file
View File

@ -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<string, Value> };
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function object(value: unknown): Record<string, unknown> {
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";
}

180
vscode/test/client.test.ts Normal file
View File

@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
await assert.rejects(
ImageClient.connect(`127.0.0.1:${address.port}`, () => {}),
/ECONNREFUSED/,
);
});

72
vscode/test/fixtures.ts Normal file
View File

@ -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<string> {
const sockets = new Set<Socket>();
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<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((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}`;
}

View File

@ -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/);
});

18
vscode/tsconfig.json Normal file
View File

@ -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"]
}