mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-31 11:55:33 +00:00
Claude Code deletes session transcripts after cleanupPeriodDays (default 30) at startup. Daily totals survive in the daily cache, but per-session detail older than the cutoff is unrecoverable, and most users learn this only after losing months of history. Doctor now reports the effective retention and, below one year, warns with the exact settings.json edit that preserves transcripts.
384 lines
16 KiB
TypeScript
384 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||
import { join } from 'path'
|
||
import { tmpdir } from 'os'
|
||
|
||
import { collectDoctorReport, renderDoctorTable, renderDoctorJson } from '../src/doctor.js'
|
||
import { createCodexProvider } from '../src/providers/codex.js'
|
||
import { emptyCache, type SessionCache } from '../src/session-cache.js'
|
||
import type { Provider, ProbeRoot, SessionSource } from '../src/providers/types.js'
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
// A valid single-line Codex rollout so real discovery + probeRoots + parse run
|
||
// end to end against a fixture directory.
|
||
function sessionMeta(): string {
|
||
return JSON.stringify({
|
||
type: 'session_meta',
|
||
timestamp: '2026-04-14T10:00:00Z',
|
||
payload: {
|
||
cwd: '/Users/test/proj',
|
||
originator: 'codex-cli',
|
||
session_id: 'sess-001',
|
||
model: 'gpt-5.3-codex',
|
||
},
|
||
})
|
||
}
|
||
|
||
function tokenCount(): string {
|
||
return JSON.stringify({
|
||
type: 'event_msg',
|
||
timestamp: '2026-04-14T10:01:00Z',
|
||
payload: {
|
||
type: 'token_count',
|
||
info: {
|
||
total_token_usage: {
|
||
input_tokens: 100,
|
||
cached_input_tokens: 0,
|
||
output_tokens: 50,
|
||
reasoning_output_tokens: 0,
|
||
total_tokens: 150,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
async function writeCodexSession(codexDir: string): Promise<void> {
|
||
const dayDir = join(codexDir, 'sessions', '2026', '04', '14')
|
||
await mkdir(dayDir, { recursive: true })
|
||
await writeFile(join(dayDir, 'rollout-sess-001.jsonl'), `${sessionMeta()}\n${tokenCount()}\n`)
|
||
}
|
||
|
||
// Fully controllable synthetic provider for the edge cases (network, throwing,
|
||
// parse failure) that on-disk fixtures cannot force deterministically.
|
||
function fakeProvider(over: Partial<Provider> & { name: string }): Provider {
|
||
return {
|
||
displayName: over.name,
|
||
modelDisplayName: (m: string) => m,
|
||
toolDisplayName: (t: string) => t,
|
||
discoverSessions: async () => [],
|
||
createSessionParser: () => ({ async *parse() {} }),
|
||
...over,
|
||
}
|
||
}
|
||
|
||
function only(report: Awaited<ReturnType<typeof collectDoctorReport>>, name: string) {
|
||
const r = report.providers.find(p => p.provider === name)
|
||
if (!r) throw new Error(`no report row for ${name}`)
|
||
return r
|
||
}
|
||
|
||
let tmpDir: string
|
||
beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'doctor-test-')) })
|
||
afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) })
|
||
|
||
// ── Real-provider fixture-dir cases: found / empty / missing ───────────────
|
||
|
||
describe('collectDoctorReport - codex fixture dirs', () => {
|
||
it('found: a real session file yields an OK verdict and a parsed sample', async () => {
|
||
await writeCodexSession(tmpDir)
|
||
const provider = createCodexProvider(tmpDir)
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
const r = only(report, 'codex')
|
||
|
||
expect(r.status).toBe('ok')
|
||
expect(r.candidatesFound).toBeGreaterThanOrEqual(1)
|
||
expect(r.parsedOk).toBeGreaterThanOrEqual(1)
|
||
expect(r.parseFailed).toBe(0)
|
||
expect(r.verdict).toMatch(/^OK \(/)
|
||
const sessions = r.probePaths.find(p => p.label === 'sessions')
|
||
expect(sessions?.exists).toBe(true)
|
||
})
|
||
|
||
it('empty: sessions dir exists but holds nothing -> NOTHING FOUND (no sessions)', async () => {
|
||
await mkdir(join(tmpDir, 'sessions'), { recursive: true })
|
||
const provider = createCodexProvider(tmpDir)
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
const r = only(report, 'codex')
|
||
|
||
expect(r.status).toBe('empty')
|
||
expect(r.candidatesFound).toBe(0)
|
||
expect(r.verdict).toContain('holds no sessions')
|
||
expect(r.probePaths.find(p => p.label === 'sessions')?.exists).toBe(true)
|
||
})
|
||
|
||
it('missing: no sessions dir -> NOTHING FOUND names the missing path', async () => {
|
||
// tmpDir has no `sessions` subdir at all.
|
||
const provider = createCodexProvider(tmpDir)
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
const r = only(report, 'codex')
|
||
|
||
expect(r.status).toBe('empty')
|
||
expect(r.candidatesFound).toBe(0)
|
||
// Mutation guard: if the exists-check is broken to always return true, this
|
||
// flips to false and the verdict drops "does not exist".
|
||
expect(r.probePaths.find(p => p.label === 'sessions')?.exists).toBe(false)
|
||
expect(r.verdict).toContain('does not exist')
|
||
expect(r.verdict).toContain(join(tmpDir, 'sessions'))
|
||
})
|
||
})
|
||
|
||
// ── Override case ──────────────────────────────────────────────────────────
|
||
|
||
describe('collectDoctorReport - env override', () => {
|
||
it('names a set override pointing at a missing dir', async () => {
|
||
const prev = process.env['CODEX_HOME']
|
||
const bogus = join(tmpDir, 'does-not-exist')
|
||
process.env['CODEX_HOME'] = bogus
|
||
try {
|
||
// Construct after setting env so the provider resolves CODEX_HOME.
|
||
const provider = createCodexProvider()
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
const r = only(report, 'codex')
|
||
|
||
expect(r.envOverrides).toEqual([{ name: 'CODEX_HOME', value: bogus }])
|
||
expect(r.status).toBe('empty')
|
||
expect(r.verdict).toContain('override CODEX_HOME set')
|
||
expect(r.verdict).toContain('does not exist')
|
||
} finally {
|
||
if (prev === undefined) delete process.env['CODEX_HOME']
|
||
else process.env['CODEX_HOME'] = prev
|
||
}
|
||
})
|
||
})
|
||
|
||
// ── Synthetic edge cases ───────────────────────────────────────────────────
|
||
|
||
describe('collectDoctorReport - isolation and edge cases', () => {
|
||
it('a provider that throws in discovery becomes an ERROR row, others survive', async () => {
|
||
const boom = fakeProvider({
|
||
name: 'boom',
|
||
discoverSessions: async () => { throw new Error('disk on fire') },
|
||
})
|
||
const good = fakeProvider({
|
||
name: 'good',
|
||
discoverSessions: async () => [{ path: '/x/a.json', project: 'p', provider: 'good' }],
|
||
})
|
||
const report = await collectDoctorReport('all', { providers: [boom, good], cache: emptyCache() })
|
||
|
||
const b = only(report, 'boom')
|
||
expect(b.status).toBe('error')
|
||
expect(b.error).toContain('disk on fire')
|
||
expect(b.verdict).toMatch(/^ERROR \(/)
|
||
expect(only(report, 'good').status).toBe('ok')
|
||
})
|
||
|
||
it('a parser that throws is counted and downgrades the verdict to ERRORS', async () => {
|
||
const src: SessionSource = { path: '/x/a.json', project: 'p', provider: 'flaky' }
|
||
const flaky = fakeProvider({
|
||
name: 'flaky',
|
||
discoverSessions: async () => [src],
|
||
createSessionParser: () => ({
|
||
// eslint-disable-next-line require-yield
|
||
async *parse() { throw new Error('bad json') },
|
||
}),
|
||
})
|
||
const r = only(await collectDoctorReport('all', { providers: [flaky], cache: emptyCache() }), 'flaky')
|
||
|
||
expect(r.status).toBe('errors')
|
||
expect(r.parseFailed).toBe(1)
|
||
expect(r.parsedOk).toBe(0)
|
||
expect(r.verdict).toMatch(/^ERRORS \(/)
|
||
})
|
||
|
||
it('network providers are never parsed offline', async () => {
|
||
let parserCreated = false
|
||
const net = fakeProvider({
|
||
name: 'net',
|
||
network: true,
|
||
discoverSessions: async () => [{ path: 'net:report', project: 'p', provider: 'net' }],
|
||
createSessionParser: () => { parserCreated = true; return { async *parse() {} } },
|
||
})
|
||
const r = only(await collectDoctorReport('all', { providers: [net], cache: emptyCache() }), 'net')
|
||
|
||
expect(parserCreated).toBe(false)
|
||
expect(r.status).toBe('network')
|
||
expect(r.sampled).toBe(0)
|
||
expect(r.verdict).toContain('NETWORK')
|
||
})
|
||
|
||
it('bounds the parse sample and flags it', async () => {
|
||
const sources: SessionSource[] = Array.from({ length: 5 }, (_, i) => ({ path: `/x/${i}.json`, project: 'p', provider: 'many' }))
|
||
const many = fakeProvider({
|
||
name: 'many',
|
||
discoverSessions: async () => sources,
|
||
createSessionParser: () => ({ async *parse() { yield undefined as never } }),
|
||
})
|
||
const r = only(await collectDoctorReport('all', { providers: [many], cache: emptyCache(), sampleLimit: 2 }), 'many')
|
||
|
||
expect(r.candidatesFound).toBe(5)
|
||
expect(r.sampled).toBe(2)
|
||
expect(r.bounded).toBe(true)
|
||
expect(r.status).toBe('ok')
|
||
})
|
||
|
||
it('reports cache state from the injected snapshot', async () => {
|
||
const cache: SessionCache = emptyCache()
|
||
cache.providers['cached'] = {
|
||
envFingerprint: 'x',
|
||
files: {
|
||
'/a.jsonl': { fingerprint: { dev: 1, ino: 1, mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [] },
|
||
'/b.jsonl': { fingerprint: { dev: 1, ino: 2, mtimeMs: 1, sizeBytes: 1 }, mcpInventory: [], turns: [], failed: true },
|
||
},
|
||
}
|
||
const provider = fakeProvider({ name: 'cached', discoverSessions: async () => [] })
|
||
const r = only(await collectDoctorReport('all', { providers: [provider], cache }), 'cached')
|
||
|
||
expect(r.cachedFiles).toBe(2)
|
||
expect(r.cachedFailed).toBe(1)
|
||
})
|
||
|
||
it('filters to a single provider by name', async () => {
|
||
const a = fakeProvider({ name: 'a' })
|
||
const b = fakeProvider({ name: 'b' })
|
||
const report = await collectDoctorReport('b', { providers: [a, b], cache: emptyCache() })
|
||
expect(report.providers.map(p => p.provider)).toEqual(['b'])
|
||
})
|
||
})
|
||
|
||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||
|
||
describe('doctor rendering', () => {
|
||
it('renders a plain-text table naming the override and missing path', async () => {
|
||
const provider = fakeProvider({
|
||
name: 'claude',
|
||
displayName: 'Claude',
|
||
probeRoots: async (): Promise<ProbeRoot[]> => [{ path: '/nonexistent/projects', label: 'projects' }],
|
||
discoverSessions: async () => [],
|
||
})
|
||
const prev = process.env['CLAUDE_CONFIG_DIR']
|
||
process.env['CLAUDE_CONFIG_DIR'] = '/nonexistent'
|
||
try {
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
const out = renderDoctorTable(report, { color: false })
|
||
expect(out).toContain('CodeBurn doctor')
|
||
expect(out).toContain('NOTHING FOUND')
|
||
expect(out).toContain('CLAUDE_CONFIG_DIR=/nonexistent')
|
||
expect(out).toContain('/nonexistent/projects')
|
||
expect(out).toContain('missing')
|
||
// no-color mode emits no ANSI escapes
|
||
// eslint-disable-next-line no-control-regex
|
||
expect(out).not.toMatch(/\[/)
|
||
} finally {
|
||
if (prev === undefined) delete process.env['CLAUDE_CONFIG_DIR']
|
||
else process.env['CLAUDE_CONFIG_DIR'] = prev
|
||
}
|
||
})
|
||
|
||
it('emits valid, round-trippable JSON', async () => {
|
||
const provider = fakeProvider({ name: 'a', discoverSessions: async () => [] })
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
const parsed = JSON.parse(renderDoctorJson(report))
|
||
expect(parsed.providers[0].provider).toBe('a')
|
||
expect(typeof parsed.generatedAt).toBe('string')
|
||
})
|
||
})
|
||
|
||
// ── Re-review hardening (#685): inert-diagnostic guarantees ────────────────
|
||
|
||
describe('doctor is inert', () => {
|
||
it('sets the cache-write suppression flag while collecting and restores it after', async () => {
|
||
let flagDuringParse: string | undefined
|
||
const spy = fakeProvider({
|
||
name: 'spy',
|
||
discoverSessions: async () => [{ path: '/tmp/x', project: 'p', provider: 'spy' }],
|
||
createSessionParser: () => ({
|
||
async *parse() {
|
||
flagDuringParse = process.env['CODEBURN_SUPPRESS_CACHE_WRITES']
|
||
},
|
||
}),
|
||
})
|
||
delete process.env['CODEBURN_SUPPRESS_CACHE_WRITES']
|
||
await collectDoctorReport('spy', { providers: [spy], cache: emptyCache() })
|
||
expect(flagDuringParse).toBe('1')
|
||
expect(process.env['CODEBURN_SUPPRESS_CACHE_WRITES']).toBeUndefined()
|
||
})
|
||
|
||
it('never sample-parses a provider whose parse spawns processes (antigravity)', async () => {
|
||
let parsed = false
|
||
const ag = fakeProvider({
|
||
name: 'antigravity',
|
||
discoverSessions: async () => [{ path: '/tmp/cascade-x.pb', project: 'p', provider: 'antigravity' }],
|
||
createSessionParser: () => ({
|
||
async *parse() {
|
||
parsed = true
|
||
},
|
||
}),
|
||
})
|
||
const report = await collectDoctorReport('antigravity', { providers: [ag], cache: emptyCache() })
|
||
expect(parsed).toBe(false)
|
||
const row = only(report, 'antigravity')
|
||
expect(row.status).toBe('ok')
|
||
expect(row.verdict).toContain('parse sample skipped')
|
||
expect(row.candidatesFound).toBe(1)
|
||
})
|
||
|
||
it('does not blame CODEBURN_CACHE_DIR for an empty provider', async () => {
|
||
const prev = process.env['CODEBURN_CACHE_DIR']
|
||
process.env['CODEBURN_CACHE_DIR'] = '/tmp/some-cache'
|
||
try {
|
||
const empty = fakeProvider({ name: 'antigravity' })
|
||
const report = await collectDoctorReport('antigravity', { providers: [empty], cache: emptyCache() })
|
||
const row = only(report, 'antigravity')
|
||
expect(row.verdict).not.toContain('CODEBURN_CACHE_DIR')
|
||
} finally {
|
||
if (prev === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
||
else process.env['CODEBURN_CACHE_DIR'] = prev
|
||
}
|
||
})
|
||
})
|
||
|
||
// ── Claude transcript retention note ───────────────────────────────────────
|
||
describe('collectDoctorReport - claude retention note', () => {
|
||
async function withClaudeConfig(settings: string | null, fn: () => Promise<void>): Promise<void> {
|
||
const prev = process.env['CLAUDE_CONFIG_DIR']
|
||
const prevMulti = process.env['CLAUDE_CONFIG_DIRS']
|
||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||
const dir = join(tmpDir, 'claude-config')
|
||
await mkdir(dir, { recursive: true })
|
||
if (settings !== null) await writeFile(join(dir, 'settings.json'), settings)
|
||
process.env['CLAUDE_CONFIG_DIR'] = dir
|
||
try {
|
||
await fn()
|
||
} finally {
|
||
if (prev === undefined) delete process.env['CLAUDE_CONFIG_DIR']
|
||
else process.env['CLAUDE_CONFIG_DIR'] = prev
|
||
if (prevMulti !== undefined) process.env['CLAUDE_CONFIG_DIRS'] = prevMulti
|
||
}
|
||
}
|
||
|
||
it('reports an explicit cleanupPeriodDays and renders it as preserved when long', async () => {
|
||
await withClaudeConfig(JSON.stringify({ cleanupPeriodDays: 3650 }), async () => {
|
||
const provider = fakeProvider({ name: 'claude' })
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
expect(report.claudeRetention).toMatchObject({ effectiveDays: 3650, configured: true })
|
||
const table = renderDoctorTable(report, { color: false })
|
||
expect(table).toContain('deletes transcripts after 3650 days')
|
||
expect(table).toContain('per-session detail is preserved')
|
||
})
|
||
})
|
||
|
||
it('reports the 30-day default and renders a warning with the settings path', async () => {
|
||
await withClaudeConfig(JSON.stringify({ theme: 'dark' }), async () => {
|
||
const provider = fakeProvider({ name: 'claude' })
|
||
const report = await collectDoctorReport('all', { providers: [provider], cache: emptyCache() })
|
||
expect(report.claudeRetention).toMatchObject({ effectiveDays: 30, configured: false })
|
||
const table = renderDoctorTable(report, { color: false })
|
||
expect(table).toContain('deletes transcripts after 30 days')
|
||
expect(table).toContain('cleanupPeriodDays not set')
|
||
expect(table).toContain('"cleanupPeriodDays": 3650')
|
||
expect(table).toContain(report.claudeRetention!.settingsPath)
|
||
})
|
||
})
|
||
|
||
it('emits no note when claude is not in the report or has no settings file', async () => {
|
||
await withClaudeConfig(null, async () => {
|
||
const claudeless = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'codex' })], cache: emptyCache() })
|
||
expect(claudeless.claudeRetention).toBeUndefined()
|
||
const noSettings = await collectDoctorReport('all', { providers: [fakeProvider({ name: 'claude' })], cache: emptyCache() })
|
||
expect(noSettings.claudeRetention).toBeUndefined()
|
||
expect(renderDoctorTable(noSettings, { color: false })).not.toContain('deletes transcripts')
|
||
})
|
||
})
|
||
})
|