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:
parent
7a368c3944
commit
8ce6ca9a2a
|
|
@ -1 +1,4 @@
|
|||
target/
|
||||
target/
|
||||
vscode/node_modules/
|
||||
vscode/out/
|
||||
vscode/.vscode-test/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
18
AGENTS.md
18
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
38
README.md
38
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
20
mise.toml
20
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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
out/
|
||||
package-lock.json
|
||||
.vscode-test/
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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, []);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue