Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/cli/src/codex-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import type { ParsedProviderCall } from './providers/types.js'
// decodes only the new bytes instead of re-streaming the whole file.
// This is lossless: Codex rollout files are durable (never auto-deleted), so the
// one-time re-derive on first run under v8 rebuilds byte-identical data.
//
// #926's structural-validation guards change decode behavior only for record
// shapes measured at 0 occurrences across 136k real events, so forcing a full
// re-parse of multi-GB rollout corpora over that is a bad trade — deliberately
// NOT bumped. The daily-cache bump alone propagates the discovery widening:
// newly-eligible files aren't in this cache yet and parse fresh regardless.
const CODEX_CACHE_VERSION = 8
const CACHE_FILE = 'codex-results.json'

Expand Down
23 changes: 20 additions & 3 deletions packages/cli/src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,24 @@ 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 23: 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 older 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.
// This branch was authored against v15/16, but main shipped 17 in v0.9.20 and
// has since moved to 20, with 21 (#946) and 22 (#1056) claimed on the
// main-side pipeline; this bump takes 23 so no real user's cache file — built
// by any binary on either line of history — can be adopted as current without
// the widened-discovery re-derivation firing. A lower number would let a
// main-built cache pass isMigratableCache() unchanged and the fix would never
// take effect for that user.
//
// 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 +74,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 = 23
const MIN_SUPPORTED_VERSION = 23
// 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 v23 (#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