feat: pretty-print actor JSON and highlight syslog-style logs

Format actor state and mailbox files with two-space indentation while preserving exact portable values. Use syslog-style log headers and an actor.log filename recognized by VS Code's built-in log language.

Verify exact formatting, severity mapping, newline escaping and log language detection with unit and real extension-host tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 20:20:00 +01:00
parent 318bb5dbd2
commit 8a7e34b1f8
9 changed files with 106 additions and 23 deletions

View File

@ -6,6 +6,11 @@ All notable changes to this project will be documented in this file.
### Changed ### Changed
- Pretty-print actor state and mailbox JSON with two-space indentation while
preserving exact integers and actor-reference markers.
- Rename the actor's virtual log file to `actor.log` for automatic VS Code log
highlighting and show syslog-style priority, actor, sequence, and severity fields.
- Expand VS Code actors into directories with read-only `meta.json`/`log`, - Expand VS Code actors into directories with read-only `meta.json`/`log`,
live-editable `config.json`, and guarded natural-JSON `state.json`/`mailbox.json`. live-editable `config.json`, and guarded natural-JSON `state.json`/`mailbox.json`.
- Allow reload/become to repair failed actors' implementations without clearing - Allow reload/become to repair failed actors' implementations without clearing

View File

@ -182,7 +182,9 @@ Its **Actors** view lists
actors created through the REPL or another client as expandable directories. actors created through the REPL or another client as expandable directories.
Open `meta.json` for read-only identity/status/behavior-path metadata, Open `meta.json` for read-only identity/status/behavior-path metadata,
`config.json` for live log-level configuration, `state.json` for natural JSON `config.json` for live log-level configuration, `state.json` for natural JSON
state, `mailbox.json` for reserved/queued messages, or `log` for retained logs. state, `mailbox.json` for reserved/queued messages, or `actor.log` for retained logs.
State and mailbox JSON use two-space indentation and preserve exact integers.
The `.log` file uses syslog-style lines and VS Code's built-in log highlighting.
State and mailbox files are writable only for inactive paused/failed actors. State and mailbox files are writable only for inactive paused/failed actors.
Server notifications automatically refresh the tree and open files; Server notifications automatically refresh the tree and open files;
**Runa: Refresh Image** is also available on demand. **Runa: Refresh Image** is also available on demand.

View File

@ -67,12 +67,20 @@ machine running the extension host, not necessarily the desktop.
- `meta.json`: read-only identity, lifecycle, execution telemetry, source path - `meta.json`: read-only identity, lifecycle, execution telemetry, source path
(`behavior_path`), pending source path, and temporary-behavior flag. (`behavior_path`), pending source path, and temporary-behavior flag.
- `config.json`: `{"log_level":"info"}`, editable even while running. - `config.json`: `{"log_level":"info"}`, editable even while running.
- `state.json`: natural JSON state, using `$actor`/`$record` markers as needed. - `state.json`: pretty-printed natural JSON state with two-space indentation,
using `$actor`/`$record` markers as needed and preserving exact 64-bit integers.
- `mailbox.json`: `{"reserved":[],"queued":[]}`, with at most one reserved - `mailbox.json`: `{"reserved":[],"queued":[]}`, with at most one reserved
message. State and mailbox are writable only when paused or failed with no message. State and mailbox are writable only when paused or failed with no
active invocation. Saving never resumes the actor. active invocation. JSON uses two-space indentation. Saving never resumes the actor.
- `log`: read-only retained entries, automatically refreshed on image changes. - `actor.log`: read-only retained entries, automatically refreshed on image changes,
Entries show their global sequence, level, and message. Only the server's recognized by VS Code's built-in Log language for syntax highlighting.
Entries use an RFC 5424-style syslog header:
`<134>1 - - runa <actor-ref> <sequence> - [INFO] message`.
Priorities use the local0 facility and mapped severity (trace/debug: debug,
info: informational, warn: warning, error: error, fatal: critical).
Timestamp and hostname are `-` because the image does not record them;
sequence IDs preserve ordering without inventing timestamps. Embedded
CR/LF characters are escaped so each entry occupies one line. Only the server's
latest 1,000 accepted entries per actor are available. latest 1,000 accepted entries per actor are available.
- `behavior`: present after an actor-specific temporary behavior is saved. - `behavior`: present after an actor-specific temporary behavior is saved.
These files use connection-scoped `runa-actor:` URIs. Removed/disconnected These files use connection-scoped `runa-actor:` URIs. Removed/disconnected
@ -141,7 +149,7 @@ execution. Message input uses `{"reserved":[],"queued":[]}` with zero or one
reserved message. Clearing the reservation removes the failed input that would reserved message. Clearing the reservation removes the failed input that would
otherwise retry on restart. Edits do not resume actors. **Set Actor Log Level...** otherwise retry on restart. Edits do not resume actors. **Set Actor Log Level...**
selects trace/debug/info/warn/error/fatal (default info); snapshots show the level. selects trace/debug/info/warn/error/fatal (default info); snapshots show the level.
The actor's `log` file shows retained output; CLI `logs [-f] [actor...]` also The actor's `actor.log` file shows retained output; CLI `logs [-f] [actor...]` also
supports a live event subscription. supports a live event subscription.
### Actor-specific behaviors ### Actor-specific behaviors

