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
143 changes: 143 additions & 0 deletions app/electron/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ function releaseSlot(): void {
/** SIGKILL every in-flight child and cancel anything still queued for a slot.
* Wired to Electron's `before-quit`. */
export function killAll(): void {
serveClient?.destroy()
serveClient = null
for (const child of activeChildren) child.kill('SIGKILL')
activeChildren.clear()
// A queued waiter has no child to reap, so releaseSlot never fires for it;
Expand Down Expand Up @@ -388,6 +390,132 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?:
* Read-only, so concurrent identical calls share one child and a 5s result cache
* absorbs same-cadence pollers. Never use this for config-mutating commands.
*/
// ── Resident serve child ────────────────────────────────────────────────
// The heavy read queries (one per panel) each pay seconds of CLI startup on
// a large corpus: node boot + a 100MB+ session-cache JSON.parse before any
// query work. `codeburn serve` is the same CLI kept warm: requests go over
// stdio and the cache stays parsed in the child. Routing rules keep this
// strictly an optimization:
// - only SERVE_ROUTED commands (the app's JSON panel queries) are eligible;
// - requests route through serve only once the child is READY AND WARM, so
// the cold-start path keeps its spawn (with its stderr progress events);
// - any serve failure falls back to a normal spawn for that call;
// - three child deaths permanently disable serve for this app run.
const SERVE_ROUTED = new Set(['status', 'models', 'sessions', 'compare', 'yield', 'spend', 'optimize', 'audit'])
const SERVE_REQUEST_TIMEOUT_MS = 60_000
const SERVE_MAX_RESTARTS = 3

class ServeClient {
private child: ReturnType<typeof spawn> | null = null
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }>()
private nextId = 1
private ready = false
private warm = false
private deaths = 0
private buffer = ''

constructor(private readonly spec: SpawnSpec) {}

isWarmAndReady(): boolean { return this.ready && this.warm && this.child !== null }
disabled(): boolean { return this.deaths >= SERVE_MAX_RESTARTS }

start(): void {
if (this.child || this.disabled()) return
const child = spawn(this.spec.bin, [...this.spec.args], { shell: false, stdio: ['pipe', 'pipe', 'ignore'], env: this.spec.env })
this.child = child
child.stdout!.setEncoding('utf8')
child.stdout!.on('data', (chunk: string) => this.onData(chunk))
const onGone = () => this.onDeath()
child.on('exit', onGone)
child.on('error', onGone)
// Background warm-up: one cheap query makes the child parse the session
// cache once; every later panel fetch reuses the in-memory copy.
void this.request(['status', '--format', 'menubar-json', '--period', 'today'], SERVE_REQUEST_TIMEOUT_MS)
.then(() => { this.warm = true })
.catch(() => { /* warm-up failure just leaves routing on the spawn path */ })
}

private onData(chunk: string): void {
this.buffer += chunk
let idx: number
while ((idx = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, idx).trim()
this.buffer = this.buffer.slice(idx + 1)
if (!line) continue
let msg: { id?: number; ready?: boolean; ok?: boolean; refused?: boolean; output?: string; error?: string }
try { msg = JSON.parse(line) } catch { continue }
if (msg.ready) { this.ready = true; continue }
if (typeof msg.id !== 'number') continue
const waiter = this.pending.get(msg.id)
if (!waiter) continue
this.pending.delete(msg.id)
clearTimeout(waiter.timer)
if (msg.ok && typeof msg.output === 'string') {
try { waiter.resolve(JSON.parse(msg.output)) }
catch { waiter.reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON')) }
} else {
waiter.reject(new CliError('nonzero', msg.error ?? 'serve request failed'))
}
}
}

private onDeath(): void {
const child = this.child
this.child = null
this.ready = false
this.warm = false
this.deaths += 1
if (child) activeChildren.delete(child as never)
for (const [, waiter] of this.pending) {
clearTimeout(waiter.timer)
waiter.reject(new CliError('nonzero', 'codeburn serve exited'))
}
this.pending.clear()
}

request(args: string[], timeoutMs: number): Promise<unknown> {
const child = this.child
if (!child?.stdin) return Promise.reject(new CliError('nonzero', 'serve not running'))
const id = this.nextId++
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
// A hung request would block the serialized queue behind it; kill the
// child so everything falls back to spawns and a fresh serve restarts.
this.pending.delete(id)
reject(new CliError('timeout', 'codeburn serve timed out'))
child.kill('SIGKILL')
}, timeoutMs)
this.pending.set(id, { resolve, reject, timer })
child.stdin!.write(JSON.stringify({ id, args }) + '\n', (err) => {
if (err) {
this.pending.delete(id)
clearTimeout(timer)
reject(new CliError('nonzero', 'serve write failed'))
}
})
})
}

destroy(): void {
this.deaths = SERVE_MAX_RESTARTS
this.child?.kill('SIGKILL')
this.onDeath()
}
}

let serveClient: ServeClient | null = null

/** Start the resident serve child and its warm-up query. Called once from app
* startup (never from the spawn path, so unit tests of the scheduler and the
* cold-start flow are byte-identical without it). Safe to call repeatedly. */
export function startServeWarmup(): void {
const target = resolveTarget()
if (!target) return
if (serveClient?.disabled()) return
if (!serveClient) serveClient = new ServeClient(spawnSpecFor(target, ['serve', '--stdio']))
serveClient.start()
}

