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
- 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`,
live-editable `config.json`, and guarded natural-JSON `state.json`/`mailbox.json`.
- 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.
Open `meta.json` for read-only identity/status/behavior-path metadata,
`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.
Server notifications automatically refresh the tree and open files;
**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
(`behavior_path`), pending source path, and temporary-behavior flag.
- `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
message. State and mailbox are writable only when paused or failed with no
active invocation. Saving never resumes the actor.
- `log`: read-only retained entries, automatically refreshed on image changes.
Entries show their global sequence, level, and message. Only the server's
active invocation. JSON uses two-space indentation. Saving never resumes the actor.
- `actor.log`: read-only retained entries, automatically refreshed on image changes,
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.
- `behavior`: present after an actor-specific temporary behavior is saved.
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
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.
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.
### Actor-specific behaviors

View File

@ -484,7 +484,7 @@ async function editActorFiles(address: string): Promise<void> {
);
assert.deepEqual(
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 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);
const state = await vscode.workspace.openTextDocument(uri("state.json"));
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 replaceText(state, "40");
assert.ok(await state.save());
@ -520,6 +529,10 @@ async function editActorFiles(address: string): Promise<void> {
await vscode.window.showTextDocument(mailbox);
await replaceText(mailbox, '{"reserved":[2],"queued":[3]}');
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(
async () =>
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.tick", 20);
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 });
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(
(await vscode.workspace.fs.stat(log.uri)).permissions,
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"),
);
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.tick", 20);
await tempExecuted;
@ -631,7 +645,7 @@ async function editActorFiles(address: string): Promise<void> {
await vscode.commands.executeCommand("runa.refresh");
assert.match(
Buffer.from(await vscode.workspace.fs.readFile(log.uri)).toString(),
/INFO: temporary/,
/\[INFO\] temporary/,
);
assert.equal(
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 { TextDecoder } from "node:util";
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 { formatValue, parseValue } from "./values";
@ -12,7 +12,7 @@ export const ACTOR_FILES = [
"config.json",
"state.json",
"mailbox.json",
"log",
"actor.log",
] as const;
export function actorFileUri(session: number, actor: string, file?: string): vscode.Uri {
return vscode.Uri.from({
@ -124,10 +124,10 @@ export class ActorFiles implements vscode.FileSystemProvider {
let text: string;
switch (file) {
case "state.json":
text = formatValue(actor.state);
text = formatValue(actor.state, 2);
break;
case "mailbox.json":
text = formatValue(mailboxValue(actor));
text = formatValue(mailboxValue(actor), 2);
break;
case "config.json":
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);
return content;
}
case "log": {
case "actor.log": {
if (!entry.connected || entry.removed) return this.cached(uri);
const connection = this.current(entry);
const logs = await this.readLive(uri, () => connection.client.logs(actor.actor_ref));
if (logs === undefined) return this.cached(uri);
connection.check();
const content = Buffer.from(
logs
.map((log) => `${log.sequence} ${log.level.toUpperCase()}: ${log.message}\n`)
.join(""),
);
const content = Buffer.from(logs.map(formatLogEntry).join(""));
this.textCache.set(uri.toString(), content);
return content;
}

View File

@ -47,6 +47,20 @@ export interface LogEntry {
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 {
path: string;
source_id: string;

View File

@ -1,7 +1,7 @@
import { parse, stringify } from "lossless-json";
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 => {
switch (value.type) {
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");
return result;
}

View File

@ -10,6 +10,8 @@ import {
isSourceRevision,
ProtocolError,
Command,
formatLogEntry,
LOG_LEVELS,
} from "../src/protocol";
import { actor, json, REF } from "./fixtures";
@ -17,6 +19,21 @@ const command: Command = { operation: "list_actors" };
const envelope = (reply: unknown) =>
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", () => {
const command: Command = {
operation: "write_actor_behavior",

View File

@ -3,6 +3,33 @@ import test from "node:test";
import { parseValue, formatValue } from "../src/values";
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", () => {
for (const source of [
"null",