perf(claude-usage): batch transcript scans (#1836)
This commit is contained in:
parent
41d642bd29
commit
d8610a714e
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue