feat: add editable actor inspection and context self references
Add scheduler toolbar and copy-reference actions, guarded inspection JSON saves with protected-field warnings, and read-only ctx.this for self-messaging. Preserve exact actor values and unsaved edits across refreshes, and test the save lifecycle in the VS Code host. Isolate the CLI forwarding test from the editable counter example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
de69059fa3
commit
c851fefa98
|
|
@ -32,6 +32,9 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Editable VS Code inspection JSON for inactive actors, restoring protected
|
||||
fields with a warning; actor-reference copy actions and a scheduler toolbar button.
|
||||
- Read-only `ctx.this` actor reference for transactional self-messaging.
|
||||
- Guarded actor state and reserved/queued message editing through the CLI,
|
||||
REPL, and VS Code for paused/stopped/failed actors without active execution.
|
||||
- Immediate actor logs via `ctx.log(level, message)` and severity helpers,
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -16,8 +16,9 @@ effects. Source and compiled implementation identities are BLAKE3 content
|
|||
addresses with an in-memory compile cache. Persistence, canonicalization,
|
||||
remote network access, and the graphical world are not implemented yet.
|
||||
A minimal VS Code extension can connect to the local image, list actors, and
|
||||
inspect state, queued/reserved messages, and execution telemetry in read-only
|
||||
documents. The image owns mutable, directory-based Rune source files with
|
||||
inspect state, queued/reserved messages, and execution telemetry. Inspection
|
||||
JSON is editable for inactive paused/stopped/failed actors; saving restores
|
||||
protected fields with a warning. The image owns mutable, directory-based Rune source files with
|
||||
independent commit history. The CLI exposes source writes, commit/history/rollback,
|
||||
reload/become, graceful shutdown, kill, restart, pause/resume, and cancellation through a trusted image
|
||||
API. A versioned transport-neutral protocol contract is now defined, but no
|
||||
|
|
@ -211,6 +212,9 @@ actors. `logs` prints retained history; `logs -f` additionally subscribes to
|
|||
future entries until Ctrl+C, which returns to the REPL or closes a one-shot CLI.
|
||||
In VS Code, actor context menus offer **Edit Actor State...**,
|
||||
**Edit Reserved and Queued Messages...**, and **Set Actor Log Level...**.
|
||||
You can also edit those fields directly in inspection JSON and save. Protected
|
||||
fields are restored with a warning. The Actors toolbar advances the scheduler,
|
||||
and **Copy Actor Reference** copies a reference from the actor context menu.
|
||||
|
||||
Rune handlers can call `ctx.log("info", "message")`, or `ctx.trace`, `ctx.debug`,
|
||||
`ctx.info`, `ctx.warn`, `ctx.error`, and `ctx.fatal` with a string message.
|
||||
|
|
@ -222,6 +226,9 @@ survive handler failure and may repeat on retries. Each actor keeps its latest
|
|||
preserves logs and the threshold. Server exit loses them. Rebuild the server,
|
||||
CLI, and extension together for protocol v5.
|
||||
|
||||
The read-only `ctx.this` field is the current actor's typed reference:
|
||||
`ctx.send(ctx.this, message)` queues a normal transactional message to itself.
|
||||
|
||||
## Development
|
||||
|
||||
Install [mise](https://mise.jdx.dev/) and run `mise install` to install Tombi.
|
||||
|
|
|
|||
|
|
@ -1509,7 +1509,21 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn repl_drives_actor_messages_and_inspection() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut server = server();
|
||||
let mut server = Server::new();
|
||||
dispatch(
|
||||
&mut server,
|
||||
Command::WriteSource {
|
||||
path: COUNTER_BEHAVIOR.to_owned(),
|
||||
expected_source_id: None,
|
||||
source: r#"pub fn handle(ctx, state, sender, message) {
|
||||
if message.op == "send" {
|
||||
ctx.send(message.target, #{op: "add", value: message.value});
|
||||
state + 1
|
||||
} else { state + message.value }
|
||||
}"#
|
||||
.to_owned(),
|
||||
},
|
||||
)?;
|
||||
let input = io::Cursor::new(
|
||||
"let sender = spawn actors/counter 0\nlet receiver = spawn actors/counter 10\nsend sender {\"op\": \"send\", \"value\": 5, \"target\": {\"$actor\": \"receiver\"}}\ntick 100\ninspect sender\ninspect receiver\nevents\nquit\n",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ impl ActorLogs {
|
|||
}
|
||||
|
||||
impl ActorContext {
|
||||
pub(crate) fn this(&self) -> crate::value::ActorReference {
|
||||
crate::value::ActorReference(self.actor)
|
||||
}
|
||||
|
||||
pub fn new(actor: ActorRef) -> Self {
|
||||
Self {
|
||||
actor,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use rune::termcolor::{ColorChoice, StandardStream};
|
|||
use rune::{Context, Diagnostics, Module, Source, Sources, Vm};
|
||||
|
||||
pub const RUNE_VERSION: &str = "0.14.2";
|
||||
pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v2:send,send_from,log,trace,debug,info,warn,error,fatal";
|
||||
pub const COMPILER_CONFIGURATION_ID: &str = "default-modules; native-functions:v2:native_double,native_suspend; runa::ActorReference:v1; runa::ActorContext:v3:this,send,send_from,log,trace,debug,info,warn,error,fatal";
|
||||
|
||||
mod execution;
|
||||
pub use execution::{Invocation, InvocationProgress, Slice};
|
||||
|
|
@ -64,6 +64,7 @@ impl CompiledProgram {
|
|||
let mut values = Module::with_crate("runa")?;
|
||||
values.ty::<value::ActorReference>()?;
|
||||
values.ty::<ActorContext>()?;
|
||||
values.field_function(&rune::runtime::Protocol::GET, "this", ActorContext::this)?;
|
||||
values.associated_function("send", ActorContext::send)?;
|
||||
values.associated_function("send_from", ActorContext::send_from)?;
|
||||
values.associated_function("log", ActorContext::log)?;
|
||||
|
|
|
|||
|
|
@ -1671,4 +1671,37 @@ mod tests {
|
|||
assert!(completed.reserved.is_none());
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn context_this_sends_to_self_and_cannot_be_reassigned() -> ExperimentResult<()> {
|
||||
let mut runtime = Runtime::default();
|
||||
let implementation = publish(
|
||||
&mut runtime,
|
||||
r#"
|
||||
pub fn handle(ctx, state, sender, message) {
|
||||
if message == 1 { ctx.send(ctx.this, 2); }
|
||||
state + message
|
||||
}
|
||||
"#,
|
||||
)?;
|
||||
let actor = runtime.spawn(implementation, Value::Integer(0), capacity(4))?;
|
||||
runtime.send(actor, Value::Integer(1))?;
|
||||
for _ in 0..20 {
|
||||
runtime.tick(capacity(32));
|
||||
}
|
||||
assert_eq!(runtime.inspect(actor)?.state, Value::Integer(3));
|
||||
assert_eq!(runtime.inspect(actor)?.status, Status::Idle);
|
||||
let invalid = publish(
|
||||
&mut runtime,
|
||||
r#"
|
||||
pub fn handle(ctx, state, sender, message) { ctx.this = sender; state }
|
||||
"#,
|
||||
)?;
|
||||
let actor = runtime.spawn(invalid, Value::Null, capacity(2))?;
|
||||
runtime.send(actor, Value::Null)?;
|
||||
for _ in 0..10 {
|
||||
runtime.tick(capacity(32));
|
||||
}
|
||||
assert!(matches!(runtime.inspect(actor)?.status, Status::Failed(_)));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,6 +237,9 @@ pub fn handle(ctx, state, sender, message) {
|
|||
The runtime allocates a fresh Rune object graph for each state and message
|
||||
input, and copies portable data back out before committing. `ctx.send` copies
|
||||
message data and records the executing actor as sender.
|
||||
The read-only `ctx.this` field returns the current actor's typed reference;
|
||||
`ctx.send(ctx.this, message)` sends a normal transactional message to itself.
|
||||
Assigning `ctx.this` is not supported and fails execution.
|
||||
`ctx.send_from(sender, recipient, message)` permits forwarding the original
|
||||
sender (including unit for host-originated input). A Rune handler can
|
||||
mutate its invocation-local draft but cannot mutate committed state, the
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ In that host:
|
|||
4. Right-click an actor to send a JSON message or a system message: pause,
|
||||
resume, stop, kill, restart, reload, or become another source path. 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
|
||||
5. Use the **Advance Scheduler** button in the Actors toolbar (or
|
||||
**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.
|
||||
|
|
@ -63,7 +64,16 @@ machine running the extension host, not necessarily the desktop.
|
|||
committed state, FIFO queued messages, the reserved message, status, and
|
||||
active-execution telemetry. Tagged values distinguish actor references from
|
||||
strings and preserve signed 64-bit integers exactly.
|
||||
- Documents are read-only snapshots, not editable actor state or live VM frames.
|
||||
- Documents can be edited and saved while the actor is paused, stopped, or failed
|
||||
without an active invocation. Edit the tagged `actor.state`, `actor.reserved`,
|
||||
`actor.queued`, and `actor.log_level` fields. Saving applies changed writable
|
||||
fields; changes to identity, status, telemetry, connection metadata, and other
|
||||
protected fields are discarded with a warning. The editor then reloads the
|
||||
canonical server snapshot. Invalid writable fields reject the save.
|
||||
Running, idle, suspended, and disconnected actors are read-only.
|
||||
Unsaved edits are not replaced by automatic refreshes; saving rechecks the
|
||||
current actor status. State/message edits and log-level changes are separate
|
||||
server operations, not one transaction.
|
||||
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
|
||||
|
|
@ -74,8 +84,8 @@ machine running the extension host, not necessarily the desktop.
|
|||
unresponsive peer is forcibly disconnected after five seconds. Protocol
|
||||
corruption and request failures still terminate the connection without retry.
|
||||
Each connection gets distinct document URIs; reconnecting never replaces
|
||||
snapshots from an earlier connection or another image. Closing a document
|
||||
releases its cache; snapshots are not persisted across VS Code restarts.
|
||||
snapshots from an earlier connection or another image. Snapshots are kept
|
||||
for the extension session, not persisted across VS Code restarts.
|
||||
- There is no auto-connect, polling, automatic reconnect, or request retry.
|
||||
Timeout, peer disconnect, malformed data, and version/correlation failures
|
||||
invalidate the connection and are surfaced in a notification and the **Runa**
|
||||
|
|
@ -86,6 +96,8 @@ machine running the extension host, not necessarily the desktop.
|
|||
|
||||
For automation, the `runa.connect` command also accepts an explicit endpoint
|
||||
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
||||
**Copy Actor Reference** in the actor context menu or inline button copies the
|
||||
raw reference to the clipboard.
|
||||
`runa.spawn` accepts `{ path: string, state: string }`, where state is
|
||||
natural JSON text, and resolves to the new reference. `runa.spawnSource` and
|
||||
`runa.spawnSourceWithState` accept a source file URI (the latter also optional
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export async function run(): Promise<void> {
|
|||
["runa.sources", "runa.actors"],
|
||||
);
|
||||
assert.equal(contributions.views.explorer, undefined);
|
||||
assert.ok(
|
||||
contributions.menus["view/title"].some(
|
||||
(item: { command: string; when: string }) =>
|
||||
item.command === "runa.tick" && item.when.includes("runa.actors"),
|
||||
),
|
||||
);
|
||||
await vscode.commands.executeCommand("workbench.view.extension.runa");
|
||||
await vscode.commands.executeCommand("runa.connect", image.address);
|
||||
assert.ok(!(await vscode.commands.getCommands(true)).includes("runa.addSourceFolder"));
|
||||
|
|
@ -36,6 +42,8 @@ export async function run(): Promise<void> {
|
|||
});
|
||||
assert.equal(typeof spawned, "string");
|
||||
const selection = { actor_ref: String(spawned) };
|
||||
await vscode.commands.executeCommand("runa.copyActorReference", selection);
|
||||
assert.equal(await vscode.env.clipboard.readText(), spawned);
|
||||
await vscode.commands.executeCommand("runa.inspect", selection);
|
||||
const controlled = vscode.window.activeTextEditor?.document;
|
||||
assert.ok(controlled);
|
||||
|
|
@ -146,12 +154,40 @@ export async function run(): Promise<void> {
|
|||
assert.match(document.getText(), /queued from another client/);
|
||||
const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri));
|
||||
assert.match(old.getText(), /"connected": false/);
|
||||
await vscode.commands.executeCommand("runa.editState", { actor_ref: actorRef }, "77");
|
||||
await vscode.commands.executeCommand(
|
||||
"runa.editMessages",
|
||||
{ actor_ref: actorRef },
|
||||
'{"reserved":[],"queued":[{"op":"add","value":2}]}',
|
||||
assert.equal((await vscode.workspace.fs.stat(document.uri)).permissions, undefined);
|
||||
const updated = JSON.parse(document.getText());
|
||||
updated.actor.state = { type: "integer", data: 77 };
|
||||
updated.actor.reserved = null;
|
||||
updated.actor.queued = [
|
||||
{
|
||||
type: "record",
|
||||
data: { op: { type: "string", data: "add" }, value: { type: "integer", data: 2 } },
|
||||
},
|
||||
];
|
||||
updated.actor.log_level = "warn";
|
||||
updated.actor.incarnation = 999;
|
||||
updated.connection.endpoint = "not the actual endpoint";
|
||||
const canonical = documentChanged(document, (text) => {
|
||||
const saved = JSON.parse(text);
|
||||
return (
|
||||
saved.actor.state.data === 77 &&
|
||||
saved.actor.incarnation !== 999 &&
|
||||
saved.actor.log_level === "warn" &&
|
||||
saved.connection.endpoint === image.address
|
||||
);
|
||||
});
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.replace(
|
||||
document.uri,
|
||||
new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)),
|
||||
JSON.stringify(updated, null, 2),
|
||||
);
|
||||
assert.equal(await vscode.workspace.applyEdit(edit), true);
|
||||
const unsaved = document.getText();
|
||||
await vscode.commands.executeCommand("runa.refresh");
|
||||
assert.equal(document.getText(), unsaved, "refresh must preserve unsaved inspection edits");
|
||||
assert.equal(await document.save(), true);
|
||||
await canonical;
|
||||
const recovered = documentChanged(document, (text) => {
|
||||
const actor = JSON.parse(text).actor;
|
||||
return (
|
||||
|
|
@ -168,6 +204,13 @@ export async function run(): Promise<void> {
|
|||
assert.equal(JSON.parse(repaired.getText()).actor.state.data, 79, repaired.getText());
|
||||
assert.equal(JSON.parse(repaired.getText()).actor.log_level, "warn");
|
||||
assert.equal(JSON.parse(repaired.getText()).actor.status.status, "idle");
|
||||
assert.equal(
|
||||
(await vscode.workspace.fs.stat(repaired.uri)).permissions,
|
||||
vscode.FilePermission.Readonly,
|
||||
);
|
||||
await assert.rejects(async () => {
|
||||
await vscode.workspace.fs.writeFile(repaired.uri, Buffer.from(repaired.getText()));
|
||||
}, /paused, stopped, or failed/);
|
||||
console.log(
|
||||
"Runa extension host: spawn, send, system messages, ticks, inspection, automatic refresh, reconnect passed",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -161,9 +161,16 @@
|
|||
{
|
||||
"command": "runa.tick",
|
||||
"title": "Advance Scheduler",
|
||||
"icon": "$(debug-step-over)",
|
||||
"category": "Runa",
|
||||
"enablement": "runa.connected"
|
||||
},
|
||||
{
|
||||
"command": "runa.copyActorReference",
|
||||
"title": "Copy Actor Reference",
|
||||
"category": "Runa",
|
||||
"icon": "$(copy)"
|
||||
},
|
||||
{
|
||||
"command": "runa.inspect",
|
||||
"title": "Inspect Actor",
|
||||
|
|
@ -208,6 +215,11 @@
|
|||
],
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{
|
||||
"command": "runa.tick",
|
||||
"when": "view == runa.actors && runa.connected",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "runa.connect",
|
||||
"when": "view == runa.sources && !runa.connected",
|
||||
|
|
@ -239,6 +251,16 @@
|
|||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "runa.copyActorReference",
|
||||
"when": "view == runa.actors && viewItem == runa.actor",
|
||||
"group": "inline@2"
|
||||
},
|
||||
{
|
||||
"command": "runa.copyActorReference",
|
||||
"when": "view == runa.actors && viewItem == runa.actor",
|
||||
"group": "copy"
|
||||
},
|
||||
{
|
||||
"command": "runa.editState",
|
||||
"when": "view == runa.actors && viewItem == runa.actor",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import * as vscode from "vscode";
|
|||
import { ImageClient, parseEndpoint } from "./client";
|
||||
import {
|
||||
ActorSnapshot,
|
||||
formatSnapshot,
|
||||
isSourcePath,
|
||||
SystemKind,
|
||||
SystemMessage,
|
||||
|
|
@ -11,6 +10,8 @@ import {
|
|||
} from "./protocol";
|
||||
import { RefreshQueue } from "./refresh";
|
||||
import { parseValue, formatValue } from "./values";
|
||||
import { SnapshotDocuments } from "./snapshotProvider";
|
||||
import { canEditActor } from "./snapshotEdits";
|
||||
import {
|
||||
ImageSourceFileSystem,
|
||||
ImageSourceTree,
|
||||
|
|
@ -69,79 +70,36 @@ class ActorTree implements vscode.TreeDataProvider<ActorSnapshot> {
|
|||
}
|
||||
}
|
||||
|
||||
interface DocumentSnapshot {
|
||||
session: number;
|
||||
endpoint: string;
|
||||
actor: ActorSnapshot;
|
||||
capturedAt: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
class SnapshotDocuments implements vscode.TextDocumentContentProvider {
|
||||
private readonly changes = new vscode.EventEmitter<vscode.Uri>();
|
||||
readonly onDidChange = this.changes.event;
|
||||
private readonly documents = new Map<string, DocumentSnapshot>();
|
||||
|
||||
set(session: number, endpoint: string, actor: ActorSnapshot): vscode.Uri {
|
||||
const uri = vscode.Uri.from({
|
||||
scheme: "runa",
|
||||
authority: String(session),
|
||||
path: `/actors/${actor.actor_ref}.json`,
|
||||
});
|
||||
this.documents.set(uri.toString(), {
|
||||
session,
|
||||
endpoint,
|
||||
actor,
|
||||
capturedAt: new Date().toISOString(),
|
||||
connected: true,
|
||||
});
|
||||
this.changes.fire(uri);
|
||||
return uri;
|
||||
}
|
||||
|
||||
refresh(session: number, endpoint: string, actors: ActorSnapshot[]): void {
|
||||
const byRef = new Map(actors.map((actor) => [actor.actor_ref, actor]));
|
||||
for (const snapshot of this.documents.values()) {
|
||||
const actor = byRef.get(snapshot.actor.actor_ref);
|
||||
if (snapshot.session === session && actor) this.set(session, endpoint, actor);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(session: number): void {
|
||||
for (const [key, snapshot] of this.documents) {
|
||||
if (snapshot.session === session) {
|
||||
snapshot.connected = false;
|
||||
this.changes.fire(vscode.Uri.parse(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provideTextDocumentContent(uri: vscode.Uri): string {
|
||||
const snapshot = this.documents.get(uri.toString());
|
||||
if (!snapshot) throw new Error("Snapshot is no longer available; inspect the actor again");
|
||||
return formatSnapshot(
|
||||
snapshot.actor,
|
||||
snapshot.endpoint,
|
||||
snapshot.capturedAt,
|
||||
snapshot.connected,
|
||||
);
|
||||
}
|
||||
|
||||
remove(uri: vscode.Uri): void {
|
||||
this.documents.delete(uri.toString());
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.changes.dispose();
|
||||
this.documents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
type SourceTarget = vscode.Uri | { path: string } | { uri: vscode.Uri } | string | undefined;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): { sourceTree: ImageSourceTree } {
|
||||
const tree = new ActorTree();
|
||||
const documents = new SnapshotDocuments();
|
||||
const documents = new SnapshotDocuments(
|
||||
async (savedSession, actorRef, edit) => {
|
||||
const connection = active();
|
||||
if (savedSession !== connection.session)
|
||||
throw new Error("Inspection belongs to a disconnected session");
|
||||
const actor = await connection.client.inspectActor(actorRef);
|
||||
if (!canEditActor(actor))
|
||||
throw new Error(
|
||||
"Editing requires a paused, stopped, or failed actor without active execution",
|
||||
);
|
||||
connection.check();
|
||||
if (edit.state !== null || edit.messages !== null)
|
||||
await connection.client.editActor(actorRef, edit.state, edit.messages);
|
||||
connection.check();
|
||||
if (edit.level !== undefined) await connection.client.setLogLevel(actorRef, edit.level);
|
||||
connection.check();
|
||||
const updated = await connection.client.inspectActor(actorRef);
|
||||
connection.check();
|
||||
documents.set(connection.session, connection.endpoint, updated);
|
||||
await refresh();
|
||||
},
|
||||
(message) => {
|
||||
output.appendLine(message);
|
||||
void vscode.window.showWarningMessage(`Runa: ${message}`);
|
||||
},
|
||||
);
|
||||
const sources = new ImageSourceFileSystem();
|
||||
const sourceTree = new ImageSourceTree(sources);
|
||||
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
||||
|
|
@ -707,16 +665,22 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
|||
output,
|
||||
status,
|
||||
vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }),
|
||||
vscode.workspace.registerTextDocumentContentProvider("runa", documents),
|
||||
vscode.workspace.registerFileSystemProvider("runa", documents, { isCaseSensitive: true }),
|
||||
vscode.workspace.onDidSaveTextDocument((document) => {
|
||||
void documents.didSave(document).catch(report);
|
||||
}),
|
||||
vscode.workspace.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
|
||||
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||
documents.remove(document.uri);
|
||||
revisions.remove(document.uri);
|
||||
}),
|
||||
command("runa.connect", connect),
|
||||
command("runa.disconnect", disconnect),
|
||||
command("runa.refresh", refresh),
|
||||
command("runa.inspect", inspect),
|
||||
command("runa.copyActorReference", async (selected?: { actor_ref: string }) => {
|
||||
const actorRef = await chooseActor(selected);
|
||||
if (actorRef !== undefined) await vscode.env.clipboard.writeText(actorRef);
|
||||
}),
|
||||
command("runa.spawn", spawn),
|
||||
command("runa.spawnSource", spawnSource),
|
||||
command("runa.spawnSourceWithState", spawnSourceWithState),
|
||||
|
|
@ -738,7 +702,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
|||
systemCommand("resume"),
|
||||
systemCommand("reload"),
|
||||
command("runa.become", become),
|
||||
command("runa.tick", tick),
|
||||
command("runa.tick", (count?: number) => tick(typeof count === "number" ? count : undefined)),
|
||||
{
|
||||
dispose: () => {
|
||||
disposed = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { parse, stringify } from "lossless-json";
|
||||
import {
|
||||
ActorSnapshot,
|
||||
decodeValue,
|
||||
LOG_LEVELS,
|
||||
LogLevel,
|
||||
MAX_MESSAGE_BYTES,
|
||||
Value,
|
||||
} from "./protocol";
|
||||
|
||||
export function canEditActor(actor: ActorSnapshot): boolean {
|
||||
return actor.execution === null && ["paused", "stopped", "failed"].includes(actor.status.status);
|
||||
}
|
||||
|
||||
export interface SnapshotEdit {
|
||||
state: Value | null;
|
||||
messages: { reserved: Value | null; queued: Value[] } | null;
|
||||
level: LogLevel | undefined;
|
||||
discarded: string[];
|
||||
}
|
||||
|
||||
export function parseSnapshotEdit(source: string, original: string): SnapshotEdit {
|
||||
if (Buffer.byteLength(source) > MAX_MESSAGE_BYTES) throw new Error("Snapshot exceeds 1 MiB");
|
||||
const read = (text: string): Record<string, unknown> => {
|
||||
const value: unknown = parse(text, undefined, (literal) =>
|
||||
/^-?(0|[1-9]\d*)$/.test(literal) ? BigInt(literal) : Number(literal),
|
||||
);
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw new Error("Expected an inspection JSON object");
|
||||
return Object.fromEntries(Object.entries(value));
|
||||
};
|
||||
const input = read(source);
|
||||
const baseline = read(original);
|
||||
const object = (value: unknown): Record<string, unknown> => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
throw new Error("Expected actor object");
|
||||
return Object.fromEntries(Object.entries(value));
|
||||
};
|
||||
const actor = object(input.actor);
|
||||
const before = object(baseline.actor);
|
||||
const same = (left: unknown, right: unknown) => stringify(left) === stringify(right);
|
||||
const discarded: string[] = [];
|
||||
for (const key of new Set([...Object.keys(input), ...Object.keys(baseline)])) {
|
||||
if (key !== "actor" && !same(input[key], baseline[key])) discarded.push(key);
|
||||
}
|
||||
for (const key of new Set([...Object.keys(actor), ...Object.keys(before)])) {
|
||||
if (
|
||||
!["state", "reserved", "queued", "log_level"].includes(key) &&
|
||||
!same(actor[key], before[key])
|
||||
)
|
||||
discarded.push(`actor.${key}`);
|
||||
}
|
||||
const state = same(actor.state, before.state) ? null : decodeValue(actor.state);
|
||||
let messages: SnapshotEdit["messages"] = null;
|
||||
if (!same(actor.reserved, before.reserved) || !same(actor.queued, before.queued)) {
|
||||
if (!Array.isArray(actor.queued)) throw new Error("actor.queued must be an array");
|
||||
messages = {
|
||||
reserved: actor.reserved === null ? null : decodeValue(actor.reserved),
|
||||
queued: actor.queued.map(decodeValue),
|
||||
};
|
||||
}
|
||||
let level: LogLevel | undefined;
|
||||
if (!same(actor.log_level, before.log_level)) {
|
||||
level = LOG_LEVELS.find((level) => level === actor.log_level);
|
||||
if (level === undefined) throw new Error("Invalid actor.log_level");
|
||||
}
|
||||
return { state, messages, level, discarded };
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import * as vscode from "vscode";
|
||||
import { ActorSnapshot, formatSnapshot } from "./protocol";
|
||||
import { canEditActor, parseSnapshotEdit, SnapshotEdit } from "./snapshotEdits";
|
||||
|
||||
interface DocumentSnapshot {
|
||||
session: number;
|
||||
endpoint: string;
|
||||
actor: ActorSnapshot;
|
||||
capturedAt: string;
|
||||
connected: boolean;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export class SnapshotDocuments implements vscode.FileSystemProvider {
|
||||
private readonly changes = new vscode.EventEmitter<vscode.FileChangeEvent[]>();
|
||||
readonly onDidChangeFile = this.changes.event;
|
||||
private readonly documents = new Map<string, DocumentSnapshot>();
|
||||
private readonly baselines = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly save: (session: number, actor: string, edit: SnapshotEdit) => Promise<void>,
|
||||
private readonly warn: (message: string) => void,
|
||||
) {}
|
||||
|
||||
set(session: number, endpoint: string, actor: ActorSnapshot): vscode.Uri {
|
||||
const uri = vscode.Uri.from({
|
||||
scheme: "runa",
|
||||
authority: String(session),
|
||||
path: `/actors/${actor.actor_ref}.json`,
|
||||
});
|
||||
const previous = this.documents.get(uri.toString());
|
||||
this.documents.set(uri.toString(), {
|
||||
session,
|
||||
endpoint,
|
||||
actor,
|
||||
capturedAt: new Date().toISOString(),
|
||||
connected: true,
|
||||
mtime: Math.max(Date.now(), (previous?.mtime ?? 0) + 1),
|
||||
});
|
||||
this.changed(uri);
|
||||
return uri;
|
||||
}
|
||||
|
||||
refresh(session: number, endpoint: string, actors: ActorSnapshot[]): void {
|
||||
const byRef = new Map(actors.map((actor) => [actor.actor_ref, actor]));
|
||||
for (const snapshot of this.documents.values()) {
|
||||
const actor = byRef.get(snapshot.actor.actor_ref);
|
||||
if (snapshot.session === session && actor) this.set(session, endpoint, actor);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(session: number): void {
|
||||
for (const [key, snapshot] of this.documents) {
|
||||
if (snapshot.session === session) {
|
||||
snapshot.connected = false;
|
||||
snapshot.mtime++;
|
||||
this.changed(vscode.Uri.parse(key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private changed(uri: vscode.Uri): void {
|
||||
this.changes.fire([{ type: vscode.FileChangeType.Changed, uri }]);
|
||||
}
|
||||
|
||||
private snapshot(uri: vscode.Uri): DocumentSnapshot {
|
||||
const snapshot = this.documents.get(uri.toString());
|
||||
if (!snapshot) throw vscode.FileSystemError.FileNotFound(uri);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private text(snapshot: DocumentSnapshot): string {
|
||||
return formatSnapshot(
|
||||
snapshot.actor,
|
||||
snapshot.endpoint,
|
||||
snapshot.capturedAt,
|
||||
snapshot.connected,
|
||||
);
|
||||
}
|
||||
|
||||
stat(uri: vscode.Uri): vscode.FileStat {
|
||||
if (this.isDirectory(uri))
|
||||
return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 };
|
||||
const snapshot = this.snapshot(uri);
|
||||
return {
|
||||
type: vscode.FileType.File,
|
||||
ctime: 0,
|
||||
mtime: snapshot.mtime,
|
||||
size: Buffer.byteLength(this.text(snapshot)),
|
||||
permissions:
|
||||
snapshot.connected && canEditActor(snapshot.actor)
|
||||
? undefined
|
||||
: vscode.FilePermission.Readonly,
|
||||
};
|
||||
}
|
||||
|
||||
readFile(uri: vscode.Uri): Uint8Array {
|
||||
const text = this.text(this.snapshot(uri));
|
||||
const dirty = vscode.workspace.textDocuments.some(
|
||||
(document) => document.uri.toString() === uri.toString() && document.isDirty,
|
||||
);
|
||||
if (!dirty) this.baselines.set(uri.toString(), text);
|
||||
return Buffer.from(text);
|
||||
}
|
||||
|
||||
async writeFile(uri: vscode.Uri, content: Uint8Array): Promise<void> {
|
||||
const snapshot = this.snapshot(uri);
|
||||
if (!snapshot.connected || !canEditActor(snapshot.actor))
|
||||
throw vscode.FileSystemError.NoPermissions(
|
||||
"Actor must be paused, stopped, or failed with no active invocation",
|
||||
);
|
||||
const baseline = this.baselines.get(uri.toString());
|
||||
if (baseline === undefined)
|
||||
throw vscode.FileSystemError.Unavailable("Inspect the actor before editing");
|
||||
const edit = parseSnapshotEdit(Buffer.from(content).toString("utf8"), baseline);
|
||||
await this.save(snapshot.session, snapshot.actor.actor_ref, edit);
|
||||
if (edit.discarded.length)
|
||||
this.warn(`Discarded read-only inspection fields: ${edit.discarded.join(", ")}`);
|
||||
this.changed(uri);
|
||||
}
|
||||
|
||||
async didSave(document: vscode.TextDocument): Promise<void> {
|
||||
if (document.uri.scheme === "runa" && !document.isDirty)
|
||||
await vscode.commands.executeCommand("workbench.action.files.revertResource", document.uri);
|
||||
}
|
||||
|
||||
watch(): vscode.Disposable {
|
||||
return new vscode.Disposable(() => {});
|
||||
}
|
||||
private isDirectory(uri: vscode.Uri): boolean {
|
||||
return (
|
||||
uri.scheme === "runa" &&
|
||||
(uri.path === "/" || uri.path === "/actors") &&
|
||||
[...this.documents.values()].some((snapshot) => String(snapshot.session) === uri.authority)
|
||||
);
|
||||
}
|
||||
|
||||
readDirectory(uri: vscode.Uri): [string, vscode.FileType][] {
|
||||
if (!this.isDirectory(uri)) throw vscode.FileSystemError.FileNotADirectory(uri);
|
||||
if (uri.path === "/") return [["actors", vscode.FileType.Directory]];
|
||||
return [...this.documents.values()]
|
||||
.filter((snapshot) => String(snapshot.session) === uri.authority)
|
||||
.map((snapshot) => [`${snapshot.actor.actor_ref}.json`, vscode.FileType.File]);
|
||||
}
|
||||
createDirectory(uri: vscode.Uri): void {
|
||||
if (!this.isDirectory(uri))
|
||||
throw vscode.FileSystemError.NoPermissions("Actor snapshot directories cannot be created");
|
||||
}
|
||||
delete(): never {
|
||||
throw vscode.FileSystemError.NoPermissions("Actor snapshots cannot be deleted");
|
||||
}
|
||||
rename(): never {
|
||||
throw vscode.FileSystemError.NoPermissions("Actor snapshots cannot be renamed");
|
||||
}
|
||||
dispose(): void {
|
||||
this.changes.dispose();
|
||||
this.documents.clear();
|
||||
this.baselines.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { formatSnapshot } from "../src/protocol";
|
||||
import { canEditActor, parseSnapshotEdit } from "../src/snapshotEdits";
|
||||
import { actor } from "./fixtures";
|
||||
|
||||
test("inspection edits retain exact values and separate protected fields", () => {
|
||||
const snapshot = actor();
|
||||
snapshot.execution = null;
|
||||
const original = formatSnapshot(snapshot, "127.0.0.1:3737", "captured", true);
|
||||
const edited = {
|
||||
...snapshot,
|
||||
actor_ref: "changed",
|
||||
log_level: "warn" as const,
|
||||
reserved: null,
|
||||
queued: [{ type: "integer" as const, data: -9223372036854775808n }],
|
||||
state: { type: "integer" as const, data: 9223372036854775807n },
|
||||
};
|
||||
const source = formatSnapshot(edited, "different", "captured", true);
|
||||
const edit = parseSnapshotEdit(source, original);
|
||||
assert.deepEqual(edit.state, edited.state);
|
||||
assert.deepEqual(edit.messages, { reserved: null, queued: edited.queued });
|
||||
assert.equal(edit.level, "warn");
|
||||
assert.deepEqual(edit.discarded, ["connection", "actor.actor_ref"]);
|
||||
const protectedNumber = parseSnapshotEdit(
|
||||
source.replace('"incarnation": 1', '"incarnation": 1.5'),
|
||||
original,
|
||||
);
|
||||
assert.ok(protectedNumber.discarded.includes("actor.incarnation"));
|
||||
assert.equal(canEditActor(snapshot), true);
|
||||
snapshot.status = { status: "idle" };
|
||||
assert.equal(canEditActor(snapshot), false);
|
||||
snapshot.status = { status: "paused" };
|
||||
snapshot.execution = { vm_steps: 1n, instruction_pointer: 1n, call_depth: 1n };
|
||||
assert.equal(canEditActor(snapshot), false);
|
||||
});
|
||||
|
||||
test("inspection edits validate all writable fields before saving", () => {
|
||||
const original = formatSnapshot(actor(), "endpoint", "captured", true);
|
||||
assert.throws(
|
||||
() => parseSnapshotEdit(original.replace('"info"', '"nope"'), original),
|
||||
/log_level/,
|
||||
);
|
||||
assert.throws(() =>
|
||||
parseSnapshotEdit(original.replace("9223372036854775807", "9223372036854775808"), original),
|
||||
);
|
||||
assert.throws(() => parseSnapshotEdit("{}", original), /actor object/);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseSnapshotEdit(original.replace('"queued": [', '"queued": null, "extra": ['), original),
|
||||
/queued/,
|
||||
);
|
||||
assert.deepEqual(parseSnapshotEdit(original, original), {
|
||||
state: null,
|
||||
messages: null,
|
||||
level: undefined,
|
||||
discarded: [],
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue