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