export function spawnCli(
args: string[],
opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority } = {},
Expand All @@ -406,6 +534,21 @@ export function spawnCli(
// Coalesce/cache hits settle here, BEFORE queueing, so they never hold a slot.
if (existing) return existing

// Serve fast-path: warm resident child answers the panel query without a
// spawn. The child is started once at app startup (startServeWarmup); until
// it is warm, every call keeps the plain spawn path.
if (SERVE_ROUTED.has(args[0] ?? '') && !opts.extraEnv) {
const serve = serveClient
if (serve?.isWarmAndReady()) {
const flight = serve.request(args, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
.catch(() => runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr))
.then(value => { readCache.set(key, { at: Date.now(), value }); return value })
.finally(() => { readInflight.delete(key) })
readInflight.set(key, flight)
return flight
}
}

const priority = opts.priority ?? 'interactive'
const flight = (async () => {
await acquireSlot(priority)
Expand Down
6 changes: 5 additions & 1 deletion app/electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, shell, type MenuItemConstructorOptions } from 'electron'
import path from 'node:path'

import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, type ActionResult, type SpawnPriority } from './cli'
import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, startServeWarmup, type ActionResult, type SpawnPriority } from './cli'
import { getQuota, sanitizeError } from './quota'
import { Telemetry } from './telemetry'
import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates'
Expand Down Expand Up @@ -564,6 +564,10 @@ function bootstrap(): void {
}))

void app.whenReady().then(() => {
// Start the resident serve child early so its warm-up (one cache parse)
// finishes during the first panels' cold spawns; every fetch after that
// answers from the warm child in milliseconds.
startServeWarmup()
// Consent-gated anonymous telemetry (desktop only). Nothing transmits until
// the onboarding consent screen is completed and the toggle is on; EU/EEA/
// UK/CH installs default the toggle off. Dev builds never send.
Expand Down
2 changes: 1 addition & 1 deletion src/data/litellm-snapshot.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/data/pricing-fallback.json

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,12 @@ function assertScope(value: string, allowed: readonly string[], command: string)
}
}

// Wrapped in a factory because commander option state is sticky across
// parses: `codeburn serve` executes many requests in one process and must
// build a FRESH program per request or one request's --period would leak
// into the next one's defaults. The normal CLI path builds it exactly once.
function buildProgram(): Command {

async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
Expand Down Expand Up @@ -2339,4 +2345,22 @@ registerActCommands(program)
registerGuardCommands(program)
registerSyncCommands(program)

program.parse()
program
.command('serve')
.description('Run a resident query server over stdio (used by the desktop app to avoid per-fetch CLI startup cost)')
.option('--stdio', 'Serve JSON requests over stdin/stdout (the only mode)')
.action(() => {
// Never reached: the serve entry is dispatched before commander parses,
// because serving needs the buildProgram factory itself. Registered so
// `codeburn serve` appears in help and never falls through to `report`.
})

return program
}

if (process.argv[2] === 'serve') {
const { runStdioServe } = await import('./serve.js')
await runStdioServe(buildProgram)
} else {
buildProgram().parse()
}
48 changes: 46 additions & 2 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3190,7 +3190,35 @@ async function parseProviderSources(

const CACHE_TTL_MS = 180_000
const MAX_CACHE_ENTRIES = 10
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number }>()
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number; startMs?: number; endMs?: number; sig?: string }>()

// Burst reuse for a resident process (codeburn serve). Every payload command
// anchors its range end at its own `new Date()`, so two panel fetches issued
// milliseconds apart carry different end timestamps and the exact-key memo
// above never hits in real traffic — each fetch re-runs the full discovery +
// fingerprint sweep. Within this window, a parse whose range differs ONLY by
// a through-now end within the window is served by trimming the previous
// parse instead. Staleness is bounded by the window; 0 (the default outside
// serve) disables it, so one-shot CLI runs are byte-exact as ever.
function parseBurstWindowMs(): number {
const raw = Number(process.env['CODEBURN_PARSE_BURST_MS'] ?? '0')
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60_000) : 0
}

function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null {
const windowMs = parseBurstWindowMs()
if (windowMs <= 0) return null
const now = Date.now()
const startMs = dateRange.start.getTime()
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
return filterProjectsByDateRange(entry.data, dateRange)
}
return null
}

function cacheKey(dateRange?: DateRange, providerFilter?: string): string {
const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none'
Expand All @@ -3216,7 +3244,15 @@ function cachePut(key: string, data: ProjectSummary[]) {
const oldest = [...sessionCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0]
if (oldest) sessionCache.delete(oldest[0])
}
sessionCache.set(key, { data, ts: now })
sessionCache.set(key, { data, ts: now, ...(putMeta ?? {}) })
putMeta = null
}

// Range metadata for the entry cachePut is about to store, set by the one
// parseAllSessions call path right before it saves its result.
let putMeta: { startMs: number; endMs: number; sig: string } | null = null
export function setCachePutMeta(meta: { startMs: number; endMs: number; sig: string } | null): void {
putMeta = meta
}

export function filterProjectsByName(
Expand Down Expand Up @@ -3631,6 +3667,13 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s
const key = cacheKey(dateRange, providerFilter)
const cached = sessionCache.get(key)
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data
// The signature is the key minus the range: what must match for a burst
// reuse (provider, config env, proxy hash) regardless of the now-anchor.
const burstSig = cacheKey(undefined, providerFilter)
if (dateRange) {
const reused = burstReuse(dateRange, burstSig)
if (reused) return reused
}

let diskCache = await loadCache()
await cleanupOrphanedTempFiles()
Expand Down Expand Up @@ -3855,6 +3898,7 @@ async function runParse(

const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD)
correlateCrossProviderPrSessions(result)
if (dateRange) setCachePutMeta({ startMs: dateRange.start.getTime(), endMs: dateRange.end.getTime(), sig: cacheKey(undefined, providerFilter) })
cachePut(key, result)
return result
}
Loading
Loading