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
|
### 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,
|
- Guarded actor state and reserved/queued message editing through the CLI,
|
||||||
REPL, and VS Code for paused/stopped/failed actors without active execution.
|
REPL, and VS Code for paused/stopped/failed actors without active execution.
|
||||||
- Immediate actor logs via `ctx.log(level, message)` and severity helpers,
|
- 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,
|
addresses with an in-memory compile cache. Persistence, canonicalization,
|
||||||
remote network access, and the graphical world are not implemented yet.
|
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
|
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
|
inspect state, queued/reserved messages, and execution telemetry. Inspection
|
||||||
documents. The image owns mutable, directory-based Rune source files with
|
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,
|
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
|
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
|
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.
|
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...**,
|
In VS Code, actor context menus offer **Edit Actor State...**,
|
||||||
**Edit Reserved and Queued Messages...**, and **Set Actor Log Level...**.
|
**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`,
|
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.
|
`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,
|
preserves logs and the threshold. Server exit loses them. Rebuild the server,
|
||||||
CLI, and extension together for protocol v5.
|
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
|
## Development
|
||||||
|
|
||||||
Install [mise](https://mise.jdx.dev/) and run `mise install` to install Tombi.
|
Install [mise](https://mise.jdx.dev/) and run `mise install` to install Tombi.
|
||||||
|
|
|
||||||
|
|
@ -1509,7 +1509,21 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_drives_actor_messages_and_inspection() -> Result<(), Box<dyn std::error::Error>> {
|
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 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",
|
"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 {
|
impl ActorContext {
|
||||||
|
pub(crate) fn this(&self) -> crate::value::ActorReference {
|
||||||
|
crate::value::ActorReference(self.actor)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(actor: ActorRef) -> Self {
|
pub fn new(actor: ActorRef) -> Self {
|
||||||
Self {
|
Self {
|
||||||
actor,
|
actor,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use rune::termcolor::{ColorChoice, StandardStream};
|
||||||
use rune::{Context, Diagnostics, Module, Source, Sources, Vm};
|
use rune::{Context, Diagnostics, Module, Source, Sources, Vm};
|
||||||
|
|
||||||
pub const RUNE_VERSION: &str = "0.14.2";
|
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;
|
mod execution;
|
||||||
pub use execution::{Invocation, InvocationProgress, Slice};
|
pub use execution::{Invocation, InvocationProgress, Slice};
|
||||||
|
|
@ -64,6 +64,7 @@ impl CompiledProgram {
|
||||||
let mut values = Module::with_crate("runa")?;
|
let mut values = Module::with_crate("runa")?;
|
||||||
values.ty::<value::ActorReference>()?;
|
values.ty::<value::ActorReference>()?;
|
||||||
values.ty::<ActorContext>()?;
|
values.ty::<ActorContext>()?;
|
||||||
|
values.field_function(&rune::runtime::Protocol::GET, "this", ActorContext::this)?;
|
||||||
values.associated_function("send", ActorContext::send)?;
|
values.associated_function("send", ActorContext::send)?;
|
||||||
values.associated_function("send_from", ActorContext::send_from)?;
|
values.associated_function("send_from", ActorContext::send_from)?;
|
||||||
values.associated_function("log", ActorContext::log)?;
|
values.associated_function("log", ActorContext::log)?;
|
||||||
|
|
|
||||||
|
|
@ -1671,4 +1671,37 @@ mod tests {
|
||||||
assert!(completed.reserved.is_none());
|
assert!(completed.reserved.is_none());
|
||||||
Ok(())
|
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
|
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
|
input, and copies portable data back out before committing. `ctx.send` copies
|
||||||
message data and records the executing actor as sender.
|
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
|
`ctx.send_from(sender, recipient, message)` permits forwarding the original
|
||||||
sender (including unit for host-originated input). A Rune handler can
|
sender (including unit for host-originated input). A Rune handler can
|
||||||
mutate its invocation-local draft but cannot mutate committed state, the
|
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,
|
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
|
resume, stop, kill, restart, reload, or become another source path. These
|
||||||
actions are also available in the Command Palette with an actor picker.
|
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
|
(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.
|
execute it. For the counter, send `{"op":"add","value":7}`, then advance.
|
||||||
6. Run **Runa: Disconnect** to close only this client connection.
|
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
|
committed state, FIFO queued messages, the reserved message, status, and
|
||||||
active-execution telemetry. Tagged values distinguish actor references from
|
active-execution telemetry. Tagged values distinguish actor references from
|
||||||
strings and preserve signed 64-bit integers exactly.
|
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.
|
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
|
||||||
|
|
@ -74,8 +84,8 @@ machine running the extension host, not necessarily the desktop.
|
||||||
unresponsive peer is forcibly disconnected after five seconds. Protocol
|
unresponsive peer is forcibly disconnected after five seconds. Protocol
|
||||||
corruption and request failures still terminate the connection without retry.
|
corruption and request failures still terminate the connection without retry.
|
||||||
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. Snapshots are kept
|
||||||
releases its cache; snapshots are not persisted across VS Code restarts.
|
for the extension session, not persisted across VS Code restarts.
|
||||||
- There is no auto-connect, polling, automatic reconnect, or request retry.
|
- There is no auto-connect, polling, automatic reconnect, or request retry.
|
||||||
Timeout, peer disconnect, malformed data, and version/correlation failures
|
Timeout, peer disconnect, malformed data, and version/correlation failures
|
||||||
invalidate the connection and are surfaced in a notification and the **Runa**
|
invalidate the connection and are surfaced in a notification and the **Runa**
|
||||||
|
|
@ -86,6 +96,8 @@ machine running the extension host, not necessarily the desktop.
|
||||||
|
|
||||||
For automation, the `runa.connect` command also accepts an explicit endpoint
|
For automation, the `runa.connect` command also accepts an explicit endpoint
|
||||||
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
string, and `runa.inspect` accepts an object with an `actor_ref` field.
|
||||||
|
**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
|
`runa.spawn` accepts `{ path: string, state: string }`, where state is
|
||||||
natural JSON text, and resolves to the new reference. `runa.spawnSource` and
|
natural JSON text, and resolves to the new reference. `runa.spawnSource` and
|
||||||
`runa.spawnSourceWithState` accept a source file URI (the latter also optional
|
`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"],
|
["runa.sources", "runa.actors"],
|
||||||
);
|
);
|
||||||
assert.equal(contributions.views.explorer, undefined);
|
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("workbench.view.extension.runa");
|
||||||
await vscode.commands.executeCommand("runa.connect", image.address);
|
await vscode.commands.executeCommand("runa.connect", image.address);
|
||||||
assert.ok(!(await vscode.commands.getCommands(true)).includes("runa.addSourceFolder"));
|
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");
|
assert.equal(typeof spawned, "string");
|
||||||
const selection = { actor_ref: String(spawned) };
|
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);
|
await vscode.commands.executeCommand("runa.inspect", selection);
|
||||||
const controlled = vscode.window.activeTextEditor?.document;
|
const controlled = vscode.window.activeTextEditor?.document;
|
||||||
assert.ok(controlled);
|
assert.ok(controlled);
|
||||||
|
|
@ -146,12 +154,40 @@ export async function run(): Promise<void> {
|
||||||
assert.match(document.getText(), /queued from another client/);
|
assert.match(document.getText(), /queued from another client/);
|
||||||
const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri));
|
const old = await vscode.workspace.openTextDocument(vscode.Uri.parse(firstUri));
|
||||||
assert.match(old.getText(), /"connected": false/);
|
assert.match(old.getText(), /"connected": false/);
|
||||||
await vscode.commands.executeCommand("runa.editState", { actor_ref: actorRef }, "77");
|
assert.equal((await vscode.workspace.fs.stat(document.uri)).permissions, undefined);
|
||||||
await vscode.commands.executeCommand(
|
const updated = JSON.parse(document.getText());
|
||||||
"runa.editMessages",
|
updated.actor.state = { type: "integer", data: 77 };
|
||||||
{ actor_ref: actorRef },
|
updated.actor.reserved = null;
|
||||||
'{"reserved":[],"queued":[{"op":"add","value":2}]}',
|
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 recovered = documentChanged(document, (text) => {
|
||||||
const actor = JSON.parse(text).actor;
|
const actor = JSON.parse(text).actor;
|
||||||
return (
|
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.state.data, 79, repaired.getText());
|
||||||
assert.equal(JSON.parse(repaired.getText()).actor.log_level, "warn");
|
assert.equal(JSON.parse(repaired.getText()).actor.log_level, "warn");
|
||||||
assert.equal(JSON.parse(repaired.getText()).actor.status.status, "idle");
|
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(
|
console.log(
|
||||||
"Runa extension host: spawn, send, system messages, ticks, inspection, automatic refresh, reconnect passed",
|
"Runa extension host: spawn, send, system messages, ticks, inspection, automatic refresh, reconnect passed",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -161,9 +161,16 @@
|
||||||
{
|
{
|
||||||
"command": "runa.tick",
|
"command": "runa.tick",
|
||||||
"title": "Advance Scheduler",
|
"title": "Advance Scheduler",
|
||||||
|
"icon": "$(debug-step-over)",
|
||||||
"category": "Runa",
|
"category": "Runa",
|
||||||
"enablement": "runa.connected"
|
"enablement": "runa.connected"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "runa.copyActorReference",
|
||||||
|
"title": "Copy Actor Reference",
|
||||||
|
"category": "Runa",
|
||||||
|
"icon": "$(copy)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.inspect",
|
"command": "runa.inspect",
|
||||||
"title": "Inspect Actor",
|
"title": "Inspect Actor",
|
||||||
|
|
@ -208,6 +215,11 @@
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
"view/title": [
|
"view/title": [
|
||||||
|
{
|
||||||
|
"command": "runa.tick",
|
||||||
|
"when": "view == runa.actors && runa.connected",
|
||||||
|
"group": "navigation"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "runa.connect",
|
"command": "runa.connect",
|
||||||
"when": "view == runa.sources && !runa.connected",
|
"when": "view == runa.sources && !runa.connected",
|
||||||
|
|
@ -239,6 +251,16 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"view/item/context": [
|
"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",
|
"command": "runa.editState",
|
||||||
"when": "view == runa.actors && viewItem == runa.actor",
|
"when": "view == runa.actors && viewItem == runa.actor",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import * as vscode from "vscode";
|
||||||
import { ImageClient, parseEndpoint } from "./client";
|
import { ImageClient, parseEndpoint } from "./client";
|
||||||
import {
|
import {
|
||||||
ActorSnapshot,
|
ActorSnapshot,
|
||||||
formatSnapshot,
|
|
||||||
isSourcePath,
|
isSourcePath,
|
||||||
SystemKind,
|
SystemKind,
|
||||||
SystemMessage,
|
SystemMessage,
|
||||||
|
|
@ -11,6 +10,8 @@ import {
|
||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
import { RefreshQueue } from "./refresh";
|
import { RefreshQueue } from "./refresh";
|
||||||
import { parseValue, formatValue } from "./values";
|
import { parseValue, formatValue } from "./values";
|
||||||
|
import { SnapshotDocuments } from "./snapshotProvider";
|
||||||
|
import { canEditActor } from "./snapshotEdits";
|
||||||
import {
|
import {
|
||||||
ImageSourceFileSystem,
|
ImageSourceFileSystem,
|
||||||
ImageSourceTree,
|
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;
|
type SourceTarget = vscode.Uri | { path: string } | { uri: vscode.Uri } | string | undefined;
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext): { sourceTree: ImageSourceTree } {
|
export function activate(context: vscode.ExtensionContext): { sourceTree: ImageSourceTree } {
|
||||||
const tree = new ActorTree();
|
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 sources = new ImageSourceFileSystem();
|
||||||
const sourceTree = new ImageSourceTree(sources);
|
const sourceTree = new ImageSourceTree(sources);
|
||||||
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
const sourceView = vscode.window.createTreeView("runa.sources", { treeDataProvider: sourceTree });
|
||||||
|
|
@ -707,16 +665,22 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
output,
|
output,
|
||||||
status,
|
status,
|
||||||
vscode.workspace.registerFileSystemProvider(SOURCE_SCHEME, sources, { isCaseSensitive: true }),
|
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.registerTextDocumentContentProvider(REVISION_SCHEME, revisions),
|
||||||
vscode.workspace.onDidCloseTextDocument((document) => {
|
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||||
documents.remove(document.uri);
|
|
||||||
revisions.remove(document.uri);
|
revisions.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.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.spawn", spawn),
|
||||||
command("runa.spawnSource", spawnSource),
|
command("runa.spawnSource", spawnSource),
|
||||||
command("runa.spawnSourceWithState", spawnSourceWithState),
|
command("runa.spawnSourceWithState", spawnSourceWithState),
|
||||||
|
|
@ -738,7 +702,7 @@ export function activate(context: vscode.ExtensionContext): { sourceTree: ImageS
|
||||||
systemCommand("resume"),
|
systemCommand("resume"),
|
||||||
systemCommand("reload"),
|
systemCommand("reload"),
|
||||||
command("runa.become", become),
|
command("runa.become", become),
|
||||||
command("runa.tick", tick),
|
command("runa.tick", (count?: number) => tick(typeof count === "number" ? count : undefined)),
|
||||||
{
|
{
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
disposed = true;
|
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