Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3205,6 +3205,20 @@ function parseBurstWindowMs(): number {
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60_000) : 0
}

// A resident process (codeburn serve) can install a validator that answers
// "has any watched session root changed since this timestamp?" — typically
// backed by fs.watch over every provider's probeRoots(). While the validator
// reports clean, a previous parse stays reusable well past the burst window,
// bounded by a hard cap so a missed filesystem event self-heals instead of
// pinning stale data forever. Null (the default everywhere but serve) keeps
// reuse strictly inside the burst window.
let parseReuseValidator: ((sinceTs: number) => boolean) | null = null
const VALIDATED_REUSE_CAP_MS = 5 * 60 * 1000

export function setParseReuseValidator(validator: ((sinceTs: number) => boolean) | null): void {
parseReuseValidator = validator
}

function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null {
const windowMs = parseBurstWindowMs()
if (windowMs <= 0) return null
Expand All @@ -3213,8 +3227,11 @@ function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null
const endMs = dateRange.end.getTime()
for (const entry of sessionCache.values()) {
if (entry.sig !== sig || entry.startMs !== startMs || entry.endMs === undefined) continue
if (now - entry.ts > windowMs) continue
if (endMs < entry.endMs || endMs - entry.endMs > windowMs) continue
const age = now - entry.ts
const insideBurst = age <= windowMs
const validatedClean = parseReuseValidator !== null && age <= VALIDATED_REUSE_CAP_MS && parseReuseValidator(entry.ts)
if (!insideBurst && !validatedClean) continue
if (endMs < entry.endMs || endMs - entry.endMs > Math.max(windowMs, validatedClean ? VALIDATED_REUSE_CAP_MS : 0)) continue
return filterProjectsByDateRange(entry.data, dateRange)
}
return null
Expand Down
74 changes: 73 additions & 1 deletion src/serve.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { watch, type FSWatcher } from 'fs'
import { stat } from 'fs/promises'
import { createInterface } from 'readline'

import type { Command } from 'commander'
Expand Down Expand Up @@ -88,12 +90,69 @@ async function runCaptured(buildProgram: () => Command, args: string[]): Promise
}
}

/// Watch every provider's probe roots (the same paths codeburn doctor reports
/// as "where discovery looks") so the parse-reuse validator can answer "did
/// any session data change since T?" without a stat sweep. macOS fs.watch
/// rides FSEvents and supports recursive directory watches; a root that fails
/// to watch is simply not covered, which only shortens reuse (the burst
/// window and the hard cap still apply), never staleness.
async function startRootWatchers(): Promise<{ startedAt: number; lastEventAt: () => number; close: () => void }> {
let lastEventAt = 0
const startedAt = Date.now()
const watchers: FSWatcher[] = []
try {
const { getAllProviders } = await import('./providers/index.js')
const providers = await getAllProviders()
const roots = new Set<string>()
for (const provider of providers) {
if (!provider.probeRoots) continue
try {
for (const root of await provider.probeRoots()) roots.add(root.path)
} catch { /* a failing probe just goes unwatched */ }
}
for (const root of roots) {
try {
const info = await stat(root)
const watcher = watch(root, { recursive: info.isDirectory() }, () => { lastEventAt = Date.now() })
watcher.on('error', () => { /* dropped watcher = shorter reuse, never staleness */ })
watchers.push(watcher)
} catch { /* nonexistent root: nothing to watch */ }
}
} catch { /* watcherless serve still works via the burst window */ }
return {
startedAt,
lastEventAt: () => lastEventAt,
close: () => { for (const w of watchers) w.close() },
}
}

