diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 45d7b348..a1069b01 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -284,6 +284,17 @@ the same machine (no quarantine attribute is applied to files that were never downloaded) — they only apply to a build distributed to someone else, e.g. via a GitHub Release. +### Folder-access prompts re-appear on every update + +CodeBurn requests access to folders like Documents, Desktop, and Downloads +(via `mac.extendInfo` in `app/package.json`) to read local AI coding tool +session logs. Because each ad-hoc/unsigned build has no stable Developer ID, +macOS TCC treats every rebuild as a new app identity, so users get +re-prompted for folder access after each update even though nothing else +changed. Signing with a stable Developer ID certificate (see "Upgrade path" +below) fixes this — TCC grants persist across updates once the app's +identity is stable. + ## Upgrade path: paid account + notarization When a paid Apple Developer Program membership is available, the same diff --git a/app/package.json b/app/package.json index b4ab6bea..283497fc 100644 --- a/app/package.json +++ b/app/package.json @@ -76,7 +76,12 @@ "icon": "build/icon.png", "identity": "-", "hardenedRuntime": false, - "gatekeeperAssess": false + "gatekeeperAssess": false, + "extendInfo": { + "NSDocumentsFolderUsageDescription": "CodeBurn reads your local AI coding tool session logs (Claude Code, Codex, Cursor, and others) to calculate your token usage and cost. Your data never leaves your machine.", + "NSDesktopFolderUsageDescription": "CodeBurn reads your local AI coding tool session logs (Claude Code, Codex, Cursor, and others) to calculate your token usage and cost. Your data never leaves your machine.", + "NSDownloadsFolderUsageDescription": "CodeBurn reads your local AI coding tool session logs (Claude Code, Codex, Cursor, and others) to calculate your token usage and cost. Your data never leaves your machine." + } }, "dmg": {}, "win": { diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index 220070ae..8fde55a9 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -10,7 +10,7 @@ import { Splash } from './components/Splash' import { ToastHost } from './components/ToastHost' import { rangeLabel, TopBar } from './components/TopBar' import { Window } from './components/Window' -import { usePolled } from './hooks/usePolled' +import { hasPolledMemo, primePolledMemo, usePolled } from './hooks/usePolled' import { readDailyBudget } from './lib/budget' import { formatCompact, formatUsd, setActiveCurrency } from './lib/format' import { motionClass } from './lib/motion' @@ -77,6 +77,18 @@ const PERIOD_LABELS: Record = { const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all'] +// Instant-switch memo key for an overview result. Shared by the overview poll +// and the provider prefetcher so the two never drift out of sync. +function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string { + return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}` +} + +// Prefetch pacing: wait a short idle after the first paint, then warm one +// provider at a time at low priority so the background scan never competes with +// the interaction the user is actually having. +const PREFETCH_START_DELAY_MS = 1500 +const PREFETCH_STAGGER_MS = 400 + function isPeriod(value: string): value is Period { return (STANDARD_PERIODS as string[]).includes(value) } @@ -159,16 +171,20 @@ function AppMain() { ? codeburn.getOverview(period, provider, customRange) : codeburn.getOverview(period, provider), [period, provider, customRange?.from, customRange?.to, claudeConfigSource], - { memoKey: `overview|${provider}|${period}|${customRange?.from ?? ''}-${customRange?.to ?? ''}|${claudeConfigSource ?? ''}` }, + { memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) }, ) const refreshOverview = overview.refresh // Boot readiness: the overview poll is the single cold-cache warmer (long // timeout + progress). Other sections gate their first CLI spawn on this so a // cold first run hydrates ONCE here instead of fanning out into a parallel - // full-history parse per section. Flips true the moment overview has data OR a - // (resolved) error; after that everything polls normally. - const ready = overview.data != null || overview.error != null + // full-history parse per section. Flips true the moment overview first has data + // OR a (resolved) error; LATCHED, so a later uncached switch (which clears + // overview.data to paint a skeleton) can never re-gate the sections. + const [ready, setReady] = useState(false) + useEffect(() => { + if (overview.data != null || overview.error != null) setReady(true) + }, [overview.data, overview.error]) // First-launch onboarding: shown until the telemetry consent screen has been // completed once. All telemetry bridge calls are typeof-guarded so an older @@ -236,6 +252,38 @@ function AppMain() { setCurrencyTick(tick => tick + 1) }, [overview.data?.currency?.code, overview.data?.currency?.rate, overview.data?.currency?.symbol]) + // Prefetch for millisecond switches: once the first overview has resolved, + // quietly warm the instant-switch memo for every OTHER detected provider at the + // current period, so a picker switch to one paints from memory instead of + // waiting on a fresh 2-3s CLI spawn. One provider at a time, lowest priority, + // and only for the plain view (no custom range / no config scope) the picker + // actually toggles between. The CLI's own read-cache + in-flight coalescing keep + // this from double-spawning against a live user fetch; hasPolledMemo skips any + // provider already warm (including one warmed by a real visit). + useEffect(() => { + if (!ready || overview.data == null || customRange || claudeConfigSource) return + const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider) + if (targets.length === 0) return + let cancelled = false + const warm = async () => { + for (const id of targets) { + if (cancelled) return + const key = overviewMemoKey(id, period, null, null) + if (hasPolledMemo(key)) continue + try { + const value = await codeburn.getOverview(period, id) + if (!cancelled) primePolledMemo(key, value) + } catch { /* best-effort warm; a real switch will fetch and surface any error */ } + if (!cancelled) await new Promise(resolve => setTimeout(resolve, PREFETCH_STAGGER_MS)) + } + } + const start = setTimeout(() => { void warm() }, PREFETCH_START_DELAY_MS) + return () => { cancelled = true; clearTimeout(start) } + // `overview.data == null` (a boolean) gates on first-resolution without + // re-running every poll; the data content itself is intentionally not a dep. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null]) + useEffect(() => { const id = window.setInterval(() => setNow(Date.now()), 1000) return () => window.clearInterval(id) diff --git a/app/renderer/components/AboutModal.tsx b/app/renderer/components/AboutModal.tsx index 5e374e90..8e189e45 100644 --- a/app/renderer/components/AboutModal.tsx +++ b/app/renderer/components/AboutModal.tsx @@ -2,6 +2,7 @@ import { useEffect, type MouseEvent, type ReactNode } from 'react' import { version } from '../../package.json' import { FlameMark } from './FlameMark' +import { BUILD_STAMP } from '../lib/build' import { codeburn } from '../lib/ipc' export type SocialLink = { @@ -42,6 +43,7 @@ export function AboutModal({ socials, onClose }: { socials: SocialLink[]; onClos
CodeBurn
v{version}
+
{BUILD_STAMP}
Know where every token goes, across every AI coding tool.
diff --git a/app/renderer/components/Splash.test.tsx b/app/renderer/components/Splash.test.tsx index 00f253bb..f6b266af 100644 --- a/app/renderer/components/Splash.test.tsx +++ b/app/renderer/components/Splash.test.tsx @@ -87,9 +87,10 @@ describe('Splash', () => { expect(document.querySelector('.splash-status')).toBeNull() act(() => { - progressCb?.({ kind: 'providers', providers: ['claude', 'codex'] }) + // The leading `providers` event carries cold:true only on a genuine full + // hydration — that, and only that, reveals the indexing detail. + progressCb?.({ kind: 'providers', cold: true, providers: ['claude', 'codex'] }) progressCb?.({ kind: 'provider', provider: 'claude', state: 'start' }) - // A nonzero-total tick means the cache is genuinely cold: reveal at once. progressCb?.({ kind: 'tick', provider: 'claude', done: 120, total: 480 }) }) @@ -108,6 +109,23 @@ describe('Splash', () => { expect(document.querySelector('.splash-status-line')?.textContent).toBe('Indexing your usage history…') }) + it('never reveals the indexing status on a warm launch (providers/ticks without cold)', () => { + render() + expect(splashEl()).toBeInTheDocument() + + act(() => { + // A warm launch's incremental re-parse streams the same providers/provider/ + // tick events, but the providers event has NO cold flag. The strip must stay + // hidden — this is the "indexing on every launch" bug fix. + progressCb?.({ kind: 'providers', cold: false, providers: ['claude', 'codex'] }) + progressCb?.({ kind: 'provider', provider: 'claude', state: 'start' }) + progressCb?.({ kind: 'tick', provider: 'claude', done: 40, total: 60 }) + progressCb?.({ kind: 'provider', provider: 'claude', state: 'done' }) + }) + + expect(document.querySelector('.splash-status')).toBeNull() + }) + it('swaps instantly under reduced motion (no fade, no min-time)', () => { mockReducedMotion(true) const { rerender } = render() diff --git a/app/renderer/components/Splash.tsx b/app/renderer/components/Splash.tsx index 5af7f7e5..0b00b73d 100644 --- a/app/renderer/components/Splash.tsx +++ b/app/renderer/components/Splash.tsx @@ -6,14 +6,12 @@ import { ProviderLogo } from './ProviderLogo' import { motionClass, motionEnabled, reducedMotion } from '../lib/motion' import { codeburn } from '../lib/ipc' import type { ScanProgressEvent } from '../lib/types' +import { BUILD_STAMP } from '../lib/build' import { version } from '../../package.json' import loaderVideo from '../assets/splash-loader.webm' const MIN_ON_SCREEN_MS = 600 const CROSSFADE_MS = 250 -// If the first scan is still running this long after boot, reveal the per-provider -// indexing detail. Warm launches resolve well before this, so they never show it. -const REVEAL_FALLBACK_MS = 3500 type Phase = 'lit' | 'out' | 'done' type ProvStatus = 'pending' | 'active' | 'done' @@ -24,28 +22,29 @@ type Progress = { status: Record claudeDone: number claudeTotal: number - /** A tick with a nonzero total — the cache is genuinely cold and parsing. */ - realWork: boolean - /** Any progress event at all has arrived (distinguishes a new CLI from old). */ - seen: boolean + /** True only for a genuine cold hydration (the CLI's `providers` event carries + * `cold: true`). A warm launch's incremental re-parse of a few changed files + * emits the same providers/tick stream WITHOUT this, so it never reveals the + * indexing detail. */ + cold: boolean } -const EMPTY: Progress = { order: [], status: {}, claudeDone: 0, claudeTotal: 0, realWork: false, seen: false } +const EMPTY: Progress = { order: [], status: {}, claudeDone: 0, claudeTotal: 0, cold: false } function reduceProgress(state: Progress, event: ScanProgressEvent): Progress { switch (event.kind) { case 'providers': { const status: Record = {} for (const p of event.providers) status[p] = state.status[p] ?? 'pending' - return { ...state, order: event.providers, status, seen: true } + return { ...state, order: event.providers, status, cold: state.cold || event.cold === true } } case 'provider': { const order = state.order.includes(event.provider) ? state.order : [...state.order, event.provider] const next: ProvStatus = event.state === 'done' ? 'done' : 'active' - return { ...state, order, status: { ...state.status, [event.provider]: next }, seen: true } + return { ...state, order, status: { ...state.status, [event.provider]: next } } } case 'tick': - return { ...state, claudeDone: event.done, claudeTotal: event.total, realWork: state.realWork || event.total > 0, seen: true } + return { ...state, claudeDone: event.done, claudeTotal: event.total } case 'done': { const status = { ...state.status } for (const p of state.order) status[p] = 'done' @@ -98,10 +97,11 @@ function SplashStatus({ progress }: { progress: Progress }) { * bring it back. * * On a genuinely cold first run the overview warmup streams per-provider scan - * progress (main.ts forwards the CLI's stderr). Once real parse work is detected - * (or the scan simply outlasts REVEAL_FALLBACK_MS), the splash reveals a compact - * status block (see SplashStatus). A warm launch resolves before that threshold - * and never shows it. + * progress (main.ts forwards the CLI's stderr) and the leading `providers` event + * carries `cold: true`; that — and only that — reveals the compact indexing + * status block (see SplashStatus). A warm launch's incremental re-parse emits the + * same provider/tick stream without the cold flag, so the splash stays a plain + * flame and dismisses the moment data arrives. */ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: boolean }) { const [phase, setPhase] = useState('lit') @@ -109,7 +109,6 @@ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: bool const [reveal, setReveal] = useState(false) const shownAt = useRef(Date.now()) const done = useRef(false) - const seenRef = useRef(false) // Subscribe once to cold-start progress. `codeburn` is undefined outside the // Electron preload (e.g. unit tests); guard so the splash still renders. @@ -118,14 +117,9 @@ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: bool return codeburn.onProgress(event => setProgress(prev => reduceProgress(prev, event))) }, []) - useEffect(() => { seenRef.current = progress.seen }, [progress.seen]) - - // Real parse work reveals the detail at once; otherwise a slow-scan fallback. - useEffect(() => { if (progress.realWork) setReveal(true) }, [progress.realWork]) - useEffect(() => { - const timer = setTimeout(() => { if (seenRef.current) setReveal(true) }, REVEAL_FALLBACK_MS) - return () => clearTimeout(timer) - }, []) + // Only a genuine cold hydration reveals the indexing detail. Warm launches + // never set `cold`, so the strip stays hidden and the flame dismisses fast. + useEffect(() => { if (progress.cold) setReveal(true) }, [progress.cold]) useEffect(() => { if (done.current) return @@ -169,6 +163,7 @@ export function Splash({ hasData, hasError }: { hasData: boolean; hasError: bool )}
CodeBurn
v{version}
+
{BUILD_STAMP}
{showDetail && }
, document.body, diff --git a/app/renderer/hooks/usePolled.test.ts b/app/renderer/hooks/usePolled.test.ts index 98e12d8f..3329fa7f 100644 --- a/app/renderer/hooks/usePolled.test.ts +++ b/app/renderer/hooks/usePolled.test.ts @@ -115,6 +115,34 @@ describe('usePolled', () => { expect(result.current.switching).toBe(false) }) + 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) })) + + const { result, rerender } = renderHook( + ({ k }: { k: string }) => usePolled(fetcher, [k], { memoKey: k }), + { initialProps: { k: 'miss-A' } }, + ) + await act(async () => { resolvers[0]!('A0') }) + expect(result.current.data).toBe('A0') + + // Switch to a brand-new key with nothing memoized: data must drop to null so + // the section paints its skeleton, NOT the previous filter's numbers. This is + // the "old numbers for 2-3s on switch" fix — no cache hit, no stale hold. + rerender({ k: 'miss-B' }) + expect(result.current.data).toBeNull() + expect(result.current.switching).toBe(false) + expect(result.current.loading).toBe(true) + + await act(async () => { resolvers[1]!('B0') }) + expect(result.current.data).toBe('B0') + + // A background re-poll on the SAME key (its last result is memoized) must keep + // showing data — the clear-on-miss must never blank a plain refresh. + act(() => { result.current.refresh() }) + expect(result.current.data).toBe('B0') + }) + it('manual cadence (null interval) polls only on mount + refresh, never on a timer', async () => { vi.useFakeTimers() try { diff --git a/app/renderer/hooks/usePolled.ts b/app/renderer/hooks/usePolled.ts index 76cc828c..6a7b22c8 100644 --- a/app/renderer/hooks/usePolled.ts +++ b/app/renderer/hooks/usePolled.ts @@ -50,6 +50,20 @@ export function __resetPolledMemo(): void { memoStore.clear() } +/** Seed the instant-switch memo out of band. The prefetcher (App.tsx) warms the + * overview result for every detected provider so a picker switch to one paints + * from memory in the same frame instead of waiting on a fresh CLI spawn. Keyed + * identically to the corresponding usePolled `memoKey`. */ +export function primePolledMemo(key: string, value: unknown): void { + memoSet(key, value) +} + +/** Whether a live result is already memoized for `key` (does not affect recency). + * Lets the prefetcher skip providers it has already warmed. */ +export function hasPolledMemo(key: string): boolean { + return memoStore.has(key) +} + /** * Generic CLI-backed data hook: fetches on mount + whenever `deps` change, then * re-polls every `intervalMs`. Errors are normalized to the CliError shape so @@ -92,11 +106,15 @@ export function usePolled( 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 - // runs. Otherwise fall back to the normal loading state. + // runs. If there is NO cached result for the new key, clear stale data so the + // section paints its loading/skeleton state — never the previous filter's + // numbers. (An interval re-poll keeps the same key, whose last result is + // always cached, so a background refresh never blanks.) let servedCached = false if (memoKey) { const cached = memoGet(memoKey) if (cached !== undefined) { setData(cached); servedCached = true } + else setData(null) } setLoading(true) setSwitching(servedCached) diff --git a/app/renderer/lib/build.ts b/app/renderer/lib/build.ts new file mode 100644 index 00000000..1d40a004 --- /dev/null +++ b/app/renderer/lib/build.ts @@ -0,0 +1,11 @@ +// Build identity, injected at package time by vite `define` (see vite.config.ts) +// from the git sha + build date. Falls back to 'dev' under the dev server and +// unit tests, where `define` is absent. Shown in the splash footer and the About +// modal so a field report is never ambiguous about which build is running. +declare const __BUILD_SHA__: string +declare const __BUILD_DATE__: string + +export const BUILD_SHA = typeof __BUILD_SHA__ !== 'undefined' ? __BUILD_SHA__ : 'dev' +export const BUILD_DATE = typeof __BUILD_DATE__ !== 'undefined' ? __BUILD_DATE__ : '' +/** e.g. "a1b2c3d · 2026-07-16", or just the sha when no date is available. */ +export const BUILD_STAMP = BUILD_DATE ? `${BUILD_SHA} · ${BUILD_DATE}` : BUILD_SHA diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 66cf6f37..b12e9af4 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -561,9 +561,11 @@ export type TelemetryStatus = { onboarded: boolean } -/** Cold-start scan progress streamed from the CLI warmup (src/parser.ts). */ +/** Cold-start scan progress streamed from the CLI warmup (src/parser.ts). + * `cold` (on the `providers` event) is true only for a genuine full hydration; + * a warm launch's incremental re-parse emits the same events without it. */ export type ScanProgressEvent = - | { kind: 'providers'; providers: string[] } + | { kind: 'providers'; providers: string[]; cold?: boolean } | { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number } | { kind: 'tick'; provider: string; done: number; total: number } | { kind: 'done' } diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx index 142d6d6f..eefff525 100644 --- a/app/renderer/sections/Overview.test.tsx +++ b/app/renderer/sections/Overview.test.tsx @@ -298,16 +298,16 @@ describe('Overview', () => { expect(within(risks).getByText(/Today's spend is 10× your typical/)).toBeInTheDocument() }) - it('uses an honest empty state when no realized savings exist', async () => { + it('hides the applied-fixes line when there are no realized savings', async () => { const now = new Date() getOverview.mockResolvedValue(makePayload(now)) getActReport.mockResolvedValue({ totals: { realizedCostUSD: 0, measuredActions: 7 } }) render() - expect(await screen.findByText('across 0 fixes')).toBeInTheDocument() await waitFor(() => expect(getActReport).toHaveBeenCalled()) - expect(screen.getByText('$0.00')).toBeInTheDocument() + expect(screen.queryByText('Saved by applied fixes')).not.toBeInTheDocument() + expect(screen.queryByText(/across \d+ fix/)).not.toBeInTheDocument() }) it('zero-fills a contiguous 30-day window from sparse history', async () => { diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx index a37986ed..36c23843 100644 --- a/app/renderer/sections/Overview.tsx +++ b/app/renderer/sections/Overview.tsx @@ -625,7 +625,9 @@ export function OverviewContent({
{data.current.label}{streakDays(data.history.daily, now)}-day streak
{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions
-
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
+ {saved > 0 && ( +
Saved by applied fixes{formatUsd(saved)}across {applied} {applied === 1 ? 'fix' : 'fixes'}
+ )} {localSaved > 0 && (
Saved via local models{formatUsd(localSaved)}local-model routing
)} diff --git a/app/renderer/sections/Sessions.test.tsx b/app/renderer/sections/Sessions.test.tsx index 8ff8302b..8b38ed0a 100644 --- a/app/renderer/sections/Sessions.test.tsx +++ b/app/renderer/sections/Sessions.test.tsx @@ -243,6 +243,22 @@ describe('Sessions', () => { expect(await screen.findByText('No sessions in this range yet.')).toBeInTheDocument() }) + it('keeps the provider quick-filter visible in the empty state so a filtered-out user can switch back', async () => { + const user = userEvent.setup() + getSessions.mockResolvedValue([]) + const onProviderChange = vi.fn() + const detected = [ + { id: 'claude', label: 'Claude' }, + { id: 'codex', label: 'Codex' }, + ] + render() + await screen.findByText('No sessions in this range yet.') + + const filter = screen.getByRole('group', { name: /provider/i }) + await user.click(within(filter).getByRole('button', { name: 'All' })) + expect(onProviderChange).toHaveBeenCalledWith('all') + }) + it('lifts provider quick-filter clicks to the app callback with the internal id', async () => { const user = userEvent.setup() getSessions.mockResolvedValue(rows) diff --git a/app/renderer/sections/Sessions.tsx b/app/renderer/sections/Sessions.tsx index 4fb6153d..4baea6e9 100644 --- a/app/renderer/sections/Sessions.tsx +++ b/app/renderer/sections/Sessions.tsx @@ -59,6 +59,42 @@ function groupSortValue(sort: SessionSort, rows: SessionRow[]): number { return rows.reduce((latest, row) => Math.max(latest, endedAtTime(row)), 0) } +function ProviderFilterRow({ + provider, + detectedProviders, + onProviderChange, +}: { + provider: string + detectedProviders: Array<{ id: string; label: string }> + onProviderChange: (value: string) => void +}) { + if (detectedProviders.length === 0) return null + return ( +
+ + {detectedProviders.map(entry => ( + + ))} +
+ ) +} + export function Sessions({ period, provider, @@ -151,6 +187,7 @@ export function Sessions({ if (!report.data.length) { return ( + No sessions in this range yet. ) @@ -163,30 +200,7 @@ export function Sessions({ return (
{report.error && } - {detectedProviders.length > 0 && ( -
- - {detectedProviders.map(entry => ( - - ))} -
- )} +
, which that CSP // blocks; relax script-src to allow inline scripts for the dev server only. @@ -21,6 +38,10 @@ export default defineConfig({ root: 'renderer', base: './', plugins: [react(), devCsp()], + define: { + __BUILD_SHA__: JSON.stringify(stamp.sha), + __BUILD_DATE__: JSON.stringify(stamp.date), + }, server: { host: '127.0.0.1', port: 5173, strictPort: true }, build: { outDir: '../dist/renderer', emptyOutDir: true }, }) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 0ee3bb3d..13cd998c 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -70,6 +70,12 @@ export type DailyCache = { savingsConfigHash: string lastComputedDate: string | null days: DailyEntry[] + /// True only once the full backfill window has been hydrated from a COMPLETE + /// session parse. A cache that was finalized against a partial (interrupted) + /// session hydration — the "chart is empty for the first ~20 days" bug — reads + /// as incomplete and is fully re-backfilled. Absent on caches written before + /// this field existed → treated as incomplete (one self-healing re-backfill). + complete?: boolean } function getCacheDir(): string { @@ -90,10 +96,10 @@ export function dailyCachePath(): string { } export function emptyCache(savingsConfigHash = ''): DailyCache { - return { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [] } + return { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [], complete: false } } -function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[] } { +function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[]; complete?: boolean } { if (!parsed || typeof parsed !== 'object') return false const c = parsed as Partial if (typeof c.version !== 'number') return false @@ -120,12 +126,15 @@ function migrateDays(days: Record[]): DailyEntry[] { })) } -function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[] }): DailyCache { +function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[]; complete?: boolean }): DailyCache { return { version: DAILY_CACHE_VERSION, savingsConfigHash: parsed.savingsConfigHash ?? '', lastComputedDate: parsed.lastComputedDate, days: migrateDays(parsed.days), + // Only a cache explicitly marked complete stays trusted; one written before + // the marker existed reads false and is re-backfilled once. + complete: parsed.complete === true, } } @@ -215,6 +224,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate savingsConfigHash: cache.savingsConfigHash, lastComputedDate: nextLast, days: pruned, + complete: cache.complete, } } @@ -249,6 +259,12 @@ export async function ensureCacheHydrated( /// longer accurate, so we treat the cache as stale and force a full /// re-hydration. Pass `''` for "no savings config" to disable. savingsConfigHash: string = '', + /// Whether the session parse that fed this backfill left the session cache + /// fully hydrated. A partial (interrupted) session cache yields empty/partial + /// older days; finalizing them would freeze that gap into the daily history. + /// So the backfill is only marked `complete` when this returns true. Defaults + /// to a trusting `true` for callers that don't (or can't) supply it. + sessionComplete: () => boolean = () => true, ): Promise { const now = new Date() const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) @@ -258,16 +274,21 @@ export async function ensureCacheHydrated( return withDailyCacheLock(async () => { let c = await loadDailyCache() - // Savings config changed: roll the cache forward into the active - // mapping. We can't cheaply recompute savings for already-cached - // historical days without re-parsing every session, so we drop the - // cached days and re-hydrate from the daily cache retention window. - if (c.savingsConfigHash !== savingsConfigHash) { + // Two reasons to drop the cached days and re-hydrate the whole retention + // window: + // 1. Savings config changed — the cached `savingsUSD` totals are stale, and + // we can't cheaply recompute them per historical day without re-parsing. + // 2. The cache was never finalized against a COMPLETE session parse (an old + // pre-marker cache, or one frozen from a partial/interrupted hydration). + // Its older days may be empty or partial; trusting `lastComputedDate` + // would leave that gap forever (the "first ~20 days missing" bug). + if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true) { c = { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [], + complete: false, } } @@ -296,6 +317,17 @@ export async function ensureCacheHydrated( const gapProjects = await parseSessions(gapRange) const gapDays = aggregateDays(gapProjects) c = addNewDays(c, gapDays, yesterdayStr) + // Finalize as complete ONLY when the session parse that produced these days + // was itself complete. If it was partial, leave `complete: false` so the + // next launch (once the session cache is whole) re-backfills instead of + // freezing the partial history. + c = { ...c, complete: sessionComplete() } + await saveDailyCache(c) + } else if (c.complete !== true && sessionComplete()) { + // No gap to fill (already current through yesterday) but not yet marked — + // e.g. a brand-new machine whose only data is today. Finalize so future + // launches don't re-backfill the whole window every time. + c = { ...c, complete: true } await saveDailyCache(c) } return c diff --git a/src/parser.ts b/src/parser.ts index 84071d47..5cdc5489 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -20,6 +20,7 @@ import { computeEnvFingerprint, DURABLE_PROVIDER_NAMES, fingerprintFile, + isCacheComplete, loadCache, reconcileFile, saveCache, @@ -2097,7 +2098,11 @@ export function setInteractiveScanUI(active = true): void { // createScanProgress's `\r` TTY line (that one never fires under a piped spawn). export const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS ' export type ScanProgressEvent = - | { kind: 'providers'; providers: string[] } + // `cold` is true only for a genuine full hydration (the on-disk cache was + // empty). A warm launch's incremental re-parse of a handful of changed files + // still emits `providers`/`tick`, so consumers must gate any "indexing" UI on + // this flag, not on the mere presence of tick work. + | { kind: 'providers'; providers: string[]; cold?: boolean } | { kind: 'provider'; provider: string; state: 'start' | 'done'; files?: number } | { kind: 'tick'; provider: string; done: number; total: number } @@ -2555,6 +2560,15 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD) } +// Reflects whether the most recently completed parse left the session cache +// fully hydrated. The daily backfill reads this so it never finalizes history +// built on a partial (interrupted) session cache. Set only at the end of a +// runParse that reaches completion; a killed run leaves it false. +let sessionHydrationComplete = false +export function isSessionHydrationComplete(): boolean { + return sessionHydrationComplete +} + export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { const key = cacheKey(dateRange, providerFilter) const cached = sessionCache.get(key) @@ -2563,22 +2577,29 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s let diskCache = await loadCache() await cleanupOrphanedTempFiles() - // Cold-hydration coordination (advisory, cross-process). Engages only when the - // on-disk cache is empty — a genuine full parse. If another live process is - // already hydrating, wait for it, then reload the now-warm cache instead of - // double-parsing (kills the menubar-vs-desktop cache clobber). Never a - // correctness gate: on any doubt it proceeds unlocked. - const hydration = await beginColdHydration(Object.keys(diskCache.providers).length === 0) + // Cold-hydration coordination (advisory, cross-process). Engages whenever the + // on-disk cache is not COMPLETE — an empty cache OR a partial one an interrupted + // cold start left behind. Keying on completeness (not mere non-emptiness) is + // what keeps a resumed partial hydration under the lock, so a concurrent menubar + // + desktop can't race their partial writes and freeze a partial daily history. + // If another live process is already hydrating, wait for it, then reload the + // now-warm cache instead of double-parsing. Never a correctness gate: on any + // doubt it proceeds unlocked. + const hydration = await beginColdHydration(!isCacheComplete(diskCache)) if (hydration.waited) diskCache = await loadCache() + // Cold = this run finishes a genuine (possibly resumed) full parse. A warm run + // reconciles an already-complete corpus. Drives the splash's cold-only reveal + // and the daily backfill's "don't finalize partial history" guard. + const isCold = !isCacheComplete(diskCache) try { - return await runParse(key, diskCache, dateRange, providerFilter) + return await runParse(key, diskCache, dateRange, providerFilter, isCold) } finally { await hydration.release() } } -async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRange, providerFilter?: string): Promise { +async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRange, providerFilter?: string, isCold = false): Promise { const seenMsgIds = new Set() const seenKeys = new Set() const allSources = await discoverAllSessions(providerFilter) @@ -2605,7 +2626,7 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa try { await saveCache(diskCache) } catch { /* best-effort partial save */ } } - emitScanProgress({ kind: 'providers', providers: [ + emitScanProgress({ kind: 'providers', cold: isCold, providers: [ ...(claudeSources.length > 0 ? ['claude'] : []), ...providerGroups.keys(), ] }) @@ -2650,9 +2671,18 @@ async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRa otherProjects.push(...projects) } - if ((diskCache as { _dirty?: boolean })._dirty) { + // The full scan reached the end: this cache is now complete. Mark it and + // persist even when nothing else is dirty, so a pre-marker cache (or a partial + // that happened to already hold every current file) stops being re-read as cold + // on every launch, and the completeness marker the daily backfill + splash rely + // on is durable. A run killed before here never reaches this, so its throttled + // partial saves keep `complete: false` and the next launch resumes cold. + const wasComplete = isCacheComplete(diskCache) + if (!wasComplete) diskCache.complete = true + if ((diskCache as { _dirty?: boolean })._dirty || !wasComplete) { try { await saveCache(diskCache) } catch {} } + sessionHydrationComplete = true // Merge across providers by normalised project path so the same repository // is not double-counted when it was worked on with more than one tool diff --git a/src/session-cache.ts b/src/session-cache.ts index 983afb4b..2d5c2658 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -81,6 +81,15 @@ export type ProviderSection = { export type SessionCache = { version: number providers: Record + /** True only once a full scan has run to completion. The throttled partial + * saves during a cold hydration persist `false`; the single end-of-parse save + * flips it `true`. A cache that is present-but-incomplete (an interrupted cold + * start left a partial behind) must be treated as still cold — otherwise the + * emptiness heuristic reads the partial as warm, the cross-process hydration + * lock never engages, and totals heal only gradually while a concurrent parse + * can freeze a partial daily history. Absent on caches written before this + * field existed → read as incomplete (one self-healing re-hydration). */ + complete?: boolean } // ── Constants ────────────────────────────────────────────────────────── @@ -186,7 +195,14 @@ export function computeEnvFingerprint(provider: string): string { // ── Load / Save ──────────────────────────────────────────────────────── export function emptyCache(): SessionCache { - return { version: CACHE_VERSION, providers: {} } + return { version: CACHE_VERSION, providers: {}, complete: false } +} + +/** A cache is warm only when a full scan finished against it. Empty-but-marked + * (a machine with no sessions) is complete; present-but-unmarked (an interrupted + * cold start, or a pre-marker cache) is NOT — it is still cold. */ +export function isCacheComplete(cache: SessionCache): boolean { + return cache.complete === true } function isNum(v: unknown): v is number { diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 0fc1c1ce..2f0f780b 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -1,7 +1,7 @@ import { homedir } from 'node:os' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory, type DateRange } from './types.js' import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, buildMenubarPayload } from './menubar-json.js' -import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource } from './parser.js' +import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js' import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName } from './models.js' import { getAllProviders, safeDiscoverSessions } from './providers/index.js' import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js' @@ -74,6 +74,9 @@ async function hydrateCache(): Promise { (range) => parseAllSessions(range, 'all'), aggregateProjectsIntoDays, getDailyCacheConfigHash(), + // Never finalize the daily history off a partial (interrupted) session + // hydration — that is what froze empty older days into the chart. + isSessionHydrationComplete, ) } catch (err) { // Previously swallowed silently, which turned any backfill failure into an diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 01b98f96..e7949c49 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -177,6 +177,7 @@ describe('loadDailyCache', () => { savingsConfigHash: 'cfg-hash-1', lastComputedDate: '2026-04-10', days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)], + complete: true, } await saveDailyCache(saved) const loaded = await loadDailyCache() @@ -310,6 +311,7 @@ describe('ensureCacheHydrated', () => { savingsConfigHash: '', lastComputedDate: '2026-06-11', days: [emptyDay('2026-06-11', 5, 10)], + complete: true, } await saveDailyCache(saved) @@ -337,6 +339,7 @@ describe('ensureCacheHydrated', () => { savingsConfigHash: '', lastComputedDate: '2026-06-12', days: [emptyDay('2026-06-11', 5, 10), emptyDay('2026-06-12', 9, 20)], + complete: true, } await saveDailyCache(saved) @@ -385,6 +388,7 @@ describe('ensureCacheHydrated: savings config invalidation', () => { savingsConfigHash: 'cfg-A', lastComputedDate: twoDaysAgoStr, days: [emptyDay(twoDaysAgoStr, 1.5, 3)], + complete: true, } await saveDailyCache(seeded) @@ -402,6 +406,7 @@ describe('ensureCacheHydrated: savings config invalidation', () => { savingsConfigHash: 'cfg-C', lastComputedDate: twoDaysAgoStr, days: [emptyDay(twoDaysAgoStr, 1.5, 3)], + complete: true, } await saveDailyCache(seeded2) const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-C') diff --git a/tests/hydration-interrupt-convergence.test.ts b/tests/hydration-interrupt-convergence.test.ts new file mode 100644 index 00000000..7ad68a09 --- /dev/null +++ b/tests/hydration-interrupt-convergence.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, mkdir, rm, writeFile, readFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { loadPricing, setLocalModelSavings, setModelAliases } from '../src/models.js' +import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' +import { clearSessionCache } from '../src/parser.js' +import { sessionCachePath } from '../src/session-cache.js' +import { dailyCachePath } from '../src/daily-cache.js' +import type { DateRange } from '../src/types.js' + +// Several DISTINCT historical days (all strictly before yesterday, so each lands +// in the daily backfill window). The chart-freeze bug drops exactly these older +// days, so the fixture must span more than one. +const now = new Date() +function daysAgo(n: number): Date { + return new Date(now.getFullYear(), now.getMonth(), now.getDate() - n) +} +function isoAt(d: Date, hour: number): string { + return new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, 0, 0).toISOString() +} +const FIXTURE_DAYS = [12, 9, 6, 4].map(daysAgo) +const RANGE: DateRange = { start: daysAgo(30), end: now } +const PERIOD = { range: RANGE, label: '30-day window' } + +let tmpDirs: string[] = [] + +beforeAll(async () => { + await loadPricing() +}) +beforeEach(() => { + setLocalModelSavings({}) + setModelAliases({}) +}) +afterEach(async () => { + clearSessionCache() + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } +}) + +/** Point discovery + cache at fresh isolated dirs and seed the fixture sessions. */ +async function seed(): Promise { + const base = await mkdtemp(join(tmpdir(), 'codeburn-hyd-src-')) + const cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-hyd-cache-')) + tmpDirs.push(base, cacheDir) + const projectDir = join(base, 'projects', 'p') + await mkdir(projectDir, { recursive: true }) + + const line = (id: string, ts: string): string => JSON.stringify({ + type: 'assistant', timestamp: ts, sessionId: `s-${id}`, + message: { + type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id, + content: [], + usage: { input_tokens: 80000, output_tokens: 16000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 }, + }, + }) + // One session file per fixture day. + for (let i = 0; i < FIXTURE_DAYS.length; i++) { + await writeFile(join(projectDir, `s${i}.jsonl`), line(`msg-${i}`, isoAt(FIXTURE_DAYS[i]!, 10)) + '\n', 'utf-8') + } + + process.env['CLAUDE_CONFIG_DIR'] = base + process.env['CODEBURN_CACHE_DIR'] = cacheDir +} + +/** The (date, cost) shape of the daily chart — the thing that froze empty. */ +function dailyShape(payload: Awaited>): Array<[string, number]> { + return payload.history.daily.map(d => [d.date, Math.round(d.cost * 1e6) / 1e6] as [string, number]) +} + +describe('interrupted hydration converges to the uninterrupted result', () => { + it('re-hydrates a session cache + daily history frozen from a partial scan and matches a never-interrupted run', async () => { + // ── Reference: a clean, never-interrupted hydration. ── + await seed() + clearSessionCache() + const reference = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + const refDaily = dailyShape(reference) + // Sanity: the fixture really produced a multi-day, non-zero chart. + expect(reference.current.cost).toBeGreaterThan(0) + expect(refDaily.length).toBeGreaterThanOrEqual(FIXTURE_DAYS.length) + + // ── Simulate an interrupted / raced hydration by poisoning the on-disk caches + // to exactly the artifact such a run leaves behind. ── + await seed() + clearSessionCache() + // Prime real caches, then corrupt them into the "partial" state. + await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + + // (a) Session cache: present but NOT marked complete — an interrupted cold + // start's throttled partial save. + const sessionRaw = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) + sessionRaw.complete = false + await writeFile(sessionCachePath(), JSON.stringify(sessionRaw), 'utf-8') + + // (b) Daily cache: frozen with the older days dropped but `lastComputedDate` + // advanced to yesterday and NO completeness marker — the exact freeze that + // leaves the chart empty for the first ~N days forever. + const yesterday = daysAgo(1) + const yesterdayStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}` + const dailyRaw = JSON.parse(await readFile(dailyCachePath(), 'utf-8')) + dailyRaw.days = [] + dailyRaw.lastComputedDate = yesterdayStr + delete dailyRaw.complete + await writeFile(dailyCachePath(), JSON.stringify(dailyRaw), 'utf-8') + + // ── Relaunch: the parse must detect both caches as incomplete, finish the + // backfill, and converge to the reference. ── + clearSessionCache() + const healed = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + + // The frozen chart is fully restored, day-for-day and cost-for-cost. + expect(dailyShape(healed)).toEqual(refDaily) + // Headline totals converge exactly. + expect(healed.current.cost).toBe(reference.current.cost) + expect(healed.current.calls).toBe(reference.current.calls) + + // And the on-disk markers are now durably complete, so the next launch is warm. + expect(JSON.parse(await readFile(sessionCachePath(), 'utf-8')).complete).toBe(true) + expect(JSON.parse(await readFile(dailyCachePath(), 'utf-8')).complete).toBe(true) + }) +}) diff --git a/tests/menubar-report-parity.test.ts b/tests/menubar-report-parity.test.ts new file mode 100644 index 00000000..7ba70b30 --- /dev/null +++ b/tests/menubar-report-parity.test.ts @@ -0,0 +1,118 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { loadPricing, setLocalModelSavings, setModelAliases } from '../src/models.js' +import { buildMenubarPayloadForRange, buildPeriodData } from '../src/usage-aggregator.js' +import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js' +import type { DateRange } from '../src/types.js' + +// A fixed HISTORICAL day so the range never overlaps "today": the app parses +// today separately on every build, and on a live machine today's own sessions +// change between calls — anchoring in the past keeps the corpus immutable and +// the payload deterministic. +const FIXTURE_DAY = Date.UTC(2026, 3, 16) +const RANGE: DateRange = { + start: new Date(FIXTURE_DAY - 24 * 60 * 60 * 1000), + end: new Date(FIXTURE_DAY + 24 * 60 * 60 * 1000), +} +const PERIOD = { range: RANGE, label: 'Fixture window' } + +let tmpDirs: string[] = [] + +beforeAll(async () => { + await loadPricing() +}) + +beforeEach(() => { + // Runs AFTER the global env-isolation beforeEach, so these win for the test body. + setLocalModelSavings({}) + setModelAliases({}) +}) + +afterEach(async () => { + clearSessionCache() + while (tmpDirs.length > 0) { + const d = tmpDirs.pop() + if (d) await rm(d, { recursive: true, force: true }) + } +}) + +/** Seed one priced Claude session on the fixture day and point discovery + the + * cache dir at isolated temp dirs. Returns the range's expected corpus root. */ +async function seedFixture(): Promise { + const base = await mkdtemp(join(tmpdir(), 'codeburn-parity-src-')) + const cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-parity-cache-')) + tmpDirs.push(base, cacheDir) + + const projectDir = join(base, 'projects', 'p') + await mkdir(projectDir, { recursive: true }) + const line = (id: string, ts: string): string => JSON.stringify({ + type: 'assistant', + timestamp: ts, + sessionId: 's1', + message: { + type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id, + content: [], + usage: { input_tokens: 120000, output_tokens: 24000, cache_creation_input_tokens: 0, cache_read_input_tokens: 500000 }, + }, + }) + await writeFile( + join(projectDir, 's1.jsonl'), + [line('msg-1', '2026-04-16T10:00:00.000Z'), line('msg-2', '2026-04-16T11:30:00.000Z')].join('\n') + '\n', + 'utf-8', + ) + + process.env['CLAUDE_CONFIG_DIR'] = base + process.env['CODEBURN_CACHE_DIR'] = cacheDir +} + +/** Everything but the per-call wall-clock `generated` stamp. */ +function stableShape(payload: unknown): unknown { + const clone = structuredClone(payload) as { generated?: string } + clone.generated = '' + return clone +} + +describe('app payload ↔ CLI report parity', () => { + it('emits byte-identical totals on repeated builds over the same warm cache', async () => { + await seedFixture() + + // Warm the versioned session cache once, then build the overview payload + // twice. clearSessionCache() between builds drops only the in-memory TTL, so + // each build re-runs the full emitter pipeline over the SAME warm disk cache. + clearSessionCache() + await parseAllSessions(RANGE, 'all') + + clearSessionCache() + const first = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + clearSessionCache() + const second = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + + expect(first.current.calls).toBeGreaterThan(0) + // Identical output => the emitters are deterministic on a warm cache (no + // ordering / floating drift between the app's repeated polls). + expect(stableShape(first)).toEqual(stableShape(second)) + }) + + it('matches the CLI report path (buildPeriodData) for the same period + provider', async () => { + await seedFixture() + + clearSessionCache() + const payload = await buildMenubarPayloadForRange(PERIOD, { provider: 'all', optimize: false, timeline: false }) + + // The CLI report / status --format json path: parse the same range, aggregate + // via buildPeriodData. The app's current block is the same function over the + // same projects, so the headline cost must be bit-for-bit equal. + clearSessionCache() + const reportProjects = filterProjectsByName(await parseAllSessions(RANGE, 'all'), [], []) + const report = buildPeriodData(PERIOD.label, reportProjects) + + expect(report.cost).toBeGreaterThan(0) + expect(payload.current.cost).toBe(report.cost) + expect(payload.current.calls).toBe(report.calls) + expect(payload.current.inputTokens).toBe(report.inputTokens) + expect(payload.current.outputTokens).toBe(report.outputTokens) + }) +})