mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
* Store the session cache in day pieces with a per-provider index Layout v10: a provider's files live in pieces by the UTC day of their newest call, each with a key file, and an index row per file (piece, fingerprint, call span, v9 month) read on every load. A ranged load reads the index, the key files and only the members it reports on; a save rewrites only the pieces it touched. Rows keep v9's full-load order and month scope, so orphans, the first-paint snapshot and dedup see what they saw on v9. The v9 month shards re-lay out once, streaming, and are removed after v10 publishes. * Skip cached transcripts outside a narrow query's months when unchanged v9 could not see them, so every narrow query re-parsed each older Claude transcript it discovered and republished the cache. The index knows their fingerprint; an unchanged one needs nothing, a changed one still re-parses as if uncached. * Read piece members in batches through one buffer, and full loads concurrently * Write piece key files without a sync; rebuild lost keys from the piece * Changelog: day pieces * Seed dedup with the keys of unchanged transcripts outside the query's months before a whole-file parse * Upgrade-path check: verify the v10 envelope, indexes, pieces and key files
141 lines
7.5 KiB
TypeScript
141 lines
7.5 KiB
TypeScript
// End to end through parseProviderSources: a ranged parse holds out-of-range
|
|
// rollouts as stubs, and a stub must still do everything its entry would
|
|
// outside the summaries (claim its dedup keys in serve order, be evicted from
|
|
// disk when its source is deleted). Codex captures CODEX_HOME when its module
|
|
// is first imported, hence the env before the dynamic imports.
|
|
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
import { readFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
import type { DateRange } from '../src/types.js'
|
|
|
|
const tmpDir = mkdtempSync(join(tmpdir(), 'bounded-load-'))
|
|
const savedCodexHome = process.env['CODEX_HOME']
|
|
process.env['CODEX_HOME'] = join(tmpDir, 'codex')
|
|
|
|
const { cacheStubs, clearLoadCacheMemo, loadCache, markCacheDirty, monthScopeForRange, saveCache, sessionCacheDir } = await import('../src/session-cache.js')
|
|
const { clearSessionCache, parseAllSessions, withColdFirstPaintFloor } = await import('../src/parser.js')
|
|
const { clearCodexMemCaches } = await import('../src/codex-cache.js')
|
|
|
|
const ENV_KEYS = ['CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'CODEBURN_CACHE_SCOPE'] as const
|
|
const saved = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
|
|
let run = 0
|
|
|
|
function rollout(day: string, sid: string, startIso: string, n: number): string {
|
|
const dir = join(tmpDir, 'codex', 'sessions', '2099', day.slice(0, 2), day.slice(3))
|
|
mkdirSync(dir, { recursive: true })
|
|
const path = join(dir, `rollout-2099-${day}T10-00-00-${sid}.jsonl`)
|
|
const lines = [
|
|
{ type: 'session_meta', timestamp: startIso, payload: { id: sid, session_id: sid, cwd: `/work/${sid}`, originator: 'codex_cli_rs', model: 'gpt-5.3-codex', cli_version: '0.50.0' } },
|
|
{ type: 'turn_context', timestamp: startIso, payload: { cwd: `/work/${sid}`, model: 'gpt-5.3-codex' } },
|
|
]
|
|
const total = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: 0 }
|
|
for (let i = 0; i < n; i++) {
|
|
const ts = new Date(Date.parse(startIso) + i * 60_000).toISOString()
|
|
const last = { input_tokens: 1000 + i, cached_input_tokens: 100, output_tokens: 50 + i, reasoning_output_tokens: 0, total_tokens: 1050 + 2 * i }
|
|
for (const k of Object.keys(total) as Array<keyof typeof total>) total[k] += last[k]
|
|
lines.push({ type: 'response_item', timestamp: ts, payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `step ${i}` }] } } as never)
|
|
lines.push({ type: 'event_msg', timestamp: ts, payload: { type: 'token_count', info: { last_token_usage: last, total_token_usage: { ...total } } } } as never)
|
|
}
|
|
writeFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n')
|
|
return path
|
|
}
|
|
|
|
const may1: DateRange = { start: new Date('2099-05-01T00:00:00.000Z'), end: new Date('2099-05-01T23:59:59.999Z') }
|
|
const calls = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number => projects.reduce((n, p) => n + p.totalApiCalls, 0)
|
|
const cost = (projects: Awaited<ReturnType<typeof parseAllSessions>>): number => projects.reduce((n, p) => n + p.totalCostUSD, 0)
|
|
|
|
beforeEach(() => {
|
|
rmSync(join(tmpDir, 'codex'), { recursive: true, force: true })
|
|
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, `cache-${run++}`)
|
|
process.env['CLAUDE_CONFIG_DIR'] = join(tmpDir, 'no-claude')
|
|
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'no-desktop')
|
|
delete process.env['CLAUDE_CONFIG_DIRS']
|
|
delete process.env['CODEBURN_CACHE_SCOPE']
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
clearCodexMemCaches()
|
|
})
|
|
|
|
afterAll(() => {
|
|
for (const k of ENV_KEYS) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k] }
|
|
if (savedCodexHome === undefined) delete process.env['CODEX_HOME']
|
|
else process.env['CODEX_HOME'] = savedCodexHome
|
|
rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
async function reparse(range: DateRange, scope?: 'all'): Promise<Awaited<ReturnType<typeof parseAllSessions>>> {
|
|
clearSessionCache()
|
|
clearLoadCacheMemo()
|
|
clearCodexMemCaches()
|
|
if (scope) process.env['CODEBURN_CACHE_SCOPE'] = scope
|
|
else delete process.env['CODEBURN_CACHE_SCOPE']
|
|
return parseAllSessions(range, 'codex')
|
|
}
|
|
|
|
describe('bounded load through the provider parse', () => {
|
|
it('replays a stub\'s dedup keys in serve order, exactly as its full entry would', async () => {
|
|
const old = rollout('04-10', 'aaold', '2099-04-10T10:00:00.000Z', 3)
|
|
const today = rollout('05-01', 'bbtoday', '2099-05-01T10:00:00.000Z', 2)
|
|
await parseAllSessions(undefined, 'codex')
|
|
|
|
// Give the in-range file a turn that carries one of the old file's keys, as
|
|
// a file parsed before the other was cached can.
|
|
clearLoadCacheMemo()
|
|
const full = await loadCache()
|
|
const section = full.providers['codex']!
|
|
const oldKey = section.files[old]!.turns[0]!.calls[0]!.deduplicationKey
|
|
section.files[today]!.turns[1]!.calls[0]!.deduplicationKey = oldKey
|
|
markCacheDirty(full, 'codex', today)
|
|
expect(await saveCache(full)).toBe(true)
|
|
|
|
clearLoadCacheMemo()
|
|
const scoped = await loadCache(monthScopeForRange(may1.start, may1.end))
|
|
expect([...cacheStubs(scoped, 'codex')!.keys()]).toEqual([old])
|
|
|
|
const bounded = await reparse(may1)
|
|
const reference = await reparse(may1, 'all')
|
|
expect(calls(bounded)).toBe(1)
|
|
expect(calls(bounded)).toBe(calls(reference))
|
|
expect(cost(bounded)).toBe(cost(reference))
|
|
|
|
// The first-paint snapshot serves every cached entry as an orphan, in load
|
|
// order: the stub has to take its full entry's place in that order too.
|
|
const snapshot = async (scope?: 'all') => {
|
|
clearSessionCache(); clearLoadCacheMemo(); clearCodexMemCaches()
|
|
if (scope) process.env['CODEBURN_CACHE_SCOPE'] = scope
|
|
else delete process.env['CODEBURN_CACHE_SCOPE']
|
|
return (await withColdFirstPaintFloor(may1.start, () => parseAllSessions(may1, 'codex'), true, true)).result
|
|
}
|
|
const boundedSnapshot = await snapshot()
|
|
expect(calls(boundedSnapshot)).toBe(1)
|
|
expect(JSON.stringify(boundedSnapshot)).toBe(JSON.stringify(await snapshot('all')))
|
|
})
|
|
|
|
it('evicts a deleted transcript that was only a stub from its piece on disk', async () => {
|
|
const doomed = rollout('04-10', 'aadoomed', '2099-04-10T10:00:00.000Z', 2)
|
|
const kept = rollout('04-10', 'cckept', '2099-04-10T12:00:00.000Z', 2)
|
|
rollout('05-01', 'bbtoday', '2099-05-01T10:00:00.000Z', 2)
|
|
await parseAllSessions(undefined, 'codex')
|
|
const aprilPiece = async (): Promise<string> => {
|
|
const envelope = JSON.parse(await readFile(join(sessionCacheDir(), 'envelope.json'), 'utf-8'))
|
|
const index = JSON.parse(await readFile(join(sessionCacheDir(), envelope.providers.codex.index), 'utf-8'))
|
|
return readFile(join(sessionCacheDir(), index.pieces['2099-04-10']), 'utf-8')
|
|
}
|
|
const before = await aprilPiece()
|
|
const line = (text: string, path: string) => text.split('\n').find(l => l.startsWith(JSON.stringify(path) + ':'))?.replace(/,$/, '')
|
|
expect(line(before, doomed)).toBeDefined()
|
|
|
|
clearLoadCacheMemo()
|
|
const scoped = await loadCache(monthScopeForRange(may1.start, may1.end))
|
|
expect([...cacheStubs(scoped, 'codex')!.keys()].sort()).toEqual([doomed, kept].sort())
|
|
|
|
rmSync(doomed)
|
|
await reparse(may1)
|
|
const after = await aprilPiece()
|
|
expect(after).not.toContain(doomed)
|
|
expect(line(after, kept)).toBe(line(before, kept))
|
|
expect(Object.keys(JSON.parse(after))).toEqual([kept])
|
|
})
|
|
})
|