mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-14 19:14:28 +00:00
perf: cache provider discovery metadata
This commit is contained in:
parent
2a9daec0ea
commit
ff442c71f2
7 changed files with 331 additions and 3 deletions
146
src/discovery-cache.ts
Normal file
146
src/discovery-cache.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue