feat: add VS Code behavior and source browsing
Show published behaviors and revision history, retrieve exact Rune source through the shared protocol, and cache read-only source documents by connection session and revision. Refresh behavior listings on image notifications and cover browsing against the real server and VS Code host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e8c9fcad65
commit
39b7443c79
|
|
@ -6,6 +6,10 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- VS Code behavior explorer with revision history, implementation identities,
|
||||||
|
and read-only Rune source documents isolated by connection and revision.
|
||||||
|
Image notifications refresh behavior listings without changing historical
|
||||||
|
source documents.
|
||||||
- VS Code actor spawning with explicit behavior and initial state, structured
|
- VS Code actor spawning with explicit behavior and initial state, structured
|
||||||
message sending, pause/resume/stop/restart controls, and explicit scheduler
|
message sending, pause/resume/stop/restart controls, and explicit scheduler
|
||||||
ticks. JSON inputs preserve i64 integers and `$actor`/`$record` markers.
|
ticks. JSON inputs preserve i64 integers and `$actor`/`$record` markers.
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,12 @@ open this repository and launch **Run Runa Extension** from Run and Debug
|
||||||
in the Extension Development Host. The **Runa Actors** view in Explorer lists
|
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
|
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 Image** is also available on demand.
|
||||||
|
|
||||||
|
The **Runa Behaviors** view shows published behaviors and their revision
|
||||||
|
history. Click a behavior for its current source, or expand it to open an older
|
||||||
|
revision. **Runa: Open Behavior Source** also offers a revision picker.
|
||||||
|
Source documents are read-only and remain unchanged when new revisions appear.
|
||||||
|
|
||||||
Use **Runa: Spawn Actor** to choose a behavior and supply JSON initial state.
|
Use **Runa: Spawn Actor** to choose a behavior and supply JSON initial state.
|
||||||
Actor context menus offer structured message sending and pause/resume/stop/restart.
|
Actor context menus offer structured message sending and pause/resume/stop/restart.
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,10 @@ 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.
|
||||||
Spawn with explicit behavior/state, structured send, pause/resume/stop/restart,
|
Spawn with explicit behavior/state, structured send, pause/resume/stop/restart,
|
||||||
and explicit scheduler ticks are also implemented. Remaining UX below is
|
explicit scheduler ticks, and a behavior explorer with revision history and
|
||||||
|
read-only source documents are also implemented. Sources are fetched at explicit
|
||||||
|
revisions and cached by connection session; image notifications refresh listings
|
||||||
|
without replacing historical source text. Remaining UX below is
|
||||||
planned; detailed runtime event subscriptions and
|
planned; detailed runtime event subscriptions and
|
||||||
durable replay still need design.
|
durable replay still need design.
|
||||||
|
|
||||||
|
|
@ -491,7 +494,7 @@ variables. Mise's `start` and `cli` tasks run the server and REPL respectively.
|
||||||
|
|
||||||
Connection management, the actor explorer, and read-only snapshot inspection
|
Connection management, the actor explorer, and read-only snapshot inspection
|
||||||
are implemented in `vscode/`, along with spawn/send, lifecycle controls, and
|
are implemented in `vscode/`, along with spawn/send, lifecycle controls, and
|
||||||
explicit scheduler ticks. Behavior editing,
|
explicit scheduler ticks, behavior/revision browsing, and immutable source documents. 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.
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,11 @@ 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 issues `list_actors`, `inspect_actor`,
|
The TypeScript VS Code client issues `list_actors`, `inspect_actor`,
|
||||||
`get_behaviors`, `spawn`, `send`, `tick`, and pause/resume/stop/restart operations.
|
`get_behaviors`, `get_behavior_source`, `spawn`, `send`, `tick`, and
|
||||||
|
pause/resume/stop/restart operations.
|
||||||
|
Behavior source lookup uses an explicit immutable revision, not a mutable
|
||||||
|
latest-source alias. VS Code caches read-only source documents by connection,
|
||||||
|
behavior name, and revision while refreshing behavior listings on notifications.
|
||||||
It preserves i64 inputs and bounds the complete outgoing envelope as well as
|
It preserves i64 inputs and bounds the complete outgoing envelope as well as
|
||||||
incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages,
|
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.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ machine running the extension host, not necessarily the desktop.
|
||||||
The timestamp records when this client received the data, not a runtime clock.
|
The timestamp records when this client received the data, not a runtime clock.
|
||||||
Server `image_changed` notifications automatically update the tree and open
|
Server `image_changed` notifications automatically update the tree and open
|
||||||
documents. Bursts are coalesced, and changes arriving during a fetch trigger
|
documents. Bursts are coalesced, and changes arriving during a fetch trigger
|
||||||
a follow-up fetch. **Refresh Actors** is also available on demand.
|
a follow-up fetch. **Refresh Image** is also available on demand.
|
||||||
- Open documents are retained and marked `connected: false` on disconnect.
|
- Open documents are retained and marked `connected: false` on disconnect.
|
||||||
Each connection gets distinct document URIs; reconnecting never replaces
|
Each connection gets distinct document URIs; reconnecting never replaces
|
||||||
snapshots from an earlier connection or another image. Closing a document
|
snapshots from an earlier connection or another image. Closing a document
|
||||||
|
|
@ -86,6 +86,31 @@ natural JSON text, and resolves to the new reference. `runa.send` accepts
|
||||||
`runa.tick` accepts a positive integer tick count. Command failures are reported
|
`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.
|
in the Runa output channel and an error notification; commands are never retried.
|
||||||
|
|
||||||
|
## Behavior and source browsing
|
||||||
|
|
||||||
|
The **Runa Behaviors** view in Explorer lists the image's published behaviors.
|
||||||
|
Each item shows the current revision; expand it for revision history, newest
|
||||||
|
first. Tooltips show the implementation identity and the current revision is
|
||||||
|
marked. Click the behavior to open its current source, or a revision to open
|
||||||
|
that exact source. **Runa: Open Behavior Source** offers the same choices in
|
||||||
|
the Command Palette.
|
||||||
|
|
||||||
|
Sources are fetched through `get_behavior_source` and opened as read-only
|
||||||
|
`.rn` virtual documents, not local workspace files. If a Rune language
|
||||||
|
extension is installed, VS Code can associate its language support with `.rn`.
|
||||||
|
The source text is preserved exactly, without metadata headers. The URI identifies
|
||||||
|
the connection session, behavior name, and immutable revision. Documents remain
|
||||||
|
cached on disconnect and never turn into the latest revision automatically.
|
||||||
|
Reconnect opens distinct URIs; closing a source document releases its cache.
|
||||||
|
Cached sources are not persisted across VS Code restarts.
|
||||||
|
|
||||||
|
Server notifications refresh both explorers. **Runa: Refresh Image** does the
|
||||||
|
same on demand. Browsing does not publish code, install actor behavior, tick the
|
||||||
|
scheduler, or drain runtime events. Source editing and publication remain future
|
||||||
|
work. For automation, `runa.openBehaviorSource` accepts
|
||||||
|
`{ name: string, revision: string }` with a positive decimal revision and returns
|
||||||
|
the opened document URI.
|
||||||
|
|
||||||
## Development and validation
|
## Development and validation
|
||||||
|
|
||||||
From the repository root:
|
From the repository root:
|
||||||
|
|
@ -112,6 +137,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,
|
Behavior editing and publication,
|
||||||
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,43 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { ImageClient } from "../src/client";
|
||||||
|
import { ProtocolError } from "../src/protocol";
|
||||||
|
import { control, startImage } from "./server";
|
||||||
|
|
||||||
|
test(
|
||||||
|
"behavior browsing retrieves immutable revision source and preserves connection after missing revisions",
|
||||||
|
{ 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));
|
||||||
|
const original = await client.behaviorSource("counter", 1n);
|
||||||
|
assert.match(original, /pub fn handle/);
|
||||||
|
const source = "pub fn handle(state, message) { (state + 2, []) }\n";
|
||||||
|
const published = await control(image.address, {
|
||||||
|
operation: "publish_behavior",
|
||||||
|
parameters: { name: "counter", expected_revision: 1, source },
|
||||||
|
});
|
||||||
|
assert.match(JSON.stringify(published), /"kind":"behavior"/);
|
||||||
|
const behavior = (await client.behaviors()).find((behavior) => behavior.name === "counter");
|
||||||
|
assert.ok(behavior);
|
||||||
|
assert.equal(behavior.revision, 2n);
|
||||||
|
assert.deepEqual(
|
||||||
|
behavior.history.map((version) => version.revision),
|
||||||
|
[1n, 2n],
|
||||||
|
);
|
||||||
|
assert.equal(await client.behaviorSource("counter", 2n), source);
|
||||||
|
assert.equal(await client.behaviorSource("counter", 1n), original);
|
||||||
|
await assert.rejects(client.behaviorSource("counter", 3n), ProtocolError);
|
||||||
|
await assert.rejects(client.behaviorSource("missing", 1n), ProtocolError);
|
||||||
|
for (const revision of [0n, -1n, 18446744073709551616n]) {
|
||||||
|
await assert.rejects(client.behaviorSource("counter", revision), /positive u64/);
|
||||||
|
}
|
||||||
|
await assert.rejects(client.behaviorSource(" ", 1n), /behavior name/);
|
||||||
|
assert.equal(await client.behaviorSource("counter", 1n), original);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as vscode from "vscode";
|
import * as vscode from "vscode";
|
||||||
import { control, spawnCounter, startImage } from "./server";
|
import { control, spawnCounter, startImage } from "./server";
|
||||||
|
import { BehaviorTree, SourceDocuments } from "../src/behaviors";
|
||||||
|
import { ImageClient } from "../src/client";
|
||||||
|
|
||||||
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);
|
await vscode.commands.executeCommand("runa.connect", image.address);
|
||||||
|
await browseBehaviors(image.address);
|
||||||
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
||||||
behavior: "counter",
|
behavior: "counter",
|
||||||
state: "5",
|
state: "5",
|
||||||
|
|
@ -111,6 +114,100 @@ export async function run(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function browseBehaviors(address: string): Promise<void> {
|
||||||
|
const client = await ImageClient.connect(address, (error) => assert.equal(error, undefined));
|
||||||
|
const tree = new BehaviorTree();
|
||||||
|
const sources = new SourceDocuments();
|
||||||
|
try {
|
||||||
|
const source = await client.behaviorSource("counter", 1n);
|
||||||
|
await control(address, {
|
||||||
|
operation: "publish_behavior",
|
||||||
|
parameters: {
|
||||||
|
name: "browse-test",
|
||||||
|
expected_revision: null,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const listing = await client.behaviors();
|
||||||
|
tree.replace(listing);
|
||||||
|
const behavior = tree.getChildren().find((node) => node.name === "browse-test");
|
||||||
|
assert.ok(behavior);
|
||||||
|
assert.equal(
|
||||||
|
tree.getTreeItem(behavior).collapsibleState,
|
||||||
|
vscode.TreeItemCollapsibleState.Collapsed,
|
||||||
|
);
|
||||||
|
const version = tree.getChildren(behavior)[0];
|
||||||
|
assert.ok(version);
|
||||||
|
assert.equal(tree.getTreeItem(version).description, "current");
|
||||||
|
assert.deepEqual(tree.getChildren(version), []);
|
||||||
|
const item = tree.getTreeItem(behavior);
|
||||||
|
const opened = await vscode.commands.executeCommand<string>(
|
||||||
|
"runa.openBehaviorSource",
|
||||||
|
...item.command!.arguments!,
|
||||||
|
);
|
||||||
|
assert.ok(opened);
|
||||||
|
const original = vscode.window.activeTextEditor?.document;
|
||||||
|
assert.ok(original);
|
||||||
|
assert.equal(original.uri.scheme, "runa-source");
|
||||||
|
assert.match(original.uri.path, /\/1\.rn$/);
|
||||||
|
assert.equal(original.getText(), source);
|
||||||
|
const originalText = original.getText();
|
||||||
|
await vscode.commands.executeCommand("type", { text: "// cannot edit\n" });
|
||||||
|
assert.equal(original.getText(), originalText);
|
||||||
|
const newerSource = "pub fn handle(state, message) { (state + 2, []) }\n";
|
||||||
|
await control(address, {
|
||||||
|
operation: "publish_behavior",
|
||||||
|
parameters: {
|
||||||
|
name: "browse-test",
|
||||||
|
expected_revision: 1,
|
||||||
|
source: newerSource,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await vscode.commands.executeCommand("runa.refresh");
|
||||||
|
// Context-menu arguments are raw tree nodes, unlike item command arguments.
|
||||||
|
const updated = (await client.behaviors()).find((node) => node.name === "browse-test");
|
||||||
|
assert.ok(updated);
|
||||||
|
tree.replace([updated]);
|
||||||
|
assert.deepEqual(
|
||||||
|
tree.getChildren(updated).map((node) => node.revision),
|
||||||
|
[2n, 1n],
|
||||||
|
);
|
||||||
|
await vscode.commands.executeCommand("runa.openBehaviorSource", updated);
|
||||||
|
const newer = vscode.window.activeTextEditor?.document;
|
||||||
|
assert.ok(newer);
|
||||||
|
assert.equal(newer.getText(), newerSource);
|
||||||
|
assert.notEqual(newer.uri.toString(), original.uri.toString());
|
||||||
|
assert.equal(original.getText(), originalText);
|
||||||
|
const uri = sources.set(1, "name/with?#% spaces", 1n, originalText);
|
||||||
|
assert.equal(sources.provideTextDocumentContent(uri), originalText);
|
||||||
|
assert.notEqual(
|
||||||
|
sources.set(2, "name/with?#% spaces", 1n, newerSource).toString(),
|
||||||
|
uri.toString(),
|
||||||
|
);
|
||||||
|
assert.throws(() => sources.set(1, "name/with?#% spaces", 1n, newerSource), /Immutable/);
|
||||||
|
sources.remove(uri);
|
||||||
|
assert.throws(() => sources.provideTextDocumentContent(uri), /no longer cached/);
|
||||||
|
await vscode.commands.executeCommand("runa.disconnect");
|
||||||
|
assert.equal(original.getText(), originalText);
|
||||||
|
await vscode.commands.executeCommand("runa.connect", address);
|
||||||
|
await vscode.commands.executeCommand("runa.openBehaviorSource", {
|
||||||
|
name: "browse-test",
|
||||||
|
revision: "1",
|
||||||
|
});
|
||||||
|
const reconnected = vscode.window.activeTextEditor?.document;
|
||||||
|
assert.ok(reconnected);
|
||||||
|
assert.equal(reconnected.getText(), originalText);
|
||||||
|
assert.notEqual(reconnected.uri.toString(), original.uri.toString());
|
||||||
|
console.log(
|
||||||
|
"Runa behavior browsing: revision tree, immutable source, read-only documents, session isolation passed",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
tree.dispose();
|
||||||
|
sources.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function documentChanged(
|
function documentChanged(
|
||||||
document: vscode.TextDocument,
|
document: vscode.TextDocument,
|
||||||
expected: (text: string) => boolean,
|
expected: (text: string) => boolean,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
],
|
],
|
||||||
"main": "./out/src/extension.js",
|
"main": "./out/src/extension.js",
|
||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
"onView:runa.actors"
|
"onView:runa.actors",
|
||||||
|
"onView:runa.behaviors"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"compile": "tsc -p .",
|
"compile": "tsc -p .",
|
||||||
|
|
@ -37,6 +38,12 @@
|
||||||
},
|
},
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"commands": [
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "runa.openBehaviorSource",
|
||||||
|
"title": "Runa: Open Behavior Source",
|
||||||
|
"icon": "$(go-to-file)",
|
||||||
|
"enablement": "runa.connected"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.spawn",
|
"command": "runa.spawn",
|
||||||
"title": "Runa: Spawn Actor",
|
"title": "Runa: Spawn Actor",
|
||||||
|
|
@ -83,7 +90,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.refresh",
|
"command": "runa.refresh",
|
||||||
"title": "Runa: Refresh Actors",
|
"title": "Runa: Refresh Image",
|
||||||
"icon": "$(refresh)"
|
"icon": "$(refresh)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -97,10 +104,19 @@
|
||||||
{
|
{
|
||||||
"id": "runa.actors",
|
"id": "runa.actors",
|
||||||
"name": "Runa Actors"
|
"name": "Runa Actors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "runa.behaviors",
|
||||||
|
"name": "Runa Behaviors"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"viewsWelcome": [
|
"viewsWelcome": [
|
||||||
|
{
|
||||||
|
"view": "runa.behaviors",
|
||||||
|
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
|
||||||
|
"when": "!runa.connected"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"view": "runa.actors",
|
"view": "runa.actors",
|
||||||
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
|
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
|
||||||
|
|
@ -116,20 +132,25 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.connect",
|
"command": "runa.connect",
|
||||||
"when": "view == runa.actors && !runa.connected",
|
"when": "(view == runa.actors || view == runa.behaviors) && !runa.connected",
|
||||||
"group": "navigation"
|
"group": "navigation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.refresh",
|
"command": "runa.refresh",
|
||||||
"when": "view == runa.actors && runa.connected",
|
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected",
|
||||||
"group": "navigation"
|
"group": "navigation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.disconnect",
|
"command": "runa.disconnect",
|
||||||
"when": "view == runa.actors && runa.connected"
|
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view/item/context": [
|
"view/item/context": [
|
||||||
|
{
|
||||||
|
"command": "runa.openBehaviorSource",
|
||||||
|
"when": "view == runa.behaviors && viewItem == runa.behavior",
|
||||||
|
"group": "inline"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.send",
|
"command": "runa.send",
|
||||||
"when": "view == runa.actors && viewItem == runa.actor",
|
"when": "view == runa.actors && viewItem == runa.actor",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import * as vscode from "vscode";
|
||||||
|
import { Behavior } from "./protocol";
|
||||||
|
|
||||||
|
export interface BehaviorRevision {
|
||||||
|
name: string;
|
||||||
|
revision: bigint;
|
||||||
|
implementation_id: string;
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BehaviorTree implements vscode.TreeDataProvider<Behavior | BehaviorRevision> {
|
||||||
|
private readonly changes = new vscode.EventEmitter<void>();
|
||||||
|
readonly onDidChangeTreeData = this.changes.event;
|
||||||
|
behaviors: Behavior[] = [];
|
||||||
|
|
||||||
|
replace(behaviors: Behavior[]): void {
|
||||||
|
this.behaviors = behaviors;
|
||||||
|
this.changes.fire();
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildren(parent?: Behavior | BehaviorRevision): (Behavior | BehaviorRevision)[] {
|
||||||
|
if (!parent) return this.behaviors;
|
||||||
|
if (!("history" in parent)) return [];
|
||||||
|
return [...parent.history].reverse().map((version) => ({
|
||||||
|
name: parent.name,
|
||||||
|
...version,
|
||||||
|
current: version.revision === parent.revision,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getTreeItem(behavior: Behavior | BehaviorRevision): vscode.TreeItem {
|
||||||
|
const root = "history" in behavior;
|
||||||
|
const item = new vscode.TreeItem(
|
||||||
|
root ? behavior.name : `Revision ${behavior.revision}`,
|
||||||
|
root ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None,
|
||||||
|
);
|
||||||
|
item.id = JSON.stringify(root ? [behavior.name] : [behavior.name, String(behavior.revision)]);
|
||||||
|
item.contextValue = "runa.behavior";
|
||||||
|
item.description = root
|
||||||
|
? `revision ${behavior.revision}`
|
||||||
|
: behavior.current
|
||||||
|
? "current"
|
||||||
|
: undefined;
|
||||||
|
item.tooltip = `Behavior: ${behavior.name}\nRevision: ${behavior.revision}\nImplementation: ${
|
||||||
|
root ? behavior.current_implementation_id : behavior.implementation_id
|
||||||
|
}`;
|
||||||
|
item.iconPath = new vscode.ThemeIcon(root ? "symbol-class" : "git-commit");
|
||||||
|
item.command = {
|
||||||
|
command: "runa.openBehaviorSource",
|
||||||
|
title: "Open Rune Source",
|
||||||
|
arguments: [{ name: behavior.name, revision: String(behavior.revision) }],
|
||||||
|
};
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.changes.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SourceDocuments implements vscode.TextDocumentContentProvider {
|
||||||
|
private readonly sources = new Map<string, string>();
|
||||||
|
|
||||||
|
set(session: number, name: string, revision: bigint, source: string): vscode.Uri {
|
||||||
|
const uri = vscode.Uri.from({
|
||||||
|
scheme: "runa-source",
|
||||||
|
authority: String(session),
|
||||||
|
path: `/behaviors/${encodeURIComponent(name)}/${revision}.rn`,
|
||||||
|
});
|
||||||
|
const previous = this.sources.get(uri.toString());
|
||||||
|
if (previous !== undefined && previous !== source) {
|
||||||
|
throw new Error("Immutable behavior revision changed source");
|
||||||
|
}
|
||||||
|
this.sources.set(uri.toString(), source);
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
provideTextDocumentContent(uri: vscode.Uri): string {
|
||||||
|
const source = this.sources.get(uri.toString());
|
||||||
|
if (source === undefined)
|
||||||
|
throw new Error("Source is no longer cached; open the behavior revision again");
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(uri: vscode.Uri): void {
|
||||||
|
this.sources.delete(uri.toString());
|
||||||
|
}
|
||||||
|
dispose(): void {
|
||||||
|
this.sources.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -123,6 +123,19 @@ export class ImageClient {
|
||||||
return reply.data;
|
return reply.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async behaviorSource(name: string, revision: bigint): Promise<string> {
|
||||||
|
if (typeof name !== "string" || !name.trim()) throw new Error("Specify a behavior name");
|
||||||
|
if (typeof revision !== "bigint" || revision < 1n || revision > 18446744073709551615n) {
|
||||||
|
throw new Error("Behavior revision must be a positive u64 integer");
|
||||||
|
}
|
||||||
|
const reply = await this.request({
|
||||||
|
operation: "get_behavior_source",
|
||||||
|
parameters: { name, revision },
|
||||||
|
});
|
||||||
|
if (reply.kind !== "behavior_source") throw new Error("Expected behavior source");
|
||||||
|
return reply.data.source;
|
||||||
|
}
|
||||||
|
|
||||||
async spawn(behavior: string, state: Value, capacity = 64): Promise<string> {
|
async spawn(behavior: string, state: Value, capacity = 64): Promise<string> {
|
||||||
if (!behavior.trim()) throw new Error("Specify a behavior to spawn");
|
if (!behavior.trim()) throw new Error("Specify a behavior to spawn");
|
||||||
if (!Number.isSafeInteger(capacity) || capacity < 1)
|
if (!Number.isSafeInteger(capacity) || capacity < 1)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ImageClient, parseEndpoint } from "./client";
|
||||||
import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol";
|
import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol";
|
||||||
import { RefreshQueue } from "./refresh";
|
import { RefreshQueue } from "./refresh";
|
||||||
import { parseValue } from "./values";
|
import { parseValue } from "./values";
|
||||||
|
import { BehaviorTree, SourceDocuments } from "./behaviors";
|
||||||
|
|
||||||
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>();
|
||||||
|
|
@ -117,6 +118,11 @@ class SnapshotDocuments implements vscode.TextDocumentContentProvider {
|
||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
const tree = new ActorTree();
|
const tree = new ActorTree();
|
||||||
const documents = new SnapshotDocuments();
|
const documents = new SnapshotDocuments();
|
||||||
|
const behaviors = new BehaviorTree();
|
||||||
|
const sources = new SourceDocuments();
|
||||||
|
const behaviorView = vscode.window.createTreeView("runa.behaviors", {
|
||||||
|
treeDataProvider: behaviors,
|
||||||
|
});
|
||||||
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
|
const view = vscode.window.createTreeView("runa.actors", { treeDataProvider: tree });
|
||||||
const output = vscode.window.createOutputChannel("Runa");
|
const output = vscode.window.createOutputChannel("Runa");
|
||||||
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
|
const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
|
||||||
|
|
@ -139,11 +145,13 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
void vscode.commands.executeCommand("setContext", "runa.connected", connected);
|
void vscode.commands.executeCommand("setContext", "runa.connected", connected);
|
||||||
status.text = connected ? "$(plug) Runa" : "$(debug-disconnect) Runa";
|
status.text = connected ? "$(plug) Runa" : "$(debug-disconnect) Runa";
|
||||||
status.tooltip = connected
|
status.tooltip = connected
|
||||||
? `Connected to ${endpoint}. Click to refresh actors.`
|
? `Connected to ${endpoint}. Click to refresh the image.`
|
||||||
: "Connect to Runa";
|
: "Connect to Runa";
|
||||||
status.command = connected ? "runa.refresh" : "runa.connect";
|
status.command = connected ? "runa.refresh" : "runa.connect";
|
||||||
view.description = connected ? endpoint : "Disconnected";
|
view.description = connected ? endpoint : "Disconnected";
|
||||||
view.message = connected ? undefined : "Not connected to an image.";
|
view.message = connected ? undefined : "Not connected to an image.";
|
||||||
|
behaviorView.description = connected ? endpoint : "Disconnected";
|
||||||
|
behaviorView.message = connected ? undefined : "Not connected to an image.";
|
||||||
};
|
};
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
|
|
@ -155,6 +163,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
session++;
|
session++;
|
||||||
previous?.close();
|
previous?.close();
|
||||||
tree.replace([]);
|
tree.replace([]);
|
||||||
|
behaviors.replace([]);
|
||||||
connectionStatus(false);
|
connectionStatus(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -163,8 +172,11 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
const currentSession = session;
|
const currentSession = session;
|
||||||
if (!current) throw new Error("Connect to a local image first");
|
if (!current) throw new Error("Connect to a local image first");
|
||||||
const actors = await current.listActors();
|
const actors = await current.listActors();
|
||||||
|
const listing = await current.behaviors();
|
||||||
if (current !== client || currentSession !== session) return;
|
if (current !== client || currentSession !== session) return;
|
||||||
tree.replace(actors);
|
tree.replace(actors);
|
||||||
|
behaviors.replace(listing);
|
||||||
|
behaviorView.message = listing.length ? undefined : "No published behaviors.";
|
||||||
documents.refresh(session, endpoint, actors);
|
documents.refresh(session, endpoint, actors);
|
||||||
view.message = actors.length ? undefined : "No actors. Use Runa: Spawn Actor to create one.";
|
view.message = actors.length ? undefined : "No actors. Use Runa: Spawn Actor to create one.";
|
||||||
};
|
};
|
||||||
|
|
@ -204,6 +216,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
client = undefined;
|
client = undefined;
|
||||||
updates.dispose();
|
updates.dispose();
|
||||||
tree.replace([]);
|
tree.replace([]);
|
||||||
|
behaviors.replace([]);
|
||||||
documents.disconnect(currentSession);
|
documents.disconnect(currentSession);
|
||||||
connectionStatus(false);
|
connectionStatus(false);
|
||||||
output.appendLine(`Disconnected from ${address}`);
|
output.appendLine(`Disconnected from ${address}`);
|
||||||
|
|
@ -247,7 +260,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
description: actor.status.status,
|
description: actor.status.status,
|
||||||
actorRef: actor.actor_ref,
|
actorRef: actor.actor_ref,
|
||||||
})),
|
})),
|
||||||
{ placeHolder: "Choose an actor to inspect (refresh to discover newly spawned actors)" },
|
{ placeHolder: "Choose an actor to inspect" },
|
||||||
);
|
);
|
||||||
if (!choice) return;
|
if (!choice) return;
|
||||||
actorRef = choice.actorRef;
|
actorRef = choice.actorRef;
|
||||||
|
|
@ -302,6 +315,43 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openBehaviorSource = async (selected?: { name: string; revision: string | bigint }) => {
|
||||||
|
const connection = active();
|
||||||
|
const currentSession = session;
|
||||||
|
let target = selected;
|
||||||
|
if (!target) {
|
||||||
|
const listing = await connection.client.behaviors();
|
||||||
|
connection.check();
|
||||||
|
const choice = await vscode.window.showQuickPick(
|
||||||
|
listing.flatMap((behavior) =>
|
||||||
|
[...behavior.history].reverse().map((version) => ({
|
||||||
|
label: behavior.name,
|
||||||
|
description: `revision ${version.revision}${version.revision === behavior.revision ? " (current)" : ""}`,
|
||||||
|
target: { name: behavior.name, revision: String(version.revision) },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
{ placeHolder: "Choose a behavior revision to view" },
|
||||||
|
);
|
||||||
|
target = choice?.target;
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
if (
|
||||||
|
(typeof target.revision !== "string" && typeof target.revision !== "bigint") ||
|
||||||
|
!/^[1-9]\d*$/.test(String(target.revision))
|
||||||
|
) {
|
||||||
|
throw new Error("Specify a positive decimal behavior revision");
|
||||||
|
}
|
||||||
|
connection.check();
|
||||||
|
const revision = BigInt(target.revision);
|
||||||
|
const source = await connection.client.behaviorSource(target.name, revision);
|
||||||
|
connection.check();
|
||||||
|
const uri = sources.set(currentSession, target.name, revision, source);
|
||||||
|
const document = await vscode.workspace.openTextDocument(uri);
|
||||||
|
connection.check();
|
||||||
|
await vscode.window.showTextDocument(document, { preview: true });
|
||||||
|
return uri.toString();
|
||||||
|
};
|
||||||
|
|
||||||
const spawn = async (options?: { behavior: string; state: string }) => {
|
const spawn = async (options?: { behavior: string; state: string }) => {
|
||||||
const connection = active();
|
const connection = active();
|
||||||
const behaviors = await connection.client.behaviors();
|
const behaviors = await connection.client.behaviors();
|
||||||
|
|
@ -383,16 +433,24 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||||
|
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
tree,
|
tree,
|
||||||
|
behaviors,
|
||||||
|
sources,
|
||||||
|
behaviorView,
|
||||||
documents,
|
documents,
|
||||||
view,
|
view,
|
||||||
output,
|
output,
|
||||||
status,
|
status,
|
||||||
vscode.workspace.registerTextDocumentContentProvider("runa", documents),
|
vscode.workspace.registerTextDocumentContentProvider("runa", documents),
|
||||||
vscode.workspace.onDidCloseTextDocument((document) => documents.remove(document.uri)),
|
vscode.workspace.registerTextDocumentContentProvider("runa-source", sources),
|
||||||
|
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||||
|
documents.remove(document.uri);
|
||||||
|
sources.remove(document.uri);
|
||||||
|
}),
|
||||||
command("runa.connect", connect),
|
command("runa.connect", connect),
|
||||||
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.openBehaviorSource", openBehaviorSource),
|
||||||
command("runa.spawn", spawn),
|
command("runa.spawn", spawn),
|
||||||
command("runa.send", send),
|
command("runa.send", send),
|
||||||
command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)),
|
command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)),
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export interface Behavior {
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
| { operation: "list_actors" | "get_behaviors" }
|
| { operation: "list_actors" | "get_behaviors" }
|
||||||
|
| { operation: "get_behavior_source"; parameters: { name: string; revision: bigint } }
|
||||||
| { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: string } }
|
| { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: string } }
|
||||||
| {
|
| {
|
||||||
operation: "spawn";
|
operation: "spawn";
|
||||||
|
|
@ -183,6 +184,7 @@ export type Reply =
|
||||||
| { kind: "behaviors"; data: Behavior[] }
|
| { kind: "behaviors"; data: Behavior[] }
|
||||||
| { kind: "actor_spawned"; data: { actor_ref: string } }
|
| { kind: "actor_spawned"; data: { actor_ref: string } }
|
||||||
| { kind: "acknowledged" }
|
| { kind: "acknowledged" }
|
||||||
|
| { kind: "behavior_source"; data: { source: string } }
|
||||||
| { kind: "tick_completed"; data: { ticks: bigint } };
|
| { kind: "tick_completed"; data: { ticks: bigint } };
|
||||||
|
|
||||||
function decodeEnvelope(message: string): Record<string, unknown> {
|
function decodeEnvelope(message: string): Record<string, unknown> {
|
||||||
|
|
@ -235,6 +237,9 @@ export function decodeResponse(line: string, requestId: string, command: Command
|
||||||
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_behavior_source" && reply.kind === "behavior_source") {
|
||||||
|
return { kind: "behavior_source", data: { source: text(object(reply.data).source) } };
|
||||||
|
}
|
||||||
if (command.operation === "get_behaviors" && reply.kind === "behaviors") {
|
if (command.operation === "get_behaviors" && reply.kind === "behaviors") {
|
||||||
const data = array(reply.data).map((input): Behavior => {
|
const data = array(reply.data).map((input): Behavior => {
|
||||||
const behavior = object(input);
|
const behavior = object(input);
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,32 @@ test("behavior listings validate names, revisions, history, and uniqueness", ()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("behavior source replies require text and the matching operation", () => {
|
||||||
|
const command: Command = {
|
||||||
|
operation: "get_behavior_source",
|
||||||
|
parameters: {
|
||||||
|
name: "counter",
|
||||||
|
revision: 18446744073709551615n,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const source = "pub fn handle(state, message) {\n (state, []) // 雪\n}\n";
|
||||||
|
assert.deepEqual(
|
||||||
|
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "test", command),
|
||||||
|
{ kind: "behavior_source", data: { source } },
|
||||||
|
);
|
||||||
|
for (const data of [{}, { source: null }, { source: 12 }, { source: [] }]) {
|
||||||
|
assert.throws(() =>
|
||||||
|
decodeResponse(envelope({ kind: "behavior_source", data }), "test", command),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(() => decodeResponse(envelope({ kind: "actors", data: [] }), "test", command));
|
||||||
|
assert.throws(() =>
|
||||||
|
decodeResponse(envelope({ kind: "behavior_source", data: { source } }), "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 },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue