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:
parent
318bb5dbd2
commit
8a7e34b1f8
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue