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
|
||||
|
||||
- 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
|
||||
message sending, pause/resume/stop/restart controls, and explicit scheduler
|
||||
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
|
||||
actors created through the REPL or another client. Click an actor to inspect
|
||||
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.
|
||||
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
|
||||
by connection session so reconnecting to another image cannot overwrite them.
|
||||
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
|
||||
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
|
||||
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.
|
||||
Non-destructive image-change notifications and automatic snapshot refresh are
|
||||
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 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
|
||||
incoming messages. It enforces loopback endpoints, bounded UTF-8 text messages,
|
||||
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.
|
||||
Server `image_changed` notifications automatically update the tree and open
|
||||
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.
|
||||
Each connection gets distinct document URIs; reconnecting never replaces
|
||||
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
|
||||
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
|
||||
|
||||
From the repository root:
|
||||
|
|
@ -112,6 +137,6 @@ well as session isolation. On headless Linux:
|
|||
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
|
||||
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 * as vscode from "vscode";
|
||||
import { control, spawnCounter, startImage } from "./server";
|
||||
import { BehaviorTree, SourceDocuments } from "../src/behaviors";
|
||||
import { ImageClient } from "../src/client";
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const image = await startImage();
|
||||
try {
|
||||
await vscode.commands.executeCommand("runa.connect", image.address);
|
||||
await browseBehaviors(image.address);
|
||||
const spawned = await vscode.commands.executeCommand<unknown>("runa.spawn", {
|
||||
behavior: "counter",
|
||||
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(
|
||||
document: vscode.TextDocument,
|
||||
expected: (text: string) => boolean,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
],
|
||||
"main": "./out/src/extension.js",
|
||||
"activationEvents": [
|
||||
"onView:runa.actors"
|
||||
"onView:runa.actors",
|
||||
"onView:runa.behaviors"
|
||||
],
|
||||
"scripts": {
|
||||
"compile": "tsc -p .",
|
||||
|
|
@ -37,6 +38,12 @@
|
|||
},
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "runa.openBehaviorSource",
|
||||
"title": "Runa: Open Behavior Source",
|
||||
"icon": "$(go-to-file)",
|
||||
"enablement": "runa.connected"
|
||||
},
|
||||
{
|
||||
"command": "runa.spawn",
|
||||
"title": "Runa: Spawn Actor",
|
||||
|
|
@ -83,7 +90,7 @@
|
|||
},
|
||||
{
|
||||
"command": "runa.refresh",
|
||||
"title": "Runa: Refresh Actors",
|
||||
"title": "Runa: Refresh Image",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
|
|
@ -97,10 +104,19 @@
|
|||
{
|
||||
"id": "runa.actors",
|
||||
"name": "Runa Actors"
|
||||
},
|
||||
{
|
||||
"id": "runa.behaviors",
|
||||
"name": "Runa Behaviors"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewsWelcome": [
|
||||
{
|
||||
"view": "runa.behaviors",
|
||||
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
|
||||
"when": "!runa.connected"
|
||||
},
|
||||
{
|
||||
"view": "runa.actors",
|
||||
"contents": "Connect to a trusted local Runa image.\n[Connect](command:runa.connect)",
|
||||
|
|
@ -116,20 +132,25 @@
|
|||
},
|
||||
{
|
||||
"command": "runa.connect",
|
||||
"when": "view == runa.actors && !runa.connected",
|
||||
"when": "(view == runa.actors || view == runa.behaviors) && !runa.connected",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "runa.refresh",
|
||||
"when": "view == runa.actors && runa.connected",
|
||||
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "runa.disconnect",
|
||||
"when": "view == runa.actors && runa.connected"
|
||||
"when": "(view == runa.actors || view == runa.behaviors) && runa.connected"
|
||||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "runa.openBehaviorSource",
|
||||
"when": "view == runa.behaviors && viewItem == runa.behavior",
|
||||
"group": "inline"
|
||||
},
|
||||
{
|
||||
"command": "runa.send",
|
||||
"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;
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!behavior.trim()) throw new Error("Specify a behavior to spawn");
|
||||
if (!Number.isSafeInteger(capacity) || capacity < 1)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ImageClient, parseEndpoint } from "./client";
|
|||
import { ActorSnapshot, formatSnapshot, LifecycleOperation } from "./protocol";
|
||||
import { RefreshQueue } from "./refresh";
|
||||
import { parseValue } from "./values";
|
||||
import { BehaviorTree, SourceDocuments } from "./behaviors";
|
||||
|
||||
class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
|
||||
private readonly changes = new vscode.EventEmitter<void>();
|
||||
|
|
@ -117,6 +118,11 @@ class SnapshotDocuments implements vscode.TextDocumentContentProvider {
|
|||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const tree = new ActorTree();
|
||||
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 output = vscode.window.createOutputChannel("Runa");
|
||||
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);
|
||||
status.text = connected ? "$(plug) Runa" : "$(debug-disconnect) Runa";
|
||||
status.tooltip = connected
|
||||
? `Connected to ${endpoint}. Click to refresh actors.`
|
||||
? `Connected to ${endpoint}. Click to refresh the image.`
|
||||
: "Connect to Runa";
|
||||
status.command = connected ? "runa.refresh" : "runa.connect";
|
||||
view.description = connected ? endpoint : "Disconnected";
|
||||
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 = () => {
|
||||
|
|
@ -155,6 +163,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
session++;
|
||||
previous?.close();
|
||||
tree.replace([]);
|
||||
behaviors.replace([]);
|
||||
connectionStatus(false);
|
||||
};
|
||||
|
||||
|
|
@ -163,8 +172,11 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
const currentSession = session;
|
||||
if (!current) throw new Error("Connect to a local image first");
|
||||
const actors = await current.listActors();
|
||||
const listing = await current.behaviors();
|
||||
if (current !== client || currentSession !== session) return;
|
||||
tree.replace(actors);
|
||||
behaviors.replace(listing);
|
||||
behaviorView.message = listing.length ? undefined : "No published behaviors.";
|
||||
documents.refresh(session, endpoint, actors);
|
||||
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;
|
||||
updates.dispose();
|
||||
tree.replace([]);
|
||||
behaviors.replace([]);
|
||||
documents.disconnect(currentSession);
|
||||
connectionStatus(false);
|
||||
output.appendLine(`Disconnected from ${address}`);
|
||||
|
|
@ -247,7 +260,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
description: actor.status.status,
|
||||
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;
|
||||
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 connection = active();
|
||||
const behaviors = await connection.client.behaviors();
|
||||
|
|
@ -383,16 +433,24 @@ export function activate(context: vscode.ExtensionContext): void {
|
|||
|
||||
context.subscriptions.push(
|
||||
tree,
|
||||
behaviors,
|
||||
sources,
|
||||
behaviorView,
|
||||
documents,
|
||||
view,
|
||||
output,
|
||||
status,
|
||||
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.disconnect", disconnect),
|
||||
command("runa.refresh", refresh),
|
||||
command("runa.inspect", inspect),
|
||||
command("runa.openBehaviorSource", openBehaviorSource),
|
||||
command("runa.spawn", spawn),
|
||||
command("runa.send", send),
|
||||
command("runa.stop", (selected?: { actor_ref: string }) => lifecycle("stop_actor", selected)),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface Behavior {
|
|||
|
||||
export type Command =
|
||||
| { operation: "list_actors" | "get_behaviors" }
|
||||
| { operation: "get_behavior_source"; parameters: { name: string; revision: bigint } }
|
||||
| { operation: "inspect_actor" | LifecycleOperation; parameters: { actor_ref: string } }
|
||||
| {
|
||||
operation: "spawn";
|
||||
|
|
@ -183,6 +184,7 @@ export type Reply =
|
|||
| { kind: "behaviors"; data: Behavior[] }
|
||||
| { kind: "actor_spawned"; data: { actor_ref: string } }
|
||||
| { kind: "acknowledged" }
|
||||
| { kind: "behavior_source"; data: { source: string } }
|
||||
| { kind: "tick_completed"; data: { ticks: bigint } };
|
||||
|
||||
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));
|
||||
}
|
||||
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") {
|
||||
const data = array(reply.data).map((input): Behavior => {
|
||||
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", () => {
|
||||
for (const state of [
|
||||
{ type: "integer", data: 9223372036854775808n },
|
||||
|
|
|
|||
Loading…
Reference in New Issue