import { rename } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { getAgentDisplayName, getModelDisplayName } from "@/lib/agents/models"; import { CONFIG_DIR } from "@/lib/config"; import type { DerivedRunStatus, IterationEntry, LogEntry, Priority, ProjectStats, SessionStats, SessionSummary, SystemEntry, } from "@/lib/types"; const LOG_FILE_EXTENSION = ".jsonl"; const SUMMARY_FILE_SUFFIX = ".summary.json"; const SUMMARY_SCHEMA_VERSION = 2 as const; const SUMMARY_TEMP_SUFFIX = ".tmp"; export type LogIncrementalMode = "reset" | "incremental" | "unchanged"; export interface LogIncrementalState { logPath: string; offsetBytes: number; lastModified: number; trailingPartialLine: string; boundaryProbe?: string; } export interface LogIncrementalResult { mode: LogIncrementalMode; entries: LogEntry[]; state: LogIncrementalState; } const LOG_FILE_TEXT_ENCODER = new TextEncoder(); const LOG_INCREMENTAL_BOUNDARY_BYTE_LENGTH = 256; interface LogSink { write( chunk: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer ): number | Promise; flush(): number | Promise; end(error?: Error): number | Promise; } const LOG_SINKS = new Map(); const LOG_WRITE_QUEUES = new Map>(); const SUMMARY_CACHE = new Map(); function queueLogWrite(logPath: string, task: () => Promise): Promise { const previous = LOG_WRITE_QUEUES.get(logPath) ?? Promise.resolve(); let releaseQueue: (() => void) | undefined; const queued = new Promise((resolve) => { releaseQueue = resolve; }); LOG_WRITE_QUEUES.set(logPath, queued); return previous .catch(() => { // Keep queue progressing even if previous append failed. }) .then(task) .finally(() => { releaseQueue?.(); if (LOG_WRITE_QUEUES.get(logPath) === queued) { LOG_WRITE_QUEUES.delete(logPath); } }); } export function sanitizeForFilename(input: string): string { return input .replace(/[/\\:*?"<>|]/g, "-") // Replace filesystem-unsafe chars .replace(/\s+/g, "-") // Replace whitespace .replace(/-+/g, "-") // Collapse multiple hyphens .replace(/^-|-$/g, "") // Trim leading/trailing hyphens .toLowerCase(); } function normalizeProjectPath(projectPath: string): string { const normalized = projectPath.replace(/\\/g, "/").replace(/\/+/g, "/"); if (normalized === "/") { return normalized; } return normalized.replace(/\/+$/g, ""); } function hashProjectPath(projectPath: string): string { let hash = 0x811c9dc5; for (const byte of new TextEncoder().encode(projectPath)) { hash ^= byte; hash = Math.imul(hash, 0x01000193) >>> 0; } return hash.toString(16).padStart(8, "0"); } export function getProjectName(projectPath: string): string { const normalizedPath = normalizeProjectPath(projectPath); if (!normalizedPath || normalizedPath === "/") { return "unknown-project"; } const basenameSegment = normalizedPath.split("/").at(-1) ?? ""; const basenameSlug = sanitizeForFilename(basenameSegment); if (!basenameSlug) { return "unknown-project"; } return `${basenameSlug}-${hashProjectPath(normalizedPath)}`; } export function getProjectStorageDir( storageRoot: string = CONFIG_DIR, projectPath: string ): string { return join(storageRoot, getProjectName(projectPath)); } export function getProjectLogsDir(storageRoot: string = CONFIG_DIR, projectPath: string): string { return join(getProjectStorageDir(storageRoot, projectPath), "logs"); } export function getProjectWorktreesDir( storageRoot: string = CONFIG_DIR, projectPath: string ): string { return join(getProjectStorageDir(storageRoot, projectPath), "worktrees"); } export function getProjectNameFromLogPath(logPath: string): string { const logDir = dirname(logPath); if (basename(logDir) === "logs") { const projectDir = dirname(logDir); return basename(projectDir) || "unknown-project"; } return basename(logDir) || "unknown-project"; } export async function getGitBranch(cwd?: string): Promise { try { const result = Bun.spawnSync(["git", "branch", "--show-current"], { cwd, stdout: "pipe", stderr: "pipe", }); if (result.exitCode === 0) { const branch = result.stdout.toString().trim(); return branch || undefined; } } catch { // Git not installed or not a git repo - graceful fallback } return undefined; } export function generateLogFilename(timestamp: Date, gitBranch?: string): string { const ts = timestamp.toISOString().replace(/[:.]/g, "-").slice(0, 19); if (gitBranch) { const sanitizedBranch = sanitizeForFilename(gitBranch); return `${ts}_${sanitizedBranch}.jsonl`; } return `${ts}.jsonl`; } export async function createLogSession( storageRoot: string = CONFIG_DIR, projectPath: string, gitBranch?: string ): Promise { const filename = generateLogFilename(new Date(), gitBranch); return join(getProjectLogsDir(storageRoot, projectPath), filename); } export function getSummaryPath(logPath: string): string { if (logPath.endsWith(LOG_FILE_EXTENSION)) { return `${logPath.slice(0, -LOG_FILE_EXTENSION.length)}${SUMMARY_FILE_SUFFIX}`; } return `${logPath}${SUMMARY_FILE_SUFFIX}`; } export async function deleteSessionFiles(sessionPath: string): Promise { await closeLogSink(sessionPath); SUMMARY_CACHE.delete(sessionPath); const paths = [sessionPath, getSummaryPath(sessionPath)]; await Promise.all( paths.map(async (p) => { try { await Bun.file(p).delete(); } catch { // Ignore — file may not exist } }) ); } function parseLogLine(line: string): LogEntry | null { if (!line) { return null; } try { return JSON.parse(line) as LogEntry; } catch { return null; } } function parseLogChunk( chunk: string, trailingPartialLine: string = "" ): { entries: LogEntry[]; trailingPartialLine: string } { const combined = `${trailingPartialLine}${chunk}`; if (!combined) { return { entries: [], trailingPartialLine: "" }; } const lines = combined.split("\n"); const endsWithNewline = combined.endsWith("\n"); let nextTrailingPartialLine = ""; if (!endsWithNewline) { nextTrailingPartialLine = lines.pop() ?? ""; } else if (lines.at(-1) === "") { lines.pop(); } const entries: LogEntry[] = []; for (const line of lines) { const parsed = parseLogLine(line); if (parsed) { entries.push(parsed); } } if (nextTrailingPartialLine) { const parsedTrailing = parseLogLine(nextTrailingPartialLine); if (parsedTrailing) { entries.push(parsedTrailing); nextTrailingPartialLine = ""; } } return { entries, trailingPartialLine: nextTrailingPartialLine }; } function parseLogContent(content: string): LogEntry[] { return parseLogChunk(content).entries; } function encodeBytesAsHex(bytes: Uint8Array): string { return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); } function buildBoundaryProbeFromBytes(bytes: Uint8Array): string { if (bytes.length === 0) { return ""; } const start = Math.max(0, bytes.length - LOG_INCREMENTAL_BOUNDARY_BYTE_LENGTH); return encodeBytesAsHex(bytes.slice(start)); } async function readBoundaryProbe(logPath: string, offsetBytes: number): Promise { if (offsetBytes <= 0) { return ""; } const start = Math.max(0, offsetBytes - LOG_INCREMENTAL_BOUNDARY_BYTE_LENGTH); const buffer = await Bun.file(logPath).slice(start, offsetBytes).arrayBuffer(); return encodeBytesAsHex(new Uint8Array(buffer)); } interface IterationMetrics { iterations: IterationEntry[]; lastIteration: IterationEntry | undefined; totalFixes: number; totalSkipped: number; priorityCounts: Record; totalDuration?: number; } const PRIORITIES: Priority[] = ["P0", "P1", "P2", "P3"]; function emptyPriorityCounts(): Record { return { P0: 0, P1: 0, P2: 0, P3: 0 }; } function aggregatePriorityCounts( target: Record, source: Record ): void { for (const priority of PRIORITIES) { target[priority] += source[priority]; } } function countFindingPriorityCounts( findings: Array<{ priority: Priority; }> ): Record { const counts = emptyPriorityCounts(); for (const finding of findings) { counts[finding.priority] += 1; } return counts; } function computeIterationMetrics(entries: LogEntry[]): IterationMetrics { const iterations = entries.filter((entry): entry is IterationEntry => entry.type === "iteration"); const lastIteration = iterations.at(-1); let totalFixes = 0; let totalSkipped = 0; const priorityCounts = emptyPriorityCounts(); let totalDuration: number | undefined; for (const iteration of iterations) { if (iteration.fixes) { totalFixes += iteration.fixes.fixes.length; totalSkipped += iteration.fixes.skipped.length; for (const fix of iteration.fixes.fixes) { if (Object.hasOwn(priorityCounts, fix.priority)) { priorityCounts[fix.priority]++; } } } if (iteration.duration !== undefined) { totalDuration = (totalDuration ?? 0) + iteration.duration; } } return { iterations, lastIteration, totalFixes, totalSkipped, priorityCounts, totalDuration, }; } function deriveRunStatusFromEntries( entries: LogEntry[], metrics: IterationMetrics ): DerivedRunStatus { const latestLifecycleEntry = [...entries].reverse().find((entry) => entry.type !== "handoff"); if (!latestLifecycleEntry) { return "unknown"; } if (latestLifecycleEntry.type === "session_end") { return latestLifecycleEntry.status; } if (latestLifecycleEntry.type === "iteration") { return deriveRunStatusFromIteration(latestLifecycleEntry); } if ( latestLifecycleEntry.type === "review_iteration" || latestLifecycleEntry.type === "batch_fix" ) { if (latestLifecycleEntry.error) { return latestLifecycleEntry.error.message.toLowerCase().includes("interrupt") ? "interrupted" : "failed"; } return "running"; } if (latestLifecycleEntry.type === "finding_selection") { return "running"; } if (metrics.lastIteration) { return deriveRunStatusFromIteration(metrics.lastIteration); } return "unknown"; } function buildSessionSummary(logPath: string, entries: LogEntry[]): SessionSummary { return entries.reduce( (summary, entry) => applyEntryToSummary(summary, entry, logPath), createEmptySessionSummary(logPath) ); } function createEmptySessionSummary(logPath: string): SessionSummary { return { schemaVersion: SUMMARY_SCHEMA_VERSION, logPath, summaryPath: getSummaryPath(logPath), projectName: getProjectNameFromLogPath(logPath), status: "unknown", iterations: 0, hasIteration: false, totalFixes: 0, totalSkipped: 0, priorityCounts: emptyPriorityCounts(), updatedAt: Date.now(), }; } function deriveRunStatusFromIteration(iteration: IterationEntry): DerivedRunStatus { if (!iteration.error) { return "completed"; } if (iteration.error.message.toLowerCase().includes("interrupt")) { return "interrupted"; } return "failed"; } function applyEntryToSummary( summary: SessionSummary, entry: LogEntry, logPath: string ): SessionSummary { const projectName = entry.type === "system" ? getProjectName(entry.projectPath) : summary.projectName; const updatedAt = entry.timestamp ?? Date.now(); const next: SessionSummary = { ...summary, schemaVersion: SUMMARY_SCHEMA_VERSION, logPath, summaryPath: getSummaryPath(logPath), projectName, startedAt: summary.startedAt ?? entry.timestamp, updatedAt, priorityCounts: { ...summary.priorityCounts }, }; if (entry.type === "system") { next.sessionId = entry.sessionId; next.projectPath = entry.projectPath; next.gitBranch = entry.gitBranch; next.worktreeBranch = entry.worktreeBranch ?? next.worktreeBranch; next.startedAt = summary.startedAt ?? entry.timestamp; return next; } if (entry.type === "iteration") { next.iterations = summary.iterations + 1; next.hasIteration = true; next.status = deriveRunStatusFromIteration(entry); next.endedAt = undefined; next.reason = undefined; if (entry.fixes) { next.totalFixes = summary.totalFixes + entry.fixes.fixes.length; next.totalSkipped = summary.totalSkipped + entry.fixes.skipped.length; for (const fix of entry.fixes.fixes) { if (Object.hasOwn(next.priorityCounts, fix.priority)) { next.priorityCounts[fix.priority]++; } } } if (entry.duration !== undefined) { next.totalDuration = (summary.totalDuration ?? 0) + entry.duration; } return next; } if (entry.type === "review_iteration") { next.iterations = summary.iterations + 1; next.hasIteration = true; next.status = entry.error?.message.toLowerCase().includes("interrupt") === true ? "interrupted" : "running"; if (entry.error && next.status !== "interrupted") { next.status = "failed"; } next.sessionStatus = entry.sessionStatus; next.phase = "review"; next.endedAt = undefined; next.reason = undefined; next.totalFindings = (summary.totalFindings ?? 0) + entry.findings.length; aggregatePriorityCounts(next.priorityCounts, countFindingPriorityCounts(entry.findings)); if (entry.duration !== undefined) { next.totalDuration = (summary.totalDuration ?? 0) + entry.duration; } return next; } if (entry.type === "finding_selection") { next.status = "running"; next.sessionStatus = "running"; next.phase = "selection"; next.endedAt = undefined; next.reason = undefined; next.totalSelectedFindings = entry.selectedFindingIds.length; return next; } if (entry.type === "batch_fix") { const resolvedFindings = entry.fixResults.filter( (result) => result.status === "resolved" ).length; const unresolvedFindings = entry.fixResults.filter( (result) => result.status === "unresolved" ).length; const skippedFindings = entry.fixResults.filter((result) => result.status === "skipped").length; const failed = entry.error !== undefined; const interrupted = entry.error?.message.toLowerCase().includes("interrupt") === true; next.status = interrupted ? "interrupted" : failed ? "failed" : "running"; next.sessionStatus = interrupted ? "interrupted" : failed ? "failed" : "running"; next.phase = "batch-fix"; next.endedAt = undefined; next.reason = failed ? entry.error?.message : undefined; next.totalResolvedSelectedFindings = resolvedFindings; next.totalUnresolvedSelectedFindings = unresolvedFindings; next.totalFixes = resolvedFindings; next.totalSkipped = skippedFindings + unresolvedFindings; if (entry.duration !== undefined) { next.totalDuration = (summary.totalDuration ?? 0) + entry.duration; } return next; } if (entry.type === "handoff") { next.handoffStatus = entry.handoffStatus; next.handoffId = entry.handoffId ?? next.handoffId; next.handoffUpdatedAt = entry.timestamp; if (entry.commitSha !== undefined) { next.commitSha = entry.commitSha; } return next; } if (entry.type !== "session_end") { return next; } next.status = entry.status; next.reason = entry.reason; next.endedAt = entry.timestamp; next.phase = entry.phase; next.sessionStatus = entry.sessionStatus ?? next.sessionStatus; next.reviewOutcome = entry.reviewOutcome ?? next.reviewOutcome; next.handoffStatus = entry.handoffStatus ?? next.handoffStatus; next.handoffId = entry.handoffId ?? next.handoffId; next.handoffUpdatedAt = entry.handoffUpdatedAt ?? next.handoffUpdatedAt; next.mergeReady = entry.mergeReady; next.commitSha = entry.commitSha ?? next.commitSha; next.worktreeBranch = entry.worktreeBranch; return next; } async function awaitSinkResult(result: number | Promise): Promise { await Promise.resolve(result); } async function getOrCreateLogSink(logPath: string): Promise { const existingSink = LOG_SINKS.get(logPath); if (existingSink) { return existingSink; } const file = Bun.file(logPath); if (!(await file.exists())) { await Bun.write(logPath, "", { createPath: true }); } const sink = Bun.file(logPath).writer(); LOG_SINKS.set(logPath, sink); return sink; } async function appendLogLine(logPath: string, line: string): Promise { const sink = await getOrCreateLogSink(logPath); await awaitSinkResult(sink.write(line)); await awaitSinkResult(sink.flush()); } async function closeLogSink(logPath: string): Promise { const sink = LOG_SINKS.get(logPath); LOG_SINKS.delete(logPath); if (!sink) { return; } try { await awaitSinkResult(sink.end()); } catch { // Ignore sink close failures during cleanup. } } export async function readSessionSummary(logPath: string): Promise { const summaryPath = getSummaryPath(logPath); const file = Bun.file(summaryPath); if (!(await file.exists())) { return null; } try { const content = await file.text(); const parsed = JSON.parse(content) as Omit & { schemaVersion?: number; }; if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== SUMMARY_SCHEMA_VERSION) { return null; } return { ...parsed, schemaVersion: SUMMARY_SCHEMA_VERSION, }; } catch { return null; } } /** * Check if the summary file is fresh (not older than the log file). * Returns true if summary exists and is at least as new as the log file. */ async function isSummaryFresh(logPath: string): Promise { const summaryPath = getSummaryPath(logPath); const logFile = Bun.file(logPath); const summaryFile = Bun.file(summaryPath); if (!(await summaryFile.exists())) { return false; } const logMtime = logFile.lastModified; const summaryMtime = summaryFile.lastModified; // Summary is fresh if it was modified at or after the log file return summaryMtime >= logMtime; } async function writeSessionSummary(logPath: string, summary: SessionSummary): Promise { const summaryPath = getSummaryPath(logPath); const tempPath = `${summaryPath}${SUMMARY_TEMP_SUFFIX}.${process.pid}.${Date.now()}`; const content = JSON.stringify(summary, null, 2); await Bun.write(tempPath, content, { createPath: true }); try { await rename(tempPath, summaryPath); } catch (error) { await Bun.file(tempPath) .delete() .catch(() => {}); throw error; } } async function rebuildSessionSummary(logPath: string): Promise { const file = Bun.file(logPath); if (!(await file.exists())) { return null; } const entries = await readLog(logPath); const summary = buildSessionSummary(logPath, entries); await writeSessionSummary(logPath, summary); return summary; } async function resolveSessionSummary(logPath: string): Promise { if (await isSummaryFresh(logPath)) { const summary = await readSessionSummary(logPath); if (summary) { return summary; } } return rebuildSessionSummary(logPath); } async function getSummaryForAppend(logPath: string): Promise { const cached = SUMMARY_CACHE.get(logPath); if (cached) { return cached; } const file = Bun.file(logPath); const resolvedSummary = (await file.exists()) && file.size > 0 ? await resolveSessionSummary(logPath) : null; const summary = resolvedSummary ?? createEmptySessionSummary(logPath); SUMMARY_CACHE.set(logPath, summary); return summary; } /** * Append to an existing log file when no active writer is available (e.g. after * a process restart). Bun.file().writer() starts at byte 0, so we must * read-then-rewrite to preserve prior entries. */ async function appendByRewrite(logPath: string, line: string): Promise { const file = Bun.file(logPath); const existing = (await file.exists()) ? await file.text() : ""; const content = `${existing}${line}`; await Bun.write(logPath, content, { createPath: true }); const summary = buildSessionSummary(logPath, parseLogContent(content)); await writeSessionSummary(logPath, summary); SUMMARY_CACHE.set(logPath, summary); } export async function appendLog(logPath: string, entry: LogEntry): Promise { return queueLogWrite(logPath, async () => { const line = `${JSON.stringify(entry)}\n`; const file = Bun.file(logPath); const hasActiveSink = LOG_SINKS.has(logPath); const fileExists = await file.exists(); if (!hasActiveSink && fileExists && file.size > 0) { await appendByRewrite(logPath, line); } else { const baseSummary = await getSummaryForAppend(logPath); await appendLogLine(logPath, line); const updatedSummary = applyEntryToSummary(baseSummary, entry, logPath); await writeSessionSummary(logPath, updatedSummary); SUMMARY_CACHE.set(logPath, updatedSummary); } if (entry.type === "session_end") { await closeLogSink(logPath); SUMMARY_CACHE.delete(logPath); } }); } function createIncrementalState( logPath: string, offsetBytes: number, lastModified: number, trailingPartialLine: string, boundaryProbe: string = "" ): LogIncrementalState { return { logPath, offsetBytes, lastModified, trailingPartialLine, boundaryProbe, }; } function createResetResultFromContent(logPath: string, content: string): LogIncrementalResult { const parsed = parseLogChunk(content); const contentBytes = LOG_FILE_TEXT_ENCODER.encode(content); const snapshotOffsetBytes = contentBytes.byteLength; const snapshotLastModified = Bun.file(logPath).lastModified; const boundaryProbe = buildBoundaryProbeFromBytes(contentBytes); return { mode: "reset", entries: parsed.entries, state: createIncrementalState( logPath, snapshotOffsetBytes, snapshotLastModified, parsed.trailingPartialLine, boundaryProbe ), }; } export async function readLogIncremental( logPath: string, previous?: LogIncrementalState ): Promise { const file = Bun.file(logPath); if (!(await file.exists())) { return { mode: "reset", entries: [], state: createIncrementalState(logPath, 0, 0, ""), }; } const fileSize = file.size; const fileLastModified = file.lastModified; const canUsePreviousState = previous && previous.logPath === logPath && typeof previous.boundaryProbe === "string" && Number.isFinite(previous.offsetBytes) && previous.offsetBytes >= 0 && previous.offsetBytes <= fileSize; if (!canUsePreviousState || !previous) { const content = await file.text(); return createResetResultFromContent(logPath, content); } if (fileSize === previous.offsetBytes) { if (fileLastModified === previous.lastModified) { return { mode: "unchanged", entries: [], state: createIncrementalState( logPath, previous.offsetBytes, fileLastModified, previous.trailingPartialLine, previous.boundaryProbe ), }; } const content = await file.text(); return createResetResultFromContent(logPath, content); } if (fileSize < previous.offsetBytes || fileLastModified < previous.lastModified) { const content = await file.text(); return createResetResultFromContent(logPath, content); } const boundaryProbe = await readBoundaryProbe(logPath, previous.offsetBytes); if (boundaryProbe !== previous.boundaryProbe) { const content = await file.text(); return createResetResultFromContent(logPath, content); } const appendedChunk = await file.slice(previous.offsetBytes, fileSize).text(); const parsed = parseLogChunk(appendedChunk, previous.trailingPartialLine); const nextBoundaryProbe = await readBoundaryProbe(logPath, fileSize); return { mode: "incremental", entries: parsed.entries, state: createIncrementalState( logPath, fileSize, fileLastModified, parsed.trailingPartialLine, nextBoundaryProbe ), }; } export async function readLog(logPath: string): Promise { const file = Bun.file(logPath); if (!(await file.exists())) { return []; } const content = await file.text(); return parseLogContent(content); } export interface LogSession { path: string; name: string; projectName: string; timestamp: number; } async function buildSessionsFromDir(logsDir: string): Promise { const sessions: LogSession[] = []; const pattern = `*/logs/*${LOG_FILE_EXTENSION}`; const glob = new Bun.Glob(pattern); for await (const relativePath of glob.scan({ cwd: logsDir })) { if (!relativePath.endsWith(LOG_FILE_EXTENSION)) { continue; } const filePath = join(logsDir, relativePath); const timestamp = Bun.file(filePath).lastModified; sessions.push({ path: filePath, name: basename(filePath), projectName: getProjectNameFromLogPath(filePath), timestamp, }); } sessions.sort((a, b) => b.timestamp - a.timestamp); return sessions; } export async function listLogSessions(storageRoot: string = CONFIG_DIR): Promise { try { return await buildSessionsFromDir(storageRoot); } catch { return []; } } export async function listProjectLogSessions( storageRoot: string = CONFIG_DIR, projectPath: string ): Promise { const projectName = getProjectName(projectPath); try { const sessions = await buildSessionsFromDir(storageRoot); return sessions.filter((session) => session.projectName === projectName); } catch { return []; } } export async function getLatestProjectLogSession( storageRoot: string = CONFIG_DIR, projectPath: string ): Promise { const sessions = await listProjectLogSessions(storageRoot, projectPath); return sessions.length > 0 ? (sessions[0] ?? null) : null; } export async function computeSessionStats(session: LogSession): Promise { const entries = await readLog(session.path); const metrics = computeIterationMetrics(entries); // This handles the case where a crash occurred between log write and summary write. const summary = await resolveSessionSummary(session.path); const systemEntry = entries.find((e): e is SystemEntry => e.type === "system"); const reviewer = systemEntry?.reviewer?.agent ?? "claude"; const reviewerModel = systemEntry?.reviewer?.model ?? "unknown"; const reviewerReasoning = systemEntry?.reviewer?.reasoning; const fixer = systemEntry?.fixer?.agent ?? "claude"; const fixerModel = systemEntry?.fixer?.model ?? "unknown"; const fixerReasoning = systemEntry?.fixer?.reasoning; return { sessionPath: session.path, sessionName: session.name, sessionId: summary?.sessionId ?? systemEntry?.sessionId, timestamp: session.timestamp, gitBranch: summary?.gitBranch ?? systemEntry?.gitBranch, worktreeBranch: summary ? summary.worktreeBranch : systemEntry?.worktreeBranch, mergeReady: summary?.mergeReady, commitSha: summary?.commitSha, reviewOutcome: summary?.reviewOutcome, handoffStatus: summary?.handoffStatus, handoffId: summary?.handoffId, handoffUpdatedAt: summary?.handoffUpdatedAt, status: summary?.status ?? deriveRunStatusFromEntries(entries, metrics), sessionStatus: summary?.sessionStatus, phase: summary?.phase, totalFixes: summary?.totalFixes ?? metrics.totalFixes, totalSkipped: summary?.totalSkipped ?? metrics.totalSkipped, priorityCounts: summary?.priorityCounts ?? metrics.priorityCounts, iterations: summary?.iterations ?? metrics.iterations.length, totalDuration: summary?.totalDuration ?? metrics.totalDuration, totalFindings: summary?.totalFindings, totalSelectedFindings: summary?.totalSelectedFindings, totalResolvedSelectedFindings: summary?.totalResolvedSelectedFindings, totalUnresolvedSelectedFindings: summary?.totalUnresolvedSelectedFindings, entries, reviewer, reviewerModel, reviewerReasoning, reviewerDisplayName: getAgentDisplayName(reviewer), reviewerModelDisplayName: getModelDisplayName(reviewer, reviewerModel), fixer, fixerModel, fixerReasoning, fixerDisplayName: getAgentDisplayName(fixer), fixerModelDisplayName: getModelDisplayName(fixer, fixerModel), }; } export async function computeProjectStats( projectName: string, sessions: LogSession[] ): Promise { const sessionStats = await Promise.all(sessions.map(computeSessionStats)); let displayName = projectName; for (const stats of sessionStats) { const systemEntry = stats.entries.find((e): e is SystemEntry => e.type === "system"); if (!systemEntry?.projectPath) { continue; } const segments = systemEntry.projectPath.split(/[/\\]/); displayName = segments.at(-1) || projectName; break; } let totalFixes = 0; let totalSkipped = 0; let totalIterations = 0; const priorityCounts = emptyPriorityCounts(); for (const stats of sessionStats) { totalFixes += stats.totalFixes; totalSkipped += stats.totalSkipped; totalIterations += stats.iterations; aggregatePriorityCounts(priorityCounts, stats.priorityCounts); } const averageIterations = sessionStats.length > 0 ? totalIterations / sessionStats.length : 0; const fixRate = totalFixes + totalSkipped > 0 ? totalFixes / (totalFixes + totalSkipped) : 0; return { projectName, displayName, totalFixes, totalSkipped, priorityCounts, sessionCount: sessions.length, averageIterations, fixRate, sessions: sessionStats, }; }