From 5c7ff78a89cb58d04fbd706414d1f8bd50dd4e95 Mon Sep 17 00:00:00 2001 From: reviewer Date: Wed, 22 Jul 2026 01:30:52 +0200 Subject: [PATCH] perf(desktop): warm reports and cache compare scans --- app/README.md | 2 +- app/electron/cli.test.ts | 27 +++++- app/electron/cli.ts | 7 +- app/electron/main.test.ts | 27 ++++-- app/electron/main.ts | 19 ++-- app/electron/preload.ts | 10 +-- app/renderer/App.tsx | 67 +++++++++++++- app/renderer/hooks/usePolled.test.ts | 17 +++- app/renderer/hooks/usePolled.ts | 52 ++++++++--- app/renderer/lib/reportKeys.ts | 29 ++++++ app/renderer/lib/types.ts | 10 +-- app/renderer/sections/Compare.tsx | 3 +- app/renderer/sections/Models.tsx | 3 +- app/renderer/sections/Optimize.test.tsx | 4 +- app/renderer/sections/Optimize.tsx | 26 ++++-- app/renderer/sections/Overview.tsx | 38 +++++++- app/renderer/sections/Sessions.tsx | 3 +- app/renderer/sections/Spend.tsx | 3 +- src/compare-stats.ts | 112 +++++++++++++++++++++--- src/main.ts | 22 +++-- tests/compare-stats.test.ts | 21 +++++ 21 files changed, 418 insertions(+), 84 deletions(-) create mode 100644 app/renderer/lib/reportKeys.ts diff --git a/app/README.md b/app/README.md index c59af88..4916d05 100644 --- a/app/README.md +++ b/app/README.md @@ -32,7 +32,7 @@ This follows the menubar pattern: Current bridge calls: -- Overview: `codeburn status --format menubar-json --period [--provider ]` +- Overview: `codeburn status --format menubar-json --period --no-timeline --no-optimize [--provider ]` - Plans: `codeburn status --format json --period ` - Models: `codeburn models --format json --period [--provider ] [--by-task]` - Optimize: `codeburn yield --format json --period ` diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index e95bc93..138cdcc 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -456,9 +456,9 @@ describe('spawnCli concurrency scheduler', () => { const releaseDir = join(dir, 'release'); mkdirSync(releaseDir) schedulerBin(startedFile, releaseDir) - // Fill both slots so the next two calls must queue. - const p1 = spawnCli(['fill1'], { priority: 'background' }) - const p2 = spawnCli(['fill2'], { priority: 'background' }) + // Fill both slots with real interactive work so the next two calls queue. + const p1 = spawnCli(['fill1'], { priority: 'interactive' }) + const p2 = spawnCli(['fill2'], { priority: 'interactive' }) await waitUntil(() => startedList(startedFile).length === 2) // Queue a background first, then an interactive. @@ -478,6 +478,27 @@ describe('spawnCli concurrency scheduler', () => { await Promise.all([p1, p2, pbg, pint]) }) + it('reserves one CLI lane while background prefetch is running', async () => { + const startedFile = join(dir, 'started') + const releaseDir = join(dir, 'release'); mkdirSync(releaseDir) + schedulerBin(startedFile, releaseDir) + + const bg1 = spawnCli(['bg1'], { priority: 'background' }) + const bg2 = spawnCli(['bg2'], { priority: 'background' }) + await waitUntil(() => startedList(startedFile).includes('bg1')) + await delay(50) + expect(startedList(startedFile)).toEqual(['bg1']) + + const interactive = spawnCli(['clicked'], { priority: 'interactive' }) + await waitUntil(() => startedList(startedFile).includes('clicked')) + expect(startedList(startedFile)).not.toContain('bg2') + + release(releaseDir, 'bg1'); release(releaseDir, 'clicked') + await waitUntil(() => startedList(startedFile).includes('bg2')) + release(releaseDir, 'bg2') + await Promise.all([bg1, bg2, interactive]) + }) + it('does not spend a slot on a coalesced (same-argv) call', async () => { const startedFile = join(dir, 'started') const releaseDir = join(dir, 'release'); mkdirSync(releaseDir) diff --git a/app/electron/cli.ts b/app/electron/cli.ts index 30efddc..467454c 100644 --- a/app/electron/cli.ts +++ b/app/electron/cli.ts @@ -77,10 +77,13 @@ let running = 0 const interactiveQueue: SlotWaiter[] = [] const backgroundQueue: SlotWaiter[] = [] -/** Grant free slots to queued waiters, interactive first, up to the cap. */ +/** Grant free slots to queued waiters, interactive first, up to the cap. + * Background warmups intentionally use at most MAX-1 slots so speculative tab + * prefetch can never occupy every CLI lane; a click always has one lane ready. */ function pumpSlots(): void { while (running < MAX_CONCURRENT_CLI) { - const waiter = interactiveQueue.shift() ?? backgroundQueue.shift() + const interactive = interactiveQueue.shift() + const waiter = interactive ?? (running < MAX_CONCURRENT_CLI - 1 ? backgroundQueue.shift() : undefined) if (!waiter) return running += 1 waiter.resolve() diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 30dfe17..5374816 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -72,8 +72,8 @@ const CHANNELS = [ ] as const const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [ - { channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] }, - { channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'claude'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--provider', 'claude'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'all'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize'] }, { channel: 'codeburn:getPlans', args: ['week'], argv: ['status', '--format', 'json', '--period', 'week'] }, { channel: 'codeburn:getActReport', args: [], argv: ['act', 'report', '--json'] }, { channel: 'codeburn:getModels', args: ['week', 'claude', true], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task'] }, @@ -86,9 +86,9 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = { channel: 'codeburn:getYield', args: ['today', 'claude'], argv: ['yield', '--format', 'json', '--period', 'today', '--provider', 'claude'] }, { channel: 'codeburn:getSpendFlow', args: ['month', 'openai'], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--provider', 'openai'] }, { channel: 'codeburn:getOptimizeReport', args: ['month', 'openai'], argv: ['optimize', '--format', 'json', '--period', 'month', '--provider', 'openai'] }, - { channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] }, - { channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] }, - { channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--from', '2026-07-01', '--to', '2026-07-11'] }, + { channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize', '--claude-config-source', 'claude-config:91dda17e8cf35193'] }, + { channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--no-optimize', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] }, { channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] }, { channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] }, @@ -163,7 +163,7 @@ describe('createBridgeHandlers (IPC wiring)', () => { const { spawnCli, spawnCliAction, calls } = fakeSpawn() const handlers = createBridgeHandlers(withQuota({ spawnCli, spawnCliAction, resolveCodeburnPath: () => '/bin/codeburn' })) const res = await handlers['codeburn:getOverview']!('30days', 'all') - expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline']) + expect(calls[0]).toEqual(['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--no-optimize']) expect(res).toEqual({ ok: true, value: { current: { cost: 12.34 } } }) }) @@ -443,6 +443,21 @@ describe('createBridgeHandlers (cold-start warmup)', () => { expect(opts[2]?.priority).toBe('background') }) + it('runs section prefetch reads at background priority without changing argv', async () => { + const opts: Array | undefined> = [] + const spawnCli = vi.fn(async (_args: string[], o?: Record) => { opts.push(o); return [] }) + const handlers = createBridgeHandlers(base({ spawnCli })) + + await handlers['codeburn:getSessions']!('30days', 'all', undefined, true) + await handlers['codeburn:getModels']!('30days', 'all', false, undefined, true) + await handlers['codeburn:getCompareModels']!('30days', 'all', true) + + expect(spawnCli.mock.calls[0]?.[0]).toEqual(['sessions', '--format', 'json', '--period', '30days']) + expect(spawnCli.mock.calls[1]?.[0]).toEqual(['models', '--format', 'json', '--period', '30days']) + expect(spawnCli.mock.calls[2]?.[0]).toEqual(['compare', '--format', 'json', '--period', '30days']) + expect(opts.map(o => o?.priority)).toEqual(['background', 'background', 'background']) + }) + it('re-arms the long timeout when the first overview fails (cache is still cold)', async () => { const opts: Array<{ timeoutMs?: number } | undefined> = [] let n = 0 diff --git a/app/electron/main.ts b/app/electron/main.ts index 82a95c0..f3c3f01 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -253,12 +253,15 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re telemetry?.track('cold_start', { ms: Date.now() - (coldStartBegan ?? Date.now()), timedOut }) } - const run = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => { + const run = (build: (...args: any[]) => string[], backgroundArg?: number): Handler => async (...args: any[]) => { let cmd: string | undefined try { const argv = build(...args) cmd = argv[0] - return { ok: true, value: await deps.spawnCli(argv) } + const priority: SpawnPriority | undefined = backgroundArg !== undefined && args[backgroundArg] === true + ? 'background' + : undefined + return { ok: true, value: await deps.spawnCli(argv, priority ? { priority } : undefined) } } catch (err) { const error = toEnvelopeError(err) telemetry?.track('cli_error', cliErrorProps(err, cmd)) @@ -270,7 +273,7 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re // --no-timeline (skips buildGranularHistory on every poll). The Swift menubar // omits the flag and keeps the timeline unchanged. const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [ - 'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline', + 'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline', '--no-optimize', ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)), ] @@ -319,13 +322,13 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re 'codeburn:getActReport': run(() => ['act', 'report', '--json']), 'codeburn:getModels': run((period: string, provider: string, byTask: boolean, range?: DateRange) => [ 'models', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...(byTask ? ['--by-task'] : []), ...rangeArgs(vRange(range)), - ]), + ], 4), 'codeburn:getSessions': run((period: string, provider: string, range?: DateRange) => [ 'sessions', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), - ]), + ], 3), 'codeburn:getCompareModels': run((period: string, provider: string) => [ 'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), - ]), + ], 2), 'codeburn:getCompare': run((period: string, provider: string, modelA: string, modelB: string) => [ 'compare', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), '--model-a', vToken(modelA), '--model-b', vToken(modelB), ]), @@ -334,10 +337,10 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re ]), 'codeburn:getSpendFlow': run((period: string, provider: string, range?: DateRange) => [ 'spend', '--format', 'flow-json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), - ]), + ], 3), 'codeburn:getOptimizeReport': run((period: string, provider: string, range?: DateRange) => [ 'optimize', '--format', 'json', '--period', vPeriod(period), ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), - ]), + ], 3), 'codeburn:getDevices': run((period: string) => ['devices', '--format', 'json', '--period', vPeriod(period)]), 'codeburn:getDevicesScan': run(() => ['devices', 'scan', '--format', 'json']), 'codeburn:getShareStatus': run(() => ['share', 'status', '--format', 'json']), diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 39426eb..8859dc6 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -23,13 +23,13 @@ const bridge = { getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background), getPlans: (period: string) => invoke('codeburn:getPlans', period), getActReport: () => invoke('codeburn:getActReport'), - getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range), - getSessions: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSessions', period, provider, range), - getCompareModels: (period: string, provider: string) => invoke('codeburn:getCompareModels', period, provider), + getModels: (period: string, provider: string, byTask: boolean, range?: DateRange, background?: boolean) => invoke('codeburn:getModels', period, provider, byTask, range, background), + getSessions: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getSessions', period, provider, range, background), + getCompareModels: (period: string, provider: string, background?: boolean) => invoke('codeburn:getCompareModels', period, provider, background), getCompare: (period: string, provider: string, modelA: string, modelB: string) => invoke('codeburn:getCompare', period, provider, modelA, modelB), getYield: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getYield', period, provider, range), - getSpendFlow: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getSpendFlow', period, provider, range), - getOptimizeReport: (period: string, provider: string, range?: DateRange) => invoke('codeburn:getOptimizeReport', period, provider, range), + getSpendFlow: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getSpendFlow', period, provider, range, background), + getOptimizeReport: (period: string, provider: string, range?: DateRange, background?: boolean) => invoke('codeburn:getOptimizeReport', period, provider, range, background), getDevices: (period: string) => invoke('codeburn:getDevices', period), getDevicesScan: () => invoke('codeburn:getDevicesScan'), getShareStatus: () => invoke('codeburn:getShareStatus'), diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 1540bd3..f69fc3c 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -17,6 +17,7 @@ import { formatCompact, formatUsd, setActiveCurrency } from './lib/format' import { motionClass } from './lib/motion' import { codeburn } from './lib/ipc' import { localDateKey } from './lib/period' +import { compareModelsReportKey, modelsReportKey, optimizeReportKey, sessionsReportKey, spendFlowReportKey } from './lib/reportKeys' import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence' import { OverviewContent } from './sections/Overview' import { OptimizeContent } from './sections/Optimize' @@ -141,13 +142,15 @@ const PREFETCH_START_DELAY_MS = 1500 // A warm spawn takes seconds, so a 400ms stagger let the loop fire the whole set // almost at once; pace it wide enough that each warm genuinely trails the last. const PREFETCH_STAGGER_MS = 2000 +const SECTION_PREFETCH_START_DELAY_MS = 500 +const SECTION_PREFETCH_STAGGER_MS = 100 // Base instant-switch memo keys live during overview polling besides the per- -// provider prefetch entries: `overview|all`, `overview-act`, `overview-yield`, -// plus one slot of headroom for section navigation. The memo cap is sized to +// provider prefetch entries plus the active provider's sessions, models, spend, +// optimize, yield, and compare-picker reports. The memo cap is sized to // (detected providers + this) so warmed entries — and the base overview key — // never LRU-evict between polls (which would blank the overview and re-arm the // prefetch every cycle). -const BASE_MEMO_KEYS = 4 +const BASE_MEMO_KEYS = 12 function isPeriod(value: string): value is Period { return (STANDARD_PERIODS as string[]).includes(value) @@ -281,7 +284,9 @@ function AppMain() { void (async () => { let modelCategories: Map | undefined try { - modelCategories = topCategoryByModel(await codeburn.getModels(period, 'all', false)) + const rows = await codeburn.getModels(period, 'all', false) + primePolledMemo(modelsReportKey(period, 'all', false), rows) + modelCategories = topCategoryByModel(rows) } catch { /* degrade: emit the snapshot without per-model topCategory */ } trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories)) })() @@ -393,6 +398,58 @@ function AppMain() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null]) + // Warm the first view of each heavyweight section after the overview paints. + // These run one at a time at background priority; the main-process scheduler + // reserves the second CLI lane for a real click. Results seed the same memo + // keys usePolled reads, so the first navigation paints immediately instead of + // showing a skeleton while a fresh process starts. + const warmedSectionKeys = useRef>(new Set()) + useEffect(() => { + if (!ready || overview.data == null || customRange || claudeConfigSource) return + const tasks: Array<{ key: string; fetch: () => Promise }> = [ + { + key: sessionsReportKey(period, provider), + fetch: () => codeburn.getSessions(period, provider, undefined, true), + }, + { + key: modelsReportKey(period, provider, false), + fetch: () => codeburn.getModels(period, provider, false, undefined, true), + }, + { + key: spendFlowReportKey(period, provider), + fetch: () => codeburn.getSpendFlow(period, provider, undefined, true), + }, + { + key: optimizeReportKey(period, provider), + fetch: () => codeburn.getOptimizeReport(period, provider, undefined, true), + }, + { + key: compareModelsReportKey(period, provider), + fetch: () => codeburn.getCompareModels(period, provider, true), + }, + ] + + let cancelled = false + const warm = async () => { + for (const task of tasks) { + if (cancelled) return + if (warmedSectionKeys.current.has(task.key) || hasPolledMemo(task.key)) continue + warmedSectionKeys.current.add(task.key) + try { + const value = await task.fetch() + if (!cancelled) primePolledMemo(task.key, value) + } catch { + // Best effort. Remove the guard so a later dependency change can retry; + // a real navigation still fetches and surfaces the structured error. + warmedSectionKeys.current.delete(task.key) + } + if (!cancelled) await new Promise(resolve => setTimeout(resolve, SECTION_PREFETCH_STAGGER_MS)) + } + } + const start = setTimeout(() => { void warm() }, SECTION_PREFETCH_START_DELAY_MS) + return () => { cancelled = true; clearTimeout(start) } + }, [ready, period, provider, customRange, claudeConfigSource, overview.data == null]) + useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000) return () => window.clearInterval(id) @@ -411,6 +468,8 @@ function AppMain() { // couple seconds (quick like the menubar) instead of at the next poll. const onConfigMutated = useCallback(() => { clearPolledMemo() + warmedKeys.current.clear() + warmedSectionKeys.current.clear() refreshVisible() }, [refreshVisible]) diff --git a/app/renderer/hooks/usePolled.test.ts b/app/renderer/hooks/usePolled.test.ts index 3d19ae3..9bca333 100644 --- a/app/renderer/hooks/usePolled.test.ts +++ b/app/renderer/hooks/usePolled.test.ts @@ -101,7 +101,7 @@ describe('usePolled', () => { // Mount on key kA, resolve to A0 → memoized under kA. const { result, rerender } = renderHook( - ({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k }), + ({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k, intervalMs: null }), { initialProps: { k: 'kA' } }, ) await act(async () => { resolvers[0]!('A0') }) @@ -126,6 +126,21 @@ describe('usePolled', () => { expect(result.current.switching).toBe(false) }) + it('does not refetch a fresh memoized result on remount, but manual refresh bypasses freshness', async () => { + primePolledMemo('fresh-tab', 'cached') + const fetcher = vi.fn().mockResolvedValue('fresh') + const { result } = renderHook(() => usePolled(fetcher, [], { memoKey: 'fresh-tab', intervalMs: 60_000 })) + + await act(async () => {}) + expect(result.current.data).toBe('cached') + expect(result.current.loading).toBe(false) + expect(fetcher).not.toHaveBeenCalled() + + await act(async () => { result.current.refresh() }) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(result.current.data).toBe('fresh') + }) + it('clears stale data on a switch to an unmemoized key (skeleton, never the prior filter)', async () => { const resolvers: Array<(v: string) => void> = [] const fetcher = vi.fn(() => new Promise(resolve => { resolvers.push(resolve) })) diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts index 6067e00..f3fc455 100644 --- a/app/renderer/hooks/usePolled.ts +++ b/app/renderer/hooks/usePolled.ts @@ -29,9 +29,10 @@ export type Polled = { // storm). The App raises it via setPolledMemoMax to (detected providers + base // keys); DEFAULT_MEMO_MAX is the floor for isolated hook/component tests. const DEFAULT_MEMO_MAX = 8 -const MEMO_MAX_CAP = 24 +const MEMO_MAX_CAP = 64 let memoMax = DEFAULT_MEMO_MAX -const memoStore = new Map() +type MemoEntry = { value: unknown; at: number } +const memoStore = new Map() /** Raise (or lower) the instant-switch memo cap so warmed entries survive between * polls. Clamped to [DEFAULT_MEMO_MAX, MEMO_MAX_CAP]; trims immediately if the @@ -46,18 +47,18 @@ export function setPolledMemoMax(n: number): void { } } -function memoGet(key: string): T | undefined { +function memoGet(key: string): { value: T; at: number } | undefined { if (!memoStore.has(key)) return undefined - const value = memoStore.get(key) as T + const entry = memoStore.get(key) as MemoEntry // Touch recency. memoStore.delete(key) - memoStore.set(key, value) - return value + memoStore.set(key, entry) + return { value: entry.value as T, at: entry.at } } -function memoSet(key: string, value: unknown): void { +function memoSet(key: string, value: unknown, at = Date.now()): void { if (memoStore.has(key)) memoStore.delete(key) - memoStore.set(key, value) + memoStore.set(key, { value, at }) while (memoStore.size > memoMax) { const oldest = memoStore.keys().next().value if (oldest === undefined) break @@ -120,11 +121,15 @@ export function usePolled( const intervalMs = opts.intervalMs !== undefined ? opts.intervalMs : cadence.intervalMs const enabled = opts.enabled ?? true const memoKey = opts.memoKey - const [data, setData] = useState(() => (memoKey ? memoGet(memoKey) ?? null : null)) + const [data, setData] = useState(() => (memoKey ? memoGet(memoKey)?.value ?? null : null)) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) const [switching, setSwitching] = useState(false) const [lastSuccessAt, setLastSuccessAt] = useState(null) + // Fresh memo data suppresses only the first load after a component remount. + // Later dependency changes (provider/period/config/refreshToken) must still + // revalidate, even when they switch to a recently-prefetched key. + const initialLoadRef = useRef(true) // Generation counter: every load() (mount, deps change, interval, refresh) // claims the next epoch; a fetch applies its result only while its epoch is // still current. This is what keeps a slow fetch from an older deps/period @@ -134,8 +139,10 @@ export function usePolled( // visibilitychange catch-up can read it without re-subscribing on every poll. const lastSuccessRef = useRef(null) - const load = useCallback(() => { + const load = useCallback((force = false) => { if (!enabled) return + const initialLoad = initialLoadRef.current + initialLoadRef.current = false const epoch = ++epochRef.current // Instant paint: on a deps/key change, if a last-good result for the new key // is cached, show it immediately and flag `switching` while the fresh fetch @@ -144,11 +151,30 @@ export function usePolled( // numbers. (An interval re-poll keeps the same key, whose last result is // always cached, so a background refresh never blanks.) let servedCached = false + let cachedAt: number | null = null if (memoKey) { const cached = memoGet(memoKey) - if (cached !== undefined) { setData(cached); servedCached = true } + if (cached !== undefined) { + setData(cached.value) + servedCached = true + cachedAt = cached.at + } else setData(null) } + // A tab remount or switch-back should not immediately launch an identical + // CLI process when its last-good result is still inside the configured + // refresh cadence. Manual cadence keeps memoized data until Refresh is + // explicitly clicked. Stale entries continue through the normal + // stale-while-revalidate path below. + const cachedIsFresh = cachedAt !== null && (intervalMs === null || Date.now() - cachedAt < intervalMs) + if (servedCached && initialLoad && !force && cachedIsFresh) { + setLoading(false) + setSwitching(false) + setError(null) + setLastSuccessAt(cachedAt) + lastSuccessRef.current = cachedAt + return + } setLoading(true) setSwitching(servedCached) // Clear any prior error at the start of each attempt so a fresh poll never @@ -177,7 +203,7 @@ export function usePolled( // `memoKey` are prepended so flipping the gate / key re-creates load and // fires immediately. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, memoKey, ...deps]) + }, [enabled, memoKey, intervalMs, ...deps]) useEffect(() => { load() @@ -212,7 +238,7 @@ export function usePolled( }, [load, intervalMs]) const refresh = useCallback(() => { - load() + load(true) }, [load]) return { data, error, loading, switching, lastSuccessAt, refresh } diff --git a/app/renderer/lib/reportKeys.ts b/app/renderer/lib/reportKeys.ts new file mode 100644 index 0000000..87529b3 --- /dev/null +++ b/app/renderer/lib/reportKeys.ts @@ -0,0 +1,29 @@ +import type { DateRange, Period } from './types' + +function rangeKey(range: DateRange | null | undefined): string { + return `${range?.from ?? ''}-${range?.to ?? ''}` +} + +export function sessionsReportKey(period: Period, provider: string, range?: DateRange | null): string { + return `sessions|${period}|${provider}|${rangeKey(range)}` +} + +export function modelsReportKey(period: Period, provider: string, byTask: boolean, range?: DateRange | null): string { + return `models|${period}|${provider}|${byTask}|${rangeKey(range)}` +} + +export function spendFlowReportKey(period: Period, provider: string, range?: DateRange | null): string { + return `spendflow|${period}|${provider}|${rangeKey(range)}` +} + +export function optimizeReportKey(period: Period, provider: string, range?: DateRange | null): string { + return `optimize|${period}|${provider}|${rangeKey(range)}` +} + +export function yieldReportKey(period: Period, provider: string, range?: DateRange | null): string { + return `yield|${period}|${provider}|${rangeKey(range)}` +} + +export function compareModelsReportKey(period: Period, provider: string): string { + return `comparemodels|${period}|${provider}` +} diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 14e2d80..3514a60 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -644,13 +644,13 @@ export interface CodeburnBridge { /** Node process.arch of the host ('arm64', 'x64', ...). Absent on preloads * that predate the direct-download update link. */ readonly arch?: string - getModels(period: Period, provider: string, byTask: boolean, range?: DateRange): Promise - getSessions(period: Period, provider: string, range?: DateRange): Promise - getCompareModels(period: Period, provider: string): Promise + getModels(period: Period, provider: string, byTask: boolean, range?: DateRange, background?: boolean): Promise + getSessions(period: Period, provider: string, range?: DateRange, background?: boolean): Promise + getCompareModels(period: Period, provider: string, background?: boolean): Promise getCompare(period: Period, provider: string, modelA: string, modelB: string): Promise getYield(period: Period, provider: string, range?: DateRange): Promise - getSpendFlow(period: Period, provider: string, range?: DateRange): Promise - getOptimizeReport(period: Period, provider: string, range?: DateRange): Promise + getSpendFlow(period: Period, provider: string, range?: DateRange, background?: boolean): Promise + getOptimizeReport(period: Period, provider: string, range?: DateRange, background?: boolean): Promise getDevices(period: Period): Promise getDevicesScan(): Promise getShareStatus(): Promise diff --git a/app/renderer/sections/Compare.tsx b/app/renderer/sections/Compare.tsx index dbcbc90..574f700 100644 --- a/app/renderer/sections/Compare.tsx +++ b/app/renderer/sections/Compare.tsx @@ -8,6 +8,7 @@ import { SectionSkeleton } from '../components/Skeleton' import { usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { compareModelsReportKey } from '../lib/reportKeys' import type { CompareJsonReport, ComparisonRow, DateRange, ModelStats, Period, WorkingStyleRow } from '../lib/types' function fmtMetric(v: number | null, fn: 'cost' | 'number' | 'percent' | 'decimal'): string { @@ -44,7 +45,7 @@ export function Compare({ const models = usePolled( () => codeburn.getCompareModels(period, provider), [period, provider, refreshToken], - { enabled: ready, memoKey: `comparemodels|${period}|${provider}` }, + { enabled: ready, memoKey: compareModelsReportKey(period, provider) }, ) const [modelA, setModelA] = useState(null) const [modelB, setModelB] = useState(null) diff --git a/app/renderer/sections/Models.tsx b/app/renderer/sections/Models.tsx index 730b7c9..ed2e306 100644 --- a/app/renderer/sections/Models.tsx +++ b/app/renderer/sections/Models.tsx @@ -11,6 +11,7 @@ import type { Section } from '../components/Sidebar' import { usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { modelsReportKey } from '../lib/reportKeys' import type { AuditRow, DateRange, ModelReportRow, Period } from '../lib/types' import type { SettingsPane } from './Settings' @@ -95,7 +96,7 @@ function ModelsUsage({ const report = usePolled( () => range ? codeburn.getModels(period, provider, byTask, range) : codeburn.getModels(period, provider, byTask), [period, provider, byTask, range?.from, range?.to, refreshToken], - { enabled: ready, memoKey: `models|${period}|${provider}|${byTask}|${range?.from ?? ''}-${range?.to ?? ''}` }, + { enabled: ready, memoKey: modelsReportKey(period, provider, byTask, range) }, ) if (!report.data) { diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index 5219afa..5c7ad7f 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -208,7 +208,7 @@ describe('Optimize', () => { expect(screen.getByText('No abandoned sessions in this range yet.')).toBeInTheDocument() }) - it('labels the Fixes tab with the rendered list length, not the menubar-wide findingCount', async () => { + it('labels the Fixes tab from the full optimize report', async () => { const payload = makePayload() // The menubar counts 25 findings, but the Fixes tab only renders topFindings. payload.optimize = { @@ -223,7 +223,7 @@ describe('Optimize', () => { render() - expect(await screen.findByRole('tab', { name: 'Fixes 2' })).toBeInTheDocument() + expect(await screen.findByRole('tab', { name: 'Fixes 3' })).toBeInTheDocument() expect(screen.queryByRole('tab', { name: 'Fixes 25' })).not.toBeInTheDocument() }) diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 03d8674..668c13d 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -9,6 +9,7 @@ import { StaleBanner } from '../components/StaleBanner' import { type Polled, usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { optimizeReportKey, yieldReportKey } from '../lib/reportKeys' import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types' type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes' @@ -41,12 +42,12 @@ export function OptimizeContent({ const optimizeReport = usePolled( () => range ? codeburn.getOptimizeReport(period, provider, range) : codeburn.getOptimizeReport(period, provider), [period, provider, range?.from, range?.to, refreshToken], - { enabled: ready, memoKey: `optimize|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` }, + { enabled: ready, memoKey: optimizeReportKey(period, provider, range) }, ) const yieldReport = usePolled( () => range ? codeburn.getYield(period, provider, range) : codeburn.getYield(period, provider), [period, provider, range?.from, range?.to, refreshToken], - { enabled: ready, memoKey: `optyield|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` }, + { enabled: ready, memoKey: yieldReportKey(period, provider, range) }, ) const [tab, setTab] = useState('waste') @@ -59,11 +60,11 @@ export function OptimizeContent({ const revertedTotal = yieldData ? formatUsd(yieldData.summary.reverted.costUSD) : '—' const abandonedTotal = yieldData ? formatUsd(yieldData.summary.abandoned.costUSD) : '—' const options = [ - { value: 'waste', label: `Waste ${formatUsd(overview.data.optimize.savingsUSD)}` }, + { value: 'waste', label: `Waste ${formatUsd(optimizeReport.data?.summary.potentialSavingsCostUSD ?? 0)}` }, { value: 'reverts', label: `Reverts ${revertedTotal}` }, { value: 'abandoned', label: `Abandoned ${abandonedTotal}` }, - // The Fixes tab renders topFindings (capped list), so label the count that shows. - { value: 'fixes', label: `Fixes ${overview.data.optimize.topFindings.length.toLocaleString('en-US')}` }, + // The Fixes tab renders the complete optimize report, so use its full count. + { value: 'fixes', label: `Fixes ${(optimizeReport.data?.summary.findingCount ?? 0).toLocaleString('en-US')}` }, ] return ( @@ -83,7 +84,7 @@ export function OptimizeContent({ ) : tab === 'abandoned' ? ( ) : ( - + )} @@ -234,6 +235,15 @@ function YieldRows({ ) } -function FixesRows({ data }: { data: MenubarPayload }) { - return +function FixesRows({ report }: { report: Polled }) { + if (!report.data) { + if (report.error) return + return Scanning optimize findings… + } + const findings: Finding[] = report.data.findings.map(finding => ({ + title: finding.title, + impact: finding.severity, + savingsUSD: finding.estimatedSavingsUSD, + })) + return } diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index 06cb9c6..c240fef 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -12,12 +12,14 @@ import { motionEnabled, useBarGrowIn } from '../lib/motion' import { type Polled, usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { optimizeReportKey, yieldReportKey } from '../lib/reportKeys' import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period' import type { ActReportJson, DailyHistoryEntry, DateRange, MenubarPayload, + OptimizeJsonReport, Period, YieldJsonReport, } from '../lib/types' @@ -669,8 +671,40 @@ export function OverviewContent({ // so the cold hydration runs once (via overview) rather than 3 parses at once // on boot. Defaults true so standalone renders/tests poll normally. const actReport = usePolled(() => codeburn.getActReport(), [], { enabled: ready, memoKey: 'overview-act' }) - const yieldReport = usePolled(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready, memoKey: `overview-yield|${period}|${provider}` }) - const { data, error } = overview + const yieldReport = usePolled(() => codeburn.getYield(period, provider), [period, provider], { enabled: ready, memoKey: yieldReportKey(period, provider) }) + const optimizeReport = usePolled( + async () => { + if (typeof codeburn.getOptimizeReport !== 'function') return null + const report = range + ? await codeburn.getOptimizeReport(period, provider, range) + : await codeburn.getOptimizeReport(period, provider) + // Some older preload/test bridges expose the method but return no value. + // Treat that as unavailable enrichment; the lightweight overview remains + // fully usable with the optimize fields already present in its payload. + return report ?? null + }, + [period, provider, range?.from, range?.to], + { enabled: ready, memoKey: optimizeReportKey(period, provider, range) }, + ) + const error = overview.error + // The headline status call intentionally skips optimize work so the first + // frame arrives quickly. Enrich that payload when the independently-cached + // optimize report resolves; until then the rest of Overview remains usable. + const data = useMemo(() => { + if (!overview.data || !optimizeReport.data) return overview.data + return { + ...overview.data, + optimize: { + findingCount: optimizeReport.data.summary.findingCount, + savingsUSD: optimizeReport.data.summary.potentialSavingsCostUSD, + topFindings: optimizeReport.data.findings.slice(0, 5).map(finding => ({ + title: finding.title, + impact: finding.severity, + savingsUSD: finding.estimatedSavingsUSD, + })), + }, + } + }, [overview.data, optimizeReport.data]) const modelIndex = useMemo(() => data ? buildModelIndex(data) : new Map(), [data]) if (!data) { diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 3d0297e..2b0f8aa 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -11,6 +11,7 @@ import { Stat } from '../components/Stat' import { usePolled } from '../hooks/usePolled' import { formatCompact, formatDayLong, formatDayShort, formatDuration, formatUsd, shortenProjectPath } from '../lib/format' import { codeburn } from '../lib/ipc' +import { sessionsReportKey } from '../lib/reportKeys' import type { DateRange, Period, SessionRow } from '../lib/types' export const INITIAL_VISIBLE = 120 @@ -120,7 +121,7 @@ export function Sessions({ const report = usePolled( () => range ? codeburn.getSessions(period, provider, range) : codeburn.getSessions(period, provider), [period, provider, range?.from, range?.to, refreshToken], - { enabled: ready, memoKey: `sessions|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` }, + { enabled: ready, memoKey: sessionsReportKey(period, provider, range) }, ) const rows = report.data ?? [] const q = query.trim().toLowerCase() diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index 364b770..0f86cf5 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -11,6 +11,7 @@ import { StaleBanner } from '../components/StaleBanner' import { type Polled, usePolled } from '../hooks/usePolled' import { formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' +import { spendFlowReportKey } from '../lib/reportKeys' import { contiguousDailyWindow, dataStartKey, localDateKey } from '../lib/period' import type { CliError, DateRange, MenubarPayload, Period, SpendFlow } from '../lib/types' @@ -61,7 +62,7 @@ export function SpendContent({ const flow = usePolled( () => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider), [period, provider, range?.from, range?.to, refreshToken], - { enabled: ready, memoKey: `spendflow|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` }, + { enabled: ready, memoKey: spendFlowReportKey(period, provider, range) }, ) if (!overview.data) { diff --git a/src/compare-stats.ts b/src/compare-stats.ts index 1e3db2d..83a8221 100644 --- a/src/compare-stats.ts +++ b/src/compare-stats.ts @@ -1,4 +1,5 @@ -import { readdir, readFile } from 'fs/promises' +import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises' +import { homedir } from 'os' import { join } from 'path' import type { ProjectSummary } from './types.js' @@ -342,6 +343,64 @@ function isCompactFile(name: string): boolean { return name.includes('compact') } +const SELF_CORRECTION_CACHE_VERSION = 1 +const SELF_CORRECTION_CACHE_FILE = `compare-self-corrections.v${SELF_CORRECTION_CACHE_VERSION}.json` + +type CorrectionEvent = { key: string; model: string; correction: boolean } +type CorrectionFileCache = { + mtimeMs: number + sizeBytes: number + events: CorrectionEvent[] +} +type CorrectionCache = { + version: number + files: Record +} + +function correctionCacheDir(): string { + return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +} + +function correctionCachePath(): string { + return join(correctionCacheDir(), SELF_CORRECTION_CACHE_FILE) +} + +async function loadCorrectionCache(): Promise { + try { + const parsed = JSON.parse(await readFile(correctionCachePath(), 'utf8')) as Partial + if (parsed.version !== SELF_CORRECTION_CACHE_VERSION || !parsed.files || typeof parsed.files !== 'object') { + return { version: SELF_CORRECTION_CACHE_VERSION, files: {} } + } + return { version: SELF_CORRECTION_CACHE_VERSION, files: parsed.files } + } catch { + return { version: SELF_CORRECTION_CACHE_VERSION, files: {} } + } +} + +function isCorrectionFileCache(value: unknown): value is CorrectionFileCache { + if (!value || typeof value !== 'object') return false + const entry = value as Partial + return typeof entry.mtimeMs === 'number' + && typeof entry.sizeBytes === 'number' + && Array.isArray(entry.events) + && entry.events.every(event => !!event + && typeof event === 'object' + && typeof event.key === 'string' + && typeof event.model === 'string' + && typeof event.correction === 'boolean') +} + +async function saveCorrectionCache(cache: CorrectionCache): Promise { + try { + await mkdir(correctionCacheDir(), { recursive: true }) + // This is a performance-only cache. A partial write is harmless: the next + // run fails validation/JSON parsing and rebuilds it from the transcripts. + await writeFile(correctionCachePath(), JSON.stringify(cache), { encoding: 'utf8', mode: 0o600 }) + } catch { + // Read-only homes and transient antivirus locks must never break compare. + } +} + async function collectJsonlFiles(sessionDir: string): Promise { const entries = await readdir(sessionDir, { withFileTypes: true }) const files: string[] = [] @@ -363,6 +422,10 @@ async function collectJsonlFiles(sessionDir: string): Promise { export async function scanSelfCorrections(projectDirs: string[]): Promise> { const counts = new Map() const seen = new Set() + const cache = await loadCorrectionCache() + let cacheChanged = false + const allFiles: string[] = [] + const discovered = new Set() for (const dir of projectDirs) { let entries @@ -372,22 +435,37 @@ export async function scanSelfCorrections(projectDirs: string[]): Promise') continue const dedupeKey = `${model}:${ts}` - if (seen.has(dedupeKey)) continue - seen.add(dedupeKey) - const text = extractText(msgRec['content']) - if (SELF_CORRECTION_PATTERNS.some(p => p.test(text))) { - counts.set(model, (counts.get(model) ?? 0) + 1) - } + events.push({ key: dedupeKey, model, correction: SELF_CORRECTION_PATTERNS.some(p => p.test(text)) }) } + + cache.files[file] = { ...fingerprint, events } + cacheChanged = true + } + + // Keep the original global model+timestamp deduplication semantics even + // when events came from different cached files. + for (const event of events) { + if (seen.has(event.key)) continue + seen.add(event.key) + if (event.correction) counts.set(event.model, (counts.get(event.model) ?? 0) + 1) } } + if (cacheChanged) await saveCorrectionCache(cache) + return counts } diff --git a/src/main.ts b/src/main.ts index ae6867c..170ccd1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1861,6 +1861,20 @@ program const projects = await parseAllSessions(range, opts.provider) const models = aggregateModelStats(projects) + // The desktop uses the no-pair form only to populate its model pickers. + // Self-correction counts require a separate raw JSONL scan and are not + // displayed in that picker, so return the lightweight aggregate before + // doing that work. The selected pair path below still enriches both + // models with the full metric. + if (!opts.modelA && !opts.modelB) { + process.stdout.write(JSON.stringify(models, null, 2) + '\n') + return + } + if (!opts.modelA || !opts.modelB) { + process.stderr.write('codeburn compare: --model-a and --model-b must be provided together.\n') + process.exit(1) + } + const providers = await getAllProviders() const dirs: string[] = [] for (const provider of providers) { @@ -1872,14 +1886,6 @@ program model.selfCorrections = corrections.get(model.model) ?? 0 } - if (!opts.modelA && !opts.modelB) { - process.stdout.write(JSON.stringify(models, null, 2) + '\n') - return - } - if (!opts.modelA || !opts.modelB) { - process.stderr.write('codeburn compare: --model-a and --model-b must be provided together.\n') - process.exit(1) - } const modelA = models.find(model => model.model === opts.modelA) const modelB = models.find(model => model.model === opts.modelB) if (!modelA) { diff --git a/tests/compare-stats.test.ts b/tests/compare-stats.test.ts index 02a9e9a..7ea9a04 100644 --- a/tests/compare-stats.test.ts +++ b/tests/compare-stats.test.ts @@ -278,12 +278,17 @@ function jsonlLine(type: string, model: string, text: string, timestamp = '2026- describe('scanSelfCorrections', () => { let tmpDir: string + let previousCacheDir: string | undefined beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-test-')) + previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') }) afterEach(async () => { + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir await rm(tmpDir, { recursive: true, force: true }) }) @@ -406,6 +411,22 @@ describe('scanSelfCorrections', () => { await rm(dir2, { recursive: true, force: true }) } }) + + it('invalidates only a changed file fingerprint and includes appended corrections', async () => { + const sessionDir = join(tmpDir, 'session-growing') + await mkdir(sessionDir) + const file = join(sessionDir, 'session.jsonl') + await writeFile(file, jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z') + '\n') + + expect((await scanSelfCorrections([tmpDir])).get('opus-4-6')).toBe(1) + + await writeFile(file, [ + jsonlLine('assistant', 'opus-4-6', 'I was wrong.', '2026-04-15T10:00:00Z'), + jsonlLine('assistant', 'opus-4-6', 'My mistake.', '2026-04-15T10:01:00Z'), + ].join('\n') + '\n') + + expect((await scanSelfCorrections([tmpDir])).get('opus-4-6')).toBe(2) + }) }) describe('computeCategoryComparison', () => {