diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a4f89..328e654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 040b770..a8e6a0c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/vscode/README.md b/vscode/README.md index 8a2f171..6940507 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -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 - [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 diff --git a/vscode/integration/host.ts b/vscode/integration/host.ts index 361ff8f..8e086d0 100644 --- a/vscode/integration/host.ts +++ b/vscode/integration/host.ts @@ -484,7 +484,7 @@ async function editActorFiles(address: string): Promise { ); 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 { 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 { 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 { 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 { 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 { 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, diff --git a/vscode/src/actorFiles.ts b/vscode/src/actorFiles.ts index 6f14bdc..ae3dca4 100644 --- a/vscode/src/actorFiles.ts +++ b/vscode/src/actorFiles.ts @@ -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; } diff --git a/vscode/src/protocol.ts b/vscode/src/protocol.ts index e869bc6..42e434f 100644 --- a/vscode/src/protocol.ts +++ b/vscode/src/protocol.ts @@ -47,6 +47,20 @@ export interface LogEntry { message: string; } +export function formatLogEntry(entry: LogEntry): string { + const severity: Record = { + 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; diff --git a/vscode/src/values.ts b/vscode/src/values.ts index 6fa226f..2be6ca5 100644 --- a/vscode/src/values.ts +++ b/vscode/src/values.ts @@ -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; } diff --git a/vscode/test/protocol.test.ts b/vscode/test/protocol.test.ts index b9cb0d6..f3a7222 100644 --- a/vscode/test/protocol.test.ts +++ b/vscode/test/protocol.test.ts @@ -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", diff --git a/vscode/test/values.test.ts b/vscode/test/values.test.ts index 32ac53a..dcf2d72 100644 --- a/vscode/test/values.test.ts +++ b/vscode/test/values.test.ts @@ -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",