mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-28 18:35:29 +00:00
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:
parent
1b69c83165
commit
e9655fa03c
23 changed files with 607 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
11
app/renderer/lib/build.ts
Normal 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
|
||||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
124
tests/hydration-interrupt-convergence.test.ts
Normal file
124
tests/hydration-interrupt-convergence.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
118
tests/menubar-report-parity.test.ts
Normal file
118
tests/menubar-report-parity.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue