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
|
||||
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue