mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-09 16:44:34 +00:00
* 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.
113 lines
3.8 KiB
TypeScript
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(() => {})
|
|
}
|
|
}
|