Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions packages/cli/src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,17 @@ import { homedir } from 'os'
import { join } from 'path'
import type { DateRange, ProjectSummary } from './types.js'

// Bumped to 15: per-project daily rollups. Days and provider slices now carry
// Bumped to 16: Codex discovery is structural instead of originator-gated
// (#873/#626), so rollouts written by third-party frontends driving
// `codex app-server` ("t3code_desktop", "JetBrains.IntelliJ IDEA", ...) now
// contribute usage that v15 rollups never contained. Those files were rejected
// before they were ever parsed, so nothing downstream can notice on its own:
// `usage-aggregator` serves every day before today from this cache, and
// retention is ten years, so an upgrading user with a warm cache would keep the
// pre-fix history forever while today's numbers silently disagreed with it.
// Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation.
//
// v15: 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
Expand Down Expand Up @@ -57,8 +67,8 @@ 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 = 15
const MIN_SUPPORTED_VERSION = 15
export const DAILY_CACHE_VERSION = 16
const MIN_SUPPORTED_VERSION = 16
// Version-suffixed so different binaries each own a distinct file and never
// clobber an incompatible schema. Bumping the version mints a fresh filename;
// adoptOlderDailyCaches then unions days out of every previous file (including
Expand Down
41 changes: 37 additions & 4 deletions packages/cli/src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,30 @@ async function readFirstLine(filePath: string): Promise<CodexEntry | null> {
}
}

// Validation is STRUCTURAL, never string-matching on `payload.originator`.
// `originator` is a free-form CLIENT IDENTITY string, not a format marker: any
// tool driving `codex app-server` writes structurally identical rollouts under
// ~/.codex/sessions with its own value ("codex-tui", "Codex Desktop",
// "t3code_desktop", "JetBrains.IntelliJ IDEA", ...). Gating on the spelling
// silently dropped every third-party frontend and needed a new allowlist entry
// per client (issues #626, #873).
//
// A `session_meta` first line with a well-formed payload object is signal
// enough: the walker only visits `rollout-*.jsonl` under the strict
// YYYY/MM/DD path or `archived_sessions/`, and codex.ts is the only provider
// that reads ~/.codex, so directory ownership — not originator content —
// decides the provider. Genuinely foreign files (wrong entry type, missing or
// non-object payload, malformed JSON) are still rejected.
async function isValidCodexSession(filePath: string): Promise<{ valid: boolean; meta?: CodexEntry }> {
const entry = await readFirstLine(filePath)
if (!entry) return { valid: false }
// `entry` comes from an unchecked JSON.parse cast, so re-check the payload
// shape at runtime instead of trusting the declared type.
const payload: unknown = entry.payload
const valid = entry.type === 'session_meta' &&
typeof entry.payload?.originator === 'string' &&
entry.payload.originator.toLowerCase().startsWith('codex')
typeof payload === 'object' &&
payload !== null &&
!Array.isArray(payload)
return { valid, meta: valid ? entry : undefined }
}

Expand All @@ -108,7 +126,13 @@ async function discoverSessionFile(filePath: string): Promise<SessionSource | nu
const { valid, meta } = await isValidCodexSession(filePath)
if (!valid || !meta) return null

const cwd = meta.payload?.cwd ?? 'unknown'
// Same unchecked-cast caveat as the payload check above: `cwd` is declared
// `string` but comes straight off JSON.parse. A rollout carrying a number,
// object or array here would throw out of sanitizeProject, escape
// discoverSessions, and make safeDiscoverSessions return [] for the WHOLE
// codex provider — every Codex report reading zero because of one bad file.
const rawCwd: unknown = meta.payload?.cwd
const cwd = typeof rawCwd === 'string' && rawCwd ? rawCwd : 'unknown'
return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' }
}

Expand Down Expand Up @@ -244,7 +268,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
// pin an empty result set (mirrors the pre-phase-4 sawAnyLine guard).
if (!sawAnyLine && !resume) return

