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.
|
- `scripts/`: general-purpose development automation.
|
||||||
- `tests/`: system and end-to-end tests; require an explicit runner rather than
|
- `tests/`: system and end-to-end tests; require an explicit runner rather than
|
||||||
assuming Cargo discovers tests at a virtual workspace root.
|
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.
|
- `README.md`: project overview and current getting-started instructions.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
@ -88,17 +89,24 @@ Run these from the repository root:
|
||||||
mise run format
|
mise run format
|
||||||
mise run lint
|
mise run lint
|
||||||
mise run test
|
mise run test
|
||||||
mise run run
|
mise run start
|
||||||
mise run serve
|
mise run serve
|
||||||
|
mise run test-extension-host
|
||||||
mise run pre-commit
|
mise run pre-commit
|
||||||
```
|
```
|
||||||
|
|
||||||
`mise run pre-commit` runs format, lint, and test sequentially, stopping on
|
`mise run pre-commit` runs format, lint, and test sequentially, stopping on
|
||||||
failure. Formatting uses the repository's Tombi and rustfmt configurations.
|
failure. Formatting uses the repository's Tombi, rustfmt, and extension Prettier
|
||||||
Clippy checks all workspace targets and treats warnings as errors.
|
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
|
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
|
## Conventions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### 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,
|
- Portable structured actor values: null, booleans, signed 64-bit integers,
|
||||||
strings, lists, records, and distinct NanoID actor references.
|
strings, lists, records, and distinct NanoID actor references.
|
||||||
- JSON state/message literals in the REPL, including named `$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
|
single-worker actor demonstration with bounded mailboxes and explicit message
|
||||||
effects. Source and compiled implementation identities are BLAKE3 content
|
effects. Source and compiled implementation identities are BLAKE3 content
|
||||||
addresses with an in-memory compile cache. Persistence, canonicalization,
|
addresses with an in-memory compile cache. Persistence, canonicalization,
|
||||||
remote network access, graphical world, and VS Code extension are not
|
remote network access, and the graphical world are not implemented yet.
|
||||||
implemented yet. The code library now supports an in-memory versioned behavior
|
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
|
registry, and the CLI exposes publishing, history, rollback, safe-boundary
|
||||||
installation, pause/resume, and cancellation through a trusted in-process image
|
installation, pause/resume, and cancellation through a trusted in-process image
|
||||||
API. A versioned transport-neutral protocol contract is now defined, but no
|
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
|
TCP connection establishment is limited to 5 seconds and reads/writes to 30
|
||||||
seconds. Transport failures disconnect the client and are never automatically
|
seconds. Transport failures disconnect the client and are never automatically
|
||||||
retried: an operation may have committed even if its response was lost.
|
retried: an operation may have committed even if its response was lost.
|
||||||
The VS Code extension is
|
The VS Code extension connects over the network to this same live image; actors remain in
|
||||||
intended to connect over the network to this same live image; actors remain in
|
|
||||||
the image regardless of how clients connect. Remote network access requires
|
the image regardless of how clients connect. Remote network access requires
|
||||||
authentication and authorization, which are not implemented.
|
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
|
capabilities and limitations, and [the runtime guide](docs/runtime.md) for
|
||||||
the runtime contract.
|
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
|
## Development
|
||||||
|
|
||||||
Install [mise](https://mise.jdx.dev/) and run `mise install` to install Tombi.
|
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
|
```sh
|
||||||
mise run format
|
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
|
on the first failure. Run it successfully before committing and review any
|
||||||
formatter changes. This is a task, not an automatically installed Git hook.
|
formatter changes. This is a task, not an automatically installed Git hook.
|
||||||
|
|
||||||
Tombi formats TOML files offline; rustfmt formats all workspace Rust code.
|
Tombi formats TOML files offline; rustfmt formats all workspace Rust code;
|
||||||
Clippy checks all workspace targets with warnings treated as errors, and the
|
Prettier formats the extension. Clippy checks all workspace targets with
|
||||||
test task runs `cargo test --workspace`.
|
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
|
## Project structure
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -239,7 +239,15 @@ Normal actors should not automatically receive development privileges. Arbitrary
|
||||||
|
|
||||||
## 9. VS Code Extension
|
## 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:
|
Required extension UX:
|
||||||
|
|
||||||
|
|
@ -475,6 +483,10 @@ single-image actor model.
|
||||||
- Connection, actor explorer, state/mailbox inspection, spawn/send controls.
|
- Connection, actor explorer, state/mailbox inspection, spawn/send controls.
|
||||||
- Virtual behavior documents, compilation diagnostics, hot upgrades, runtime events.
|
- 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
|
### Phase 8 — Persistence and advanced debugger
|
||||||
|
|
||||||
- Durable mailboxes and state, snapshot/restore, supervised recovery.
|
- 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.
|
publishes the bundled `counter` behavior on image startup, not on connection.
|
||||||
The stdio executable continues to start with an empty image.
|
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
|
The current validation checks the protocol version, request ID, actor-reference
|
||||||
shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox
|
shape, structured-value limits, positive revisions where required, non-empty behavior names, mailbox
|
||||||
capacity, and instruction budget. Behavior-name grammar, actor existence,
|
capacity, and instruction budget. Behavior-name grammar, actor existence,
|
||||||
|
|
|
||||||
20
mise.toml
20
mise.toml
|
|
@ -2,12 +2,20 @@
|
||||||
tombi = "latest"
|
tombi = "latest"
|
||||||
|
|
||||||
[tasks.format]
|
[tasks.format]
|
||||||
description = "Format TOML and Rust files"
|
description = "Format TOML, Rust, and extension files"
|
||||||
run = ["tombi format --offline", "cargo fmt --all"]
|
run = ["tombi format --offline", "cargo fmt --all", "npm --prefix vscode run format"]
|
||||||
|
|
||||||
[tasks.test]
|
[tasks.test]
|
||||||
description = "Run workspace tests"
|
description = "Run Rust and extension tests"
|
||||||
run = "cargo test --workspace"
|
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]
|
[tasks.start]
|
||||||
description = "Start the local Runa CLI"
|
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"
|
run = "cargo run -p runa-server -- --listen 127.0.0.1:3737"
|
||||||
|
|
||||||
[tasks.lint]
|
[tasks.lint]
|
||||||
description = "Lint all workspace targets with Clippy"
|
description = "Check Rust targets and the TypeScript extension"
|
||||||
run = "cargo clippy --workspace --all-targets -- -D warnings"
|
run = ["cargo clippy --workspace --all-targets -- -D warnings", "npm --prefix vscode run lint"]
|
||||||
|
|
||||||
[tasks.pre-commit]
|
[tasks.pre-commit]
|
||||||
description = "Format, lint, and test before committing"
|
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