diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 502bb1ed..e553ff0f 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -1,11 +1,35 @@ import { randomBytes } from 'crypto' import { existsSync } from 'fs' -import { mkdir, open, readFile, rename, unlink } from 'fs/promises' +import { mkdir, open, readdir, readFile, rename, stat, unlink } from 'fs/promises' import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 13: day bucketing is now TURN-anchored (a turn's whole cost/calls +// Bumped to 15: per-project daily rollups. Days and provider slices now carry +// a `projects` breakdown (cost/calls/savings/sessions per project) so project +// history outlives the session files, like models and categories already do. +// This bump is the first to ride the v14 carry-forward: the old cache is +// adopted losslessly and only days whose sources survive are re-derived (now +// with projects); days already sourceless keep their totals and simply have +// no project split. +// +// v14: NEVER-LOSE history. Session files are ephemeral (Claude Code +// deletes transcripts after ~30 days), so a day that can no longer be re-derived +// from sources exists ONLY in this cache. Every earlier version treated the +// cache as disposable — schema bumps, savings-config changes, timezone changes +// and incomplete-hydration retries all dropped the days and re-derived from +// whatever sources survived, silently truncating history to the source-retention +// window (five bumps between 2026-06-22 and 2026-07-16 erased everything before +// 2026-04-24 on a machine with usage since March). From v14 on, invalidation +// re-derives what it can and CARRIES FORWARD every (day, provider) slice it +// cannot, and loading a missing/unsupported cache file adopts days from every +// older daily-cache file in the cache dir instead of starting empty. Bumping +// the version now only forces re-derivation of days whose sources still exist; +// it must never again lose the rest. DailyEntry.providers slices carry a full +// per-provider breakdown (tokens, models, categories) so those carry-forwards +// stay exact across rebuilds. +// +// v13: day bucketing is now TURN-anchored (a turn's whole cost/calls // land on the day of its user-message timestamp) to match the live headline/ // report rollup. v12 bucketed each call by its own timestamp, so a midnight- // straddling turn split across two days and history.daily / the provider @@ -33,15 +57,51 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 13 -const MIN_SUPPORTED_VERSION = 13 +export const DAILY_CACHE_VERSION = 15 +const MIN_SUPPORTED_VERSION = 15 // Version-suffixed so different binaries each own a distinct file and never -// clobber an incompatible schema. Bumping the version mints a fresh filename. +// clobber an incompatible schema. Bumping the version mints a fresh filename; +// adoptOlderDailyCaches then unions days out of every previous file (including +// the pre-versioning `daily-cache.json`, which old binaries still own and we +// never write or delete). 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 ModelDayStats = { + calls: number + cost: number + savingsUSD: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number +} + +export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number; editTurns: number; oneShotTurns: number } + +/// `path` is the project's filesystem path when known — it is what display +/// layers derive a friendly name from once the sessions that carried the +/// mapping are gone. +export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string } + +export type ProviderDaySlice = { + calls: number + cost: number + savingsUSD: number + /// Full per-provider breakdown, written since v14. Slices adopted from older + /// caches carry only the three fields above; carrying such a slice forward + /// restores exact cost/calls/savings but not the day's token/model/category + /// split for that provider. + sessions?: number + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + editTurns?: number + oneShotTurns?: number + models?: Record + categories?: Record + projects?: Record +} export type DailyEntry = { date: string @@ -55,25 +115,26 @@ export type DailyEntry = { cacheWriteTokens: number editTurns: number oneShotTurns: number - models: Record - categories: Record - providers: Record + models: Record + categories: Record + providers: Record + /// Per-project rollup (session-level project attribution). Absent on days + /// recorded before v15 — those days keep their totals but have no project + /// split, and nothing can reconstruct one once the sources are gone. + projects?: Record + /// Present when some of this day's data was carried forward from an earlier + /// cache generation instead of re-derived from session files (the files no + /// longer exist). Carried values keep the accounting of the version that + /// recorded them — stale accounting beats a silent zero. + carried?: true } export type DailyCache = { version: number /// Hash of the active `localModelSavings` config at the time the cache /// was last written. When the user changes their baseline mapping the - /// hash mismatches and `ensureCacheHydrated` discards the cached days - /// so historical savings are recomputed against the current mapping. + /// hash mismatches and `ensureCacheHydrated` re-derives available history, + /// then carries forward slices whose sources are gone. savingsConfigHash: string /// IANA local timezone the days were bucketed under (day boundaries are /// local-time). If the machine's timezone changes, previously-cached days are @@ -105,10 +166,6 @@ 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() @@ -126,23 +183,115 @@ function isMigratableCache(parsed: unknown): parsed is { version: number; lastCo return c.version >= MIN_SUPPORTED_VERSION && c.version <= DAILY_CACHE_VERSION } +function num(v: unknown): number { + return typeof v === 'number' && Number.isFinite(v) ? v : 0 +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function sanitizeModels(raw: unknown): DailyEntry['models'] { + if (!isRecord(raw)) return {} + const out: DailyEntry['models'] = {} + for (const [name, m] of Object.entries(raw)) { + if (name in Object.prototype || !isRecord(m)) continue + setOwn(out, name, { + calls: num(m.calls), + cost: num(m.cost), + savingsUSD: num(m.savingsUSD), + inputTokens: num(m.inputTokens), + outputTokens: num(m.outputTokens), + cacheReadTokens: num(m.cacheReadTokens), + cacheWriteTokens: num(m.cacheWriteTokens), + }) + } + return out +} + +function sanitizeCategories(raw: unknown): DailyEntry['categories'] { + if (!isRecord(raw)) return {} + const out: DailyEntry['categories'] = {} + for (const [name, c] of Object.entries(raw)) { + if (name in Object.prototype || !isRecord(c)) continue + setOwn(out, name, { + turns: num(c.turns), + cost: num(c.cost), + savingsUSD: num(c.savingsUSD), + editTurns: num(c.editTurns), + oneShotTurns: num(c.oneShotTurns), + }) + } + return out +} + +const OPTIONAL_SLICE_NUMERICS = ['sessions', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'editTurns', 'oneShotTurns'] as const + +/// Same junk-tolerance as sanitizeProjects, one level up: a foreign cache can +/// hold anything under a provider slice, and structuredClone in the merge +/// would faithfully preserve that junk into the next cache generation. Numeric +/// fields and nested maps are sanitized before the slice enters the cache. +function sanitizeProviders(raw: unknown): DailyEntry['providers'] { + if (!isRecord(raw)) return {} + const out: DailyEntry['providers'] = {} + for (const [name, s] of Object.entries(raw)) { + if (name in Object.prototype || !isRecord(s)) continue + const slice = s + const clean: ProviderDaySlice = { calls: num(slice.calls), cost: num(slice.cost), savingsUSD: num(slice.savingsUSD) } + for (const key of OPTIONAL_SLICE_NUMERICS) { + if (slice[key] !== undefined) clean[key] = num(slice[key]) + } + if (isRecord(slice.models)) clean.models = sanitizeModels(slice.models) + if (isRecord(slice.categories)) clean.categories = sanitizeCategories(slice.categories) + const projects = sanitizeProjects(slice.projects).projects + if (projects) clean.projects = projects + setOwn(out, name, clean) + } + return out +} + +/// Foreign or hand-edited caches can hold anything under `projects`; keep only +/// a plain record of finite numeric stats (arrays and null entries dropped) so +/// later carry merges can't crash on junk. +function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } { + if (!isRecord(raw)) return {} + const out: NonNullable = {} + for (const [name, p] of Object.entries(raw)) { + if (name in Object.prototype || !isRecord(p)) continue + setOwn(out, name, { + cost: num(p.cost), + calls: num(p.calls), + savingsUSD: num(p.savingsUSD), + sessions: num(p.sessions), + ...(typeof p.path === 'string' && p.path.length > 0 ? { path: p.path } : {}), + }) + } + return Object.keys(out).length > 0 ? { projects: out } : {} +} + +const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/ + function migrateDays(days: Record[]): DailyEntry[] { - return days.map(d => ({ - date: d.date as string, - cost: (d.cost as number) ?? 0, - savingsUSD: (d.savingsUSD as number) ?? 0, - calls: (d.calls as number) ?? 0, - sessions: (d.sessions as number) ?? 0, - inputTokens: (d.inputTokens as number) ?? 0, - outputTokens: (d.outputTokens as number) ?? 0, - cacheReadTokens: (d.cacheReadTokens as number) ?? 0, - cacheWriteTokens: (d.cacheWriteTokens as number) ?? 0, - editTurns: (d.editTurns as number) ?? 0, - oneShotTurns: (d.oneShotTurns as number) ?? 0, - models: (d.models as DailyEntry['models']) ?? {}, - categories: (d.categories as DailyEntry['categories']) ?? {}, - providers: (d.providers as DailyEntry['providers']) ?? {}, - })) + return days + .filter(d => d && typeof d === 'object' && typeof d.date === 'string' && DATE_KEY_RE.test(d.date)) + .map(d => ({ + date: d.date as string, + cost: num(d.cost), + savingsUSD: num(d.savingsUSD), + calls: num(d.calls), + sessions: num(d.sessions), + inputTokens: num(d.inputTokens), + outputTokens: num(d.outputTokens), + cacheReadTokens: num(d.cacheReadTokens), + cacheWriteTokens: num(d.cacheWriteTokens), + editTurns: num(d.editTurns), + oneShotTurns: num(d.oneShotTurns), + models: sanitizeModels(d.models), + categories: sanitizeCategories(d.categories), + providers: sanitizeProviders(d.providers), + ...(sanitizeProjects(d.projects)), + ...(d.carried === true ? { carried: true as const } : {}), + })) } function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean }): DailyCache { @@ -150,7 +299,9 @@ function migratedFrom(parsed: { version: number; lastComputedDate: string | null version: DAILY_CACHE_VERSION, savingsConfigHash: parsed.savingsConfigHash ?? '', tzKey: parsed.tzKey, - lastComputedDate: parsed.lastComputedDate, + lastComputedDate: typeof parsed.lastComputedDate === 'string' && DATE_KEY_RE.test(parsed.lastComputedDate) + ? parsed.lastComputedDate + : null, 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. @@ -168,31 +319,99 @@ export async function loadDailyCache(): Promise { if (parsed.version < DAILY_CACHE_VERSION) await saveDailyCache(migrated).catch(() => {}) return migrated } - return emptyCache() } catch { - return emptyCache() + // fall through to adoption — a corrupt current file must not cost history + // that older cache files still hold. } + return adoptOlderDailyCaches() } - // 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() + return adoptOlderDailyCaches() } -async function adoptLegacyDailyCache(): Promise { - const legacy = getLegacyCachePath() - if (!existsSync(legacy)) return emptyCache() +type AdoptableCache = { version: number; lastComputedDate?: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean } + +function isAdoptableCache(parsed: unknown): parsed is AdoptableCache { + if (!parsed || typeof parsed !== 'object') return false + const c = parsed as Partial + return typeof c.version === 'number' && Array.isArray(c.days) +} + +/// Versioned file absent (or unreadable): adopt days from EVERY other +/// daily-cache file in the cache dir — the legacy unversioned file, older +/// versioned files, and manual .bak copies. Files are read, never written or +/// deleted (old binaries still own theirs). A candidate at exactly our version +/// (the legacy file written by a same-version binary) is fully trusted and +/// becomes the base; every other candidate contributes per-(day, provider) +/// slices it alone still has, marked `carried`. This is what makes a schema +/// bump lossless: the new version starts from the union of everything every +/// previous version ever recorded, then re-derives what sources still support. +async function adoptOlderDailyCaches(): Promise { + const dir = getCacheDir() + let names: string[] = [] 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 - } - return emptyCache() + names = await readdir(dir) } catch { return emptyCache() } + const candidates: { parsed: AdoptableCache; mtimeMs: number }[] = [] + for (const name of names) { + if (!name.startsWith('daily-cache') || !name.includes('.json')) continue + if (name === DAILY_CACHE_FILENAME) continue + // .tmp files are included deliberately: a crash between the atomic write + // completing and the rename landing leaves the NEWEST state only in the + // .tmp. A truncated half-write fails JSON.parse below and is skipped. + const path = join(dir, name) + try { + const parsed: unknown = JSON.parse(await readFile(path, 'utf-8')) + if (!isAdoptableCache(parsed)) continue + candidates.push({ parsed, mtimeMs: (await stat(path)).mtimeMs }) + } catch { + continue + } + } + if (candidates.length === 0) return emptyCache() + // Priority: newer schema first, then most recently written. Higher priority + // wins per (day, provider); lower priority only fills what is missing. + candidates.sort((a, b) => (b.parsed.version - a.parsed.version) || (b.mtimeMs - a.mtimeMs)) + + let base: DailyCache + let rest = candidates + if (candidates[0]!.parsed.version === DAILY_CACHE_VERSION && isMigratableCache(candidates[0]!.parsed)) { + base = migratedFrom(candidates[0]!.parsed as Parameters[0]) + rest = candidates.slice(1) + } else { + base = emptyCache() + } + let days = base.days + for (const { parsed } of rest) { + days = mergeDayEntries(days, migrateDays(parsed.days), true) + } + // loadDailyCache has standalone readers, so the adopted result must already + // satisfy the cache's own invariants: no today/future entries (they would be + // served frozen instead of recomputed live) and nothing past retention. + const now = new Date() + const todayStr = toDateString(now) + const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)) + days = applyRetention(days.filter(d => d.date < todayStr), yesterdayStr) + // A trusted base can carry lastComputedDate >= today (clock skew wrote a + // frozen today entry that the purge above just removed). Left as-is it would + // make hydration skip the gap parse forever and the purged day would never + // be recomputed. Clamp back to the retained data. + let lastComputedDate = base.lastComputedDate + if (lastComputedDate && lastComputedDate > yesterdayStr) { + lastComputedDate = days.length > 0 ? days[days.length - 1]!.date : null + } + const adopted: DailyCache = { + ...base, + lastComputedDate, + days, + // An untrusted base means nothing here was derived under the current + // accounting: leave complete unset so the next hydration re-derives every + // day whose sources survive (the merge keeps the rest). + complete: rest.length === candidates.length ? false : base.complete, + } + await saveDailyCache(adopted).catch(() => {}) + return adopted } export async function saveDailyCache(cache: DailyCache): Promise { @@ -222,20 +441,6 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate byDate.set(day.date, day) } const merged = Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date)) - // Prune entries older than the BACKFILL window so the cache file does not - // grow unbounded over years of daily use. The "all time" / 6-month period - // and the BACKFILL_DAYS bootstrap both fit comfortably inside this cap. - // Anchor the cap on the newestDate boundary so a stale or stuck clock - // can't accidentally evict everything. Skip the prune entirely if - // newestDate is malformed — an invalid Date would produce a NaN cutoff - // and `d.date >= "Invalid Date"` would silently drop every entry. - const cutoffDate = new Date(`${newestDate}T00:00:00Z`) - let pruned = merged - if (!isNaN(cutoffDate.getTime())) { - cutoffDate.setUTCDate(cutoffDate.getUTCDate() - DAILY_CACHE_RETENTION_DAYS) - const cutoff = toDateString(cutoffDate) - pruned = merged.filter(d => d.date >= cutoff) - } const nextLast = cache.lastComputedDate && cache.lastComputedDate > newestDate ? cache.lastComputedDate : newestDate @@ -244,11 +449,166 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate savingsConfigHash: cache.savingsConfigHash, tzKey: cache.tzKey, lastComputedDate: nextLast, - days: pruned, + days: applyRetention(merged, newestDate), complete: cache.complete, } } +/// Prune entries older than the retention window so the cache file does not +/// grow unbounded over years of daily use. Anchor the cutoff on newestDate so +/// a stale or stuck clock can't accidentally evict everything. Skip the prune +/// entirely if newestDate is malformed — an invalid Date would produce a NaN +/// cutoff and `d.date >= "Invalid Date"` would silently drop every entry. +function applyRetention(days: DailyEntry[], newestDate: string): DailyEntry[] { + const cutoffDate = new Date(`${newestDate}T00:00:00Z`) + if (isNaN(cutoffDate.getTime())) return days + cutoffDate.setUTCDate(cutoffDate.getUTCDate() - DAILY_CACHE_RETENTION_DAYS) + const cutoff = toDateString(cutoffDate) + return days.filter(d => d.date >= cutoff) +} + +function hasSliceData(slice: ProviderDaySlice): boolean { + return slice.cost > 0 || slice.calls > 0 || (slice.savingsUSD ?? 0) > 0 +} + +/// A day from a pre-v5-era cache: day-level totals exist but the providers map +/// is empty, so nothing can be attributed per provider. Such a day merges +/// all-or-nothing — filling slices into it would double-count whatever share +/// of its totals the incoming provider already contributed. +function isOpaqueDay(day: DailyEntry): boolean { + return (day.cost > 0 || day.calls > 0) && Object.keys(day.providers).length === 0 +} + +function emptyModelStats(): ModelDayStats { + return { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } +} + +/// Fold one provider's day slice into a day: the providers map, the day-level +/// totals, and (when the slice carries them — v14+ slices do) the model and +/// category breakdowns. Skinny slices from pre-v14 caches restore only +/// cost/calls/savings; the day's other totals simply don't grow. A zero-data +/// placeholder already present for the provider (a session that started this +/// day but whose turns all landed on another) only contributes its session +/// count, deduplicated by max — the same real session may be counted on both +/// sides. +function addSliceIntoDay(day: DailyEntry, provider: string, slice: ProviderDaySlice): void { + // Reads keyed by names from foreign caches use hasOwn throughout: a plain + // lookup of "__proto__" returns the prototype object, and accumulating into + // it pollutes every object in the process. + const placeholder = Object.hasOwn(day.providers, provider) ? day.providers[provider] : undefined + const placeholderSessions = placeholder?.sessions ?? 0 + const merged = structuredClone(slice) + if (placeholderSessions > (merged.sessions ?? 0)) merged.sessions = placeholderSessions + setOwn(day.providers, provider, merged) + day.cost += slice.cost + day.calls += slice.calls + day.savingsUSD += slice.savingsUSD ?? 0 + day.sessions += Math.max(0, (slice.sessions ?? 0) - placeholderSessions) + day.inputTokens += slice.inputTokens ?? 0 + day.outputTokens += slice.outputTokens ?? 0 + day.cacheReadTokens += slice.cacheReadTokens ?? 0 + day.cacheWriteTokens += slice.cacheWriteTokens ?? 0 + day.editTurns += slice.editTurns ?? 0 + day.oneShotTurns += slice.oneShotTurns ?? 0 + for (const [name, m] of Object.entries(slice.models ?? {})) { + const acc = Object.hasOwn(day.models, name) ? day.models[name]! : emptyModelStats() + acc.calls += m.calls + acc.cost += m.cost + acc.savingsUSD += m.savingsUSD ?? 0 + acc.inputTokens += m.inputTokens + acc.outputTokens += m.outputTokens + acc.cacheReadTokens += m.cacheReadTokens + acc.cacheWriteTokens += m.cacheWriteTokens + setOwn(day.models, name, acc) + } + for (const [cat, c] of Object.entries(slice.categories ?? {})) { + const acc = Object.hasOwn(day.categories, cat) ? day.categories[cat]! : { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } + acc.turns += c.turns + acc.cost += c.cost + acc.savingsUSD += c.savingsUSD ?? 0 + acc.editTurns += c.editTurns + acc.oneShotTurns += c.oneShotTurns + setOwn(day.categories, cat, acc) + } + const placeholderProjects = placeholder?.projects ?? {} + for (const [name, p] of Object.entries(slice.projects ?? {})) { + if (!p || typeof p !== 'object' || Array.isArray(p)) continue + const dayProjects = (day.projects ??= {}) + const acc = Object.hasOwn(dayProjects, name) ? dayProjects[name]! : { cost: 0, calls: 0, savingsUSD: 0, sessions: 0 } + acc.cost += num(p.cost) + acc.calls += num(p.calls) + acc.savingsUSD += num(p.savingsUSD) + if (!acc.path && typeof p.path === 'string') acc.path = p.path + // Same session dedup as the slice-level sessions above: a placeholder's + // project sessions were already counted into the day when the fresh day + // was built, so only the excess is added. + const placeholderProjectSessions = Object.hasOwn(placeholderProjects, name) ? num(placeholderProjects[name]?.sessions) : 0 + acc.sessions += Math.max(0, num(p.sessions) - placeholderProjectSessions) + setOwn(dayProjects, name, acc) + } + // Placeholder-only projects (session counted fresh, calls landed elsewhere) + // survive on the merged slice rather than being dropped by the clone above. + const mergedProjects = merged.projects + if (mergedProjects) { + for (const [name, p] of Object.entries(placeholderProjects)) { + if (!p || typeof p !== 'object') continue + if (Object.hasOwn(mergedProjects, name)) { + if (num(p.sessions) > num(mergedProjects[name]!.sessions)) mergedProjects[name]!.sessions = num(p.sessions) + } else { + setOwn(mergedProjects, name, { cost: 0, calls: 0, savingsUSD: 0, sessions: num(p.sessions) }) + } + } + } else if (placeholder?.projects) { + merged.projects = structuredClone(placeholder.projects) + } +} + +/// Assign via defineProperty so filesystem-derived keys like "__proto__" become +/// ordinary own properties instead of mutating the prototype link. +function setOwn(target: Record, key: string, value: T): void { + Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) +} + +/// Merge two day lists per (date, provider): `primary` wins wherever both have +/// data; `secondary` only fills dates primary lacks entirely and provider +/// slices primary lacks on shared dates. Nothing in secondary can overwrite or +/// double into primary. With markSecondaryCarried, every day that received a +/// secondary contribution is flagged `carried`. +/// +/// Days that cannot be attributed per provider merge all-or-nothing: +/// - an OPAQUE primary day (pre-v5-era: totals but empty providers map) is +/// never slice-filled — its totals may already contain the incoming +/// provider's share, so adding would double-count; +/// - an opaque secondary day on a date primary already has contributes +/// nothing — its day-level totals cannot be attributed without slices. +/// A primary slice blocks a secondary one only when it carries DATA; a +/// zero-data placeholder (sessions only) is merged into, not treated as a +/// re-derivation of the provider's day. +export function mergeDayEntries(primary: DailyEntry[], secondary: DailyEntry[], markSecondaryCarried: boolean): DailyEntry[] { + const byDate = new Map() + for (const day of primary) byDate.set(day.date, structuredClone(day)) + for (const day of secondary) { + const existing = byDate.get(day.date) + if (!existing) { + const copy = structuredClone(day) + if (markSecondaryCarried) copy.carried = true + byDate.set(day.date, copy) + continue + } + if (isOpaqueDay(existing)) continue + for (const [provider, slice] of Object.entries(day.providers)) { + // Sessions-only slices (a session whose calls all landed on another + // day) still carry a real session count — worth preserving. + if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue + const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined + if (existingSlice && hasSliceData(existingSlice)) continue + addSliceIntoDay(existing, provider, slice) + if (markSecondaryCarried) existing.carried = true + } + } + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)) +} + export function getDaysInRange(cache: DailyCache, start: string, end: string): DailyEntry[] { return cache.days.filter(d => d.date >= start && d.date <= end) } @@ -295,47 +655,68 @@ export async function ensureCacheHydrated( return withDailyCacheLock(async () => { let c = await loadDailyCache() - // Three 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. + // Drop any cached entry dated today or later BEFORE anything else can + // carry it forward. The cache only ever stores complete past days (up to + // yesterday), so a >= today entry can only come from the clock moving + // backward or a stale older cache; left in place it would be served frozen + // instead of recomputed live. Yesterday and earlier stay cached. + const todayStr = toDateString(now) + if (c.days.some(d => d.date >= todayStr)) { + const freshDays = c.days.filter(d => d.date < todayStr) + const latestFresh = freshDays.length > 0 ? freshDays[freshDays.length - 1].date : null + c = { ...c, days: freshDays, lastComputedDate: latestFresh } + } + + // Three reasons to re-derive the whole retention window: + // 1. Savings config changed — cached `savingsUSD` totals are stale. // 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). + // pre-marker cache, an adoption from older cache files, or one frozen + // from a partial/interrupted hydration). // 3. The local timezone changed — days are bucketed by local midnight, so a // TZ change mis-buckets every cached day. Only invalidate when a tzKey is // present and differs (a cache written before this field, or a test // fixture, has none → left alone rather than force a spurious rebuild). + // + // Re-derive, NOT discard. Session files are ephemeral; a cached day whose + // sources are gone exists nowhere else, so the old days stay as a baseline + // and the fresh parse overrides per (day, provider) wherever it actually + // produced data. What it could not re-derive is carried forward (marked + // `carried`) with its old accounting — every wipe here before v14 turned + // into permanently lost history. const tzKey = currentTzKey() const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) { + const baseline = c.days + const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS) + let freshDays: DailyEntry[] = [] + if (backfillStart.getTime() <= yesterdayEnd.getTime()) { + freshDays = aggregateDays(await parseSessions({ start: backfillStart, end: yesterdayEnd })) + } + const parseWasComplete = sessionComplete() + // A PARTIAL parse must not overwrite finalized baseline days with + // undercounts (if their sources die before the next complete parse, the + // undercount would be what survives). Partial fresh data only fills days + // and slices the baseline lacks; the next complete parse gets to win. + const merged = parseWasComplete + ? mergeDayEntries(freshDays, baseline, true) + : mergeDayEntries(baseline, freshDays, false) c = { version: DAILY_CACHE_VERSION, savingsConfigHash, tzKey, - lastComputedDate: null, - days: [], - complete: false, + lastComputedDate: yesterdayStr, + days: applyRetention(merged, yesterdayStr), + complete: parseWasComplete, } - } else if (c.tzKey === undefined) { + await saveDailyCache(c) + return c + } + if (c.tzKey === undefined) { // First write under the tzKey scheme: tag the cache so a later TZ change is // detectable, without discarding the (still-valid, same-TZ) cached days. c = { ...c, tzKey } } - // Drop any cached entry dated today or later. The cache only ever stores - // complete past days (up to yesterday), so a >= today entry can only come - // from the clock moving backward or a stale older cache; left in place it - // would be served frozen instead of recomputed live. Yesterday and earlier - // stay cached, so this does not re-parse already-cached days. - const todayStr = toDateString(now) - if (c.days.some(d => d.date >= todayStr)) { - const freshDays = c.days.filter(d => d.date < todayStr) - const latestFresh = freshDays.length > 0 ? freshDays[freshDays.length - 1].date : null - c = { ...c, days: freshDays, lastComputedDate: latestFresh } - } - const gapStart = c.lastComputedDate ? new Date( parseInt(c.lastComputedDate.slice(0, 4)), diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts index 79ca7805..b4d54408 100644 --- a/src/day-aggregator.ts +++ b/src/day-aggregator.ts @@ -1,4 +1,4 @@ -import type { DailyEntry } from './daily-cache.js' +import type { DailyEntry, ProjectDayStats, ProviderDaySlice } from './daily-cache.js' import type { PeriodData } from './menubar-json.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' @@ -26,6 +26,14 @@ export function dateKey(iso: string): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } +function emptySlice(): ProviderDaySlice { + return { + calls: 0, cost: 0, savingsUSD: 0, + sessions: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, + } +} + export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntry[] { const byDate = new Map() const ensure = (date: string): DailyEntry => { @@ -33,11 +41,37 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr if (!d) { d = emptyEntry(date); byDate.set(date, d) } return d } + const ensureSlice = (day: DailyEntry, provider: string): ProviderDaySlice => { + let s = day.providers[provider] + if (!s) { s = emptySlice(); day.providers[provider] = s } + return s + } + const ensureProject = (holder: { projects?: Record }, project: string, path?: string): ProjectDayStats => { + const projects = (holder.projects ??= {}) + // defineProperty so a project directory named "__proto__" becomes an own + // key instead of mutating the prototype link. + let p = Object.hasOwn(projects, project) ? projects[project] : undefined + if (!p) { + p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0 } + Object.defineProperty(projects, project, { value: p, enumerable: true, writable: true, configurable: true }) + } + if (!p.path && path) p.path = path + return p + } for (const project of projects) { for (const session of project.sessions) { const sessionDate = dateKey(session.firstTimestamp) - ensure(sessionDate).sessions += 1 + const sessionDay = ensure(sessionDate) + sessionDay.sessions += 1 + ensureProject(sessionDay, session.project, project.projectPath).sessions += 1 + // A session belongs to exactly one provider; its calls all carry it. + const sessionProvider = session.turns.flatMap(t => t.assistantCalls)[0]?.provider + if (sessionProvider) { + const slice = ensureSlice(sessionDay, sessionProvider) + slice.sessions! += 1 + ensureProject(slice, session.project, project.projectPath).sessions += 1 + } for (const turn of session.turns) { if (turn.assistantCalls.length === 0) continue @@ -68,6 +102,30 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr cat.oneShotTurns += oneShotTurns turnDay.categories[turn.category] = cat + // Slice-level turn stats are attributed per provider actually present + // in the turn, each with only ITS calls' cost — a slice's category + // totals must never contain another provider's spend, or a later + // carry-forward of that slice would overstate the day. + const providersInTurn = new Map() + for (const call of turn.assistantCalls) { + const acc = providersInTurn.get(call.provider) ?? { cost: 0, savingsUSD: 0 } + acc.cost += call.costUSD + acc.savingsUSD += call.savingsUSD ?? 0 + providersInTurn.set(call.provider, acc) + } + for (const [prov, totals] of providersInTurn) { + const turnSlice = ensureSlice(turnDay, prov) + turnSlice.editTurns! += editTurns + turnSlice.oneShotTurns! += oneShotTurns + const sliceCat = turnSlice.categories![turn.category] ?? { turns: 0, cost: 0, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } + sliceCat.turns += 1 + sliceCat.cost += totals.cost + sliceCat.savingsUSD += totals.savingsUSD + sliceCat.editTurns += editTurns + sliceCat.oneShotTurns += oneShotTurns + turnSlice.categories![turn.category] = sliceCat + } + for (const call of turn.assistantCalls) { const callSavings = call.savingsUSD ?? 0 @@ -79,6 +137,11 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr turnDay.cacheReadTokens += call.usage.cacheReadInputTokens turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens + const dayProject = ensureProject(turnDay, session.project, project.projectPath) + dayProject.cost += call.costUSD + dayProject.calls += 1 + dayProject.savingsUSD += callSavings + const model = turnDay.models[call.model] ?? { calls: 0, cost: 0, savingsUSD: 0, inputTokens: 0, outputTokens: 0, @@ -93,11 +156,33 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr model.cacheWriteTokens += call.usage.cacheCreationInputTokens turnDay.models[call.model] = model - const provider = turnDay.providers[call.provider] ?? { calls: 0, cost: 0, savingsUSD: 0 } - provider.calls += 1 - provider.cost += call.costUSD - provider.savingsUSD += callSavings - turnDay.providers[call.provider] = provider + const slice = ensureSlice(turnDay, call.provider) + slice.calls += 1 + slice.cost += call.costUSD + slice.savingsUSD += callSavings + slice.inputTokens! += call.usage.inputTokens + slice.outputTokens! += call.usage.outputTokens + slice.cacheReadTokens! += call.usage.cacheReadInputTokens + slice.cacheWriteTokens! += call.usage.cacheCreationInputTokens + + const sliceProject = ensureProject(slice, session.project, project.projectPath) + sliceProject.cost += call.costUSD + sliceProject.calls += 1 + sliceProject.savingsUSD += callSavings + + const sliceModel = slice.models![call.model] ?? { + calls: 0, cost: 0, savingsUSD: 0, + inputTokens: 0, outputTokens: 0, + cacheReadTokens: 0, cacheWriteTokens: 0, + } + sliceModel.calls += 1 + sliceModel.cost += call.costUSD + sliceModel.savingsUSD += callSavings + sliceModel.inputTokens += call.usage.inputTokens + sliceModel.outputTokens += call.usage.outputTokens + sliceModel.cacheReadTokens += call.usage.cacheReadInputTokens + sliceModel.cacheWriteTokens += call.usage.cacheCreationInputTokens + slice.models![call.model] = sliceModel } } } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index e8dd2400..13ad0f7b 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -10,7 +10,7 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggreg import { aggregateModelEfficiency } from './model-efficiency.js' import { aggregateModels } from './models-report.js' import { scanAndDetect } from './optimize.js' -import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js' +import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js' import { buildGranularHistory } from './granular-history.js' export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { @@ -212,6 +212,11 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: let scanProjects!: ProjectSummary[] let scanRange!: DateRange let cache: DailyCache = emptyCache() + /// The exact day set behind the all-provider headline (cache-backed + /// historical days + today's live days, day-filtered). Non-null only on the + /// unscoped all-provider path; it is the authority the projects view merges + /// from, so carried days count even after their session files are gone. + let cacheDaysForPeriod: DailyEntry[] | null = null let todayProviderData: PeriodData | null = null let claudeConfigs: ClaudeConfigSelector | undefined const requestedClaudeConfigSourceId = opts.claudeConfigSourceId?.trim() || null @@ -272,6 +277,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) const unfilteredDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) const allDays = daysSelection ? unfilteredDays.filter(d => daysSelection.days.has(d.date)) : unfilteredDays + cacheDaysForPeriod = allDays currentData = buildPeriodDataFromDays(allDays, periodInfo.label) } else { cache = await loadDailyCache() @@ -284,11 +290,38 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } } if (isAllProviders) { - // Load-bearing overwrite: the daily-cache path above never carries - // estimatedCostUSD (DailyEntry has no such field), so this fresh-parse - // rebuild is what keeps the estimated marker alive on cached periods. - // Removing it as redundant silently drops the flag. - currentData = buildPeriodData(periodInfo.label, scanProjects) + const scanData = buildPeriodData(periodInfo.label, scanProjects) + if (cacheDaysForPeriod === null) { + // Scoped all-provider path (claude-config source selected): the scan IS + // the authority, as before. + currentData = scanData + } else { + // The daily-cache path is the authority for headline totals: it includes + // carried days whose session files no longer exist, which the fresh + // parse cannot see. Replacing it wholesale with the scan (the previous + // behavior) silently truncated cost/calls/models/categories to the + // source-retention window. The scan contributes ONLY what DailyEntry + // genuinely lacks: the estimated-cost markers and unpriced-model + // detection, both derivable solely from surviving sessions. + currentData.estimatedCostUSD = scanData.estimatedCostUSD + currentData.unpricedModels = scanData.unpricedModels + // Sessions: the cache buckets a session on its START day, the scan + // counts it on any day it was ACTIVE. Each undercounts differently + // (day-filtered views miss midnight-spanners; the scan misses expired + // sessions). Both count distinct real sessions, so max is the tightest + // safe bound and never double counts. + currentData.sessions = Math.max(currentData.sessions, scanData.sessions) + const estimatedByModel = new Map( + scanData.models + .filter(m => m.estimatedCostUSD != null) + .map(m => [m.name, m.estimatedCostUSD!]), + ) + if (estimatedByModel.size > 0) { + currentData.models = currentData.models.map(m => + estimatedByModel.has(m.name) ? { ...m, estimatedCostUSD: estimatedByModel.get(m.name) } : m, + ) + } + } } claudeConfigs = claudeConfigs ?? await claudeConfigSelector(scanProjects, null) @@ -402,33 +435,71 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } const home = homedir() - const friendlyProject = (p: ProjectSummary) => { - const resolved = p.projectPath || p.project - if (resolved === home || resolved === home + '/') return 'Home' - return resolved.split('/').filter(Boolean).pop() || p.project + const friendlyFromPath = (path: string | undefined, fallback: string): string => { + if (!path) return fallback + if (path === home || path === home + '/') return 'Home' + return path.split('/').filter(Boolean).pop() || fallback } + const friendlyProject = (p: ProjectSummary) => friendlyFromPath(p.projectPath || p.project, p.project) + const sessionDetailsOf = (p: ProjectSummary) => [...p.sessions] + .sort((a, b) => b.totalCostUSD - a.totalCostUSD) + .slice(0, 10) + .map(s => ({ + cost: s.totalCostUSD, + savingsUSD: s.totalSavingsUSD, + calls: s.apiCalls, + inputTokens: s.totalInputTokens, + outputTokens: s.totalOutputTokens, + date: s.firstTimestamp?.split('T')[0] ?? '', + models: Object.entries(s.modelBreakdown) + .map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD })) + .sort((a, b) => b.cost - a.cost) + .slice(0, 3), + })) - currentData.projects = scanProjects.map(p => ({ - name: friendlyProject(p), - cost: p.totalCostUSD, - savingsUSD: p.totalSavingsUSD, - sessions: p.sessions.length, - sessionDetails: [...p.sessions] - .sort((a, b) => b.totalCostUSD - a.totalCostUSD) - .slice(0, 10) - .map(s => ({ - cost: s.totalCostUSD, - savingsUSD: s.totalSavingsUSD, - calls: s.apiCalls, - inputTokens: s.totalInputTokens, - outputTokens: s.totalOutputTokens, - date: s.firstTimestamp?.split('T')[0] ?? '', - models: Object.entries(s.modelBreakdown) - .map(([name, m]) => ({ name, cost: m.costUSD, savingsUSD: m.savingsUSD })) - .sort((a, b) => b.cost - a.cost) - .slice(0, 3), - })), - })) + if (cacheDaysForPeriod !== null) { + // Project totals come from the SAME day set as the headline, so carried + // days count here too. The surviving-session parse contributes only what + // day entries cannot: the per-session drill-down and a fresher project + // path. Days recorded before the projects rollup existed have totals but + // no project split, so this list can sum to less than the headline — an + // honest gap, not a bug. + type CachedProjectTotal = { cost: number; savingsUSD: number; sessions: number; path?: string } + const cachedTotals = new Map() + for (const d of cacheDaysForPeriod) { + for (const [name, p] of Object.entries(d.projects ?? {})) { + const acc = cachedTotals.get(name) ?? { cost: 0, savingsUSD: 0, sessions: 0 } + acc.cost += p.cost + acc.savingsUSD += p.savingsUSD + acc.sessions += p.sessions + if (!acc.path && p.path) acc.path = p.path + cachedTotals.set(name, acc) + } + } + const liveByName = new Map(scanProjects.map(p => [p.project, p])) + const names = new Set([...cachedTotals.keys(), ...liveByName.keys()]) + currentData.projects = [...names].map(name => { + const cached = cachedTotals.get(name) + const live = liveByName.get(name) + return { + name: live ? friendlyProject(live) : friendlyFromPath(cached?.path, name), + cost: cached?.cost ?? live!.totalCostUSD, + savingsUSD: cached?.savingsUSD ?? live!.totalSavingsUSD, + // max for the same reason as the headline: start-day bucketing vs + // active-day counting, both lower bounds of distinct sessions. + sessions: Math.max(cached?.sessions ?? 0, live?.sessions.length ?? 0), + ...(live ? { sessionDetails: sessionDetailsOf(live) } : {}), + } + }).sort((a, b) => b.cost - a.cost) + } else { + currentData.projects = scanProjects.map(p => ({ + name: friendlyProject(p), + cost: p.totalCostUSD, + savingsUSD: p.totalSavingsUSD, + sessions: p.sessions.length, + sessionDetails: sessionDetailsOf(p), + })) + } const effMap = aggregateModelEfficiency(scanProjects) currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({ diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts new file mode 100644 index 00000000..1d8f7e93 --- /dev/null +++ b/tests/daily-cache-carry-forward.test.ts @@ -0,0 +1,713 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import type { ProjectSummary } from '../src/types.js' +import { buildPeriodDataFromDays } from '../src/day-aggregator.js' + +import { + DAILY_CACHE_VERSION, + type DailyCache, + type DailyEntry, + type ProviderDaySlice, + currentTzKey, + dailyCachePath, + ensureCacheHydrated, + loadDailyCache, + mergeDayEntries, + saveDailyCache, +} from '../src/daily-cache.js' + +const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-carry-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + +beforeEach(async () => { + process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT + await mkdir(TMP_CACHE_ROOT, { recursive: true }) +}) + +afterEach(async () => { + if (existsSync(TMP_CACHE_ROOT)) { + await rm(TMP_CACHE_ROOT, { recursive: true, force: true }) + } +}) + +function slice(cost: number, calls: number, extra: Partial = {}): ProviderDaySlice { + return { cost, calls, savingsUSD: 0, ...extra } +} + +function day(date: string, providers: Record, overrides: Partial = {}): DailyEntry { + const cost = Object.values(providers).reduce((s, p) => s + p.cost, 0) + const calls = Object.values(providers).reduce((s, p) => s + p.calls, 0) + return { + date, + cost, + savingsUSD: 0, + calls, + sessions: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: {}, + categories: {}, + providers, + ...overrides, + } +} + +function daysAgoStr(n: number): string { + const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +const noSessions = async (): Promise => [] + +describe('mergeDayEntries', () => { + it('keeps primary-only and secondary-only days; secondary days get the carried mark', () => { + const merged = mergeDayEntries( + [day('2026-06-01', { claude: slice(10, 2) })], + [day('2026-05-01', { claude: slice(5, 1) })], + true, + ) + expect(merged.map(d => d.date)).toEqual(['2026-05-01', '2026-06-01']) + expect(merged[0]!.carried).toBe(true) + expect(merged[1]!.carried).toBeUndefined() + }) + + it('primary wins per provider on shared dates — no overwrite, no double count', () => { + const merged = mergeDayEntries( + [day('2026-06-01', { claude: slice(50, 5) })], + [day('2026-06-01', { claude: slice(100, 10) })], + true, + ) + expect(merged).toHaveLength(1) + expect(merged[0]!.cost).toBe(50) + expect(merged[0]!.providers['claude']!.cost).toBe(50) + expect(merged[0]!.carried).toBeUndefined() + }) + + it('fills provider slices missing from primary on shared dates and sums day totals', () => { + // The real 2026-04-26 case: rebuild found only codex, the old cache still + // had the claude slice — the merged day must hold both. + const merged = mergeDayEntries( + [day('2026-04-26', { codex: slice(3.28, 4) })], + [day('2026-04-26', { codex: slice(3.28, 4), claude: slice(54.08, 120) })], + true, + ) + expect(merged).toHaveLength(1) + expect(merged[0]!.providers['codex']!.cost).toBe(3.28) + expect(merged[0]!.providers['claude']!.cost).toBe(54.08) + expect(merged[0]!.cost).toBeCloseTo(57.36, 5) + expect(merged[0]!.calls).toBe(124) + expect(merged[0]!.carried).toBe(true) + }) + + it('a rich slice carries its tokens, models, categories, and projects into the day', () => { + const rich = slice(20, 3, { + sessions: 2, + inputTokens: 1000, + outputTokens: 500, + cacheReadTokens: 200, + cacheWriteTokens: 100, + editTurns: 2, + oneShotTurns: 1, + models: { 'opus-4-8': { calls: 3, cost: 20, savingsUSD: 0, inputTokens: 1000, outputTokens: 500, cacheReadTokens: 200, cacheWriteTokens: 100 } }, + categories: { coding: { turns: 3, cost: 20, savingsUSD: 0, editTurns: 2, oneShotTurns: 1 } }, + projects: { eywa: { cost: 15, calls: 2, savingsUSD: 0, sessions: 1 }, codeburn: { cost: 5, calls: 1, savingsUSD: 0, sessions: 1 } }, + }) + const merged = mergeDayEntries( + [day('2026-06-01', { codex: slice(1, 1, { projects: { codeburn: { cost: 1, calls: 1, savingsUSD: 0, sessions: 1 } } }) }, { projects: { codeburn: { cost: 1, calls: 1, savingsUSD: 0, sessions: 1 } } })], + [day('2026-06-01', { claude: rich })], + true, + ) + const m = merged[0]! + expect(m.cost).toBe(21) + expect(m.sessions).toBe(2) + expect(m.inputTokens).toBe(1000) + expect(m.outputTokens).toBe(500) + expect(m.editTurns).toBe(2) + expect(m.oneShotTurns).toBe(1) + expect(m.models['opus-4-8']!.cost).toBe(20) + expect(m.categories['coding']!.turns).toBe(3) + // Projects from the carried claude slice fold into the day's project map, + // summing with the fresh codex contribution on the shared project. + expect(m.projects!['eywa']).toEqual({ cost: 15, calls: 2, savingsUSD: 0, sessions: 1 }) + expect(m.projects!['codeburn']).toEqual({ cost: 6, calls: 2, savingsUSD: 0, sessions: 2 }) + }) + + it('a skinny pre-v14 slice still restores exact cost/calls/savings', () => { + const merged = mergeDayEntries( + [day('2026-06-01', { codex: slice(1, 1) })], + [day('2026-06-01', { claude: { calls: 7, cost: 13.42, savingsUSD: 2 } })], + true, + ) + expect(merged[0]!.cost).toBeCloseTo(14.42, 5) + expect(merged[0]!.calls).toBe(8) + expect(merged[0]!.savingsUSD).toBe(2) + }) + + it('an empty-providers secondary day cannot contribute to an existing date', () => { + const merged = mergeDayEntries( + [day('2026-06-01', { claude: slice(10, 2) })], + [day('2026-06-01', {}, { cost: 999, calls: 999 })], + true, + ) + expect(merged[0]!.cost).toBe(10) + expect(merged[0]!.carried).toBeUndefined() + }) + + it('never slice-fills an OPAQUE day (totals but empty providers) — no double count', () => { + // Adversarial-review finding: an adopted pre-v5 day has day totals that + // already CONTAIN every provider's share, but no slices to dedupe against. + // Filling a codex slice into it would double-count codex. + const opaque = day('2026-07-15', {}, { cost: 10, calls: 5 }) + const merged = mergeDayEntries([opaque], [day('2026-07-15', { codex: slice(3, 1) })], false) + expect(merged).toHaveLength(1) + expect(merged[0]!.cost).toBe(10) + expect(merged[0]!.calls).toBe(5) + expect(merged[0]!.providers).toEqual({}) + }) + + it('a zero-data placeholder slice does not block a carried slice', () => { + // Adversarial-review finding: a session that starts on day X with all its + // turns landing on X+1 leaves a {sessions: 1, calls: 0, cost: 0} slice on + // X. That placeholder must not suppress the baseline's real X data. + const placeholder = day('2026-07-10', { claude: slice(0, 0, { sessions: 1 }) }, { sessions: 1 }) + const baseline = day('2026-07-10', { claude: slice(8, 2, { sessions: 1 }) }, { sessions: 1 }) + const merged = mergeDayEntries([placeholder], [baseline], true) + expect(merged[0]!.providers['claude']).toMatchObject({ cost: 8, calls: 2 }) + // The same real session may be counted on both sides — deduplicated by max. + expect(merged[0]!.providers['claude']!.sessions).toBe(1) + expect(merged[0]!.sessions).toBe(1) + expect(merged[0]!.cost).toBe(8) + expect(merged[0]!.carried).toBe(true) + }) + + it('does not double-count a project session across a placeholder merge', () => { + // v15-review finding: the placeholder's project sessions were already + // counted into the fresh day; folding the carried slice must only add the + // excess, and day/provider/project session totals must reconcile. + const placeholder = day('2026-06-01', { + claude: slice(0, 0, { sessions: 1, projects: { P: { cost: 0, calls: 0, savingsUSD: 0, sessions: 1 } } }), + }, { sessions: 1, projects: { P: { cost: 0, calls: 0, savingsUSD: 0, sessions: 1 } } }) + const carried = day('2026-06-01', { + claude: slice(0, 0, { sessions: 1, projects: { P: { cost: 0, calls: 0, savingsUSD: 0, sessions: 1 } } }), + }, { sessions: 1, projects: { P: { cost: 0, calls: 0, savingsUSD: 0, sessions: 1 } } }) + const merged = mergeDayEntries([placeholder], [carried], true) + expect(merged[0]!.sessions).toBe(1) + expect(merged[0]!.projects!['P']!.sessions).toBe(1) + expect(merged[0]!.providers['claude']!.sessions).toBe(1) + }) + + it('survives junk project data from foreign caches without crashing or corrupting', () => { + const junkSlice = slice(5, 2, { + projects: { good: { cost: 5, calls: 2, savingsUSD: 0, sessions: 1 }, bad: null, worse: [1, 2], strings: { cost: 'x', calls: 2 } } as never, + }) + const merged = mergeDayEntries([day('2026-06-01', { codex: slice(1, 1) })], [day('2026-06-01', { claude: junkSlice })], true) + expect(merged[0]!.projects!['good']).toEqual({ cost: 5, calls: 2, savingsUSD: 0, sessions: 1 }) + expect(merged[0]!.projects!['bad']).toBeUndefined() + expect(merged[0]!.projects!['worse']).toBeUndefined() + expect(merged[0]!.projects!['strings']).toEqual({ cost: 0, calls: 2, savingsUSD: 0, sessions: 0 }) + }) + + it('a project named __proto__ becomes an own key, never prototype pollution', () => { + const evil = slice(3, 1, { projects: { ['__proto__']: { cost: 3, calls: 1, savingsUSD: 0, sessions: 1 } } }) + const merged = mergeDayEntries([day('2026-06-01', { codex: slice(1, 1) })], [day('2026-06-01', { claude: evil })], true) + expect(Object.hasOwn(merged[0]!.projects!, '__proto__')).toBe(true) + expect(merged[0]!.projects!['__proto__' as string]).toMatchObject({ cost: 3 }) + expect(({} as Record)['cost']).toBeUndefined() + }) + + it('a hostile __proto__ provider/model/category key becomes an own key on merge', () => { + const hostile = day('2026-06-01', Object.defineProperty({}, '__proto__', { + value: slice(4, 2, { + models: { ['__proto__']: { calls: 2, cost: 4, savingsUSD: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } } as never, + categories: { ['__proto__']: { turns: 1, cost: 4, savingsUSD: 0, editTurns: 0, oneShotTurns: 0 } } as never, + }), + enumerable: true, writable: true, configurable: true, + }) as Record) + const merged = mergeDayEntries([day('2026-06-01', { codex: slice(1, 1) })], [hostile], true) + expect(Object.hasOwn(merged[0]!.providers, '__proto__')).toBe(true) + expect(Object.hasOwn(merged[0]!.models, '__proto__')).toBe(true) + expect(Object.hasOwn(merged[0]!.categories, '__proto__')).toBe(true) + expect(merged[0]!.cost).toBe(5) + expect(({} as Record)['cost']).toBeUndefined() + expect(({} as Record)['calls']).toBeUndefined() + }) + + it('does not mutate its inputs', () => { + const primary = [day('2026-06-01', { codex: slice(1, 1) })] + const secondary = [day('2026-06-01', { claude: slice(5, 2) })] + mergeDayEntries(primary, secondary, true) + expect(primary[0]!.cost).toBe(1) + expect(Object.keys(primary[0]!.providers)).toEqual(['codex']) + expect(secondary[0]!.carried).toBeUndefined() + }) +}) + +describe('never-lose invariant: invalidations with vanished sources', () => { + const seededDay = () => day(daysAgoStr(30), { claude: slice(230.06, 400), codex: slice(79.29, 60) }) + + async function seed(overrides: Partial = {}): Promise { + const d = seededDay() + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [d], + complete: true, + ...overrides, + } + await saveDailyCache(cache) + return d + } + + it('savings-hash change with an empty re-parse keeps the day (carried)', async () => { + const d = await seed() + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-B') + expect(out.days).toHaveLength(1) + expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls, carried: true }) + expect(out.days[0]!.providers['claude']!.cost).toBe(230.06) + expect(out.days[0]!.providers['codex']!.cost).toBe(79.29) + }) + + it('timezone change with an empty re-parse keeps the day (carried)', async () => { + const d = await seed({ tzKey: 'Test/OtherZone' }) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, carried: true }) + expect(out.tzKey).toBe(currentTzKey()) + }) + + it('incomplete-cache retry with an empty re-parse keeps the day', async () => { + const d = await seed({ complete: false }) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, carried: true }) + expect(out.complete).toBe(true) + }) + + it('a version bump (current file absent, old-version file remains) keeps the day', async () => { + const d = await seed() + // Simulate the upgrade: the new binary's filename does not exist yet; the + // previous version's file (older internal schema version) is still in the + // cache dir under its old name. + const oldContent = JSON.parse(await readFile(dailyCachePath(), 'utf-8')) + oldContent.version = DAILY_CACHE_VERSION - 1 + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${DAILY_CACHE_VERSION - 1}.json`), JSON.stringify(oldContent), 'utf-8') + await rm(dailyCachePath()) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days).toHaveLength(1) + expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls, carried: true }) + }) + + it('a same-version file found under an old name is trusted as-is (no spurious rebuild)', async () => { + const d = await seed() + await rename(dailyCachePath(), join(TMP_CACHE_ROOT, 'daily-cache.json')) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days).toHaveLength(1) + expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls }) + expect(out.days[0]!.carried).toBeUndefined() + expect(out.complete).toBe(true) + }) + + it('survives a whole gauntlet: version bump, then hash change, then tz change', async () => { + const d = await seed() + await rename(dailyCachePath(), join(TMP_CACHE_ROOT, 'daily-cache.v9.json.bak')) + await ensureCacheHydrated(noSessions, () => [], 'cfg-B') + await ensureCacheHydrated(noSessions, () => [], 'cfg-C') + const tampered = await loadDailyCache() + await saveDailyCache({ ...tampered, tzKey: 'Test/OtherZone' }) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-C') + expect(out.days).toHaveLength(1) + expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls }) + expect(out.days[0]!.providers['claude']!.cost).toBe(230.06) + }) + + it('corrections still land: a re-derivable day takes the fresh (lower) value, not the old one', async () => { + const target = daysAgoStr(30) + await seed() + // The kiro-style correction: the fresh parse re-derives this day at a much + // lower, correct cost. Fresh must WIN for the provider it re-derived. + const corrected = [day(target, { claude: slice(14.0, 400) })] + const out = await ensureCacheHydrated(noSessions, () => corrected, 'cfg-B') + expect(out.days).toHaveLength(1) + expect(out.days[0]!.providers['claude']!.cost).toBe(14.0) + // codex was NOT re-derived (its files are gone) → carried forward. + expect(out.days[0]!.providers['codex']!.cost).toBe(79.29) + expect(out.days[0]!.cost).toBeCloseTo(14.0 + 79.29, 5) + expect(out.days[0]!.carried).toBe(true) + }) + + it('a PARTIAL session parse cannot overwrite finalized days with undercounts', async () => { + const target = daysAgoStr(30) + await seed({ complete: false }) + // Interrupted hydration: the parse only saw half the claude turns. + const partial = [day(target, { claude: slice(100.0, 150) })] + const out = await ensureCacheHydrated(noSessions, () => partial, 'cfg-A', () => false) + // Baseline wins per (date, provider); the undercount is discarded. + expect(out.days[0]!.providers['claude']!.cost).toBe(230.06) + expect(out.days[0]!.providers['codex']!.cost).toBe(79.29) + // Still not finalized — the next complete parse gets to correct for real. + expect(out.complete).toBe(false) + }) + + it('a partial parse still fills days the baseline lacks entirely', async () => { + await seed({ complete: false }) + const newDate = daysAgoStr(10) + const partial = [day(newDate, { grok: slice(6.29, 9) })] + const out = await ensureCacheHydrated(noSessions, () => partial, 'cfg-A', () => false) + expect(out.days.map(d => d.date)).toEqual([daysAgoStr(30), newDate]) + expect(out.days[1]!.providers['grok']!.cost).toBe(6.29) + }) + + it('a partial parse cannot double into an opaque adopted day', async () => { + // The full adversarial-review repro: a pre-v5 cache day with totals but no + // provider slices is adopted, then a PARTIAL parse produces a codex slice + // for the same date. The opaque day must stand untouched at $10/5. + const target = daysAgoStr(5) + const ancient = { version: 2, days: [{ date: target, cost: 10, calls: 5 }] } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(ancient), 'utf-8') + const partial = [day(target, { codex: slice(3, 1) })] + const out = await ensureCacheHydrated(noSessions, () => partial, '', () => false) + const merged = out.days.find(d => d.date === target)! + expect(merged.cost).toBe(10) + expect(merged.calls).toBe(5) + expect(merged.providers).toEqual({}) + expect(out.complete).toBe(false) + }) + + it('carried days cannot resurrect a today/future entry', async () => { + const today = daysAgoStr(0) + await seed({ days: [seededDay(), day(today, { claude: slice(99, 9) })], complete: false }) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days.map(d => d.date)).toEqual([daysAgoStr(30)]) + }) + + it('retention still prunes ancient carried days after a rebuild', async () => { + await seed({ days: [seededDay(), day('2020-01-01', { claude: slice(1, 1) })], complete: false }) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days.map(d => d.date)).toEqual([daysAgoStr(30)]) + }) +}) + +describe('adoption union across older cache files', () => { + it('reconstructs the fullest history from every older daily-cache file, .bak included', async () => { + // The real machine scenario, miniaturized: a v13 file whose rebuild lost + // the old claude slices, a v9 .bak that still has them, and a legacy v10 + // written by an installed app. Higher version wins per (date, provider); + // lower versions only extend. + const v13 = { + version: 13, + savingsConfigHash: '', + lastComputedDate: '2026-07-17', + complete: true, + days: [ + day('2026-04-26', { codex: slice(3.28, 4) }), + day('2026-06-13', { claude: slice(230.06, 400), codex: slice(79.29, 60) }), + ], + } + const v9bak = { + version: 9, + lastComputedDate: '2026-07-02', + days: [ + day('2026-04-26', { codex: slice(3.9, 5), claude: slice(54.08, 120) }), + day('2026-05-03', { claude: slice(9.27, 15) }), + ], + } + const legacyV10 = { + version: 10, + lastComputedDate: '2026-07-19', + days: [ + day('2026-05-20', { cursor: slice(0.01, 1) }), + ], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json'), JSON.stringify(v13), 'utf-8') + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json.v9.bak'), JSON.stringify(v9bak), 'utf-8') + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(legacyV10), 'utf-8') + + const cache = await loadDailyCache() + expect(cache.version).toBe(DAILY_CACHE_VERSION) + expect(cache.days.map(d => d.date)).toEqual(['2026-04-26', '2026-05-03', '2026-05-20', '2026-06-13']) + const apr26 = cache.days[0]! + // codex from v13 (higher version wins), claude rescued from the v9 .bak. + expect(apr26.providers['codex']!.cost).toBe(3.28) + expect(apr26.providers['claude']!.cost).toBe(54.08) + expect(apr26.cost).toBeCloseTo(57.36, 5) + expect(apr26.carried).toBe(true) + expect(cache.days[1]!.providers['claude']!.cost).toBe(9.27) + expect(cache.days[2]!.providers['cursor']!.cost).toBe(0.01) + // Adopted history is pending re-derivation. + expect(cache.complete).not.toBe(true) + // The v14 file is persisted so adoption is one-time. + expect(existsSync(dailyCachePath())).toBe(true) + // None of the source files were touched. + expect(JSON.parse(await readFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json'), 'utf-8'))).toEqual(JSON.parse(JSON.stringify(v13))) + expect(JSON.parse(await readFile(join(TMP_CACHE_ROOT, 'daily-cache.json.v9.bak'), 'utf-8'))).toEqual(JSON.parse(JSON.stringify(v9bak))) + }) + + it('skips malformed candidates without failing the adoption', async () => { + const good = { + version: 12, + lastComputedDate: '2026-07-01', + days: [day('2026-06-01', { claude: slice(10, 2) })], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v12.json'), JSON.stringify(good), 'utf-8') + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json'), 'not json at all {{', 'utf-8') + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v14.json.deadbeef.tmp'), 'truncated{', 'utf-8') + const cache = await loadDailyCache() + expect(cache.days).toHaveLength(1) + expect(cache.days[0]).toMatchObject({ date: '2026-06-01', cost: 10, carried: true }) + }) + + it('rescues a fully-written .tmp left by a crash before rename', async () => { + // Adversarial-review finding: the atomic write completes (content synced) + // but the process dies before rename. The .tmp is then the ONLY copy of + // the newest state; a parseable one must be adopted. + const onlyCopy = { + version: 13, + days: [day(daysAgoStr(15), { claude: slice(88, 11) })], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json.a1b2c3d4.tmp'), JSON.stringify(onlyCopy), 'utf-8') + const cache = await loadDailyCache() + expect(cache.days).toHaveLength(1) + expect(cache.days[0]).toMatchObject({ date: daysAgoStr(15), cost: 88, carried: true }) + }) + + it('adoption purges today/future entries and applies retention before persisting', async () => { + // Adversarial-review finding: loadDailyCache has standalone readers, so a + // frozen today entry or a 2023 day must not survive adoption itself. + const old = { + version: 13, + days: [ + day('2023-01-01', { claude: slice(1, 1) }), + day(daysAgoStr(15), { claude: slice(5, 2) }), + day(daysAgoStr(0), { claude: slice(99, 9) }), + ], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json'), JSON.stringify(old), 'utf-8') + const cache = await loadDailyCache() + expect(cache.days.map(d => d.date)).toEqual([daysAgoStr(15)]) + }) + + it('drops malformed day entries but keeps the valid ones', async () => { + const mixed = { + version: 11, + days: [ + day('2026-06-01', { claude: slice(10, 2) }), + { date: 'garbage', cost: 5 }, + { cost: 7 }, + { date: '2026-06-02', cost: 'NaN-ish' }, + ], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v11.json'), JSON.stringify(mixed), 'utf-8') + const cache = await loadDailyCache() + expect(cache.days.map(d => d.date)).toEqual(['2026-06-01', '2026-06-02']) + expect(cache.days[0]!.cost).toBe(10) + expect(cache.days[1]!.cost).toBe(0) + }) + + it('sanitizes junk inside provider slices at migration, not just at fold time', async () => { + // v15-closure finding: slice-level junk survived structuredClone into the + // next cache generation. Migration must scrub it. + const dirty = { + version: 13, + days: [{ + date: daysAgoStr(20), cost: 5, calls: 2, + providers: { + claude: { calls: 2, cost: '5', savingsUSD: null, projects: { good: { cost: 5, calls: 2 }, bad: [1] } }, + junk: [1, 2], + }, + }], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json'), JSON.stringify(dirty), 'utf-8') + const cache = await loadDailyCache() + const claude = cache.days[0]!.providers['claude']! + expect(claude).toMatchObject({ calls: 2, cost: 0, savingsUSD: 0 }) + expect(claude.projects).toEqual({ good: { cost: 5, calls: 2, savingsUSD: 0, sessions: 0 } }) + expect(cache.days[0]!.providers['junk']).toBeUndefined() + }) + + it('rescues older files even when the current versioned file is corrupt', async () => { + const old = { + version: 13, + days: [day('2026-06-01', { claude: slice(42, 7) })], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json'), JSON.stringify(old), 'utf-8') + await writeFile(dailyCachePath(), 'corrupted{{{', 'utf-8') + const cache = await loadDailyCache() + expect(cache.days).toHaveLength(1) + expect(cache.days[0]).toMatchObject({ date: '2026-06-01', cost: 42, carried: true }) + }) + + it('adopted carried days then survive a hydration whose parse finds nothing', async () => { + const old = { + version: 13, + days: [day(daysAgoStr(40), { claude: slice(409.62, 900) })], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v13.json'), JSON.stringify(old), 'utf-8') + const out = await ensureCacheHydrated(noSessions, () => [], '') + expect(out.days).toHaveLength(1) + expect(out.days[0]).toMatchObject({ date: daysAgoStr(40), cost: 409.62, carried: true }) + expect(out.complete).toBe(true) + + // And KEEP surviving on subsequent normal hydrations. + const again = await ensureCacheHydrated(noSessions, () => [], '') + expect(again.days).toHaveLength(1) + expect(again.days[0]!.cost).toBe(409.62) + }) + + it('clamps a stale lastComputedDate when adoption purges a frozen today entry', async () => { + // Verification-pass finding: a trusted same-version candidate carrying + // lastComputedDate = today (clock skew) would make hydration skip the gap + // parse forever after the today entry is purged. + const today = daysAgoStr(0) + const trusted = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: today, + complete: true, + days: [day(daysAgoStr(3), { claude: slice(5, 2) }), day(today, { claude: slice(9, 1) })], + } + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(trusted), 'utf-8') + const cache = await loadDailyCache() + expect(cache.days.map(d => d.date)).toEqual([daysAgoStr(3)]) + expect(cache.lastComputedDate).toBe(daysAgoStr(3)) + // Hydration can now fill the gap up to yesterday instead of skipping it. + let parsed = 0 + await ensureCacheHydrated(async () => { parsed += 1; return [] }, () => [], '') + expect(parsed).toBe(1) + }) + + it('preserves a carried sessions-only slice (its calls landed on the next day)', () => { + const fresh = day('2026-07-10', { codex: slice(3, 1) }) + const baseline = day('2026-07-10', { claude: slice(0, 0, { sessions: 1 }) }, { sessions: 1 }) + const merged = mergeDayEntries([fresh], [baseline], true) + expect(merged[0]!.providers['claude']!.sessions).toBe(1) + expect(merged[0]!.sessions).toBe(1) + expect(merged[0]!.cost).toBe(3) + expect(merged[0]!.carried).toBe(true) + }) + + it('round-trips carried marks and rich slices through save/load', async () => { + const rich = day(daysAgoStr(20), { + claude: slice(20, 3, { + sessions: 2, + inputTokens: 1000, + models: { 'opus-4-8': { calls: 3, cost: 20, savingsUSD: 0, inputTokens: 1000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + categories: { coding: { turns: 3, cost: 20, savingsUSD: 0, editTurns: 1, oneShotTurns: 1 } }, + }), + }, { carried: true }) + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [rich], + complete: true, + } + await saveDailyCache(cache) + const loaded = await loadDailyCache() + expect(loaded).toEqual(cache) + }) + + it('sanitizes malformed day-level model and category maps during load', async () => { + const target = daysAgoStr(20) + const dirty = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + complete: true, + days: [{ + ...day(target, { claude: slice(5, 2) }), + models: 'bad', + categories: 9, + }], + } + await writeFile(dailyCachePath(), JSON.stringify(dirty), 'utf-8') + + const loaded = await loadDailyCache() + + expect(loaded.days[0]!.models).toEqual({}) + expect(loaded.days[0]!.categories).toEqual({}) + }) + + it('drops inherited provider and model keys before period aggregation', async () => { + const target = daysAgoStr(20) + const modelStats = { + calls: 1, + cost: 2, + savingsUSD: 0, + inputTokens: 3, + outputTokens: 4, + cacheReadTokens: 5, + cacheWriteTokens: 6, + } + const dirty = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + complete: true, + days: [{ + ...day(target, { + toString: slice(2, 1), + safeProvider: slice(3, 1), + }), + models: { + constructor: modelStats, + safeModel: modelStats, + }, + }], + } + await writeFile(dailyCachePath(), JSON.stringify(dirty), 'utf-8') + + const loaded = await loadDailyCache() + const loadedDay = loaded.days[0]! + let period: ReturnType + try { + period = buildPeriodDataFromDays(loaded.days, 'loaded') + } finally { + const objectConstructor = Object as typeof Object & Record + delete objectConstructor.calls + delete objectConstructor.cost + delete objectConstructor.savingsUSD + } + + expect(Object.hasOwn(loadedDay.providers, 'toString')).toBe(false) + expect(Object.hasOwn(loadedDay.models, 'constructor')).toBe(false) + const assertFiniteNumbers = (value: unknown): void => { + if (typeof value === 'number') { + expect(Number.isFinite(value)).toBe(true) + } else if (value && typeof value === 'object') { + for (const child of Object.values(value)) assertFiniteNumbers(child) + } + } + assertFiniteNumbers(period) + }) + + it('sanitizes a non-string lastComputedDate before hydration parses it', async () => { + const dirty = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: '', + tzKey: currentTzKey(), + lastComputedDate: 42, + complete: true, + days: [], + } + await writeFile(dailyCachePath(), JSON.stringify(dirty), 'utf-8') + + const loaded = await loadDailyCache() + expect(loaded.days).toEqual([]) + + const hydrated = await ensureCacheHydrated(noSessions, () => [], '') + + expect(loaded.lastComputedDate).toBeNull() + expect(hydrated.complete).toBe(true) + }) +}) diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 9b69153a..7cff2ea4 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -68,11 +68,11 @@ describe('loadDailyCache', () => { expect(cache.days).toEqual([]) }) - // 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 () => { + // With carry-forward (v14), a legacy unversioned file whose version is not + // the current one is ADOPTED as a carried baseline — its days survive into + // the new cache, marked `carried` and pending re-derivation. The legacy file + // itself is never rewritten, backed up, or deleted (old binaries still own it). + it('adopts a legacy file too old to trust as a carried baseline, without rewriting it', async () => { const saved = { version: 1, lastComputedDate: '2026-04-10', @@ -83,15 +83,17 @@ describe('loadDailyCache', () => { 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() - // Legacy file untouched (no .bak, contents intact); no versioned file written. + expect(cache.days).toHaveLength(1) + expect(cache.days[0]).toMatchObject({ date: '2026-04-10', cost: 10, calls: 5, carried: true }) + // Adopted days are not yet finalized under current accounting. + expect(cache.complete).not.toBe(true) + // Legacy file untouched (no .bak, contents intact); versioned file persisted. 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) + expect(existsSync(dailyCachePath())).toBe(true) }) - it('ignores a legacy v2 cache (provider rollups would be stale) and leaves it intact', async () => { + it('adopts a legacy v2 cache as carried days and leaves the file intact', async () => { const saved = { version: 2, lastComputedDate: '2026-04-10', @@ -107,13 +109,14 @@ describe('loadDailyCache', () => { 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(cache.days).toHaveLength(1) + expect(cache.days[0]).toMatchObject({ date: '2026-04-10', cost: 10, calls: 5, sessions: 2, carried: true }) + expect(cache.days[0]!.models['claude-opus-4-6']!.cost).toBe(10) expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(false) expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved) }) - it('ignores a legacy v5 cache (predates 1-hour cache pricing) and leaves it intact', async () => { + it('adopts a legacy v5 cache including its provider slices', async () => { const saved = { version: 5, lastComputedDate: '2026-05-01', @@ -139,8 +142,9 @@ describe('loadDailyCache', () => { 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(cache.days).toHaveLength(1) + expect(cache.days[0]).toMatchObject({ date: '2026-05-01', cost: 0.37575, calls: 1, carried: true }) + expect(cache.days[0]!.providers['claude']).toMatchObject({ calls: 1, cost: 0.37575 }) expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(false) expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(saved) }) @@ -380,7 +384,7 @@ describe('withDailyCacheLock', () => { }) describe('ensureCacheHydrated: savings config invalidation', () => { - it('discards cached days when the savingsConfigHash changes between calls', async () => { + it('re-derives on savingsConfigHash change but CARRIES days the parse cannot re-derive', async () => { // Seed a cache with a day OLDER than yesterday so the hydration window // (which keeps `d.date < yesterdayStr`) actually retains it. const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000) @@ -394,15 +398,18 @@ describe('ensureCacheHydrated: savings config invalidation', () => { } await saveDailyCache(seeded) + // The re-derive parse finds NOTHING (session files already deleted). The + // day must survive as carried — this exact path used to wipe it. const parseSessions = async (): Promise => [] const aggregateDays = (): DailyEntry[] => [] - // Hash mismatch → ensureCacheHydrated must drop the stale day and start fresh. const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-B') expect(rehydrated.savingsConfigHash).toBe('cfg-B') - expect(rehydrated.days).toEqual([]) + expect(rehydrated.days).toHaveLength(1) + expect(rehydrated.days[0]).toMatchObject({ date: twoDaysAgoStr, cost: 1.5, calls: 3, carried: true }) + expect(rehydrated.complete).toBe(true) - // Same hash → cached days survive. + // Same hash → cached days survive untouched (no carried marker). const seeded2: DailyCache = { version: DAILY_CACHE_VERSION, savingsConfigHash: 'cfg-C', @@ -414,6 +421,7 @@ describe('ensureCacheHydrated: savings config invalidation', () => { const preserved = await ensureCacheHydrated(parseSessions, aggregateDays, 'cfg-C') expect(preserved.days).toHaveLength(1) expect(preserved.days[0]!.date).toBe(twoDaysAgoStr) + expect(preserved.days[0]!.carried).toBeUndefined() }) }) @@ -423,10 +431,11 @@ describe('ensureCacheHydrated: timezone invalidation', () => { const parseSessions = async (): Promise => [] const aggregateDays = (): DailyEntry[] => [] - it('re-hydrates when the cached tzKey differs from the current timezone', async () => { + it('re-derives on timezone change but keeps days whose sources are gone', async () => { // Days are bucketed by local midnight, so a cache tagged under a different - // timezone mis-buckets every day and must be discarded (like a savings-hash - // mismatch). 'Test/OtherZone' can never equal a real IANA zone. + // timezone re-derives everything. Days that can no longer be re-derived stay + // (old-tz bucketing beats a silent zero). 'Test/OtherZone' can never equal a + // real IANA zone. const seeded: DailyCache = { version: DAILY_CACHE_VERSION, savingsConfigHash: '', @@ -438,7 +447,8 @@ describe('ensureCacheHydrated: timezone invalidation', () => { await saveDailyCache(seeded) const rehydrated = await ensureCacheHydrated(parseSessions, aggregateDays, '') expect(rehydrated.tzKey).toBe(currentTzKey()) - expect(rehydrated.days).toEqual([]) + expect(rehydrated.days).toHaveLength(1) + expect(rehydrated.days[0]).toMatchObject({ date: twoDaysAgoStr, cost: 1.5, carried: true }) }) it('keeps cached days when the tzKey matches the current timezone', async () => { diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts index ca8ba777..2f2898b0 100644 --- a/tests/day-aggregator.test.ts +++ b/tests/day-aggregator.test.ts @@ -197,8 +197,33 @@ describe('aggregateProjectsIntoDays', () => { inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, }) - expect(day.providers['claude']).toEqual({ calls: 1, cost: 7, savingsUSD: 0 }) - expect(day.providers['codex']).toEqual({ calls: 1, cost: 3, savingsUSD: 0 }) + // Provider slices carry the full per-provider breakdown (v14) so that a + // carried-forward slice stays exact across daily-cache rebuilds. + expect(day.providers['claude']).toMatchObject({ + calls: 1, cost: 7, savingsUSD: 0, + inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, + }) + expect(day.providers['claude']!.models).toEqual({ + 'Opus 4.7': { calls: 1, cost: 7, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0 }, + }) + expect(day.providers['codex']).toMatchObject({ + calls: 1, cost: 3, savingsUSD: 0, + inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0, + }) + expect(day.providers['codex']!.models).toEqual({ + 'gpt-5': { calls: 1, cost: 3, savingsUSD: 0, inputTokens: 100, outputTokens: 200, cacheReadTokens: 50, cacheWriteTokens: 0 }, + }) + // Slice categories hold only that provider's share of the turn — a slice + // must never carry another provider's spend into a later merge. + expect(day.providers['claude']!.categories!['coding']).toMatchObject({ turns: 1, cost: 7 }) + expect(day.providers['codex']!.categories!['coding']).toMatchObject({ turns: 1, cost: 3 }) + // Day-level category still counts the whole turn once. + expect(day.categories['coding']).toMatchObject({ turns: 1, cost: 10 }) + // Per-project rollup at day level and inside each provider slice; path is + // stored so display layers can derive a friendly name once sessions expire. + expect(day.projects!['p']).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' }) + expect(day.providers['claude']!.projects!['p']).toMatchObject({ cost: 7, calls: 1 }) + expect(day.providers['codex']!.projects!['p']).toMatchObject({ cost: 3, calls: 1 }) }) }) diff --git a/tests/menubar-carried-headline.test.ts b/tests/menubar-carried-headline.test.ts new file mode 100644 index 00000000..4b8af488 --- /dev/null +++ b/tests/menubar-carried-headline.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js' +import { getDateRange } from '../src/cli-date.js' +import { loadPricing } from '../src/models.js' +import { buildMenubarPayloadForRange, getDailyCacheConfigHash } from '../src/usage-aggregator.js' + +// The adversarial-review blocker on the carry-forward PR: the daily cache held +// carried history, history.daily showed it, but the HEADLINE current.cost / +// calls / topProjects were rebuilt from the surviving-session parse alone, so +// the user-visible totals stayed truncated once session files expired. This +// test seeds a cache whose only day is carried (no session files exist at all) +// and asserts the headline reflects it. + +const ROOT = join(tmpdir(), `codeburn-carried-headline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) +const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const +let savedEnv: Record + +function daysAgoStr(n: number): string { + const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function carriedDay(date: string): DailyEntry { + return { + date, + cost: 100, + savingsUSD: 0, + calls: 40, + sessions: 3, + inputTokens: 5000, + outputTokens: 2000, + cacheReadTokens: 0, + cacheWriteTokens: 0, + editTurns: 4, + oneShotTurns: 2, + models: { 'Opus 4.8': { calls: 40, cost: 100, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } }, + categories: { coding: { turns: 10, cost: 100, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } }, + providers: { + claude: { + calls: 40, cost: 100, savingsUSD: 0, sessions: 3, + inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0, + projects: { 'proj-x': { cost: 100, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } }, + }, + }, + projects: { 'proj-x': { cost: 100, calls: 40, savingsUSD: 0, sessions: 3, path: '/Users/gone/proj-x' } }, + carried: true, + } +} + +beforeAll(async () => { + await loadPricing() +}) + +beforeEach(async () => { + savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]])) + await mkdir(join(ROOT, 'home'), { recursive: true }) + await mkdir(join(ROOT, 'cache'), { recursive: true }) + process.env['HOME'] = join(ROOT, 'home') + process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache') + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CLAUDE_CONFIG_DIRS'] + delete process.env['CODEX_HOME'] +}) + +afterEach(async () => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } + if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true }) +}) + +describe('carried history reaches the user-visible headline', () => { + it('serves cost/calls/models/projects from carried cache days when no session files survive', async () => { + const day = daysAgoStr(10) + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: getDailyCacheConfigHash(), + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [carriedDay(day)], + complete: true, + } + await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8') + + const payload = await buildMenubarPayloadForRange(getDateRange('all'), { provider: 'all', optimize: false, timeline: false }) + + // The history strip always showed the day; the regression was everything below. + expect(payload.history.daily.some(d => d.date === day && d.cost === 100)).toBe(true) + + // Headline totals must reflect the carried day, not the (empty) live parse. + expect(payload.current.cost).toBe(100) + expect(payload.current.calls).toBe(40) + expect(payload.current.sessions).toBe(3) + + // Model and project views must surface it too, with the friendly name + // derived from the stored project path. + expect(payload.current.topModels.some(m => m.name === 'Opus 4.8' && m.cost === 100)).toBe(true) + const projects = payload.current.topProjects + expect(projects.some(p => p.name === 'proj-x' && p.cost === 100 && p.sessions === 3)).toBe(true) + }) + + it('merges live today data on top of carried history without double counting', async () => { + // Same seed, but the range also includes today, for which there is still + // no live data — totals must stay exactly the carried values. + const day = daysAgoStr(10) + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: getDailyCacheConfigHash(), + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [carriedDay(day)], + complete: true, + } + await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8') + + const payload = await buildMenubarPayloadForRange(getDateRange('all'), { provider: 'all', optimize: false, timeline: false }) + expect(payload.current.cost).toBe(100) + expect(payload.current.topProjects).toHaveLength(1) + }) +})