/** * Debug report bundle creation. * * Creates a .tar.gz archive with session data, logs, system info, and optional profiling data. */ import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { WorkProfile } from "@oh-my-pi/pi-natives"; import { APP_NAME, getLogPath, getLogsDir, getReportsDir, isEnoent, localDay, logger } from "@oh-my-pi/pi-utils"; import { writeArchive } from "@oh-my-pi/pi-utils/ar"; import { readSessionHeaderId } from "../session/session-loader"; import { collectSubSessions } from "../session/sub-sessions"; import type { CpuProfile, MemoryStats } from "./profiler"; import { collectSystemInfo, sanitizeEnv } from "./system-info"; /** Maximum number of log lines to load into memory at once. */ const MAX_LOG_LINES = 5000; /** Maximum bytes to read from the tail of a log file (2 MB). */ const MAX_LOG_BYTES = 2 * 1024 * 1024; /** The last `n` lines of `text`, dropping its first line when that line is cut. */ function lastLines(text: string, startsMidLine: boolean, n: number): string { const lines = text.split("\n"); if (startsMidLine) lines.shift(); return lines.slice(-n).join("\n"); } /** Read last N lines from a file, reading at most `maxBytes` from the tail. */ async function readLastLines(filePath: string, n: number, maxBytes = MAX_LOG_BYTES): Promise { try { const file = Bun.file(filePath); const size = file.size; const start = Math.max(0, size - maxBytes); const content = start > 0 ? await file.slice(start, size).text() : await file.text(); return lastLines(content, start > 0, n); } catch (err) { if (isEnoent(err)) return ""; throw err; } } export interface ReportBundleOptions { /** Session file path */ sessionFile: string | undefined; /** Settings to include */ settings?: Record; /** CPU profile (for performance reports) */ cpuProfile?: CpuProfile; /** Numeric memory statistics, never raw heap contents */ memoryStats?: MemoryStats; /** Work profile (for work scheduling reports) */ workProfile?: WorkProfile; /** Raw provider SSE diagnostics captured by the session buffer */ rawSseText?: string; } export interface ReportBundleResult { path: string; files: string[]; } export interface DebugLogSource { getInitialText(): Promise; hasOlderLogs(): boolean; loadOlderLogs(limitDays?: number): Promise; } /** * Create a debug report bundle. * * Bundle contents: * - session.jsonl: Current session transcript * - artifacts/: Current session's artifacts subtree (recursive), including any * subagent session transcripts nested under it * - logs.txt: Recent log entries * - session-logs.txt: Today's log records of this session and its subagents * - system.json: OS, arch, CPU, memory, versions * - env.json: Sanitized environment variables * - config.json: Resolved settings * - profile.cpuprofile: CPU profile (performance report only) * - raw-sse.txt: Recent raw provider SSE diagnostics (when captured) * - profile.md: Markdown CPU profile (performance report only) * - memory.json: Numeric process and heap statistics (memory report only) * - work.folded: Work profile folded stacks (work report only) * - work.md: Work profile summary (work report only) * - work.svg: Work profile flamegraph (work report only) */ export async function createReportBundle(options: ReportBundleOptions): Promise { const reportsDir = getReportsDir(); await fs.mkdir(reportsDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const outputPath = path.join(reportsDir, `omp-report-${timestamp}.tar.gz`); const data: Record = {}; const files: string[] = []; // Collect system info const systemInfo = await collectSystemInfo(); data["system.json"] = JSON.stringify(systemInfo, null, 2); files.push("system.json"); // Sanitized environment data["env.json"] = JSON.stringify(sanitizeEnv(Bun.env as Record), null, 2); files.push("env.json"); // Settings/config if (options.settings) { data["config.json"] = JSON.stringify(options.settings, null, 2); files.push("config.json"); } // Recent raw provider SSE diagnostics if (options.rawSseText && options.rawSseText.trim().length > 0) { data["raw-sse.txt"] = options.rawSseText; files.push("raw-sse.txt"); } // Session file if (options.sessionFile) { try { const sessionContent = await Bun.file(options.sessionFile).text(); data["session.jsonl"] = sessionContent; files.push("session.jsonl"); } catch { // Session file might not exist yet } // Artifacts subtree (same path without .jsonl). Recursing captures the // current session's nested subagent transcripts and their artifacts while // staying inside this session's own directory — unrelated co-located // sessions in the sessions root are never touched (#8648). const artifactsDir = options.sessionFile.slice(0, -6); await addDirectoryToArchive(data, files, artifactsDir, "artifacts"); } // Recent logs (last 1000 lines) across every same-day process. PID-qualified // filenames mean a report generated from a later invocation must still gather // the crashed process's log, so read all of today's files, not just our own. // Every record those files hold for this session or its subagents goes to // session-logs.txt, however far back in the file it sits. const { tails, sessionLines } = await collectSameDayLogs(1000, await collectSessionIds(options.sessionFile)); if (tails) { data["logs.txt"] = tails; files.push("logs.txt"); } if (sessionLines) { data["session-logs.txt"] = sessionLines; files.push("session-logs.txt"); } // CPU profile if (options.cpuProfile) { data["profile.cpuprofile"] = options.cpuProfile.data; files.push("profile.cpuprofile"); data["profile.md"] = options.cpuProfile.markdown; files.push("profile.md"); } // Memory statistics exclude heap contents, which can contain credentials. if (options.memoryStats) { data["memory.json"] = JSON.stringify(options.memoryStats, null, 2); files.push("memory.json"); } // Work profile if (options.workProfile) { data["work.folded"] = options.workProfile.folded; files.push("work.folded"); data["work.md"] = options.workProfile.summary; files.push("work.md"); if (options.workProfile.svg) { data["work.svg"] = options.workProfile.svg; files.push("work.svg"); } } // Write archive await writeArchive(outputPath, "tar.gz", Object.entries(data)); return { path: outputPath, files }; } /** Recursively add every file under a directory to the archive. */ async function addDirectoryToArchive( data: Record, files: string[], dirPath: string, archivePrefix: string, ): Promise { let entries: Dirent[]; try { entries = await fs.readdir(dirPath, { withFileTypes: true }); } catch { // Directory doesn't exist return; } for (const entry of entries) { const entryPath = path.join(dirPath, entry.name); const archivePath = `${archivePrefix}/${entry.name}`; if (entry.isDirectory()) { await addDirectoryToArchive(data, files, entryPath, archivePath); continue; } if (!entry.isFile()) continue; try { data[archivePath] = await Bun.file(entryPath).text(); files.push(archivePath); } catch { // Skip files we can't read } } } /** Get recent log entries for display (tail-limited to avoid OOM on large files). */ export async function getLogText(): Promise { // The file transport batches writes; include this process's latest records. logger.flush(); return readLastLines(getLogPath(), MAX_LOG_LINES); } /** * Ids of the session and its subagents, found the way `/export` finds subagent * transcripts, so other JSONL artifacts never pull in an unrelated session. */ async function collectSessionIds(sessionFile: string | undefined): Promise> { const ids = new Set(); if (!sessionFile) return ids; const ownId = await readSessionHeaderId(sessionFile).catch(() => undefined); if (ownId) ids.add(ownId); const subSessions = await collectSubSessions(sessionFile).catch(() => ({})); for (const sub of Object.values(subSessions)) if (sub.header) ids.add(sub.header.id); return ids; } /** * Concatenate the tail of every same-day process log so a report generated * after a crash still captures the fatal PID's `omp...log`. Files * are ordered oldest-first by mtime and separated by a filename header. * `sessionLines` holds every record of those files whose `sessionId` is in * `sessionIds`, in the same file order, capped at the last {@link MAX_LOG_LINES}. */ async function collectSameDayLogs( linesPerFile: number, sessionIds: ReadonlySet, ): Promise<{ tails: string; sessionLines: string }> { logger.flush(); const logsDir = getLogsDir(); // Log files are named with the local day (see localDay / RotatingFileSink), // so match them with the local day too — the UTC key misses the live log // between local midnight and UTC midnight. const today = localDay(new Date()); const sameDay: Array<{ name: string; mtimeMs: number }> = []; try { const entries = await fs.readdir(logsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; const match = LOG_FILE_PATTERN.exec(entry.name); if (!match || match[1] !== today) continue; try { const stat = await fs.stat(path.join(logsDir, entry.name)); sameDay.push({ name: entry.name, mtimeMs: stat.mtimeMs }); } catch { // File may have rotated away between readdir and stat. } } } catch { return { tails: "", sessionLines: "" }; } sameDay.sort((a, b) => a.mtimeMs - b.mtimeMs); const chunks: string[] = []; const sessionLines: string[] = []; for (const { name } of sameDay) { const filePath = path.join(logsDir, name); if (sessionIds.size === 0) { const text = await readLastLines(filePath, linesPerFile); if (text) chunks.push(`===== ${name} =====\n${text}`); continue; } // One read serves both files, so the tail and the session lines agree; // the tail keeps the same byte cap as a tail read. let bytes: Uint8Array; try { bytes = await Bun.file(filePath).bytes(); } catch (err) { if (isEnoent(err)) continue; // Rotated away since the listing. throw err; } const tailStart = Math.max(0, bytes.length - MAX_LOG_BYTES); const decoder = new TextDecoder(); const text = lastLines(decoder.decode(bytes.subarray(tailStart)), tailStart > 0, linesPerFile); if (text) chunks.push(`===== ${name} =====\n${text}`); const lines = decoder.decode(bytes).split("\n"); for (const line of lines) { if (!line.includes('"sessionId":')) continue; try { const { sessionId } = JSON.parse(line) as { sessionId?: unknown }; if (typeof sessionId === "string" && sessionIds.has(sessionId)) sessionLines.push(line); } catch { // A torn last line of a live file. } } // Keep only what can reach the archive, file by file. if (sessionLines.length > MAX_LOG_LINES) sessionLines.splice(0, sessionLines.length - MAX_LOG_LINES); } return { tails: chunks.join("\n\n"), sessionLines: sessionLines.join("\n") }; } const LOG_FILE_PATTERN = new RegExp(`^${APP_NAME}\\.(\\d{4}-\\d{2}-\\d{2})\\.\\d+\\.log(?:\\.\\d+)?$`); export async function createDebugLogSource(): Promise { 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 => { logger.flush(); return readLastLines(todayPath, MAX_LOG_LINES); }; const hasOlderLogs = (): boolean => cursor < olderFiles.length; const loadOlderLogs = async (limitDays: number = 1): Promise => { 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 readLastLines(filePath, MAX_LOG_LINES); 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, ): Promise<{ count: number; totalSize: number; oldestDate: Date | null }> { let count = 0; let totalSize = 0; let oldestDate: Date | null = null; try { const sessions = await fs.readdir(sessionsDir, { withFileTypes: true }); for (const session of sessions) { // Artifact directories don't have .jsonl extension if (session.isDirectory()) { const dirPath = path.join(sessionsDir, session.name); try { const stat = await fs.stat(dirPath); const files = await fs.readdir(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); const fileStat = await fs.stat(filePath); if (fileStat.isFile()) { count++; totalSize += fileStat.size; } } if (!oldestDate || stat.mtime < oldestDate) { oldestDate = stat.mtime; } } catch { // Skip inaccessible directories } } } } catch { // Directory doesn't exist } return { count, totalSize, oldestDate }; } /** Clear artifact cache older than N days */ export async function clearArtifactCache(sessionsDir: string, daysOld: number = 30): Promise<{ removed: number }> { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - daysOld); let removed = 0; try { const sessions = await fs.readdir(sessionsDir, { withFileTypes: true }); for (const session of sessions) { if (session.isDirectory()) { const dirPath = path.join(sessionsDir, session.name); try { const stat = await fs.stat(dirPath); if (stat.mtime < cutoff) { await fs.rm(dirPath, { recursive: true, force: true }); removed++; } } catch { // Skip inaccessible directories } } } } catch { // Directory doesn't exist } return { removed }; }