perf: cache provider discovery metadata

This commit is contained in:
Sharada Mohanty 2026-04-20 17:55:15 +02:00
parent 2a9daec0ea
commit ff442c71f2
7 changed files with 331 additions and 3 deletions

146
src/discovery-cache.ts Normal file
View file

@ -0,0 +1,146 @@
import { createHash, randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
import { homedir } from 'os'
import { dirname, join } from 'path'
import type { SessionSource } from './providers/types.js'
const DISCOVERY_CACHE_VERSION = 1
export type DiscoverySnapshotEntry = {
path: string
mtimeMs: number
}
type DiscoveryCacheEntry = {
version: number
provider: string
scope: string
snapshot: DiscoverySnapshotEntry[]
sources: SessionSource[]
}
function cacheRoot(): string {
const base = process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
return join(base, 'discovery-cache-v1')
}
function cacheFilename(provider: string, scope: string): string {
return `${createHash('sha1').update(`${provider}:${scope}`).digest('hex')}.json`
}
function cachePath(provider: string, scope: string): string {
return join(cacheRoot(), cacheFilename(provider, scope))
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function isDiscoverySnapshotEntry(value: unknown): value is DiscoverySnapshotEntry {
return isPlainObject(value)
&& typeof value.path === 'string'
&& isFiniteNumber(value.mtimeMs)
}
function isSessionSource(value: unknown): value is SessionSource {
return isPlainObject(value)
&& typeof value.path === 'string'
&& typeof value.project === 'string'
&& typeof value.provider === 'string'
&& (value.fingerprintPath === undefined || typeof value.fingerprintPath === 'string')
&& (value.cacheStrategy === undefined || value.cacheStrategy === 'full-reparse' || value.cacheStrategy === 'append-jsonl')
&& (value.progressLabel === undefined || typeof value.progressLabel === 'string')
&& (value.parserVersion === undefined || typeof value.parserVersion === 'string')
}
function isDiscoveryCacheEntry(value: unknown): value is DiscoveryCacheEntry {
return isPlainObject(value)
&& value.version === DISCOVERY_CACHE_VERSION
&& typeof value.provider === 'string'
&& typeof value.scope === 'string'
&& Array.isArray(value.snapshot)
&& value.snapshot.every(isDiscoverySnapshotEntry)
&& Array.isArray(value.sources)
&& value.sources.every(isSessionSource)
}
function normalizeSnapshot(snapshot: DiscoverySnapshotEntry[]): DiscoverySnapshotEntry[] {
return [...snapshot].sort((left, right) => left.path.localeCompare(right.path))
}
function snapshotsMatch(left: DiscoverySnapshotEntry[], right: DiscoverySnapshotEntry[]): boolean {
if (left.length !== right.length) return false
return left.every((entry, index) => {
const other = right[index]
return !!other && entry.path === other.path && entry.mtimeMs === other.mtimeMs
})
}
async function atomicWriteJson(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true })
const temp = `${path}.${randomBytes(8).toString('hex')}.tmp`
const handle = await open(temp, 'w', 0o600)
try {
await handle.writeFile(JSON.stringify(value), { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(temp, path)
} catch (err) {
try {
await unlink(temp)
} catch {
// ignore cleanup failures
}
throw err
}
}
export async function loadDiscoveryCache(
provider: string,
scope: string,
snapshot: DiscoverySnapshotEntry[],
): Promise<SessionSource[] | null> {
const path = cachePath(provider, scope)
if (!existsSync(path)) return null
try {
const raw = await readFile(path, 'utf-8')
const parsed: unknown = JSON.parse(raw)
if (!isDiscoveryCacheEntry(parsed)) return null
if (parsed.provider !== provider || parsed.scope !== scope) return null
const normalizedSnapshot = normalizeSnapshot(snapshot)
const cachedSnapshot = normalizeSnapshot(parsed.snapshot)
if (!snapshotsMatch(normalizedSnapshot, cachedSnapshot)) return null
return parsed.sources
} catch {
return null
}
}
export async function saveDiscoveryCache(
provider: string,
scope: string,
snapshot: DiscoverySnapshotEntry[],
sources: SessionSource[],
): Promise<void> {
await mkdir(cacheRoot(), { recursive: true })
await atomicWriteJson(cachePath(provider, scope), {
version: DISCOVERY_CACHE_VERSION,
provider,
scope,
snapshot: normalizeSnapshot(snapshot),
sources,
} satisfies DiscoveryCacheEntry)
}

View file

@ -2,6 +2,7 @@ import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { type DiscoverySnapshotEntry, loadDiscoveryCache, saveDiscoveryCache } from '../discovery-cache.js'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -86,8 +87,49 @@ async function isValidCodexSession(filePath: string): Promise<{ valid: boolean;
return { valid, meta: valid ? entry : undefined }
}
async function collectCodexDiscoverySnapshot(sessionsDir: string): Promise<DiscoverySnapshotEntry[]> {
const snapshot: DiscoverySnapshotEntry[] = []
let years: string[]
try {
years = await readdir(sessionsDir)
} catch {
return snapshot
}
for (const year of years) {
if (!/^\d{4}$/.test(year)) continue
const yearDir = join(sessionsDir, year)
const yearStat = await stat(yearDir).catch(() => null)
if (!yearStat?.isDirectory()) continue
const months = await readdir(yearDir).catch(() => [] as string[])
for (const month of months) {
if (!/^\d{2}$/.test(month)) continue
const monthDir = join(yearDir, month)
const monthStat = await stat(monthDir).catch(() => null)
if (!monthStat?.isDirectory()) continue
const days = await readdir(monthDir).catch(() => [] as string[])
for (const day of days) {
if (!/^\d{2}$/.test(day)) continue
const dayDir = join(monthDir, day)
const dayStat = await stat(dayDir).catch(() => null)
if (!dayStat?.isDirectory()) continue
snapshot.push({ path: dayDir, mtimeMs: dayStat.mtimeMs })
}
}
}
return snapshot
}
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
const sessionsDir = join(codexDir, 'sessions')
const snapshot = await collectCodexDiscoverySnapshot(sessionsDir)
const cached = await loadDiscoveryCache('codex', sessionsDir, snapshot)
if (cached) return cached
const sources: SessionSource[] = []
let years: string[]
@ -136,6 +178,7 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
}
}
await saveDiscoveryCache('codex', sessionsDir, snapshot, sources)
return sources
}