View File

@ -484,7 +484,7 @@ async function editActorFiles(address: string): Promise<void> {
); );
assert.deepEqual( assert.deepEqual(
actorTree.getChildren(parent).map((node) => ("file" in node ? node.file : "")), actorTree.getChildren(parent).map((node) => ("file" in node ? node.file : "")),
["meta.json", "config.json", "state.json", "mailbox.json", "log"], ["meta.json", "config.json", "state.json", "mailbox.json", "actor.log"],
); );
const session = actorTree.session; const session = actorTree.session;
const uri = (name: string) => actorFileUri(session, ref, name); const uri = (name: string) => actorFileUri(session, ref, name);
@ -513,6 +513,15 @@ async function editActorFiles(address: string): Promise<void> {
assert.equal((await vscode.workspace.fs.stat(uri("state.json"))).permissions, undefined); assert.equal((await vscode.workspace.fs.stat(uri("state.json"))).permissions, undefined);
const state = await vscode.workspace.openTextDocument(uri("state.json")); const state = await vscode.workspace.openTextDocument(uri("state.json"));
assert.equal(state.getText().trim(), "1"); assert.equal(state.getText().trim(), "1");
await vscode.workspace.fs.writeFile(
state.uri,
Buffer.from('{"nested":[9223372036854775807,{"value":true}]}'),
);
const prettyState = Buffer.from(await vscode.workspace.fs.readFile(state.uri)).toString();
assert.equal(
prettyState,
'{\n "nested": [\n 9223372036854775807,\n {\n "value": true\n }\n ]\n}\n',
);
await vscode.window.showTextDocument(state, { preview: false }); await vscode.window.showTextDocument(state, { preview: false });
await replaceText(state, "40"); await replaceText(state, "40");
assert.ok(await state.save()); assert.ok(await state.save());
@ -520,6 +529,10 @@ async function editActorFiles(address: string): Promise<void> {
await vscode.window.showTextDocument(mailbox); await vscode.window.showTextDocument(mailbox);
await replaceText(mailbox, '{"reserved":[2],"queued":[3]}'); await replaceText(mailbox, '{"reserved":[2],"queued":[3]}');
assert.ok(await mailbox.save()); assert.ok(await mailbox.save());
assert.equal(
Buffer.from(await vscode.workspace.fs.readFile(mailbox.uri)).toString(),
'{\n "reserved": [\n 2\n ],\n "queued": [\n 3\n ]\n}\n',
);
await assert.rejects( await assert.rejects(
async () => async () =>
vscode.workspace.fs.writeFile(mailbox.uri, Buffer.from('{"reserved":[1,2],"queued":[]}')), vscode.workspace.fs.writeFile(mailbox.uri, Buffer.from('{"reserved":[1,2],"queued":[]}')),
@ -529,9 +542,10 @@ async function editActorFiles(address: string): Promise<void> {
await vscode.commands.executeCommand("runa.restart", selected); await vscode.commands.executeCommand("runa.restart", selected);
await vscode.commands.executeCommand("runa.tick", 20); await vscode.commands.executeCommand("runa.tick", 20);
await executed; await executed;
const log = await vscode.workspace.openTextDocument(uri("log")); const log = await vscode.workspace.openTextDocument(uri("actor.log"));
await vscode.window.showTextDocument(log, { preview: false }); await vscode.window.showTextDocument(log, { preview: false });
assert.match(log.getText(), /INFO: original/); assert.equal(log.languageId, "log");
assert.match(log.getText(), new RegExp(`^<134>1 - - runa ${ref} \\d+ - \\[INFO\\] original\\n`));
assert.equal( assert.equal(
(await vscode.workspace.fs.stat(log.uri)).permissions, (await vscode.workspace.fs.stat(log.uri)).permissions,
vscode.FilePermission.Readonly, vscode.FilePermission.Readonly,
@ -566,7 +580,7 @@ async function editActorFiles(address: string): Promise<void> {
actorTree.getChildren(withTemporary).some((node) => "file" in node && node.file === "behavior"), actorTree.getChildren(withTemporary).some((node) => "file" in node && node.file === "behavior"),
); );
const tempExecuted = documentChanged(state, (text) => text.trim() === "145"); const tempExecuted = documentChanged(state, (text) => text.trim() === "145");
const logged = documentChanged(log, (text) => text.includes("INFO: temporary")); const logged = documentChanged(log, (text) => text.includes("[INFO] temporary"));
await vscode.commands.executeCommand("runa.send", selected, "1"); await vscode.commands.executeCommand("runa.send", selected, "1");
await vscode.commands.executeCommand("runa.tick", 20); await vscode.commands.executeCommand("runa.tick", 20);
await tempExecuted; await tempExecuted;
@ -631,7 +645,7 @@ async function editActorFiles(address: string): Promise<void> {
await vscode.commands.executeCommand("runa.refresh"); await vscode.commands.executeCommand("runa.refresh");
assert.match( assert.match(
Buffer.from(await vscode.workspace.fs.readFile(log.uri)).toString(), Buffer.from(await vscode.workspace.fs.readFile(log.uri)).toString(),
/INFO: temporary/, /\[INFO\] temporary/,
); );
assert.equal( assert.equal(
JSON.parse(Buffer.from(await vscode.workspace.fs.readFile(meta.uri)).toString()).removed, JSON.parse(Buffer.from(await vscode.workspace.fs.readFile(meta.uri)).toString()).removed,

View File

@ -2,7 +2,7 @@ import * as vscode from "vscode";
import { stringify } from "lossless-json"; import { stringify } from "lossless-json";
import { TextDecoder } from "node:util"; import { TextDecoder } from "node:util";
import { ImageClient } from "./client"; import { ImageClient } from "./client";
import { ActorSnapshot, LOG_LEVELS, ProtocolError } from "./protocol"; import { ActorSnapshot, LOG_LEVELS, ProtocolError, formatLogEntry } from "./protocol";
import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits"; import { canEditActor, mailboxValue, mailboxEdit } from "./snapshotEdits";
import { formatValue, parseValue } from "./values"; import { formatValue, parseValue } from "./values";
@ -12,7 +12,7 @@ export const ACTOR_FILES = [
"config.json", "config.json",
"state.json", "state.json",
"mailbox.json", "mailbox.json",
"log", "actor.log",
] as const; ] as const;
export function actorFileUri(session: number, actor: string, file?: string): vscode.Uri { export function actorFileUri(session: number, actor: string, file?: string): vscode.Uri {
return vscode.Uri.from({ return vscode.Uri.from({
@ -124,10 +124,10 @@ export class ActorFiles implements vscode.FileSystemProvider {
let text: string; let text: string;
switch (file) { switch (file) {
case "state.json": case "state.json":
text = formatValue(actor.state); text = formatValue(actor.state, 2);
break; break;
case "mailbox.json": case "mailbox.json":
text = formatValue(mailboxValue(actor)); text = formatValue(mailboxValue(actor), 2);
break; break;
case "config.json": case "config.json":
text = JSON.stringify({ log_level: actor.log_level }, null, 2); text = JSON.stringify({ log_level: actor.log_level }, null, 2);
@ -167,17 +167,13 @@ export class ActorFiles implements vscode.FileSystemProvider {
this.textCache.set(uri.toString(), content); this.textCache.set(uri.toString(), content);
return content; return content;
} }
case "log": { case "actor.log": {
if (!entry.connected || entry.removed) return this.cached(uri); if (!entry.connected || entry.removed) return this.cached(uri);
const connection = this.current(entry); const connection = this.current(entry);
const logs = await this.readLive(uri, () => connection.client.logs(actor.actor_ref)); const logs = await this.readLive(uri, () => connection.client.logs(actor.actor_ref));
if (logs === undefined) return this.cached(uri); if (logs === undefined) return this.cached(uri);
connection.check(); connection.check();
const content = Buffer.from( const content = Buffer.from(logs.map(formatLogEntry).join(""));
logs
.map((log) => `${log.sequence} ${log.level.toUpperCase()}: ${log.message}\n`)
.join(""),
);
this.textCache.set(uri.toString(), content); this.textCache.set(uri.toString(), content);
return content; return content;
} }

View File

@ -47,6 +47,20 @@ export interface LogEntry {
message: string; message: string;
} }
export function formatLogEntry(entry: LogEntry): string {
const severity: Record<LogLevel, number> = {
trace: 7,
debug: 7,
info: 6,
warn: 4,
error: 3,
fatal: 2,
};
const priority = 16 * 8 + severity[entry.level];
const message = entry.message.replaceAll("\r", "\\r").replaceAll("\n", "\\n");
return `<${priority}>1 - - runa ${entry.actor_ref} ${entry.sequence} - [${entry.level.toUpperCase()}] ${message}\n`;
}
export interface SourceFile { export interface SourceFile {
path: string; path: string;
source_id: string; source_id: string;

View File

@ -1,7 +1,7 @@
import { parse, stringify } from "lossless-json"; import { parse, stringify } from "lossless-json";
import { decodeValue, isActorRef, MAX_MESSAGE_BYTES, Value } from "./protocol"; import { decodeValue, isActorRef, MAX_MESSAGE_BYTES, Value } from "./protocol";
export function formatValue(value: Value): string { export function formatValue(value: Value, indent = 0): string {
const natural = (value: Value): unknown => { const natural = (value: Value): unknown => {
switch (value.type) { switch (value.type) {
case "null": case "null":
@ -24,7 +24,7 @@ export function formatValue(value: Value): string {
} }
} }
}; };
const result = stringify(natural(value)); const result = stringify(natural(value), null, indent);
if (result === undefined) throw new Error("Could not encode actor value"); if (result === undefined) throw new Error("Could not encode actor value");
return result; return result;
} }

View File

@ -10,6 +10,8 @@ import {
isSourceRevision, isSourceRevision,
ProtocolError, ProtocolError,
Command, Command,
formatLogEntry,
LOG_LEVELS,
} from "../src/protocol"; } from "../src/protocol";
import { actor, json, REF } from "./fixtures"; import { actor, json, REF } from "./fixtures";
@ -17,6 +19,21 @@ const command: Command = { operation: "list_actors" };
const envelope = (reply: unknown) => const envelope = (reply: unknown) =>
json({ version: 5, request_id: "test", result: { Ok: reply } }); json({ version: 5, request_id: "test", result: { Ok: reply } });
test("actor log lines use syslog priorities, exact sequence IDs and single-line messages", () => {
const priorities = [135, 135, 134, 132, 131, 130];
for (const [index, level] of LOG_LEVELS.entries()) {
assert.equal(
formatLogEntry({
actor_ref: REF,
level,
sequence: 18446744073709551615n,
message: "first\r\nsecond",
}),
`<${priorities[index]}>1 - - runa ${REF} 18446744073709551615 - [${level.toUpperCase()}] first\\r\\nsecond\n`,
);
}
});
test("actor behavior replies check identity, text and the requested operation", () => { test("actor behavior replies check identity, text and the requested operation", () => {
const command: Command = { const command: Command = {
operation: "write_actor_behavior", operation: "write_actor_behavior",

View File

@ -3,6 +3,33 @@ import test from "node:test";
import { parseValue, formatValue } from "../src/values"; import { parseValue, formatValue } from "../src/values";
import { REF } from "./fixtures"; import { REF } from "./fixtures";
test("pretty natural JSON uses two-space indentation without losing values or markers", () => {
const source = `{"reserved":[null],"queued":[9223372036854775807,{"$actor":"${REF}"},{"$record":{"$actor":"literal"}}]}`;
const value = parseValue(source);
const pretty = formatValue(value, 2);
assert.equal(
pretty,
`{
"reserved": [
null
],
"queued": [
9223372036854775807,
{
"$actor": "${REF}"
},
{
"$record": {
"$actor": "literal"
}
}
]
}`,
);
assert.deepEqual(parseValue(pretty), value);
assert.equal(formatValue(value), source);
});
test("editing JSON round-trips exact integers, references, null messages and escaped records", () => { test("editing JSON round-trips exact integers, references, null messages and escaped records", () => {
for (const source of [ for (const source of [
"null", "null",