feat(coding-agent/debug): Enabled loading older debug logs from archived files

- Added a DebugLogSource that reads dated log files and feeds the viewer from the debug selector.
- Made load-older handling asynchronous to fetch external chunks while preserving cursor and scroll state.
- Updated log viewer tests for the new options, external log sources, and async loading paths.
This commit is contained in:
julia
2026-02-14 01:22:17 +00:00
committed by can1357
parent a866f527b2
commit a2a10561f6
5 changed files with 273 additions and 59 deletions
+1
View File
@@ -13,6 +13,7 @@
- Added interactive debug log viewer with selection, copy, and expand/collapse controls
- Added inline filtering and count display to the debug log viewer
- Added pid filter toggle and load-older pagination controls to the debug log viewer
- Enabled loading older debug logs from archived files in viewer
### Fixed
- Sanitized debug log display to strip control codes, normalize tabs, and trim width
+6 -3
View File
@@ -13,7 +13,7 @@ import type { InteractiveModeContext } from "../modes/types";
import { openPath } from "../utils/open";
import { DebugLogViewerComponent } from "./log-viewer";
import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler";
import { clearArtifactCache, createReportBundle, getArtifactCacheStats, getLogText } from "./report-bundle";
import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle";
import { collectSystemInfo, formatSystemInfo } from "./system-info";
/** Debug menu options */
@@ -273,8 +273,9 @@ export class DebugSelectorComponent extends Container {
async #handleViewLogs(): Promise<void> {
try {
const logs = await getLogText();
if (!logs) {
const logSource = await createDebugLogSource();
const logs = await logSource.getInitialText();
if (!logs && !logSource.hasOlderLogs()) {
this.ctx.showWarning("No log entries found for today.");
return;
}
@@ -285,6 +286,8 @@ export class DebugSelectorComponent extends Container {
onExit: () => this.ctx.showDebugSelector(),
onStatus: message => this.ctx.showStatus(message, { dim: true }),
onError: message => this.ctx.showError(message),
onUpdate: () => this.ctx.ui.requestRender(),
logSource,
});
this.ctx.editorContainer.clear();
+159 -40
View File
@@ -9,9 +9,10 @@ import {
parseDebugLogPid,
parseDebugLogTimestampMs,
} from "./log-formatting";
import type { DebugLogSource } from "./report-bundle";
export const SESSION_BOUNDARY_WARNING = "### WARNING - Logs above are older than current session!";
export const LOAD_OLDER_LABEL = "### LOAD UP TO 50 OLDER ENTRIES";
export const LOAD_OLDER_LABEL = "### MOVE UP TO LOAD MORE...";
const INITIAL_LOG_CHUNK = 50;
const LOAD_OLDER_CHUNK = 50;
@@ -22,6 +23,15 @@ type LogEntry = {
pid: number | undefined;
};
type CursorToken = { kind: "log"; logIndex: number } | { kind: "load-older" };
type DebugLogViewerModelOptions = {
processStartMs?: number;
processPid?: number;
hasOlderLogs?: () => boolean;
loadOlderLogs?: (limitDays?: number) => Promise<string>;
};
type ViewerRow =
| {
kind: "warning";
@@ -62,8 +72,11 @@ export class DebugLogViewerModel {
#loadedStartIndex: number;
#processFilterEnabled = false;
#processPid: number;
#hasOlderLogs?: () => boolean;
#loadOlderLogs?: (limitDays?: number) => Promise<string>;
constructor(logText: string, processStartMs: number = getProcessStartMs(), processPid: number = process.pid) {
constructor(logText: string, options: DebugLogViewerModelOptions = {}) {
const { processStartMs = getProcessStartMs(), processPid = process.pid, hasOlderLogs, loadOlderLogs } = options;
this.#entries = splitLogText(logText).map(rawLine => ({
rawLine,
timestampMs: parseDebugLogTimestampMs(rawLine),
@@ -71,6 +84,8 @@ export class DebugLogViewerModel {
}));
this.#processStartMs = processStartMs;
this.#processPid = processPid;
this.#hasOlderLogs = hasOlderLogs;
this.#loadOlderLogs = loadOlderLogs;
this.#loadedStartIndex = Math.max(0, this.#entries.length - INITIAL_LOG_CHUNK);
this.#rows = [];
this.#visibleLogIndices = [];
@@ -245,28 +260,73 @@ export class DebugLogViewerModel {
}
canLoadOlder(): boolean {
return this.#loadedStartIndex > 0;
return this.#loadedStartIndex > 0 || this.#hasExternalOlderLogs();
}
loadOlder(additionalCount: number = LOAD_OLDER_CHUNK): void {
if (!this.canLoadOlder()) {
return;
async loadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<boolean> {
if (this.#loadedStartIndex > 0) {
return this.#loadOlderInMemory(additionalCount);
}
if (!this.#loadOlderLogs || !this.#hasExternalOlderLogs()) {
return false;
}
const olderText = await this.#loadOlderLogs();
if (olderText.length === 0) {
if (!this.#hasExternalOlderLogs()) {
this.#rebuildRows();
}
return false;
}
const added = this.prependLogs(olderText);
if (added === 0) {
if (!this.#hasExternalOlderLogs()) {
this.#rebuildRows();
}
return false;
}
return this.#loadOlderInMemory(additionalCount);
}
prependLogs(logText: string): number {
const previousCursor = this.#getCursorToken();
const previousAnchorLogIndex = this.#getAnchorLogIndex();
const newEntries = splitLogText(logText).map(rawLine => ({
rawLine,
timestampMs: parseDebugLogTimestampMs(rawLine),
pid: parseDebugLogPid(rawLine),
}));
if (newEntries.length === 0) {
return 0;
}
const offset = newEntries.length;
this.#entries = [...newEntries, ...this.#entries];
this.#loadedStartIndex += offset;
this.#expandedLogIndices = new Set([...this.#expandedLogIndices].map(logIndex => logIndex + offset));
const adjustedCursor: CursorToken | undefined =
previousCursor?.kind === "log" ? { kind: "log", logIndex: previousCursor.logIndex + offset } : previousCursor;
const adjustedAnchor = previousAnchorLogIndex === undefined ? undefined : previousAnchorLogIndex + offset;
this.#rebuildRows(adjustedCursor, adjustedAnchor);
return offset;
}
#loadOlderInMemory(additionalCount: number = LOAD_OLDER_CHUNK): boolean {
if (this.#loadedStartIndex === 0) {
return false;
}
const requested = Math.max(1, additionalCount);
const nextStart = Math.max(0, this.#loadedStartIndex - requested);
if (nextStart === this.#loadedStartIndex) {
return;
return false;
}
this.#loadedStartIndex = nextStart;
this.#rebuildRows();
return true;
}
#rebuildRows(): void {
const previousCursor = this.#getCursorToken();
const previousAnchorLogIndex = this.#getAnchorLogIndex();
#rebuildRows(
previousCursor: CursorToken | undefined = this.#getCursorToken(),
previousAnchorLogIndex = this.#getAnchorLogIndex(),
): void {
const query = this.#filterQuery.toLowerCase();
const visible: number[] = [];
for (let i = this.#loadedStartIndex; i < this.#entries.length; i++) {
@@ -345,6 +405,9 @@ export class DebugLogViewerModel {
}
#hasOlderEntries(query: string): boolean {
if (this.#hasExternalOlderLogs()) {
return true;
}
if (this.#loadedStartIndex === 0) {
return false;
}
@@ -357,12 +420,16 @@ export class DebugLogViewerModel {
return false;
}
#hasExternalOlderLogs(): boolean {
return this.#hasOlderLogs?.() ?? false;
}
#getCursorRow(): ViewerRow | undefined {
const rowIndex = this.cursorRowIndex;
return rowIndex === undefined ? undefined : this.#rows[rowIndex];
}
#getCursorToken(): { kind: "log"; logIndex: number } | { kind: "load-older" } | undefined {
#getCursorToken(): CursorToken | undefined {
const row = this.#getCursorRow();
if (!row) {
return undefined;
@@ -393,6 +460,9 @@ interface DebugLogViewerComponentOptions {
onStatus?: (message: string) => void;
onError?: (message: string) => void;
processStartMs?: number;
processPid?: number;
logSource?: DebugLogSource;
onUpdate?: () => void;
}
export class DebugLogViewerComponent implements Component {
@@ -401,16 +471,26 @@ export class DebugLogViewerComponent implements Component {
#onExit: () => void;
#onStatus?: (message: string) => void;
#onError?: (message: string) => void;
#onUpdate?: () => void;
#logSource?: DebugLogSource;
#lastRenderWidth = 80;
#scrollRowOffset = 0;
#statusMessage: string | undefined;
#loadingOlder = false;
constructor(options: DebugLogViewerComponentOptions) {
this.#model = new DebugLogViewerModel(options.logs, options.processStartMs);
this.#logSource = options.logSource;
this.#model = new DebugLogViewerModel(options.logs, {
processStartMs: options.processStartMs,
processPid: options.processPid,
hasOlderLogs: this.#logSource?.hasOlderLogs.bind(this.#logSource),
loadOlderLogs: this.#logSource?.loadOlderLogs.bind(this.#logSource),
});
this.#terminalRows = options.terminalRows;
this.#onExit = options.onExit;
this.#onStatus = options.onStatus;
this.#onError = options.onError;
this.#onUpdate = options.onUpdate;
}
handleInput(keyData: string): void {
@@ -440,27 +520,21 @@ export class DebugLogViewerComponent implements Component {
if (matchesKey(keyData, "ctrl+o")) {
this.#statusMessage = undefined;
this.#model.loadOlder(this.#bodyHeight() + 1);
this.#ensureCursorVisible();
void this.#handleLoadOlder(this.#bodyHeight() + 1);
return;
}
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
if (this.#model.cursorRowKind === "load-older") {
this.#statusMessage = undefined;
this.#model.loadOlder();
this.#ensureCursorVisible();
void this.#handleLoadOlder();
}
return;
}
if (matchesKey(keyData, "shift+up")) {
this.#statusMessage = undefined;
if (this.#maybeLoadOlderOnUp(true)) {
return;
}
this.#model.moveCursor(-1, true);
this.#ensureCursorVisible();
void this.#handleMoveUp(true);
return;
}
@@ -473,11 +547,7 @@ export class DebugLogViewerComponent implements Component {
if (matchesKey(keyData, "up")) {
this.#statusMessage = undefined;
if (this.#maybeLoadOlderOnUp(false)) {
return;
}
this.#model.moveCursor(-1, false);
this.#ensureCursorVisible();
void this.#handleMoveUp(false);
return;
}
@@ -491,8 +561,7 @@ export class DebugLogViewerComponent implements Component {
if (matchesKey(keyData, "right")) {
this.#statusMessage = undefined;
if (this.#model.cursorRowKind === "load-older") {
this.#model.loadOlder();
this.#ensureCursorVisible();
void this.#handleLoadOlder();
return;
}
this.#model.expandSelected();
@@ -541,7 +610,6 @@ export class DebugLogViewerComponent implements Component {
return [
this.#frameTop(innerWidth),
this.#frameSeparator(innerWidth),
this.#frameLine(this.#summaryText(), innerWidth),
this.#frameSeparator(innerWidth),
this.#frameLine(this.#filterText(), innerWidth),
@@ -557,7 +625,7 @@ export class DebugLogViewerComponent implements Component {
}
#controlsText(): string {
return "Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+A: select all Ctrl+P: pid filter Ctrl+O: load older Ctrl+C: copy Esc: back";
return "Esc: back Ctrl+C: copy Up/Down: move Shift+Up/Down: select range Left/Right: collapse/expand Ctrl+A: select all Ctrl+O: load older Ctrl+P: pid filter";
}
#filterText(): string {
@@ -581,21 +649,72 @@ export class DebugLogViewerComponent implements Component {
return Math.max(3, this.#terminalRows - 8);
}
#maybeLoadOlderOnUp(extendSelection: boolean): boolean {
if (this.#model.cursorRowKind === "load-older") {
this.#model.loadOlder();
async #handleLoadOlder(additionalCount: number = LOAD_OLDER_CHUNK): Promise<void> {
const loaded = await this.#loadOlder(additionalCount);
if (loaded) {
this.#ensureCursorVisible();
return true;
this.#onUpdate?.();
}
}
async #handleMoveUp(extendSelection: boolean): Promise<void> {
if (this.#model.cursorRowKind === "load-older") {
const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK);
if (loaded) {
this.#ensureCursorVisible();
this.#onUpdate?.();
return;
}
}
if (!this.#model.canLoadOlder() || !this.#model.isCursorAtFirstSelectableRow()) {
return false;
if (this.#model.canLoadOlder() && this.#model.isCursorAtFirstSelectableRow()) {
const loaded = await this.#loadOlder(LOAD_OLDER_CHUNK);
if (loaded) {
this.#model.moveCursor(-1, extendSelection);
this.#ensureCursorVisible();
this.#onUpdate?.();
return;
}
}
this.#model.loadOlder();
this.#model.moveCursor(-1, extendSelection);
this.#ensureCursorVisible();
return true;
this.#onUpdate?.();
}
async #loadOlder(additionalCount: number): Promise<boolean> {
if (this.#loadingOlder || !this.#model.canLoadOlder()) {
return false;
}
this.#loadingOlder = true;
const previousCursorRowIndex = this.#model.cursorRowIndex;
const previousScrollOffset = this.#scrollRowOffset;
try {
const didLoad = await this.#model.loadOlder(additionalCount);
if (didLoad) {
this.#preserveScrollPosition(previousCursorRowIndex, previousScrollOffset);
}
return didLoad;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.#statusMessage = `Load older failed: ${message}`;
this.#onError?.(`Failed to load older logs: ${message}`);
this.#onUpdate?.();
return false;
} finally {
this.#loadingOlder = false;
}
}
#preserveScrollPosition(previousCursorRowIndex: number | undefined, previousScrollOffset: number): void {
const cursorRowIndex = this.#model.cursorRowIndex;
if (previousCursorRowIndex === undefined || cursorRowIndex === undefined) {
return;
}
const delta = cursorRowIndex - previousCursorRowIndex;
const nextOffset = previousScrollOffset + delta;
const maxOffset = Math.max(0, this.#model.rows.length - this.#bodyHeight());
this.#scrollRowOffset = Math.max(0, Math.min(maxOffset, nextOffset));
}
#renderRows(innerWidth: number): Array<{ lines: string[]; rowIndex: number }> {
@@ -7,7 +7,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { WorkProfile } from "@oh-my-pi/pi-natives";
import { isEnoent } from "@oh-my-pi/pi-utils";
import { getLogPath, getReportsDir } from "@oh-my-pi/pi-utils/dirs";
import { APP_NAME, getLogPath, getLogsDir, getReportsDir } from "@oh-my-pi/pi-utils/dirs";
import type { CpuProfile, HeapSnapshot } from "./profiler";
import { collectSystemInfo, sanitizeEnv } from "./system-info";
@@ -41,6 +41,12 @@ export interface ReportBundleResult {
files: string[];
}
export interface DebugLogSource {
getInitialText(): Promise<string>;
hasOlderLogs(): boolean;
loadOlderLogs(limitDays?: number): Promise<string>;
}
/**
* Create a debug report bundle.
*
@@ -220,6 +226,73 @@ export async function getLogText(): Promise<string> {
}
}
const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.log$`);
export async function createDebugLogSource(): Promise<DebugLogSource> {
const logsDir = getLogsDir();
const todayPath = getLogPath();
const todayName = path.basename(todayPath);
let olderFiles: string[] = [];
try {
const entries = await fs.readdir(logsDir, { withFileTypes: true });
const datedFiles = entries
.filter(entry => entry.isFile())
.map(entry => {
const match = LOG_FILE_PATTERN.exec(entry.name);
return match ? { name: entry.name, date: match[1] } : undefined;
})
.filter((entry): entry is { name: string; date: string } => entry !== undefined)
.filter(entry => entry.name !== todayName)
.sort((a, b) => b.date.localeCompare(a.date));
olderFiles = datedFiles.map(entry => entry.name);
} catch {
olderFiles = [];
}
let cursor = 0;
const getInitialText = async (): Promise<string> => {
try {
return await Bun.file(todayPath).text();
} catch (err) {
if (isEnoent(err)) return "";
throw err;
}
};
const hasOlderLogs = (): boolean => cursor < olderFiles.length;
const loadOlderLogs = async (limitDays: number = 1): Promise<string> => {
if (!hasOlderLogs()) {
return "";
}
const count = Math.max(1, limitDays);
const slice = olderFiles.slice(cursor, cursor + count);
cursor += slice.length;
const chunks: string[] = [];
for (const filename of slice.reverse()) {
const filePath = path.join(logsDir, filename);
try {
const content = await Bun.file(filePath).text();
if (content.length > 0) {
chunks.push(content);
}
} catch (err) {
if (!isEnoent(err)) {
throw err;
}
}
}
return chunks.filter(chunk => chunk.length > 0).join("\n");
};
return {
getInitialText,
hasOlderLogs,
loadOlderLogs,
};
}
/** Calculate total size of artifact cache */
export async function getArtifactCacheStats(
sessionsDir: string,
@@ -22,7 +22,7 @@ describe("DebugLogViewerModel", () => {
it("defaults cursor to the newest log entry", () => {
const logs = ["alpha", "beta", "gamma"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
expect(model.cursorLogIndex).toBe(2);
});
it("inserts session boundary warning between older and current-session logs", () => {
@@ -33,7 +33,7 @@ describe("DebugLogViewerModel", () => {
'{"timestamp":"2026-02-14T12:00:05.000Z","level":"info","message":"current"}',
].join("\n");
const model = new DebugLogViewerModel(logs, processStartMs);
const model = new DebugLogViewerModel(logs, { processStartMs });
const rowKinds = model.rows.map(row => describeRow(row as { kind: string; logIndex?: number }));
expect(rowKinds).toEqual(["log:0", "log:1", SESSION_BOUNDARY_WARNING, "log:2"]);
@@ -41,7 +41,7 @@ describe("DebugLogViewerModel", () => {
it("filters logs with case-insensitive substring matching", () => {
const logs = ["Alpha", "beta", "Gamma", "BETTER"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
model.setFilterQuery("be");
const rowKinds = model.rows.map(row => describeRow(row as { kind: string; logIndex?: number }));
@@ -55,7 +55,7 @@ describe("DebugLogViewerModel", () => {
'{"pid":84,"level":"info","message":"beta"}',
'{"level":"info","message":"missing"}',
].join("\n");
const model = new DebugLogViewerModel(logs, Date.now(), 42);
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now(), processPid: 42 });
expect(model.visibleLogCount).toBe(3);
model.toggleProcessFilter();
@@ -65,26 +65,26 @@ describe("DebugLogViewerModel", () => {
it("selects all visible log rows", () => {
const logs = ["alpha", "beta", "gamma"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
model.selectAllVisible();
expect(model.getSelectedLogIndices()).toEqual([0, 1, 2]);
});
it("progressively loads older entries in chunks", () => {
it("progressively loads older entries in chunks", async () => {
const logs = Array.from({ length: 120 }, (_, index) => `log-${index}`).join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
expect(model.visibleLogCount).toBe(50);
const initialKinds = model.rows.slice(0, 2).map(row => describeRow(row as { kind: string; logIndex?: number }));
expect(initialKinds).toEqual([LOAD_OLDER_LABEL, "log:70"]);
model.loadOlder(5);
await model.loadOlder(5);
expect(model.visibleLogCount).toBe(55);
const expandedKinds = model.rows.slice(0, 2).map(row => describeRow(row as { kind: string; logIndex?: number }));
expect(expandedKinds).toEqual([LOAD_OLDER_LABEL, "log:65"]);
model.loadOlder(50);
await model.loadOlder(50);
expect(model.visibleLogCount).toBe(105);
const expandedAgainKinds = model.rows
.slice(0, 2)
@@ -92,9 +92,27 @@ describe("DebugLogViewerModel", () => {
expect(expandedAgainKinds).toEqual([LOAD_OLDER_LABEL, "log:15"]);
});
it("loads older entries from external sources while keeping cursor stable", async () => {
const logs = ["new-1", "new-2", "new-3"].join("\n");
let hasOlder = true;
const model = new DebugLogViewerModel(logs, {
processStartMs: Date.now(),
hasOlderLogs: () => hasOlder,
loadOlderLogs: async () => {
hasOlder = false;
return ["old-1", "old-2"].join("\n");
},
});
expect(model.rows[0]?.kind).toBe("load-older");
await model.loadOlder(1);
expect(model.getRawLine(model.cursorLogIndex ?? 0)).toBe("new-3");
expect(model.rows[0]?.kind).toBe("load-older");
});
it("clamps cursor when filtered list shrinks", () => {
const logs = ["alpha", "beta", "gamma"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
model.moveCursor(2, false);
expect(model.cursorLogIndex).toBe(2);
@@ -106,7 +124,7 @@ describe("DebugLogViewerModel", () => {
it("resets selection anchor when filtered view drops the anchor log", () => {
const logs = ["alpha", "beta", "gamma", "delta"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
model.moveCursor(-999, false);
model.moveCursor(2, true);
@@ -123,7 +141,7 @@ describe("DebugLogViewerModel", () => {
'{"timestamp":"2026-02-14T12:00:05.000Z","level":"info","message":"current"}',
'{"timestamp":"2026-02-14T12:00:10.000Z","level":"info","message":"current-2"}',
].join("\n");
const model = new DebugLogViewerModel(logs, processStartMs);
const model = new DebugLogViewerModel(logs, { processStartMs });
model.setFilterQuery("old");
expect(model.rows.map(row => row.kind)).toEqual(["log"]);
@@ -142,7 +160,7 @@ describe("DebugLogViewerModel", () => {
it("copies only selected visible entries", () => {
const logs = ["alpha", "bar", "baz"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
model.setFilterQuery("ba");
model.moveCursor(-999, false);
@@ -153,7 +171,7 @@ describe("DebugLogViewerModel", () => {
it("supports shift-range selection and reset on plain movement", () => {
const logs = ["a", "b", "c", "d"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
model.moveCursor(-999, false);
model.moveCursor(1, true);
@@ -166,7 +184,7 @@ describe("DebugLogViewerModel", () => {
it("expands and collapses all selected rows", () => {
const logs = ["a", "b", "c"].join("\n");
const model = new DebugLogViewerModel(logs, Date.now());
const model = new DebugLogViewerModel(logs, { processStartMs: Date.now() });
model.moveCursor(-999, false);
model.moveCursor(1, true);