View file

@ -2,6 +2,7 @@ import { readdir, stat } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { homedir } from 'os'
import { type DiscoverySnapshotEntry, loadDiscoveryCache, saveDiscoveryCache } from '../discovery-cache.js'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -157,7 +158,42 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
}
async function collectCopilotDiscoverySnapshot(sessionStateDir: string): Promise<DiscoverySnapshotEntry[]> {
const snapshot: DiscoverySnapshotEntry[] = []
let sessionDirs: string[]
try {
sessionDirs = await readdir(sessionStateDir)
} catch {
return snapshot
}
for (const sessionId of sessionDirs) {
const sessionDir = join(sessionStateDir, sessionId)
const dirStat = await stat(sessionDir).catch(() => null)
if (!dirStat?.isDirectory()) continue
const eventsPath = join(sessionDir, 'events.jsonl')
const eventsStat = await stat(eventsPath).catch(() => null)
if (!eventsStat?.isFile()) continue
snapshot.push({ path: eventsPath, mtimeMs: eventsStat.mtimeMs })
const workspacePath = join(sessionDir, 'workspace.yaml')
const workspaceStat = await stat(workspacePath).catch(() => null)
if (workspaceStat?.isFile()) {
snapshot.push({ path: workspacePath, mtimeMs: workspaceStat.mtimeMs })
}
}
return snapshot
}
async function discoverSessionsInDir(sessionStateDir: string): Promise<SessionSource[]> {
const snapshot = await collectCopilotDiscoverySnapshot(sessionStateDir)
const cached = await loadDiscoveryCache('copilot', sessionStateDir, snapshot)
if (cached) return cached
const sources: SessionSource[] = []
let sessionDirs: string[]
@ -190,6 +226,7 @@ async function discoverSessionsInDir(sessionStateDir: string): Promise<SessionSo
})
}
await saveDiscoveryCache('copilot', sessionStateDir, snapshot, sources)
return sources
}

View file

@ -2,6 +2,7 @@ import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { type DiscoverySnapshotEntry, loadDiscoveryCache, saveDiscoveryCache } from '../discovery-cache.js'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
@ -68,7 +69,31 @@ async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
}
}
async function collectPiDiscoverySnapshot(sessionsDir: string): Promise<DiscoverySnapshotEntry[]> {
const snapshot: DiscoverySnapshotEntry[] = []
let projectDirs: string[]
try {
projectDirs = await readdir(sessionsDir)
} catch {
return snapshot
}
for (const dirName of projectDirs) {
const dirPath = join(sessionsDir, dirName)
const dirStat = await stat(dirPath).catch(() => null)
if (!dirStat?.isDirectory()) continue
snapshot.push({ path: dirPath, mtimeMs: dirStat.mtimeMs })
}
return snapshot
}
async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource[]> {
const snapshot = await collectPiDiscoverySnapshot(sessionsDir)
const cached = await loadDiscoveryCache('pi', sessionsDir, snapshot)
if (cached) return cached
const sources: SessionSource[] = []
let projectDirs: string[]
@ -112,6 +137,7 @@ async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource
}
}
await saveDiscoveryCache('pi', sessionsDir, snapshot, sources)
return sources
}