export async function runStdioServe(buildProgram: () => Command): Promise<void> {
// Panel bursts (the app fetching every panel for one period) reuse a parse
// whose through-now range end differs by less than this window, instead of
// re-running the discovery sweep per panel. Serve-only: one-shot CLI runs
// never set this, so their results stay byte-exact.
if (!process.env['CODEBURN_PARSE_BURST_MS']) process.env['CODEBURN_PARSE_BURST_MS'] = '10000'
// Event-driven reuse: while no watched session root has changed, a previous
// parse stays valid past the burst window (capped in parser.ts, so a missed
// filesystem event self-heals within minutes). This is what turns a warm
// no-change fetch into a no-op instead of a stat sweep.
let rootsQuietSince: ((sinceTs: number) => boolean) | null = null
void startRootWatchers().then(async (w) => {
const { setParseReuseValidator } = await import('./parser.js')
// Clean means: the watchers were already armed when the parse happened,
// and no filesystem event has landed since. lastEventAt of 0 is a quiet
// system (clean for anything parsed after arming), not an unknown.
const quiet = (sinceTs: number): boolean => sinceTs >= w.startedAt && w.lastEventAt() < sinceTs
rootsQuietSince = quiet
setParseReuseValidator(quiet)
}).catch(() => { /* watcherless serve still works via the burst window */ })

// Output-level memo: an identical panel query while the roots are quiet
// returns the previous stdout verbatim - the aggregation work is skipped
// too, not just the parse. Invalidation is the same event-or-cap rule the
// parse reuse uses.
const OUTPUT_MEMO_CAP_MS = 5 * 60 * 1000
const outputMemo = new Map<string, { at: number; output: string }>()
if (process.stdin.isTTY) {
process.stderr.write('codeburn serve speaks JSON over stdio and exists for the desktop app to hold warm.\nNothing interactive happens here; press Ctrl+C to exit.\n')
}
Expand Down Expand Up @@ -124,9 +183,22 @@ export async function runStdioServe(buildProgram: () => Command): Promise<void>
write({ id: request.id, ok: false, refused: true, error: 'command not served' })
return
}
const memoKey = request.args.join('\u0000')
const memoHit = outputMemo.get(memoKey)
if (memoHit && Date.now() - memoHit.at < OUTPUT_MEMO_CAP_MS && rootsQuietSince?.(memoHit.at)) {
write({ id: request.id, ok: true, output: memoHit.output })
return
}
try {
const { output, code } = await runCaptured(buildProgram, request.args)
if (code === 0) write({ id: request.id, ok: true, output })
if (code === 0) {
outputMemo.set(memoKey, { at: Date.now(), output })
if (outputMemo.size > 32) {
const oldest = [...outputMemo.entries()].sort((a, b) => a[1].at - b[1].at)[0]
if (oldest) outputMemo.delete(oldest[0])
}
write({ id: request.id, ok: true, output })
}
else write({ id: request.id, ok: false, error: `exit ${code}`, output })
} catch (err) {
write({ id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) })
Expand Down
43 changes: 42 additions & 1 deletion tests/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { join } from 'path'
import { createRequire } from 'node:module'

import { isSqliteAvailable } from '../src/sqlite.js'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { clearSessionCache, parseAllSessions, setParseReuseValidator } from '../src/parser.js'
import { loadCache, saveCache, sessionCachePath } from '../src/session-cache.js'
import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js'

Expand Down Expand Up @@ -726,3 +726,44 @@ describe('(q) parse burst reuse (CODEBURN_PARSE_BURST_MS)', () => {
_synthYields = []
})
})

describe('(r) validated parse reuse (setParseReuseValidator)', () => {
it('reuses past the burst window while the validator reports quiet, never when dirty', async () => {
vi.stubEnv('CODEBURN_PARSE_BURST_MS', '1')
clearSessionCache()
const start = new Date(Date.now() - 60 * 60 * 1000)
const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString()
const synthFile = join(tmpHome, 'synth-validated.txt')
await writeFile(synthFile, 'placeholder')
_synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }]
_synthYields = [{
provider: 'test-synthetic', model: 'synth-model',
inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [],
timestamp: ts, speed: 'standard', deduplicationKey: 'synth-val-1', userMessage: 'hi', sessionId: 'sv-1',
}] as never

const first = await parseAllSessions({ start, end: new Date() }, 'test-synthetic')
expect(totalOutput(first)).toBe(5)

// 1ms burst window has certainly elapsed; with a quiet validator the
// previous parse is still served (world changed, result must not).
await new Promise(r => setTimeout(r, 5))
setParseReuseValidator(() => true)
_synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-val-2', outputTokens: 7 }] as never
await writeFile(synthFile, 'placeholder v2')
const second = await parseAllSessions({ start, end: new Date(Date.now() + 500) }, 'test-synthetic')
expect(totalOutput(second)).toBe(5)

// A dirty validator ends the reuse: fresh parse sees the new call.
setParseReuseValidator(() => false)
const third = await parseAllSessions({ start, end: new Date(Date.now() + 1000) }, 'test-synthetic')
expect(totalOutput(third)).toBe(12)

setParseReuseValidator(null)
vi.unstubAllEnvs()
_synthSources = []
_synthYields = []
})
})
Loading