refactor: organize usage and CSV modules by concrete owners (#41059)

This commit is contained in:
Neil
2026-11-10 23:26:07 -08:00
committed by GitHub
parent 8cac33b751
commit 280bcbc48a
158 changed files with 655 additions and 4024 deletions
+1 -3
View File
@@ -26,10 +26,8 @@ on:
- 'src/main/runtime/orchestration/db/row-column-lists.test.ts'
- 'src/main/codex-usage/codex-rollout-file-lifetime.test.ts'
- 'src/main/codex-usage/scanner-incremental-append.test.ts'
- 'src/main/usage/jsonl-file-reader.test.ts'
- 'src/main/usage/jsonl-file-snapshot.test.ts'
- 'src/main/usage/source-input/jsonl/jsonl-file-reader.test.ts'
- 'src/main/claude-usage/scanner-incremental-append.test.ts'
- 'src/main/claude-usage/store-checkpoint-persistence.test.ts'
permissions:
contents: read
@@ -39,7 +39,7 @@ export const MAIN_BUILD_ENTRY_POINTS: Record<string, string> = {
'src/main/worker-thread-child-process/worker-thread-child-process-entry.ts'
),
// Usage scans synchronously walk histories/SQLite; keep them off the main loop.
'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'),
'usage-scan-worker-entry': resolve('src/main/usage/scan-worker/usage-scan-worker-entry.ts'),
'workspace-space-scan-worker-entry': resolve(
'src/main/workspaces/space-analysis/workspace-space-scan-worker-entry.ts'
),
+1 -1
View File
@@ -41,7 +41,7 @@
"src/main/plugins/testing/plugin-test-register.ts",
"src/main/ai-vault/scan-service/session-scanner-service-entry.ts",
"src/main/native-chat/wsl-transcript-fs-process-entry.ts",
"src/main/usage/usage-scan-worker-entry.ts",
"src/main/usage/scan-worker/usage-scan-worker-entry.ts",
"src/main/workspaces/space-analysis/workspace-space-scan-worker-entry.ts",
"src/main/claude-accounts/claude-profile-setup-worker-entry.ts",
"src/main/persistence/profile-state/profile-state-backup-worker-entry.ts",
+2 -2
View File
@@ -539,8 +539,8 @@
1 src/main/updater/linux-package/linux-package-install-command.ts
1 src/main/updater/linux-package/linux-update-package-type.ts
6 src/main/updater/serve-update-handoff.ts
2 src/main/usage/usage-provider-store-lifecycle.ts
1 src/main/usage/usage-scan-worker-spawn.ts
2 src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts
1 src/main/usage/scan-worker/usage-scan-worker-spawn.ts
1 src/main/warp-themes/discovery.ts
1 src/main/window/desktop-integration/custom-app-icon.ts
3 src/main/window/desktop-integration/macos-press-and-hold-default.ts
+4 -4
View File
@@ -99,11 +99,11 @@
1 src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-row-keys.ts
1 src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts
1 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts
1 src/renderer/src/components/editor/csv/CsvGrid.tsx
1 src/renderer/src/components/editor/csv/grid/CsvGrid.tsx
1 src/renderer/src/components/editor/csv/CsvViewer.tsx
1 src/renderer/src/components/editor/csv/useCsvClipboard.ts
1 src/renderer/src/components/editor/csv/useCsvPagedPreview.ts
3 src/renderer/src/components/editor/csv/useCsvTableEditor.ts
1 src/renderer/src/components/editor/csv/editing/clipboard/useCsvClipboard.ts
1 src/renderer/src/components/editor/csv/preview/useCsvPagedPreview.ts
3 src/renderer/src/components/editor/csv/editing/useCsvTableEditor.ts
1 src/renderer/src/components/editor/diff/large-files/useDiffViewerLargeDiffLifecycle.ts
3 src/renderer/src/components/editor/diff/monaco-view/DiffViewer.tsx
1 src/renderer/src/components/editor/diff/review/DiffNotesSendMenu.tsx
@@ -1102,7 +1102,6 @@
"src/main/claude-usage/claude-model-pricing.test.ts": 74,
"src/main/claude-usage/claude-usage-report-aggregation.test.ts": 57,
"src/main/claude-usage/persisted-source-authority.test.ts": 491,
"src/main/claude-usage/persisted-token-columns-store.test.ts": 376,
"src/main/claude-usage/scanner-incremental-append.test.ts": 235,
"src/main/claude-usage/scanner-incremental-ownership.test.ts": 545,
"src/main/claude-usage/scanner-incremental-reader.test.ts": 819,
@@ -1110,9 +1109,6 @@
"src/main/claude-usage/scanner-scan.test.ts": 295,
"src/main/claude-usage/scanner.test.ts": 80,
"src/main/claude-usage/source-cache-compression.test.ts": 74,
"src/main/claude-usage/store-checkpoint-persistence.test.ts": 545,
"src/main/claude-usage/store-source-authority.test.ts": 530,
"src/main/claude-usage/store.test.ts": 377,
"src/main/claude-usage/transcript-file-discovery-errors.test.ts": 62,
"src/main/claude-usage/transcript-reader-lifetime.test.ts": 100,
"src/main/claude-usage/transcript-tuple-append.test.ts": 435,
@@ -1323,8 +1319,6 @@
"src/main/codex-usage/scanner-paths.test.ts": 493,
"src/main/codex-usage/scanner.test.ts": 247,
"src/main/codex-usage/session-file-discovery-errors.test.ts": 70,
"src/main/codex-usage/store-automation-usage.test.ts": 305,
"src/main/codex-usage/store-persistence.test.ts": 364,
"src/main/codex/app-server/codex-app-server-client.test.ts": 13839,
"src/main/codex/app-server/codex-app-server-connection-exit-order.test.ts": 264,
"src/main/codex/app-server/codex-app-server-connection-tree-unproven.test.ts": 634,
@@ -2302,7 +2296,6 @@
"src/main/ipc/terminal-preview-output-stream.test.ts": 53,
"src/main/ipc/terminal-render-desync-evidence.test.ts": 78,
"src/main/ipc/terminal-startup-color-query-replies.test.ts": 265,
"src/main/ipc/usage-provider-handlers-readiness.test.ts": 506,
"src/main/ipc/filesystem/watcher-process/watcher-event-root-path-rewrite.test.ts": 48,
"src/main/workspaces/removal/watcher-removal-gate.test.ts": 384,
"src/main/ipc/workspace-cleanup/workspace-cleanup-activity.test.ts": 76,
@@ -4326,22 +4319,15 @@
"src/main/updater/lifecycle/serve-supervisor-install-park.test.ts": 173,
"src/main/updater/mac-install/shipit-leftover-recovery.test.ts": 136,
"src/main/updater/lifecycle/updater-wsl-move-install-hold.test.ts": 287,
"src/main/usage/usage-cache-snapshot-writer.test.ts": 55,
"src/main/usage/usage-worktree-canonicalizer.test.ts": 43,
"src/main/usage/usage-worktree-metadata.test.ts": 61,
"src/main/usage/agent-token-usage-reporter.test.ts": 343,
"src/main/usage/analytics-session-id-store.test.ts": 195,
"src/main/usage/highest-usage-key.test.ts": 45,
"src/main/usage/jsonl-file-reader.test.ts": 112,
"src/main/usage/jsonl-file-snapshot.test.ts": 54,
"src/main/usage/jsonl-line-offsets.test.ts": 66,
"src/main/usage/usage-breakdown-scaling.test.ts": 84,
"src/main/usage/usage-calendar-range.test.ts": 53,
"src/main/usage/usage-provider-store-initialization.test.ts": 586,
"src/main/usage/usage-provider-store-lifecycle.test.ts": 769,
"src/main/usage/usage-scan-worker-cache-cleanup.test.ts": 153,
"src/main/usage/usage-source-cache-file.test.ts": 94,
"src/main/usage/usage-worktree-resolver.test.ts": 269,
"src/main/usage/cache/usage-cache-snapshot-writer.test.ts": 55,
"src/main/usage/reporting/token-usage/analytics-session-id-store.test.ts": 195,
"src/main/usage/source-input/jsonl/jsonl-file-reader.test.ts": 112,
"src/main/usage/source-input/jsonl/jsonl-line-offsets.test.ts": 66,
"src/main/usage/rollups/usage-breakdown-scaling.test.ts": 84,
"src/main/usage/reporting/range-query/usage-calendar-range.test.ts": 53,
"src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts": 769,
"src/main/usage/cache/usage-source-cache-file.test.ts": 94,
"src/main/usage/worktree-attribution/usage-worktree-resolver.test.ts": 269,
"src/main/warp-themes/discovery.test.ts": 58,
"src/main/warp-themes/index.test.ts": 678,
"src/main/warp-themes/manual-warp-theme-files.test.ts": 104,
@@ -4795,21 +4781,19 @@
"src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts": 54,
"src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.disk-precondition.test.tsx": 2383,
"src/renderer/src/components/editor/conflict-review-file-tree-windowing.test.tsx": 1487,
"src/renderer/src/components/editor/csv/CsvGrid.test.tsx": 994,
"src/renderer/src/components/editor/csv/CsvViewer.delimiter.test.tsx": 2923,
"src/renderer/src/components/editor/csv/CsvViewer.editing.test.tsx": 3583,
"src/renderer/src/components/editor/csv/csv-byte-index.test.ts": 1645,
"src/renderer/src/components/editor/csv/csv-cell-links.test.tsx": 302,
"src/renderer/src/components/editor/csv/csv-column-width-preferences.test.ts": 208,
"src/renderer/src/components/editor/csv/csv-paged-preview.test.ts": 143,
"src/renderer/src/components/editor/csv/csv-parse-budget.test.ts": 102,
"src/renderer/src/components/editor/csv/csv-parse.test.ts": 148,
"src/renderer/src/components/editor/csv/csv-pending-save.test.ts": 1926,
"src/renderer/src/components/editor/csv/csv-preview-save-safety.test.ts": 1973,
"src/renderer/src/components/editor/csv/csv-table-interactions.test.ts": 310,
"src/renderer/src/components/editor/csv/csv-text-document.test.ts": 153,
"src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx": 429,
"src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx": 714,
"src/renderer/src/components/editor/csv/document/csv-byte-index.test.ts": 1645,
"src/renderer/src/components/editor/csv/grid/csv-cell-links.test.tsx": 302,
"src/renderer/src/components/editor/csv/grid/columns/csv-column-width-preferences.test.ts": 208,
"src/renderer/src/components/editor/csv/preview/csv-paged-preview.test.ts": 143,
"src/renderer/src/components/editor/csv/document/csv-parse-budget.test.ts": 102,
"src/renderer/src/components/editor/csv/document/csv-parse.test.ts": 148,
"src/renderer/src/components/editor/save/queue/csv-pending-save.test.ts": 1926,
"src/renderer/src/components/editor/save/queue/csv-preview-save-safety.test.ts": 1973,
"src/renderer/src/components/editor/csv/editing/csv-table-interactions.test.ts": 310,
"src/renderer/src/components/editor/csv/document/csv-text-document.test.ts": 153,
"src/renderer/src/components/editor/csv/editing/useCsvPendingCell.test.tsx": 714,
"src/renderer/src/components/editor/view-mode/custom-editor-routing.test.ts": 2715,
"src/renderer/src/components/editor/rich-markdown/details/details-markdown-html.test.ts": 93,
"src/renderer/src/components/editor/save/flush/discard-editor-file-changes.save-race.test.ts": 1150,
@@ -27,14 +27,40 @@ export function resolveClaudeBenchmarkCommit(ref) {
}
const SNAPSHOT_MODULE_PATHS = [
'src/main/usage/cache/usage-cache-snapshot-writer.ts',
'src/main/usage/usage-cache-snapshot-writer.ts',
'src/main/usage-cache-snapshot-writer.ts'
]
const PERSISTENCE_MODULE_PATHS = {
snapshot: SNAPSHOT_MODULE_PATHS,
lifecycle: [
'src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts',
'src/main/usage/usage-provider-store-lifecycle.ts'
],
sourceCache: [
'src/main/usage/cache/usage-source-cache-file.ts',
'src/main/usage/usage-source-cache-file.ts'
]
}
function persistenceEntry(mode) {
function listClaudeBenchmarkModulePaths(commit, modulePaths) {
const listed = runProcessSync({
program: 'git',
args: ['ls-tree', '--name-only', commit, '--', ...modulePaths],
cwd: ROOT,
timeoutMs: 10_000,
maxOutputBytes: MIB,
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
})
assert.equal(listed.code, 0, listed.stderr)
assert.equal(listed.outputTruncated, false)
return new Set(listed.stdout.trim().split('\n'))
}
function persistenceEntry(mode, modulePaths) {
const exports = [
`export { UsageCacheSnapshotWriter } from './src/main/usage/usage-cache-snapshot-writer';`,
`export { UsageProviderStoreLifecycle } from './src/main/usage/usage-provider-store-lifecycle';`
`export { UsageCacheSnapshotWriter } from './${modulePaths.snapshot.slice(0, -3)}';`,
`export { UsageProviderStoreLifecycle } from './${modulePaths.lifecycle.slice(0, -3)}';`
]
if (mode === 'published') {
exports.push(`export { validatePersistedClaudeUsageProjections }
@@ -42,7 +68,7 @@ function persistenceEntry(mode) {
} else if (mode === 'main') {
exports.push(`export { readUsageSourceCache as readSourceCache,
writeUsageSourceCache as writeSourceCache, splitUsageCacheFile as splitCacheFile }
from './src/main/usage/usage-source-cache-file';`)
from './${modulePaths.sourceCache.slice(0, -3)}';`)
} else {
exports.push(`export { readClaudeUsageSourceCache as readSourceCache,
writeClaudeUsageSourceCache as writeSourceCache, persistClaudeUsageSourceCache as persistSourceCache,
@@ -75,24 +101,25 @@ const INACTIVE_LIFECYCLE_IMPORTS = new Map([
export async function loadClaudeUsageBenchmarkPersistence(mode, commit = null, frozen = new Map()) {
const sourceFingerprints = {}
const stubFingerprints = {}
let snapshotModulePath = SNAPSHOT_MODULE_PATHS[0]
const modulePaths = Object.fromEntries(
Object.entries(PERSISTENCE_MODULE_PATHS).map(([name, paths]) => [name, paths[0]])
)
const nativeModulePaths = new Map()
if (typeof commit === 'string') {
const listed = runProcessSync({
program: 'git',
args: ['ls-tree', '--name-only', commit, '--', ...SNAPSHOT_MODULE_PATHS],
cwd: ROOT,
timeoutMs: 10_000,
maxOutputBytes: MIB,
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
})
assert.equal(listed.code, 0, listed.stderr)
assert.equal(listed.outputTruncated, false)
const paths = new Set(listed.stdout.trim().split('\n'))
snapshotModulePath = SNAPSHOT_MODULE_PATHS.find((path) => paths.has(path))
assert(snapshotModulePath, 'Pinned usage snapshot writer is absent')
const paths = listClaudeBenchmarkModulePaths(
commit,
Object.values(PERSISTENCE_MODULE_PATHS).flat()
)
for (const [name, candidates] of Object.entries(PERSISTENCE_MODULE_PATHS)) {
if (name === 'sourceCache' && mode !== 'main') {
continue
}
modulePaths[name] = candidates.find((path) => paths.has(path))
assert(modulePaths[name], `Pinned usage ${name} is absent`)
}
}
const result = await build({
stdin: { contents: persistenceEntry(mode), loader: 'ts', resolveDir: ROOT },
stdin: { contents: persistenceEntry(mode, modulePaths), loader: 'ts', resolveDir: ROOT },
platform: 'node',
format: 'esm',
bundle: true,
@@ -107,7 +134,7 @@ export async function loadClaudeUsageBenchmarkPersistence(mode, commit = null, f
.join('/')
const modulePath = resolved.endsWith('.ts') ? resolved : `${resolved}.ts`
return SNAPSHOT_MODULE_PATHS.includes(modulePath)
? { path: resolve(ROOT, snapshotModulePath) }
? { path: resolve(ROOT, modulePaths.snapshot) }
: undefined
})
bundler.onResolve(
@@ -122,6 +149,29 @@ export async function loadClaudeUsageBenchmarkPersistence(mode, commit = null, f
return match ? { path: match, namespace: 'inactive-lifecycle' } : undefined
}
)
bundler.onResolve({ filter: /^\./ }, (args) => {
if (typeof commit !== 'string') {
return undefined
}
const target = relative(ROOT, resolve(args.resolveDir, args.path)).split(sep).join('/')
if (!target.startsWith('src/')) {
return undefined
}
if (!nativeModulePaths.has(target)) {
const extensions = ['.tsx', '.ts', '.jsx', '.js', '.css', '.json']
const candidates = extname(target)
? [target]
: [
...extensions.map((extension) => target + extension),
...extensions.map((extension) => `${target}/index${extension}`)
]
const paths = listClaudeBenchmarkModulePaths(commit, candidates)
const modulePath = candidates.find((path) => paths.has(path))
assert(modulePath, `Pinned usage source is absent: ${String(commit)}:${target}`)
nativeModulePaths.set(target, modulePath)
}
return { path: resolve(ROOT, nativeModulePaths.get(target)) }
})
bundler.onLoad({ filter: /.*/, namespace: 'inactive-lifecycle' }, (args) => {
const contents = INACTIVE_LIFECYCLE_IMPORTS.get(args.path)
stubFingerprints[args.path] = claudeBenchmarkSha256(contents)
+2 -4
View File
@@ -14,12 +14,10 @@ const contracts = [
'src/shared/native-chat/tools/native-chat-tool-pairing.performance.test.ts',
'src/main/codex-usage/codex-rollout-file-lifetime.test.ts',
'src/main/codex-usage/scanner-incremental-append.test.ts',
'src/main/usage/jsonl-file-reader.test.ts',
'src/main/usage/jsonl-file-snapshot.test.ts',
'src/main/usage/source-input/jsonl/jsonl-file-reader.test.ts',
'config/scripts/static-analysis/oxlint-contracts/app-store-performance-plugin.test.mjs',
'config/scripts/static-analysis/oxlint-contracts/quadratic-buffer-concat-plugin.test.mjs',
'src/main/claude-usage/scanner-incremental-append.test.ts',
'src/main/claude-usage/store-checkpoint-persistence.test.ts'
'src/main/claude-usage/scanner-incremental-append.test.ts'
]
for (const contract of contracts) {
if (!existsSync(resolve(contract))) {
@@ -2,7 +2,7 @@ import type { AutomationRunUsage } from '../../shared/automations/history/automa
import type { ClaudeUsagePersistedState } from './types'
import { estimateCostUsd } from './claude-model-pricing'
import { addBillableTokens, NO_TOKENS, type ClaudeBillableTokens } from './claude-billable-tokens'
import { shouldForceAutomationUsageScan } from '../usage/automation-usage-scan-forcing'
import { shouldForceAutomationUsageScan } from '../usage/provider-lifecycle/automation-usage-scan-forcing'
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
@@ -1,4 +1,4 @@
import { highestUsageKey } from '../usage/highest-usage-key'
import { highestUsageKey } from '../usage/rollups/highest-usage-key'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
@@ -1,6 +1,9 @@
import type { ClaudeUsageRange, ClaudeUsageScope } from '../../shared/claude-usage-types'
import type { ClaudeUsagePersistedState } from './types'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import {
getLocalUsageDay,
getUsageRangeCutoff
} from '../usage/reporting/range-query/usage-calendar-range'
export function getFilteredDaily(
state: ClaudeUsagePersistedState,
@@ -1,4 +1,4 @@
import { appendFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
@@ -6,17 +6,8 @@ import type { ClaudeUsagePersistedFile, ClaudeUsagePersistedState } from './type
import { scanClaudeUsageFiles } from './scanner'
import { readClaudeUsageScanFile } from './transcript-record-parser'
import { projectClaudeUsageScanFile } from './transcript-usage-projection'
import { parseClaudeUsageReport, serializeClaudeUsageReport } from './persisted-usage-report'
import {
readClaudeUsageSourceCache,
splitClaudeUsageCacheFile,
writeClaudeUsageSourceCache
} from './persisted-source-cache'
import {
compressClaudeUsageSourceText,
decodeClaudeUsageSourceText
} from './source-cache-compression'
import { readUsageSourceCache } from '../usage/usage-source-cache-file'
import { parseClaudeUsageReport } from './persisted-usage-report'
import { splitClaudeUsageCacheFile } from './persisted-source-cache'
const { discovered } = vi.hoisted(() => {
const discovered: { paths: string[] } = { paths: [] }
@@ -28,7 +19,6 @@ vi.mock('./transcript-file-discovery', () => ({
let directory: string
let transcript: string
let sourcePath: string
function row(input: number): string {
return `${JSON.stringify({
@@ -40,10 +30,6 @@ function row(input: number): string {
})}\n`
}
function ref() {
return { path: sourcePath, schemaVersion: 7, worktreeFingerprint: '[]', reuse: true }
}
function state(file: ClaudeUsagePersistedFile, schemaVersion = 7): ClaudeUsagePersistedState {
return {
schemaVersion,
@@ -70,103 +56,12 @@ async function fixture(large = true): Promise<ClaudeUsagePersistedFile> {
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-claude-source-authority-'))
transcript = join(directory, 'session.jsonl')
sourcePath = join(directory, 'usage-sources.json')
discovered.paths = [transcript]
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
it('uses a verified packed restart for exact append corrections', async () => {
const original = await fixture()
await writeClaudeUsageSourceCache(ref(), [original])
const cached = await readClaudeUsageSourceCache(ref())
expect(cached.sources).toEqual([original])
expect(cached.verifiedSources?.has(cached.sources[0])).toBe(true)
await appendFile(transcript, row(150))
const resumed = await scanClaudeUsageFiles(
[],
cached.sources,
undefined,
[],
cached.verifiedSources
)
const cold = await scanClaudeUsageFiles([], [], undefined, [])
expect(resumed.sessions).toEqual(cold.sessions)
expect(resumed.dailyAggregates).toEqual(cold.dailyAggregates)
expect(resumed.sessions[0]?.turnCount).toBe(1)
expect(resumed.sessions[0]?.totalInputTokens).toBe(150)
})
it('compresses large caches losslessly and resumes the same totals after restart', async () => {
const rows = Array.from({ length: 3000 }, (_, index) =>
row(index).replace('"request"', `"request-${index}"`)
).join('')
await writeFile(transcript, rows)
const cold = await scanClaudeUsageFiles([], [], undefined, [])
const mutableRef = ref()
const pending = writeClaudeUsageSourceCache(mutableRef, cold.processedFiles)
mutableRef.path = join(directory, 'different-cache.json')
mutableRef.worktreeFingerprint = '["different-workspace"]'
await pending
await expect(readFile(mutableRef.path)).rejects.toMatchObject({ code: 'ENOENT' })
const text = await readFile(sourcePath)
expect([...text.subarray(0, 2)]).toEqual([0x1f, 0x8b])
const decoded = await decodeClaudeUsageSourceText(text)
expect(Buffer.byteLength(text)).toBeLessThan(Buffer.byteLength(decoded))
const cached = await readClaudeUsageSourceCache(ref())
expect(cached.sources).toEqual(cold.processedFiles)
expect(cached.verifiedSources?.has(cached.sources[0])).toBe(true)
await appendFile(transcript, row(4000).replace('"request"', '"request-7"'))
const resumed = await scanClaudeUsageFiles(
[],
cached.sources,
undefined,
[],
cached.verifiedSources
)
expect(resumed).toEqual(await scanClaudeUsageFiles([], [], undefined, []))
// Older readers discard the optional resume cache; the separately stored report stays readable.
expect(await readUsageSourceCache(ref())).toEqual([])
})
it('keeps old plain caches readable and rejects compressed content with invalid integrity', async () => {
await writeFile(
transcript,
Array.from({ length: 3000 }, (_, index) =>
row(index).replace('"request"', `"request-${index}"`)
).join('')
)
const cold = await scanClaudeUsageFiles([], [], undefined, [])
await writeClaudeUsageSourceCache(ref(), cold.processedFiles)
const decoded = await decodeClaudeUsageSourceText(await readFile(sourcePath))
await writeFile(sourcePath, decoded)
expect((await readClaudeUsageSourceCache(ref())).sources).toEqual(cold.processedFiles)
const report = serializeClaudeUsageReport(state(cold.processedFiles[0]))
const damaged = JSON.parse(decoded)
damaged.sources[0].sessions[0].totalInputTokens++
await writeFile(sourcePath, await compressClaudeUsageSourceText(JSON.stringify(damaged)))
expect(await readClaudeUsageSourceCache(ref())).toEqual({ sources: [] })
expect(await parseClaudeUsageReport(report, JSON.parse(report))).toEqual(JSON.parse(report))
})
it('protects tiny source rollups despite their absent resume checkpoint', async () => {
const original = await fixture(false)
expect(original.parseResumeState).toBeNull()
await writeClaudeUsageSourceCache(ref(), [original])
const saved = JSON.parse(await readFile(sourcePath, 'utf8'))
saved.sources[0].sessions[0].totalInputTokens++
await writeFile(sourcePath, JSON.stringify(saved))
const rejected = await readClaudeUsageSourceCache(ref())
expect(rejected.sources).toEqual([])
const rebuilt = await scanClaudeUsageFiles([], rejected.sources, undefined, [])
expect(rebuilt.sessions).toEqual(original.sessions)
})
it('rejects same-stat direct scanner corruption without a raw-envelope proof', async () => {
const original = await fixture()
const checkpoint = original.parseResumeState
@@ -11,14 +11,14 @@ import {
type ClaudeUsageVerifiedSources
} from './persisted-projection-validation'
import { parseClaudeUsageReport, serializeClaudeUsageReport } from './persisted-usage-report'
import { sealUsageCacheJson, verifyUsageCacheJson } from '../usage/usage-cache-json-integrity'
import { sealUsageCacheJson, verifyUsageCacheJson } from '../usage/cache/usage-cache-json-integrity'
import {
usageSourceCachePath,
writeUsageSourceCacheData,
type UsageCacheSplitRequest,
type UsageCacheSplitResult,
type UsageSourceCacheRef
} from '../usage/usage-source-cache-file'
} from '../usage/cache/usage-source-cache-file'
const SOURCE_DOMAIN = 'claude-usage-sources-v1'
@@ -1,94 +0,0 @@
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import {
createPersistedUsageTestFixture,
type PersistedUsageTestFixture
} from './persisted-usage-test-fixture'
const { getPath } = vi.hoisted(() => ({ getPath: vi.fn<() => string>() }))
vi.mock('electron', () => ({ app: { getPath } }))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanClaudeUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
vi.mock('./transcript-file-discovery', () => ({
listClaudeTranscriptFiles: vi.fn(),
claudeProfileTranscriptDirs: vi.fn(() => [])
}))
let fixture: PersistedUsageTestFixture
beforeEach(async () => {
fixture = await createPersistedUsageTestFixture(getPath)
})
afterEach(async () => {
await fixture.cleanup()
})
it.each([
{ name: 'empty', checkpoint: {} },
{ name: 'offset-only', checkpoint: { parsedBytes: 20_000 } },
{
name: 'unsigned tuples',
checkpoint: {
parsedBytes: 20_000,
lineCount: 1,
boundaryDigest: 'boundary',
headDigest: 'head',
physicalFileId: '1:2',
ownedTokenMaxima: [[100, 10, 0, 20, 5, null]],
projections: [],
encounterOrder: []
}
}
])(
'preserves $name legacy checkpoints when enabling and flushing without a scan',
async ({ checkpoint }) => {
const file = {
path: join(fixture.directory, 'legacy.jsonl'),
mtimeMs: 1,
ctimeMs: 2,
physicalFileId: '1:2',
size: 20_000,
lineCount: 1,
sessions: [],
dailyAggregates: [],
ownedDedupeKeys: ['legacy-key'],
hasDeferredClaims: false,
parseResumeState: checkpoint
}
const state = {
schemaVersion: 6,
worktreeFingerprint: '[]',
processedFiles: [file],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: 1,
lastScanCompletedAt: 2,
lastScanError: null
}
}
await writeFile(fixture.reportPath, JSON.stringify(state))
const store = fixture.createStore()
await store.setEnabled(true)
await store.flush()
const report = JSON.parse(await readFile(fixture.reportPath, 'utf8'))
const sidecar = JSON.parse(await readFile(fixture.sourceRef.path, 'utf8'))
expect(report).not.toHaveProperty('processedFiles')
expect(report).not.toHaveProperty('usageIntegrity')
expect(report.schemaVersion).toBe(6)
expect(report.scanState).toEqual({ ...state.scanState, enabled: true })
expect(sidecar.schemaVersion).toBe(6)
expect(sidecar).not.toHaveProperty('usageIntegrity')
expect(sidecar.sources).toEqual([file])
expect(sidecar.sources[0]?.parseResumeState).not.toHaveProperty('tokenCodecVersion')
expect(sidecar.sources[0]?.parseResumeState).not.toHaveProperty('ownedTokenColumns')
expect(sidecar.sources[0]?.parseResumeState).not.toHaveProperty('projectionIntegrity')
expect(fixture.scanWorker).not.toHaveBeenCalled()
}
)
@@ -3,7 +3,7 @@ import {
hasClaudeUsageProtectedCheckpoint,
initializePersistedClaudeUsageProjections
} from './persisted-projection-validation'
import { sealUsageCacheJson, verifyUsageCacheJson } from '../usage/usage-cache-json-integrity'
import { sealUsageCacheJson, verifyUsageCacheJson } from '../usage/cache/usage-cache-json-integrity'
const REPORT_DOMAIN = 'claude-usage-report-v1'
@@ -12,14 +12,14 @@ import {
persistClaudeUsageSourceCache
} from './persisted-source-cache'
import { normalizeClaudeUsageSourceFiles } from './persisted-projection-validation'
import { usageSourceCachePath } from '../usage/usage-source-cache-file'
import { usageSourceCachePath } from '../usage/cache/usage-source-cache-file'
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
import { listClaudeTranscriptFiles } from './transcript-file-discovery'
import {
scanClaudeUsageFilesViaWorker,
splitUsageCacheFileViaWorker
} from '../usage/usage-scan-worker-spawn'
} from '../usage/scan-worker/usage-scan-worker-spawn'
export async function createPersistedUsageTestFixture(getPath: Mock<() => string>) {
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-persisted-usage-'))
@@ -1,11 +1,10 @@
import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import type * as NodeOs from 'node:os'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { scanClaudeUsageFiles } from './scanner'
import type { ClaudeUsagePersistedFile } from './types'
import { MIN_RESUMABLE_PREFIX_BYTES } from '../usage/jsonl-file-checkpoint'
const { homedirMock } = vi.hoisted(() => ({ homedirMock: vi.fn<() => string>() }))
@@ -52,9 +51,6 @@ function assistantRow(key: string, model: string, cacheReadTokens: number): stri
}
// Resume checkpoints require at least MIN_RESUMABLE_PREFIX_BYTES.
function padding(): string {
return `${JSON.stringify({ type: 'user', content: 'x'.repeat(4 * MIN_RESUMABLE_PREFIX_BYTES) })}\n`
}
async function scan(previous: ClaudeUsagePersistedFile[] = []): Promise<ScanResult> {
const { scanClaudeUsageFiles } = await import('./scanner')
@@ -86,38 +82,4 @@ describe('Claude usage long-context subsets', () => {
expect(byModel.get('claude-sonnet-4-6')?.longContext).toBeUndefined()
expect(result.sessions[0]?.locationBreakdown[0]?.longContext?.cacheReadTokens).toBe(100_000)
})
it('moves a resumed turn into the long-context subset when its usage grows past the threshold', async () => {
await writeFile(transcriptPath, padding() + assistantRow('turn', 'claude-haiku-5-5', 90_000))
const first = await scan()
expect(first.dailyAggregates[0]?.longContext).toBeUndefined()
const persisted: ClaudeUsagePersistedFile[] = JSON.parse(JSON.stringify(first.processedFiles))
expect(persisted[0]?.parseResumeState).toBeTruthy()
await appendFile(transcriptPath, assistantRow('turn', 'claude-haiku-5-5', 120_000))
const resumed = await scan(persisted)
const cold = await scan()
expect(resumed.dailyAggregates[0]?.longContext?.cacheReadTokens).toBe(120_000)
expect(resumed.dailyAggregates).toEqual(cold.dailyAggregates)
expect(resumed.sessions).toEqual(cold.sessions)
})
it('reparses a file saved by an older projection version', async () => {
await writeFile(transcriptPath, assistantRow('turn', 'claude-haiku-5-5', 120_000))
const first = await scan()
const legacy = first.processedFiles.map(
({ projectionVersion: _projectionVersion, ...file }) => ({
...file,
dailyAggregates: file.dailyAggregates.map(({ longContext: _longContext, ...row }) => row)
})
)
const rebuilt = await scan(legacy)
expect(rebuilt.dailyAggregates[0]?.longContext?.cacheReadTokens).toBe(120_000)
expect(rebuilt.processedFiles[0]?.projectionVersion).toBe(
first.processedFiles[0]?.projectionVersion
)
})
})
@@ -1,187 +0,0 @@
import { appendFile, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { projectClaudeUsageScanFile } from './transcript-usage-projection'
import { readClaudeUsageScanFile } from './transcript-record-parser'
import {
createPersistedUsageTestFixture,
type PersistedUsageTestFixture
} from './persisted-usage-test-fixture'
const { getPath } = vi.hoisted(() => ({ getPath: vi.fn<() => string>() }))
vi.mock('electron', () => ({ app: { getPath } }))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanClaudeUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
vi.mock('./transcript-file-discovery', () => ({
listClaudeTranscriptFiles: vi.fn(),
claudeProfileTranscriptDirs: vi.fn(() => [])
}))
let fixture: PersistedUsageTestFixture
const KEY_COUNT = 1024
beforeEach(async () => {
fixture = await createPersistedUsageTestFixture(getPath)
})
afterEach(async () => {
await fixture.cleanup()
})
it('persists every distinct maximum within the sidecar byte budget and resumes an older indented cache', async () => {
await writeFile(
fixture.transcript,
Array.from({ length: KEY_COUNT }, (_, index) => fixture.row(index)).join('')
)
const initial = await fixture.project()
const store = fixture.createStore()
await store.setEnabled(true)
await store.refresh(true)
await store.flush()
const report = JSON.parse(await readFile(fixture.reportPath, 'utf8'))
const sourceText = await readFile(fixture.sourceRef.path, 'utf8')
const sidecar = JSON.parse(sourceText)
expect(report.schemaVersion).toBe(7)
expect(report).not.toHaveProperty('processedFiles')
expect(report.sessions).toEqual(initial.sessions)
expect(Buffer.byteLength(sourceText)).toBeLessThan(100_000)
expect(sidecar.schemaVersion).toBe(7)
expect(sidecar.sources[0]?.parseResumeState).not.toHaveProperty('ownedTokenMaxima')
expect(sidecar.sources[0]?.parseResumeState.tokenCodecVersion).toBe(1)
expect(await fixture.decodedSources()).toEqual([initial])
expect(initial.ownedDedupeKeys).toHaveLength(KEY_COUNT)
expect(initial.parseResumeState?.ownedTokenMaxima).toHaveLength(KEY_COUNT)
await rm(fixture.sourceRef.path)
await writeFile(fixture.reportPath, JSON.stringify(fixture.state([initial]), null, 2))
await appendFile(fixture.transcript, fixture.row(0, true))
const restarted = fixture.createStore()
await restarted.whenLoaded()
const migrated = JSON.parse(await readFile(fixture.sourceRef.path, 'utf8'))
expect(migrated.schemaVersion).toBe(7)
expect(migrated.sources[0]?.parseResumeState.tokenCodecVersion).toBe(1)
expect(await fixture.decodedSources()).toEqual([initial])
await restarted.refresh(true)
await restarted.flush()
const afterRestart = JSON.parse(await readFile(fixture.reportPath, 'utf8'))
const cold = await fixture.project()
expect(afterRestart.schemaVersion).toBe(7)
expect(afterRestart).not.toHaveProperty('processedFiles')
expect(afterRestart.sessions).toEqual(cold.sessions)
expect(afterRestart.dailyAggregates).toEqual(cold.dailyAggregates)
expect(await fixture.decodedSources()).toEqual([cold])
expect(cold.ownedDedupeKeys).toHaveLength(KEY_COUNT)
expect(cold.sessions[0]?.sessionId).toBe('original-session')
expect(cold.sessions[0]?.turnCount).toBe(KEY_COUNT)
expect(cold.parseResumeState?.ownedTokenMaxima[0]).toEqual([2000, 55, 5, 15, 3, 0])
})
it('hides invalid saved totals until a refresh rebuilds the owner and its deferred fork', async () => {
const initial = await fixture.writeResumable()
const forkPath = join(fixture.directory, 'fork.jsonl')
await writeFile(forkPath, await readFile(fixture.transcript, 'utf8'))
fixture.transcripts.push(forkPath)
const fork = await projectClaudeUsageScanFile(
await readClaudeUsageScanFile(forkPath),
new Map(),
() => false
)
expect(fork.hasDeferredClaims).toBe(true)
const state = fixture.state([initial, fork])
const maxima = initial.parseResumeState?.ownedTokenMaxima[0]
if (!maxima) {
throw new Error('Expected a resumable owner fixture.')
}
maxima[0]--
await writeFile(fixture.reportPath, JSON.stringify(state))
const store = fixture.createStore()
await store.whenLoaded()
expect(await fixture.decodedSources()).toEqual([])
expect(store.getSnapshot('all', 'all')).toMatchObject({
summary: { sessions: 0, turns: 0, inputTokens: 0 },
daily: [],
recentSessions: [],
scanState: {
enabled: true,
hasAnyClaudeData: false,
lastScanCompletedAt: null,
lastScanError: 'Saved usage totals could not be validated. Refresh to rebuild them.'
}
})
await store.refresh(false)
await store.flush()
expect(store.getSnapshot('all', 'all')).toMatchObject({
summary: { sessions: 1, turns: 1, inputTokens: 1000 },
scanState: { enabled: true, hasAnyClaudeData: true, lastScanError: null }
})
const rebuilt = await fixture.decodedSources()
expect(rebuilt).toHaveLength(2)
expect(rebuilt.flatMap((file) => file.ownedDedupeKeys)).toHaveLength(1)
expect(rebuilt.some((file) => file.hasDeferredClaims)).toBe(true)
})
it('reconstructs duplicated global totals from validated file projections on load', async () => {
const initial = await fixture.writeResumable()
const state = fixture.state([initial])
const session = state.sessions[0]
const daily = state.dailyAggregates[0]
if (!session || !daily) {
throw new Error('Expected saved global totals.')
}
session.totalInputTokens++
daily.inputTokens++
await writeFile(fixture.reportPath, JSON.stringify(state))
const store = fixture.createStore()
await store.whenLoaded()
expect(store.getSnapshot('all', 'all')).toMatchObject({
summary: { sessions: 1, turns: 1, inputTokens: 1000 },
daily: [{ inputTokens: 1000 }],
scanState: { enabled: true, lastScanCompletedAt: state.scanState.lastScanCompletedAt }
})
await store.refresh(false)
expect(fixture.scanWorker).not.toHaveBeenCalled()
})
it.each(['nonArray', 'nullEntry', 'unsignedRollup'] as const)(
'preserves tracking when malformed %s cache records cannot be validated',
async (malformation) => {
const initial = await fixture.writeResumable()
const state = fixture.state([initial])
const malformedFiles =
malformation === 'nonArray'
? null
: malformation === 'nullEntry'
? [initial, null]
: [
initial,
{
...initial,
parseResumeState: null,
sessions: [{ ...initial.sessions[0], locationBreakdown: null }]
}
]
await writeFile(
fixture.reportPath,
JSON.stringify({ ...state, processedFiles: malformedFiles })
)
const store = fixture.createStore()
await store.whenLoaded()
expect(store.getSnapshot('all', 'all')).toMatchObject({
summary: { sessions: 0, turns: 0 },
daily: [],
scanState: {
enabled: true,
hasAnyClaudeData: false,
lastScanCompletedAt: null,
lastScanError: 'Saved usage totals could not be validated. Refresh to rebuild them.'
}
})
}
)
@@ -1,127 +0,0 @@
import { appendFile, readFile, writeFile } from 'node:fs/promises'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { UsageCacheSnapshotWriter } from '../usage/usage-cache-snapshot-writer'
import { readClaudeUsageSourceCache, writeClaudeUsageSourceCache } from './persisted-source-cache'
import { serializeClaudeUsageReport } from './persisted-usage-report'
import {
createPersistedUsageTestFixture,
type PersistedUsageTestFixture
} from './persisted-usage-test-fixture'
const { getPath } = vi.hoisted(() => ({ getPath: vi.fn<() => string>() }))
vi.mock('electron', () => ({ app: { getPath } }))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanClaudeUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
vi.mock('./transcript-file-discovery', () => ({
listClaudeTranscriptFiles: vi.fn(),
claudeProfileTranscriptDirs: vi.fn(() => [])
}))
let fixture: PersistedUsageTestFixture
beforeEach(async () => {
fixture = await createPersistedUsageTestFixture(getPath)
})
afterEach(async () => {
await fixture.cleanup()
})
it('keeps a newer source generation when an earlier report migration could not be written', async () => {
const original = await fixture.writeResumable()
const inline = fixture.state([original])
await writeFile(fixture.reportPath, JSON.stringify(inline))
vi.spyOn(UsageCacheSnapshotWriter.prototype, 'write').mockRejectedValueOnce(
new Error('report migration write failed')
)
const first = fixture.createStore()
await first.whenLoaded()
expect(JSON.parse(await readFile(fixture.reportPath, 'utf8'))).toEqual(inline)
expect(await fixture.decodedSources()).toEqual([original])
await appendFile(fixture.transcript, fixture.row(0, true))
const updated = await fixture.project()
await writeClaudeUsageSourceCache(fixture.sourceRef, [updated])
const warmSourceText = await readFile(fixture.sourceRef.path, 'utf8')
const restarted = fixture.createStore()
await restarted.whenLoaded()
expect(await readFile(fixture.sourceRef.path, 'utf8')).toBe(warmSourceText)
expect(await fixture.decodedSources()).toEqual([updated])
expect(restarted.getSnapshot('all', 'all').summary.inputTokens).toBe(1000)
await restarted.refresh(true)
await restarted.flush()
expect(await fixture.decodedSources()).toEqual([updated])
expect(restarted.getSnapshot('all', 'all').summary).toMatchObject({ turns: 1, inputTokens: 2000 })
})
it('preserves a disabled inline cache through worker failure and preference writes', async () => {
const original = await fixture.writeResumable()
const inline = fixture.state([original])
inline.scanState.enabled = false
await writeFile(fixture.reportPath, JSON.stringify(inline))
fixture.splitWorker.mockRejectedValue(new Error('worker unavailable'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = fixture.createStore()
await store.whenLoaded()
expect(store.getSnapshot('all', 'all')).toMatchObject({
summary: { inputTokens: 1000 },
scanState: { enabled: false }
})
await store.setEnabled(true)
await store.flush()
expect(JSON.parse(await readFile(fixture.reportPath, 'utf8'))).toEqual({
...inline,
scanState: { ...inline.scanState, enabled: true }
})
expect(fixture.scanWorker).not.toHaveBeenCalled()
expect((await readClaudeUsageSourceCache(fixture.sourceRef)).sources).toEqual([])
await store.refresh(true)
await store.flush()
const report = JSON.parse(await readFile(fixture.reportPath, 'utf8'))
expect(report.schemaVersion).toBe(7)
expect(report).not.toHaveProperty('processedFiles')
expect(report.scanState.enabled).toBe(true)
expect(await fixture.decodedSources()).toEqual([original])
})
it('rejects an unsigned schema7 tiny-file source without hiding its valid report', async () => {
await writeFile(fixture.transcript, fixture.row(0))
const original = await fixture.project()
expect(original.parseResumeState).toBeNull()
await writeClaudeUsageSourceCache(fixture.sourceRef, [original])
await writeFile(fixture.reportPath, serializeClaudeUsageReport(fixture.state([original], 7)))
const unsigned = JSON.parse(await readFile(fixture.sourceRef.path, 'utf8'))
delete unsigned.usageIntegrity
unsigned.sources[0].sessions[0].totalInputTokens = 9999
unsigned.sources[0].dailyAggregates[0].inputTokens = 9999
await writeFile(fixture.sourceRef.path, JSON.stringify(unsigned))
expect((await readClaudeUsageSourceCache(fixture.sourceRef)).sources).toEqual([])
const store = fixture.createStore()
await store.whenLoaded()
expect(store.getSnapshot('all', 'all').summary.inputTokens).toBe(1000)
await store.refresh(true)
await store.flush()
expect(await fixture.decodedSources()).toEqual([original])
expect(store.getSnapshot('all', 'all').summary).toMatchObject({ turns: 1, inputTokens: 1000 })
})
it('rejects a source cache attributed to a different worktree fingerprint', async () => {
const original = await fixture.writeResumable()
await writeClaudeUsageSourceCache(
{ ...fixture.sourceRef, worktreeFingerprint: '["other-worktree"]' },
[original]
)
await writeFile(fixture.reportPath, serializeClaudeUsageReport(fixture.state([original], 7)))
expect((await readClaudeUsageSourceCache(fixture.sourceRef)).sources).toEqual([])
const store = fixture.createStore()
await store.whenLoaded()
await store.refresh(true)
await store.flush()
expect(await fixture.decodedSources()).toEqual([original])
expect(store.getSnapshot('all', 'all').summary.inputTokens).toBe(1000)
})
-162
View File
@@ -1,162 +0,0 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClaudeUsagePersistedState } from './types'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanClaudeUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
import { scanClaudeUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
function createBackingStore(): ConstructorParameters<typeof ClaudeUsageStore>[0] {
return {
getRepos: () => [],
getAllWorktreeMeta: () => ({})
}
}
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore(createBackingStore())
;(store as unknown as { state: ClaudeUsagePersistedState }).state = {
schemaVersion: 1,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
},
...state
}
return store
}
function createWorktreeUsageSession(worktreeId: string) {
const tokens = {
turnCount: 1,
inputTokens: 1000,
outputTokens: 500,
cacheReadTokens: 200,
cacheWriteTokens: 100,
cacheWrite1hTokens: 0
}
return {
sessionId: 'session-1',
firstTimestamp: '2026-04-09T15:00:00.000Z',
lastTimestamp: '2026-04-09T15:05:00.000Z',
model: 'claude-sonnet-4-6',
lastCwd: '/workspace/repo-a',
lastGitBranch: 'feature/a',
primaryWorktreeId: worktreeId,
primaryRepoId: 'repo-1',
totalInputTokens: 1000,
totalOutputTokens: 500,
totalCacheReadTokens: 200,
totalCacheWriteTokens: 100,
totalCacheWrite1hTokens: 0,
...tokens,
locationBreakdown: [
{
locationKey: `worktree:${worktreeId}`,
projectLabel: 'Repo A',
repoId: 'repo-1',
worktreeId,
...tokens
}
]
}
}
describe('ClaudeUsageStore', () => {
let tempUserData: string
beforeEach(() => {
tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-'))
getPathMock.mockReturnValue(tempUserData)
initClaudeUsagePath()
vi.mocked(scanClaudeUsageFilesViaWorker).mockReset()
vi.mocked(scanClaudeUsageFilesViaWorker).mockResolvedValue({
sessions: [],
dailyAggregates: []
})
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-09T12:00:00.000-04:00'))
})
afterEach(() => {
vi.useRealTimers()
rmSync(tempUserData, { recursive: true, force: true })
})
it('joins a scan that is already in flight when the run finished before it started', async () => {
const worktreeId = 'repo-1::/workspace/repo-a'
const store = createStoreWithState({
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
// Prime the worktree fingerprint so an unforced refresh can return early.
vi.mocked(scanClaudeUsageFilesViaWorker).mockResolvedValue({
sessions: [],
dailyAggregates: []
})
await store.refresh(true)
const completedAt = Date.now() + 10_000
vi.setSystemTime(new Date(completedAt + 1_000))
let startScan = () => {}
let finishScan = () => {}
const scanStarted = new Promise<void>((resolve) => {
startScan = resolve
})
const scanFinished = new Promise<void>((resolve) => {
finishScan = resolve
})
vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementationOnce(async () => {
startScan()
await scanFinished
return {
sessions: [createWorktreeUsageSession(worktreeId)],
dailyAggregates: []
}
})
const inFlight = store.refresh(true)
await scanStarted
const usage = store.getAutomationRunUsage({
worktreeId,
terminalSessionId: 'session-1',
startedAt: completedAt - 60_000,
completedAt
})
finishScan()
await inFlight
expect((await usage).status).toBe('known')
expect((await usage).providerSessionId).toBe('session-1')
})
})
+3 -3
View File
@@ -1,4 +1,4 @@
import { claudeTokenSessions } from '../usage/agent-token-usage'
import { claudeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
import { app } from 'electron'
import { join } from 'node:path'
import type {
@@ -17,8 +17,8 @@ import type { ClaudeUsagePersistedState } from './types'
import {
scanClaudeUsageFilesViaWorker,
splitUsageCacheFileViaWorker
} from '../usage/usage-scan-worker-spawn'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
} from '../usage/scan-worker/usage-scan-worker-spawn'
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { buildBreakdown, buildDaily, buildSummary } from './claude-usage-report-aggregation'
import { buildRecentSessions } from './claude-usage-session-rows'
import type { AutomationUsageLookupInput } from './claude-usage-automation-attribution'
@@ -1,5 +1,5 @@
import { basename } from 'node:path'
import { readJsonlFileSnapshot } from '../usage/jsonl-file-snapshot'
import { readJsonlFileSnapshot } from '../usage/source-input/jsonl/jsonl-file-snapshot'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type {
@@ -7,7 +7,7 @@ import type {
ClaudeUsageParsedTurn,
ClaudeUsageProcessedFile
} from './types'
import { readJsonlLinesFromOffset } from '../usage/jsonl-line-offsets'
import { readJsonlLinesFromOffset } from '../usage/source-input/jsonl/jsonl-line-offsets'
import {
buildJsonlFileCheckpoint,
jsonlPhysicalFileId,
@@ -15,7 +15,7 @@ import {
resolveJsonlFileCheckpoint,
validateJsonlFileReader,
type JsonlFileCheckpoint
} from '../usage/jsonl-file-checkpoint'
} from '../usage/source-input/jsonl/jsonl-file-checkpoint'
type ClaudeUsageSourceRecord = {
type?: string
+1 -1
View File
@@ -106,7 +106,7 @@ export type ClaudeUsageAttributedTurn = ClaudeUsageParsedTurn & {
repoId: string | null
worktreeId: string | null
}
import type { JsonlFileCheckpoint } from '../usage/jsonl-file-checkpoint'
import type { JsonlFileCheckpoint } from '../usage/source-input/jsonl/jsonl-file-checkpoint'
export type ClaudeUsageTokenTotals = Pick<
ClaudeUsageParsedTurn,
@@ -1,7 +1,7 @@
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
import type { CodexUsagePersistedState } from './types'
import { estimateCostUsd } from './codex-usage-cost-estimate'
import { shouldForceAutomationUsageScan } from '../usage/automation-usage-scan-forcing'
import { shouldForceAutomationUsageScan } from '../usage/provider-lifecycle/automation-usage-scan-forcing'
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
@@ -1,13 +1,16 @@
import { basename } from 'node:path'
import { readJsonlFileSnapshot, type JsonlFileSnapshot } from '../usage/jsonl-file-snapshot'
import { readJsonlLinesFromOffset } from '../usage/jsonl-line-offsets'
import {
readJsonlFileSnapshot,
type JsonlFileSnapshot
} from '../usage/source-input/jsonl/jsonl-file-snapshot'
import { readJsonlLinesFromOffset } from '../usage/source-input/jsonl/jsonl-line-offsets'
import {
openJsonlFileReader,
validateJsonlFileReader,
jsonlPhysicalFileId
} from '../usage/jsonl-file-checkpoint'
} from '../usage/source-input/jsonl/jsonl-file-checkpoint'
import { attributeCodexUsageEvent } from './codex-usage-event-attribution'
import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import type { UsageWorktreeResolver } from '../usage/worktree-attribution/usage-worktree-resolver'
import { parseCodexUsageRecord, type CodexUsageParseContext } from './codex-usage-record-parser'
import { codexUsageAggregation } from './codex-usage-aggregation'
import {
@@ -4,9 +4,12 @@ import {
buildJsonlFileCheckpoint,
resolveJsonlFileCheckpoint,
type JsonlFileReader
} from '../usage/jsonl-file-checkpoint'
} from '../usage/source-input/jsonl/jsonl-file-checkpoint'
export { MIN_RESUMABLE_PREFIX_BYTES, isResumablePrefixLength } from '../usage/jsonl-file-checkpoint'
export {
MIN_RESUMABLE_PREFIX_BYTES,
isResumablePrefixLength
} from '../usage/source-input/jsonl/jsonl-file-checkpoint'
export async function buildCodexRolloutResumeState(
filePath: string,
@@ -7,7 +7,7 @@ import {
} from '../codex/configuration/homes/codex-home-paths'
import { getCodexAccountHomeSessionDirectories } from '../codex/history/rollouts/codex-account-home-discovery'
import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/history/bridges/codex-session-bridge'
import { normalizeFsPath } from '../usage/usage-path-comparison'
import { normalizeFsPath } from '../usage/worktree-attribution/usage-path-comparison'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
const YIELD_EVERY_DISCOVERY_ENTRIES = 100
@@ -1,4 +1,4 @@
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import { createUsageEventAggregation } from '../usage/rollups/usage-event-aggregation'
import type { CodexLongContextTokens, CodexUsageAttributedEvent } from './types'
type CodexUsageMetric = { hasInferredPricing: boolean } & CodexLongContextTokens
@@ -1,6 +1,6 @@
import { attributeUsageEvent } from '../usage/usage-event-attribution'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import { attributeUsageEvent } from '../usage/worktree-attribution/usage-event-attribution'
import type { UsageScanWorktreeRef } from '../usage/provider-lifecycle/usage-provider-contract'
import type { UsageWorktreeResolver } from '../usage/worktree-attribution/usage-worktree-resolver'
import type { CodexUsageAttributedEvent, CodexUsageParsedEvent } from './types'
export type CodexUsageWorktreeRef = UsageScanWorktreeRef
@@ -1,26 +1,5 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createStoreWithState, setupCodexUsageStoreEnv } from './store-test-harness'
import { describe, expect, it } from 'vitest'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanCodexUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import { parseCodexUsageFile } from './codex-rollout-file-parse'
import { parseCodexUsageRecord, type CodexUsageParseContext } from './codex-usage-record-parser'
type Usage = { input: number; cached: number; output: number }
@@ -125,73 +104,3 @@ describe('per-request long-context classification', () => {
})
})
})
describe('long-context cost across rollups', () => {
setupCodexUsageStoreEnv(getPathMock)
let rolloutDir: string | null = null
afterEach(() => {
if (rolloutDir) {
rmSync(rolloutDir, { recursive: true, force: true })
rolloutDir = null
}
})
it('prices a mixed two-day rollout per request, and the summary matches the breakdown', async () => {
rolloutDir = mkdtempSync(join(tmpdir(), 'orca-codex-long-context-'))
const rolloutPath = join(rolloutDir, 'rollout-session-1.jsonl')
const shortRequest: Usage = { input: 200_000, cached: 100_000, output: 10_000 }
const requests = [
{
timestamp: '2026-04-08T12:00:00.000Z',
usage: { input: 300_000, cached: 150_000, output: 100_000 }
},
...Array.from({ length: 10 }, (_, index) => ({
timestamp: `2026-04-0${index < 5 ? 8 : 9}T12:${String(10 + index).padStart(2, '0')}:00.000Z`,
usage: shortRequest
}))
]
const lines = [
JSON.stringify({
type: 'session_meta',
payload: { id: 'session-1', cwd: '/workspace/repo' }
}),
JSON.stringify({
type: 'turn_context',
payload: { cwd: '/workspace/repo', model: 'gpt-6-astra' }
}),
...requestLines(requests)
]
writeFileSync(rolloutPath, `${lines.join('\n')}\n`)
const parsed = await parseCodexUsageFile(rolloutPath, await createUsageWorktreeResolver([]))
const store = createStoreWithState({
sessions: parsed.sessions,
dailyAggregates: parsed.dailyAggregates
})
expect(parsed.dailyAggregates).toHaveLength(2)
expect(parsed.sessions[0]?.locationModelBreakdown[0]).toMatchObject({
longContextInputTokens: 300_000,
longContextCachedInputTokens: 150_000,
longContextOutputTokens: 100_000
})
const summary = await store.getSummary('all', '30d')
const breakdown = await store.getBreakdown('all', '30d', 'model')
// Long request: 0.15M*$20 + 0.15M*$2 + 0.1M*$75 = 10.8.
// Ten short requests: 10 * (0.1M*$10 + 0.1M*$1 + 0.01M*$50) = 16.
expect(summary.estimatedCostUsd).toBeCloseTo(26.8, 9)
expect(breakdown).toHaveLength(1)
expect(breakdown[0]?.estimatedCostUsd).toBeCloseTo(26.8, 9)
store['state'].scanState.enabled = true
vi.spyOn(store, 'refresh').mockResolvedValue({ ...store.getScanState(), lastScanError: null })
const runUsage = await store.getAutomationRunUsage({
worktreeId: 'unrelated-worktree',
terminalSessionId: 'session-1',
startedAt: Date.parse('2026-04-08T11:59:00.000Z'),
completedAt: Date.parse('2026-04-09T12:30:00.000Z')
})
expect(runUsage.estimatedCostUsd).toBeCloseTo(26.8, 9)
})
})
+2 -2
View File
@@ -1,5 +1,5 @@
import type { UsageProvider } from '../usage/usage-provider-contract'
import { scanCodexUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
import type { UsageProvider } from '../usage/provider-lifecycle/usage-provider-contract'
import { scanCodexUsageFilesViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import type { CodexUsageDailyAggregate, CodexUsageSession } from './types'
// Rescan older caches: v5 deduplicates forks across sessions; v6 adds per-request long-context counts;
@@ -1,4 +1,4 @@
import { extractString } from '../usage/usage-record-coercion'
import { extractString } from '../usage/source-input/usage-record-coercion'
import {
buildCodexUsageEventKey,
normalizeRawUsage,
@@ -1,4 +1,4 @@
import { highestUsageKey } from '../usage/highest-usage-key'
import { highestUsageKey } from '../usage/rollups/highest-usage-key'
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
@@ -1,6 +1,9 @@
import type { CodexUsageRange, CodexUsageScope } from '../../shared/codex-usage-types'
import type { CodexUsagePersistedState } from './types'
import { filterUsageDaily, filterUsageSessions } from '../usage/usage-scope-filters'
import {
filterUsageDaily,
filterUsageSessions
} from '../usage/reporting/range-query/usage-scope-filters'
export type ScopedCodexUsageModelRow = {
modelKey: string
@@ -1,4 +1,4 @@
import { ensureNumber } from '../usage/usage-record-coercion'
import { ensureNumber } from '../usage/source-input/usage-record-coercion'
export type CodexUsageRawUsage = {
inputTokens: number
@@ -1,25 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import type * as NodeOs from 'node:os'
import type * as NodeFs from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import type * as JsonlLineOffsets from '../usage/jsonl-line-offsets'
import { join } from 'node:path'
const { getPathMock, homedirMock, streamReads, onStreamOpen, parsingPaths } = vi.hoisted(() => {
const streamReads: { path: string; bytes: number; start: number; digest: boolean }[] = []
// Inject filesystem mutations between parse reads.
const onStreamOpen: { current: ((path: string, digest: boolean) => void) | null } = {
current: null
}
return {
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>(),
streamReads,
onStreamOpen,
parsingPaths: new Set<string>()
}
})
const { getPathMock, homedirMock } = vi.hoisted(() => ({
getPathMock: vi.fn<(name: string) => string>(),
homedirMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: {
@@ -35,113 +23,10 @@ vi.mock('node:os', async () => {
}
})
// Count both parsing and checkpoint reads for the scan I/O assertions.
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
createReadStream: (
path: Parameters<typeof actual.createReadStream>[0],
options?: Parameters<typeof actual.createReadStream>[1]
) => {
const filePath = String(path)
const range = typeof options === 'object' && options !== null ? options : {}
const start = range.start ?? 0
const read = { path: filePath, bytes: 0, start, digest: !parsingPaths.has(filePath) }
streamReads.push(read)
onStreamOpen.current?.(filePath, read.digest)
const stream = actual.createReadStream(path, options)
stream.on('data', (chunk: string | Buffer) => {
read.bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
})
return stream
}
}
})
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof NodeFsPromises>('node:fs/promises')
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args)
const createStream = handle.createReadStream.bind(handle)
const readWindow = handle.read.bind(handle)
let streamActive = false
vi.spyOn(handle, 'read').mockImplementation(async (...readArgs) => {
if (streamActive) {
return readWindow(...readArgs)
}
const filePath = String(args[0])
const digest = !parsingPaths.has(filePath)
onStreamOpen.current?.(filePath, digest)
const result = await readWindow(...readArgs)
const position = readArgs.at(-1)
streamReads.push({
path: filePath,
bytes: result.bytesRead,
start: typeof position === 'number' ? position : 0,
digest
})
return result
})
vi.spyOn(handle, 'createReadStream').mockImplementation((options) => {
const filePath = String(args[0])
const read = {
path: filePath,
bytes: 0,
start: options?.start ?? 0,
digest: !parsingPaths.has(filePath)
}
streamReads.push(read)
onStreamOpen.current?.(filePath, read.digest)
streamActive = true
const stream = createStream(options)
const finish = (): void => {
streamActive = false
}
stream.once('end', finish)
stream.once('close', finish)
stream.on('data', (chunk: string | Buffer) => {
read.bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
})
return stream
})
return handle
}
}
})
vi.mock('../usage/jsonl-line-offsets', async () => {
const actual = await vi.importActual<typeof JsonlLineOffsets>('../usage/jsonl-line-offsets')
return {
...actual,
readJsonlLinesFromOffset: async function* (
...args: Parameters<typeof actual.readJsonlLinesFromOffset>
) {
parsingPaths.add(args[0])
try {
yield* actual.readJsonlLinesFromOffset(...args)
} finally {
parsingPaths.delete(args[0])
}
}
}
})
import {
mkdirSync,
mkdtempSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
appendFileSync
} from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { scanCodexUsageFiles } from './scanner'
/** Mirrors the checkpoint window in jsonl-file-checkpoint.ts. */
const BOUNDARY_WINDOW_BYTES = 4096
/** Clear the resumable-prefix floor so tests actually exercise incremental parsing. */
const RESUMABLE_RECORDS = 40
@@ -179,26 +64,6 @@ function usageRecord(timestamp: string, inputTokens: number, totalInputTokens: n
})}\n`
}
function totalOnlyUsageRecord(timestamp: string, totalInputTokens: number): string {
return `${JSON.stringify({
timestamp,
type: 'event_msg',
payload: {
type: 'token_count',
info: {
model: 'gpt-5-codex',
total_token_usage: {
input_tokens: totalInputTokens,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: totalInputTokens
}
}
}
})}\n`
}
function sessionMeta(id: string): string {
return `${JSON.stringify({
type: 'session_meta',
@@ -218,11 +83,6 @@ function usageRecordRange(from: number, to: number): string {
}
/** The iterator marks parse reads to distinguish them from bounded digest reads. */
function parseReadOffsets(filePath: string): number[] {
return streamReads
.filter((entry) => entry.path === filePath && !entry.digest)
.map((entry) => entry.start)
}
function recordedResumeOffset(
files: { path: string; parseResumeState?: { parsedBytes: number } | null }[],
@@ -232,9 +92,6 @@ function recordedResumeOffset(
}
/** Session attribution catches context corruption that token totals alone can miss. */
function eventCountsBySession(sessions: { sessionId: string; eventCount: number }[]): unknown[] {
return sessions.map((session) => [session.sessionId, session.eventCount]).sort()
}
function totalTokens(aggregates: { totalTokens: number }[]): number {
return aggregates.reduce((total, aggregate) => total + aggregate.totalTokens, 0)
@@ -255,9 +112,6 @@ beforeEach(() => {
})
sessionsDir = join(userDataDir, 'codex-runtime-home', 'home', 'sessions')
mkdirSync(sessionsDir, { recursive: true })
streamReads.length = 0
onStreamOpen.current = null
parsingPaths.clear()
})
afterEach(() => {
@@ -277,64 +131,6 @@ afterEach(() => {
})
describe('scanCodexUsageFiles incremental append', () => {
it('carries cumulative token totals across the resume boundary', async () => {
const rolloutPath = join(sessionsDir, 'rollout-cumulative.jsonl')
writeFileSync(
rolloutPath,
[
sessionMeta('session-cumulative'),
usageRecordRange(0, RESUMABLE_RECORDS),
totalOnlyUsageRecord('2026-05-26T13:00:00.000Z', RESUMABLE_RECORDS + 100),
totalOnlyUsageRecord('2026-05-26T13:01:00.000Z', RESUMABLE_RECORDS + 250)
].join(''),
'utf-8'
)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS + 250)
const resumeOffset = recordedResumeOffset(first.processedFiles, rolloutPath)
appendFileSync(
rolloutPath,
totalOnlyUsageRecord('2026-05-26T13:02:00.000Z', RESUMABLE_RECORDS + 400),
'utf-8'
)
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(parseReadOffsets(rolloutPath)).toEqual([resumeOffset])
const fromScratch = await scanCodexUsageFiles([], [])
expect(totalTokens(second.dailyAggregates)).toBe(RESUMABLE_RECORDS + 400)
expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates)
})
it('tracks byte offsets through CRLF line endings', async () => {
const rolloutPath = join(sessionsDir, 'rollout-crlf.jsonl')
const toCrlf = (text: string): string => text.replaceAll('\n', '\r\n')
writeFileSync(
rolloutPath,
toCrlf(`${sessionMeta('session-crlf')}${usageRecordRange(0, RESUMABLE_RECORDS)}`),
'utf-8'
)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS)
const resumeOffset = recordedResumeOffset(first.processedFiles, rolloutPath)
appendFileSync(
rolloutPath,
toCrlf(usageRecordRange(RESUMABLE_RECORDS, RESUMABLE_RECORDS + 3)),
'utf-8'
)
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(parseReadOffsets(rolloutPath)).toEqual([resumeOffset])
const fromScratch = await scanCodexUsageFiles([], [])
expect(totalTokens(second.dailyAggregates)).toBe(RESUMABLE_RECORDS + 3)
expect(second.sessions).toEqual(fromScratch.sessions)
})
it('falls back to a full reparse when a rollout is truncated', async () => {
const rolloutPath = join(sessionsDir, 'rollout-truncated.jsonl')
writeFileSync(
@@ -354,292 +150,8 @@ describe('scanCodexUsageFiles incremental append', () => {
})
// Truncate after checkpoint validation to exercise the parse-time race.
it('reparses from the start when a rollout shrinks during its parse read', async () => {
const rolloutPath = join(sessionsDir, 'rollout-shrinks-mid-read.jsonl')
writeFileSync(
rolloutPath,
`${sessionMeta('session-shrinker')}${usageRecordRange(0, RESUMABLE_RECORDS)}`
)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS)
const resumeOffset = recordedResumeOffset(first.processedFiles, rolloutPath)
appendFileSync(rolloutPath, usageRecordRange(RESUMABLE_RECORDS, RESUMABLE_RECORDS + 2), 'utf-8')
const truncated = `${sessionMeta('session-shrinker')}${usageRecordRange(0, 5)}`
onStreamOpen.current = (path, digest) => {
if (path === rolloutPath && !digest) {
onStreamOpen.current = null
writeFileSync(path, truncated, 'utf-8')
}
}
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(onStreamOpen.current).toBeNull()
expect(parseReadOffsets(rolloutPath)).toEqual([resumeOffset, 0])
expect(totalTokens(second.dailyAggregates)).toBe(5)
expect(second.sessions[0]?.eventCount).toBe(5)
const third = await scanCodexUsageFiles([], second.processedFiles)
expect(totalTokens(third.dailyAggregates)).toBe(5)
})
// Token totals can match despite a stale session context; compare attribution with a cold scan.
/** Preserve the boundary window while changing leading records at the same file length. */
function prefixSwapPair(sessionId: string): { original: string; replacement: string } {
const sharedSuffix = usageRecordRange(20, 40)
expect(sharedSuffix.length).toBeGreaterThan(BOUNDARY_WINDOW_BYTES)
let swappedPrefix = ''
for (let index = 0; index < 20; index++) {
const minute = String(index % 60).padStart(2, '0')
swappedPrefix += usageRecord(`2026-05-26T12:${minute}:00.000Z`, 3, index + 1)
}
const original = `${sessionMeta(sessionId)}${usageRecordRange(0, 20)}${sharedSuffix}`
const replacement = `${sessionMeta(sessionId)}${swappedPrefix}${sharedSuffix}`
expect(replacement.length).toBe(original.length)
return { original, replacement }
}
it('falls back to a full reparse when the records before the offset changed', async () => {
const rolloutPath = join(sessionsDir, 'rollout-tail-swap.jsonl')
const swappedFrom = RESUMABLE_RECORDS
const swappedTo = RESUMABLE_RECORDS + 10
const sharedHead = `${sessionMeta('session-tail')}${usageRecordRange(0, swappedFrom)}`
expect(sharedHead.length).toBeGreaterThan(BOUNDARY_WINDOW_BYTES)
let heavierTail = ''
for (let index = swappedFrom; index < swappedTo; index++) {
const minute = String(index % 60).padStart(2, '0')
const hour = String(12 + (Math.floor(index / 60) % 4)).padStart(2, '0')
heavierTail += usageRecord(`2026-05-26T${hour}:${minute}:00.000Z`, 3, index + 1)
}
const original = `${sharedHead}${usageRecordRange(swappedFrom, swappedTo)}`
const replacement = `${sharedHead}${heavierTail}`
expect(replacement.length).toBe(original.length)
// Only the boundary window can detect this rewrite.
expect(original.length - sharedHead.length).toBeLessThan(BOUNDARY_WINDOW_BYTES)
writeFileSync(rolloutPath, original, 'utf-8')
// Keep the rewrite observable on filesystems whose clock has not ticked yet.
utimesSync(rolloutPath, 1, 1)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(swappedTo)
expect(recordedResumeOffset(first.processedFiles, rolloutPath)).toBeGreaterThan(0)
writeFileSync(rolloutPath, replacement, 'utf-8')
expect(statSync(rolloutPath).mtimeMs).not.toBe(first.processedFiles[0]?.mtimeMs)
const second = await scanCodexUsageFiles([], first.processedFiles)
const fromScratch = await scanCodexUsageFiles([], [])
expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates)
expect(totalTokens(second.dailyAggregates)).toBe(swappedFrom + 10 * 3)
})
// Recreated files can reuse inodes, so rotation must also check content.
it('falls back to a full reparse when a prefix was rewritten in place', async () => {
const rolloutPath = join(sessionsDir, 'rollout-prefix-swap-in-place.jsonl')
const { original, replacement } = prefixSwapPair('session-prefix-in-place')
writeFileSync(rolloutPath, original, 'utf-8')
// Keep the rewrite observable on filesystems whose clock has not ticked yet.
utimesSync(rolloutPath, 1, 1)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(40)
const inodeBefore = statSync(rolloutPath).ino
writeFileSync(rolloutPath, replacement, 'utf-8')
expect(statSync(rolloutPath).ino).toBe(inodeBefore)
expect(statSync(rolloutPath).mtimeMs).not.toBe(first.processedFiles[0]?.mtimeMs)
const second = await scanCodexUsageFiles([], first.processedFiles)
const fromScratch = await scanCodexUsageFiles([], [])
expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates)
expect(totalTokens(second.dailyAggregates)).toBe(80)
})
it('does not double-count a record completed after a partial trailing line', async () => {
const rolloutPath = join(sessionsDir, 'rollout-partial.jsonl')
const complete = usageRecordRange(0, RESUMABLE_RECORDS)
const pending = usageRecord('2026-05-26T12:59:00.000Z', 1, RESUMABLE_RECORDS + 1)
writeFileSync(
rolloutPath,
`${sessionMeta('session-partial')}${complete}${pending.slice(0, 40)}`,
'utf-8'
)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS)
writeFileSync(
rolloutPath,
`${sessionMeta('session-partial')}${complete}${pending}${usageRecordRange(RESUMABLE_RECORDS + 1, RESUMABLE_RECORDS + 2)}`,
'utf-8'
)
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(parseReadOffsets(rolloutPath)).toEqual([
recordedResumeOffset(first.processedFiles, rolloutPath)
])
expect(totalTokens(second.dailyAggregates)).toBe(RESUMABLE_RECORDS + 2)
expect(second.sessions[0]?.eventCount).toBe(RESUMABLE_RECORDS + 2)
})
// Valid JSON without its newline is counted, so incremental reuse would count the tail twice.
it('does not double-count a counted tail whose newline was not yet written', async () => {
const rolloutPath = join(sessionsDir, 'rollout-unflushed-newline.jsonl')
const complete = usageRecordRange(0, RESUMABLE_RECORDS)
const pending = usageRecord('2026-05-26T12:59:00.000Z', 1, RESUMABLE_RECORDS + 1)
writeFileSync(
rolloutPath,
`${sessionMeta('session-unflushed')}${complete}${pending.slice(0, -1)}`,
'utf-8'
)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS + 1)
expect(first.sessions[0]?.eventCount).toBe(RESUMABLE_RECORDS + 1)
// A large prefix ensures the missing newline alone suppresses the checkpoint.
expect(first.processedFiles[0]?.parseResumeState).toBeNull()
appendFileSync(
rolloutPath,
`\n${usageRecordRange(RESUMABLE_RECORDS + 1, RESUMABLE_RECORDS + 2)}`,
'utf-8'
)
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(parseReadOffsets(rolloutPath)).toEqual([0])
const fromScratch = await scanCodexUsageFiles([], [])
expect(totalTokens(second.dailyAggregates)).toBe(RESUMABLE_RECORDS + 2)
expect(second.sessions[0]?.eventCount).toBe(RESUMABLE_RECORDS + 2)
expect(second.dailyAggregates).toEqual(fromScratch.dailyAggregates)
})
it('keeps fork ownership when the owning rollout grows incrementally', async () => {
const originalPath = join(sessionsDir, 'aaaa-original.jsonl')
const forkPath = join(sessionsDir, 'zzzz-fork.jsonl')
const copiedPrefix = `${sessionMeta('session-fork')}${usageRecordRange(0, RESUMABLE_RECORDS)}`
writeFileSync(originalPath, copiedPrefix, 'utf-8')
writeFileSync(
forkPath,
`${copiedPrefix}${usageRecordRange(RESUMABLE_RECORDS, RESUMABLE_RECORDS + 2)}`,
'utf-8'
)
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS + 2)
expect(first.processedFiles.find((file) => file.path === originalPath)?.ownedEventKeys).toEqual(
expect.arrayContaining([expect.any(String)])
)
const resumeOffset = recordedResumeOffset(first.processedFiles, originalPath)
appendFileSync(
originalPath,
usageRecordRange(RESUMABLE_RECORDS + 2, RESUMABLE_RECORDS + 4),
'utf-8'
)
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(parseReadOffsets(originalPath)).toEqual([resumeOffset])
// Shared prefix + two fork-only records + two appended records, each counted once.
expect(totalTokens(second.dailyAggregates)).toBe(RESUMABLE_RECORDS + 4)
const originalAfter = second.processedFiles.find((file) => file.path === originalPath)
const forkAfter = second.processedFiles.find((file) => file.path === forkPath)
expect(originalAfter?.ownedEventKeys).toHaveLength(RESUMABLE_RECORDS + 2)
expect(forkAfter?.ownedEventKeys).toHaveLength(2)
expect(forkAfter?.hasDeferredClaims).toBe(true)
})
it('keeps a new fork from re-claiming events a resumed rollout still owns', async () => {
const originalPath = join(sessionsDir, 'aaaa-origin.jsonl')
const forkPath = join(sessionsDir, 'zzzz-late-fork.jsonl')
const copiedPrefix = `${sessionMeta('session-late')}${usageRecordRange(0, RESUMABLE_RECORDS)}`
writeFileSync(originalPath, copiedPrefix, 'utf-8')
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(RESUMABLE_RECORDS)
const resumeOffset = recordedResumeOffset(first.processedFiles, originalPath)
appendFileSync(
originalPath,
usageRecordRange(RESUMABLE_RECORDS, RESUMABLE_RECORDS + 2),
'utf-8'
)
writeFileSync(
forkPath,
`${copiedPrefix}${usageRecordRange(RESUMABLE_RECORDS + 2, RESUMABLE_RECORDS + 3)}`,
'utf-8'
)
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(parseReadOffsets(originalPath)).toEqual([resumeOffset])
expect(totalTokens(second.dailyAggregates)).toBe(RESUMABLE_RECORDS + 3)
const forkAfter = second.processedFiles.find((file) => file.path === forkPath)
expect(forkAfter?.ownedEventKeys).toHaveLength(1)
expect(forkAfter?.hasDeferredClaims).toBe(true)
})
// Released ownership must let a fork count records lost from the rewritten file.
it('lets a fork reclaim records a rewritten rollout can no longer own', async () => {
const originalPath = join(sessionsDir, 'aaaa-rewritten.jsonl')
const forkPath = join(sessionsDir, 'zzzz-inheritor.jsonl')
const sharedPrefix = `${sessionMeta('session-shared')}${usageRecordRange(0, 40)}`
writeFileSync(originalPath, sharedPrefix, 'utf-8')
const first = await scanCodexUsageFiles([], [])
expect(totalTokens(first.dailyAggregates)).toBe(40)
writeFileSync(
originalPath,
`${sessionMeta('session-rewritten')}${usageRecordRange(100, 140)}`,
'utf-8'
)
writeFileSync(forkPath, `${sharedPrefix}${usageRecordRange(40, 43)}`, 'utf-8')
const second = await scanCodexUsageFiles([], first.processedFiles)
const fromScratch = await scanCodexUsageFiles([], [])
expect(eventCountsBySession(second.sessions)).toEqual(
eventCountsBySession(fromScratch.sessions)
)
expect(totalTokens(second.dailyAggregates)).toBe(83)
})
it('still reclaims deferred fork claims after an incremental append', async () => {
const originalPath = join(sessionsDir, 'aaaa-owner.jsonl')
const forkPath = join(sessionsDir, 'zzzz-deferred.jsonl')
const copiedPrefix = `${sessionMeta('session-deferred')}${usageRecordRange(0, RESUMABLE_RECORDS)}`
writeFileSync(originalPath, copiedPrefix, 'utf-8')
writeFileSync(
forkPath,
`${copiedPrefix}${usageRecordRange(RESUMABLE_RECORDS, RESUMABLE_RECORDS + 2)}`,
'utf-8'
)
const first = await scanCodexUsageFiles([], [])
const resumeOffset = recordedResumeOffset(first.processedFiles, forkPath)
// Appending must preserve the deferred flag so the fork can later reclaim the prefix.
appendFileSync(
forkPath,
usageRecordRange(RESUMABLE_RECORDS + 2, RESUMABLE_RECORDS + 4),
'utf-8'
)
streamReads.length = 0
const second = await scanCodexUsageFiles([], first.processedFiles)
expect(parseReadOffsets(forkPath)).toEqual([resumeOffset])
expect(totalTokens(second.dailyAggregates)).toBe(RESUMABLE_RECORDS + 4)
expect(second.processedFiles.find((file) => file.path === forkPath)?.hasDeferredClaims).toBe(
true
)
rmSync(originalPath)
const third = await scanCodexUsageFiles([], second.processedFiles)
expect(third.processedFiles).toHaveLength(1)
expect(third.processedFiles[0]?.ownedEventKeys).toHaveLength(RESUMABLE_RECORDS + 4)
expect(totalTokens(third.dailyAggregates)).toBe(RESUMABLE_RECORDS + 4)
})
})
+1 -1
View File
@@ -1,4 +1,4 @@
import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import { createUsageWorktreeResolver } from '../usage/worktree-attribution/usage-worktree-resolver'
import {
getLegacySourceSkipBytesByPath,
listCodexSessionFiles,
@@ -1,77 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { scanCodexUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
import {
createStoreWithState,
createWorktreeUsageSession,
setupCodexUsageStoreEnv
} from './store-test-harness'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanCodexUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
describe('CodexUsageStore', () => {
setupCodexUsageStoreEnv(getPathMock)
it('joins a scan that is already in flight when the run finished before it started', async () => {
const worktreeId = 'repo-1::/workspace/repo'
const store = createStoreWithState({
scanState: {
enabled: true,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
})
// Prime the fingerprint so an unforced refresh can return early.
await store.refresh(true)
const completedAt = Date.now() + 10_000
vi.setSystemTime(new Date(completedAt + 1_000))
let startScan = () => {}
let finishScan = () => {}
const scanStarted = new Promise<void>((resolve) => {
startScan = resolve
})
const scanFinished = new Promise<void>((resolve) => {
finishScan = resolve
})
vi.mocked(scanCodexUsageFilesViaWorker).mockImplementationOnce(async () => {
startScan()
await scanFinished
return {
processedFiles: [],
sessions: [createWorktreeUsageSession(worktreeId)],
dailyAggregates: []
}
})
const inFlight = store.refresh(true)
await scanStarted
const usage = store.getAutomationRunUsage({
worktreeId,
terminalSessionId: 'session-1',
startedAt: completedAt - 60_000,
completedAt
})
finishScan()
await inFlight
expect((await usage).status).toBe('known')
expect((await usage).providerSessionId).toBe('session-1')
})
})
@@ -1,57 +0,0 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { setupCodexUsageStoreEnv } from './store-test-harness'
const { getPathMock } = vi.hoisted(() => ({
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
}))
vi.mock('electron', () => ({
app: {
getPath: getPathMock
}
}))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanCodexUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
import { CodexUsageStore } from './store'
import { CODEX_USAGE_SCHEMA_VERSION } from './codex-usage-provider'
import { scanCodexUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
describe('CodexUsageStore', () => {
const storeEnv = setupCodexUsageStoreEnv(getPathMock)
it('rescans from scratch when the cache predates per-request long-context counts', async () => {
const scanMock = vi.mocked(scanCodexUsageFilesViaWorker)
const sourceCachePath = join(storeEnv.tempUserData, 'orca-codex-usage-sources.json')
const cacheFile = join(storeEnv.tempUserData, 'orca-codex-usage.json')
const seeded = new CodexUsageStore({ getRepos: () => [], getAllWorktreeMeta: () => ({}) })
await seeded.setEnabled(true)
await seeded.refresh(true)
await seeded.flush()
const current = readFileSync(cacheFile, 'utf-8')
scanMock.mockClear()
await new CodexUsageStore({ getRepos: () => [], getAllWorktreeMeta: () => ({}) }).refresh(true)
expect(scanMock).toHaveBeenLastCalledWith([], {
path: sourceCachePath,
schemaVersion: CODEX_USAGE_SCHEMA_VERSION,
worktreeFingerprint: '[]',
reuse: true
})
writeFileSync(cacheFile, JSON.stringify({ ...JSON.parse(current), schemaVersion: 5 }))
scanMock.mockClear()
await new CodexUsageStore({ getRepos: () => [], getAllWorktreeMeta: () => ({}) }).refresh(false)
expect(scanMock).toHaveBeenLastCalledWith([], {
path: sourceCachePath,
schemaVersion: CODEX_USAGE_SCHEMA_VERSION,
worktreeFingerprint: '[]',
reuse: false
})
})
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, vi } from 'vitest'
import type { Mock } from 'vitest'
import { scanCodexUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
import { scanCodexUsageFilesViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import { CodexUsageStore, initCodexUsagePath } from './store'
import type { CodexUsagePersistedState } from './types'
+3 -3
View File
@@ -1,4 +1,4 @@
import { codexOpenCodeTokenSessions } from '../usage/agent-token-usage'
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
import { app } from 'electron'
import { join } from 'node:path'
import type {
@@ -19,8 +19,8 @@ import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-pr
import { resolveCodexAutomationRunUsage } from './codex-automation-run-attribution'
import { buildRecentSessions } from './codex-usage-session-rows'
import { buildBreakdown, buildDaily, buildSummary } from './codex-usage-rollup-projections'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/usage-scan-worker-spawn'
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
@@ -1,172 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { ClaudeUsagePersistedState } from '../claude-usage/types'
import type { UsageCacheSplitResult } from '../usage/usage-source-cache-file'
const { handlers, getPathMock } = vi.hoisted(() => ({
handlers: new Map<string, (event: unknown, args?: unknown) => unknown>(),
getPathMock: vi.fn<() => string>()
}))
vi.mock('electron', () => ({
app: { getPath: getPathMock },
ipcMain: {
handle: (channel: string, handler: (event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
}
}
}))
vi.mock('../usage/usage-scan-worker-spawn', () => ({
scanClaudeUsageFilesViaWorker: vi.fn(),
scanCodexUsageFilesViaWorker: vi.fn(),
scanOpenCodeUsageDatabasesViaWorker: vi.fn(),
scanMuseUsageFilesViaWorker: vi.fn(),
splitUsageCacheFileViaWorker: vi.fn()
}))
import { ClaudeUsageStore, initClaudeUsagePath } from '../claude-usage/store'
import { CodexUsageStore, initCodexUsagePath } from '../codex-usage/store'
import { OpenCodeUsageStore, initOpenCodeUsagePath } from '../opencode-usage/store'
import { MuseUsageStore, initMuseUsagePath } from '../muse-usage/store'
import {
scanClaudeUsageFilesViaWorker,
splitUsageCacheFileViaWorker
} from '../usage/usage-scan-worker-spawn'
import { registerUsageProviderHandlers } from './usage-provider-handlers'
let directory: string
let stores: { flush: () => Promise<void> }[]
let releasePending: (() => void)[]
function historicalState(): ClaudeUsagePersistedState {
return {
schemaVersion: 6,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [
{
day: '2026-10-09',
model: null,
projectKey: 'unscoped',
projectLabel: 'Unknown location',
repoId: null,
worktreeId: null,
turnCount: 1,
zeroCacheReadTurnCount: 1,
inputTokens: 7,
outputTokens: 2,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheWrite1hTokens: 0
}
],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: 1,
lastScanError: null
}
}
}
function createProviders(): Parameters<typeof registerUsageProviderHandlers>[0] {
const backingStore = { getRepos: () => [], getAllWorktreeMeta: () => ({}) }
const providers = {
claudeUsage: new ClaudeUsageStore(backingStore),
codexUsage: new CodexUsageStore(backingStore),
openCodeUsage: new OpenCodeUsageStore(backingStore),
museUsage: new MuseUsageStore(backingStore)
}
stores.push(...Object.values(providers))
registerUsageProviderHandlers(providers)
return providers
}
function reportText(state: ClaudeUsagePersistedState): string {
const { processedFiles: _files, ...report } = state
return JSON.stringify(report)
}
function invoke(channel: string, args?: unknown): unknown {
const handler = handlers.get(channel)
if (!handler) {
throw new Error(`No registered usage handler for ${channel}.`)
}
return handler({}, args)
}
function nextTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'orca-usage-ipc-readiness-'))
stores = []
releasePending = []
handlers.clear()
getPathMock.mockReturnValue(directory)
vi.mocked(scanClaudeUsageFilesViaWorker).mockReset()
vi.mocked(splitUsageCacheFileViaWorker).mockReset()
initClaudeUsagePath()
initCodexUsagePath()
initOpenCodeUsagePath()
initMuseUsagePath()
})
afterEach(async () => {
releasePending.forEach((release) => release())
await Promise.all(stores.map((store) => store.flush()))
rmSync(directory, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('awaits loading before the first disabled cached snapshot and all historical queries', async () => {
const cached = historicalState()
writeFileSync(
join(directory, 'orca-claude-usage.json'),
`${JSON.stringify(cached)}${' '.repeat(9 * 1024 * 1024)}`
)
const pending = Promise.withResolvers<UsageCacheSplitResult>()
releasePending.push(() => pending.resolve({ reportText: reportText(cached), migrated: false }))
vi.mocked(splitUsageCacheFileViaWorker).mockReturnValueOnce(pending.promise)
const { claudeUsage } = createProviders()
const snapshotGetter = vi.spyOn(claudeUsage, 'getSnapshot')
const scanStateGetter = vi.spyOn(claudeUsage, 'getScanState')
const settled = vi.fn()
const args = { scope: 'all', range: 'all', limit: 7, kind: 'model' }
const requests = [
'getSnapshot',
'getScanState',
'getSummary',
'getDaily',
'getBreakdown',
'getRecentSessions'
].map((query) =>
Promise.resolve(invoke(`claudeUsage:${query}`, args)).then((value) => {
settled()
return value
})
)
await nextTurn()
expect(settled).not.toHaveBeenCalled()
expect(snapshotGetter).not.toHaveBeenCalled()
expect(scanStateGetter).not.toHaveBeenCalled()
expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
pending.resolve({ reportText: reportText(cached), migrated: false })
const [snapshot, scanState, summary, daily] = await Promise.all(requests)
expect(snapshot).toMatchObject({
scanState: { enabled: false, isScanning: false, hasAnyClaudeData: true },
summary: { turns: 1, inputTokens: 7 }
})
expect(scanState).toMatchObject({ enabled: false, isScanning: false, hasAnyClaudeData: true })
expect(summary).toMatchObject({ turns: 1, inputTokens: 7 })
expect(daily).toEqual([
{ day: '2026-10-09', inputTokens: 7, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }
])
expect(snapshotGetter).toHaveBeenCalledWith('all', 'all', 7)
expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
})
+2 -2
View File
@@ -1,5 +1,5 @@
import type { UsageProvider } from '../usage/usage-provider-contract'
import { scanMuseUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn'
import type { UsageProvider } from '../usage/provider-lifecycle/usage-provider-contract'
import { scanMuseUsageFilesViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import type { MuseUsageDailyAggregate, MuseUsageSession } from './types'
// Event-key ordinals require invalidating older caches.
@@ -1,6 +1,6 @@
import { record, type JsonRecord } from '../../shared/codex-rollout-jsonl-cursor'
import { unwrapMuseLogRecords } from '../../shared/muse-session-log'
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
import { ensureNumber, extractString } from '../usage/source-input/usage-record-coercion'
import type { MuseUsageParsedEvent } from './types'
export type MuseUsageParseContext = {
+5 -5
View File
@@ -1,12 +1,12 @@
import { stat } from 'node:fs/promises'
import { attributeUsageEvent } from '../usage/usage-event-attribution'
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import { readJsonlLinesFromOffset } from '../usage/jsonl-line-offsets'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import { attributeUsageEvent } from '../usage/worktree-attribution/usage-event-attribution'
import { createUsageEventAggregation } from '../usage/rollups/usage-event-aggregation'
import { readJsonlLinesFromOffset } from '../usage/source-input/jsonl/jsonl-line-offsets'
import type { UsageScanWorktreeRef } from '../usage/provider-lifecycle/usage-provider-contract'
import {
createUsageWorktreeResolver,
type UsageWorktreeResolver
} from '../usage/usage-worktree-resolver'
} from '../usage/worktree-attribution/usage-worktree-resolver'
import { listMuseSessionLogFiles, type MuseSessionLogRef } from './muse-session-log-discovery'
import { parseMuseUsageLine, type MuseUsageParseContext } from './muse-usage-record-parser'
import type {
+1 -1
View File
@@ -1,4 +1,4 @@
import { highestUsageKey } from '../usage/highest-usage-key'
import { highestUsageKey } from '../usage/rollups/highest-usage-key'
import type {
MuseUsageBreakdownKind,
MuseUsageBreakdownRow,
+6 -3
View File
@@ -11,9 +11,12 @@ import type {
MuseUsageSummary
} from '../../shared/muse-usage-types'
import type { Store } from '../persistence/loading-store/store'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/usage-scan-worker-spawn'
import { filterUsageDaily, filterUsageSessions } from '../usage/usage-scope-filters'
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import {
filterUsageDaily,
filterUsageSessions
} from '../usage/reporting/range-query/usage-scope-filters'
import { museUsageProvider } from './muse-usage-provider'
import { getDefaultMuseUsageState, normalizeMuseUsageState } from './persisted-state-normalization'
import {
+1 -1
View File
@@ -4,7 +4,7 @@ import type {
UsageLocationModelBreakdown,
UsageModelBreakdown,
UsageSession
} from '../usage/usage-rollup-records'
} from '../usage/rollups/usage-rollup-records'
// Muse supplies no prices for rollups.
export type MuseUsageMetric = Record<never, never>
@@ -1,5 +1,5 @@
import type { UsageProvider } from '../usage/usage-provider-contract'
import { scanOpenCodeUsageDatabasesViaWorker } from '../usage/usage-scan-worker-spawn'
import type { UsageProvider } from '../usage/provider-lifecycle/usage-provider-contract'
import { scanOpenCodeUsageDatabasesViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types'
// Invalidate older caches: merge migrated rows per column, normalize input and preserve cache writes.
@@ -1,4 +1,4 @@
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
import { ensureNumber, extractString } from '../usage/source-input/usage-record-coercion'
import type { OpenCodeUsageRow } from './opencode-usage-row-queries'
import type { OpenCodeUsageParsedEvent } from './types'
@@ -1,6 +1,6 @@
import { normalizeComparablePath } from '../usage/usage-path-comparison'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver'
import { normalizeComparablePath } from '../usage/worktree-attribution/usage-path-comparison'
import type { UsageScanWorktreeRef } from '../usage/provider-lifecycle/usage-provider-contract'
import type { UsageWorktreeResolver } from '../usage/worktree-attribution/usage-worktree-resolver'
import type { OpenCodeUsageAttributedEvent, OpenCodeUsageParsedEvent } from './types'
export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef
+2 -2
View File
@@ -1,10 +1,10 @@
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import Database from '../sqlite/sync-database'
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import { createUsageEventAggregation } from '../usage/rollups/usage-event-aggregation'
import {
createUsageWorktreeResolver,
type UsageWorktreeResolver
} from '../usage/usage-worktree-resolver'
} from '../usage/worktree-attribution/usage-worktree-resolver'
import {
compareOpenCodeClaimPriority,
getProcessedDatabaseInfo,
@@ -1,6 +1,9 @@
import type { OpenCodeUsageRange, OpenCodeUsageScope } from '../../shared/opencode-usage-types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import {
getLocalUsageDay,
getUsageRangeCutoff
} from '../usage/reporting/range-query/usage-calendar-range'
export function filterDailyAggregatesByScopeAndRange(
dailyAggregates: OpenCodeUsageDailyAggregate[],
+1 -1
View File
@@ -1,4 +1,4 @@
import { highestUsageKey } from '../usage/highest-usage-key'
import { highestUsageKey } from '../usage/rollups/highest-usage-key'
import type {
OpenCodeUsageBreakdownKind,
OpenCodeUsageBreakdownRow,
+3 -3
View File
@@ -1,4 +1,4 @@
import { codexOpenCodeTokenSessions } from '../usage/agent-token-usage'
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
import { app } from 'electron'
import { join } from 'node:path'
import type {
@@ -29,8 +29,8 @@ import {
buildOpenCodeUsageRecentSessions,
buildOpenCodeUsageSummary
} from './snapshot-rollups'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/usage-scan-worker-spawn'
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
let _openCodeUsageFile: string | null = null
@@ -1,225 +0,0 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PostHog } from 'posthog-node'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../../shared/telemetry-agent-token-usage-schema'
import {
_enableTransportForTests,
_setPostHogClientForTests,
_setShuttingDownForTests
} from '../telemetry/client'
import {
cleanupTelemetryClientTest,
setupTelemetryClientTest,
type TelemetryClientTestState
} from '../telemetry/client-test-harness'
import { resetBurstCapsForSession } from '../telemetry/burst-cap'
import { UsageCacheSnapshotWriter } from './usage-cache-snapshot-writer'
import { AgentTokenUsageReporter } from './agent-token-usage-reporter'
const ID = '00000000-0000-4000-8000-000000000001'
const row = {
providerSessionId: 'private-provider-session',
input_tokens: 100,
output_tokens: 20,
cached_input_tokens: 50,
cache_write_input_tokens: 10
}
let directory: string
let file: string
let telemetry: TelemetryClientTestState
let reporters: AgentTokenUsageReporter[]
const identity = vi.fn(async (ids: readonly string[]) => ids.map(() => ID))
function reporter(): AgentTokenUsageReporter {
const instance = new AgentTokenUsageReporter(file, 'claude', identity)
reporters.push(instance)
return instance
}
function captures() {
return telemetry.mock.capture.mock.calls.map(([message]) => message.properties)
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'orca-token-usage-'))
file = join(directory, 'tokens.json')
telemetry = setupTelemetryClientTest()
reporters = []
identity.mockReset().mockImplementation(async (ids) => ids.map(() => ID))
})
afterEach(async () => {
await Promise.all(reporters.map((instance) => instance.flush()))
cleanupTelemetryClientTest(telemetry.envStash)
vi.restoreAllMocks()
rmSync(directory, { recursive: true, force: true })
})
describe('agent token usage', () => {
it('persists before capture, omits provider IDs, and retains revisions across restarts', async () => {
telemetry.mock.capture.mockImplementation(() => {
expect(JSON.parse(readFileSync(file, 'utf8')).snapshots[0].revision).toBeGreaterThan(0)
})
const original = reporter()
await original.report([row])
await original.report([row])
expect(captures()).toHaveLength(1)
expect(captures()[0]).toMatchObject({
analytics_session_id: ID,
revision: 1,
input_tokens: 100
})
expect(JSON.stringify(captures())).not.toContain(row.providerSessionId)
await original.report([{ ...row, input_tokens: 150 }])
expect(captures()[1]).toMatchObject({ revision: 2, input_tokens: 150 })
await reporter().report([{ ...row, input_tokens: 150 }])
expect(captures()).toHaveLength(3)
await reporter().report([{ ...row, input_tokens: 90 }])
expect(captures()[3]).toMatchObject({ revision: 3, input_tokens: 90 })
})
it.each(['opt-out', 'pending', 'environment', 'build', 'shutdown'])(
'does no identity or disk work for %s',
async (gate) => {
if (gate === 'opt-out' && telemetry.settings.telemetry) {
telemetry.settings.telemetry.optedIn = false
}
if (gate === 'pending' && telemetry.settings.telemetry) {
telemetry.settings.telemetry.optedIn = null
}
if (gate === 'environment') {
process.env.DO_NOT_TRACK = '1'
}
if (gate === 'build') {
_enableTransportForTests(false)
}
if (gate === 'shutdown') {
_setShuttingDownForTests(true)
}
await reporter().report([row])
expect(identity).not.toHaveBeenCalled()
expect(existsSync(file)).toBe(false)
expect(captures()).toEqual([])
}
)
it('rechecks consent after asynchronous identity persistence', async () => {
identity.mockImplementation(async (ids) => {
if (telemetry.settings.telemetry) {
telemetry.settings.telemetry.optedIn = false
}
return ids.map(() => ID)
})
await reporter().report([row])
expect(captures()).toEqual([])
})
it('does not publish a revision whose write failed and retries it', async () => {
const instance = reporter()
const write = vi
.spyOn(UsageCacheSnapshotWriter.prototype, 'write')
.mockRejectedValueOnce(new Error('disk full'))
await expect(instance.report([row])).rejects.toThrow('disk full')
expect(captures()).toEqual([])
write.mockRestore()
await instance.report([row])
expect(captures()[0]).toMatchObject({ revision: 1 })
})
it.each(['same process', 'restart'])(
'retries rate-limited snapshots after %s without incrementing their revision',
async (retry) => {
vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000)
const instance = reporter()
for (let count = 1; count <= 31; count++) {
await instance.report([{ ...row, input_tokens: count }])
}
expect(captures()).toHaveLength(30)
resetBurstCapsForSession()
const next = retry === 'restart' ? reporter() : instance
await next.report([{ ...row, input_tokens: 31 }])
expect(captures()).toHaveLength(31)
expect(captures().at(-1)).toMatchObject({ revision: 31, input_tokens: 31 })
await next.report([{ ...row, input_tokens: 31 }])
expect(captures()).toHaveLength(31)
}
)
it.each(['offline send', 'exit before send'])(
'retries after %s with the installed SDK',
async (failure) => {
const offlineFetch = vi.fn(async () => {
throw new Error('offline')
})
const sdk = new PostHog('test-key', {
flushAt: 100,
flushInterval: 0,
fetchRetryCount: 0,
fetch: offlineFetch
})
_setPostHogClientForTests(sdk)
try {
await reporter().report([row])
// shutdown(0) abandons the in-memory queue without a send.
if (failure === 'offline send') {
await expect(sdk.flush()).rejects.toThrow()
expect(offlineFetch).toHaveBeenCalledOnce()
} else {
expect(offlineFetch).not.toHaveBeenCalled()
}
} finally {
await sdk.shutdown(0).catch(() => {})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The test harness installs this same mock as the PostHog client.
_setPostHogClientForTests(telemetry.mock as unknown as PostHog)
}
const restarted = reporter()
await restarted.report([])
await restarted.report([])
expect(captures()).toEqual([expect.objectContaining({ revision: 1, input_tokens: 100 })])
}
)
it('retries saved snapshots without rewriting unchanged history', async () => {
const { providerSessionId: _, ...counts } = row
const persisted = JSON.stringify({
schemaVersion: 1,
snapshots: [{ ...counts, provider: 'claude', analytics_session_id: ID, revision: 7 }]
})
writeFileSync(file, persisted)
const write = vi.spyOn(UsageCacheSnapshotWriter.prototype, 'write')
const instance = reporter()
await instance.report([row])
await instance.report([])
await instance.flush()
expect(captures()).toEqual([expect.objectContaining({ revision: 7 })])
expect(write).not.toHaveBeenCalled()
expect(readFileSync(file, 'utf8')).toBe(persisted)
await instance.report([{ ...row, output_tokens: 21 }])
expect(captures()[1]).toMatchObject({ revision: 8, output_tokens: 21 })
})
it.each(['duplicate', 'wrong-provider'])(
'rejects %s snapshots without overwriting the file or capturing',
async (invalid) => {
await reporter().report([row])
telemetry.mock.capture.mockClear()
const state = JSON.parse(readFileSync(file, 'utf8'))
if (invalid === 'duplicate') {
state.snapshots.push(state.snapshots[0])
}
if (invalid === 'wrong-provider') {
state.snapshots[0].provider = 'codex'
}
const content = JSON.stringify(state)
writeFileSync(file, content)
await expect(reporter().report([row])).rejects.toThrow()
expect(readFileSync(file, 'utf8')).toBe(content)
expect(captures()).toEqual([])
}
)
it('rejects corrupt saved revisions without replacing them', async () => {
writeFileSync(file, '{broken')
await expect(reporter().report([row])).rejects.toThrow()
expect(readFileSync(file, 'utf8')).toBe('{broken')
expect(captures()).toEqual([])
})
})
@@ -4,7 +4,7 @@ import {
durableWriteTempPath,
removeStaleDurableWriteTempFiles,
writeFileDurableIfCurrent
} from '../filesystem/durable-file-write'
} from '../../filesystem/durable-file-write'
// Serialize durable writes per store and skip superseded snapshots.
export class UsageCacheSnapshotWriter {
@@ -28,12 +28,6 @@ describe('usage source cache file', () => {
rmSync(directory, { recursive: true, force: true })
})
it('names the sidecar after the report it belongs to', () => {
expect(usageSourceCachePath(join(directory, 'orca-claude-usage.json'))).toBe(
join(directory, 'orca-claude-usage-sources.json')
)
})
it('round-trips the records a scan wrote', async () => {
await writeUsageSourceCache(ref, [{ path: 'a.jsonl' }])
@@ -5,7 +5,7 @@ import {
durableWriteTempPath,
removeStaleDurableWriteTempFiles,
writeFileDurable
} from '../filesystem/durable-file-write'
} from '../../filesystem/durable-file-write'
// Only scans need bulky per-source records; worker-owned sidecars keep them off main.
-24
View File
@@ -1,24 +0,0 @@
import { expect, it } from 'vitest'
import { highestUsageKey } from './highest-usage-key'
it('reads each total once instead of sorting all projects for one winner', () => {
let reads = 0
const entries = Array.from({ length: 2000 }, (_, index): [string, number] => {
const pair: [string, number] = [String(index), (index * 173) % 2000]
Object.defineProperty(pair, '1', {
get() {
reads += 1
return (index * 173) % 2000
}
})
return pair
})
const totals = new Map(entries)
Object.defineProperty(totals, Symbol.iterator, { value: () => entries[Symbol.iterator]() })
reads = 0
const expected = [...totals].sort((left, right) => right[1] - left[1])[0][0]
expect(reads).toBeGreaterThan(10000)
reads = 0
expect(highestUsageKey(totals)).toBe(expected)
expect(reads).toBe(2000)
})
@@ -1,38 +0,0 @@
import { mkdtemp, open, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readJsonlFileSnapshot, readJsonlHandleSnapshot } from './jsonl-file-snapshot'
let directory: string
let filePath: string
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-jsonl-snapshot-'))
filePath = join(directory, 'transcript.jsonl')
await writeFile(filePath, 'one\n')
})
afterEach(async () => {
await rm(directory, { recursive: true, force: true })
})
describe('JSONL file snapshot normalization', () => {
it.each([0.123456789, 1.999999999, 1_784_029_260.125, 2_147_483_647.875])(
'preserves numeric stat timestamps at fractional epoch seconds %s',
async (seconds) => {
await utimes(filePath, seconds, seconds)
const numeric = await stat(filePath)
const snapshot = await readJsonlFileSnapshot(filePath)
const handle = await open(filePath, 'r')
try {
expect(snapshot.mtimeMs).toBe(numeric.mtimeMs)
expect(snapshot.ctimeMs).toBe(numeric.ctimeMs)
expect(snapshot.size).toBe(numeric.size)
expect(await readJsonlHandleSnapshot(handle)).toEqual(snapshot)
} finally {
await handle.close()
}
}
)
})
@@ -1,4 +1,4 @@
import type { UsageSourceCacheRef } from './usage-source-cache-file'
import type { UsageSourceCacheRef } from '../cache/usage-source-cache-file'
/** Provider-specific records preserve per-turn/per-event billing and different cache-input semantics; only the scan envelope is shared, including plugins. */
@@ -0,0 +1,57 @@
import type { AgentTokenSession } from '../reporting/token-usage/agent-token-usage'
import type { AgentTokenUsage } from '../../../shared/telemetry-agent-token-usage-schema'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import type {
UsageCacheSplitRequest,
UsageCacheSplitResult,
UsageSourceCacheRef
} from '../cache/usage-source-cache-file'
type UsageProviderScanState = {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
export type UsageProviderStoreState<SourceKey extends string> = {
schemaVersion: number
worktreeFingerprint: string | null
sessions: unknown[]
dailyAggregates: unknown[]
scanState: UsageProviderScanState
} & Record<SourceKey, unknown[]>
export type UsageProviderStoreLifecycleConfig<
SourceKey extends string,
State extends UsageProviderStoreState<SourceKey>,
DataPresenceKey extends string
> = {
logTag: string
resolveCacheFile: () => string
createDefaultState: () => State
normalizeState: (state: State) => State
parseReport?: (
text: string,
parsed?: State,
integrityVerified?: boolean
) => State | Promise<State>
serializeReport?: (state: State) => string
providerId?: 'claude'
sourceKey: SourceKey
dataPresenceKey: DataPresenceKey
tokenUsage?: {
provider: AgentTokenUsage['provider']
selectSessions: (state: State) => AgentTokenSession[]
}
scan: (
worktrees: UsageScanWorktreeRef[],
sourceCache: UsageSourceCacheRef
) => Promise<Pick<State, 'sessions' | 'dailyAggregates'>>
splitCacheFile: (request: UsageCacheSplitRequest) => Promise<UsageCacheSplitResult>
}
export type PublicUsageProviderScanState<DataPresenceKey extends string> =
UsageProviderScanState & {
isScanning: boolean
} & Record<DataPresenceKey, boolean>
@@ -0,0 +1,155 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle'
import {
splitUsageCacheFile,
type UsageCacheSplitRequest,
type UsageCacheSplitResult,
type UsageSourceCacheRef
} from '../cache/usage-source-cache-file'
type TestSource = { id: string }
type TestSession = { id: string }
type TestDailyAggregate = { day: string }
type TestScanState = {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
type TestState = {
schemaVersion: number
worktreeFingerprint: string | null
processedSources: TestSource[]
sessions: TestSession[]
dailyAggregates: TestDailyAggregate[]
scanState: TestScanState
}
type TestScanResult = Pick<TestState, 'sessions' | 'dailyAggregates'>
type TestScan = (
worktrees: UsageScanWorktreeRef[],
sourceCache: UsageSourceCacheRef
) => Promise<TestScanResult>
type TestSplit = (request: UsageCacheSplitRequest) => Promise<UsageCacheSplitResult>
const NOW = Date.parse('2026-04-10T16:00:00.000Z')
function makeState(
overrides: Partial<Omit<TestState, 'scanState'>> & { scanState?: Partial<TestScanState> } = {}
): TestState {
const { scanState, ...stateOverrides } = overrides
return {
schemaVersion: 1,
worktreeFingerprint: null,
processedSources: [],
sessions: [],
dailyAggregates: [],
...stateOverrides,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null,
...scanState
}
}
}
function emptyScanResult(): TestScanResult {
return { sessions: [], dailyAggregates: [] }
}
class TestUsageStore extends UsageProviderStoreLifecycle<
'processedSources',
TestState,
'hasAnyTestData'
> {
constructor(cacheFile: string, scan: TestScan, splitCacheFile: TestSplit) {
super(
{
getRepos: () => [],
getAllWorktreeMeta: () => ({})
},
{
tokenUsage: {
provider: 'claude',
selectSessions: (state) =>
state.sessions.map((session) => ({
providerSessionId: session.id,
input_tokens: 10,
output_tokens: 2,
cached_input_tokens: 3,
cache_write_input_tokens: 1
}))
},
logTag: '[test-usage]',
resolveCacheFile: () => cacheFile,
createDefaultState: makeState,
normalizeState: (state) => state,
sourceKey: 'processedSources',
dataPresenceKey: 'hasAnyTestData',
scan,
splitCacheFile
}
)
}
}
describe('UsageProviderStoreLifecycle', () => {
let tempDirectory: string
let stores: TestUsageStore[]
let scan: ReturnType<typeof vi.fn<TestScan>>
let split: ReturnType<typeof vi.fn<TestSplit>>
function createStore(
cacheFile = join(tempDirectory, `usage-${stores.length}.json`)
): TestUsageStore {
const store = new TestUsageStore(cacheFile, scan, split)
stores.push(store)
return store
}
beforeEach(() => {
tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-'))
stores = []
scan = vi.fn<TestScan>().mockResolvedValue(emptyScanResult())
// Run the real cache split in-process; the worker only wraps it in a thread.
split = vi.fn<TestSplit>(splitUsageCacheFile)
vi.spyOn(Date, 'now').mockReturnValue(NOW)
})
afterEach(async () => {
await Promise.all(stores.map((store) => store.flush()))
rmSync(tempDirectory, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('flush waits for queued analytics identity creation', async () => {
const store = createStore()
const identity = store.getAnalyticsSessionIds(['session'])
await store.flush()
const [id] = await identity
expect(
readFileSync(join(tempDirectory, 'usage-0-analytics-session-ids.json'), 'utf8')
).toContain(id)
})
it('keeps per-source records out of the report main writes', async () => {
const cacheFile = join(tempDirectory, 'provider.json')
const store = createStore(cacheFile)
await store.setEnabled(true)
scan.mockResolvedValueOnce({ sessions: [{ id: 'session' }], dailyAggregates: [] })
await store.refresh(true)
await store.flush()
const persisted = readFileSync(cacheFile, 'utf-8')
expect(JSON.parse(persisted)).not.toHaveProperty('processedSources')
expect(JSON.parse(persisted).sessions).toEqual([{ id: 'session' }])
expect(persisted).not.toContain('\n')
})
})
@@ -1,76 +1,33 @@
import { readFileSync, statSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { AgentTokenUsageReporter } from './agent-token-usage-reporter'
import type { AgentTokenSession } from './agent-token-usage'
import type { AgentTokenUsage } from '../../shared/telemetry-agent-token-usage-schema'
import { isTelemetryEnabled } from '../telemetry/client'
import { AgentTokenUsageReporter } from '../reporting/token-usage/agent-token-usage-reporter'
import { isTelemetryEnabled } from '../../telemetry/client'
import { join, parse } from 'node:path'
import { AnalyticsSessionIdStore, type AnalyticsSessionId } from './analytics-session-id-store'
import type { Store } from '../persistence/loading-store/store'
import { UsageCacheSnapshotWriter } from './usage-cache-snapshot-writer'
import { loadKnownUsageWorktreesByRepo } from './usage-worktree-metadata'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import { createWorktreeRefs, getUsageWorktreeFingerprint } from './usage-worktree-refs'
import { UsageInlineCachePreservation } from './usage-inline-cache-preservation'
import {
usageSourceCachePath,
type UsageCacheSplitRequest,
type UsageCacheSplitResult,
type UsageSourceCacheRef
} from './usage-source-cache-file'
AnalyticsSessionIdStore,
type AnalyticsSessionId
} from '../reporting/token-usage/analytics-session-id-store'
import type { Store } from '../../persistence/loading-store/store'
import { UsageCacheSnapshotWriter } from '../cache/usage-cache-snapshot-writer'
import { loadKnownUsageWorktreesByRepo } from '../worktree-attribution/usage-worktree-metadata'
import {
createWorktreeRefs,
getUsageWorktreeFingerprint
} from '../worktree-attribution/usage-worktree-refs'
import { UsageInlineCachePreservation } from '../cache/usage-inline-cache-preservation'
import { usageSourceCachePath } from '../cache/usage-source-cache-file'
import type {
UsageProviderStoreState,
UsageProviderStoreLifecycleConfig,
PublicUsageProviderScanState
} from './usage-provider-store-contract'
const STALE_MS = 5 * 60_000
// Keep large cache parsing on the worker; small reports retain synchronous reads.
const MAIN_THREAD_PARSE_MAX_BYTES = 8 * 1024 * 1024
type UsageProviderScanState = {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
type UsageProviderStoreState<SourceKey extends string> = {
schemaVersion: number
worktreeFingerprint: string | null
sessions: unknown[]
dailyAggregates: unknown[]
scanState: UsageProviderScanState
} & Record<SourceKey, unknown[]>
type UsageProviderStoreLifecycleConfig<
SourceKey extends string,
State extends UsageProviderStoreState<SourceKey>,
DataPresenceKey extends string
> = {
logTag: string
resolveCacheFile: () => string
createDefaultState: () => State
normalizeState: (state: State) => State
parseReport?: (
text: string,
parsed?: State,
integrityVerified?: boolean
) => State | Promise<State>
serializeReport?: (state: State) => string
providerId?: 'claude'
sourceKey: SourceKey
dataPresenceKey: DataPresenceKey
tokenUsage?: {
provider: AgentTokenUsage['provider']
selectSessions: (state: State) => AgentTokenSession[]
}
scan: (
worktrees: UsageScanWorktreeRef[],
sourceCache: UsageSourceCacheRef
) => Promise<Pick<State, 'sessions' | 'dailyAggregates'>>
splitCacheFile: (request: UsageCacheSplitRequest) => Promise<UsageCacheSplitResult>
}
type PublicUsageProviderScanState<DataPresenceKey extends string> = UsageProviderScanState & {
isScanning: boolean
} & Record<DataPresenceKey, boolean>
export abstract class UsageProviderStoreLifecycle<
SourceKey extends string,
State extends UsageProviderStoreState<SourceKey>,
@@ -4,9 +4,9 @@ import {
agentTokenCountsSchema,
agentTokenUsageSchema,
type AgentTokenUsage
} from '../../shared/telemetry-agent-token-usage-schema'
import { isTelemetryEnabled, track } from '../telemetry/client'
import { UsageCacheSnapshotWriter } from './usage-cache-snapshot-writer'
} from '../../../../shared/telemetry-agent-token-usage-schema'
import { isTelemetryEnabled, track } from '../../../telemetry/client'
import { UsageCacheSnapshotWriter } from '../../cache/usage-cache-snapshot-writer'
import type { AgentTokenSession } from './agent-token-usage'
const stateSchema = z
@@ -1,7 +1,7 @@
import type { AgentTokenCounts } from '../../shared/telemetry-agent-token-usage-schema'
import type { ClaudeUsageSession } from '../claude-usage/types'
import type { CodexUsageSession } from '../codex-usage/types'
import type { OpenCodeUsageSession } from '../opencode-usage/types'
import type { AgentTokenCounts } from '../../../../shared/telemetry-agent-token-usage-schema'
import type { ClaudeUsageSession } from '../../../claude-usage/types'
import type { CodexUsageSession } from '../../../codex-usage/types'
import type { OpenCodeUsageSession } from '../../../opencode-usage/types'
export type AgentTokenSession = AgentTokenCounts & { providerSessionId: string }
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { z } from 'zod'
import { UsageCacheSnapshotWriter } from './usage-cache-snapshot-writer'
import { UsageCacheSnapshotWriter } from '../../cache/usage-cache-snapshot-writer'
const providerSessionIdSchema = z
.string()
@@ -33,114 +33,6 @@ function events(count: number): Event[] {
}))
}
it('folds streamed events immediately with the batch counters, metrics and metadata', () => {
const [seed] = events(1)
const first: Event = {
...seed,
sessionId: 'mixed',
timestamp: '2026-09-09T00:00:00Z',
day: '2026-09-09',
projectKey: 'z',
projectLabel: 'Z',
repoId: 'repo-z',
worktreeId: 'tree-z',
model: 'large',
inputTokens: 3,
cachedInputTokens: 1,
outputTokens: 5,
reasoningOutputTokens: 2,
totalTokens: 8,
cost: 2
}
const input: Event[] = [
first,
{
...seed,
sessionId: 'unknown',
timestamp: '2026-09-10T00:00:00Z',
day: '2026-09-10',
model: null
},
{
...first,
timestamp: '2026-09-07T00:00:00Z',
day: '2026-09-07',
projectKey: 'a',
projectLabel: 'A',
repoId: 'repo-a',
worktreeId: 'tree-a',
model: 'small',
inputTokens: 7,
cachedInputTokens: 3,
outputTokens: 11,
reasoningOutputTokens: 5,
totalTokens: 18,
cost: 5
},
{
...first,
timestamp: '2026-09-09T12:00:00Z',
projectLabel: 'Changed label',
repoId: 'changed-repo',
worktreeId: 'changed-tree',
inputTokens: 13,
cachedInputTokens: 7,
outputTokens: 17,
reasoningOutputTokens: 11,
totalTokens: 30,
cost: 7
}
]
const expected = aggregation.aggregate(input)
const accumulator = aggregation.createAccumulator()
for (const event of input) {
accumulator.add(event)
event.totalTokens = 999
event.cost = 999
event.model = 'changed'
event.projectLabel = 'Changed after adding'
}
const result = accumulator.finalize()
expect(result).toEqual(expected)
expect(result.sessions.map((session) => session.sessionId)).toEqual(['unknown', 'mixed'])
expect(result.sessions[0].primaryModel).toBe('Unknown model')
const mixed = result.sessions[1]
expect(mixed).toMatchObject({
firstTimestamp: '2026-09-07T00:00:00Z',
lastTimestamp: '2026-09-09T12:00:00Z',
primaryModel: 'Mixed models',
hasMixedModels: true,
primaryProjectLabel: 'Multiple locations',
hasMixedLocations: true,
primaryRepoId: 'repo-z',
primaryWorktreeId: 'tree-z',
eventCount: 3,
totalInputTokens: 23,
totalCachedInputTokens: 11,
totalOutputTokens: 33,
totalReasoningOutputTokens: 18,
totalTokens: 56,
cost: 14
})
expect(mixed.locationBreakdown).toMatchObject([
{ locationKey: 'z', projectLabel: 'Z', totalTokens: 38, cost: 9 },
{ locationKey: 'a', projectLabel: 'A', totalTokens: 18, cost: 5 }
])
expect(mixed.modelBreakdown).toMatchObject([
{ modelKey: 'large', totalTokens: 38, cost: 9 },
{ modelKey: 'small', totalTokens: 18, cost: 5 }
])
expect(mixed.locationModelBreakdown).toMatchObject([
{ locationKey: 'z', modelKey: 'large', totalTokens: 38, cost: 9 },
{ locationKey: 'a', modelKey: 'small', totalTokens: 18, cost: 5 }
])
expect(result.dailyAggregates).toMatchObject([
{ day: '2026-09-07', projectKey: 'a', totalTokens: 18, cost: 5 },
{ day: '2026-09-09', projectKey: 'z', projectLabel: 'Z', totalTokens: 38, cost: 9 },
{ day: '2026-09-10', projectKey: 'path-0', totalTokens: 3, cost: 0.5 }
])
})
it('keeps first encounter order when streamed rollup sort keys tie', () => {
const accumulator = aggregation.createAccumulator()
for (const [index, event] of events(4).entries()) {
@@ -1,10 +1,10 @@
import type {
WorkerRequestTransport,
WorkerThreadFactory
} from '../worker-threads/worker-thread-transport'
import { removeStaleDurableWriteTempFiles } from '../filesystem/durable-file-write'
} from '../../worker-threads/worker-thread-transport'
import { removeStaleDurableWriteTempFiles } from '../../filesystem/durable-file-write'
import type { UsageScanWorkerRequest } from './usage-scan-worker-protocol'
import { usageSourceCachePath } from './usage-source-cache-file'
import { usageSourceCachePath } from '../cache/usage-source-cache-file'
export function createUsageScanWorkerTransport(
factory: WorkerThreadFactory
@@ -1,10 +1,10 @@
import { WorkerThreadRequestQueue } from '../worker-threads/worker-thread-request-queue'
import type { WorkerThreadFactory } from '../worker-threads/worker-thread-transport'
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../codex-usage/types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from '../opencode-usage/types'
import type { MuseUsageDailyAggregate, MuseUsageSession } from '../muse-usage/types'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import { WorkerThreadRequestQueue } from '../../worker-threads/worker-thread-request-queue'
import type { WorkerThreadFactory } from '../../worker-threads/worker-thread-transport'
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../../codex-usage/types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from '../../opencode-usage/types'
import type { MuseUsageDailyAggregate, MuseUsageSession } from '../../muse-usage/types'
import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract'
import type {
UsageScanWorkerProviderId,
UsageScanWorkerRequest,
@@ -17,7 +17,7 @@ import type {
UsageCacheSplitRequest,
UsageCacheSplitResult,
UsageSourceCacheRef
} from './usage-source-cache-file'
} from '../cache/usage-source-cache-file'
import { isUsageScanWorkerProgress } from './usage-scan-worker-protocol'
import { createUsageScanWorkerTransport } from './usage-scan-worker-cache-cleanup'
@@ -1,18 +1,18 @@
import { parentPort } from 'node:worker_threads'
import { scanClaudeUsageFiles } from '../claude-usage/scanner'
import { scanCodexUsageFiles } from '../codex-usage/scanner'
import { scanOpenCodeUsageDatabases } from '../opencode-usage/scanner'
import { scanMuseUsageFiles } from '../muse-usage/scanner'
import { normalizeMuseUsagePersistedFiles } from '../muse-usage/persisted-file-normalization'
import { normalizeOpenCodeUsagePersistedDatabases } from '../opencode-usage/persisted-database-normalization'
import { scanClaudeUsageFiles } from '../../claude-usage/scanner'
import { scanCodexUsageFiles } from '../../codex-usage/scanner'
import { scanOpenCodeUsageDatabases } from '../../opencode-usage/scanner'
import { scanMuseUsageFiles } from '../../muse-usage/scanner'
import { normalizeMuseUsagePersistedFiles } from '../../muse-usage/persisted-file-normalization'
import { normalizeOpenCodeUsagePersistedDatabases } from '../../opencode-usage/persisted-database-normalization'
import {
readClaudeUsageSourceCache,
splitClaudeUsageCacheFile,
persistClaudeUsageSourceCache
} from '../claude-usage/persisted-source-cache'
import type { CodexUsagePersistedFile } from '../codex-usage/types'
import type { OpenCodeUsagePersistedDatabase } from '../opencode-usage/types'
import type { MuseUsagePersistedFile } from '../muse-usage/types'
} from '../../claude-usage/persisted-source-cache'
import type { CodexUsagePersistedFile } from '../../codex-usage/types'
import type { OpenCodeUsagePersistedDatabase } from '../../opencode-usage/types'
import type { MuseUsagePersistedFile } from '../../muse-usage/types'
import type {
UsageScanWorkerProgress,
UsageScanWorkerRequest,
@@ -25,7 +25,7 @@ import {
splitUsageCacheFile,
writeUsageSourceCache,
type UsageSourceCacheRef
} from './usage-source-cache-file'
} from '../cache/usage-source-cache-file'
// #20940: serial history/SQLite scans stay off main; imports must remain Electron-free (plain-node-entry-guard).
// OpenCode WSL overrides can fork a transcript sidecar. Worker teardown only closes IPC; the sidecar exits on disconnect. Future children need explicit ownership if disconnect does not stop them. Sidecars restart per worker lifecycle.
@@ -1,13 +1,13 @@
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../codex-usage/types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from '../opencode-usage/types'
import type { MuseUsageDailyAggregate, MuseUsageSession } from '../muse-usage/types'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../../codex-usage/types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from '../../opencode-usage/types'
import type { MuseUsageDailyAggregate, MuseUsageSession } from '../../muse-usage/types'
import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract'
import type {
UsageCacheSplitRequest,
UsageCacheSplitResult,
UsageSourceCacheRef
} from './usage-source-cache-file'
} from '../cache/usage-source-cache-file'
// #20940: synchronous history/SQLite scans share one worker. Keep this boundary Electron-free and structured-cloneable plain data.
@@ -1,21 +1,21 @@
import { claudeProfileTranscriptDirs } from '../claude-usage/transcript-file-discovery'
import { claudeProfileTranscriptDirs } from '../../claude-usage/transcript-file-discovery'
import { existsSync } from 'node:fs'
import type { Worker } from 'node:worker_threads'
import { spawnWorkerThread } from '../worker-threads/worker-thread-spawn'
import { spawnWorkerThread } from '../../worker-threads/worker-thread-spawn'
import {
currentWorkerEntryLayout,
resolveWorkerThreadEntryPath
} from '../worker-threads/worker-thread-entry-path'
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../codex-usage/types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from '../opencode-usage/types'
import type { MuseUsageDailyAggregate, MuseUsageSession } from '../muse-usage/types'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
} from '../../worker-threads/worker-thread-entry-path'
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../../claude-usage/types'
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../../codex-usage/types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from '../../opencode-usage/types'
import type { MuseUsageDailyAggregate, MuseUsageSession } from '../../muse-usage/types'
import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract'
import type {
UsageCacheSplitRequest,
UsageCacheSplitResult,
UsageSourceCacheRef
} from './usage-source-cache-file'
} from '../cache/usage-source-cache-file'
import {
scanClaudeUsageOnWorker,
scanCodexUsageOnWorker,
@@ -1,4 +1,4 @@
import { appendFile, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -69,24 +69,6 @@ describe('pinned small JSONL reader', () => {
expect(await validateJsonlFileReader(active)).toBe(true)
})
it('rejects an unchanged EOF after a positive short read without yielding a partial line', async () => {
const active = await openFixture('one\ntwo\n')
const read = active.handle.read.bind(active.handle)
vi.spyOn(active.handle, 'read')
.mockImplementationOnce(async (...args) => {
const [buffer, offset, length, position] = positionalReadArgs(args)
return read(buffer, offset, Math.min(length, 2), position)
})
.mockImplementationOnce(async (...args) => {
const result = await read(...args)
return { ...result, bytesRead: 0 }
})
await expect(collect(active)).rejects.toThrow(/snapshot read.*unchanged file boundary/)
expect(active.streamBytes).toBe(0)
expect(active.snapshotBytes).toBe(0)
})
it('rejects a zero-byte verification read even after parsing every line', async () => {
const active = await openFixture('one\ntwo\n')
await collect(active)
@@ -109,18 +91,6 @@ describe('pinned small JSONL reader', () => {
expect(await validateJsonlFileReader(active)).toBe(false)
})
it('leaves an append after the captured boundary for the next scan', async () => {
const original = 'one会🙂\r\n'
const active = await openFixture(original)
await appendFile(filePath, 'two🙂\n')
expect(await collect(active)).toEqual([
{ line: 'one会🙂', endOffset: Buffer.byteLength(original), terminated: true }
])
expect(await validateJsonlFileReader(active)).toBe(true)
expect(active.streamBytes).toBe(Buffer.byteLength(original))
})
it('rejects a full small-file middle rewrite even when the source also grows', async () => {
const original = 'head\nold\ntail\n'
const active = await openFixture(original)
@@ -1,332 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as FsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle'
import { UsageCacheSnapshotWriter } from './usage-cache-snapshot-writer'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import {
usageSourceCachePath,
type UsageCacheSplitRequest,
type UsageCacheSplitResult,
type UsageSourceCacheRef
} from './usage-source-cache-file'
const { identityWriteGate } = vi.hoisted(() => ({
identityWriteGate: {
prefix: '',
pending: Promise.resolve(),
entered: vi.fn()
}
}))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
if (
args[1] === 'w' &&
identityWriteGate.prefix &&
String(args[0]).startsWith(identityWriteGate.prefix)
) {
identityWriteGate.entered()
await identityWriteGate.pending
}
return actual.open(...args)
}
}
})
type TestState = {
schemaVersion: number
worktreeFingerprint: string | null
processedSources: { id: string }[]
sessions: { id: string }[]
dailyAggregates: { day: string }[]
scanState: {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
}
type ScanProjection = Pick<TestState, 'sessions' | 'dailyAggregates'>
type Scan = (
worktrees: UsageScanWorktreeRef[],
sourceCache: UsageSourceCacheRef
) => Promise<ScanProjection>
type Split = (request: UsageCacheSplitRequest) => Promise<UsageCacheSplitResult>
type LoadOptions = {
splitCacheFile: Split
scan?: Scan
parseReport?: (text: string) => TestState | Promise<TestState>
serializeReport?: (state: TestState) => string
}
function defaultState(): TestState {
return {
schemaVersion: 1,
worktreeFingerprint: null,
processedSources: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
function historicalState(enabled = false): TestState {
return {
...defaultState(),
worktreeFingerprint: '[]',
processedSources: [{ id: 'historical-source' }],
sessions: [{ id: 'historical-session' }],
dailyAggregates: [{ day: '2026-10-09' }],
scanState: { ...defaultState().scanState, enabled, lastScanCompletedAt: 1 }
}
}
function reportState(state: TestState): TestState {
return { ...state, processedSources: [] }
}
function reportText(state: TestState): string {
const { processedSources: _sources, ...report } = state
return JSON.stringify(report)
}
class LoadingUsageStore extends UsageProviderStoreLifecycle<
'processedSources',
TestState,
'hasAnyTestData'
> {
constructor(cacheFile: string, options: LoadOptions) {
super(
{ getRepos: () => [], getAllWorktreeMeta: () => ({}) },
{
logTag: '[test-loading-usage]',
resolveCacheFile: () => cacheFile,
createDefaultState: defaultState,
normalizeState: (state) => state,
sourceKey: 'processedSources',
dataPresenceKey: 'hasAnyTestData',
scan: options.scan ?? (async () => defaultState()),
splitCacheFile: options.splitCacheFile,
parseReport: options.parseReport,
serializeReport: options.serializeReport
}
)
}
getSnapshot(): TestState {
return structuredClone(this.state)
}
}
let directory: string
let cacheFile: string
let stores: LoadingUsageStore[]
let releasePending: (() => void)[]
function createStore(options: LoadOptions): LoadingUsageStore {
const store = new LoadingUsageStore(cacheFile, options)
stores.push(store)
return store
}
function writeLargeLegacyCache(state: TestState): string {
const payload = `${JSON.stringify(state)}${' '.repeat(9 * 1024 * 1024)}`
writeFileSync(cacheFile, payload)
return payload
}
function pendingSplit(state: TestState, migrated = false) {
const pending = Promise.withResolvers<UsageCacheSplitResult>()
releasePending.push(() => pending.resolve({ reportText: reportText(state), migrated }))
return pending
}
function nextTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'orca-usage-loading-'))
cacheFile = join(directory, 'usage.json')
stores = []
releasePending = []
identityWriteGate.prefix = ''
identityWriteGate.pending = Promise.resolve()
identityWriteGate.entered.mockClear()
})
afterEach(async () => {
releasePending.forEach((release) => release())
await Promise.all(stores.map((store) => store.flush()))
rmSync(directory, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('queues enabled changes and flush behind loading without writing provisional state', async () => {
const cached = historicalState()
const initialPayload = writeLargeLegacyCache(cached)
const pending = pendingSplit(cached, true)
const serialize = vi.fn((state: TestState) => {
const { processedSources: _sources, ...report } = state
return JSON.stringify({ ...report, serialized: true })
})
const store = createStore({ splitCacheFile: () => pending.promise, serializeReport: serialize })
const enable = store.setEnabled(true)
const disable = store.setEnabled(false)
const finalEnable = store.setEnabled(true)
const flushed = vi.fn()
const flush = store.flush().then(flushed)
await nextTurn()
expect(serialize).not.toHaveBeenCalled()
expect(flushed).not.toHaveBeenCalled()
expect(readFileSync(cacheFile, 'utf8')).toBe(initialPayload)
pending.resolve({ reportText: reportText(cached), migrated: true })
await flush
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
sessions: cached.sessions,
scanState: { enabled: true },
serialized: true
})
await Promise.all([enable, disable, finalEnable])
expect(store.getSnapshot()).toEqual({
...reportState(cached),
scanState: { ...cached.scanState, enabled: true }
})
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).not.toHaveProperty('processedSources')
})
it('admits a shared refresh after loading and keeps the newer worker projection', async () => {
const cached = historicalState(true)
writeLargeLegacyCache(cached)
const pending = pendingSplit(cached)
const scanPending = Promise.withResolvers<ScanProjection>()
releasePending.push(() => scanPending.resolve(defaultState()))
const scan = vi.fn<Scan>(() => scanPending.promise)
const store = createStore({ splitCacheFile: () => pending.promise, scan })
const first = store.refresh(true)
const second = store.refresh(true)
await nextTurn()
expect(scan).not.toHaveBeenCalled()
pending.resolve({ reportText: reportText(cached), migrated: false })
await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1))
expect(scan).toHaveBeenCalledWith([], {
path: usageSourceCachePath(cacheFile),
schemaVersion: 1,
worktreeFingerprint: '[]',
reuse: true
})
const newer = { sessions: [{ id: 'new-session' }], dailyAggregates: [{ day: '2026-10-10' }] }
const workerReply = { ...newer, processedSources: [{ id: 'worker-source-graph' }] }
scanPending.resolve(workerReply)
await Promise.all([first, second])
await store.whenLoaded()
await nextTurn()
expect(store.getSnapshot()).toMatchObject({ ...newer, processedSources: [] })
expect(JSON.stringify(store.getSnapshot())).not.toContain('worker-source-graph')
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject(newer)
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).not.toHaveProperty('processedSources')
})
it.each(['rejection', 'throw'] as const)(
'preserves disabled cached history after a worker %s',
async (failureMode) => {
const cached = historicalState()
writeLargeLegacyCache(cached)
const failure = new Error('usage worker unavailable')
const unhandled = vi.fn()
vi.spyOn(console, 'warn').mockImplementation(() => {})
process.on('unhandledRejection', unhandled)
try {
const store = createStore({
splitCacheFile: () => {
if (failureMode === 'throw') {
throw failure
}
return Promise.reject(failure)
}
})
await expect(store.whenLoaded()).resolves.toBeUndefined()
await nextTurn()
expect(unhandled).not.toHaveBeenCalled()
expect(store.getSnapshot()).toEqual(reportState(cached))
expect(store.getScanState()).toMatchObject({ enabled: false, hasAnyTestData: true })
expect(store.getSnapshot().processedSources).toEqual([])
await store.setEnabled(true)
await store.flush()
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toEqual({
...cached,
scanState: { ...cached.scanState, enabled: true }
})
expect(store.getSnapshot().processedSources).toEqual([])
} finally {
process.off('unhandledRejection', unhandled)
}
}
)
it('extends whenLoaded through an asynchronous provider report parser', async () => {
const cached = historicalState()
writeFileSync(cacheFile, reportText(cached))
const parsed = Promise.withResolvers<TestState>()
releasePending.push(() => parsed.resolve(reportState(cached)))
const parseReport = vi.fn(() => parsed.promise)
const store = createStore({ splitCacheFile: vi.fn<Split>(), parseReport })
const ready = vi.fn()
const loaded = store.whenLoaded().then(ready)
const enabled = store.setEnabled(true)
await nextTurn()
expect(ready).not.toHaveBeenCalled()
expect(readFileSync(cacheFile, 'utf8')).toBe(reportText(cached))
parsed.resolve(reportState(cached))
await Promise.all([loaded, enabled])
expect(store.getSnapshot()).toMatchObject({
sessions: cached.sessions,
scanState: { enabled: true }
})
})
it('drains the pending analytics writer after an inline preference write rejects', async () => {
const cached = historicalState()
writeLargeLegacyCache(cached)
vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = createStore({
splitCacheFile: async () => {
throw new Error('usage worker unavailable')
}
})
await store.whenLoaded()
rmSync(cacheFile)
await expect(store.setEnabled(true)).rejects.toMatchObject({ code: 'ENOENT' })
const identityFile = join(directory, 'usage-analytics-session-ids.json')
const pending = Promise.withResolvers<void>()
releasePending.push(() => pending.resolve(undefined))
identityWriteGate.prefix = identityFile
identityWriteGate.pending = pending.promise
const identity = store.getAnalyticsSessionIds(['session-with-pending-identity'])
await vi.waitFor(() => expect(identityWriteGate.entered).toHaveBeenCalledTimes(1))
const writerFlush = vi.spyOn(UsageCacheSnapshotWriter.prototype, 'flush')
const flushed = vi.fn()
const flushing = store.flush().then(flushed)
await nextTurn()
expect(writerFlush).toHaveBeenCalled()
expect(flushed).not.toHaveBeenCalled()
pending.resolve(undefined)
await expect(flushing).resolves.toBeUndefined()
const [id] = await identity
expect(JSON.parse(readFileSync(identityFile, 'utf8'))).toMatchObject({
entries: [['session-with-pending-identity', id]]
})
expect(writerFlush).toHaveBeenCalledTimes(2)
expect(store.getSnapshot().processedSources).toEqual([])
})
@@ -1,463 +0,0 @@
import {
setupTelemetryClientTest,
cleanupTelemetryClientTest
} from '../telemetry/client-test-harness'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import type * as FsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle'
import {
splitUsageCacheFile,
type UsageCacheSplitRequest,
type UsageCacheSplitResult,
type UsageSourceCacheRef
} from './usage-source-cache-file'
const { writeProbe } = vi.hoisted(() => ({
writeProbe: {
opens: 0,
renames: 0,
blocked: false,
waiters: [] as (() => void)[]
}
}))
vi.mock('node:fs/promises', async () => {
const actual = await vi.importActual<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] === 'w') {
writeProbe.opens += 1
if (writeProbe.blocked) {
await new Promise<void>((resolve) => writeProbe.waiters.push(resolve))
}
}
return actual.open(...args)
}) as typeof actual.open,
rename: ((...args: Parameters<typeof actual.rename>) => {
writeProbe.renames += 1
return actual.rename(...args)
}) as typeof actual.rename
}
})
type TestSource = { id: string }
type TestSession = { id: string }
type TestDailyAggregate = { day: string }
type TestScanState = {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
type TestState = {
schemaVersion: number
worktreeFingerprint: string | null
processedSources: TestSource[]
sessions: TestSession[]
dailyAggregates: TestDailyAggregate[]
scanState: TestScanState
}
type TestScanResult = Pick<TestState, 'sessions' | 'dailyAggregates'>
type TestScan = (
worktrees: UsageScanWorktreeRef[],
sourceCache: UsageSourceCacheRef
) => Promise<TestScanResult>
type TestSplit = (request: UsageCacheSplitRequest) => Promise<UsageCacheSplitResult>
const NOW = Date.parse('2026-04-10T16:00:00.000Z')
const EMPTY_WORKTREE_FINGERPRINT = '[]'
function makeState(
overrides: Partial<Omit<TestState, 'scanState'>> & { scanState?: Partial<TestScanState> } = {}
): TestState {
const { scanState, ...stateOverrides } = overrides
return {
schemaVersion: 1,
worktreeFingerprint: null,
processedSources: [],
sessions: [],
dailyAggregates: [],
...stateOverrides,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null,
...scanState
}
}
}
function emptyScanResult(): TestScanResult {
return { sessions: [], dailyAggregates: [] }
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve
})
return { promise, resolve }
}
class TestUsageStore extends UsageProviderStoreLifecycle<
'processedSources',
TestState,
'hasAnyTestData'
> {
constructor(cacheFile: string, scan: TestScan, splitCacheFile: TestSplit) {
super(
{
getRepos: () => [],
getAllWorktreeMeta: () => ({})
},
{
tokenUsage: {
provider: 'claude',
selectSessions: (state) =>
state.sessions.map((session) => ({
providerSessionId: session.id,
input_tokens: 10,
output_tokens: 2,
cached_input_tokens: 3,
cache_write_input_tokens: 1
}))
},
logTag: '[test-usage]',
resolveCacheFile: () => cacheFile,
createDefaultState: makeState,
normalizeState: (state) => state,
sourceKey: 'processedSources',
dataPresenceKey: 'hasAnyTestData',
scan,
splitCacheFile
}
)
}
replaceState(state: TestState): void {
this.state = state
}
getState(): TestState {
return this.state
}
}
describe('UsageProviderStoreLifecycle', () => {
let tempDirectory: string
let stores: TestUsageStore[]
let scan: ReturnType<typeof vi.fn<TestScan>>
let split: ReturnType<typeof vi.fn<TestSplit>>
function createStore(
cacheFile = join(tempDirectory, `usage-${stores.length}.json`)
): TestUsageStore {
const store = new TestUsageStore(cacheFile, scan, split)
stores.push(store)
return store
}
beforeEach(() => {
tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-'))
stores = []
writeProbe.opens = 0
writeProbe.renames = 0
writeProbe.blocked = false
writeProbe.waiters = []
scan = vi.fn<TestScan>().mockResolvedValue(emptyScanResult())
// Run the real cache split in-process; the worker only wraps it in a thread.
split = vi.fn<TestSplit>(splitUsageCacheFile)
vi.spyOn(Date, 'now').mockReturnValue(NOW)
})
afterEach(async () => {
await Promise.all(stores.map((store) => store.flush()))
rmSync(tempDirectory, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('retries saved revisions when the usage cache is rebuilt', async () => {
const telemetry = setupTelemetryClientTest()
try {
const cacheFile = join(tempDirectory, 'provider.json')
scan.mockResolvedValue({ ...emptyScanResult(), sessions: [{ id: 'provider-session' }] })
const original = createStore(cacheFile)
await original.refresh(true)
expect(telemetry.mock.capture).not.toHaveBeenCalled()
await original.setEnabled(true)
await original.refresh(true)
const first = telemetry.mock.capture.mock.calls[0]?.[0]
expect(first).toMatchObject({
event: 'agent_token_usage',
properties: { revision: 1, input_tokens: 10 }
})
await original.flush()
rmSync(cacheFile)
const rebuilt = createStore(cacheFile)
writeProbe.opens = 0
await rebuilt.whenLoaded()
expect(writeProbe.opens).toBe(0)
await rebuilt.setEnabled(true)
writeProbe.opens = 0
await rebuilt.refresh(true)
expect(telemetry.mock.capture).toHaveBeenCalledTimes(2)
expect(telemetry.mock.capture.mock.calls[1]?.[0]).toMatchObject({
properties: first.properties
})
expect(writeProbe.opens).toBe(1)
} finally {
cleanupTelemetryClientTest(telemetry.envStash)
}
})
it('keeps analytics identity separate from usage cache rebuilds and never puts it in snapshots', async () => {
const cacheFile = join(tempDirectory, 'provider.json')
const identityFile = join(tempDirectory, 'provider-analytics-session-ids.json')
const original = createStore(cacheFile)
expect(existsSync(identityFile)).toBe(false)
const [id] = await original.getAnalyticsSessionIds(['provider-session'])
expect(existsSync(identityFile)).toBe(true)
await original.setEnabled(true)
await original.refresh(true)
await original.flush()
expect(JSON.stringify(original.getState())).not.toContain(id)
expect(readFileSync(cacheFile, 'utf8')).not.toContain(id)
rmSync(cacheFile)
const rebuilt = createStore(cacheFile)
expect(await rebuilt.getAnalyticsSessionIds(['provider-session'])).toEqual([id])
expect(await createStore().getAnalyticsSessionIds(['provider-session'])).not.toEqual([id])
})
it('flush waits for queued analytics identity creation', async () => {
const store = createStore()
const identity = store.getAnalyticsSessionIds(['session'])
await store.flush()
const [id] = await identity
expect(
readFileSync(join(tempDirectory, 'usage-0-analytics-session-ids.json'), 'utf8')
).toContain(id)
})
it('keeps per-source records out of the report main writes', async () => {
const cacheFile = join(tempDirectory, 'provider.json')
const store = createStore(cacheFile)
await store.setEnabled(true)
scan.mockResolvedValueOnce({ sessions: [{ id: 'session' }], dailyAggregates: [] })
await store.refresh(true)
await store.flush()
const persisted = readFileSync(cacheFile, 'utf-8')
expect(JSON.parse(persisted)).not.toHaveProperty('processedSources')
expect(JSON.parse(persisted).sessions).toEqual([{ id: 'session' }])
expect(persisted).not.toContain('\n')
})
it('preserves a small legacy cache for the next warm scan', async () => {
const cacheFile = join(tempDirectory, 'provider.json')
writeFileSync(
cacheFile,
JSON.stringify(
makeState({
worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT,
processedSources: [{ id: 'inline' }],
sessions: [{ id: 'session' }]
})
)
)
const store = createStore(cacheFile)
await store.whenLoaded()
expect(split).toHaveBeenCalledWith({ cacheFile, sourceKey: 'processedSources' })
expect(store.getState().sessions).toEqual([{ id: 'session' }])
expect(store.getState().processedSources).toEqual([])
expect(
JSON.parse(readFileSync(join(tempDirectory, 'provider-sources.json'), 'utf-8'))
).toMatchObject({
sources: [{ id: 'inline' }]
})
})
it('splits a large legacy cache on the worker before any reader or writer sees it', async () => {
const cacheFile = join(tempDirectory, 'provider.json')
// Exceed the main-thread parse ceiling like a pre-sidecar Claude history.
const processedSources = Array.from({ length: 9_000 }, (_, index) => ({
id: `source-${index}-${'x'.repeat(1_000)}`
}))
writeFileSync(
cacheFile,
JSON.stringify(
makeState({
worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT,
processedSources,
sessions: [{ id: 'session' }],
scanState: { enabled: true, lastScanCompletedAt: NOW - 1 }
}),
null,
2
)
)
const store = createStore(cacheFile)
// A write racing load must not overwrite the history being loaded.
const disabled = store.setEnabled(false)
await Promise.resolve()
expect(split).toHaveBeenCalledWith({ cacheFile, sourceKey: 'processedSources' })
await store.whenLoaded()
await disabled
await store.flush()
expect(store.getState().sessions).toEqual([{ id: 'session' }])
expect(store.getState().processedSources).toEqual([])
const report = JSON.parse(readFileSync(cacheFile, 'utf-8'))
expect(report).not.toHaveProperty('processedSources')
expect(report).toMatchObject({ sessions: [{ id: 'session' }], scanState: { enabled: false } })
const sidecar = JSON.parse(readFileSync(join(tempDirectory, 'provider-sources.json'), 'utf-8'))
expect(sidecar).toEqual({
schemaVersion: 1,
worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT,
sources: processedSources
})
})
it('reads a large cache on the main thread when the worker cannot split it', async () => {
const cacheFile = join(tempDirectory, 'provider.json')
writeFileSync(
cacheFile,
JSON.stringify(
makeState({
sessions: [{ id: 'session' }],
processedSources: [{ id: 'x'.repeat(9 * 1024 * 1024) }]
})
)
)
split.mockRejectedValueOnce(new Error('worker unavailable'))
vi.spyOn(console, 'warn').mockImplementation(() => {})
const store = createStore(cacheFile)
await store.whenLoaded()
expect(store.getState().sessions).toEqual([{ id: 'session' }])
expect(store.getState().processedSources).toEqual([])
})
it('flush waits for a pending migration and its report rewrite', async () => {
const cacheFile = join(tempDirectory, 'provider.json')
writeFileSync(
cacheFile,
JSON.stringify(makeState({ processedSources: [{ id: 'inline' }] })) +
' '.repeat(9 * 1024 * 1024)
)
const pendingSplit = createDeferred<UsageCacheSplitResult>()
split.mockReturnValueOnce(pendingSplit.promise)
const store = createStore(cacheFile)
let flushed = false
const flushing = store.flush().then(() => {
flushed = true
})
await new Promise((resolve) => setImmediate(resolve))
expect(flushed).toBe(false)
pendingSplit.resolve({ reportText: JSON.stringify(makeState()), migrated: true })
await flushing
expect(JSON.parse(readFileSync(cacheFile, 'utf-8'))).not.toHaveProperty('processedSources')
})
it('shares one in-flight scan and exposes its live state', async () => {
const pendingScan = createDeferred<TestScanResult>()
scan.mockReturnValueOnce(pendingScan.promise)
const store = createStore()
store.replaceState(
makeState({ scanState: { enabled: true, lastScanError: 'previous failure' } })
)
const firstRefresh = store.refresh(true)
const secondRefresh = store.refresh(true)
await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1))
expect(store.getScanState()).toMatchObject({
isScanning: true,
lastScanStartedAt: NOW,
lastScanError: null
})
expect(writeProbe.opens).toBe(0)
pendingScan.resolve({
sessions: [{ id: 'session' }],
dailyAggregates: []
})
await Promise.all([firstRefresh, secondRefresh])
expect(store.getScanState()).toMatchObject({
isScanning: false,
lastScanCompletedAt: NOW,
hasAnyTestData: true
})
expect(writeProbe.opens).toBe(1)
expect(store.getState().sessions).toEqual([{ id: 'session' }])
})
it('vetoes a superseded generation before rename', async () => {
const cacheFile = join(tempDirectory, 'usage-0.json')
const store = createStore()
writeProbe.blocked = true
const first = store.setEnabled(true)
await vi.waitFor(() => expect(writeProbe.waiters).toHaveLength(1))
const second = store.setEnabled(false)
writeProbe.blocked = false
writeProbe.waiters.splice(0).forEach((resolve) => resolve())
await Promise.all([first, second])
expect(writeProbe.opens).toBe(2)
expect(writeProbe.renames).toBe(1)
expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.enabled).toBe(false)
expect(readdirSync(tempDirectory).filter((name) => name.endsWith('.tmp'))).toHaveLength(0)
})
it('sweeps a temp file orphaned before rename', async () => {
const cacheFile = join(tempDirectory, 'orphaned-usage.json')
const orphan = `${cacheFile}.${process.pid + 1}.1.test.tmp`
writeFileSync(orphan, '{}')
createStore(cacheFile)
await vi.waitFor(() => expect(existsSync(orphan)).toBe(false))
})
it('retains the last successful projection when a scan fails', async () => {
const cacheFile = join(tempDirectory, 'usage-0.json')
const store = createStore()
const previousState = makeState({
worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT,
sessions: [{ id: 'session' }],
dailyAggregates: [{ day: '2026-04-09' }],
scanState: { enabled: true, lastScanCompletedAt: NOW - 1_000 }
})
store.replaceState(previousState)
scan.mockRejectedValueOnce(new Error('scan exploded'))
await expect(store.refresh(true)).resolves.toMatchObject({
isScanning: false,
lastScanCompletedAt: NOW - 1_000,
lastScanError: 'scan exploded'
})
expect(store.getState()).toMatchObject({
worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT,
sessions: previousState.sessions,
dailyAggregates: previousState.dailyAggregates
})
expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.lastScanError).toBe(
'scan exploded'
)
})
})
@@ -1,102 +0,0 @@
import { once } from 'node:events'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { Worker } from 'node:worker_threads'
import { build } from 'esbuild'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { durableWriteTempPath } from '../filesystem/durable-file-write'
import { createUsageScanWorkerTransport } from './usage-scan-worker-cache-cleanup'
import type { UsageScanWorkerRequest } from './usage-scan-worker-protocol'
import { usageSourceCachePath } from './usage-source-cache-file'
let directory: string
let cacheModule: string
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'orca-usage-worker-cleanup-'))
cacheModule = join(directory, 'source-cache.cjs')
await build({
entryPoints: [resolve(__dirname, 'usage-source-cache-file.ts')],
outfile: cacheModule,
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22'
})
})
afterAll(() => rmSync(directory, { recursive: true, force: true }))
// Pause an actual durable write after opening its temp; terminating the worker skips its finally.
const BLOCKED_WRITE_WORKER = `
const { parentPort, workerData } = require('node:worker_threads');
const fs = require('node:fs/promises');
const originalOpen = fs.open;
fs.open = async (...args) => {
const handle = await originalOpen(...args);
if (args[1] === 'w') {
await handle.writeFile('partial-cache');
parentPort.postMessage(args[0]);
await new Promise(() => {});
}
return handle;
};
const cache = require(workerData.cacheModule);
parentPort.on('message', (request) => {
const ref = request.operation === 'scan' ? request.sourceCache : {
path: cache.usageSourceCachePath(request.cacheFile),
schemaVersion: 1, worktreeFingerprint: '[]', reuse: false
};
cache.writeUsageSourceCache(ref, [{ id: 'source' }]);
});
`
describe('usage worker cache cleanup', () => {
it.each(['scan', 'splitCacheFile'] as const)(
'reclaims only the exited %s worker temp while replacement writes survive',
async (operation) => {
const cacheFile = join(directory, `${operation}.json`)
const sourcePath = usageSourceCachePath(cacheFile)
const worker = new Worker(BLOCKED_WRITE_WORKER, {
eval: true,
workerData: { cacheModule }
})
const transport = createUsageScanWorkerTransport(() => worker)
try {
const started = once(worker, 'message')
const request: UsageScanWorkerRequest =
operation === 'scan'
? {
id: 1,
operation,
providerId: 'codex',
worktrees: [],
sourceCache: {
path: sourcePath,
schemaVersion: 1,
worktreeFingerprint: '[]',
reuse: false
}
}
: { id: 1, operation, cacheFile, sourceKey: 'processedFiles' }
transport.postMessage(request)
const [tempPath] = await started
if (typeof tempPath !== 'string') {
throw new Error('Worker did not report its temporary path')
}
const replacement = durableWriteTempPath(sourcePath, String(worker.threadId + 1))
writeFileSync(replacement, 'replacement-cache')
expect(existsSync(tempPath)).toBe(true)
await transport.terminate()
expect(existsSync(tempPath)).toBe(false)
expect(existsSync(replacement)).toBe(true)
} finally {
await worker.terminate()
}
},
15_000
)
})
@@ -1,37 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY,
canonicalizeUsageWorktreePaths
} from './usage-worktree-canonicalizer'
describe('canonicalizeUsageWorktreePaths', () => {
it('caps concurrent canonicalization work and preserves longest-path-first ordering', async () => {
let active = 0
let maxActive = 0
const seenPaths: string[] = []
const worktrees = Array.from(
{ length: USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY + 3 },
(_value, index) => ({
path: `/repo/${index}`,
worktreeId: `worktree-${index}`
})
)
const result = await canonicalizeUsageWorktreePaths(worktrees, async (path) => {
active++
maxActive = Math.max(maxActive, active)
seenPaths.push(path)
await new Promise((resolve) => setTimeout(resolve, 0))
active--
return path.endsWith('/10') ? `${path}/nested/longer` : path
})
expect(maxActive).toBeLessThanOrEqual(USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY)
expect(seenPaths).toEqual(worktrees.map((worktree) => worktree.path))
expect(result[0]).toMatchObject({
path: '/repo/10',
canonicalPath: '/repo/10/nested/longer'
})
})
})
@@ -1,85 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { loadKnownUsageWorktreesByRepo } from './usage-worktree-metadata'
describe('loadKnownUsageWorktreesByRepo', () => {
it('builds usage worktree refs from repo roots and persisted metadata', () => {
const store = {
getAllWorktreeMeta: vi.fn(() => ({
'repo-1::/workspace/repo-a-feature': {
displayName: 'Feature A'
},
'repo-2::/remote/repo-b-feature': {
displayName: 'Remote feature'
},
malformed: {
displayName: 'Ignored'
}
}))
}
const repos = [
{
id: 'repo-1',
path: '/workspace/repo-a',
displayName: 'Repo A'
},
{
id: 'repo-2',
path: '/remote/repo-b',
displayName: 'Remote Repo',
connectionId: 'ssh-1'
}
]
expect(loadKnownUsageWorktreesByRepo(store as never, repos as never)).toEqual(
new Map([
[
'repo-1',
[
{
worktreeId: 'repo-1::/workspace/repo-a',
path: '/workspace/repo-a',
displayName: 'Repo A'
},
{
worktreeId: 'repo-1::/workspace/repo-a-feature',
path: '/workspace/repo-a-feature',
displayName: 'Feature A'
}
]
]
])
)
expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1)
})
it('indexes repos once for many persisted worktrees', () => {
const repoCount = 200
let repoIdReads = 0
const repos = Array.from({ length: repoCount }, (_, index) => ({
get id() {
repoIdReads += 1
return `repo-${index}`
},
path: `/workspace/repo-${index}`,
displayName: `Repo ${index}`
}))
const worktreeMeta = Object.fromEntries(
Array.from({ length: repoCount }, (_, offset) => {
const index = repoCount - offset - 1
return [
`repo-${index}::/workspace/repo-${index}-feature`,
{ displayName: `Feature ${index}` }
]
})
)
const result = loadKnownUsageWorktreesByRepo(
{ getAllWorktreeMeta: () => worktreeMeta } as never,
repos as never
)
expect(result.size).toBe(repoCount)
expect([...result.values()].every((worktrees) => worktrees.length === 2)).toBe(true)
expect(repoIdReads).toBeLessThanOrEqual(repoCount * 4)
})
})
@@ -1,82 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as WorktreePathComparison from '../git/worktrees/catalog/worktree-path-comparison'
import type { UsageScanWorktreeRef } from './usage-provider-contract'
const { worktreePathComparisons } = vi.hoisted(() => ({
worktreePathComparisons: { count: 0 }
}))
vi.mock('../git/worktrees/catalog/worktree-path-comparison', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreePathComparison>()
return {
...actual,
areWorktreePathsEqual: (left: string, right: string) => {
worktreePathComparisons.count += 1
return actual.areWorktreePathsEqual(left, right)
}
}
})
import { createUsageWorktreeResolver } from './usage-worktree-resolver'
function worktree(path: string, index: number): UsageScanWorktreeRef {
return {
repoId: `repo-${index}`,
worktreeId: `repo-${index}::${path}`,
path,
displayName: `Repo ${index}`
}
}
describe('createUsageWorktreeResolver', () => {
beforeEach(() => {
worktreePathComparisons.count = 0
})
it('walks the worktree list once per distinct cwd, including misses', async () => {
const resolveWorktree = await createUsageWorktreeResolver(
Array.from({ length: 50 }, (_, index) =>
worktree(`/repo-${String(index).padStart(3, '0')}`, index)
)
)
const attribute = (event: number): string | null => {
const cwd = event % 2 === 0 ? '/repo-049/nested/pkg' : '/outside/project'
return resolveWorktree(cwd)?.worktreeId ?? null
}
expect(attribute(0)).toBe('repo-49::/repo-049')
expect(attribute(1)).toBeNull()
const afterFirstOfEachCwd = worktreePathComparisons.count
expect(afterFirstOfEachCwd).toBeGreaterThan(0)
expect(afterFirstOfEachCwd).toBeLessThanOrEqual(100)
for (let event = 2; event < 1_000; event++) {
expect(attribute(event)).toBe(event % 2 === 0 ? 'repo-49::/repo-049' : null)
}
// Two distinct cwds walked the list once each; 998 more events cost nothing.
expect(worktreePathComparisons.count).toBe(afterFirstOfEachCwd)
})
it('keeps containment semantics unchanged', async () => {
const resolveWorktree = await createUsageWorktreeResolver([worktree('/workspace/repo', 1)])
expect(resolveWorktree('/workspace/repo')?.worktreeId).toBe('repo-1::/workspace/repo')
expect(resolveWorktree('/workspace/repo/packages/app')?.worktreeId).toBe(
'repo-1::/workspace/repo'
)
// `..name` is a child directory; `..` escapes.
expect(resolveWorktree('/workspace/repo/..fixtures/session')?.worktreeId).toBe(
'repo-1::/workspace/repo'
)
expect(resolveWorktree('/workspace/repo/../other/session')).toBeNull()
expect(resolveWorktree('/workspace/repo-sibling')).toBeNull()
})
it('does not treat a different Windows drive as contained', async () => {
const resolveWorktree = await createUsageWorktreeResolver([worktree('C:\\repo', 1)])
expect(resolveWorktree('C:\\repo\\packages\\app')?.worktreeId).toBe('repo-1::C:\\repo')
expect(resolveWorktree('D:\\other\\repo')).toBeNull()
})
})
@@ -1,4 +1,4 @@
import { getLocalUsageDay } from './usage-calendar-range'
import { getLocalUsageDay } from '../reporting/range-query/usage-calendar-range'
import { normalizeComparablePath } from './usage-path-comparison'
import type { UsageWorktreeResolver } from './usage-worktree-resolver'

Some files were not shown because too many files have changed in this diff Show More