View file

@ -1,18 +1,21 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createCodexProvider } from '../../src/providers/codex.js'
import * as fsUtils from '../../src/fs-utils.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codex-test-'))
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
})
afterEach(async () => {
delete process.env['CODEBURN_CACHE_DIR']
await rm(tmpDir, { recursive: true, force: true })
})
@ -136,6 +139,28 @@ describe('codex provider - session discovery', () => {
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('reuses cached discovery results when the directory tree is unchanged', async () => {
await writeSession(tmpDir, '2026-04-14', 'rollout-cached.jsonl', [
sessionMeta({ cwd: '/Users/test/myproject' }),
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
])
const provider = createCodexProvider(tmpDir)
const readSpy = vi.spyOn(fsUtils, 'readSessionFile')
const first = await provider.discoverSessions()
const firstReadCount = readSpy.mock.calls.length
const second = await provider.discoverSessions()
const secondReadCount = readSpy.mock.calls.length
expect(first).toHaveLength(1)
expect(second).toEqual(first)
expect(firstReadCount).toBeGreaterThan(0)
expect(secondReadCount).toBe(firstReadCount)
readSpy.mockRestore()
})
})
describe('codex provider - JSONL parsing', () => {

View file

@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { copilot, createCopilotProvider } from '../../src/providers/copilot.js'
import * as fsUtils from '../../src/fs-utils.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
@ -40,9 +41,11 @@ function assistantMessage(opts: { messageId: string; outputTokens: number; tools
describe('copilot provider - JSONL parsing', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-'))
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
})
afterEach(async () => {
delete process.env['CODEBURN_CACHE_DIR']
await rm(tmpDir, { recursive: true, force: true })
})
@ -219,6 +222,25 @@ describe('copilot provider - discoverSessions', () => {
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(0)
})
it('reuses cached discovery results when session directories are unchanged', async () => {
await createSessionDir('sess-disc-cached', [modelChange('gpt-4.1')], '/home/user/myapp')
const provider = createCopilotProvider(tmpDir)
const readSpy = vi.spyOn(fsUtils, 'readSessionFile')
const first = await provider.discoverSessions()
const firstReadCount = readSpy.mock.calls.length
const second = await provider.discoverSessions()
const secondReadCount = readSpy.mock.calls.length
expect(first).toHaveLength(1)
expect(second).toEqual(first)
expect(firstReadCount).toBeGreaterThan(0)
expect(secondReadCount).toBe(firstReadCount)
readSpy.mockRestore()
})
})
describe('copilot provider - metadata', () => {

View file

@ -1,18 +1,24 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createPiProvider } from '../../src/providers/pi.js'
import * as fsUtils from '../../src/fs-utils.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
let cacheDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'pi-test-'))
cacheDir = await mkdtemp(join(tmpdir(), 'pi-cache-'))
process.env['CODEBURN_CACHE_DIR'] = cacheDir
})
afterEach(async () => {
delete process.env['CODEBURN_CACHE_DIR']
await rm(cacheDir, { recursive: true, force: true })
await rm(tmpDir, { recursive: true, force: true })
})
@ -146,6 +152,29 @@ describe('pi provider - session discovery', () => {
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('reuses cached discovery results when project directories are unchanged', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'cached.jsonl', [
sessionMeta({ cwd: '/Users/test/myproject' }),
assistantMessage({}),
])
const provider = createPiProvider(tmpDir)
const readSpy = vi.spyOn(fsUtils, 'readSessionFile')
const first = await provider.discoverSessions()
const firstReadCount = readSpy.mock.calls.length
const second = await provider.discoverSessions()
const secondReadCount = readSpy.mock.calls.length
expect(first).toHaveLength(1)
expect(second).toEqual(first)
expect(firstReadCount).toBeGreaterThan(0)
expect(secondReadCount).toBe(firstReadCount)
readSpy.mockRestore()
})
})
describe('pi provider - JSONL parsing', () => {