fix: hydration completeness markers, cold-only splash, honest switching, prefetch

P0: an interrupted cold hydration left partial caches that the
emptiness check read as warm — the daily backfill froze (missing older
days) while session parsing healed gradually (drifting totals),
poisoning every surface sharing the cache. Both caches now carry an
explicit complete marker: partial resumes cold under the hydration
lock; unmarked caches self-heal with one re-hydration. Regression test
seeds the exact frozen artifact and asserts bit-for-bit convergence
with a never-interrupted run.

- splash indexing reveals only on genuinely cold scans (explicit cold
  flag in the progress protocol), never on warm incremental re-parses
- uncached filter switches clear to skeleton instead of showing the
  previous filter's numbers; cached switches paint same-commit;
  background prefetch warms every detected provider after idle
- build stamp (git sha + date) in splash and About ends build ambiguity
- payload parity test: identical payloads on identical cache, and
  payload totals equal the CLI report path
- Sessions empty state keeps the provider filter row; zero savings
  line hidden; TCC usage-description strings + Full Disk Access docs
- warm profile documented: optimize scan ~1.47s dominant (safe
  refactor deferred), parse ~1s, aggregation ~30ms

Root 1848, app 320, typechecks clean.
This commit is contained in:
iamtoruk 2026-07-16 14:05:11 -07:00
parent 1b69c83165
commit e9655fa03c
23 changed files with 607 additions and 84 deletions

View file

@ -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

View file

@ -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": {

View file

@ -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<Period, string> = {
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)

View file

@ -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
<span className="about-modal-logo" aria-hidden="true"><FlameMark size={52} /></span>
<div className="about-modal-name" id="about-modal-title">CodeBurn</div>
<div className="about-modal-version">v{version}</div>
<div className="about-modal-build">{BUILD_STAMP}</div>
<div className="about-modal-tagline">Know where every token goes, across every AI coding tool.</div>
</div>
<div className="about-modal-side">

View file

@ -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(<Splash hasData={false} hasError={false} />)
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(<Splash hasData={false} hasError={false} />)

View file

@ -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<string, ProvStatus>
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<string, ProvStatus> = {}
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<Phase>('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
)}
<div className="splash-word">CodeBurn</div>
<div className="splash-version">v{version}</div>
<div className="splash-build">{BUILD_STAMP}</div>
{showDetail && <SplashStatus progress={progress} />}
</div>,
document.body,

View file

