From ff442c71f205c070997d7860659b4909c7fa62c2 Mon Sep 17 00:00:00 2001 From: Sharada Mohanty Date: Mon, 20 Apr 2026 17:55:15 +0200 Subject: [PATCH] perf: cache provider discovery metadata --- src/discovery-cache.ts | 146 ++++++++++++++++++++++++++++++++ src/providers/codex.ts | 43 ++++++++++ src/providers/copilot.ts | 37 ++++++++ src/providers/pi.ts | 26 ++++++ tests/providers/codex.test.ts | 27 +++++- tests/providers/copilot.test.ts | 24 +++++- tests/providers/pi.test.ts | 31 ++++++- 7 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 src/discovery-cache.ts diff --git a/src/discovery-cache.ts b/src/discovery-cache.ts new file mode 100644 index 0000000..39b76e4 --- /dev/null +++ b/src/discovery-cache.ts @@ -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 { + 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 { + 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 { + 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 { + await mkdir(cacheRoot(), { recursive: true }) + await atomicWriteJson(cachePath(provider, scope), { + version: DISCOVERY_CACHE_VERSION, + provider, + scope, + snapshot: normalizeSnapshot(snapshot), + sources, + } satisfies DiscoveryCacheEntry) +} diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 5c0ff07..9b27530 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -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 { + 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 { 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 } } + await saveDiscoveryCache('codex', sessionsDir, snapshot, sources) return sources } diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 039f844..e0a353b 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -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): SessionPars } } +async function collectCopilotDiscoverySnapshot(sessionStateDir: string): Promise { + 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 { + 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 { } } +async function collectPiDiscoverySnapshot(sessionsDir: string): Promise { + 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 { + 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 { 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', () => { diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts index eb1b6c5..ba02f2c 100644 --- a/tests/providers/copilot.test.ts +++ b/tests/providers/copilot.test.ts @@ -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', () => { diff --git a/tests/providers/pi.test.ts b/tests/providers/pi.test.ts index 74f8274..64d4265 100644 --- a/tests/providers/pi.test.ts +++ b/tests/providers/pi.test.ts @@ -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', () => {