import * as p from "@clack/prompts"; import { getCommandDef } from "@/cli"; import { parseCommand } from "@/lib/cli-parser"; import { CONFIG_DIR } from "@/lib/config"; import { formatDuration } from "@/lib/format"; import { computeSessionStats, getProjectName, getProjectNameFromLogPath, listLogSessions, listProjectLogSessions, } from "@/lib/logger"; import { type ActiveSession, listAllActiveSessions } from "@/lib/session-state"; import type { AgentSettings, DerivedRunStatus, FixEntry, HandoffStatus, IterationEntry, Priority, SessionStats, SkippedEntry, SystemEntry, } from "@/lib/types"; interface LogsOptions { json: boolean; last: number; global: boolean; } // Session state uses "default" when branch is unavailable, but logs store undefined. function normalizeBranch(branch: string | undefined): string | undefined { const trimmed = branch?.trim(); if (!trimmed || trimmed === "default") { return undefined; } return trimmed; } function hasUniqueProjectBranchMatch( session: SessionStats, activeSessions: ActiveSession[] ): boolean { const sessionProjectName = getProjectNameFromLogPath(session.sessionPath); const sessionBranch = normalizeBranch(session.gitBranch); const matches = activeSessions.filter((active) => { const activeProjectName = getProjectName(active.projectPath); if (sessionProjectName !== activeProjectName) { return false; } return normalizeBranch(active.branch) === sessionBranch; }); return matches.length === 1; } /** * Mark sessions in the array as "running" if they match any active session. * This operates directly on SessionStats[]. */ export function markSessionStatsRunning( sessions: SessionStats[], activeSessions: ActiveSession[] ): void { for (const session of sessions) { if (session.sessionId) { if (activeSessions.some((active) => active.sessionId === session.sessionId)) { session.status = "running"; } continue; } if (hasUniqueProjectBranchMatch(session, activeSessions)) { session.status = "running"; } } } function isUnknownEmptySession(session: SessionStats): boolean { return session.status === "unknown" && session.iterations === 0; } function printNoProjectSessions(projectName: string, json: boolean): void { if (json) { console.log(JSON.stringify({ project: projectName, sessions: [] }, null, 2)); return; } p.log.info("No review sessions found for current working directory."); p.log.message('Start a review with "rr run" first.'); } function formatDate(timestamp: number): string { return new Date(timestamp).toLocaleString(); } export function formatStatus(status: DerivedRunStatus): string { return status; } export function formatPriorityCounts(counts: Record): string { return `P0: ${counts.P0} P1: ${counts.P1} P2: ${counts.P2} P3: ${counts.P3}`; } export { formatDuration } from "@/lib/format"; function extractSystemEntry(session: SessionStats): SystemEntry | undefined { for (const entry of session.entries) { if (entry.type === "system") { return entry as SystemEntry; } } return undefined; } export interface SessionJson { sessionId?: string; project: string; branch?: string; status: DerivedRunStatus; sessionStatus?: SessionStats["sessionStatus"]; phase?: SessionStats["phase"]; reviewOutcome?: SessionStats["reviewOutcome"]; timestamp: number; iterations: number; duration?: number; reviewer?: AgentSettings; fixer?: AgentSettings; handoffStatus?: HandoffStatus; handoffUpdatedAt?: number; commitSha?: string; summary: { totalFixes: number; totalSkipped: number; priorityCounts: Record; totalFindings?: number; totalSelectedFindings?: number; totalResolvedSelectedFindings?: number; totalUnresolvedSelectedFindings?: number; }; fixes: FixEntry[]; skipped: SkippedEntry[]; } export interface ProjectSessionsJson { project: string; sessions: SessionJson[]; } export interface GlobalSessionsJson { sessions: SessionJson[]; } export function buildSessionJson( projectName: string, session: SessionStats, fixes: FixEntry[], skipped: SkippedEntry[] ): SessionJson { const systemEntry = extractSystemEntry(session); return { sessionId: session.sessionId, project: projectName, branch: session.gitBranch, status: session.status, sessionStatus: session.sessionStatus, phase: session.phase, reviewOutcome: session.reviewOutcome, timestamp: session.timestamp, iterations: session.iterations, duration: session.totalDuration, reviewer: systemEntry?.reviewer, fixer: systemEntry?.fixer, handoffStatus: session.handoffStatus, handoffUpdatedAt: session.handoffUpdatedAt, commitSha: session.commitSha, summary: { totalFixes: session.totalFixes, totalSkipped: session.totalSkipped, priorityCounts: session.priorityCounts, totalFindings: session.totalFindings, totalSelectedFindings: session.totalSelectedFindings, totalResolvedSelectedFindings: session.totalResolvedSelectedFindings, totalUnresolvedSelectedFindings: session.totalUnresolvedSelectedFindings, }, fixes, skipped, }; } export function buildProjectSessionsJson( projectName: string, sessions: SessionStats[] ): ProjectSessionsJson { const sessionJsons = sessions.map((session) => { const { fixes, skipped } = extractFixesAndSkipped(session); return buildSessionJson(projectName, session, fixes, skipped); }); return { project: projectName, sessions: sessionJsons, }; } export function buildGlobalSessionsJson(sessions: SessionStats[]): GlobalSessionsJson { const sessionJsons = sessions.map((session) => { const systemEntry = extractSystemEntry(session); const projectName = systemEntry?.projectPath ? getProjectName(systemEntry.projectPath) : "unknown"; const { fixes, skipped } = extractFixesAndSkipped(session); return buildSessionJson(projectName, session, fixes, skipped); }); return { sessions: sessionJsons, }; } function extractFixesAndSkipped(session: SessionStats): { fixes: FixEntry[]; skipped: SkippedEntry[]; } { const fixes: FixEntry[] = []; const skipped: SkippedEntry[] = []; for (const entry of session.entries) { if (entry.type === "iteration") { const iterEntry = entry as IterationEntry; if (iterEntry.fixes) { fixes.push(...iterEntry.fixes.fixes); skipped.push(...iterEntry.fixes.skipped); } } } return { fixes, skipped }; } function formatStatusWithIcon(status: DerivedRunStatus): string { return status; } function hasBatchFirstSummary(session: SessionStats): boolean { return ( session.totalFindings !== undefined || session.totalSelectedFindings !== undefined || session.totalResolvedSelectedFindings !== undefined || session.totalUnresolvedSelectedFindings !== undefined || session.reviewOutcome === "findings-pending" ); } function formatAgent(settings: AgentSettings): string { return settings.model ? `${settings.agent} (${settings.model})` : settings.agent; } function formatHandoffSummary( handoffStatus: HandoffStatus | undefined, commitSha: string | undefined ): string | null { if (!handoffStatus) { return null; } return commitSha ? `Handoff: ${handoffStatus} · ${commitSha}` : `Handoff: ${handoffStatus}`; } function renderTerminalSession( projectName: string, session: SessionStats, fixes: FixEntry[], skipped: SkippedEntry[], index?: number, total?: number ): void { const branch = session.gitBranch ?? "no branch"; const statusDisplay = formatStatusWithIcon(session.status); const systemEntry = extractSystemEntry(session); const sessionLabel = index !== undefined && total !== undefined && total > 1 ? `Review Session Log (${index} of ${total})` : "Review Session Log"; p.intro(sessionLabel); p.log.info(`Project: ${projectName}`); p.log.info(`Branch: ${branch}`); p.log.info(`Status: ${statusDisplay}`); if (session.phase || session.sessionStatus || session.reviewOutcome) { const workflowPhase = session.phase ?? "unknown"; const workflowStatus = session.sessionStatus ?? "unknown"; const workflowOutcome = session.reviewOutcome ?? "unknown"; p.log.info(`Workflow: ${workflowPhase} · ${workflowStatus} · ${workflowOutcome}`); } p.log.info(`Time: ${formatDate(session.timestamp)}`); if (session.totalDuration !== undefined) { p.log.info(`Duration: ${formatDuration(session.totalDuration)}`); } if (systemEntry) { p.log.info(`Reviewer: ${formatAgent(systemEntry.reviewer)}`); p.log.info(`Fixer: ${formatAgent(systemEntry.fixer)}`); } p.log.message(""); if (hasBatchFirstSummary(session)) { p.log.step(`Review: ${session.iterations} iterations · ${session.totalFindings ?? 0} findings`); p.log.message(formatPriorityCounts(session.priorityCounts)); if (session.totalSelectedFindings !== undefined) { p.log.message(`Selection: ${session.totalSelectedFindings} selected`); } if ( session.totalResolvedSelectedFindings !== undefined || session.totalUnresolvedSelectedFindings !== undefined ) { p.log.message( `Remediation: ${session.totalResolvedSelectedFindings ?? 0} resolved · ${session.totalUnresolvedSelectedFindings ?? 0} unresolved` ); } } else { p.log.step( `${session.iterations} iterations · ${session.totalFixes} fixes · ${session.totalSkipped} skipped` ); p.log.message(formatPriorityCounts(session.priorityCounts)); } const handoffSummary = formatHandoffSummary(session.handoffStatus, session.commitSha); if (handoffSummary) { p.log.message(handoffSummary); } if (session.reviewOutcome === "findings-pending") { const command = session.sessionId !== undefined ? `rr fix --session ${session.sessionId}` : "rr fix --session "; p.log.message(`Pending findings: run ${command}`); } if (fixes.length > 0) { p.log.message(""); p.log.step(`Fixes (${fixes.length})`); for (const fix of fixes) { const file = fix.file ? ` ${fix.file}` : ""; p.log.message(`${fix.priority} ${fix.title}${file}`); } } else if ( session.totalFixes === 0 && session.status === "completed" && session.reviewOutcome !== "findings-pending" ) { p.log.message(""); p.log.success("No issues found - code is clean!"); } if (skipped.length > 0) { p.log.message(""); p.log.step(`Skipped (${skipped.length})`); for (const item of skipped) { p.log.message(` ${item.title} - ${item.reason}`); } } p.outro(""); } export async function runLog(args: string[]): Promise { const logDef = getCommandDef("log"); if (!logDef) { p.log.error("Internal error: log command definition not found"); process.exit(1); } let options: LogsOptions; try { const result = parseCommand(logDef, args); options = result.values; } catch (error) { p.log.error(`${error}`); process.exit(1); } if (options.global && !options.json) { p.log.error("--global requires --json"); process.exit(1); } if (options.last !== undefined && options.last <= 0) { p.log.error("-n/--last must be a positive number"); process.exit(1); } if (options.json && options.global) { const allLogSessions = await listLogSessions(CONFIG_DIR); if (allLogSessions.length === 0) { console.log(JSON.stringify({ sessions: [] }, null, 2)); return; } const sessionStats = await Promise.all(allLogSessions.map(computeSessionStats)); const activeSessions = await listAllActiveSessions(CONFIG_DIR); markSessionStatsRunning(sessionStats, activeSessions); const filtered = sessionStats.filter((session) => !isUnknownEmptySession(session)); const jsonOutput = buildGlobalSessionsJson(filtered); console.log(JSON.stringify(jsonOutput, null, 2)); return; } const currentProjectPath = process.cwd(); const projectName = getProjectName(currentProjectPath); const projectSessions = await listProjectLogSessions(CONFIG_DIR, currentProjectPath); if (projectSessions.length === 0) { printNoProjectSessions(projectName, options.json); return; } const limit = options.last ?? 1; // Compute stats for enough sessions to potentially fill the limit after filtering // We process more than needed since some may be filtered out const maxToProcess = Math.min(projectSessions.length, limit * 2 + 5); const allStats: SessionStats[] = []; for (let i = 0; i < maxToProcess; i++) { const session = projectSessions[i]; if (session) { allStats.push(await computeSessionStats(session)); } } // Mark running sessions BEFORE filtering const activeSessions = await listAllActiveSessions(CONFIG_DIR); markSessionStatsRunning(allStats, activeSessions); // Now filter and apply limit const sessionStats: SessionStats[] = []; for (const stats of allStats) { if (isUnknownEmptySession(stats)) { continue; } sessionStats.push(stats); if (sessionStats.length >= limit) { break; } } if (sessionStats.length === 0) { printNoProjectSessions(projectName, options.json); return; } if (options.json) { const jsonOutput = buildProjectSessionsJson(projectName, sessionStats); console.log(JSON.stringify(jsonOutput, null, 2)); return; } const total = sessionStats.length; for (let i = 0; i < sessionStats.length; i++) { const session = sessionStats[i]; if (!session) continue; const { fixes, skipped } = extractFixesAndSkipped(session); renderTerminalSession(projectName, session, fixes, skipped, i + 1, total); } }