perf(claude-usage): batch transcript scans (#1836)

This commit is contained in:
Neil 2026-05-14 01:22:28 -07:00 committed by GitHub
parent 41d642bd29
commit d8610a714e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 132 additions and 8 deletions

View File

@ -0,0 +1,91 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type * as Os from 'os'
const tempRoots: string[] = []
async function makeClaudeProjectsRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-claude-usage-'))
tempRoots.push(root)
await mkdir(join(root, '.claude', 'projects', 'project-a'), { recursive: true })
return root
}
afterEach(async () => {
vi.doUnmock('os')
vi.resetModules()
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('scanClaudeUsageFiles', () => {
it('scans transcript files from the configured Claude projects directory', async () => {
const root = await makeClaudeProjectsRoot()
const projectDir = join(root, '.claude', 'projects', 'project-a')
const firstFile = join(projectDir, 'a.jsonl')
const secondFile = join(projectDir, 'b.jsonl')
await writeFile(
firstFile,
[
JSON.stringify({
type: 'assistant',
sessionId: 'session-1',
timestamp: '2026-04-09T10:00:00.000Z',
cwd: '/workspace/repo-a',
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 100,
output_tokens: 20,
cache_read_input_tokens: 10,
cache_creation_input_tokens: 5
}
}
}),
JSON.stringify({ type: 'user', sessionId: 'session-1' })
].join('\n')
)
await writeFile(
secondFile,
JSON.stringify({
type: 'assistant',
sessionId: 'session-2',
timestamp: '2026-04-10T10:00:00.000Z',
cwd: '/outside/repo-b',
message: {
model: 'claude-sonnet-4-6',
usage: {
input_tokens: 50,
output_tokens: 10
}
}
})
)
vi.resetModules()
vi.doMock('os', async () => ({
...(await vi.importActual<typeof Os>('os')),
homedir: () => root
}))
const { scanClaudeUsageFiles } = await import('./scanner')
const result = await scanClaudeUsageFiles([
{
repoId: 'repo-1',
worktreeId: 'worktree-1',
path: '/workspace/repo-a',
displayName: 'Repo A'
}
])
expect(result.processedFiles.map((file) => [file.path, file.lineCount])).toEqual([
[firstFile, 2],
[secondFile, 1]
])
expect(result.sessions.map((session) => session.sessionId)).toEqual(['session-2', 'session-1'])
expect(result.dailyAggregates).toHaveLength(2)
expect(result.dailyAggregates[0]?.projectLabel).toBe('Repo A')
})
})

View File

@ -39,7 +39,7 @@ type ClaudeUsageSourceRecord = {
}
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
const YIELD_EVERY_FILES = 10
const FILE_SCAN_BATCH_SIZE = 4
function getDefaultProjectLabel(cwd: string | null): string {
if (!cwd) {
@ -168,6 +168,36 @@ export async function parseClaudeUsageFile(filePath: string): Promise<ClaudeUsag
return turns
}
async function readClaudeUsageScanFile(filePath: string): Promise<{
processedFile: ClaudeUsageProcessedFile
turns: ClaudeUsageParsedTurn[]
}> {
const fileStat = await stat(filePath)
let lineCount = 0
const turns: ClaudeUsageParsedTurn[] = []
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
lineCount++
const parsed = parseClaudeUsageRecord(line)
if (parsed) {
turns.push(parsed)
}
}
return {
processedFile: {
path: filePath,
mtimeMs: fileStat.mtimeMs,
lineCount
},
turns
}
}
function localDayFromTimestamp(timestamp: string): string | null {
const parsed = new Date(timestamp)
if (Number.isNaN(parsed.getTime())) {
@ -361,13 +391,16 @@ export async function scanClaudeUsageFiles(worktrees: ClaudeUsageWorktreeRef[]):
const allTurns: ClaudeUsageParsedTurn[] = []
const worktreeLookup = await buildWorktreeLookup(worktrees)
for (const [index, filePath] of files.entries()) {
processedFiles.push(await getProcessedFileInfo(filePath))
allTurns.push(...(await parseClaudeUsageFile(filePath)))
// Why: transcript scans can touch many files and run on the Electron main
// process. Yield between batches so Settings stays responsive while the
// analytics refresh is in flight.
if ((index + 1) % YIELD_EVERY_FILES === 0) {
for (let index = 0; index < files.length; index += FILE_SCAN_BATCH_SIZE) {
const batch = files.slice(index, index + FILE_SCAN_BATCH_SIZE)
const results = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath)))
for (const result of results) {
processedFiles.push(result.processedFile)
allTurns.push(...result.turns)
}
// Why: transcript scans run in Electron's main process. Small parallel
// batches cut independent file I/O without letting Settings stay blocked.
if (index + batch.length < files.length) {
await yieldToEventLoop()
}
}