const { calls: richCalls, state: newState } = decodeCodex({
const { calls: richCalls, diagnostics, state: newState } = decodeCodex({
records,
context: { privacyKey: '', providerId: 'codex', sourceRef: source.path },
state: initialState,
Expand All @@ -253,6 +277,15 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
seenKeys,
sessionIdFallback: basename(source.path, '.jsonl'),
})
if (diagnostics.length > 0) {
// The decoder drops token_count events whose timestamp is not a
// parseable string (a number/object/bool from an unchecked cast, or
// garbage text): such a call would make the day aggregator bucket it
// under 'NaN-NaN-NaN', a day the daily cache keeps for ten years.
// Surface the drop on stderr, mirroring the Zed bridge, so the
// skipped usage is visible instead of silent.
process.stderr.write(`codeburn: skipped ${diagnostics.length} codex token_count event(s) with unparseable timestamps\n`)
}

const newPriced = richCalls.map(toPricedProviderCall)
const allCalls = resume ? [...priorCalls, ...newPriced] : newPriced
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/tests/daily-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,79 @@ describe('ensureCacheHydrated: timezone invalidation', () => {
expect(preserved.days[0]!.date).toBe(twoDaysAgoStr)
})
})

// Codex discovery went structural in v16 (#873/#626), admitting rollouts from
// third-party frontends that v15 rollups never counted. Every historical day is
// served from this cache (usage-aggregator only recomputes today) and retention
// is ten years, so without a schema bump an upgrading user keeps the pre-fix
// numbers forever: the session COUNT moves because discovery reruns, while
// cost/calls stay frozen — a self-contradicting report that reads as "fixed".
describe('ensureCacheHydrated: schema version invalidation (#873)', () => {
it('re-derives a warm v15 cache instead of serving its pre-fix rollups', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))

const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
// A cache exactly as a pre-fix release left it: current schema at the time,
// finalized off a complete parse, watermark at yesterday, matching tz.
// Nothing but the version bump can invalidate it.
const v15 = {
version: 15,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-06-11', 4.55, 1)],
complete: true,
watermarkTrusted: true,
}
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), JSON.stringify(v15), 'utf-8')

let parseCalls = 0
const hydrated = await ensureCacheHydrated(
async () => {
parseCalls += 1
return []
},
() => [emptyDay('2026-06-11', 18.2, 2)],
)

// The whole point: the window is re-parsed rather than served frozen.
expect(parseCalls).toBe(1)
// ...and the fresh derivation wins over the stale v15 day.
expect(hydrated.days.find(d => d.date === '2026-06-11')?.cost).toBe(18.2)
expect(hydrated.days.find(d => d.date === '2026-06-11')?.calls).toBe(2)
expect(hydrated.version).toBe(DAILY_CACHE_VERSION)
// The v15 file is never rewritten or deleted — old binaries still own it.
expect(JSON.parse(await readFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), 'utf-8')).version).toBe(15)
})

it('carries a v15 day forward when its sources can no longer re-derive it', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-12T12:00:00.000Z'))

const { writeFile, mkdir } = await import('fs/promises')
await mkdir(TMP_CACHE_ROOT, { recursive: true })
const v15 = {
version: 15,
savingsConfigHash: '',
tzKey: currentTzKey(),
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-04-02', 7, 3), emptyDay('2026-06-11', 4.55, 1)],
complete: true,
watermarkTrusted: true,
}
await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.v15.json'), JSON.stringify(v15), 'utf-8')

// The parse can only still see the recent day; April's sources are gone.
const hydrated = await ensureCacheHydrated(
async () => [],
() => [emptyDay('2026-06-11', 18.2, 2)],
)

// NEVER-LOSE (v14) still holds across this bump: the sourceless day keeps
// its old accounting rather than being dropped or zeroed.
expect(hydrated.days.find(d => d.date === '2026-04-02')?.cost).toBe(7)
expect(hydrated.days.find(d => d.date === '2026-06-11')?.cost).toBe(18.2)
})
})
Loading
Loading