mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-11 09:34:45 +00:00
Three review leftovers plus one inconsistency found while fixing them. computePace returns undefined for a non-finite usedFraction instead of letting NaN sail through the clamp into every pace field. Daily-cache retention goes from 730 days to 3650: the cache is the only durable record of carried days and lifetime now reads from it, so the 2-year prune would have replayed the lost-history bug in slow motion at that horizon. Measured cost stays small (~2.3 MB / ~11 ms parse per 730 dense days). The vercel-gateway dashboard-range test pins a relative day instead of 2026-06-01, which would have started failing once the rolling 6-month window moved past it around December.
713 lines
31 KiB
TypeScript
713 lines
31 KiB
TypeScript
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> = {}): ProviderDaySlice {
|
|
return { cost, calls, savingsUSD: 0, ...extra }
|
|
}
|
|
|
|
function day(date: string, providers: Record<string, ProviderDaySlice>, overrides: Partial<DailyEntry> = {}): 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<ProjectSummary[]> => []
|
|
|
|
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<string, unknown>)['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<string, ProviderDaySlice>)
|
|
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<string, unknown>)['cost']).toBeUndefined()
|
|
expect(({} as Record<string, unknown>)['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<DailyCache> = {}): Promise<DailyEntry> {
|
|
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('2010-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('2010-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<typeof buildPeriodDataFromDays>
|
|
try {
|
|
period = buildPeriodDataFromDays(loaded.days, 'loaded')
|
|
} finally {
|
|
const objectConstructor = Object as typeof Object & Record<string, unknown>
|
|
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)
|
|
})
|
|
})
|