mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-10 17:14:48 +00:00
444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
import { existsSync } from 'fs'
|
|
import { readFile } from 'fs/promises'
|
|
import { dirname, join } from 'path'
|
|
|
|
import { Chalk } from 'chalk'
|
|
|
|
import { getClaudeConfigDirs } from './providers/claude.js'
|
|
import { getAllProviders } from './providers/index.js'
|
|
import type { Provider } from './providers/types.js'
|
|
import {
|
|
PROVIDER_ENV_VARS,
|
|
PROVIDER_PARSE_VERSIONS,
|
|
loadCache,
|
|
type SessionCache,
|
|
} from './session-cache.js'
|
|
import { renderTable } from './text-table.js'
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
|
|
export type DoctorProbePath = {
|
|
path: string
|
|
label: string
|
|
exists: boolean
|
|
}
|
|
|
|
export type DoctorEnvOverride = {
|
|
name: string
|
|
value: string
|
|
}
|
|
|
|
export type DoctorStatus = 'ok' | 'empty' | 'errors' | 'error' | 'network'
|
|
|
|
export type DoctorProviderReport = {
|
|
provider: string
|
|
displayName: string
|
|
status: DoctorStatus
|
|
/** Directories/dbs the provider scans, with existence checked (may be empty
|
|
* for providers that do not expose probeRoots). */
|
|
probePaths: DoctorProbePath[]
|
|
/** Env overrides that are actually set for this provider. */
|
|
envOverrides: DoctorEnvOverride[]
|
|
parseVersion?: string
|
|
/** Session sources discovered (candidate files/dbs). */
|
|
candidatesFound: number
|
|
/** How many discovered sources we attempted to parse (bounded sample). */
|
|
sampled: number
|
|
parsedOk: number
|
|
parseFailed: number
|
|
/** True when we sampled fewer sources than were discovered. */
|
|
bounded: boolean
|
|
/** Files cached for this provider in session-cache.json. */
|
|
cachedFiles: number
|
|
/** Cached entries flagged as parse failures. */
|
|
cachedFailed: number
|
|
/** One-line human verdict. */
|
|
verdict: string
|
|
/** Message when the provider itself threw (status 'error'). */
|
|
error?: string
|
|
}
|
|
|
|
export type ClaudeRetentionNote = {
|
|
/// Effective transcript retention in days. Claude Code deletes session
|
|
/// files older than cleanupPeriodDays at startup; 30 is its default when
|
|
/// the setting is absent.
|
|
effectiveDays: number
|
|
/// True when cleanupPeriodDays is explicitly set in settings.json.
|
|
configured: boolean
|
|
settingsPath: string
|
|
}
|
|
|
|
export type DoctorReport = {
|
|
generatedAt: string
|
|
providers: DoctorProviderReport[]
|
|
/// Present when the Claude provider is in the report and a config dir was
|
|
/// found. Surfaced because deleted transcripts are unrecoverable: daily
|
|
/// totals survive in CodeBurn's cache, but per-session detail does not.
|
|
claudeRetention?: ClaudeRetentionNote
|
|
}
|
|
|
|
export type CollectDoctorOptions = {
|
|
/** Injectable provider list (defaults to the real registry). */
|
|
providers?: Provider[]
|
|
/** Injectable cache snapshot (defaults to reading session-cache.json). */
|
|
cache?: SessionCache
|
|
/** Max discovered sources to parse-sample per provider. */
|
|
sampleLimit?: number
|
|
}
|
|
|
|
// Bound the parse sample: at most this many discovered sources per provider,
|
|
// truncating each source's yields at PARSE_CALL_CAP. Note the cap bounds the
|
|
// yield loop only; eager parsers (codex, cursor) do their full per-file work
|
|
// before the first yield, so a very large single source is still parsed whole.
|
|
const DEFAULT_SAMPLE_LIMIT = 8
|
|
const PARSE_CALL_CAP = 500
|
|
|
|
// Providers whose parse() has side effects beyond reading: antigravity probes
|
|
// for a live language server (spawns ps/lsof and RPCs it when found). A
|
|
// diagnostic that promises to be inert must not sample-parse those; discovery
|
|
// (readdir/stat only) still runs, so session counts stay meaningful.
|
|
const PARSE_SPAWNS = new Set(['antigravity'])
|
|
|
|
// CodeBurn's own cache location: listed in PROVIDER_ENV_VARS for cache
|
|
// fingerprinting, but it is not a discovery path, so it must never be blamed
|
|
// in a NOTHING FOUND hint.
|
|
const NON_DISCOVERY_ENV_VARS = new Set(['CODEBURN_CACHE_DIR'])
|
|
|
|
// ── Collect (pure, testable) ─────────────────────────────────────────────
|
|
|
|
function collectEnvOverrides(providerName: string): DoctorEnvOverride[] {
|
|
const vars = PROVIDER_ENV_VARS[providerName] ?? []
|
|
const out: DoctorEnvOverride[] = []
|
|
for (const name of vars) {
|
|
const value = process.env[name]
|
|
if (value !== undefined && value !== '') out.push({ name, value })
|
|
}
|
|
return out
|
|
}
|
|
|
|
async function collectProbePaths(provider: Provider): Promise<DoctorProbePath[]> {
|
|
if (!provider.probeRoots) return []
|
|
const roots = await provider.probeRoots()
|
|
return roots.map(r => ({ path: r.path, label: r.label, exists: existsSync(r.path) }))
|
|
}
|
|
|
|
// A discovered source path can carry a virtual suffix (`<db>#cursor-ws=...`,
|
|
// `<db>:<sessionId>`); strip it to the real on-disk path, then to its parent
|
|
// dir so many per-session sources collapse to a handful of probed directories.
|
|
function realPathOf(sourcePath: string): string {
|
|
const hashIdx = sourcePath.indexOf('#')
|
|
let p = hashIdx > 0 ? sourcePath.slice(0, hashIdx) : sourcePath
|
|
const colonIdx = p.lastIndexOf(':')
|
|
// Keep Windows drive letters (`C:\...`): only strip a colon that is not the
|
|
// drive separator (index > 1).
|
|
if (colonIdx > 1) p = p.slice(0, colonIdx)
|
|
return p
|
|
}
|
|
|
|
function derivePathsFromSources(sourcePaths: string[]): DoctorProbePath[] {
|
|
const dirs = new Set<string>()
|
|
for (const sp of sourcePaths) {
|
|
const real = realPathOf(sp)
|
|
dirs.add(existsSync(real) ? dirname(real) : real)
|
|
}
|
|
return [...dirs].sort().map(path => ({ path, label: 'discovered', exists: existsSync(path) }))
|
|
}
|
|
|
|
function pluralSessions(n: number): string {
|
|
return `${n} session${n === 1 ? '' : 's'}`
|
|
}
|
|
|
|
function emptyVerdict(
|
|
probePaths: DoctorProbePath[],
|
|
envOverrides: DoctorEnvOverride[],
|
|
): string {
|
|
const discoveryOverrides = envOverrides.filter(o => !NON_DISCOVERY_ENV_VARS.has(o.name))
|
|
const overrideNames = discoveryOverrides.map(o => o.name).join(', ')
|
|
const hasOverride = discoveryOverrides.length > 0
|
|
const known = probePaths.filter(p => p.label !== 'discovered')
|
|
const missing = known.filter(p => !p.exists)
|
|
const present = known.filter(p => p.exists)
|
|
|
|
// No known probe roots to check: honest, override-aware fallback.
|
|
if (known.length === 0) {
|
|
return hasOverride
|
|
? `NOTHING FOUND (override ${overrideNames} set, but nothing was discovered)`
|
|
: 'NOTHING FOUND (tool likely not installed or no history yet)'
|
|
}
|
|
// With an override set, a missing probed path is the likely culprit; name it
|
|
// so the row itself points at the misconfiguration (Details lists them all).
|
|
if (hasOverride) {
|
|
return missing.length > 0
|
|
? `NOTHING FOUND (override ${overrideNames} set; ${missing[0]!.path} does not exist)`
|
|
: `NOTHING FOUND (override ${overrideNames} set; ${present[0]!.path} holds no sessions)`
|
|
}
|
|
// No override. If every probed path is missing, the tool is likely not
|
|
// installed; if some exist, the data dir is there but empty (no history).
|
|
return present.length === 0
|
|
? `NOTHING FOUND (${missing[0]!.path} does not exist; tool likely not installed)`
|
|
: `NOTHING FOUND (${present[0]!.path} exists but holds no sessions; no history yet)`
|
|
}
|
|
|
|
async function collectOneProvider(
|
|
provider: Provider,
|
|
cache: SessionCache,
|
|
sampleLimit: number,
|
|
): Promise<DoctorProviderReport> {
|
|
const base: DoctorProviderReport = {
|
|
provider: provider.name,
|
|
displayName: provider.displayName,
|
|
status: 'ok',
|
|
probePaths: [],
|
|
envOverrides: collectEnvOverrides(provider.name),
|
|
parseVersion: PROVIDER_PARSE_VERSIONS[provider.name],
|
|
candidatesFound: 0,
|
|
sampled: 0,
|
|
parsedOk: 0,
|
|
parseFailed: 0,
|
|
bounded: false,
|
|
cachedFiles: 0,
|
|
cachedFailed: 0,
|
|
verdict: '',
|
|
}
|
|
|
|
const section = cache.providers[provider.name]
|
|
if (section) {
|
|
const files = Object.values(section.files)
|
|
base.cachedFiles = files.length
|
|
base.cachedFailed = files.filter(f => f.failed).length
|
|
}
|
|
|
|
// Any single provider throwing (probe, discovery, or a parser) must never
|
|
// crash doctor or blank the other rows: catch and report it as an ERROR row.
|
|
try {
|
|
base.probePaths = await collectProbePaths(provider)
|
|
|
|
const sources = await provider.discoverSessions()
|
|
base.candidatesFound = sources.length
|
|
if (base.probePaths.length === 0) {
|
|
base.probePaths = derivePathsFromSources(sources.map(s => s.path))
|
|
}
|
|
|
|
// Network providers fetch on parse; doctor runs offline, so we never parse
|
|
// them. Discovery for the one network provider is offline (it only checks
|
|
// for a configured API key), so the count above still means something.
|
|
if (provider.network) {
|
|
base.status = 'network'
|
|
base.verdict = base.candidatesFound > 0
|
|
? `NETWORK (${base.candidatesFound} source configured; parse skipped offline)`
|
|
: 'NETWORK (not configured; no API key)'
|
|
return base
|
|
}
|
|
|
|
if (sources.length > 0 && PARSE_SPAWNS.has(provider.name)) {
|
|
base.status = 'ok'
|
|
base.verdict = `OK (${pluralSessions(sources.length)}; parse sample skipped, provider probes live processes)`
|
|
return base
|
|
}
|
|
|
|
if (sources.length > 0) {
|
|
const sample = sources.slice(0, sampleLimit)
|
|
base.bounded = sample.length < sources.length
|
|
const seenKeys = new Set<string>()
|
|
for (const source of sample) {
|
|
base.sampled++
|
|
try {
|
|
const parser = provider.createSessionParser(source, seenKeys)
|
|
let n = 0
|
|
for await (const _call of parser.parse()) {
|
|
if (++n >= PARSE_CALL_CAP) break
|
|
}
|
|
base.parsedOk++
|
|
} catch {
|
|
base.parseFailed++
|
|
}
|
|
}
|
|
}
|
|
|
|
if (base.parseFailed > 0) {
|
|
base.status = 'errors'
|
|
base.verdict = `ERRORS (${base.parseFailed}/${base.sampled} sampled file${base.sampled === 1 ? '' : 's'} failed to parse)`
|
|
} else if (base.candidatesFound === 0) {
|
|
base.status = 'empty'
|
|
base.verdict = emptyVerdict(base.probePaths, base.envOverrides)
|
|
} else {
|
|
base.status = 'ok'
|
|
base.verdict = `OK (${pluralSessions(base.candidatesFound)})`
|
|
}
|
|
} catch (err) {
|
|
base.status = 'error'
|
|
base.error = err instanceof Error ? err.message : String(err)
|
|
base.verdict = `ERROR (${base.error})`
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
export async function collectDoctorReport(
|
|
providerFilter?: string,
|
|
opts: CollectDoctorOptions = {},
|
|
): Promise<DoctorReport> {
|
|
const all = opts.providers ?? await getAllProviders()
|
|
const filtered = providerFilter && providerFilter !== 'all'
|
|
? all.filter(p => p.name === providerFilter)
|
|
: all
|
|
const cache = opts.cache ?? await loadCache()
|
|
const sampleLimit = opts.sampleLimit ?? DEFAULT_SAMPLE_LIMIT
|
|
|
|
// Doctor promises to be strictly read-only, but sample-parsing drives real
|
|
// provider parsers, and cursor's writes its results cache to disk before its
|
|
// first yield. The flag tells cache writers to stand down for this process
|
|
// while doctor collects; restored afterwards so long-lived embedders (tests,
|
|
// MCP) keep normal behavior.
|
|
const prevSuppress = process.env['CODEBURN_SUPPRESS_CACHE_WRITES']
|
|
process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] = '1'
|
|
try {
|
|
const providers: DoctorProviderReport[] = []
|
|
for (const provider of filtered) {
|
|
providers.push(await collectOneProvider(provider, cache, sampleLimit))
|
|
}
|
|
providers.sort((a, b) => (a.displayName < b.displayName ? -1 : a.displayName > b.displayName ? 1 : 0))
|
|
|
|
const report: DoctorReport = { generatedAt: new Date().toISOString(), providers }
|
|
if (providers.some(p => p.provider === 'claude')) {
|
|
const retention = await collectClaudeRetention()
|
|
if (retention) report.claudeRetention = retention
|
|
}
|
|
return report
|
|
} finally {
|
|
if (prevSuppress === undefined) delete process.env['CODEBURN_SUPPRESS_CACHE_WRITES']
|
|
else process.env['CODEBURN_SUPPRESS_CACHE_WRITES'] = prevSuppress
|
|
}
|
|
}
|
|
|
|
// Claude Code's documented default when cleanupPeriodDays is absent.
|
|
const CLAUDE_DEFAULT_CLEANUP_DAYS = 30
|
|
// Below this, long-horizon views depend entirely on CodeBurn's daily cache;
|
|
// the doctor line turns into a warning.
|
|
const CLAUDE_RETENTION_WARN_DAYS = 365
|
|
|
|
async function collectClaudeRetention(): Promise<ClaudeRetentionNote | undefined> {
|
|
for (const dir of await getClaudeConfigDirs()) {
|
|
const settingsPath = join(dir, 'settings.json')
|
|
let raw: string
|
|
try {
|
|
raw = await readFile(settingsPath, 'utf-8')
|
|
} catch {
|
|
continue
|
|
}
|
|
try {
|
|
const parsed: unknown = JSON.parse(raw)
|
|
const days = (parsed as Record<string, unknown> | null)?.['cleanupPeriodDays']
|
|
if (typeof days === 'number' && Number.isFinite(days)) {
|
|
return { effectiveDays: days, configured: true, settingsPath }
|
|
}
|
|
return { effectiveDays: CLAUDE_DEFAULT_CLEANUP_DAYS, configured: false, settingsPath }
|
|
} catch {
|
|
// Unparseable settings: report the default; Claude Code would apply it too.
|
|
return { effectiveDays: CLAUDE_DEFAULT_CLEANUP_DAYS, configured: false, settingsPath }
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
export function renderDoctorJson(report: DoctorReport): string {
|
|
return JSON.stringify(report, null, 2)
|
|
}
|
|
|
|
export function renderDoctorTable(
|
|
report: DoctorReport,
|
|
opts: { color?: boolean } = {},
|
|
): string {
|
|
const c = new Chalk(opts.color === false ? { level: 0 } : {})
|
|
const out: string[] = []
|
|
|
|
const n = report.providers.length
|
|
out.push(c.bold('CodeBurn doctor') + c.dim(` ${n} provider${n === 1 ? '' : 's'} ${report.generatedAt.slice(0, 19).replace('T', ' ')} UTC`))
|
|
out.push('')
|
|
|
|
const colorVerdict = (r: DoctorProviderReport): string => {
|
|
if (r.status === 'ok') return c.green(r.verdict)
|
|
if (r.status === 'network') return c.cyan(r.verdict)
|
|
if (r.status === 'empty') return c.yellow(r.verdict)
|
|
return c.red(r.verdict)
|
|
}
|
|
|
|
const rows = report.providers.map(r => [
|
|
r.displayName,
|
|
r.status === 'network' ? '-' : String(r.candidatesFound),
|
|
r.status === 'network' || r.sampled === 0 ? '-' : `${r.parsedOk}/${r.sampled}${r.bounded ? '+' : ''}`,
|
|
String(r.cachedFiles),
|
|
colorVerdict(r),
|
|
])
|
|
|
|
out.push(renderTable(
|
|
[
|
|
{ header: 'Provider' },
|
|
{ header: 'Sessions', right: true },
|
|
{ header: 'Parsed', right: true },
|
|
{ header: 'Cached', right: true },
|
|
{ header: 'Verdict' },
|
|
],
|
|
rows,
|
|
{ color: opts.color },
|
|
))
|
|
|
|
// Detail: show the exact probed paths + overrides only where there is
|
|
// something diagnostic to show (known probe roots, an override, a hard
|
|
// error, or cached parse failures), so a wrong path is spotted at a glance
|
|
// without a wall of empty blocks for tools that are simply not installed.
|
|
const detail = report.providers.filter(
|
|
r =>
|
|
r.status === 'error' ||
|
|
r.status === 'errors' ||
|
|
r.envOverrides.length > 0 ||
|
|
r.cachedFailed > 0 ||
|
|
r.probePaths.some(p => p.label !== 'discovered'),
|
|
)
|
|
if (detail.length > 0) {
|
|
out.push('')
|
|
out.push(c.bold('Details'))
|
|
for (const r of detail) {
|
|
out.push(' ' + c.bold(r.displayName))
|
|
for (const o of r.envOverrides) {
|
|
out.push(' ' + c.dim('override ') + `${o.name}=${o.value}`)
|
|
}
|
|
for (const p of r.probePaths) {
|
|
const mark = p.exists ? c.green('exists') : c.red('missing')
|
|
out.push(' ' + c.dim(`${p.label}: `) + p.path + ' ' + c.dim('(') + mark + c.dim(')'))
|
|
}
|
|
if (r.parseVersion) out.push(' ' + c.dim('parser: ') + r.parseVersion)
|
|
if (r.cachedFailed > 0) out.push(' ' + c.dim('cached parse failures: ') + String(r.cachedFailed))
|
|
if (r.error) out.push(' ' + c.red('error: ') + r.error)
|
|
}
|
|
}
|
|
|
|
if (report.claudeRetention) {
|
|
const r = report.claudeRetention
|
|
const source = r.configured ? 'cleanupPeriodDays' : 'cleanupPeriodDays not set; Claude Code default'
|
|
const line = `Claude Code deletes transcripts after ${r.effectiveDays} day${r.effectiveDays === 1 ? '' : 's'} (${source}).`
|
|
out.push('')
|
|
if (r.effectiveDays < CLAUDE_RETENTION_WARN_DAYS) {
|
|
out.push(
|
|
c.yellow(line) + ' ' +
|
|
`Daily totals survive in CodeBurn's cache, but per-session detail older than that is gone for good. ` +
|
|
`To keep it, set "cleanupPeriodDays": 3650 in ${r.settingsPath}.`,
|
|
)
|
|
} else {
|
|
out.push(c.dim(line + ' Long transcript retention: per-session detail is preserved.'))
|
|
}
|
|
}
|
|
|
|
out.push('')
|
|
const broken = report.providers.filter(r => r.status === 'error' || r.status === 'errors')
|
|
const empty = report.providers.filter(r => r.status === 'empty')
|
|
const ok = report.providers.filter(r => r.status === 'ok')
|
|
out.push(
|
|
c.dim('Bottom line: ') +
|
|
`${ok.length} OK, ${empty.length} with nothing found, ${broken.length} with errors.`,
|
|
)
|
|
|
|
return out.join('\n') + '\n'
|
|
}
|