mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
refactor: organize usage and CSV modules by concrete owners (#41059)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { highestUsageKey } from '../usage/highest-usage-key'
|
||||
import { highestUsageKey } from '../usage/rollups/highest-usage-key'
|
||||
import type {
|
||||
MuseUsageBreakdownKind,
|
||||
MuseUsageBreakdownRow,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { highestUsageKey } from '../usage/highest-usage-key'
|
||||
import { highestUsageKey } from '../usage/rollups/highest-usage-key'
|
||||
import type {
|
||||
OpenCodeUsageBreakdownKind,
|
||||
OpenCodeUsageBreakdownRow,
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Vendored
Vendored
+1
-1
@@ -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 {
|
||||
-6
@@ -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' }])
|
||||
|
||||
Vendored
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
-1
@@ -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')
|
||||
})
|
||||
})
|
||||
+21
-64
@@ -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>,
|
||||
+3
-3
@@ -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
|
||||
+4
-4
@@ -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
-1
@@ -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()
|
||||
-108
@@ -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()) {
|
||||
+3
-3
@@ -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
|
||||
+8
-8
@@ -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'
|
||||
|
||||
+11
-11
@@ -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.
|
||||
+6
-6
@@ -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.
|
||||
|
||||
+9
-9
@@ -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
-31
@@ -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
-1
@@ -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
Reference in New Issue
Block a user