From 300e949951767759898ccf379cd9b6f251965d2e Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Thu, 16 Jul 2026 11:47:05 -0700 Subject: [PATCH] feat(app): refresh cadence, instant switching, cache coherence, telemetry v1 Performance and coherence: - Settings > General 'Refresh every' (Manual/30s/1m/3m/5m/10m), live via RefreshCadenceContext; Manual polls only on demand - usePolled memoKey LRU: provider/period switches paint the last-good result instantly with a switching hairline while refreshing quietly - quota TTL 5min + honest rate-limited copy on 429 backoff - version-suffixed cache files (session-cache.v5.json, daily-cache.v12, auto-minted on future bumps); legacy files never written or deleted, adopted once when versions match: old and new binaries coexist without clobbering (field-observed menubar-vs-desktop ping-pong) - advisory hydration lock: concurrent cold starts share one scan (wait-then-read-warm), stale/dead locks self-heal, never a correctness gate Telemetry v1 + onboarding (desktop only, per owner decisions): - first-launch onboarding (3 feature screens + consent); region-split default (EU/EEA/UK/CH off, elsewhere on, unknown off); nothing sends before consent completion or while off; dev builds never send - anonymous install UUID, rotated on opt-out; day-granularity events, cost buckets only; whitelisted names; 200-event queue, 5min flush - Settings > Privacy live toggle replaces the static claim Zero computed-number changes. App 316/316, root 1805. Wire contract targets api.codeburn.app/v1/telemetry (Worker follows). --- app/electron/main.test.ts | 62 +++++ app/electron/main.ts | 56 ++++- app/electron/preload.ts | 4 + app/electron/quota/index.ts | 10 +- app/electron/quota/types.ts | 4 + app/electron/telemetry.test.ts | 162 ++++++++++++ app/electron/telemetry.ts | 260 ++++++++++++++++++++ app/renderer/App.tsx | 86 ++++++- app/renderer/components/Onboarding.test.tsx | 63 +++++ app/renderer/components/Onboarding.tsx | 107 ++++++++ app/renderer/hooks/usePolled.test.ts | 72 ++++++ app/renderer/hooks/usePolled.ts | 79 +++++- app/renderer/lib/refreshCadence.tsx | 55 +++++ app/renderer/lib/types.ts | 16 ++ app/renderer/sections/Compare.tsx | 3 +- app/renderer/sections/Models.tsx | 4 +- app/renderer/sections/Optimize.test.tsx | 2 +- app/renderer/sections/Optimize.tsx | 4 +- app/renderer/sections/Overview.test.tsx | 3 +- app/renderer/sections/Overview.tsx | 4 +- app/renderer/sections/Plans.tsx | 10 +- app/renderer/sections/Sessions.tsx | 2 +- app/renderer/sections/Settings.tsx | 27 +- app/renderer/sections/Spend.tsx | 2 +- app/renderer/styles/plain.css | 76 ++++++ app/renderer/test/setup.ts | 8 +- src/daily-cache.ts | 70 ++++-- src/parser.ts | 19 +- src/session-cache.ts | 146 ++++++++++- tests/cache-persistence-coldstart.test.ts | 4 +- tests/codex-cache-invalidation.test.ts | 3 +- tests/daily-cache.test.ts | 67 +++-- tests/kiro-cache-invalidation.test.ts | 3 +- tests/parser-antigravity-timestamp.test.ts | 3 +- tests/parser-gemini-cache.test.ts | 6 +- tests/parser-hydration-lock.test.ts | 138 +++++++++++ tests/parser.test.ts | 4 +- tests/providers/codewhale.test.ts | 3 +- tests/session-cache.test.ts | 74 +++++- 39 files changed, 1630 insertions(+), 91 deletions(-) create mode 100644 app/electron/telemetry.test.ts create mode 100644 app/electron/telemetry.ts create mode 100644 app/renderer/components/Onboarding.test.tsx create mode 100644 app/renderer/components/Onboarding.tsx create mode 100644 app/renderer/lib/refreshCadence.tsx create mode 100644 tests/parser-hydration-lock.test.ts diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts index 74e592c5..8b45cd71 100644 --- a/app/electron/main.test.ts +++ b/app/electron/main.test.ts @@ -60,6 +60,10 @@ const CHANNELS = [ 'codeburn:resetPlan', 'codeburn:exportData', 'codeburn:cliStatus', + 'codeburn:telemetryStatus', + 'codeburn:telemetrySetEnabled', + 'codeburn:telemetryOnboarded', + 'codeburn:telemetryTrack', ] as const const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> = [ @@ -320,3 +324,61 @@ describe('createBridgeHandlers (cold-start warmup)', () => { expect(emitProgress).toHaveBeenCalledWith({ kind: 'tick', provider: 'claude', done: 5, total: 10 }) }) }) + +describe('createBridgeHandlers (telemetry wiring)', () => { + const fakeTelemetry = () => ({ + status: vi.fn(() => ({ installId: 'id-1', country: 'US', enabled: true, defaultEnabled: true, onboarded: false })), + setEnabled: vi.fn((enabled: boolean) => ({ installId: 'id-2', country: 'US', enabled, defaultEnabled: true, onboarded: false })), + completeOnboarding: vi.fn((enabled: boolean) => ({ installId: 'id-1', country: 'US', enabled, defaultEnabled: true, onboarded: true })), + track: vi.fn(), + }) + const deps = (telemetry: ReturnType | null) => ({ + spawnCli: vi.fn(async () => ({ current: { cost: 1 } })), + spawnCliAction: vi.fn(), + resolveCodeburnPath: () => '/bin/codeburn', + getQuota: vi.fn(async () => []), + emitProgress: vi.fn(), + telemetry, + }) + + it('exposes status/consent/track channels and forwards to the telemetry service', async () => { + const telemetry = fakeTelemetry() + const handlers = createBridgeHandlers(deps(telemetry)) + + expect(await handlers['codeburn:telemetryStatus']!()).toMatchObject({ ok: true, value: { installId: 'id-1', onboarded: false } }) + expect(await handlers['codeburn:telemetrySetEnabled']!(false)).toMatchObject({ ok: true, value: { enabled: false } }) + expect(telemetry.setEnabled).toHaveBeenCalledWith(false) + expect(await handlers['codeburn:telemetryOnboarded']!(true)).toMatchObject({ ok: true, value: { onboarded: true } }) + expect(telemetry.completeOnboarding).toHaveBeenCalledWith(true) + await handlers['codeburn:telemetryTrack']!('section_view', { section: 'spend' }) + expect(telemetry.track).toHaveBeenCalledWith('section_view', { section: 'spend' }) + }) + + it('returns null (not an error) when telemetry is unavailable', async () => { + const handlers = createBridgeHandlers(deps(null)) + expect(await handlers['codeburn:telemetryStatus']!()).toEqual({ ok: true, value: null }) + expect(await handlers['codeburn:telemetryTrack']!('section_view', {})).toEqual({ ok: true, value: true }) + }) + + it('tracks cold_start once on the first overview success, with duration', async () => { + const telemetry = fakeTelemetry() + const handlers = createBridgeHandlers(deps(telemetry)) + await handlers['codeburn:getOverview']!('30days', 'all') + await handlers['codeburn:getOverview']!('30days', 'all') + const coldStarts = telemetry.track.mock.calls.filter(([name]) => name === 'cold_start') + expect(coldStarts.length).toBe(1) + expect(coldStarts[0]![1]).toMatchObject({ timedOut: false }) + expect(typeof (coldStarts[0]![1] as { ms: number }).ms).toBe('number') + }) + + it('tracks cli_error kinds from failing handlers', async () => { + const telemetry = fakeTelemetry() + const failing = { + ...deps(telemetry), + spawnCli: vi.fn(async () => { throw new CliError('timeout', 'timed out') }), + } + const handlers = createBridgeHandlers(failing) + await handlers['codeburn:getSessions']!('week', 'all') + expect(telemetry.track).toHaveBeenCalledWith('cli_error', { kind: 'timeout' }) + }) +}) diff --git a/app/electron/main.ts b/app/electron/main.ts index cf3596f0..de3d5805 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -3,6 +3,13 @@ import path from 'node:path' import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult } from './cli' import { getQuota, sanitizeError } from './quota' +import { Telemetry } from './telemetry' + +// Initialized in bootstrap() once Electron paths exist; stays null under tests. +let telemetryInstance: Telemetry | null = null + +/** The slice of Telemetry the bridge handlers use — injectable for tests. */ +export type TelemetryBridge = Pick // Result envelope: handlers never throw across IPC so the structured error // `kind` survives contextBridge serialization. preload.ts unwraps it. @@ -127,6 +134,8 @@ type Deps = { getQuota: typeof getQuota /** Forward cold-start scan-progress events to the renderer splash. */ emitProgress?: (event: unknown) => void + /** Consent-gated anonymous telemetry; absent under tests unless injected. */ + telemetry?: TelemetryBridge | null } type Handler = (...args: any[]) => Promise @@ -136,8 +145,9 @@ type Handler = (...args: any[]) => Promise * shell) and returns a result envelope. Pure + injectable so the wiring is * unit-testable without launching Electron. */ -export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress }): Record { +export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, resolveCodeburnPath, getQuota, emitProgress: broadcastProgress, telemetry: telemetryInstance }): Record { const emitProgress = deps.emitProgress ?? (() => {}) + const telemetry = deps.telemetry ?? null // Flips true after the first overview fetch succeeds. Until then, every // overview fetch runs cold (long timeout + progress streaming); the shared // spawnCli coalescing means concurrent same-arg re-polls join one child. @@ -147,7 +157,9 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re try { return { ok: true, value: await deps.spawnCli(build(...args)) } } catch (err) { - return { ok: false, error: toEnvelopeError(err) } + const error = toEnvelopeError(err) + telemetry?.track('cli_error', { kind: error.kind }) + return { ok: false, error } } } @@ -160,6 +172,7 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re ] const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null) => { + const startedAt = Date.now() try { const args = buildOverviewArgs(period, provider, range, configSource) if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args) } @@ -170,9 +183,13 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re }) overviewWarmed = true emitProgress({ kind: 'done' }) + telemetry?.track('cold_start', { ms: Date.now() - startedAt, timedOut: false }) return { ok: true, value } } catch (err) { - return { ok: false, error: toEnvelopeError(err) } + const error = toEnvelopeError(err) + if (!overviewWarmed) telemetry?.track('cold_start', { ms: Date.now() - startedAt, timedOut: error.kind === 'timeout' }) + telemetry?.track('cli_error', { kind: error.kind }) + return { ok: false, error } } } const runAction = (build: (...args: any[]) => string[]): Handler => async (...args: any[]) => { @@ -239,6 +256,15 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re const p = deps.resolveCodeburnPath() return { ok: true, value: { found: p !== null, path: p } } }, + // Telemetry consent + events. Value is null when telemetry is unavailable + // (tests, or init failure) — the renderer treats null as "no onboarding". + 'codeburn:telemetryStatus': async () => ({ ok: true, value: telemetry ? telemetry.status() : null }), + 'codeburn:telemetrySetEnabled': async (enabled?: boolean) => ({ ok: true, value: telemetry ? telemetry.setEnabled(Boolean(enabled)) : null }), + 'codeburn:telemetryOnboarded': async (enabled?: boolean) => ({ ok: true, value: telemetry ? telemetry.completeOnboarding(Boolean(enabled)) : null }), + 'codeburn:telemetryTrack': async (name?: string, props?: unknown) => { + telemetry?.track(String(name ?? ''), props) + return { ok: true, value: true } + }, } } @@ -400,9 +426,31 @@ function bootstrap(): void { win.focus() }) - app.on('before-quit', () => killAll()) + app.on('before-quit', () => { + killAll() + // Best-effort final beat: session duration + whatever is still queued. + telemetryInstance?.trackClose() + void telemetryInstance?.flush() + }) void app.whenReady().then(() => { + // Consent-gated anonymous telemetry (desktop only). Nothing transmits until + // the onboarding consent screen is completed and the toggle is on; EU/EEA/ + // UK/CH installs default the toggle off. Dev builds never send. + try { + telemetryInstance = new Telemetry({ + stateDir: app.getPath('userData'), + country: app.getLocaleCountryCode() || null, + isPackaged: app.isPackaged, + appVersion: app.getVersion(), + }) + // completeOnboarding tracks the first app_open itself; only already- + // onboarded installs record subsequent opens here. + if (telemetryInstance.status().onboarded) telemetryInstance.track('app_open', {}) + setInterval(() => { void telemetryInstance?.flush() }, 5 * 60_000) + } catch (err) { + console.error('telemetry init failed (continuing without):', err) + } registerHandlers() installApplicationMenu() createWindow() diff --git a/app/electron/preload.ts b/app/electron/preload.ts index 106c7f74..c6858462 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -50,6 +50,10 @@ const bridge = { exportData: (format: string, provider: string, outPath: string) => invoke('codeburn:exportData', format, provider, outPath), chooseDirectory: () => invoke('codeburn:chooseDirectory'), cliStatus: () => invoke('codeburn:cliStatus'), + telemetryStatus: () => invoke('codeburn:telemetryStatus'), + setTelemetryEnabled: (enabled: boolean) => invoke('codeburn:telemetrySetEnabled', enabled), + completeOnboarding: (enabled: boolean) => invoke('codeburn:telemetryOnboarded', enabled), + telemetryTrack: (name: string, props?: Record) => invoke('codeburn:telemetryTrack', name, props), openExternal: (url: string) => ipcRenderer.invoke('open-external', url), // Cold-start scan progress (main → renderer). Returns an unsubscribe fn. onProgress: (cb: (event: unknown) => void) => { diff --git a/app/electron/quota/index.ts b/app/electron/quota/index.ts index 50436231..a17dac19 100644 --- a/app/electron/quota/index.ts +++ b/app/electron/quota/index.ts @@ -28,7 +28,9 @@ const defaultDeps: QuotaDeps = { readFile: readSecureFile, writeFile: atomicWriteSecureFile, now: Date.now, - refreshMs: 2 * 60_000, + // Politeness floor: quota stays gentle regardless of the app refresh cadence. + // A user-initiated force refresh still bypasses this (invalidate()). + refreshMs: 5 * 60_000, } function unavailable(provider: ProviderName, connection: QuotaProvider['connection']): QuotaProvider { @@ -89,11 +91,11 @@ export class QuotaService { // poll; keep showing the live connection rather than flapping to // disconnected. A forced refresh re-reads the keychain and reveals truth. if (!allowKeychain && (next.connection === 'disconnected' || next.connection === 'accessDenied')) return previous - if (next.connection === 'transientFailure') return { ...previous, connection: 'transientFailure' } + if (next.connection === 'transientFailure') return { ...previous, connection: 'transientFailure', rateLimited: next.rateLimited } return next } const until = blocked[provider] ? Date.parse(blocked[provider]!) : NaN - if (Number.isFinite(until) && until > this.deps.now()) return retainOnFailure(unavailable(provider, 'transientFailure')) + if (Number.isFinite(until) && until > this.deps.now()) return retainOnFailure({ ...unavailable(provider, 'transientFailure'), rateLimited: true }) const generation = this.generations[provider] const controller = new AbortController() this.controllers[provider] = controller @@ -104,6 +106,8 @@ export class QuotaService { if (result.retryAfterSeconds !== undefined) { blocked[provider] = new Date(this.deps.now() + result.retryAfterSeconds * 1000).toISOString() await this.writeBlocked(blocked) + if (this.controllers[provider] === controller) this.controllers[provider] = undefined + return retainOnFailure({ ...result.quota, rateLimited: true }) } else if (blocked[provider]) { delete blocked[provider] await this.writeBlocked(blocked) diff --git a/app/electron/quota/types.ts b/app/electron/quota/types.ts index 143a7d81..981025b7 100644 --- a/app/electron/quota/types.ts +++ b/app/electron/quota/types.ts @@ -11,6 +11,10 @@ export type QuotaProvider = { details: QuotaWindow[] planLabel: string | null footerLines: string[] + /** Set when the provider is in a 429 backoff window (rate limited by the + * upstream quota endpoint), so the UI can say so honestly instead of the + * generic "waiting" copy. */ + rateLimited?: boolean } export type ProviderName = QuotaProvider['provider'] diff --git a/app/electron/telemetry.test.ts b/app/electron/telemetry.test.ts new file mode 100644 index 00000000..4f1f2228 --- /dev/null +++ b/app/electron/telemetry.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { defaultEnabledFor, Telemetry } from './telemetry' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'cb-telemetry-')) + delete process.env.CODEBURN_TELEMETRY_DEV +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + delete process.env.CODEBURN_TELEMETRY_DEV +}) + +function make(over: Partial[0]> = {}) { + const posts: Array<{ url: string; body: unknown }> = [] + const fetchFn = vi.fn(async (url: unknown, init?: { body?: unknown }) => { + posts.push({ url: String(url), body: JSON.parse(String(init?.body)) }) + return { ok: true } as Response + }) as unknown as typeof fetch + const telemetry = new Telemetry({ + stateDir: dir, + country: 'US', + isPackaged: true, + appVersion: '1.0.0', + fetchFn, + ...over, + }) + return { telemetry, posts, fetchFn } +} + +describe('regional consent defaults', () => { + it('defaults OFF in the EU/EEA/UK/CH and for unknown regions, ON elsewhere', () => { + for (const c of ['DE', 'FR', 'NL', 'SE', 'GB', 'CH', 'NO', 'IS']) expect(defaultEnabledFor(c), c).toBe(false) + for (const c of ['US', 'CA', 'JP', 'AU', 'BR', 'IN']) expect(defaultEnabledFor(c), c).toBe(true) + expect(defaultEnabledFor(null)).toBe(false) + expect(defaultEnabledFor(undefined)).toBe(false) + }) + + it('a fresh EU install starts disabled; a fresh US install starts enabled', () => { + const eu = new Telemetry({ stateDir: join(dir, 'eu'), country: 'DE', isPackaged: true, appVersion: '1' }) + expect(eu.status()).toMatchObject({ enabled: false, defaultEnabled: false, onboarded: false }) + const us = new Telemetry({ stateDir: join(dir, 'us'), country: 'US', isPackaged: true, appVersion: '1' }) + expect(us.status()).toMatchObject({ enabled: true, defaultEnabled: true, onboarded: false }) + }) +}) + +describe('consent gating', () => { + it('never sends before onboarding completes, even when enabled', async () => { + const { telemetry, posts } = make() + telemetry.track('app_open', {}) + expect(await telemetry.flush()).toBe(false) + expect(posts.length).toBe(0) + }) + + it('sends after onboarding, and stops (dropping the queue) when disabled', async () => { + const { telemetry, posts } = make() + telemetry.completeOnboarding(true) + telemetry.track('section_view', { section: 'spend' }) + expect(await telemetry.flush()).toBe(true) + expect(posts.length).toBe(1) + + telemetry.setEnabled(false) + telemetry.track('section_view', { section: 'models' }) + expect(telemetry.queueLength).toBe(0) + expect(await telemetry.flush()).toBe(false) + expect(posts.length).toBe(1) + }) + + it('opting out rotates the install id so history cannot be linked', () => { + const { telemetry } = make() + const before = telemetry.status().installId + telemetry.setEnabled(false) + const after = telemetry.status().installId + expect(after).not.toBe(before) + }) + + it('unpackaged (dev) builds never send unless CODEBURN_TELEMETRY_DEV=1', async () => { + const { telemetry, posts } = make({ isPackaged: false }) + telemetry.completeOnboarding(true) + expect(await telemetry.flush()).toBe(false) + expect(posts.length).toBe(0) + + process.env.CODEBURN_TELEMETRY_DEV = '1' + telemetry.track('app_open', {}) + expect(await telemetry.flush()).toBe(true) + expect(posts.length).toBe(1) + }) + + it('persists consent + install id across instances', () => { + const { telemetry } = make() + const id = telemetry.completeOnboarding(true).installId + const reloaded = new Telemetry({ stateDir: dir, country: 'US', isPackaged: true, appVersion: '1' }) + expect(reloaded.status()).toMatchObject({ installId: id, enabled: true, onboarded: true }) + const raw = JSON.parse(readFileSync(join(dir, 'telemetry.v1.json'), 'utf-8')) + expect(raw.installId).toBe(id) + }) +}) + +describe('events', () => { + it('drops unknown event names and sanitizes props', async () => { + const { telemetry, posts } = make() + telemetry.completeOnboarding(true) // queues app_open + telemetry.track('totally_made_up', { a: 1 }) + telemetry.track('section_view', { + section: 'x'.repeat(500), + junk: { nested: 'object' }, + fn: () => {}, + nan: NaN, + ok: 42, + }) + await telemetry.flush() + const body = posts[0]!.body as { events: Array<{ name: string; day: string; props: Record }> } + expect(body.events.map(e => e.name)).toEqual(['app_open', 'section_view']) + const props = body.events[1]!.props + expect((props.section as string).length).toBe(64) + expect(props.junk).toBeUndefined() + expect(props.fn).toBeUndefined() + expect(props.nan).toBeUndefined() + expect(props.ok).toBe(42) + // Day-granularity timestamps only. + expect(body.events[0]!.day).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('caps usage_snapshot at one per calendar day', () => { + const { telemetry } = make() + telemetry.completeOnboarding(true) + const before = telemetry.queueLength + telemetry.track('usage_snapshot', { costBucket: '1-10' }) + telemetry.track('usage_snapshot', { costBucket: '10-50' }) + expect(telemetry.queueLength).toBe(before + 1) + }) + + it('keeps the queue on a failed POST and clears it on success', async () => { + let ok = false + const fetchFn = vi.fn(async () => ({ ok })) as unknown as typeof fetch + const { telemetry } = make({ fetchFn }) + telemetry.completeOnboarding(true) + expect(await telemetry.flush()).toBe(false) + expect(telemetry.queueLength).toBe(1) + ok = true + expect(await telemetry.flush()).toBe(true) + expect(telemetry.queueLength).toBe(0) + }) + + it('batches with the wire contract: schema, installId, app block, events', async () => { + const { telemetry, posts } = make() + telemetry.completeOnboarding(true) + await telemetry.flush() + const body = posts[0]!.body as Record + expect(body.schema).toBe(1) + expect(typeof body.installId).toBe('string') + expect(body.app).toMatchObject({ name: 'codeburn-desktop', version: '1.0.0', country: 'US' }) + expect(Array.isArray(body.events)).toBe(true) + }) +}) diff --git a/app/electron/telemetry.ts b/app/electron/telemetry.ts new file mode 100644 index 00000000..469a23e0 --- /dev/null +++ b/app/electron/telemetry.ts @@ -0,0 +1,260 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +// Anonymous, consent-gated product telemetry for the desktop app ONLY. +// Runs entirely in the Electron main process. Like cli.ts, this module must +// NOT import `electron` so it stays unit-testable in plain node; main.ts +// injects the electron-derived bits (userData path, country, isPackaged). +// +// Privacy invariants (enforced here, not by the caller): +// - Nothing is ever sent before the user completes the onboarding consent +// screen, and nothing is sent while the toggle is off. +// - EU/EEA/UK/CH installs default the toggle OFF; everywhere else defaults ON. +// Either way the user decides on the consent screen. +// - The only identifier is a random UUID minted locally. No fingerprinting. +// - Events carry day-granularity timestamps only, and props pass a whitelist +// sanitizer (short strings / finite numbers / booleans, capped arrays). +// - Dev / unpackaged builds never send (CODEBURN_TELEMETRY_DEV=1 overrides +// for end-to-end testing). + +export const TELEMETRY_ENDPOINT = 'https://api.codeburn.app/v1/telemetry' +export const TELEMETRY_SCHEMA = 1 + +// EU-27 + EEA (IS, LI, NO) + UK + CH: conservative "default off" region. +const DEFAULT_OFF_COUNTRIES = new Set([ + 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', + 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', + 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'CH', +]) + +export function defaultEnabledFor(country: string | null | undefined): boolean { + if (!country) return false // unknown region: be conservative + return !DEFAULT_OFF_COUNTRIES.has(country.toUpperCase()) +} + +export const EVENT_NAMES = new Set([ + 'app_open', + 'app_close', + 'section_view', + 'cold_start', + 'usage_snapshot', + 'cli_error', +]) + +const MAX_QUEUE = 200 +const MAX_STRING = 64 +const MAX_ARRAY = 12 +const MAX_KEYS = 16 + +export type TelemetryStatus = { + installId: string + country: string | null + enabled: boolean + defaultEnabled: boolean + /** True once the user has been through the onboarding consent screen. */ + onboarded: boolean +} + +type PersistedState = { + version: 1 + installId: string + enabled: boolean + onboardedAt?: string + lastSnapshotDay?: string +} + +type Deps = { + /** Directory for the consent/state file (Electron userData in production). */ + stateDir: string + /** ISO-3166 alpha-2 from the OS locale, or null when unknown. */ + country: string | null + /** Only packaged builds send (unless CODEBURN_TELEMETRY_DEV=1). */ + isPackaged: boolean + appVersion: string + platform?: string + arch?: string + endpoint?: string + fetchFn?: typeof fetch + now?: () => Date +} + +type QueuedEvent = { name: string; day: string; props: Record } + +function dayKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +function sanitizeValue(value: unknown): unknown | undefined { + if (typeof value === 'string') return value.slice(0, MAX_STRING) + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined + if (typeof value === 'boolean') return value + return undefined +} + +/** Whitelist sanitizer: primitives, plus one level of arrays-of-flat-objects. */ +export function sanitizeProps(props: unknown): Record { + if (!props || typeof props !== 'object' || Array.isArray(props)) return {} + const out: Record = {} + let keys = 0 + for (const [key, value] of Object.entries(props as Record)) { + if (keys >= MAX_KEYS) break + const k = key.slice(0, MAX_STRING) + if (Array.isArray(value)) { + const items: Record[] = [] + for (const entry of value.slice(0, MAX_ARRAY)) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue + const flat: Record = {} + let inner = 0 + for (const [ik, iv] of Object.entries(entry as Record)) { + if (inner >= MAX_KEYS) break + const sv = sanitizeValue(iv) + if (sv === undefined) continue + flat[ik.slice(0, MAX_STRING)] = sv + inner++ + } + if (Object.keys(flat).length > 0) items.push(flat) + } + if (items.length > 0) { out[k] = items; keys++ } + continue + } + const sv = sanitizeValue(value) + if (sv === undefined) continue + out[k] = sv + keys++ + } + return out +} + +export class Telemetry { + private readonly deps: Required> & Deps + private state: PersistedState + private queue: QueuedEvent[] = [] + private openedAt: number + + constructor(deps: Deps) { + this.deps = deps + this.state = this.load() + this.openedAt = Date.now() + } + + private stateFile(): string { + return join(this.deps.stateDir, 'telemetry.v1.json') + } + + private load(): PersistedState { + try { + const raw = JSON.parse(readFileSync(this.stateFile(), 'utf-8')) as Partial + if (raw && raw.version === 1 && typeof raw.installId === 'string' && typeof raw.enabled === 'boolean') { + return { + version: 1, + installId: raw.installId, + enabled: raw.enabled, + onboardedAt: typeof raw.onboardedAt === 'string' ? raw.onboardedAt : undefined, + lastSnapshotDay: typeof raw.lastSnapshotDay === 'string' ? raw.lastSnapshotDay : undefined, + } + } + } catch { /* first run or unreadable — start fresh */ } + return { version: 1, installId: randomUUID(), enabled: defaultEnabledFor(this.deps.country) } + } + + private save(): void { + try { + const file = this.stateFile() + if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify(this.state), { mode: 0o600 }) + } catch { /* consent must still work in-memory */ } + } + + status(): TelemetryStatus { + return { + installId: this.state.installId, + country: this.deps.country, + enabled: this.state.enabled, + defaultEnabled: defaultEnabledFor(this.deps.country), + onboarded: this.state.onboardedAt !== undefined, + } + } + + setEnabled(enabled: boolean): TelemetryStatus { + this.state.enabled = enabled + // Opting out mints a fresh id so past and future data cannot be linked. + if (!enabled) { + this.queue = [] + this.state.installId = randomUUID() + } + this.save() + return this.status() + } + + /** The onboarding consent screen's final decision. Unlocks sending. */ + completeOnboarding(enabled: boolean): TelemetryStatus { + this.state.onboardedAt = new Date().toISOString() + const next = this.setEnabled(enabled) + this.track('app_open', {}) + return next + } + + private get canSend(): boolean { + if (!this.state.enabled || this.state.onboardedAt === undefined) return false + return this.deps.isPackaged || process.env.CODEBURN_TELEMETRY_DEV === '1' + } + + /** Queue an event. Unknown names and junk props are dropped, never thrown. */ + track(name: string, props: unknown): void { + if (!EVENT_NAMES.has(name)) return + if (!this.state.enabled) return + // usage_snapshot is an aggregate: at most one per calendar day. + const now = (this.deps.now ?? (() => new Date()))() + if (name === 'usage_snapshot') { + const day = dayKey(now) + if (this.state.lastSnapshotDay === day) return + this.state.lastSnapshotDay = day + this.save() + } + if (this.queue.length >= MAX_QUEUE) return + this.queue.push({ name, day: dayKey(now), props: sanitizeProps(props) }) + } + + /** Record session duration; queued for the next (final) flush. */ + trackClose(): void { + this.track('app_close', { sessionMinutes: Math.round((Date.now() - this.openedAt) / 60_000) }) + } + + /** Best-effort batch POST. Keeps the queue on failure, clears on success. */ + async flush(): Promise { + if (!this.canSend || this.queue.length === 0) return false + const events = this.queue + const body = JSON.stringify({ + schema: TELEMETRY_SCHEMA, + installId: this.state.installId, + app: { + name: 'codeburn-desktop', + version: this.deps.appVersion, + platform: this.deps.platform ?? process.platform, + arch: this.deps.arch ?? process.arch, + country: this.deps.country, + }, + events, + }) + try { + const fetchFn = this.deps.fetchFn ?? fetch + const res = await fetchFn(this.deps.endpoint ?? TELEMETRY_ENDPOINT, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }) + if (!res.ok) return false + // Only drop what was sent; events tracked mid-flight stay queued. + this.queue = this.queue.filter(e => !events.includes(e)) + return true + } catch { + return false + } + } + + /** Visible for tests. */ + get queueLength(): number { + return this.queue.length + } +} diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx index c60da344..220070ae 100644 --- a/app/renderer/App.tsx +++ b/app/renderer/App.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { EmptyNote } from './components/EmptyState' import { ErrorBoundary } from './components/ErrorBoundary' import { Hint } from './components/Hint' +import { Onboarding } from './components/Onboarding' import { Panel } from './components/Panel' import { Sidebar, type Section } from './components/Sidebar' import { Splash } from './components/Splash' @@ -15,6 +16,7 @@ import { formatCompact, formatUsd, setActiveCurrency } from './lib/format' import { motionClass } from './lib/motion' import { codeburn } from './lib/ipc' import { localDateKey } from './lib/period' +import { persistRefreshValue, readRefreshValue, refreshValueToMs, RefreshCadenceContext, type RefreshCadence } from './lib/refreshCadence' import { OverviewContent } from './sections/Overview' import { OptimizeContent } from './sections/Optimize' import { Models } from './sections/Models' @@ -23,7 +25,36 @@ import { Compare } from './sections/Compare' import { Plans } from './sections/Plans' import { Settings, type SettingsPane } from './sections/Settings' import { SpendContent } from './sections/Spend' -import type { DateRange, MenubarPayload, Period } from './lib/types' +import type { DateRange, MenubarPayload, Period, TelemetryStatus } from './lib/types' + +// Bucket raw dollar amounts before they leave the machine: telemetry carries +// coarse ranges, never exact spend. +function costBucket(usd: number): string { + if (usd < 1) return '<1' + if (usd < 10) return '1-10' + if (usd < 50) return '10-50' + if (usd < 200) return '50-200' + if (usd < 1000) return '200-1k' + return '1k+' +} + +/** The once-per-day anonymous aggregate (main process dedups by calendar day). */ +function usageSnapshotProps(payload: MenubarPayload): Record { + return { + period: payload.current.label, + providerCount: Object.keys(payload.current.providers).length, + costBucket: costBucket(payload.current.cost), + models: (payload.current.topModels ?? []).slice(0, 8).map(model => ({ + name: model.name, + costBucket: costBucket(model.cost), + })), + categories: (payload.current.topActivities ?? []).slice(0, 12).map(activity => ({ + name: activity.name, + // Task-completion signal: share of turns resolved in one shot, 2dp. + oneShotRate: activity.oneShotRate == null ? -1 : Math.round(activity.oneShotRate * 100) / 100, + })), + } +} const SECTION_TITLES: Record = { overview: 'Overview', @@ -88,7 +119,26 @@ function refreshedLabel(lastSuccessAt: number | null, loading: boolean, now: num return `refreshed ${minutes}m ago` } +/** Provides the app-wide refresh cadence (read persisted at boot, applied live) + * so every usePolled below reads it as its default interval. */ export function App() { + const [refreshValue, setRefreshValue] = useState(readRefreshValue) + const setValue = useCallback((value: string) => { + setRefreshValue(value) + persistRefreshValue(value) + }, []) + const cadence = useMemo( + () => ({ value: refreshValue, intervalMs: refreshValueToMs(refreshValue), setValue }), + [refreshValue, setValue], + ) + return ( + + + + ) +} + +function AppMain() { const [section, setSection] = useState
('overview') const [settingsPane, setSettingsPane] = useState('general') const [period, setPeriod] = useState(initialPeriod) @@ -109,6 +159,7 @@ export function App() { ? 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 ?? ''}` }, ) const refreshOverview = overview.refresh @@ -119,6 +170,32 @@ export function App() { // (resolved) error; after that everything polls normally. const ready = overview.data != null || overview.error != null + // First-launch onboarding: shown until the telemetry consent screen has been + // completed once. All telemetry bridge calls are typeof-guarded so an older + // preload (or the test bridge mock) degrades to "no onboarding, no tracking". + const [onboardingStatus, setOnboardingStatus] = useState(null) + useEffect(() => { + if (typeof codeburn.telemetryStatus !== 'function') return + codeburn.telemetryStatus() + .then(status => { if (status && !status.onboarded) setOnboardingStatus(status) }) + .catch(() => { /* telemetry unavailable — skip onboarding */ }) + }, []) + const finishOnboarding = useCallback((enabled: boolean) => { + setOnboardingStatus(null) + if (typeof codeburn.completeOnboarding === 'function') void codeburn.completeOnboarding(enabled).catch(() => {}) + }, []) + + const trackEvent = useCallback((name: string, props?: Record) => { + if (typeof codeburn.telemetryTrack === 'function') void codeburn.telemetryTrack(name, props).catch(() => {}) + }, []) + + // Once-per-day anonymous usage aggregate, only from the canonical view + // (all providers, standard period, no config scope) so buckets are stable. + useEffect(() => { + if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return + trackEvent('usage_snapshot', usageSnapshotProps(overview.data)) + }, [overview.data, provider, customRange, claudeConfigSource, trackEvent]) + useEffect(() => { let saved: string | null = null try { saved = globalThis.localStorage?.getItem('codeburn.theme') ?? null } catch { /* storage can be unavailable */ } @@ -172,7 +249,8 @@ export function App() { const navigate = useCallback((next: Section, pane: SettingsPane = 'general') => { setSettingsPane(pane) setSection(next) - }, []) + trackEvent('section_view', { section: next }) + }, [trackEvent]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -238,7 +316,9 @@ export function App() { } /> + {onboardingStatus && }
+
{ setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+
({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
{ const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
{budgetError &&

{budgetError}

}
@@ -313,6 +317,8 @@ function DetectedRow({ quota, onReconnect }: { quota: QuotaProvider; onReconnect {name} {quota.connection === 'disconnected' || quota.connection === 'accessDenied' ?
+ : quota.rateLimited + ? {rateLimitedNote(quota.provider)} : {quota.planLabel ?? 'Connected'}} } @@ -416,11 +422,28 @@ function DevicesPane({ period, refreshToken }: { period: Period; refreshToken: n function PrivacyPane() { return

Privacy & data

What codeburn does, and does not do, with your data.

} /> - } /> } /> +
} +/** The anonymous-telemetry consent toggle, mirroring the onboarding decision. */ +function TelemetryClaim() { + const [status, setStatus] = useState(null) + useEffect(() => { + if (typeof codeburn.telemetryStatus !== 'function') return + codeburn.telemetryStatus().then(value => setStatus(value)).catch(() => {}) + }, []) + if (!status) return null + const toggle = () => { + if (typeof codeburn.setTelemetryEnabled !== 'function') return + codeburn.setTelemetryEnabled(!status.enabled).then(value => setStatus(value)).catch(() => {}) + } + return
Anonymous telemetry
Optional usage statistics: model mix, task success, performance and errors. Never prompts, code or anything identifying.
+ +
+} + function PrivacyClaim({ title, detail, icon }: { title: string; detail: string; icon: React.ReactNode }) { return
{title}
{detail}
} diff --git a/app/renderer/sections/Spend.tsx b/app/renderer/sections/Spend.tsx index 9c811c5a..7929f08f 100644 --- a/app/renderer/sections/Spend.tsx +++ b/app/renderer/sections/Spend.tsx @@ -61,7 +61,7 @@ export function SpendContent({ const flow = usePolled( () => range ? codeburn.getSpendFlow(period, provider, range) : codeburn.getSpendFlow(period, provider), [period, provider, range?.from, range?.to, refreshToken], - { enabled: ready }, + { enabled: ready, memoKey: `spendflow|${period}|${provider}|${range?.from ?? ''}-${range?.to ?? ''}` }, ) if (!overview.data) { diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index 23cf360d..32072446 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -109,6 +109,11 @@ html, body, #root { height: 100%; margin: 0; } body { overflow: hidden; background: var(--bg); color: var(--ink); } .win { height: 100%; min-height: 0; border-radius: 0; background: var(--canvas); box-shadow: none; } .ct { min-height: 0; } +/* Subtle in-flight hairline at the top of the section body: shown while a + provider/period switch refreshes behind instantly-served memoized data. */ +.switch-line { flex: 0 0 auto; height: 2px; background: transparent; } +.switch-line.on { background: linear-gradient(90deg, transparent, var(--accent), transparent); background-size: 200% 100%; animation: switch-line-shimmer 1.1s linear infinite; } +@keyframes switch-line-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } .body { overflow-y: auto; min-height: 0; align-items: center; padding: 18px 20px; gap: 16px; } /* Children keep their natural height (don't flex-shrink to fit) so the pane scrolls instead of squishing; capped width keeps content from stretching. */ @@ -831,6 +836,7 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } @media (prefers-reduced-motion: reduce) { .skel::after, .section-fade, .toast-in { animation: none; } + .switch-line.on { animation: none; background: var(--accent); } .panel, .ov-card, .btnp, .btn, .ov-coach-cta { transition: none; } .panel:hover, .ov-card:hover, .btnp:active, .btn:active, .ov-coach-cta:active { transform: none; } } @@ -927,3 +933,73 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .splash-status { animation: none; } .splash-prov.active { animation: none; } } + +/* ————— First-launch onboarding (fixed dark canvas, like the splash) ————— */ +.onboard { + position: fixed; inset: 0; z-index: 3100; + display: flex; align-items: center; justify-content: center; + background: radial-gradient(120% 80% at 50% 42%, color-mix(in srgb, #f2701c 8%, #171310), #171310 70%); +} +.onboard-card { + display: flex; flex-direction: column; align-items: center; gap: 12px; + width: min(420px, calc(100vw - 64px)); padding: 36px 36px 24px; + text-align: center; border-radius: 14px; + background: color-mix(in srgb, #fff 4%, transparent); + border: 1px solid color-mix(in srgb, #fff 9%, transparent); +} +.onboard-glyph { color: #f2701c; display: flex; margin-bottom: 2px; } +.onboard-title { margin: 0; font-size: var(--fs-kpi); font-weight: var(--fw-strong); letter-spacing: -0.01em; color: #f4f1ec; } +.onboard-body { margin: 0; max-width: 320px; font-size: var(--fs-body); line-height: 1.55; color: #b3a595; } +.onboard-consent { + margin-top: 6px; display: flex; align-items: center; justify-content: space-between; gap: 16px; + width: 100%; padding: 10px 14px; border-radius: 9px; + border: 1px solid color-mix(in srgb, #fff 10%, transparent); + font-size: var(--fs-body); color: #f4f1ec; +} +.onboard-link { + background: none; border: none; padding: 0; cursor: pointer; + font: inherit; font-size: var(--fs-label); color: #b3a595; text-decoration: underline; text-underline-offset: 3px; +} +.onboard-link:hover, .onboard-link:focus-visible { color: #f4f1ec; outline: none; } +.onboard-controls { + margin-top: 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; +} +.onboard-btn { + min-width: 96px; padding: 7px 14px; border-radius: 8px; cursor: pointer; + font: inherit; font-size: var(--fs-meta); font-weight: var(--fw-medium); + color: #f4f1ec; background: transparent; border: 1px solid color-mix(in srgb, #fff 16%, transparent); +} +.onboard-btn:hover { background: color-mix(in srgb, #fff 6%, transparent); } +.onboard-btn.primary { background: #f2701c; border-color: #f2701c; color: #fff; } +.onboard-btn.primary:hover { background: color-mix(in srgb, #f2701c 88%, #fff); } +.onboard-btn:focus-visible { outline: 1px solid #f2701c; outline-offset: 1px; } +.onboard-btn-ghost { min-width: 96px; } +.onboard-dots { display: flex; gap: 6px; } +.onboard-dot { width: 6px; height: 6px; border-radius: 50%; background: color-mix(in srgb, #fff 18%, transparent); transition: background 150ms ease; } +.onboard-dot.on { background: #f2701c; } +.onboard-in { animation: splash-word-in 320ms ease-out both; } + +/* Toggle switch (shared shape; used by onboarding consent + Settings privacy). */ +.switch { + position: relative; width: 38px; height: 22px; flex: none; + border-radius: 999px; border: 1px solid color-mix(in srgb, #fff 18%, transparent); + background: color-mix(in srgb, #fff 8%, transparent); cursor: pointer; padding: 0; + transition: background 150ms ease, border-color 150ms ease; +} +.switch .switch-knob { + position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; + background: #f4f1ec; transition: transform 150ms ease; +} +.switch.on { background: #f2701c; border-color: #f2701c; } +.switch.on .switch-knob { transform: translateX(16px); background: #fff; } +.switch:focus-visible { outline: 1px solid #f2701c; outline-offset: 1px; } +/* On themed (Settings) surfaces the same switch keys off theme tokens. */ +.set-p .switch { border-color: var(--line2); background: var(--hover); } +.set-p .switch .switch-knob { background: var(--mut); } +.set-p .switch.on { background: var(--accent); border-color: var(--accent); } +.set-p .switch.on .switch-knob { background: #fff; } + +@media (prefers-reduced-motion: reduce) { + .onboard-in { animation: none; } + .switch, .switch .switch-knob, .onboard-dot { transition: none; } +} diff --git a/app/renderer/test/setup.ts b/app/renderer/test/setup.ts index a87c8d32..1da7fa14 100644 --- a/app/renderer/test/setup.ts +++ b/app/renderer/test/setup.ts @@ -2,8 +2,12 @@ import '@testing-library/jest-dom/vitest' import { afterEach } from 'vitest' // Only wire DOM cleanup in a browser-like (jsdom) environment; node-env tests -// (cli/main) have no document and must not import RTL's DOM cleanup. +// (cli/main) have no document and must not import RTL's DOM cleanup — nor the +// renderer hook graph (usePolled → ipc touches `window` at load). if (typeof document !== 'undefined') { const { cleanup } = await import('@testing-library/react') - afterEach(() => cleanup()) + // The usePolled memo is module-level and persists across renders; clear it + // between tests so a cached result from one test never seeds another. + const { __resetPolledMemo } = await import('../hooks/usePolled') + afterEach(() => { cleanup(); __resetPolledMemo() }) } diff --git a/src/daily-cache.ts b/src/daily-cache.ts index d267140c..0ee3bb3d 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -28,7 +28,13 @@ import type { DateRange, ProjectSummary } from './types.js' // user changes their `localModelSavings` mapping. export const DAILY_CACHE_VERSION = 12 const MIN_SUPPORTED_VERSION = 12 -const DAILY_CACHE_FILENAME = 'daily-cache.json' +// Version-suffixed so different binaries each own a distinct file and never +// clobber an incompatible schema. Bumping the version mints a fresh filename. +const DAILY_CACHE_FILENAME = `daily-cache.v${DAILY_CACHE_VERSION}.json` +// The pre-versioning filename. Never written or deleted anymore (old binaries +// own it). Adopt-copied once on first load when the versioned file is absent and +// the legacy file's version matches ours; a different version is ignored. +const LEGACY_DAILY_CACHE_FILENAME = 'daily-cache.json' export type DailyEntry = { date: string @@ -74,6 +80,15 @@ function getCachePath(): string { return join(getCacheDir(), DAILY_CACHE_FILENAME) } +function getLegacyCachePath(): string { + return join(getCacheDir(), LEGACY_DAILY_CACHE_FILENAME) +} + +/** Absolute path of the active (version-suffixed) daily cache file. */ +export function dailyCachePath(): string { + return getCachePath() +} + export function emptyCache(savingsConfigHash = ''): DailyCache { return { version: DAILY_CACHE_VERSION, savingsConfigHash, lastComputedDate: null, days: [] } } @@ -105,31 +120,46 @@ function migrateDays(days: Record[]): DailyEntry[] { })) } -async function backupOldCache(path: string, version: number): Promise { - const backupPath = `${path}.v${version}.bak` - try { await rename(path, backupPath) } catch { /* best-effort */ } +function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; days: Record[] }): DailyCache { + return { + version: DAILY_CACHE_VERSION, + savingsConfigHash: parsed.savingsConfigHash ?? '', + lastComputedDate: parsed.lastComputedDate, + days: migrateDays(parsed.days), + } } export async function loadDailyCache(): Promise { const path = getCachePath() - if (!existsSync(path)) return emptyCache() - try { - const raw = await readFile(path, 'utf-8') - const parsed: unknown = JSON.parse(raw) - if (isMigratableCache(parsed)) { - const migrated: DailyCache = { - version: DAILY_CACHE_VERSION, - savingsConfigHash: parsed.savingsConfigHash ?? '', - lastComputedDate: parsed.lastComputedDate, - days: migrateDays(parsed.days), - } - if (parsed.version < DAILY_CACHE_VERSION) { - await saveDailyCache(migrated).catch(() => {}) + if (existsSync(path)) { + try { + const parsed: unknown = JSON.parse(await readFile(path, 'utf-8')) + if (isMigratableCache(parsed)) { + const migrated = migratedFrom(parsed) + if (parsed.version < DAILY_CACHE_VERSION) await saveDailyCache(migrated).catch(() => {}) + return migrated } - return migrated + return emptyCache() + } catch { + return emptyCache() + } + } + // Versioned file absent: adopt the legacy unversioned file once, only when its + // version matches ours. A different-version legacy file is ignored and left + // intact — old binaries still own it, so we never write or delete it. + return adoptLegacyDailyCache() +} + +async function adoptLegacyDailyCache(): Promise { + const legacy = getLegacyCachePath() + if (!existsSync(legacy)) return emptyCache() + try { + const parsed: unknown = JSON.parse(await readFile(legacy, 'utf-8')) + if (isMigratableCache(parsed) && parsed.version === DAILY_CACHE_VERSION) { + const adopted = migratedFrom(parsed) + await saveDailyCache(adopted).catch(() => {}) + return adopted } - const oldVersion = (parsed as { version?: number })?.version - if (typeof oldVersion === 'number') await backupOldCache(path, oldVersion) return emptyCache() } catch { return emptyCache() diff --git a/src/parser.ts b/src/parser.ts index 1f2ac824..d23d5a98 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -15,6 +15,7 @@ import { type CachedTurn, type ProviderSection, type SessionCache, + beginColdHydration, cleanupOrphanedTempFiles, computeEnvFingerprint, DURABLE_PROVIDER_NAMES, @@ -2635,9 +2636,25 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s const cached = sessionCache.get(key) if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data - const diskCache = await loadCache() + 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) + if (hydration.waited) diskCache = await loadCache() + + try { + return await runParse(key, diskCache, dateRange, providerFilter) + } finally { + await hydration.release() + } +} + +async function runParse(key: string, diskCache: SessionCache, dateRange?: DateRange, providerFilter?: string): Promise { const seenMsgIds = new Set() const seenKeys = new Set() const allSources = await discoverAllSessions(providerFilter) diff --git a/src/session-cache.ts b/src/session-cache.ts index f696ced9..2cc70845 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -91,7 +91,16 @@ export type SessionCache = { // never change. Bump forces a one-time re-parse so metered credit costs land. export const CACHE_VERSION = 5 -const CACHE_FILE = 'session-cache.json' +// The cache filename is version-suffixed so different binaries (e.g. an old +// launchd menubar on a prior release and a newer desktop app) each own a +// distinct file and can never clobber each other's incompatible schema. Bumping +// CACHE_VERSION automatically mints a fresh filename, superseding the migration +// dance the legacy unversioned file used to need. +const CACHE_FILE = `session-cache.v${CACHE_VERSION}.json` +// The pre-versioning filename. Never written or deleted anymore — old binaries +// still own it. On first load we adopt-copy it once (see loadCache) when the +// versioned file is absent and the legacy file's version matches ours. +const LEGACY_CACHE_FILE = 'session-cache.json' const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 export const PROVIDER_ENV_VARS: Record = { @@ -155,6 +164,15 @@ function getCachePath(): string { return join(getCacheDir(), CACHE_FILE) } +function getLegacyCachePath(): string { + return join(getCacheDir(), LEGACY_CACHE_FILE) +} + +/** Absolute path of the active (version-suffixed) session cache file. */ +export function sessionCachePath(): string { + return getCachePath() +} + // ── Env Fingerprint ──────────────────────────────────────────────────── export function computeEnvFingerprint(provider: string): string { @@ -282,6 +300,22 @@ export async function loadCache(): Promise { const parsed = JSON.parse(raw) if (!validateCache(parsed)) return emptyCache() return parsed + } catch { + // Versioned file absent/unreadable: try a one-time adoption of the legacy + // unversioned file. validateCache requires version === CACHE_VERSION, so a + // different-version legacy file is ignored (left intact). We copy it into the + // versioned file once via saveCache; the legacy file is never modified. + return adoptLegacyCache() + } +} + +async function adoptLegacyCache(): Promise { + try { + const raw = await readFile(getLegacyCachePath(), 'utf-8') + const parsed = JSON.parse(raw) + if (!validateCache(parsed)) return emptyCache() + await saveCache(parsed).catch(() => {}) + return parsed } catch { return emptyCache() } @@ -420,7 +454,9 @@ export async function cleanupOrphanedTempFiles(): Promise { const entries = await readdir(dir) const now = Date.now() - const prefix = 'session-cache.json.' + // Only our own (versioned) temp files. Legacy `session-cache.json.*.tmp` + // temps belong to old binaries mid-write and must not be touched. + const prefix = `${CACHE_FILE}.` for (const entry of entries) { if (!entry.startsWith(prefix) || !entry.endsWith('.tmp')) continue try { @@ -433,3 +469,109 @@ export async function cleanupOrphanedTempFiles(): Promise { } } catch {} } + +// ── Hydration Lock ───────────────────────────────────────────────────── +// +// Advisory, cross-process coordination for the expensive cold hydration. When +// two live processes (e.g. an old launchd menubar and the desktop app) both +// cold-start against the same cache dir, without this they each parse full +// history and race their writes. The first to arrive creates the lock and +// hydrates; a second live process waits for release, then reads the now-warm +// cache instead of re-parsing. It is strictly an optimization: on any +// uncertainty we proceed with the parse, so it can never wedge a cold start. + +const HYDRATION_LOCK_FILE = 'hydrating.lock' +const LOCK_FRESH_MS = 15 * 60_000 +const LOCK_WAIT_MAX_MS = 10 * 60_000 +const LOCK_POLL_MS = 250 + +type LockRecord = { pid: number; at: number } +export type HydrationHandle = { waited: boolean; release: () => Promise } + +const NOOP_HANDLE: HydrationHandle = { waited: false, release: async () => {} } + +function lockPath(): string { + return join(getCacheDir(), HYDRATION_LOCK_FILE) +} + +// Our own pid never counts as a foreign holder: a same-process lock is either +// re-entrant or leaked, and waiting on ourselves risks a self-hang. Cross-process +// coordination is the only thing this lock is for. EPERM means the pid exists but +// belongs to another user — still alive. +function pidLooksAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false + try { process.kill(pid, 0); return true } + catch (err) { return (err as NodeJS.ErrnoException).code === 'EPERM' } +} + +async function readLockRecord(): Promise { + try { + const parsed = JSON.parse(await readFile(lockPath(), 'utf-8')) as Partial + if (typeof parsed?.pid === 'number' && typeof parsed?.at === 'number') return { pid: parsed.pid, at: parsed.at } + return null + } catch { return null } +} + +async function writeOurLock(): Promise { + try { + const dir = getCacheDir() + if (!existsSync(dir)) await mkdir(dir, { recursive: true }) + const handle = await open(lockPath(), 'wx', 0o600) + try { await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }), { encoding: 'utf-8' }) } + finally { await handle.close() } + return true + } catch { return false } +} + +async function removeOurLock(): Promise { + try { + const cur = await readLockRecord() + if (cur && cur.pid === process.pid) await unlink(lockPath()) + } catch { /* best-effort; a leaked lock is reclaimed as stale next cold start */ } +} + +const releaseHandle: HydrationHandle = { waited: false, release: removeOurLock } + +function sleep(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms) }) +} + +/** + * Coordinate a cold hydration. Pass `isCold = true` only when the on-disk cache + * is empty (a genuine full parse is imminent). Returns a handle: + * - `waited: true` → another live process was hydrating; we waited for it to + * finish (or timed out). The caller should RELOAD the cache and let its normal + * reconcile serve the now-warm entries instead of re-parsing. `release` is a + * no-op (we never held the lock). + * - `waited: false` with a real `release` → we hold the lock; hydrate, then call + * `release()` in a finally. + * - `waited: false` with a no-op `release` → proceed with the parse unlocked + * (not cold, or the lock state was uncertain). + */ +export async function beginColdHydration(isCold: boolean): Promise { + if (!isCold) return NOOP_HANDLE + try { + if (await writeOurLock()) return releaseHandle + const existing = await readLockRecord() + const fresh = existing !== null && Date.now() - existing.at < LOCK_FRESH_MS + if (existing && fresh && pidLooksAlive(existing.pid)) { + // Another live process owns a fresh lock: wait for it to release, go stale, + // or die, then let the caller reload the warm cache. + const deadline = Date.now() + LOCK_WAIT_MAX_MS + while (Date.now() < deadline) { + await sleep(LOCK_POLL_MS) + const cur = await readLockRecord() + if (!cur) break + if (Date.now() - cur.at >= LOCK_FRESH_MS) break + if (!pidLooksAlive(cur.pid)) break + } + return { waited: true, release: async () => {} } + } + // Stale, dead-pid, or unreadable lock: replace it and take over. + try { await unlink(lockPath()) } catch { /* another process may have; fine */ } + if (await writeOurLock()) return releaseHandle + return NOOP_HANDLE + } catch { + return NOOP_HANDLE + } +} diff --git a/tests/cache-persistence-coldstart.test.ts b/tests/cache-persistence-coldstart.test.ts index 3452330b..c18b677f 100644 --- a/tests/cache-persistence-coldstart.test.ts +++ b/tests/cache-persistence-coldstart.test.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { parseAllSessions, clearSessionCache } from '../src/parser.js' -import { CACHE_VERSION } from '../src/session-cache.js' +import { CACHE_VERSION, sessionCachePath } from '../src/session-cache.js' let tmpDir: string let cacheDir: string @@ -48,7 +48,7 @@ describe('cold-start cache persistence', () => { const projects = await parseAllSessions() expect(projects.length).toBeGreaterThan(0) - const raw = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) + const raw = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) expect(raw.version).toBe(CACHE_VERSION) const claudeFiles = Object.keys(raw.providers?.claude?.files ?? {}) expect(claudeFiles.length).toBeGreaterThan(0) diff --git a/tests/codex-cache-invalidation.test.ts b/tests/codex-cache-invalidation.test.ts index cfba9c45..e4e53111 100644 --- a/tests/codex-cache-invalidation.test.ts +++ b/tests/codex-cache-invalidation.test.ts @@ -13,6 +13,7 @@ import { createHash } from 'crypto' import { join } from 'path' import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { sessionCachePath } from '../src/session-cache.js' const testRoot = vi.hoisted(() => { const root = `${process.env['TMPDIR'] || '/tmp'}/codex-stale-repro-${process.pid}-${Date.now()}` @@ -74,7 +75,7 @@ describe('codex parser change invalidates stale session-cache (#478/#513)', () = // release: pre-fix envFingerprint, unchanged file fingerprint, cached // turns lack the mcp__ tool. Also reset codex-results.json to v4 so the // provider (if it runs at all) must genuinely re-parse. - const cachePath = join(CACHE_DIR, 'session-cache.json') + const cachePath = sessionCachePath() const cache = JSON.parse(await readFile(cachePath, 'utf8')) cache.providers.codex.envFingerprint = preFixFingerprint() for (const f of Object.values(cache.providers.codex.files) as any[]) { diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 98923192..01b98f96 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -8,6 +8,7 @@ import type { ProjectSummary } from '../src/types.js' import { addNewDays, + dailyCachePath, DAILY_CACHE_VERSION, type DailyCache, type DailyEntry, @@ -66,7 +67,11 @@ describe('loadDailyCache', () => { expect(cache.days).toEqual([]) }) - it('returns an empty cache and backs up when version is too old to migrate', async () => { + // With version-suffixed filenames, a legacy unversioned file whose version is + // not the current one is simply IGNORED — never migrated, never backed up, + // never touched (old binaries still own it). Load returns an empty cache and + // the legacy file is left intact on disk. + it('ignores (and never rewrites) a legacy file too old to migrate', async () => { const saved = { version: 1, lastComputedDate: '2026-04-10', @@ -74,20 +79,18 @@ describe('loadDailyCache', () => { } const { writeFile, mkdir } = await import('fs/promises') await mkdir(TMP_CACHE_ROOT, { recursive: true }) - await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json') + await writeFile(legacy, JSON.stringify(saved), 'utf-8') const cache = await loadDailyCache() expect(cache.days).toEqual([]) expect(cache.lastComputedDate).toBeNull() - expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(true) + // Legacy file untouched (no .bak, contents intact); no versioned file written. + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v1.bak'))).toBe(false) + expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved) + expect(existsSync(dailyCachePath())).toBe(false) }) - it('discards a v2 cache and starts fresh (provider rollups would be stale)', async () => { - // MIN_SUPPORTED_VERSION was raised to DAILY_CACHE_VERSION because the - // migration path cannot recompute the providers / categories / models - // rollups from session data (the cache does not retain raw sessions), - // so a migrated old cache would carry forward stale provider totals - // for the full retention window. Older caches now get discarded and - // recomputed from scratch on next run. + it('ignores a legacy v2 cache (provider rollups would be stale) and leaves it intact', async () => { const saved = { version: 2, lastComputedDate: '2026-04-10', @@ -99,16 +102,17 @@ describe('loadDailyCache', () => { } const { writeFile, mkdir } = await import('fs/promises') await mkdir(TMP_CACHE_ROOT, { recursive: true }) - await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json') + await writeFile(legacy, JSON.stringify(saved), 'utf-8') const cache = await loadDailyCache() expect(cache.version).toBe(DAILY_CACHE_VERSION) expect(cache.days).toEqual([]) expect(cache.lastComputedDate).toBeNull() - // Old cache is renamed to .v2.bak rather than deleted. - expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true) + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(false) + expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved) }) - it('discards a v5 cache because cached Claude costs predate 1-hour cache pricing', async () => { + it('ignores a legacy v5 cache (predates 1-hour cache pricing) and leaves it intact', async () => { const saved = { version: 5, lastComputedDate: '2026-05-01', @@ -130,12 +134,41 @@ describe('loadDailyCache', () => { } const { writeFile, mkdir } = await import('fs/promises') await mkdir(TMP_CACHE_ROOT, { recursive: true }) - await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json') + await writeFile(legacy, JSON.stringify(saved), 'utf-8') const cache = await loadDailyCache() expect(cache.version).toBe(DAILY_CACHE_VERSION) expect(cache.days).toEqual([]) expect(cache.lastComputedDate).toBeNull() - expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(true) + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(false) + expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved) + }) + + it('adopts a legacy file whose version matches the current one, once, without deleting it', async () => { + const saved = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'legacy-hash', + lastComputedDate: '2026-05-01', + days: [emptyDay('2026-05-01', 3.5, 9)], + } + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + const legacy = join(TMP_CACHE_ROOT, 'daily-cache.json') + await writeFile(legacy, JSON.stringify(saved), 'utf-8') + + // First load: versioned file absent → adopt-copy from legacy. + const first = await loadDailyCache() + expect(first.days).toEqual(saved.days) + expect(first.savingsConfigHash).toBe('legacy-hash') + expect(existsSync(dailyCachePath())).toBe(true) + // Legacy file is NOT deleted. + expect(existsSync(legacy)).toBe(true) + + // Adoption is one-time: mutate the legacy file, load again — the versioned + // file now wins and the stale legacy edit is never re-adopted. + await writeFile(legacy, JSON.stringify({ ...saved, days: [emptyDay('2000-01-01', 999)] }), 'utf-8') + const second = await loadDailyCache() + expect(second.days).toEqual(saved.days) }) it('round-trips a valid cache through save and load', async () => { @@ -164,7 +197,7 @@ describe('saveDailyCache', () => { const files = await readdir(TMP_CACHE_ROOT) const tempLeftovers = files.filter(f => f.endsWith('.tmp')) expect(tempLeftovers).toEqual([]) - const finalFile = await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), 'utf-8') + const finalFile = await readFile(dailyCachePath(), 'utf-8') expect(JSON.parse(finalFile)).toEqual(saved) }) }) diff --git a/tests/kiro-cache-invalidation.test.ts b/tests/kiro-cache-invalidation.test.ts index 725ba5bd..0046dd7a 100644 --- a/tests/kiro-cache-invalidation.test.ts +++ b/tests/kiro-cache-invalidation.test.ts @@ -25,6 +25,7 @@ import { CACHE_VERSION, computeEnvFingerprint, fingerprintFile, + sessionCachePath, type SessionCache, } from '../src/session-cache.js' @@ -98,7 +99,7 @@ async function seedCache(execPath: string, envFingerprint: string): Promise> { - const saved = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) as { + const saved = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) as { providers: Record }> }> } return saved.providers['antigravity']?.files[dbPath]?.turns ?? [] diff --git a/tests/parser-gemini-cache.test.ts b/tests/parser-gemini-cache.test.ts index c2542b69..b5510274 100644 --- a/tests/parser-gemini-cache.test.ts +++ b/tests/parser-gemini-cache.test.ts @@ -5,7 +5,7 @@ import { join } from 'path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { clearSessionCache, parseAllSessions } from '../src/parser.js' -import { CACHE_VERSION, computeEnvFingerprint } from '../src/session-cache.js' +import { CACHE_VERSION, computeEnvFingerprint, sessionCachePath } from '../src/session-cache.js' import type { DateRange } from '../src/types.js' let home: string @@ -54,7 +54,7 @@ describe('Gemini session cache migration', () => { })) const fileStat = await stat(sessionPath) - await writeFile(join(cacheDir, 'session-cache.json'), JSON.stringify({ + await writeFile(sessionCachePath(), JSON.stringify({ version: CACHE_VERSION, providers: { gemini: { @@ -117,7 +117,7 @@ describe('Gemini session cache migration', () => { 'gemini:gemini-session-1:g2', ]) - const savedCache = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) + const savedCache = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) const savedKeys = savedCache.providers.gemini.files[sessionPath].turns.flatMap((turn: { calls: Array<{ deduplicationKey: string }> }) => turn.calls.map(call => call.deduplicationKey), ) diff --git a/tests/parser-hydration-lock.test.ts b/tests/parser-hydration-lock.test.ts new file mode 100644 index 00000000..e5bdbe90 --- /dev/null +++ b/tests/parser-hydration-lock.test.ts @@ -0,0 +1,138 @@ +// Advisory hydration-lock coordination in parseAllSessions. When the on-disk +// cache is cold and another LIVE process already holds a fresh lock, a second +// process waits for release then reads the now-warm cache instead of re-parsing. +// Stale / dead-pid locks are ignored and replaced. The lock is an optimization, +// never a correctness gate. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync } from 'fs' +import { mkdir, mkdtemp, rm, unlink, writeFile, readFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { clearSessionCache, parseAllSessions } from '../src/parser.js' +import { sessionCachePath } from '../src/session-cache.js' + +let tmpHome: string +let cacheDir: string + +function lockPath(): string { + return join(cacheDir, 'hydrating.lock') +} + +function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms) }) +} + +function totalOutput(projects: Awaited>): number { + return projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + .reduce((sum, c) => sum + c.usage.outputTokens, 0) +} + +async function writeClaudeSession(output: number): Promise { + const dir = join(tmpHome, 'projects', 'proj') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'sess.jsonl'), JSON.stringify({ + type: 'assistant', + sessionId: 'sess', + timestamp: '2026-05-15T10:00:00Z', + cwd: '/tmp/proj', + message: { + id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [], usage: { input_tokens: 100, output_tokens: output }, + }, + }) + '\n') +} + +beforeEach(async () => { + clearSessionCache() + tmpHome = await mkdtemp(join(tmpdir(), 'cb-hydlock-home-')) + cacheDir = await mkdtemp(join(tmpdir(), 'cb-hydlock-cache-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpHome + process.env['CODEBURN_CACHE_DIR'] = cacheDir + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpHome, 'desktop-sessions') +}) + +afterEach(async () => { + clearSessionCache() + await rm(tmpHome, { recursive: true, force: true }) + await rm(cacheDir, { recursive: true, force: true }) +}) + +describe('parseAllSessions hydration lock', () => { + it('waits for a live foreign lock, then serves the warm cache instead of re-parsing', async () => { + // Warm the cache once from a real on-disk session (output 50), capture the + // exact cache structure, then tamper the cached output to a sentinel (999) + // that could ONLY come from reading the cache — a fresh parse of the + // unchanged source file would yield 50. + await writeClaudeSession(50) + expect(totalOutput(await parseAllSessions(undefined, 'claude'))).toBe(50) + + const warm = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) + for (const section of Object.values(warm.providers) as Array<{ files: Record }> }> }>) { + for (const file of Object.values(section.files)) { + for (const turn of file.turns) for (const call of turn.calls) call.usage.outputTokens = 999 + } + } + const tampered = JSON.stringify(warm) + + // Go cold: remove the versioned cache and drop the in-memory cache so the + // next parse genuinely cold-starts and consults the lock. + await unlink(sessionCachePath()) + clearSessionCache() + + // A fresh lock held by another live process (pid 1 is always alive and is + // not us). The cold parse must block on it. + await writeFile(lockPath(), JSON.stringify({ pid: 1, at: Date.now() })) + + let resolved = false + const promise = parseAllSessions(undefined, 'claude').then(r => { resolved = true; return r }) + + await delay(50) + // Still waiting on the foreign lock — it has not re-parsed and returned. + expect(resolved).toBe(false) + + // The "first process" finishes: it leaves the warm (tampered) cache behind + // and releases the lock. The waiter wakes, reloads, and serves the cache. + await writeFile(sessionCachePath(), tampered) + await unlink(lockPath()) + + const result = await promise + expect(resolved).toBe(true) + expect(totalOutput(result)).toBe(999) + // The waiter never held the lock, so nothing to clean up on its side. + expect(existsSync(lockPath())).toBe(false) + }) + + it('ignores and replaces a stale lock, parsing normally and releasing in a finally', async () => { + await writeClaudeSession(50) + + // A stale lock (timestamp well past the freshness window) must be ignored, + // replaced, and the parse proceeds normally. + await mkdir(cacheDir, { recursive: true }) + await writeFile(lockPath(), JSON.stringify({ pid: 1, at: Date.now() - 20 * 60_000 })) + + const result = await parseAllSessions(undefined, 'claude') + expect(totalOutput(result)).toBe(50) + // Lock released in the finally. + expect(existsSync(lockPath())).toBe(false) + // The parse warmed the versioned cache. + expect(existsSync(sessionCachePath())).toBe(true) + }) + + it('ignores a fresh lock whose pid is dead', async () => { + await writeClaudeSession(50) + + // Fresh timestamp but a pid that does not exist (ESRCH): treated as dead, + // ignored, replaced, and the parse proceeds normally. + await mkdir(cacheDir, { recursive: true }) + await writeFile(lockPath(), JSON.stringify({ pid: 2_147_483_646, at: Date.now() })) + + const result = await parseAllSessions(undefined, 'claude') + expect(totalOutput(result)).toBe(50) + expect(existsSync(lockPath())).toBe(false) + }) +}) diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 64157645..56ffc261 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -14,7 +14,7 @@ import { createRequire } from 'node:module' import { isSqliteAvailable } from '../src/sqlite.js' import { clearSessionCache, parseAllSessions } from '../src/parser.js' -import { loadCache, saveCache } from '../src/session-cache.js' +import { loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js' // ── Synthetic provider state ─────────────────────────────────────────────── @@ -439,7 +439,7 @@ describe('(f) durable orphans survive a parse-version bump', () => { // Simulate the fingerprint a PREVIOUS release computed (any mismatching // value takes the same code path as a real parse-version bump). const { readFile, writeFile: writeFileFs } = await import('fs/promises') - const cachePath = join(tmpCache, 'session-cache.json') + const cachePath = sessionCachePath() const disk = JSON.parse(await readFile(cachePath, 'utf-8')) as { providers: Record } expect(disk.providers['copilot']).toBeDefined() disk.providers['copilot']!.envFingerprint = '0000000000000000' diff --git a/tests/providers/codewhale.test.ts b/tests/providers/codewhale.test.ts index 84777c87..bdf9fd3a 100644 --- a/tests/providers/codewhale.test.ts +++ b/tests/providers/codewhale.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { clearSessionCache, parseAllSessions } from '../../src/parser.js' +import { sessionCachePath } from '../../src/session-cache.js' import { MAX_SESSION_FILE_BYTES } from '../../src/fs-utils.js' import { codewhale, createCodeWhaleProvider } from '../../src/providers/codewhale.js' import type { ParsedProviderCall } from '../../src/providers/types.js' @@ -274,7 +275,7 @@ describe('codewhale provider', () => { expect(first[0]!.totalCostUSD).toBeCloseTo(0.75) expect(second[0]!.totalCostUSD).toBeCloseTo(0.75) - const cache = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) as { + const cache = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) as { providers: { codewhale: { envFingerprint: string } } } expect(cache.providers.codewhale.envFingerprint).toMatch(/^[a-f0-9]{16}$/) diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts index 59ac8561..e06fd7cb 100644 --- a/tests/session-cache.test.ts +++ b/tests/session-cache.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { readFile, rm, writeFile, mkdir } from 'fs/promises' import { existsSync } from 'fs' import { tmpdir } from 'os' -import { join } from 'path' +import { basename, join } from 'path' import { CACHE_VERSION, @@ -19,8 +19,12 @@ import { mergeCallByDedupKey, reconcileFile, saveCache, + sessionCachePath, } from '../src/session-cache.js' +// Version-suffixed filename (e.g. session-cache.v5.json) the cache now writes to. +const CACHE_FILE = () => basename(sessionCachePath()) + const TMP_DIR = join(tmpdir(), `codeburn-scache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) beforeEach(() => { @@ -179,11 +183,73 @@ describe('loadCache / saveCache', () => { it('atomic write does not leave partial file on error', async () => { await saveCache(emptyCache()) - const raw = await readFile(join(TMP_DIR, 'session-cache.json'), 'utf-8') + const raw = await readFile(sessionCachePath(), 'utf-8') expect(JSON.parse(raw)).toEqual(emptyCache()) }) }) +// ── versioned filename + legacy adoption ─────────────────────────────── + +describe('versioned cache file + legacy adoption', () => { + function validCache(): SessionCache { + return { + version: CACHE_VERSION, + providers: { claude: { envFingerprint: 'abc123', files: { '/path/to/session.jsonl': makeCachedFile() } } }, + } + } + + it('writes and reads the version-suffixed file, never the legacy name', async () => { + expect(basename(sessionCachePath())).toBe(`session-cache.v${CACHE_VERSION}.json`) + await saveCache(validCache()) + expect(existsSync(sessionCachePath())).toBe(true) + expect(existsSync(join(TMP_DIR, 'session-cache.json'))).toBe(false) + expect(await loadCache()).toEqual(validCache()) + }) + + it('adopts a matching-version legacy file once, without deleting or rewriting it', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const legacy = join(TMP_DIR, 'session-cache.json') + await writeFile(legacy, JSON.stringify(validCache())) + + // Versioned file absent → adopt-copy from legacy on first load. + expect(await loadCache()).toEqual(validCache()) + expect(existsSync(sessionCachePath())).toBe(true) + // Legacy left intact (not deleted, not rewritten). + expect(existsSync(legacy)).toBe(true) + expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(validCache()) + + // Adoption is one-time: a later legacy edit is ignored once the versioned + // file exists. + const mutated: SessionCache = { version: CACHE_VERSION, providers: { codex: { envFingerprint: 'zzz', files: {} } } } + await writeFile(legacy, JSON.stringify(mutated)) + expect(await loadCache()).toEqual(validCache()) + }) + + it('ignores a different-version legacy file and never touches it', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const legacy = join(TMP_DIR, 'session-cache.json') + const stale = { version: 999, providers: { claude: { envFingerprint: 'x', files: {} } } } + await writeFile(legacy, JSON.stringify(stale)) + + expect((await loadCache()).providers).toEqual({}) + // No versioned file adopted; legacy left byte-intact. + expect(existsSync(sessionCachePath())).toBe(false) + expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(stale) + }) + + it('saveCache never creates or overwrites a pre-existing legacy file', async () => { + await mkdir(TMP_DIR, { recursive: true }) + const legacy = join(TMP_DIR, 'session-cache.json') + const legacyContent = JSON.stringify({ version: CACHE_VERSION, providers: {} }) + await writeFile(legacy, legacyContent) + + await saveCache(validCache()) + // The versioned file holds the new data; the legacy file is byte-untouched. + expect(await readFile(legacy, 'utf-8')).toBe(legacyContent) + expect(JSON.parse(await readFile(sessionCachePath(), 'utf-8'))).toEqual(validCache()) + }) +}) + // ── computeEnvFingerprint ────────────────────────────────────────────── describe('computeEnvFingerprint', () => { @@ -571,7 +637,7 @@ describe('cleanupOrphanedTempFiles', () => { it('removes .tmp files older than 5 minutes', async () => { await mkdir(TMP_DIR, { recursive: true }) - const oldTmp = join(TMP_DIR, 'session-cache.json.abc123.tmp') + const oldTmp = join(TMP_DIR, `${CACHE_FILE()}.abc123.tmp`) await writeFile(oldTmp, 'stale') const { utimes } = await import('fs/promises') const oldTime = new Date(Date.now() - 10 * 60 * 1000) @@ -584,7 +650,7 @@ describe('cleanupOrphanedTempFiles', () => { it('preserves recent .tmp files', async () => { await mkdir(TMP_DIR, { recursive: true }) - const recentTmp = join(TMP_DIR, 'session-cache.json.def456.tmp') + const recentTmp = join(TMP_DIR, `${CACHE_FILE()}.def456.tmp`) await writeFile(recentTmp, 'recent') await cleanupOrphanedTempFiles()