diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 5d0ffcea..d8c94a38 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -6,7 +6,7 @@ import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, typ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' -import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI } from './parser.js' +import { parseAllSessions, filterProjectsByDateRange, filterProjectsByName, setInteractiveScanUI, withSinglePassParse } from './parser.js' import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.js' import { aggregateModelTotals } from './model-breakdown.js' import { buildDurablePeriod } from './usage-aggregator.js' @@ -307,6 +307,10 @@ export type DurableOverview = { carriedCostUSD: number } +function getDurableRange(period: Period, customRange: DateRange | null | undefined, day: string | null): DateRange { + return day ? getDayRange(day) : customRange ?? getPeriodRange(period) +} + async function computeDurableOverview( period: Period, provider: string, @@ -315,7 +319,7 @@ async function computeDurableOverview( customRange: DateRange | null | undefined, day: string | null, ): Promise { - const range = day ? getDayRange(day) : customRange ?? getPeriodRange(period) + const range = getDurableRange(period, customRange, day) const { data, carriedCostUSD } = await buildDurablePeriod( { range, label: PERIOD_LABELS[period] }, { provider, project: projectFilter ?? [], exclude: excludeFilter ?? [] }, @@ -1867,6 +1871,42 @@ function StaticDashboard({ projects, period, activeProvider, planUsages, label, ) } +/// The initial paint's data, assembled under one declared parse scope. +/// +/// The scan parse, the plan window and the durable headline each ran the whole +/// pipeline: three ranges that often share a start and differ only in where +/// they end (end-of-day vs each caller's own `new Date()`), which the exact-key +/// memo cannot match. Declaring the widest of them lets `withSinglePassParse` +/// serve the rest by slicing it — but only the ones that are a pure narrowing, +/// so a range that genuinely needs its own file set (a plan window starting +/// before the scan range, a past `--day`) still parses on its own. +async function assembleDashboardData( + period: Period, + provider: string, + projectFilter: string[] | undefined, + excludeFilter: string[] | undefined, + customRange: DateRange | null | undefined, + initialDay: string | null, + scrollableDailyHistory: boolean, +): Promise<{ scannedProjects: ProjectSummary[]; filteredProjects: ProjectSummary[]; planUsages: PlanUsage[]; initialDurable: DurableOverview }> { + const range = getDashboardScanRange(period, customRange, initialDay, scrollableDailyHistory) + const durableRange = getDurableRange(period, customRange, initialDay) + const superset: DateRange = { + start: new Date(Math.min(range.start.getTime(), durableRange.start.getTime())), + end: new Date(Math.max(range.end.getTime(), durableRange.end.getTime())), + } + return withSinglePassParse(superset, async () => { + const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter) + const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory) + const planUsages = await getPlanUsages() + // Durable headline totals for the initial paint (carry-forward cache + today), + // matching the menubar/report. The interactive tree recomputes this on every + // period/provider/refresh change; the static one-shot render uses just this. + const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay) + return { scannedProjects, filteredProjects, planUsages, initialDurable } + }) +} + export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise { // Interactive Ink UI: it renders to the same terminal and has its own in-frame // loading state, so the CLI scan-progress line must stay silent for its whole @@ -1878,13 +1918,8 @@ export async function renderDashboard(period: Period = 'week', provider: string const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY) const scrollableDailyHistory = isTTY && dayRange == null && customRange == null const range = getDashboardScanRange(period, customRange, initialDay ?? null, scrollableDailyHistory) - const scannedProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter) - const filteredProjects = selectDashboardPeriodProjects(scannedProjects, period, scrollableDailyHistory) - const planUsages = await getPlanUsages() - // Durable headline totals for the initial paint (carry-forward cache + today), - // matching the menubar/report. The interactive tree recomputes this on every - // period/provider/refresh change; the static one-shot render uses just this. - const initialDurable = await computeDurableOverview(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null) + const { scannedProjects, filteredProjects, planUsages, initialDurable } = + await assembleDashboardData(period, provider, projectFilter, excludeFilter, customRange, initialDay ?? null, scrollableDailyHistory) const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel patchStdoutForWindows() if (isTTY) { diff --git a/src/parser.ts b/src/parser.ts index 3d4860eb..a35af849 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -4213,6 +4213,7 @@ function cacheKey(dateRange: DateRange | undefined, providerFilter: string | und export function clearSessionCache(): void { sessionCache.clear() canonicalPathCache.clear() + singlePassScope?.parses.clear() } function cachePut(key: string, data: ProjectSummary[], parseStartedAt: number) { @@ -4499,14 +4500,24 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo // mutating the child. This lets a Codex/Gemini/etc. review launched inside a // Claude subagent inherit the parent turn's PR while the subagent itself still // folds exactly once under the existing accounting model. + // + // Indexed once rather than re-filtered per child: the loop below only writes + // to `evidence`, so the unlinked set is fixed for its whole duration. + const unlinkedByAgentId = new Map() + for (const s of sessions) { + if (s.prLinks?.length) continue + const bucket = unlinkedByAgentId.get(s.agentId) + if (bucket) bucket.push(s) + else unlinkedByAgentId.set(s.agentId, [s]) + } for (const resolved of resolveSubagentAttribution(projects).values()) { for (const child of resolved) { // A multi-PR spawn set is valid for folding the child's own cost, but is // too broad to identify which PR an independently saved nested review was // about. Require one PR for cross-provider propagation. if (child.unlinked || child.prSet?.length !== 1) continue - const matches = sessions.filter(s => !s.prLinks?.length && s.agentId === child.fold.agentId) - if (matches.length === 1) evidence.set(matches[0]!, child.prSet) + const matches = unlinkedByAgentId.get(child.fold.agentId) + if (matches?.length === 1) evidence.set(matches[0]!, child.prSet) } } @@ -4536,6 +4547,21 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo const PROMPT_PREFIX = 160 const PROMPT_MIN = 80 const LAUNCH_WINDOW_MS = 15 * 60 * 1000 + // Sorted once so each candidate scans only the launches inside its own + // window instead of the whole array. Launch order is not observable: the + // match set is collapsed into a Map keyed by the sorted ref list, and + // assignCorrelatedPrs re-sorts what it is handed. + launches.sort((a, b) => a.atMs - b.atMs) + const firstLaunchAtOrAfter = (atMs: number): number => { + let lo = 0 + let hi = launches.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (launches[mid]!.atMs < atMs) lo = mid + 1 + else hi = mid + } + return lo + } for (const session of candidates) { const provider = summaryProvider(session) const prompt = session.turns @@ -4545,12 +4571,14 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo const prefix = prompt.slice(0, PROMPT_PREFIX) const startedMs = Date.parse(session.firstTimestamp) if (!Number.isFinite(startedMs)) continue - const matches = launches.filter(launch => - launch.provider !== provider - && Math.abs(launch.atMs - startedMs) <= LAUNCH_WINDOW_MS - && launch.commands.some(command => command.includes(prefix)) - ) - const refSets = new Map(matches.map(m => [m.refs.slice().sort().join('\0'), m.refs])) + const refSets = new Map() + for (let i = firstLaunchAtOrAfter(startedMs - LAUNCH_WINDOW_MS); i < launches.length; i++) { + const launch = launches[i]! + if (launch.atMs - startedMs > LAUNCH_WINDOW_MS) break + if (launch.provider === provider) continue + if (!launch.commands.some(command => command.includes(prefix))) continue + refSets.set(launch.refs.slice().sort().join('\0'), launch.refs) + } if (refSets.size === 1) { assignCorrelatedPrs(session, [...refSets.values()][0]!, 'launcher-prompt') if (session.prLinks?.length) evidence.set(session, session.prLinks) @@ -4677,7 +4705,65 @@ let readOnlyServedStale = false // new data, so the run must not report hydration complete even in write mode. let deferredRetryableSource = false +// One command invocation that renders a dashboard asks for several ranges that +// differ only in where they END — the scan range runs to end-of-day, the +// durable headline re-anchors on its own `new Date()`. The exact-key memo needs +// both endpoints equal, so it never hits and each ran the whole pipeline again. +// Inside this scope the declared range is parsed ONCE per provider filter and a +// request that is a pure NARROWING of it is served by slicing that result. +// Anything else parses normally. +// +// Pure narrowing means: same start, an end that is inside, and the same month +// shard scope. All three are load-bearing, because a parse's file set is a +// function of its range, not just its output: +// - a CHANGED file with `mtimeMs < range.start` is skipped without being +// parsed, so an earlier start pulls in files a later start never reads; +// - `loadCache` reads only the shards `monthScopeForRange` selects, so a wider +// month span hands the query loop cached files a narrower span never sees; +// - either way those extra files seed `seenKeys` / `seenMsgIds` BEFORE the +// range slice runs, and a seeded key SUPPRESSES the matching in-range turn +// in a provider parsed later — usage the narrower parse would have counted. +// Holding start and month scope equal makes both parses see an identical file +// set in an identical order, which leaves the range slice as the only +// difference — applied after the parse instead of during it, as burstReuse +// already does for the mirror-image case (same start, LATER end). +type SinglePassScope = { range: DateRange; parses: Map> } +let singlePassScope: SinglePassScope | null = null + +export async function withSinglePassParse(range: DateRange, fn: () => Promise): Promise { + const outer = singlePassScope + singlePassScope = { range, parses: new Map() } + try { + return await fn() + } finally { + singlePassScope = outer + } +} + +function singlePassParse(dateRange: DateRange | undefined, providerFilter: string | undefined): Promise | null { + const scope = singlePassScope + if (!scope || !dateRange) return null + if (dateRange.start.getTime() !== scope.range.start.getTime()) return null + if (dateRange.end.getTime() > scope.range.end.getTime()) return null + const wide = monthScopeForRange(scope.range.start, scope.range.end) + const narrow = monthScopeForRange(dateRange.start, dateRange.end) + if (wide.fromMonth !== narrow.fromMonth || wide.toMonth !== narrow.toMonth) return null + const key = providerFilter ?? 'all' + let parsed = scope.parses.get(key) + if (!parsed) { + const codexCacheDir = getCodeburnCacheDir() + parsed = withCodexCacheDirectory(codexCacheDir, () => parseAllSessionsInCacheScope(scope.range, providerFilter)) + scope.parses.set(key, parsed) + } + // The declared range itself is served verbatim, exactly as an unscoped run + // would have produced it; only a strictly narrower end pays for a slice. + if (dateRange.end.getTime() === scope.range.end.getTime()) return parsed + return parsed.then(projects => filterProjectsByDateRange(projects, dateRange)) +} + export function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { + const scoped = singlePassParse(dateRange, providerFilter) + if (scoped) return scoped // Capture synchronously, before the first await. AsyncLocalStorage keeps all // Codex cache reads, dirty writes, and the final flush on this call-time // directory even if an embedding host changes the process env mid-parse. diff --git a/tests/single-pass-parse.test.ts b/tests/single-pass-parse.test.ts new file mode 100644 index 00000000..d3157f0e --- /dev/null +++ b/tests/single-pass-parse.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +// Counts how many times the parse pipeline actually runs. A scope hit must +// serve from an earlier run rather than starting another one. +let discoveries = 0 +vi.mock('../src/providers/index.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + discoverAllSessions: (...args: Parameters) => { + discoveries++ + return actual.discoverAllSessions(...args) + }, + } +}) + +import { parseAllSessions, clearSessionCache, withSinglePassParse } from '../src/parser.js' +import type { DateRange, ProjectSummary } from '../src/types.js' + +const CWD = '/tmp/single-pass-proj' +let tmpDir: string + +const userLine = (ts: string, text: string) => JSON.stringify({ + type: 'user', sessionId: 'sess-1', timestamp: ts, cwd: CWD, + message: { role: 'user', content: text }, +}) +const asstLine = (id: string, ts: string, outputTokens: number) => JSON.stringify({ + type: 'assistant', sessionId: 'sess-1', timestamp: ts, cwd: CWD, + message: { id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 100, output_tokens: outputTokens } }, +}) + +// Three turns on the same day: 09:00, 12:00 and 21:00 UTC. A range that ends at +// midday must keep the first two and drop the third, whether it was applied +// during the parse or as a slice afterwards. +const DAY = '2026-08-20' +const range = (startIso: string, endIso: string): DateRange => ({ start: new Date(startIso), end: new Date(endIso) }) +const DAY_START = `${DAY}T00:00:00.000Z` +const DAY_END = `${DAY}T23:59:59.999Z` +const MIDDAY = `${DAY}T12:30:00.000Z` + +const shape = (projects: ProjectSummary[]) => projects.map(p => ({ + project: p.project, + cost: p.totalCostUSD, + calls: p.totalApiCalls, + turns: p.sessions.flatMap(s => s.turns.map(t => t.timestamp)).sort(), +})) + +beforeEach(async () => { + clearSessionCache() + discoveries = 0 + tmpDir = await mkdtemp(join(tmpdir(), 'single-pass-')) + const projectDir = join(tmpDir, 'projects', 'single-pass-proj') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'sess-1.jsonl'), [ + userLine(`${DAY}T09:00:00.000Z`, 'morning task'), + asstLine('msg-a', `${DAY}T09:00:01.000Z`, 20), + userLine(`${DAY}T12:00:00.000Z`, 'noon task'), + asstLine('msg-b', `${DAY}T12:00:01.000Z`, 30), + userLine(`${DAY}T21:00:00.000Z`, 'evening task'), + asstLine('msg-c', `${DAY}T21:00:01.000Z`, 40), + ].join('\n') + '\n') + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions') +}) + +afterEach(async () => { + clearSessionCache() + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CODEBURN_CACHE_DIR'] + delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('withSinglePassParse', () => { + it('serves a narrower end from the declared parse, matching an unscoped parse of that range', async () => { + const unscoped = shape(await parseAllSessions(range(DAY_START, MIDDAY))) + clearSessionCache() + discoveries = 0 + + const { wide, narrow } = await withSinglePassParse(range(DAY_START, DAY_END), async () => ({ + wide: shape(await parseAllSessions(range(DAY_START, DAY_END))), + narrow: shape(await parseAllSessions(range(DAY_START, MIDDAY))), + })) + + expect(discoveries).toBe(1) + expect(narrow).toEqual(unscoped) + // Non-vacuous: the narrow view really is a strict subset of the wide one. + expect(narrow[0]!.turns).toEqual([`${DAY}T09:00:00.000Z`, `${DAY}T12:00:00.000Z`]) + expect(wide[0]!.turns).toHaveLength(3) + expect(narrow[0]!.cost).toBeLessThan(wide[0]!.cost) + }) + + it('re-parses when the start differs, because a start also decides which files are read', async () => { + await withSinglePassParse(range(DAY_START, DAY_END), async () => { + await parseAllSessions(range(DAY_START, DAY_END)) + await parseAllSessions(range(`${DAY}T10:00:00.000Z`, MIDDAY)) + }) + expect(discoveries).toBe(2) + }) + + it('re-parses when the declared end lands in a later month than the request', async () => { + // Local end-of-day on the last of a month is next month in UTC, which is + // what monthScopeForRange keys on: the two loads would read different shards. + const start = '2026-08-31T00:00:00.000Z' + await withSinglePassParse(range(start, '2026-09-01T00:30:00.000Z'), async () => { + await parseAllSessions(range(start, '2026-09-01T00:30:00.000Z')) + await parseAllSessions(range(start, '2026-08-31T23:00:00.000Z')) + }) + expect(discoveries).toBe(2) + }) + + it('leaves a request that reaches past the declared end alone', async () => { + await withSinglePassParse(range(DAY_START, MIDDAY), async () => { + await parseAllSessions(range(DAY_START, MIDDAY)) + await parseAllSessions(range(DAY_START, DAY_END)) + }) + expect(discoveries).toBe(2) + }) + + it('scopes nothing outside the callback', async () => { + await withSinglePassParse(range(DAY_START, DAY_END), async () => { + await parseAllSessions(range(DAY_START, DAY_END)) + }) + clearSessionCache() + discoveries = 0 + await parseAllSessions(range(DAY_START, MIDDAY)) + expect(discoveries).toBe(1) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 0fa9f58b..7e90401c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { + // A worktree under .claude/ carries its own tests/ copy. They are stale by + // definition (another branch's checkout) and are not this run's subject. + exclude: ['**/node_modules/**', '**/dist/**', '**/.claude/worktrees/**'], // Runs once per worker before any test. Scrubs the developer's shell so // session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every // provider-specific *_HOME) don't bleed real local data into fixtures.