fix: surface engine logs in dashboard TUI via console capture
The dashboard TUI Logs tab only showed the handful of lines emitted through DashboardLogSink directly. Everything from @fusion/engine (scheduler, executor, triage, merger, PR monitor, heartbeat, etc.) logs via createLogger() which writes straight to console.error — bypassing the sink. Under the TUI's alt screen those writes either overdrew the frame or scrolled off, leaving the Logs tab near-empty. DashboardLogSink now offers captureConsole() / releaseConsole(): while the TUI is running, console.log/warn/error are intercepted and routed into the ring buffer. A leading `[prefix]` tag is extracted so entries carry the subsystem name. Original console functions are restored (by identity) on TUI shutdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
isTTYAvailable,
|
||||
renderHeaderToString,
|
||||
DashboardTUI,
|
||||
formatConsoleArgs,
|
||||
type SystemInfo,
|
||||
type TaskStats,
|
||||
type SettingsValues,
|
||||
@@ -134,6 +135,161 @@ describe("DashboardLogSink", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── DashboardLogSink console capture ───────────────────────────────────────
|
||||
//
|
||||
// Engine subsystems log via createLogger() in @fusion/engine, which writes
|
||||
// straight to console.error with a `[prefix]` tag. Under the TUI's alt screen
|
||||
// those writes are invisible. captureConsole() bridges them into the ring
|
||||
// buffer; releaseConsole() restores the originals on teardown.
|
||||
|
||||
describe("DashboardLogSink.captureConsole", () => {
|
||||
let originalLog: typeof console.log;
|
||||
let originalWarn: typeof console.warn;
|
||||
let originalError: typeof console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLog = console.log;
|
||||
originalWarn = console.warn;
|
||||
originalError = console.error;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Defensive: even if a test fails mid-way, don't leave console patched.
|
||||
console.log = originalLog;
|
||||
console.warn = originalWarn;
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
it("routes console.log to sink.log, splitting a [prefix] tag", () => {
|
||||
const tui = new DashboardTUI();
|
||||
// Use the public addLog-backed log() by setting the TUI mode.
|
||||
const sink = new DashboardLogSink();
|
||||
sink.setTUI(tui);
|
||||
sink.captureConsole();
|
||||
|
||||
console.log("[executor] task moved to in-progress");
|
||||
|
||||
const entries = (tui as unknown as {
|
||||
logBuffer: LogRingBuffer;
|
||||
}).logBuffer.getAll();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].level).toBe("info");
|
||||
expect(entries[0].prefix).toBe("executor");
|
||||
expect(entries[0].message).toBe("task moved to in-progress");
|
||||
|
||||
sink.releaseConsole();
|
||||
});
|
||||
|
||||
it("routes console.warn to sink.warn and preserves level", () => {
|
||||
const tui = new DashboardTUI();
|
||||
const sink = new DashboardLogSink();
|
||||
sink.setTUI(tui);
|
||||
sink.captureConsole();
|
||||
|
||||
console.warn("[merger] retry 2/3");
|
||||
|
||||
const entries = (tui as unknown as {
|
||||
logBuffer: LogRingBuffer;
|
||||
}).logBuffer.getAll();
|
||||
expect(entries[0].level).toBe("warn");
|
||||
expect(entries[0].prefix).toBe("merger");
|
||||
|
||||
sink.releaseConsole();
|
||||
});
|
||||
|
||||
it("routes console.error to sink.error", () => {
|
||||
const tui = new DashboardTUI();
|
||||
const sink = new DashboardLogSink();
|
||||
sink.setTUI(tui);
|
||||
sink.captureConsole();
|
||||
|
||||
console.error("[scheduler] could not claim lease");
|
||||
|
||||
const entries = (tui as unknown as {
|
||||
logBuffer: LogRingBuffer;
|
||||
}).logBuffer.getAll();
|
||||
expect(entries[0].level).toBe("error");
|
||||
expect(entries[0].prefix).toBe("scheduler");
|
||||
expect(entries[0].message).toBe("could not claim lease");
|
||||
|
||||
sink.releaseConsole();
|
||||
});
|
||||
|
||||
it("handles untagged messages without a prefix", () => {
|
||||
const tui = new DashboardTUI();
|
||||
const sink = new DashboardLogSink();
|
||||
sink.setTUI(tui);
|
||||
sink.captureConsole();
|
||||
|
||||
console.log("a raw line with no bracket tag");
|
||||
|
||||
const entries = (tui as unknown as {
|
||||
logBuffer: LogRingBuffer;
|
||||
}).logBuffer.getAll();
|
||||
expect(entries[0].prefix).toBeUndefined();
|
||||
expect(entries[0].message).toBe("a raw line with no bracket tag");
|
||||
|
||||
sink.releaseConsole();
|
||||
});
|
||||
|
||||
it("releaseConsole restores the original console functions", () => {
|
||||
const sink = new DashboardLogSink();
|
||||
sink.captureConsole();
|
||||
expect(console.log).not.toBe(originalLog);
|
||||
expect(console.warn).not.toBe(originalWarn);
|
||||
expect(console.error).not.toBe(originalError);
|
||||
|
||||
sink.releaseConsole();
|
||||
expect(console.log).toBe(originalLog);
|
||||
expect(console.warn).toBe(originalWarn);
|
||||
expect(console.error).toBe(originalError);
|
||||
});
|
||||
|
||||
it("captureConsole is idempotent (calling twice does not double-wrap)", () => {
|
||||
const sink = new DashboardLogSink();
|
||||
sink.captureConsole();
|
||||
const firstPatched = console.log;
|
||||
sink.captureConsole();
|
||||
expect(console.log).toBe(firstPatched);
|
||||
sink.releaseConsole();
|
||||
});
|
||||
});
|
||||
|
||||
// ── formatConsoleArgs helper ───────────────────────────────────────────────
|
||||
|
||||
describe("formatConsoleArgs", () => {
|
||||
it("joins multiple args with a space", () => {
|
||||
const { message, prefix } = formatConsoleArgs(["hello", "world"]);
|
||||
expect(prefix).toBeUndefined();
|
||||
expect(message).toBe("hello world");
|
||||
});
|
||||
|
||||
it("extracts a leading [prefix] tag", () => {
|
||||
const { message, prefix } = formatConsoleArgs(["[executor] starting task FN-123"]);
|
||||
expect(prefix).toBe("executor");
|
||||
expect(message).toBe("starting task FN-123");
|
||||
});
|
||||
|
||||
it("stringifies objects via JSON", () => {
|
||||
const { message } = formatConsoleArgs(["result:", { ok: true, count: 3 }]);
|
||||
expect(message).toBe('result: {"ok":true,"count":3}');
|
||||
});
|
||||
|
||||
it("uses error.stack when given an Error", () => {
|
||||
const err = new Error("boom");
|
||||
const { message } = formatConsoleArgs([err]);
|
||||
expect(message).toContain("boom");
|
||||
});
|
||||
|
||||
it("falls back to String() for circular objects", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const { message } = formatConsoleArgs([circular]);
|
||||
// Specifically: no throw. Exact string is platform-dependent.
|
||||
expect(typeof message).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
// ── isTTYAvailable Tests ─────────────────────────────────────────────────
|
||||
|
||||
describe("isTTYAvailable", () => {
|
||||
|
||||
@@ -1286,6 +1286,11 @@ export class DashboardTUI {
|
||||
export class DashboardLogSink {
|
||||
private tui: DashboardTUI | null = null;
|
||||
private isTTY: boolean;
|
||||
private originalConsole: {
|
||||
log: typeof console.log;
|
||||
warn: typeof console.warn;
|
||||
error: typeof console.error;
|
||||
} | null = null;
|
||||
|
||||
constructor(tui?: DashboardTUI) {
|
||||
this.tui = tui ?? null;
|
||||
@@ -1298,28 +1303,106 @@ export class DashboardLogSink {
|
||||
}
|
||||
|
||||
log(message: string, prefix?: string): void {
|
||||
const line = prefix ? `[${prefix}] ${message}` : message;
|
||||
if (this.tui && this.isTTY) {
|
||||
this.tui.log(message, prefix);
|
||||
} else if (this.originalConsole) {
|
||||
// Route through the pre-capture console.log with the correct receiver.
|
||||
this.originalConsole.log.call(console, line);
|
||||
} else {
|
||||
console.log(prefix ? `[${prefix}] ${message}` : message);
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, prefix?: string): void {
|
||||
const line = prefix ? `[${prefix}] ${message}` : message;
|
||||
if (this.tui && this.isTTY) {
|
||||
this.tui.warn(message, prefix);
|
||||
} else if (this.originalConsole) {
|
||||
this.originalConsole.warn.call(console, line);
|
||||
} else {
|
||||
console.warn(prefix ? `[${prefix}] ${message}` : message);
|
||||
console.warn(line);
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, prefix?: string): void {
|
||||
const line = prefix ? `[${prefix}] ${message}` : message;
|
||||
if (this.tui && this.isTTY) {
|
||||
this.tui.error(message, prefix);
|
||||
} else if (this.originalConsole) {
|
||||
this.originalConsole.error.call(console, line);
|
||||
} else {
|
||||
console.error(prefix ? `[${prefix}] ${message}` : message);
|
||||
console.error(line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Monkey-patch `console.log/warn/error` so everything (including the engine's
|
||||
* createLogger() output, which writes directly to console.error) surfaces in
|
||||
* the TUI's log ring buffer. Without this, most runtime logs render beneath
|
||||
* the alt-screen TUI and are immediately overwritten on the next render,
|
||||
* leaving the Logs tab nearly empty.
|
||||
*
|
||||
* Messages that start with `[prefix] rest` are unpacked so the TUI stores
|
||||
* `prefix="prefix"` and `message="rest"`. Idempotent; call `releaseConsole()`
|
||||
* on TUI shutdown to restore the originals.
|
||||
*/
|
||||
captureConsole(): void {
|
||||
if (this.originalConsole) return;
|
||||
// Store the exact function references (not bound wrappers) so
|
||||
// releaseConsole can restore identity. console.log/warn/error are
|
||||
// `this`-independent in Node, so calling them without a receiver is safe.
|
||||
this.originalConsole = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
console.log = (...args: unknown[]) => {
|
||||
const { message, prefix } = formatConsoleArgs(args);
|
||||
this.log(message, prefix);
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
const { message, prefix } = formatConsoleArgs(args);
|
||||
this.warn(message, prefix);
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
const { message, prefix } = formatConsoleArgs(args);
|
||||
this.error(message, prefix);
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore console.log/warn/error to their pre-capture implementations. */
|
||||
releaseConsole(): void {
|
||||
if (!this.originalConsole) return;
|
||||
console.log = this.originalConsole.log;
|
||||
console.warn = this.originalConsole.warn;
|
||||
console.error = this.originalConsole.error;
|
||||
this.originalConsole = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format heterogeneous console args into a single string, and extract a
|
||||
* leading `[prefix]` if present. Mirrors `util.format` loosely without
|
||||
* the dependency — objects are JSON-stringified (defensively, falling back
|
||||
* to String()), everything else is coerced via String().
|
||||
*/
|
||||
export function formatConsoleArgs(args: unknown[]): { message: string; prefix?: string } {
|
||||
const stringified = args.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return arg.stack ?? arg.message;
|
||||
if (arg === null || arg === undefined) return String(arg);
|
||||
if (typeof arg === "object") {
|
||||
try { return JSON.stringify(arg); } catch { return String(arg); }
|
||||
}
|
||||
return String(arg);
|
||||
}).join(" ");
|
||||
|
||||
const match = stringified.match(/^\[([^\]]+)\]\s*(.*)$/s);
|
||||
if (match) {
|
||||
return { prefix: match[1], message: match[2] };
|
||||
}
|
||||
return { message: stringified };
|
||||
}
|
||||
|
||||
// ── String Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -388,6 +388,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
// Wire the TUI into the log sink so all console output routes through TUI
|
||||
logSink.setTUI(tui);
|
||||
// Capture stdlib console.* so engine/scheduler/pi/etc. log lines (which
|
||||
// go straight to console.error via createLogger in @fusion/engine) land
|
||||
// in the TUI's ring buffer instead of being overwritten by the alt screen.
|
||||
logSink.captureConsole();
|
||||
}
|
||||
|
||||
store = new TaskStore(cwd);
|
||||
@@ -794,6 +798,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
// Stop TUI if active
|
||||
if (tui) {
|
||||
// Restore console.* before stopping the TUI so any log lines emitted
|
||||
// during teardown (or by late-firing listeners) go to the real terminal
|
||||
// instead of a ring buffer that's about to disappear.
|
||||
logSink.releaseConsole();
|
||||
void tui.stop();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user