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:
gsxdsm
2026-04-23 12:05:48 -07:00
parent bbdd11aab3
commit 39f7709b47
4 changed files with 255 additions and 3 deletions

View File

@@ -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", () => {

View File

@@ -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 ────────────────────────────────────────────────────────────

View File

@@ -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();
}