@ -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<string>(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 {

View file

@ -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<T>(
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<T>(memoKey)
if (cached !== undefined) { setData(cached); servedCached = true }
else setData(null)
}
setLoading(true)
setSwitching(servedCached)

11
app/renderer/lib/build.ts Normal file
View file

@ -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

View file

@ -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' }

View file

@ -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(<Overview period="30days" provider="all" />)
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 () => {

View file

@ -625,7 +625,9 @@ export function OverviewContent({
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={data.current.cost} animateKey={animateKey} />
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
{saved > 0 && (
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
)}
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}

View file

@ -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(<Sessions period="week" provider="gemini" detectedProviders={detected} onProviderChange={onProviderChange} />)
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)

View file

@ -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 (
<div className="seg session-provider-filter" role="group" aria-label="Filter sessions by provider">
<button
type="button"
className={provider === 'all' ? 'on' : undefined}
aria-pressed={provider === 'all'}
onClick={() => onProviderChange('all')}
>
All
</button>
{detectedProviders.map(entry => (
<button
key={entry.id}
type="button"
className={provider === entry.id ? 'on' : undefined}
aria-pressed={provider === entry.id}
onClick={() => onProviderChange(entry.id)}
>
<ProviderLogo provider={entry.id} size={14} />
{entry.label}
</button>
))}
</div>
)
}
export function Sessions({
period,
provider,
@ -151,6 +187,7 @@ export function Sessions({
if (!report.data.length) {
return (
<Panel title="Sessions">
<ProviderFilterRow provider={provider} detectedProviders={detectedProviders} onProviderChange={onProviderChange} />
<EmptyNote>No sessions in this range yet.</EmptyNote>
</Panel>
)
@ -163,30 +200,7 @@ export function Sessions({
return (
<div className="sessions-list-view">
{report.error && <StaleBanner error={report.error} />}
{detectedProviders.length > 0 && (
<div className="seg session-provider-filter" role="group" aria-label="Filter sessions by provider">
<button
type="button"
className={provider === 'all' ? 'on' : undefined}
aria-pressed={provider === 'all'}
onClick={() => onProviderChange('all')}
>
All
</button>
{detectedProviders.map(entry => (
<button
key={entry.id}
type="button"
className={provider === entry.id ? 'on' : undefined}
aria-pressed={provider === entry.id}
onClick={() => onProviderChange(entry.id)}
>
<ProviderLogo provider={entry.id} size={14} />
{entry.label}
</button>
))}
</div>
)}
<ProviderFilterRow provider={provider} detectedProviders={detectedProviders} onProviderChange={onProviderChange} />
<div className="sessions-toolbar">
<input
className="sessions-search"

View file

@ -162,6 +162,7 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.about-modal-logo { display: inline-block; width: 52px; height: 52px; margin-bottom: 12px; }
.about-modal-name { font-size: 16px; font-weight: 650; letter-spacing: -.01em; }
.about-modal-version { margin-top: 5px; color: var(--mut2); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.about-modal-build { margin-top: 2px; color: var(--mut2); font-size: 10.5px; font-variant-numeric: tabular-nums; opacity: 0.85; }
.about-modal-tagline { max-width: 32ch; margin: 11px auto 0; color: var(--mut); font-size: 12.5px; line-height: 1.5; }
.about-modal-side { display: flex; flex-direction: column; }
.about-modal-section { padding: 14px 18px; border-bottom: 1px solid var(--line2); }
@ -882,6 +883,9 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.splash-version {
margin-top: 2px; font-size: var(--fs-label); font-weight: var(--fw-body); color: #b3a595;
}
.splash-build {
margin-top: 1px; font-size: var(--fs-label); font-weight: var(--fw-body); color: #6f665c; font-variant-numeric: tabular-nums;
}
.splash-lit .splash-mark {
animation: splash-ignite 480ms cubic-bezier(.2,.8,.25,1) both, splash-pulse 2.4s ease-in-out 480ms infinite;

View file

@ -1,6 +1,23 @@
import { execSync } from 'node:child_process'
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
// Build stamp baked in at package/build time so About + the splash footer can
// name the exact commit running. Best-effort: a non-git checkout still builds.
function buildStamp(): { sha: string; date: string } {
let sha = 'unknown'
try {
sha = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim() || 'unknown'
// A build off a dirty tree (local fix build) must never read as the clean
// release of the same commit — that ambiguity is exactly what a build stamp
// exists to kill.
if (execSync('git status --porcelain', { encoding: 'utf8' }).trim()) sha += '-dirty'
} catch { /* not a git checkout */ }
return { sha, date: new Date().toISOString().slice(0, 10) }
}
const stamp = buildStamp()
// The production index.html ships a strict `script-src 'self'`. In dev, Vite's
// React Fast Refresh preamble is injected as an inline <script>, 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 },
})

View file

@ -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<string, unknown>[] } {
function isMigratableCache(parsed: unknown): parsed is { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record<string, unknown>[]; complete?: boolean } {
if (!parsed || typeof parsed !== 'object') return false
const c = parsed as Partial<DailyCache>
if (typeof c.version !== 'number') return false
@ -120,12 +126,15 @@ function migrateDays(days: Record<string, unknown>[]): DailyEntry[] {
}))
}
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record<string, unknown>[] }): DailyCache {
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record<string, unknown>[]; 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<DailyCache> {
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

View file

@ -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<ProjectSummary[]> {
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<ProjectSummary[]> {
async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRange, providerFilter?: string, isCold = false): Promise<ProjectSummary[]> {
const seenMsgIds = new Set<string>()
const seenKeys = new Set<string>()
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

View file

@ -81,6 +81,15 @@ export type ProviderSection = {
export type SessionCache = {
version: number
providers: Record<string, ProviderSection>
/** 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 {

View file

@ -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<DailyCache> {
(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

View file

@ -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')

View file

@ -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<void> {
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<ReturnType<typeof buildMenubarPayloadForRange>>): 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)
})
})

View file

@ -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<void> {
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)
})
})