codeburn/src/cursor-cache.ts
Resham Joshi 3dd1d1c793
feat(doctor): per-provider detection diagnostics (#685)
* feat(doctor): add per-provider detection diagnostics command

When a provider reports zero (or a number that looks wrong) there was no way
to see why: whether the tool is not installed, an env override points at the
wrong directory, the data dir is empty, or parsing failed. Users had to file
issues like "OpenCode does not work" with nothing to go on.

`codeburn doctor` closes that gap. For every provider (or one via --provider)
it shows the exact directories/dbs probed with any env override and whether the
path exists, how many session files were discovered, how many of a bounded
sample parsed cleanly, the cached file count and parser version, and a one-line
verdict: OK (n sessions), NOTHING FOUND with a concrete likely cause, or
ERRORS (n parse failures). Output is a human table by default or --json.

The collect logic is a pure function separated from rendering so tests exercise
it without a TTY. It runs fully offline and read-only (never writes caches or
config, and skips the one network provider's parse), and isolates each provider
in its own try/catch so a single thrower becomes an error row instead of
crashing the report. Providers expose their scan roots via an optional
probeRoots() so the exact paths are shown even when zero sessions are found,
reusing each provider's own path resolution rather than duplicating it.

* fix(doctor): make the inert promise actually true

Adversarial re-review reproduced two false guarantees. Cursor's parser
writes its results cache to disk before its first yield, so a doctor run
created ~/.cache/codeburn/cursor-results.json on a clean machine; the
collect pass now sets CODEBURN_SUPPRESS_CACHE_WRITES (restored after) and
the cursor writer honors it. Antigravity's parse probes for a live
language server (spawns ps and lsof, RPCs the IDE when found); doctor now
skips its parse sample the way network providers are skipped, keeping
discovery counts meaningful.

Also stop blaming CODEBURN_CACHE_DIR in NOTHING FOUND hints (it is our
cache location, not a discovery path) and correct the sample-cap comment:
the cap truncates yields, eager parsers still do whole-file work.
2026-07-16 10:38:08 -07:00

113 lines
3.8 KiB
TypeScript

import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import { randomBytes } from 'crypto'
import type { ParsedProviderCall } from './providers/types.js'
// Bumped to 3 for the workspace-aware breakdown change: the cursor parser
// now derives `sessionId` from the bubble row key (the real composer id)
// rather than the empty `conversationId` JSON field, and the workspace
// router relies on those composer ids to bucket calls per project.
// Version 2 caches contain `sessionId: 'unknown'` for every call and would
// route everything to the orphan project, so we invalidate them.
// Version 5: parseAgentKv was removed (it double-counted against bubbles);
// real context tokens from composerData.promptTokenBreakdown now drive
// input, and agentKv is used only for the tools/bash breakdown. Cached v4
// results contain stale agentKv calls and lack the real token figures.
// Version 6: conversation input moved to composer-anchored records
// (cursor:composer-input:<id>) with per-conversation source selection, the
// agent stream regained tool/system context and stream-only sessions, and
// tool names are canonicalized. v5 results mix crediting regimes.
const CURSOR_CACHE_VERSION = 6
type ResultCache = {
version?: number
dbMtimeMs: number
dbSizeBytes: number
lookbackFloor: string
calls: ParsedProviderCall[]
}
const CACHE_FILE = 'cursor-results.json'
function getCacheDir(): string {
return join(homedir(), '.cache', 'codeburn')
}
function getCachePath(): string {
return join(getCacheDir(), CACHE_FILE)
}
async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size: number } | null> {
try {
const s = await stat(dbPath)
return { mtimeMs: s.mtimeMs, size: s.size }
} catch {
return null
}
}
export async function readCachedResults(
dbPath: string,
requestedFloor: string,
): Promise<ParsedProviderCall[] | null> {
try {
const fp = await getDbFingerprint(dbPath)
if (!fp) return null
const raw = await readFile(getCachePath(), 'utf-8')
const cache = JSON.parse(raw) as ResultCache
if (
cache.version === CURSOR_CACHE_VERSION &&
cache.dbMtimeMs === fp.mtimeMs &&
cache.dbSizeBytes === fp.size &&
typeof cache.lookbackFloor === 'string' &&
cache.lookbackFloor <= requestedFloor
) {
return cache.calls
}
return null
} catch {
return null
}
}
export async function writeCachedResults(
dbPath: string,
calls: ParsedProviderCall[],
lookbackFloor: string,
): Promise<void> {
// Diagnostic contexts (codeburn doctor) sample-parse providers under a
// strictly read-only promise; this is the one parse path that writes to
// disk before its first yield, so it honors the suppression flag.
if (process.env['CODEBURN_SUPPRESS_CACHE_WRITES']) return
const fp = await getDbFingerprint(dbPath)
if (!fp) return
const dir = getCacheDir()
await mkdir(dir, { recursive: true }).catch(() => {})
const cache: ResultCache = {
version: CURSOR_CACHE_VERSION,
dbMtimeMs: fp.mtimeMs,
dbSizeBytes: fp.size,
lookbackFloor,
calls,
}
// Atomic write: stage to a randomized temp file in the same directory,
// then rename onto the final path. rename() is atomic on POSIX, so a
// crash mid-write never leaves a half-written cache, and concurrent
// CLI invocations using their own random temp names cannot interleave
// bytes in the destination file (they only race on the final rename,
// last-writer-wins, both with valid content).
const target = getCachePath()
const tempPath = `${target}.${randomBytes(8).toString('hex')}.tmp`
try {
await writeFile(tempPath, JSON.stringify(cache), 'utf-8')
await rename(tempPath, target)
} catch {
await unlink(tempPath).catch(() => {})
}
}