diff --git a/.github/workflows/performance-contracts.yml b/.github/workflows/performance-contracts.yml index 427eb577788..461cfb7a12d 100644 --- a/.github/workflows/performance-contracts.yml +++ b/.github/workflows/performance-contracts.yml @@ -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 diff --git a/config/build-plugins/main-build-entry-points.ts b/config/build-plugins/main-build-entry-points.ts index 819c08e1680..5a96cdb293f 100644 --- a/config/build-plugins/main-build-entry-points.ts +++ b/config/build-plugins/main-build-entry-points.ts @@ -39,7 +39,7 @@ export const MAIN_BUILD_ENTRY_POINTS: Record = { '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' ), diff --git a/config/knip.json b/config/knip.json index c9fe794f998..ff61d06a755 100644 --- a/config/knip.json +++ b/config/knip.json @@ -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", diff --git a/config/main-sync-fs-baseline.txt b/config/main-sync-fs-baseline.txt index 3d2b123a2a9..d5b5b40a8e6 100644 --- a/config/main-sync-fs-baseline.txt +++ b/config/main-sync-fs-baseline.txt @@ -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 diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index 12aa339d614..6d6664e99df 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -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 diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 4f53faae033..f60ab2794da 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -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, diff --git a/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs b/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs index 7e52c72b0e7..6235bdbe039 100644 --- a/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs +++ b/config/scripts/performance/claude-usage/claude-usage-benchmark-persistence.mjs @@ -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) diff --git a/config/vitest.performance.config.ts b/config/vitest.performance.config.ts index 899953662db..09f2b1bc311 100644 --- a/config/vitest.performance.config.ts +++ b/config/vitest.performance.config.ts @@ -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))) { diff --git a/src/main/claude-usage/claude-usage-automation-attribution.ts b/src/main/claude-usage/claude-usage-automation-attribution.ts index ed083d0eb5b..316f6b53d5f 100644 --- a/src/main/claude-usage/claude-usage-automation-attribution.ts +++ b/src/main/claude-usage/claude-usage-automation-attribution.ts @@ -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 diff --git a/src/main/claude-usage/claude-usage-report-aggregation.ts b/src/main/claude-usage/claude-usage-report-aggregation.ts index 8d5c5411ff9..f771e0940e7 100644 --- a/src/main/claude-usage/claude-usage-report-aggregation.ts +++ b/src/main/claude-usage/claude-usage-report-aggregation.ts @@ -1,4 +1,4 @@ -import { highestUsageKey } from '../usage/highest-usage-key' +import { highestUsageKey } from '../usage/rollups/highest-usage-key' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, diff --git a/src/main/claude-usage/claude-usage-scope-filters.ts b/src/main/claude-usage/claude-usage-scope-filters.ts index e7ae8bfb1ff..792fdd45c1c 100644 --- a/src/main/claude-usage/claude-usage-scope-filters.ts +++ b/src/main/claude-usage/claude-usage-scope-filters.ts @@ -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, diff --git a/src/main/claude-usage/persisted-source-authority.test.ts b/src/main/claude-usage/persisted-source-authority.test.ts index 394ca9693be..a0ff40dc8c8 100644 --- a/src/main/claude-usage/persisted-source-authority.test.ts +++ b/src/main/claude-usage/persisted-source-authority.test.ts @@ -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 { 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 diff --git a/src/main/claude-usage/persisted-source-cache.ts b/src/main/claude-usage/persisted-source-cache.ts index e1c60cec361..1dbe3f126ef 100644 --- a/src/main/claude-usage/persisted-source-cache.ts +++ b/src/main/claude-usage/persisted-source-cache.ts @@ -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' diff --git a/src/main/claude-usage/persisted-token-columns-store.test.ts b/src/main/claude-usage/persisted-token-columns-store.test.ts deleted file mode 100644 index f97d765aee8..00000000000 --- a/src/main/claude-usage/persisted-token-columns-store.test.ts +++ /dev/null @@ -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() - } -) diff --git a/src/main/claude-usage/persisted-usage-report.ts b/src/main/claude-usage/persisted-usage-report.ts index a5015bf90b4..29e661494bd 100644 --- a/src/main/claude-usage/persisted-usage-report.ts +++ b/src/main/claude-usage/persisted-usage-report.ts @@ -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' diff --git a/src/main/claude-usage/persisted-usage-test-fixture.ts b/src/main/claude-usage/persisted-usage-test-fixture.ts index 46735e02c99..32b1f4ca370 100644 --- a/src/main/claude-usage/persisted-usage-test-fixture.ts +++ b/src/main/claude-usage/persisted-usage-test-fixture.ts @@ -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-')) diff --git a/src/main/claude-usage/scanner-long-context.test.ts b/src/main/claude-usage/scanner-long-context.test.ts index f7953e12ea5..e3eb8168a7d 100644 --- a/src/main/claude-usage/scanner-long-context.test.ts +++ b/src/main/claude-usage/scanner-long-context.test.ts @@ -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 { 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 - ) - }) }) diff --git a/src/main/claude-usage/store-checkpoint-persistence.test.ts b/src/main/claude-usage/store-checkpoint-persistence.test.ts deleted file mode 100644 index 160b22315ca..00000000000 --- a/src/main/claude-usage/store-checkpoint-persistence.test.ts +++ /dev/null @@ -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.' - } - }) - } -) diff --git a/src/main/claude-usage/store-source-authority.test.ts b/src/main/claude-usage/store-source-authority.test.ts deleted file mode 100644 index 241ab4f1a01..00000000000 --- a/src/main/claude-usage/store-source-authority.test.ts +++ /dev/null @@ -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) -}) diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts deleted file mode 100644 index 7f3cf203f68..00000000000 --- a/src/main/claude-usage/store.test.ts +++ /dev/null @@ -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[0] { - return { - getRepos: () => [], - getAllWorktreeMeta: () => ({}) - } -} - -function createStoreWithState(state: Partial): 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((resolve) => { - startScan = resolve - }) - const scanFinished = new Promise((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') - }) -}) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index f2dc5d850f2..5272a268e19 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -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' diff --git a/src/main/claude-usage/transcript-record-parser.ts b/src/main/claude-usage/transcript-record-parser.ts index 187e34dbae6..0094e5bbecd 100644 --- a/src/main/claude-usage/transcript-record-parser.ts +++ b/src/main/claude-usage/transcript-record-parser.ts @@ -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 diff --git a/src/main/claude-usage/types.ts b/src/main/claude-usage/types.ts index ea6884bbc83..fbdbb3b1ce2 100644 --- a/src/main/claude-usage/types.ts +++ b/src/main/claude-usage/types.ts @@ -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, diff --git a/src/main/codex-usage/codex-automation-run-attribution.ts b/src/main/codex-usage/codex-automation-run-attribution.ts index 9da30261492..ae2b6f50c44 100644 --- a/src/main/codex-usage/codex-automation-run-attribution.ts +++ b/src/main/codex-usage/codex-automation-run-attribution.ts @@ -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 diff --git a/src/main/codex-usage/codex-rollout-file-parse.ts b/src/main/codex-usage/codex-rollout-file-parse.ts index 30dab4b2b71..f2e105677e1 100644 --- a/src/main/codex-usage/codex-rollout-file-parse.ts +++ b/src/main/codex-usage/codex-rollout-file-parse.ts @@ -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 { diff --git a/src/main/codex-usage/codex-rollout-resume-state.ts b/src/main/codex-usage/codex-rollout-resume-state.ts index e6592a97d60..d1b79b42243 100644 --- a/src/main/codex-usage/codex-rollout-resume-state.ts +++ b/src/main/codex-usage/codex-rollout-resume-state.ts @@ -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, diff --git a/src/main/codex-usage/codex-session-file-discovery.ts b/src/main/codex-usage/codex-session-file-discovery.ts index b6f372aa00b..62c47e98f3f 100644 --- a/src/main/codex-usage/codex-session-file-discovery.ts +++ b/src/main/codex-usage/codex-session-file-discovery.ts @@ -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 diff --git a/src/main/codex-usage/codex-usage-aggregation.ts b/src/main/codex-usage/codex-usage-aggregation.ts index 1afdc7736c0..8f5b1b8e22c 100644 --- a/src/main/codex-usage/codex-usage-aggregation.ts +++ b/src/main/codex-usage/codex-usage-aggregation.ts @@ -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 diff --git a/src/main/codex-usage/codex-usage-event-attribution.ts b/src/main/codex-usage/codex-usage-event-attribution.ts index 8ebf7fbc5e3..de1257d5792 100644 --- a/src/main/codex-usage/codex-usage-event-attribution.ts +++ b/src/main/codex-usage/codex-usage-event-attribution.ts @@ -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 diff --git a/src/main/codex-usage/codex-usage-long-context-billing.test.ts b/src/main/codex-usage/codex-usage-long-context-billing.test.ts index 9dc698a29eb..d095211610f 100644 --- a/src/main/codex-usage/codex-usage-long-context-billing.test.ts +++ b/src/main/codex-usage/codex-usage-long-context-billing.test.ts @@ -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) - }) -}) diff --git a/src/main/codex-usage/codex-usage-provider.ts b/src/main/codex-usage/codex-usage-provider.ts index ab8d0f4e7bc..8da60e3b175 100644 --- a/src/main/codex-usage/codex-usage-provider.ts +++ b/src/main/codex-usage/codex-usage-provider.ts @@ -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; diff --git a/src/main/codex-usage/codex-usage-record-parser.ts b/src/main/codex-usage/codex-usage-record-parser.ts index bd0de5b94cd..e09c7fd21f7 100644 --- a/src/main/codex-usage/codex-usage-record-parser.ts +++ b/src/main/codex-usage/codex-usage-record-parser.ts @@ -1,4 +1,4 @@ -import { extractString } from '../usage/usage-record-coercion' +import { extractString } from '../usage/source-input/usage-record-coercion' import { buildCodexUsageEventKey, normalizeRawUsage, diff --git a/src/main/codex-usage/codex-usage-rollup-projections.ts b/src/main/codex-usage/codex-usage-rollup-projections.ts index 614347aa2de..ea31ec29b7c 100644 --- a/src/main/codex-usage/codex-usage-rollup-projections.ts +++ b/src/main/codex-usage/codex-usage-rollup-projections.ts @@ -1,4 +1,4 @@ -import { highestUsageKey } from '../usage/highest-usage-key' +import { highestUsageKey } from '../usage/rollups/highest-usage-key' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, diff --git a/src/main/codex-usage/codex-usage-scope-filters.ts b/src/main/codex-usage/codex-usage-scope-filters.ts index de023f50864..ef3891e350e 100644 --- a/src/main/codex-usage/codex-usage-scope-filters.ts +++ b/src/main/codex-usage/codex-usage-scope-filters.ts @@ -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 diff --git a/src/main/codex-usage/codex-usage-token-delta.ts b/src/main/codex-usage/codex-usage-token-delta.ts index 9d391661bf9..497b7352fc2 100644 --- a/src/main/codex-usage/codex-usage-token-delta.ts +++ b/src/main/codex-usage/codex-usage-token-delta.ts @@ -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 diff --git a/src/main/codex-usage/scanner-incremental-append.test.ts b/src/main/codex-usage/scanner-incremental-append.test.ts index adfb0508d4a..06500ff51df 100644 --- a/src/main/codex-usage/scanner-incremental-append.test.ts +++ b/src/main/codex-usage/scanner-incremental-append.test.ts @@ -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() - } -}) +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('node:fs') - return { - ...actual, - createReadStream: ( - path: Parameters[0], - options?: Parameters[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('node:fs/promises') - return { - ...actual, - open: async (...args: Parameters) => { - 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('../usage/jsonl-line-offsets') - return { - ...actual, - readJsonlLinesFromOffset: async function* ( - ...args: Parameters - ) { - 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) - }) }) diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 1cc78258216..cdd1acf0d95 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -1,4 +1,4 @@ -import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver' +import { createUsageWorktreeResolver } from '../usage/worktree-attribution/usage-worktree-resolver' import { getLegacySourceSkipBytesByPath, listCodexSessionFiles, diff --git a/src/main/codex-usage/store-automation-usage.test.ts b/src/main/codex-usage/store-automation-usage.test.ts deleted file mode 100644 index ca0f8581bc3..00000000000 --- a/src/main/codex-usage/store-automation-usage.test.ts +++ /dev/null @@ -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((resolve) => { - startScan = resolve - }) - const scanFinished = new Promise((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') - }) -}) diff --git a/src/main/codex-usage/store-persistence.test.ts b/src/main/codex-usage/store-persistence.test.ts deleted file mode 100644 index 96294174cc3..00000000000 --- a/src/main/codex-usage/store-persistence.test.ts +++ /dev/null @@ -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 - }) - }) -}) diff --git a/src/main/codex-usage/store-test-harness.ts b/src/main/codex-usage/store-test-harness.ts index caab6237d71..8267abf6909 100644 --- a/src/main/codex-usage/store-test-harness.ts +++ b/src/main/codex-usage/store-test-harness.ts @@ -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' diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index aa7e6ff436e..7e5f5515c4b 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -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 diff --git a/src/main/ipc/usage-provider-handlers-readiness.test.ts b/src/main/ipc/usage-provider-handlers-readiness.test.ts deleted file mode 100644 index 50a02a8c425..00000000000 --- a/src/main/ipc/usage-provider-handlers-readiness.test.ts +++ /dev/null @@ -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 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 }[] -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[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 { - 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() - 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() -}) diff --git a/src/main/muse-usage/muse-usage-provider.ts b/src/main/muse-usage/muse-usage-provider.ts index f314dd6a070..04c08057e65 100644 --- a/src/main/muse-usage/muse-usage-provider.ts +++ b/src/main/muse-usage/muse-usage-provider.ts @@ -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. diff --git a/src/main/muse-usage/muse-usage-record-parser.ts b/src/main/muse-usage/muse-usage-record-parser.ts index 687ab69a46f..15d3bcb80a3 100644 --- a/src/main/muse-usage/muse-usage-record-parser.ts +++ b/src/main/muse-usage/muse-usage-record-parser.ts @@ -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 = { diff --git a/src/main/muse-usage/scanner.ts b/src/main/muse-usage/scanner.ts index 7418b462b78..fb89c0b10c1 100644 --- a/src/main/muse-usage/scanner.ts +++ b/src/main/muse-usage/scanner.ts @@ -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 { diff --git a/src/main/muse-usage/snapshot-rollups.ts b/src/main/muse-usage/snapshot-rollups.ts index 11794cb3779..f35cb30e9c1 100644 --- a/src/main/muse-usage/snapshot-rollups.ts +++ b/src/main/muse-usage/snapshot-rollups.ts @@ -1,4 +1,4 @@ -import { highestUsageKey } from '../usage/highest-usage-key' +import { highestUsageKey } from '../usage/rollups/highest-usage-key' import type { MuseUsageBreakdownKind, MuseUsageBreakdownRow, diff --git a/src/main/muse-usage/store.ts b/src/main/muse-usage/store.ts index 82207b0e367..da2ab662aff 100644 --- a/src/main/muse-usage/store.ts +++ b/src/main/muse-usage/store.ts @@ -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 { diff --git a/src/main/muse-usage/types.ts b/src/main/muse-usage/types.ts index 8d9e3956253..dac2a88bdcd 100644 --- a/src/main/muse-usage/types.ts +++ b/src/main/muse-usage/types.ts @@ -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 diff --git a/src/main/opencode-usage/opencode-usage-provider.ts b/src/main/opencode-usage/opencode-usage-provider.ts index f9d1ce68e5a..1911b5c5623 100644 --- a/src/main/opencode-usage/opencode-usage-provider.ts +++ b/src/main/opencode-usage/opencode-usage-provider.ts @@ -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. diff --git a/src/main/opencode-usage/opencode-usage-row-parsing.ts b/src/main/opencode-usage/opencode-usage-row-parsing.ts index 504a15efd63..b254787f582 100644 --- a/src/main/opencode-usage/opencode-usage-row-parsing.ts +++ b/src/main/opencode-usage/opencode-usage-row-parsing.ts @@ -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' diff --git a/src/main/opencode-usage/opencode-usage-worktree-attribution.ts b/src/main/opencode-usage/opencode-usage-worktree-attribution.ts index 3f236ccc1d2..ef4265478be 100644 --- a/src/main/opencode-usage/opencode-usage-worktree-attribution.ts +++ b/src/main/opencode-usage/opencode-usage-worktree-attribution.ts @@ -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 diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 4748ab61d2a..1fefb4f279a 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -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, diff --git a/src/main/opencode-usage/scope-range-filter.ts b/src/main/opencode-usage/scope-range-filter.ts index 5c9fe6db2f0..d851c37d155 100644 --- a/src/main/opencode-usage/scope-range-filter.ts +++ b/src/main/opencode-usage/scope-range-filter.ts @@ -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[], diff --git a/src/main/opencode-usage/snapshot-rollups.ts b/src/main/opencode-usage/snapshot-rollups.ts index 84e91d7ae78..065704479ad 100644 --- a/src/main/opencode-usage/snapshot-rollups.ts +++ b/src/main/opencode-usage/snapshot-rollups.ts @@ -1,4 +1,4 @@ -import { highestUsageKey } from '../usage/highest-usage-key' +import { highestUsageKey } from '../usage/rollups/highest-usage-key' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index dee4c5b3eb6..790c6d9325b 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -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 diff --git a/src/main/usage/agent-token-usage-reporter.test.ts b/src/main/usage/agent-token-usage-reporter.test.ts deleted file mode 100644 index 02df9436965..00000000000 --- a/src/main/usage/agent-token-usage-reporter.test.ts +++ /dev/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([]) - }) -}) diff --git a/src/main/usage/usage-cache-json-integrity.ts b/src/main/usage/cache/usage-cache-json-integrity.ts similarity index 100% rename from src/main/usage/usage-cache-json-integrity.ts rename to src/main/usage/cache/usage-cache-json-integrity.ts diff --git a/src/main/usage/usage-cache-snapshot-writer.test.ts b/src/main/usage/cache/usage-cache-snapshot-writer.test.ts similarity index 100% rename from src/main/usage/usage-cache-snapshot-writer.test.ts rename to src/main/usage/cache/usage-cache-snapshot-writer.test.ts diff --git a/src/main/usage/usage-cache-snapshot-writer.ts b/src/main/usage/cache/usage-cache-snapshot-writer.ts similarity index 97% rename from src/main/usage/usage-cache-snapshot-writer.ts rename to src/main/usage/cache/usage-cache-snapshot-writer.ts index 4e1dd34b4ff..f3f2527ef4f 100644 --- a/src/main/usage/usage-cache-snapshot-writer.ts +++ b/src/main/usage/cache/usage-cache-snapshot-writer.ts @@ -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 { diff --git a/src/main/usage/usage-inline-cache-preservation.ts b/src/main/usage/cache/usage-inline-cache-preservation.ts similarity index 100% rename from src/main/usage/usage-inline-cache-preservation.ts rename to src/main/usage/cache/usage-inline-cache-preservation.ts diff --git a/src/main/usage/usage-source-cache-file.test.ts b/src/main/usage/cache/usage-source-cache-file.test.ts similarity index 94% rename from src/main/usage/usage-source-cache-file.test.ts rename to src/main/usage/cache/usage-source-cache-file.test.ts index 5169ebf6d28..b347b8d1e04 100644 --- a/src/main/usage/usage-source-cache-file.test.ts +++ b/src/main/usage/cache/usage-source-cache-file.test.ts @@ -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' }]) diff --git a/src/main/usage/usage-source-cache-file.ts b/src/main/usage/cache/usage-source-cache-file.ts similarity index 98% rename from src/main/usage/usage-source-cache-file.ts rename to src/main/usage/cache/usage-source-cache-file.ts index 9c23a6c0db9..81e7e51463a 100644 --- a/src/main/usage/usage-source-cache-file.ts +++ b/src/main/usage/cache/usage-source-cache-file.ts @@ -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. diff --git a/src/main/usage/highest-usage-key.test.ts b/src/main/usage/highest-usage-key.test.ts deleted file mode 100644 index 27f78c00edf..00000000000 --- a/src/main/usage/highest-usage-key.test.ts +++ /dev/null @@ -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) -}) diff --git a/src/main/usage/jsonl-file-snapshot.test.ts b/src/main/usage/jsonl-file-snapshot.test.ts deleted file mode 100644 index c39abc7fcac..00000000000 --- a/src/main/usage/jsonl-file-snapshot.test.ts +++ /dev/null @@ -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() - } - } - ) -}) diff --git a/src/main/usage/automation-usage-scan-forcing.ts b/src/main/usage/provider-lifecycle/automation-usage-scan-forcing.ts similarity index 100% rename from src/main/usage/automation-usage-scan-forcing.ts rename to src/main/usage/provider-lifecycle/automation-usage-scan-forcing.ts diff --git a/src/main/usage/usage-provider-contract.ts b/src/main/usage/provider-lifecycle/usage-provider-contract.ts similarity index 93% rename from src/main/usage/usage-provider-contract.ts rename to src/main/usage/provider-lifecycle/usage-provider-contract.ts index f46fd56809a..30aa919a94e 100644 --- a/src/main/usage/usage-provider-contract.ts +++ b/src/main/usage/provider-lifecycle/usage-provider-contract.ts @@ -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. */ diff --git a/src/main/usage/provider-lifecycle/usage-provider-store-contract.ts b/src/main/usage/provider-lifecycle/usage-provider-store-contract.ts new file mode 100644 index 00000000000..586f64a70e8 --- /dev/null +++ b/src/main/usage/provider-lifecycle/usage-provider-store-contract.ts @@ -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 = { + schemaVersion: number + worktreeFingerprint: string | null + sessions: unknown[] + dailyAggregates: unknown[] + scanState: UsageProviderScanState +} & Record + +export type UsageProviderStoreLifecycleConfig< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> = { + logTag: string + resolveCacheFile: () => string + createDefaultState: () => State + normalizeState: (state: State) => State + parseReport?: ( + text: string, + parsed?: State, + integrityVerified?: boolean + ) => State | Promise + serializeReport?: (state: State) => string + providerId?: 'claude' + sourceKey: SourceKey + dataPresenceKey: DataPresenceKey + tokenUsage?: { + provider: AgentTokenUsage['provider'] + selectSessions: (state: State) => AgentTokenSession[] + } + scan: ( + worktrees: UsageScanWorktreeRef[], + sourceCache: UsageSourceCacheRef + ) => Promise> + splitCacheFile: (request: UsageCacheSplitRequest) => Promise +} + +export type PublicUsageProviderScanState = + UsageProviderScanState & { + isScanning: boolean + } & Record diff --git a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts new file mode 100644 index 00000000000..dd1f60c8e48 --- /dev/null +++ b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts @@ -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 +type TestScan = ( + worktrees: UsageScanWorktreeRef[], + sourceCache: UsageSourceCacheRef +) => Promise +type TestSplit = (request: UsageCacheSplitRequest) => Promise + +const NOW = Date.parse('2026-04-10T16:00:00.000Z') + +function makeState( + overrides: Partial> & { scanState?: Partial } = {} +): 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> + let split: ReturnType> + + 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().mockResolvedValue(emptyScanResult()) + // Run the real cache split in-process; the worker only wraps it in a thread. + split = vi.fn(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') + }) +}) diff --git a/src/main/usage/usage-provider-store-lifecycle.ts b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts similarity index 80% rename from src/main/usage/usage-provider-store-lifecycle.ts rename to src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts index 8bbb8c8d6c4..fe3ed77846b 100644 --- a/src/main/usage/usage-provider-store-lifecycle.ts +++ b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts @@ -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 = { - schemaVersion: number - worktreeFingerprint: string | null - sessions: unknown[] - dailyAggregates: unknown[] - scanState: UsageProviderScanState -} & Record - -type UsageProviderStoreLifecycleConfig< - SourceKey extends string, - State extends UsageProviderStoreState, - DataPresenceKey extends string -> = { - logTag: string - resolveCacheFile: () => string - createDefaultState: () => State - normalizeState: (state: State) => State - parseReport?: ( - text: string, - parsed?: State, - integrityVerified?: boolean - ) => State | Promise - serializeReport?: (state: State) => string - providerId?: 'claude' - sourceKey: SourceKey - dataPresenceKey: DataPresenceKey - tokenUsage?: { - provider: AgentTokenUsage['provider'] - selectSessions: (state: State) => AgentTokenSession[] - } - scan: ( - worktrees: UsageScanWorktreeRef[], - sourceCache: UsageSourceCacheRef - ) => Promise> - splitCacheFile: (request: UsageCacheSplitRequest) => Promise -} - -type PublicUsageProviderScanState = UsageProviderScanState & { - isScanning: boolean -} & Record - export abstract class UsageProviderStoreLifecycle< SourceKey extends string, State extends UsageProviderStoreState, diff --git a/src/main/usage/usage-calendar-range.test.ts b/src/main/usage/reporting/range-query/usage-calendar-range.test.ts similarity index 100% rename from src/main/usage/usage-calendar-range.test.ts rename to src/main/usage/reporting/range-query/usage-calendar-range.test.ts diff --git a/src/main/usage/usage-calendar-range.ts b/src/main/usage/reporting/range-query/usage-calendar-range.ts similarity index 100% rename from src/main/usage/usage-calendar-range.ts rename to src/main/usage/reporting/range-query/usage-calendar-range.ts diff --git a/src/main/usage/usage-scope-filters.ts b/src/main/usage/reporting/range-query/usage-scope-filters.ts similarity index 100% rename from src/main/usage/usage-scope-filters.ts rename to src/main/usage/reporting/range-query/usage-scope-filters.ts diff --git a/src/main/usage/agent-token-usage-reporter.ts b/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts similarity index 94% rename from src/main/usage/agent-token-usage-reporter.ts rename to src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts index a856c1ce80d..831c979be04 100644 --- a/src/main/usage/agent-token-usage-reporter.ts +++ b/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts @@ -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 diff --git a/src/main/usage/agent-token-usage.ts b/src/main/usage/reporting/token-usage/agent-token-usage.ts similarity index 86% rename from src/main/usage/agent-token-usage.ts rename to src/main/usage/reporting/token-usage/agent-token-usage.ts index bc6868d10fc..e49a19fda68 100644 --- a/src/main/usage/agent-token-usage.ts +++ b/src/main/usage/reporting/token-usage/agent-token-usage.ts @@ -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 } diff --git a/src/main/usage/analytics-session-id-store.test.ts b/src/main/usage/reporting/token-usage/analytics-session-id-store.test.ts similarity index 100% rename from src/main/usage/analytics-session-id-store.test.ts rename to src/main/usage/reporting/token-usage/analytics-session-id-store.test.ts diff --git a/src/main/usage/analytics-session-id-store.ts b/src/main/usage/reporting/token-usage/analytics-session-id-store.ts similarity index 97% rename from src/main/usage/analytics-session-id-store.ts rename to src/main/usage/reporting/token-usage/analytics-session-id-store.ts index 45665c2f1d6..e92ed1686ef 100644 --- a/src/main/usage/analytics-session-id-store.ts +++ b/src/main/usage/reporting/token-usage/analytics-session-id-store.ts @@ -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() diff --git a/src/main/usage/highest-usage-key.ts b/src/main/usage/rollups/highest-usage-key.ts similarity index 100% rename from src/main/usage/highest-usage-key.ts rename to src/main/usage/rollups/highest-usage-key.ts diff --git a/src/main/usage/usage-breakdown-scaling.test.ts b/src/main/usage/rollups/usage-breakdown-scaling.test.ts similarity index 56% rename from src/main/usage/usage-breakdown-scaling.test.ts rename to src/main/usage/rollups/usage-breakdown-scaling.test.ts index f455a2a29d6..2c207b970be 100644 --- a/src/main/usage/usage-breakdown-scaling.test.ts +++ b/src/main/usage/rollups/usage-breakdown-scaling.test.ts @@ -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()) { diff --git a/src/main/usage/usage-event-aggregation.ts b/src/main/usage/rollups/usage-event-aggregation.ts similarity index 100% rename from src/main/usage/usage-event-aggregation.ts rename to src/main/usage/rollups/usage-event-aggregation.ts diff --git a/src/main/usage/usage-rollup-merge.ts b/src/main/usage/rollups/usage-rollup-merge.ts similarity index 100% rename from src/main/usage/usage-rollup-merge.ts rename to src/main/usage/rollups/usage-rollup-merge.ts diff --git a/src/main/usage/usage-rollup-records.ts b/src/main/usage/rollups/usage-rollup-records.ts similarity index 100% rename from src/main/usage/usage-rollup-records.ts rename to src/main/usage/rollups/usage-rollup-records.ts diff --git a/src/main/usage/usage-session-breakdown-index.ts b/src/main/usage/rollups/usage-session-breakdown-index.ts similarity index 100% rename from src/main/usage/usage-session-breakdown-index.ts rename to src/main/usage/rollups/usage-session-breakdown-index.ts diff --git a/src/main/usage/usage-scan-worker-cache-cleanup.ts b/src/main/usage/scan-worker/usage-scan-worker-cache-cleanup.ts similarity index 85% rename from src/main/usage/usage-scan-worker-cache-cleanup.ts rename to src/main/usage/scan-worker/usage-scan-worker-cache-cleanup.ts index d7486a62400..d80116bfe0c 100644 --- a/src/main/usage/usage-scan-worker-cache-cleanup.ts +++ b/src/main/usage/scan-worker/usage-scan-worker-cache-cleanup.ts @@ -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 diff --git a/src/main/usage/usage-scan-worker-client.ts b/src/main/usage/scan-worker/usage-scan-worker-client.ts similarity index 93% rename from src/main/usage/usage-scan-worker-client.ts rename to src/main/usage/scan-worker/usage-scan-worker-client.ts index 78940cfe1c0..80dcde2868d 100644 --- a/src/main/usage/usage-scan-worker-client.ts +++ b/src/main/usage/scan-worker/usage-scan-worker-client.ts @@ -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' diff --git a/src/main/usage/usage-scan-worker-entry.ts b/src/main/usage/scan-worker/usage-scan-worker-entry.ts similarity index 87% rename from src/main/usage/usage-scan-worker-entry.ts rename to src/main/usage/scan-worker/usage-scan-worker-entry.ts index 8ca4fdb790c..8fb20e13802 100644 --- a/src/main/usage/usage-scan-worker-entry.ts +++ b/src/main/usage/scan-worker/usage-scan-worker-entry.ts @@ -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. diff --git a/src/main/usage/usage-scan-worker-protocol.ts b/src/main/usage/scan-worker/usage-scan-worker-protocol.ts similarity index 92% rename from src/main/usage/usage-scan-worker-protocol.ts rename to src/main/usage/scan-worker/usage-scan-worker-protocol.ts index b005bf99076..e5ac4f94f11 100644 --- a/src/main/usage/usage-scan-worker-protocol.ts +++ b/src/main/usage/scan-worker/usage-scan-worker-protocol.ts @@ -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. diff --git a/src/main/usage/usage-scan-worker-spawn.ts b/src/main/usage/scan-worker/usage-scan-worker-spawn.ts similarity index 87% rename from src/main/usage/usage-scan-worker-spawn.ts rename to src/main/usage/scan-worker/usage-scan-worker-spawn.ts index e83e9ee1288..64fcdcd242b 100644 --- a/src/main/usage/usage-scan-worker-spawn.ts +++ b/src/main/usage/scan-worker/usage-scan-worker-spawn.ts @@ -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, diff --git a/src/main/usage/jsonl-file-checkpoint.ts b/src/main/usage/source-input/jsonl/jsonl-file-checkpoint.ts similarity index 100% rename from src/main/usage/jsonl-file-checkpoint.ts rename to src/main/usage/source-input/jsonl/jsonl-file-checkpoint.ts diff --git a/src/main/usage/jsonl-file-reader.test.ts b/src/main/usage/source-input/jsonl/jsonl-file-reader.test.ts similarity index 76% rename from src/main/usage/jsonl-file-reader.test.ts rename to src/main/usage/source-input/jsonl/jsonl-file-reader.test.ts index daf2ee59a5d..6bac9ec055c 100644 --- a/src/main/usage/jsonl-file-reader.test.ts +++ b/src/main/usage/source-input/jsonl/jsonl-file-reader.test.ts @@ -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) diff --git a/src/main/usage/jsonl-file-snapshot.ts b/src/main/usage/source-input/jsonl/jsonl-file-snapshot.ts similarity index 100% rename from src/main/usage/jsonl-file-snapshot.ts rename to src/main/usage/source-input/jsonl/jsonl-file-snapshot.ts diff --git a/src/main/usage/jsonl-line-offsets.test.ts b/src/main/usage/source-input/jsonl/jsonl-line-offsets.test.ts similarity index 100% rename from src/main/usage/jsonl-line-offsets.test.ts rename to src/main/usage/source-input/jsonl/jsonl-line-offsets.test.ts diff --git a/src/main/usage/jsonl-line-offsets.ts b/src/main/usage/source-input/jsonl/jsonl-line-offsets.ts similarity index 100% rename from src/main/usage/jsonl-line-offsets.ts rename to src/main/usage/source-input/jsonl/jsonl-line-offsets.ts diff --git a/src/main/usage/usage-record-coercion.ts b/src/main/usage/source-input/usage-record-coercion.ts similarity index 100% rename from src/main/usage/usage-record-coercion.ts rename to src/main/usage/source-input/usage-record-coercion.ts diff --git a/src/main/usage/usage-provider-store-initialization.test.ts b/src/main/usage/usage-provider-store-initialization.test.ts deleted file mode 100644 index 5a672ca0902..00000000000 --- a/src/main/usage/usage-provider-store-initialization.test.ts +++ /dev/null @@ -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('node:fs/promises') - return { - ...actual, - open: async (...args: Parameters) => { - 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 -type Scan = ( - worktrees: UsageScanWorktreeRef[], - sourceCache: UsageSourceCacheRef -) => Promise -type Split = (request: UsageCacheSplitRequest) => Promise -type LoadOptions = { - splitCacheFile: Split - scan?: Scan - parseReport?: (text: string) => TestState | Promise - 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() - releasePending.push(() => pending.resolve({ reportText: reportText(state), migrated })) - return pending -} - -function nextTurn(): Promise { - 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() - releasePending.push(() => scanPending.resolve(defaultState())) - const scan = vi.fn(() => 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() - releasePending.push(() => parsed.resolve(reportState(cached))) - const parseReport = vi.fn(() => parsed.promise) - const store = createStore({ splitCacheFile: vi.fn(), 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() - 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([]) -}) diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts deleted file mode 100644 index dac20b0089c..00000000000 --- a/src/main/usage/usage-provider-store-lifecycle.test.ts +++ /dev/null @@ -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('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] === 'w') { - writeProbe.opens += 1 - if (writeProbe.blocked) { - await new Promise((resolve) => writeProbe.waiters.push(resolve)) - } - } - return actual.open(...args) - }) as typeof actual.open, - rename: ((...args: Parameters) => { - 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 -type TestScan = ( - worktrees: UsageScanWorktreeRef[], - sourceCache: UsageSourceCacheRef -) => Promise -type TestSplit = (request: UsageCacheSplitRequest) => Promise - -const NOW = Date.parse('2026-04-10T16:00:00.000Z') -const EMPTY_WORKTREE_FINGERPRINT = '[]' - -function makeState( - overrides: Partial> & { scanState?: Partial } = {} -): 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(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void - const promise = new Promise((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> - let split: ReturnType> - - 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().mockResolvedValue(emptyScanResult()) - // Run the real cache split in-process; the worker only wraps it in a thread. - split = vi.fn(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() - 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() - 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' - ) - }) -}) diff --git a/src/main/usage/usage-scan-worker-cache-cleanup.test.ts b/src/main/usage/usage-scan-worker-cache-cleanup.test.ts deleted file mode 100644 index 7e011fe775f..00000000000 --- a/src/main/usage/usage-scan-worker-cache-cleanup.test.ts +++ /dev/null @@ -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 - ) -}) diff --git a/src/main/usage/usage-worktree-canonicalizer.test.ts b/src/main/usage/usage-worktree-canonicalizer.test.ts deleted file mode 100644 index 8ab8de9b580..00000000000 --- a/src/main/usage/usage-worktree-canonicalizer.test.ts +++ /dev/null @@ -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' - }) - }) -}) diff --git a/src/main/usage/usage-worktree-metadata.test.ts b/src/main/usage/usage-worktree-metadata.test.ts deleted file mode 100644 index 8f887249f79..00000000000 --- a/src/main/usage/usage-worktree-metadata.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/src/main/usage/usage-worktree-resolver.test.ts b/src/main/usage/usage-worktree-resolver.test.ts deleted file mode 100644 index 568d397d95d..00000000000 --- a/src/main/usage/usage-worktree-resolver.test.ts +++ /dev/null @@ -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() - 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() - }) -}) diff --git a/src/main/usage/usage-event-attribution.ts b/src/main/usage/worktree-attribution/usage-event-attribution.ts similarity index 94% rename from src/main/usage/usage-event-attribution.ts rename to src/main/usage/worktree-attribution/usage-event-attribution.ts index 25c696e6263..4f70df6229e 100644 --- a/src/main/usage/usage-event-attribution.ts +++ b/src/main/usage/worktree-attribution/usage-event-attribution.ts @@ -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' diff --git a/src/main/usage/usage-path-comparison.ts b/src/main/usage/worktree-attribution/usage-path-comparison.ts similarity index 100% rename from src/main/usage/usage-path-comparison.ts rename to src/main/usage/worktree-attribution/usage-path-comparison.ts diff --git a/src/main/usage/usage-worktree-canonicalizer.ts b/src/main/usage/worktree-attribution/usage-worktree-canonicalizer.ts similarity index 100% rename from src/main/usage/usage-worktree-canonicalizer.ts rename to src/main/usage/worktree-attribution/usage-worktree-canonicalizer.ts diff --git a/src/main/usage/usage-worktree-metadata.ts b/src/main/usage/worktree-attribution/usage-worktree-metadata.ts similarity index 87% rename from src/main/usage/usage-worktree-metadata.ts rename to src/main/usage/worktree-attribution/usage-worktree-metadata.ts index 2db0e248cf7..91d040da81d 100644 --- a/src/main/usage/usage-worktree-metadata.ts +++ b/src/main/usage/worktree-attribution/usage-worktree-metadata.ts @@ -1,13 +1,13 @@ import { basename } from 'node:path' -import type { Repo } from '../../shared/repo-types' +import type { Repo } from '../../../shared/repo-types' import { getRepoMainWorktreeId, splitWorktreeId, splitWorktreeIdForFilesystem -} from '../../shared/worktree/id' -import { isFolderRepo } from '../../shared/repo-kind' -import type { Store } from '../persistence/loading-store/store' -import { getRepoRoutingConnectionId } from '../../shared/execution-host' +} from '../../../shared/worktree/id' +import { isFolderRepo } from '../../../shared/repo-kind' +import type { Store } from '../../persistence/loading-store/store' +import { getRepoRoutingConnectionId } from '../../../shared/execution-host' export type UsageWorktreeRef = { worktreeId: string diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/worktree-attribution/usage-worktree-refs.ts similarity index 87% rename from src/main/usage/usage-worktree-refs.ts rename to src/main/usage/worktree-attribution/usage-worktree-refs.ts index 77409d8cc05..0afe3afb5bf 100644 --- a/src/main/usage/usage-worktree-refs.ts +++ b/src/main/usage/worktree-attribution/usage-worktree-refs.ts @@ -1,6 +1,6 @@ -import type { Repo } from '../../shared/repo-types' +import type { Repo } from '../../../shared/repo-types' import type { UsageWorktreeRef } from './usage-worktree-metadata' -import type { UsageScanWorktreeRef } from './usage-provider-contract' +import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract' export function getUsageWorktreeFingerprint( worktreesByRepo: Map diff --git a/src/main/usage/worktree-attribution/usage-worktree-resolver.test.ts b/src/main/usage/worktree-attribution/usage-worktree-resolver.test.ts new file mode 100644 index 00000000000..bde40c217ce --- /dev/null +++ b/src/main/usage/worktree-attribution/usage-worktree-resolver.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract' + +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', () => { + 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() + }) +}) diff --git a/src/main/usage/usage-worktree-resolver.ts b/src/main/usage/worktree-attribution/usage-worktree-resolver.ts similarity index 93% rename from src/main/usage/usage-worktree-resolver.ts rename to src/main/usage/worktree-attribution/usage-worktree-resolver.ts index 2a3e128d623..7d10a04a6e9 100644 --- a/src/main/usage/usage-worktree-resolver.ts +++ b/src/main/usage/worktree-attribution/usage-worktree-resolver.ts @@ -1,9 +1,9 @@ import { realpath } from 'node:fs/promises' import { posix, win32 } from 'node:path' -import { areWorktreePathsEqual } from '../git/worktrees/catalog/worktree-path-comparison' +import { areWorktreePathsEqual } from '../../git/worktrees/catalog/worktree-path-comparison' import { canonicalizeUsageWorktreePaths } from './usage-worktree-canonicalizer' import { looksLikeWindowsPath, normalizeFsPath } from './usage-path-comparison' -import type { UsageScanWorktreeRef } from './usage-provider-contract' +import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract' type CanonicalizedUsageWorktreeRef = UsageScanWorktreeRef & { canonicalPath: string } diff --git a/src/renderer/src/components/editor/csv/CsvGrid.test.tsx b/src/renderer/src/components/editor/csv/CsvGrid.test.tsx deleted file mode 100644 index ecf01c6ad14..00000000000 --- a/src/renderer/src/components/editor/csv/CsvGrid.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { CsvGrid } from './CsvGrid' -import type * as VirtualLibrary from '@tanstack/react-virtual' - -vi.mock('@tanstack/react-virtual', async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - useVirtualizer: (options: Parameters[0]) => - original.useVirtualizer({ - ...options, - initialRect: { width: 500, height: 300 }, - observeElementRect: (_instance, callback) => { - callback({ width: 500, height: 300 }) - return () => {} - }, - observeElementOffset: (instance, callback) => { - const element = instance.scrollElement - const onScroll = (): void => - callback( - (instance.options.horizontal ? element?.scrollLeft : element?.scrollTop) ?? 0, - false - ) - element?.addEventListener('scroll', onScroll) - onScroll() - return () => element?.removeEventListener('scroll', onScroll) - } - }) - } -}) -afterEach(cleanup) - -it('returns to existing rows when a refresh shrinks the file below its selected row window', async () => { - const grid = (rowCount: number) => ( - [`r${index}`]} - /> - ) - const { rerender } = render(grid(1_500_100)) - fireEvent.click(screen.getByRole('button', { name: 'Next rows' })) - await waitFor(() => expect(screen.getByRole('cell', { name: 'r500000' })).toBeTruthy()) - rerender(grid(3)) - await waitFor(() => expect(screen.getByRole('cell', { name: 'r0' })).toBeTruthy()) - expect(screen.getAllByRole('row')).toHaveLength(4) -}) - -it('retains user widths when refreshed data changes the size estimates', async () => { - const grid = (header: string[]) => ( - ['a', 'b']} - /> - ) - const { rerender } = render(grid(['name', 'value'])) - const handle = screen.getByRole('separator', { name: 'Resize column 1' }) - fireEvent.keyDown(handle, { key: 'ArrowRight', shiftKey: true }) - rerender(grid(['a much longer header changes the width estimate', 'value'])) - await waitFor(() => expect(handle.getAttribute('aria-valuenow')).toBe('120')) - const template = screen.getAllByRole('row')[0]?.style.gridTemplateColumns - expect( - screen.getAllByRole('row').every((row) => row.style.gridTemplateColumns === template) - ).toBe(true) - fireEvent.keyDown(handle, { key: 'Home' }) - await waitFor(() => expect(handle.getAttribute('aria-valuenow')).toBe('320')) -}) - -it('makes rows beyond the browser layout limit reachable', async () => { - render( - [String(index)]} - /> - ) - const next = screen.getByRole('button', { name: 'Next rows' }) - fireEvent.click(next) - await waitFor(() => - expect(screen.getAllByRole('row')[1]?.getAttribute('aria-rowindex')).toBe('500002') - ) - fireEvent.click(next) - fireEvent.click(next) - expect(next.getAttribute('disabled')).not.toBeNull() - expect(screen.getByText('Rows 1,500,001–1,500,100')).toBeTruthy() -}) diff --git a/src/renderer/src/components/editor/csv/CsvViewer.editing.test.tsx b/src/renderer/src/components/editor/csv/CsvViewer.editing.test.tsx index 07011de9507..1891273eea4 100644 --- a/src/renderer/src/components/editor/csv/CsvViewer.editing.test.tsx +++ b/src/renderer/src/components/editor/csv/CsvViewer.editing.test.tsx @@ -1,151 +1,16 @@ // @vitest-environment happy-dom -import { useState } from 'react' -import { renderHook, cleanup, fireEvent, render, screen, act } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import CsvViewer from './CsvViewer' -import { useCsvTableEditor } from './useCsvTableEditor' -import { parseCsvTextDocument } from './csv-text-document' -import { - flushPendingEditorBuffer, - hasPendingEditorBufferChange -} from '../save/flush/editor-pending-flush' -import { APP_MENU_PASTE_EVENT } from '@/lib/clipboard/menu/app-menu-paste' -import { ORCA_EDITOR_FILE_SAVED_EVENT } from '../autosave/editor-autosave' -import { EMPTY_EDITOR_BUFFER_VIEW_INDEX as NO_SHARING } from '@/store/slices/editor/buffers/editor-buffer-views' -vi.mock('@tanstack/react-virtual', () => ({ - useVirtualizer: ({ - count, - estimateSize, - paddingStart = 0 - }: { - count: number - estimateSize: (index: number) => number - paddingStart?: number - }) => ({ - getVirtualItems: () => - Array.from({ length: Math.min(count, 20) }, (_, index) => ({ - index, - key: index, - start: paddingStart + index * estimateSize(index), - size: estimateSize(index), - end: paddingStart + (index + 1) * estimateSize(index) - })), - getTotalSize: () => paddingStart + count * estimateSize(0), - resizeItem: vi.fn(), - measure: vi.fn(), - scrollToIndex: vi.fn() - }) -})) +import { renderHook, cleanup, act } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useCsvTableEditor } from './editing/useCsvTableEditor' +import { parseCsvTextDocument } from './document/csv-text-document' afterEach(() => { cleanup() - vi.unstubAllGlobals() - localStorage.clear() - Reflect.deleteProperty(window, 'api') }) -function setup(source = '\ufeffname,value\r\nB,2\r\nA,1') { - const changes = vi.fn() - const save = vi.fn().mockResolvedValue(true) - function Editor() { - const [content, setContent] = useState(source) - const [dirty, setDirty] = useState(false) - return ( - <> - { - changes(next) - setContent(next) - setDirty(next !== source) - }} - onSave={save} - /> - {content} - {String(dirty)} - - ) - } - return { ...render(), changes, save } -} - -function edit(value: string, next: string): HTMLElement { - fireEvent.doubleClick(screen.getByRole('gridcell', { name: value })) - const input = screen.getByRole('textbox', { name: /Edit row/ }) - fireEvent.change(input, { target: { value: next } }) - return input -} - describe('editable CSV table', () => { - it('commits escaped multiline values while preserving BOM, endings and source order', () => { - const { changes } = setup() - const input = edit('B', 'x,"quoted"\nnext') - expect(screen.getByTestId('dirty').textContent).toBe('true') - fireEvent.keyDown(input, { key: 'Enter' }) - expect(changes).toHaveBeenCalledTimes(1) - expect(screen.getByTestId('source').textContent).toBe( - '\ufeffname,value\r\n"x,""quoted""\nnext",2\r\nA,1' - ) - }) - - it('Escape cancels before the following blur can commit, preserving earlier dirty state', () => { - const { changes } = setup() - fireEvent.keyDown(edit('B', 'discarded'), { key: 'Escape' }) - expect(changes).not.toHaveBeenCalled() - expect(screen.getByTestId('dirty').textContent).toBe('false') - fireEvent.keyDown(edit('B', 'kept'), { key: 'Enter' }) - fireEvent.keyDown(edit('A', 'discarded too'), { key: 'Escape' }) - expect(changes).toHaveBeenCalledTimes(1) - expect(screen.getByTestId('dirty').textContent).toBe('true') - }) - - it('edits a filtered row in its original source position', () => { - setup() - fireEvent.change(screen.getByRole('textbox', { name: 'Filter rows' }), { - target: { value: 'A' } - }) - fireEvent.keyDown(edit('1', 'updated'), { key: 'Enter' }) - expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nB,2\r\nA,updated') - }) - - it('cancels only the new cell after an earlier save completes', () => { - setup() - fireEvent.keyDown(edit('B', 'saved base'), { key: 'Enter' }) - const source = screen.getByTestId('source').textContent - const input = edit('A', 'new pending cell') - expect(hasPendingEditorBufferChange(NO_SHARING, 'csv-edit-test')).toBe(true) - act(() => - window.dispatchEvent( - new CustomEvent(ORCA_EDITOR_FILE_SAVED_EVENT, { - detail: { fileId: 'csv-edit-test', content: source } - }) - ) - ) - fireEvent.keyDown(input, { key: 'Escape' }) - expect(hasPendingEditorBufferChange(NO_SHARING, 'csv-edit-test')).toBe(false) - expect(screen.getByTestId('dirty').textContent).toBe('false') - expect(screen.getByTestId('source').textContent).toBe(source) - }) - - it('blocks table mutations after a rejected pending edit and preserves its recovery draft', () => { - const { changes, unmount } = setup() - const value = 'x'.repeat(1024 * 1024 + 1) - fireEvent.keyDown(edit('B', value), { key: 'Enter' }) - fireEvent.keyDown(screen.getByRole('button', { name: 'Rows' }), { - key: 'ArrowDown' - }) - fireEvent.click(screen.getByRole('menuitem', { name: 'Insert row below' })) - expect(changes).not.toHaveBeenCalled() - expect(screen.getByTestId('source').textContent).toBe('\ufeffname,value\r\nB,2\r\nA,1') - unmount() - expect(changes).toHaveBeenCalledWith(`\ufeffname,value\r\n${value},2\r\nA,1`) - }) - it('applies a pending cell and a subsequent structural mutation atomically', () => { const changes = vi.fn() const { result } = renderHook(() => @@ -164,92 +29,4 @@ describe('editable CSV table', () => { expect(changes).toHaveBeenCalledExactlyOnceWith('name,new,value\npending,,2') expect(result.current.interaction?.editing).toBeNull() }) - - it('routes menu paste only to the focused grid when two file panes are mounted', async () => { - const first = vi.fn() - const second = vi.fn() - const read = vi.fn().mockResolvedValue('pasted') - Object.defineProperty(window, 'api', { - configurable: true, - value: { ui: { readClipboardText: read } } - }) - render( - <> - - - - ) - fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'first' }), { button: 0 }) - fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'second' }), { button: 0 }) - await act(async () => { - window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true })) - await Promise.resolve() - }) - expect(read).toHaveBeenCalledTimes(1) - expect(first).not.toHaveBeenCalled() - expect(second).toHaveBeenCalledWith('header\npasted') - }) - - it('cancels delayed app-menu paste when focus moves to another file pane', async () => { - const first = vi.fn() - const second = vi.fn() - let resolveRead: (value: string) => void = () => {} - const pending = new Promise((resolve) => { - resolveRead = resolve - }) - const read = vi.fn(() => pending) - Object.defineProperty(window, 'api', { - configurable: true, - value: { ui: { readClipboardText: read } } - }) - render( - <> - - - - ) - fireEvent.pointerDown(screen.getByRole('gridcell', { name: 'first' }), { button: 0 }) - act(() => { - window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true })) - }) - expect(read).toHaveBeenCalledTimes(1) - act(() => { - screen.getAllByRole('grid')[1]?.focus() - }) - await act(async () => { - resolveRead('stale paste') - await pending - }) - expect(first).not.toHaveBeenCalled() - expect(second).not.toHaveBeenCalled() - }) - - it('flushes an edited pane even when another pane of the same file registered later', () => { - const first = vi.fn() - const second = vi.fn() - render( - <> - - - - ) - fireEvent.doubleClick(screen.getAllByRole('gridcell', { name: 'first' })[0]!) - fireEvent.change(screen.getByRole('textbox', { name: /Edit row/ }), { - target: { value: 'pending first pane' } - }) - expect(hasPendingEditorBufferChange(NO_SHARING, 'same-file')).toBe(true) - act(() => flushPendingEditorBuffer(NO_SHARING, 'same-file')) - expect(first).toHaveBeenCalledWith('header\npending first pane') - expect(second).not.toHaveBeenCalled() - }) }) diff --git a/src/renderer/src/components/editor/csv/CsvViewer.tsx b/src/renderer/src/components/editor/csv/CsvViewer.tsx index 058abc065ce..726d02800bf 100644 --- a/src/renderer/src/components/editor/csv/CsvViewer.tsx +++ b/src/renderer/src/components/editor/csv/CsvViewer.tsx @@ -1,15 +1,15 @@ import { useCallback, useMemo, useState } from 'react' -import { detectCsvDelimiter } from './csv-parse' +import { detectCsvDelimiter } from './document/csv-delimiter-detection' import { CsvDelimiterPicker, type CsvDelimiterChoice } from './csv-delimiter-picker' -import { CsvGrid } from './CsvGrid' +import { CsvGrid } from './grid/CsvGrid' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import { openCsvHttpLink } from './csv-link-routing' -import { parseCsvTextDocument } from './csv-text-document' -import { csvInspectionRows, csvSourceRow, type CsvInspectionSort } from './csv-inspection' -import { useCsvTableEditor } from './useCsvTableEditor' -import { CsvTableToolbar } from './CsvTableToolbar' -import { remapCsvColumnWidths } from './csv-column-width-preferences' -import { useCsvColumnWidths } from './useCsvColumnWidths' +import { parseCsvTextDocument } from './document/csv-text-document' +import { csvInspectionRows, csvSourceRow, type CsvInspectionSort } from './editing/csv-inspection' +import { useCsvTableEditor } from './editing/useCsvTableEditor' +import { CsvTableToolbar } from './editing/CsvTableToolbar' +import { remapCsvColumnWidths } from './grid/columns/csv-column-width-preferences' +import { useCsvColumnWidths } from './grid/columns/useCsvColumnWidths' import { buildFileViewPreferenceKey } from '../file-view-preference-storage' import { formatCsvPreviewError } from './csv-preview-error-message' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' diff --git a/src/renderer/src/components/editor/csv/csv-cell-links.test.tsx b/src/renderer/src/components/editor/csv/csv-cell-links.test.tsx deleted file mode 100644 index b32bfaf8ba4..00000000000 --- a/src/renderer/src/components/editor/csv/csv-cell-links.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { CsvCellValue } from './CsvCellValue' - -afterEach(() => { - cleanup() - vi.clearAllMocks() -}) - -it('opens a whole-cell URL without navigating the renderer or changing its displayed text', () => { - const onOpenUrl = vi.fn() - render() - const link = screen.getByRole('link') - expect(link.textContent).toBe(' https://example.com/a?q=b#section ') - expect(link.getAttribute('href')).toBe('https://example.com/a?q=b#section') - expect(fireEvent.click(link)).toBe(false) - expect(onOpenUrl).toHaveBeenCalledWith('https://example.com/a?q=b#section', expect.anything()) - fireEvent(link, new MouseEvent('auxclick', { button: 1, bubbles: true, cancelable: true })) - expect(onOpenUrl).toHaveBeenCalledTimes(2) -}) - -it.each([ - 'javascript:alert(1)', - 'data:text/html,', - 'file:///tmp/example.csv', - 'https://', - 'https://[broken', - 'See https://example.com for details', - 'click' -])('keeps non-URLs and executable content as literal text: %s', (value) => { - render() - expect(screen.queryByRole('link')).toBeNull() - expect(screen.getByText(value)).toBeTruthy() -}) diff --git a/src/renderer/src/components/editor/csv/csv-byte-index.test.ts b/src/renderer/src/components/editor/csv/document/csv-byte-index.test.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-byte-index.test.ts rename to src/renderer/src/components/editor/csv/document/csv-byte-index.test.ts diff --git a/src/renderer/src/components/editor/csv/csv-byte-index.ts b/src/renderer/src/components/editor/csv/document/csv-byte-index.ts similarity index 98% rename from src/renderer/src/components/editor/csv/csv-byte-index.ts rename to src/renderer/src/components/editor/csv/document/csv-byte-index.ts index fec085962f7..f53e060c9ba 100644 --- a/src/renderer/src/components/editor/csv/csv-byte-index.ts +++ b/src/renderer/src/components/editor/csv/document/csv-byte-index.ts @@ -1,4 +1,4 @@ -import { CsvPreviewError } from './csv-preview-error' +import { CsvPreviewError } from '../csv-preview-error' export const CSV_RECORD_BYTES = 1024 * 1024 // A first page can also contain a UTF-8 BOM and a trailing CRLF. diff --git a/src/renderer/src/components/editor/csv/csv-delimiter-detection.ts b/src/renderer/src/components/editor/csv/document/csv-delimiter-detection.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-delimiter-detection.ts rename to src/renderer/src/components/editor/csv/document/csv-delimiter-detection.ts diff --git a/src/renderer/src/components/editor/csv/csv-parse-budget.test.ts b/src/renderer/src/components/editor/csv/document/csv-parse-budget.test.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-parse-budget.test.ts rename to src/renderer/src/components/editor/csv/document/csv-parse-budget.test.ts diff --git a/src/renderer/src/components/editor/csv/csv-parse.test.ts b/src/renderer/src/components/editor/csv/document/csv-parse.test.ts similarity index 77% rename from src/renderer/src/components/editor/csv/csv-parse.test.ts rename to src/renderer/src/components/editor/csv/document/csv-parse.test.ts index d76d0d58785..7beb010f8dd 100644 --- a/src/renderer/src/components/editor/csv/csv-parse.test.ts +++ b/src/renderer/src/components/editor/csv/document/csv-parse.test.ts @@ -1,9 +1,6 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS, detectCsvDelimiter, parseCsv } from './csv-parse' - -afterEach(() => { - vi.restoreAllMocks() -}) +import { describe, expect, it } from 'vitest' +import { CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS, detectCsvDelimiter } from './csv-delimiter-detection' +import { parseCsv } from './csv-parse' describe('parseCsv', () => { it('preserves a single quoted empty field at EOF', () => { @@ -58,17 +55,4 @@ describe('detectCsvDelimiter', () => { const content = 'notes;one;two,value\r\n"say ""hi"";\r\nnext",1\r\nplain,2\r\n' expect(detectCsvDelimiter('x.csv', content)).toBe(',') }) - - it('bounds newline-heavy delimiter sniffing without splitting the full file', () => { - const split = vi.spyOn(String.prototype, 'split') - const charCodeAt = vi.spyOn(String.prototype, 'charCodeAt') - const content = `${'\n'.repeat(CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS + 10_000)}a\tb\tc` - - expect(detectCsvDelimiter('x.csv', content)).toBe(',') - - expect(split).not.toHaveBeenCalled() - expect(charCodeAt.mock.calls.length).toBeLessThanOrEqual( - CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS + 1 - ) - }) }) diff --git a/src/renderer/src/components/editor/csv/csv-parse.ts b/src/renderer/src/components/editor/csv/document/csv-parse.ts similarity index 95% rename from src/renderer/src/components/editor/csv/csv-parse.ts rename to src/renderer/src/components/editor/csv/document/csv-parse.ts index ef992314bc5..6dd9750a92a 100644 --- a/src/renderer/src/components/editor/csv/csv-parse.ts +++ b/src/renderer/src/components/editor/csv/document/csv-parse.ts @@ -1,6 +1,4 @@ -import { CsvPreviewError } from './csv-preview-error' - -export { detectCsvDelimiter, CSV_DELIMITER_SNIFF_SCAN_CODE_UNITS } from './csv-delimiter-detection' +import { CsvPreviewError } from '../csv-preview-error' export type CsvParseResult = { rows: string[][]; maxColumns: number } export type CsvParseLimits = { diff --git a/src/renderer/src/components/editor/csv/csv-serialization.ts b/src/renderer/src/components/editor/csv/document/csv-serialization.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-serialization.ts rename to src/renderer/src/components/editor/csv/document/csv-serialization.ts diff --git a/src/renderer/src/components/editor/csv/csv-source-record.ts b/src/renderer/src/components/editor/csv/document/csv-source-record.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-source-record.ts rename to src/renderer/src/components/editor/csv/document/csv-source-record.ts diff --git a/src/renderer/src/components/editor/csv/csv-table-edit-budget.ts b/src/renderer/src/components/editor/csv/document/csv-table-edit-budget.ts similarity index 58% rename from src/renderer/src/components/editor/csv/csv-table-edit-budget.ts rename to src/renderer/src/components/editor/csv/document/csv-table-edit-budget.ts index 2422b5e8749..fec8817aca7 100644 --- a/src/renderer/src/components/editor/csv/csv-table-edit-budget.ts +++ b/src/renderer/src/components/editor/csv/document/csv-table-edit-budget.ts @@ -1,5 +1,5 @@ -import { CSV_PAGED_PREVIEW_BYTES } from './csv-file-limits' -import { CsvPreviewError } from './csv-preview-error' +import { CSV_PAGED_PREVIEW_BYTES } from '../csv-file-limits' +import { CsvPreviewError } from '../csv-preview-error' export function assertCsvTableEditBytes(bytes: number): void { if (bytes >= CSV_PAGED_PREVIEW_BYTES) { diff --git a/src/renderer/src/components/editor/csv/csv-text-cell-edits.ts b/src/renderer/src/components/editor/csv/document/csv-text-cell-edits.ts similarity index 94% rename from src/renderer/src/components/editor/csv/csv-text-cell-edits.ts rename to src/renderer/src/components/editor/csv/document/csv-text-cell-edits.ts index ee671bf5154..a40a6d392bb 100644 --- a/src/renderer/src/components/editor/csv/csv-text-cell-edits.ts +++ b/src/renderer/src/components/editor/csv/document/csv-text-cell-edits.ts @@ -1,10 +1,10 @@ -import { measureUtf8ByteLength } from '../../../../../shared/utf8-byte-limits' +import { measureUtf8ByteLength } from '../../../../../../shared/utf8-byte-limits' import { assertCsvTableEditBytes } from './csv-table-edit-budget' import { CSV_RECORD_BYTES } from './csv-byte-index' import { csvRecordTokens } from './csv-source-record' import { serializeCsvRecord } from './csv-serialization' import type { CsvCellEdit, CsvTextDocument } from './csv-text-document' -import { CsvPreviewError } from './csv-preview-error' +import { CsvPreviewError } from '../csv-preview-error' export function editCsvTextCells( document: CsvTextDocument, diff --git a/src/renderer/src/components/editor/csv/csv-text-document.test.ts b/src/renderer/src/components/editor/csv/document/csv-text-document.test.ts similarity index 86% rename from src/renderer/src/components/editor/csv/csv-text-document.test.ts rename to src/renderer/src/components/editor/csv/document/csv-text-document.test.ts index f804678aee4..50db175e08b 100644 --- a/src/renderer/src/components/editor/csv/csv-text-document.test.ts +++ b/src/renderer/src/components/editor/csv/document/csv-text-document.test.ts @@ -38,25 +38,6 @@ describe('CSV text mutations', () => { expect(parseCsv(result).rows[2]).toEqual(['B,ob', 'two\r\nlines']) }) - it.each([',', ';', '\t'])('safely round-trips edited values with %s', (delimiter) => { - const values = [ - 'quotes "inside"', - 'line\r\nbreak', - `has${delimiter}delimiter`, - '', - '\ufeffliteral', - '=literal' - ] - const document = parseCsvTextDocument(`header\nold`, delimiter) - for (const value of values) { - const result = mutateCsvTextDocument(document, { - kind: 'cells', - edits: [{ row: 1, column: 0, value }] - }) - expect(parseCsv(result, delimiter).rows).toEqual([['header'], [value]]) - } - }) - it('extends ragged rows without padding unaffected rows', () => { const document = parseCsvTextDocument('a,b,c\nshort\n1,2,3\n', ',') expect( diff --git a/src/renderer/src/components/editor/csv/csv-text-document.ts b/src/renderer/src/components/editor/csv/document/csv-text-document.ts similarity index 98% rename from src/renderer/src/components/editor/csv/csv-text-document.ts rename to src/renderer/src/components/editor/csv/document/csv-text-document.ts index fd742291242..43c284d8ede 100644 --- a/src/renderer/src/components/editor/csv/csv-text-document.ts +++ b/src/renderer/src/components/editor/csv/document/csv-text-document.ts @@ -1,11 +1,11 @@ -import { measureUtf8ByteLength } from '../../../../../shared/utf8-byte-limits' +import { measureUtf8ByteLength } from '../../../../../../shared/utf8-byte-limits' import { parseCsv } from './csv-parse' import { CSV_MAX_COLUMNS, CSV_RECORD_BYTES } from './csv-byte-index' import { assertCsvTableEditBytes } from './csv-table-edit-budget' import { editCsvTextCells } from './csv-text-cell-edits' import { csvRecordTokens } from './csv-source-record' import { serializeCsvRecord } from './csv-serialization' -import { CsvPreviewError } from './csv-preview-error' +import { CsvPreviewError } from '../csv-preview-error' export type CsvTextDocument = { source: string diff --git a/src/renderer/src/components/editor/csv/CsvTableToolbar.tsx b/src/renderer/src/components/editor/csv/editing/CsvTableToolbar.tsx similarity index 97% rename from src/renderer/src/components/editor/csv/CsvTableToolbar.tsx rename to src/renderer/src/components/editor/csv/editing/CsvTableToolbar.tsx index 11ecaacf4b8..abb7c191349 100644 --- a/src/renderer/src/components/editor/csv/CsvTableToolbar.tsx +++ b/src/renderer/src/components/editor/csv/editing/CsvTableToolbar.tsx @@ -7,8 +7,8 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useTranslate } from '@/i18n/use-translate' -import { csvSelectionBounds, type CsvCellSelection } from './csv-cell-selection' -import type { CsvTableMutation } from './csv-text-document' +import { csvSelectionBounds, type CsvCellSelection } from '../grid/csv-cell-selection' +import type { CsvTableMutation } from '../document/csv-text-document' import type { CsvInspectionSort } from './csv-inspection' export function CsvTableToolbar({ diff --git a/src/renderer/src/components/editor/csv/csv-cell-clipboard.ts b/src/renderer/src/components/editor/csv/editing/clipboard/csv-cell-clipboard.ts similarity index 82% rename from src/renderer/src/components/editor/csv/csv-cell-clipboard.ts rename to src/renderer/src/components/editor/csv/editing/clipboard/csv-cell-clipboard.ts index fd00073774f..28ab350e69f 100644 --- a/src/renderer/src/components/editor/csv/csv-cell-clipboard.ts +++ b/src/renderer/src/components/editor/csv/editing/clipboard/csv-cell-clipboard.ts @@ -2,15 +2,15 @@ import { CLIPBOARD_TEXT_WRITE_MAX_BYTES, assertClipboardTextWithinLimit, assertClipboardTextWriteWithinLimit -} from '../../../../../shared/clipboard-text' -import { CSV_MAX_COLUMNS, CSV_RECORD_BYTES } from './csv-byte-index' -import { measureUtf8ByteLength } from '../../../../../shared/utf8-byte-limits' -import { parseCsv } from './csv-parse' -import { serializeCsvRecord } from './csv-serialization' -import { csvSelectionBounds, type CsvCellSelection } from './csv-cell-selection' -import { csvSourceRow } from './csv-inspection' -import type { CsvCellEdit } from './csv-text-document' -import { CsvPreviewError } from './csv-preview-error' +} from '../../../../../../../shared/clipboard-text' +import { CSV_MAX_COLUMNS, CSV_RECORD_BYTES } from '../../document/csv-byte-index' +import { measureUtf8ByteLength } from '../../../../../../../shared/utf8-byte-limits' +import { parseCsv } from '../../document/csv-parse' +import { serializeCsvRecord } from '../../document/csv-serialization' +import { csvSelectionBounds, type CsvCellSelection } from '../../grid/csv-cell-selection' +import { csvSourceRow } from '../csv-inspection' +import type { CsvCellEdit } from '../../document/csv-text-document' +import { CsvPreviewError } from '../../csv-preview-error' const CLIPBOARD_CELLS = 1024 * 1024 diff --git a/src/renderer/src/components/editor/csv/useCsvClipboard.ts b/src/renderer/src/components/editor/csv/editing/clipboard/useCsvClipboard.ts similarity index 91% rename from src/renderer/src/components/editor/csv/useCsvClipboard.ts rename to src/renderer/src/components/editor/csv/editing/clipboard/useCsvClipboard.ts index f87ddc8951e..9543b442fba 100644 --- a/src/renderer/src/components/editor/csv/useCsvClipboard.ts +++ b/src/renderer/src/components/editor/csv/editing/clipboard/useCsvClipboard.ts @@ -1,10 +1,10 @@ import { useEffect, useLayoutEffect, useRef, type ClipboardEvent } from 'react' import { APP_MENU_PASTE_EVENT } from '@/lib/clipboard/menu/app-menu-paste' import { copyCsvSelection, pasteCsvSelection } from './csv-cell-clipboard' -import type { CsvCellSelection } from './csv-cell-selection' -import type { CsvCellEditSession } from './csv-grid-interaction' -import type { CsvCellEdit, CsvTextDocument } from './csv-text-document' -import { CsvPreviewError } from './csv-preview-error' +import type { CsvCellSelection } from '../../grid/csv-cell-selection' +import type { CsvCellEditSession } from '../../grid/csv-grid-interaction' +import type { CsvCellEdit, CsvTextDocument } from '../../document/csv-text-document' +import { CsvPreviewError } from '../../csv-preview-error' export function useCsvClipboard({ document, diff --git a/src/renderer/src/components/editor/csv/csv-grid-keyboard.ts b/src/renderer/src/components/editor/csv/editing/csv-grid-keyboard.ts similarity index 97% rename from src/renderer/src/components/editor/csv/csv-grid-keyboard.ts rename to src/renderer/src/components/editor/csv/editing/csv-grid-keyboard.ts index dae85a626aa..f0db52f2132 100644 --- a/src/renderer/src/components/editor/csv/csv-grid-keyboard.ts +++ b/src/renderer/src/components/editor/csv/editing/csv-grid-keyboard.ts @@ -1,5 +1,5 @@ import type { KeyboardEvent } from 'react' -import type { CsvCellSelection } from './csv-cell-selection' +import type { CsvCellSelection } from '../grid/csv-cell-selection' export function csvGridKeyDown( event: KeyboardEvent, diff --git a/src/renderer/src/components/editor/csv/csv-inspection.ts b/src/renderer/src/components/editor/csv/editing/csv-inspection.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-inspection.ts rename to src/renderer/src/components/editor/csv/editing/csv-inspection.ts diff --git a/src/renderer/src/components/editor/csv/csv-table-interactions.test.ts b/src/renderer/src/components/editor/csv/editing/csv-table-interactions.test.ts similarity index 59% rename from src/renderer/src/components/editor/csv/csv-table-interactions.test.ts rename to src/renderer/src/components/editor/csv/editing/csv-table-interactions.test.ts index 252ca17d0ef..98bfd8f01c4 100644 --- a/src/renderer/src/components/editor/csv/csv-table-interactions.test.ts +++ b/src/renderer/src/components/editor/csv/editing/csv-table-interactions.test.ts @@ -1,8 +1,8 @@ import { expect, it } from 'vitest' -import { moveCsvSelection } from './csv-cell-selection' +import { moveCsvSelection } from '../grid/csv-cell-selection' import { csvInspectionRows, csvSourceRow } from './csv-inspection' -import { copyCsvSelection, pasteCsvSelection } from './csv-cell-clipboard' -import { mutateCsvTextDocument, parseCsvTextDocument } from './csv-text-document' +import { copyCsvSelection, pasteCsvSelection } from './clipboard/csv-cell-clipboard' +import { mutateCsvTextDocument, parseCsvTextDocument } from '../document/csv-text-document' it('sorts and filters for inspection without mutating source order and edits the original row', () => { const document = parseCsvTextDocument('name,value\nb,10\na,2\na,1', ',') @@ -35,21 +35,3 @@ it('copies and pastes rectangular quoted TSV without losing embedded CRLF or tra { row: 2, column: 2, value: '' } ]) }) - -it('fills a selected range with one literal value and rejects partial or oversized pastes', () => { - const rows = [ - ['a', 'b'], - ['x', 'y'], - ['z', 'w'] - ] - const selection = moveCsvSelection(moveCsvSelection(null, 1, 0, false), 2, 1, true) - expect(pasteCsvSelection('=literal', selection, rows, 2, [2, 1])).toEqual([ - { row: 2, column: 0, value: '=literal' }, - { row: 2, column: 1, value: '=literal' }, - { row: 1, column: 0, value: '=literal' }, - { row: 1, column: 1, value: '=literal' } - ]) - expect(() => pasteCsvSelection('1\t2\t3', selection, rows, 2, null)).toThrow('exceeds the table') - const huge = moveCsvSelection(moveCsvSelection(null, 0, 0, false), 100_000, 1, true) - expect(() => pasteCsvSelection('x', huge, rows, 2, null)).toThrow() -}) diff --git a/src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx b/src/renderer/src/components/editor/csv/editing/useCsvPendingCell.test.tsx similarity index 87% rename from src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx rename to src/renderer/src/components/editor/csv/editing/useCsvPendingCell.test.tsx index f5bfda3cbd1..3ae48f3da64 100644 --- a/src/renderer/src/components/editor/csv/useCsvPendingCell.test.tsx +++ b/src/renderer/src/components/editor/csv/editing/useCsvPendingCell.test.tsx @@ -3,12 +3,12 @@ import { act, useRef } from 'react' import { createRoot } from 'react-dom/client' import { expect, it, vi } from 'vitest' -import { flushClosingEditorBuffers } from '../save/flush/editor-pending-flush' -import { ORCA_EDITOR_FILE_SAVED_EVENT } from '../autosave/editor-autosave' +import { flushClosingEditorBuffers } from '../../save/flush/editor-pending-flush' +import { ORCA_EDITOR_FILE_SAVED_EVENT } from '../../autosave/editor-autosave' import { EMPTY_EDITOR_BUFFER_VIEW_INDEX as NO_SHARING } from '@/store/slices/editor/buffers/editor-buffer-views' -import { CSV_RECORD_BYTES } from './csv-byte-index' -import type { CsvCellEditSession } from './csv-grid-interaction' -import { parseCsvTextDocument } from './csv-text-document' +import { CSV_RECORD_BYTES } from '../document/csv-byte-index' +import type { CsvCellEditSession } from '../grid/csv-grid-interaction' +import { parseCsvTextDocument } from '../document/csv-text-document' import { useCsvPendingCell } from './useCsvPendingCell' it('keeps an over-limit pending cell as draft text when its tab closes', async () => { diff --git a/src/renderer/src/components/editor/csv/useCsvPendingCell.ts b/src/renderer/src/components/editor/csv/editing/useCsvPendingCell.ts similarity index 85% rename from src/renderer/src/components/editor/csv/useCsvPendingCell.ts rename to src/renderer/src/components/editor/csv/editing/useCsvPendingCell.ts index 3344ef48fbe..0250f6f2f9e 100644 --- a/src/renderer/src/components/editor/csv/useCsvPendingCell.ts +++ b/src/renderer/src/components/editor/csv/editing/useCsvPendingCell.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from 'react' -import { registerPendingEditorFlush } from '../save/flush/editor-pending-flush' -import { ORCA_EDITOR_FILE_SAVED_EVENT } from '../autosave/editor-autosave' -import { isEditorSavedEventFor } from '../save/commands/editor-saved-event-view' -import type { CsvTextDocument } from './csv-text-document' -import { editCsvTextCells } from './csv-text-cell-edits' -import type { CsvCellEditSession } from './csv-grid-interaction' +import { registerPendingEditorFlush } from '../../save/flush/editor-pending-flush' +import { ORCA_EDITOR_FILE_SAVED_EVENT } from '../../autosave/editor-autosave' +import { isEditorSavedEventFor } from '../../save/commands/editor-saved-event-view' +import type { CsvTextDocument } from '../document/csv-text-document' +import { editCsvTextCells } from '../document/csv-text-cell-edits' +import type { CsvCellEditSession } from '../grid/csv-grid-interaction' export function useCsvPendingCell({ fileId, diff --git a/src/renderer/src/components/editor/csv/useCsvTableEditor.ts b/src/renderer/src/components/editor/csv/editing/useCsvTableEditor.ts similarity index 94% rename from src/renderer/src/components/editor/csv/useCsvTableEditor.ts rename to src/renderer/src/components/editor/csv/editing/useCsvTableEditor.ts index c9f5caed023..42847b5f01f 100644 --- a/src/renderer/src/components/editor/csv/useCsvTableEditor.ts +++ b/src/renderer/src/components/editor/csv/editing/useCsvTableEditor.ts @@ -1,24 +1,24 @@ import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react' import { csvGridKeyDown } from './csv-grid-keyboard' -import type { CsvCellEditSession, CsvGridInteraction } from './csv-grid-interaction' +import type { CsvCellEditSession, CsvGridInteraction } from '../grid/csv-grid-interaction' import { csvSelectionBounds, moveCsvSelection, remapCsvSelectionAfterColumnMove, type CsvCellSelection -} from './csv-cell-selection' -import { useCsvClipboard } from './useCsvClipboard' +} from '../grid/csv-cell-selection' +import { useCsvClipboard } from './clipboard/useCsvClipboard' import { csvSourceRow } from './csv-inspection' import { mutateCsvTextDocument, parseCsvTextDocument, type CsvTextDocument, type CsvTableMutation -} from './csv-text-document' +} from '../document/csv-text-document' import { useCsvPendingCell } from './useCsvPendingCell' -import { CsvPreviewError } from './csv-preview-error' -import { formatCsvPreviewError } from './csv-preview-error-message' -import { useTranslate } from '../../../i18n/use-translate' +import { CsvPreviewError } from '../csv-preview-error' +import { formatCsvPreviewError } from '../csv-preview-error-message' +import { useTranslate } from '../../../../i18n/use-translate' export function useCsvTableEditor({ document, diff --git a/src/renderer/src/components/editor/csv/CsvCellValue.tsx b/src/renderer/src/components/editor/csv/grid/CsvCellValue.tsx similarity index 100% rename from src/renderer/src/components/editor/csv/CsvCellValue.tsx rename to src/renderer/src/components/editor/csv/grid/CsvCellValue.tsx diff --git a/src/renderer/src/components/editor/csv/CsvGrid.tsx b/src/renderer/src/components/editor/csv/grid/CsvGrid.tsx similarity index 98% rename from src/renderer/src/components/editor/csv/CsvGrid.tsx rename to src/renderer/src/components/editor/csv/grid/CsvGrid.tsx index 7247f5238e1..4fb0ed04a6d 100644 --- a/src/renderer/src/components/editor/csv/CsvGrid.tsx +++ b/src/renderer/src/components/editor/csv/grid/CsvGrid.tsx @@ -2,10 +2,10 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useSta import { useVirtualizer } from '@tanstack/react-virtual' import { Button } from '@/components/ui/button' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' -import { CsvColumnResizeHandle } from './CsvColumnResizeHandle' +import { CsvColumnResizeHandle } from './columns/CsvColumnResizeHandle' import { CsvGridCell } from './CsvGridCell' import type { CsvGridInteraction } from './csv-grid-interaction' -import type { CsvColumnWidths } from './csv-column-width-preferences' +import type { CsvColumnWidths } from './columns/csv-column-width-preferences' const ROW_HEIGHT = 28 // Keep the scroll surface below Chromium's layout extent limit. diff --git a/src/renderer/src/components/editor/csv/CsvGridCell.tsx b/src/renderer/src/components/editor/csv/grid/CsvGridCell.tsx similarity index 100% rename from src/renderer/src/components/editor/csv/CsvGridCell.tsx rename to src/renderer/src/components/editor/csv/grid/CsvGridCell.tsx diff --git a/src/renderer/src/components/editor/csv/CsvColumnResizeHandle.tsx b/src/renderer/src/components/editor/csv/grid/columns/CsvColumnResizeHandle.tsx similarity index 96% rename from src/renderer/src/components/editor/csv/CsvColumnResizeHandle.tsx rename to src/renderer/src/components/editor/csv/grid/columns/CsvColumnResizeHandle.tsx index a1066f67ff2..9fcd5179d48 100644 --- a/src/renderer/src/components/editor/csv/CsvColumnResizeHandle.tsx +++ b/src/renderer/src/components/editor/csv/grid/columns/CsvColumnResizeHandle.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef } from 'react' import { useTranslate } from '@/i18n/use-translate' import { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' -export { CSV_MIN_COLUMN_WIDTH, CSV_MAX_COLUMN_WIDTH } from './csv-column-width-limits' export function CsvColumnResizeHandle({ index, diff --git a/src/renderer/src/components/editor/csv/csv-column-width-limits.ts b/src/renderer/src/components/editor/csv/grid/columns/csv-column-width-limits.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-column-width-limits.ts rename to src/renderer/src/components/editor/csv/grid/columns/csv-column-width-limits.ts diff --git a/src/renderer/src/components/editor/csv/csv-column-width-preferences.test.ts b/src/renderer/src/components/editor/csv/grid/columns/csv-column-width-preferences.test.ts similarity index 96% rename from src/renderer/src/components/editor/csv/csv-column-width-preferences.test.ts rename to src/renderer/src/components/editor/csv/grid/columns/csv-column-width-preferences.test.ts index 472e690744e..73126aaf982 100644 --- a/src/renderer/src/components/editor/csv/csv-column-width-preferences.test.ts +++ b/src/renderer/src/components/editor/csv/grid/columns/csv-column-width-preferences.test.ts @@ -6,7 +6,7 @@ import { writeCsvColumnWidths, remapCsvColumnWidths } from './csv-column-width-preferences' -import { buildFileViewPreferenceKey } from '../file-view-preference-storage' +import { buildFileViewPreferenceKey } from '../../../file-view-preference-storage' afterEach(() => { vi.restoreAllMocks() diff --git a/src/renderer/src/components/editor/csv/csv-column-width-preferences.ts b/src/renderer/src/components/editor/csv/grid/columns/csv-column-width-preferences.ts similarity index 88% rename from src/renderer/src/components/editor/csv/csv-column-width-preferences.ts rename to src/renderer/src/components/editor/csv/grid/columns/csv-column-width-preferences.ts index 38a99ed61fd..0af13c7650b 100644 --- a/src/renderer/src/components/editor/csv/csv-column-width-preferences.ts +++ b/src/renderer/src/components/editor/csv/grid/columns/csv-column-width-preferences.ts @@ -1,7 +1,10 @@ -import { CSV_MAX_COLUMNS } from './csv-byte-index' +import { CSV_MAX_COLUMNS } from '../../document/csv-byte-index' import { CSV_MAX_COLUMN_WIDTH, CSV_MIN_COLUMN_WIDTH } from './csv-column-width-limits' -import { readFileViewPreference, writeFileViewPreference } from '../file-view-preference-storage' -import type { CsvTableMutation } from './csv-text-document' +import { + readFileViewPreference, + writeFileViewPreference +} from '../../../file-view-preference-storage' +import type { CsvTableMutation } from '../../document/csv-text-document' export const CSV_COLUMN_WIDTHS_STORAGE_KEY = 'orca.csv.column-widths.v1' export type CsvColumnWidths = Record diff --git a/src/renderer/src/components/editor/csv/useCsvColumnWidths.ts b/src/renderer/src/components/editor/csv/grid/columns/useCsvColumnWidths.ts similarity index 100% rename from src/renderer/src/components/editor/csv/useCsvColumnWidths.ts rename to src/renderer/src/components/editor/csv/grid/columns/useCsvColumnWidths.ts diff --git a/src/renderer/src/components/editor/csv/grid/csv-cell-links.test.tsx b/src/renderer/src/components/editor/csv/grid/csv-cell-links.test.tsx new file mode 100644 index 00000000000..c2a65f453e5 --- /dev/null +++ b/src/renderer/src/components/editor/csv/grid/csv-cell-links.test.tsx @@ -0,0 +1,23 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { CsvCellValue } from './CsvCellValue' + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +it.each([ + 'javascript:alert(1)', + 'data:text/html,', + 'file:///tmp/example.csv', + 'https://', + 'https://[broken', + 'See https://example.com for details', + 'click' +])('keeps non-URLs and executable content as literal text: %s', (value) => { + render() + expect(screen.queryByRole('link')).toBeNull() + expect(screen.getByText(value)).toBeTruthy() +}) diff --git a/src/renderer/src/components/editor/csv/csv-cell-selection.ts b/src/renderer/src/components/editor/csv/grid/csv-cell-selection.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-cell-selection.ts rename to src/renderer/src/components/editor/csv/grid/csv-cell-selection.ts diff --git a/src/renderer/src/components/editor/csv/csv-grid-interaction.ts b/src/renderer/src/components/editor/csv/grid/csv-grid-interaction.ts similarity index 100% rename from src/renderer/src/components/editor/csv/csv-grid-interaction.ts rename to src/renderer/src/components/editor/csv/grid/csv-grid-interaction.ts diff --git a/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx b/src/renderer/src/components/editor/csv/preview/CsvPagedViewer.tsx similarity index 89% rename from src/renderer/src/components/editor/csv/CsvPagedViewer.tsx rename to src/renderer/src/components/editor/csv/preview/CsvPagedViewer.tsx index eb0f0f525f1..5ae5a79fe20 100644 --- a/src/renderer/src/components/editor/csv/CsvPagedViewer.tsx +++ b/src/renderer/src/components/editor/csv/preview/CsvPagedViewer.tsx @@ -1,18 +1,18 @@ import { useEffect, useState } from 'react' import { readRuntimeFileRange } from '@/runtime/files/reads/runtime-file-range-client' import { useTranslate } from '@/i18n/use-translate' -import { detectCsvDelimiter } from './csv-parse' -import { CsvFooter, csvChosenDelimiter } from './CsvViewer' -import { CsvGrid } from './CsvGrid' +import { detectCsvDelimiter } from '../document/csv-delimiter-detection' +import { CsvFooter, csvChosenDelimiter } from '../CsvViewer' +import { CsvGrid } from '../grid/CsvGrid' import type { CsvFilePreview } from './csv-file-content' -import type { CsvDelimiterChoice } from './csv-delimiter-picker' +import type { CsvDelimiterChoice } from '../csv-delimiter-picker' import { useCsvPagedPreview } from './useCsvPagedPreview' -import { EditorFileLoadErrorView } from '../EditorFileLoadErrorView' -import { useCsvColumnWidths } from './useCsvColumnWidths' -import { buildFileViewPreferenceKey } from '../file-view-preference-storage' -import { openCsvHttpLink } from './csv-link-routing' +import { EditorFileLoadErrorView } from '../../EditorFileLoadErrorView' +import { useCsvColumnWidths } from '../grid/columns/useCsvColumnWidths' +import { buildFileViewPreferenceKey } from '../../file-view-preference-storage' +import { openCsvHttpLink } from '../csv-link-routing' import { runtimeTargetEnvironmentId } from '@/runtime/rpc/targets/runtime-client-target' -import { formatCsvPreviewError } from './csv-preview-error-message' +import { formatCsvPreviewError } from '../csv-preview-error-message' import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision' export default function CsvPagedViewer({ diff --git a/src/renderer/src/components/editor/csv/csv-file-content.ts b/src/renderer/src/components/editor/csv/preview/csv-file-content.ts similarity index 84% rename from src/renderer/src/components/editor/csv/csv-file-content.ts rename to src/renderer/src/components/editor/csv/preview/csv-file-content.ts index b943cac55d9..388273a35fe 100644 --- a/src/renderer/src/components/editor/csv/csv-file-content.ts +++ b/src/renderer/src/components/editor/csv/preview/csv-file-content.ts @@ -4,10 +4,9 @@ import { statRuntimeReadTarget, type RuntimeFileSnapshot } from '@/runtime/files/reads/runtime-file-range-client' -import type { FileContent } from '../editor-panel-content-types' +import type { FileContent } from '../../editor-panel-content-types' -import { CSV_PAGED_PREVIEW_BYTES } from './csv-file-limits' -export { CSV_PAGED_PREVIEW_BYTES } from './csv-file-limits' +import { CSV_PAGED_PREVIEW_BYTES } from '../csv-file-limits' export type CsvFilePreview = { readArgs: RuntimeFileReadArgs; snapshot: RuntimeFileSnapshot } diff --git a/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts b/src/renderer/src/components/editor/csv/preview/csv-paged-preview.test.ts similarity index 55% rename from src/renderer/src/components/editor/csv/csv-paged-preview.test.ts rename to src/renderer/src/components/editor/csv/preview/csv-paged-preview.test.ts index a191301fda3..be055f0085b 100644 --- a/src/renderer/src/components/editor/csv/csv-paged-preview.test.ts +++ b/src/renderer/src/components/editor/csv/preview/csv-paged-preview.test.ts @@ -6,7 +6,7 @@ vi.mock('@/runtime/files/reads/runtime-file-range-client', () => ({ statRuntimeReadTarget: mocks.stat, readRuntimeFileRange: mocks.read })) -vi.mock('./csv-preview-worker-client', () => ({ +vi.mock('./worker/csv-preview-worker-client', () => ({ CsvPreviewWorkerClient: class { request = mocks.request close = mocks.close @@ -22,31 +22,6 @@ beforeEach(() => { mocks.request.mockResolvedValue({ kind: 'rows', rows: [['value']] }) }) -it('detects changes even when a page is already cached', async () => { - const preview = new CsvPagedPreview(file) - const range = { start: 0, end: 10, firstRow: 0, rowCount: 1 } - await preview.page(0, range) - mocks.stat.mockResolvedValue({ ...snapshot, mtime: 2 }) - await expect(preview.page(0, range)).rejects.toThrow('changed on disk') - expect(mocks.read).toHaveBeenCalledTimes(1) - preview.close() -}) - -it('bounds reads, stops after cancellation, and rejects mismatched pages', async () => { - const preview = new CsvPagedPreview(file) - await preview.read(0, 1024 * 1024) - expect(mocks.read.mock.calls.map((call) => call[2])).toEqual( - Array.from({ length: 4 }, () => 256 * 1024) - ) - await expect(preview.read(-1, 1)).rejects.toThrow('Invalid CSV page range') - await expect(preview.page(0, { start: 0, end: 10, firstRow: 0, rowCount: 2 })).rejects.toThrow( - 'boundary mismatch' - ) - preview.close() - await expect(preview.read(0, 10)).rejects.toThrow('canceled') - expect(mocks.close).toHaveBeenCalledTimes(1) -}) - it('detects a mutation during the full scan before publishing an index', async () => { const preview = new CsvPagedPreview(file) mocks.request.mockResolvedValue({ kind: 'ack' }) diff --git a/src/renderer/src/components/editor/csv/csv-paged-preview.ts b/src/renderer/src/components/editor/csv/preview/csv-paged-preview.ts similarity index 95% rename from src/renderer/src/components/editor/csv/csv-paged-preview.ts rename to src/renderer/src/components/editor/csv/preview/csv-paged-preview.ts index bab64afb80a..54a771aa4ae 100644 --- a/src/renderer/src/components/editor/csv/csv-paged-preview.ts +++ b/src/renderer/src/components/editor/csv/preview/csv-paged-preview.ts @@ -1,4 +1,4 @@ -import { MAX_FILE_RANGE_READ_BYTES } from '../../../../../shared/file-content/file-range-read' +import { MAX_FILE_RANGE_READ_BYTES } from '../../../../../../shared/file-content/file-range-read' import { readRuntimeFileRange, statRuntimeReadTarget @@ -9,9 +9,9 @@ import { csvPageForRow, type CsvIndex, type CsvPageRange -} from './csv-byte-index' -import { CsvPreviewWorkerClient } from './csv-preview-worker-client' -import { CsvPreviewError } from './csv-preview-error' +} from '../document/csv-byte-index' +import { CsvPreviewWorkerClient } from './worker/csv-preview-worker-client' +import { CsvPreviewError } from '../csv-preview-error' const CACHE_CELLS = 512 * 1024 const CACHE_BYTES = 8 * 1024 * 1024 diff --git a/src/renderer/src/components/editor/csv/useCsvPagedPreview.ts b/src/renderer/src/components/editor/csv/preview/useCsvPagedPreview.ts similarity index 97% rename from src/renderer/src/components/editor/csv/useCsvPagedPreview.ts rename to src/renderer/src/components/editor/csv/preview/useCsvPagedPreview.ts index 4a7307d52ec..60af95665ad 100644 --- a/src/renderer/src/components/editor/csv/useCsvPagedPreview.ts +++ b/src/renderer/src/components/editor/csv/preview/useCsvPagedPreview.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { CsvFilePreview } from './csv-file-content' import { CsvPagedPreview } from './csv-paged-preview' -import type { CsvIndex } from './csv-byte-index' +import type { CsvIndex } from '../document/csv-byte-index' type PreviewState = { index: CsvIndex | null diff --git a/src/renderer/src/components/editor/csv/csv-preview-worker-client.ts b/src/renderer/src/components/editor/csv/preview/worker/csv-preview-worker-client.ts similarity index 96% rename from src/renderer/src/components/editor/csv/csv-preview-worker-client.ts rename to src/renderer/src/components/editor/csv/preview/worker/csv-preview-worker-client.ts index 34d61b2b2b2..301a5a43aa1 100644 --- a/src/renderer/src/components/editor/csv/csv-preview-worker-client.ts +++ b/src/renderer/src/components/editor/csv/preview/worker/csv-preview-worker-client.ts @@ -3,7 +3,7 @@ import type { CsvWorkerResponse, CsvWorkerValue } from './csv-preview-worker-protocol' -import { CsvPreviewError } from './csv-preview-error' +import { CsvPreviewError } from '../../csv-preview-error' export class CsvPreviewWorkerClient { private worker = new Worker(new URL('./csv-preview.worker.ts', import.meta.url), { diff --git a/src/renderer/src/components/editor/csv/csv-preview-worker-protocol.ts b/src/renderer/src/components/editor/csv/preview/worker/csv-preview-worker-protocol.ts similarity index 81% rename from src/renderer/src/components/editor/csv/csv-preview-worker-protocol.ts rename to src/renderer/src/components/editor/csv/preview/worker/csv-preview-worker-protocol.ts index d141c7747a9..9745d2abe0d 100644 --- a/src/renderer/src/components/editor/csv/csv-preview-worker-protocol.ts +++ b/src/renderer/src/components/editor/csv/preview/worker/csv-preview-worker-protocol.ts @@ -1,5 +1,5 @@ -import type { CsvIndex } from './csv-byte-index' -import type { CsvPreviewErrorCode } from './csv-preview-error' +import type { CsvIndex } from '../../document/csv-byte-index' +import type { CsvPreviewErrorCode } from '../../csv-preview-error' export type CsvWorkerCommand = | { kind: 'init'; delimiter: string } diff --git a/src/renderer/src/components/editor/csv/csv-preview.worker.ts b/src/renderer/src/components/editor/csv/preview/worker/csv-preview.worker.ts similarity index 87% rename from src/renderer/src/components/editor/csv/csv-preview.worker.ts rename to src/renderer/src/components/editor/csv/preview/worker/csv-preview.worker.ts index 0a9312f9906..62e830e6227 100644 --- a/src/renderer/src/components/editor/csv/csv-preview.worker.ts +++ b/src/renderer/src/components/editor/csv/preview/worker/csv-preview.worker.ts @@ -1,6 +1,11 @@ -import { CsvByteIndex, CSV_MAX_COLUMNS, CSV_RECORD_BYTES, CSV_PAGE_CELLS } from './csv-byte-index' -import { parseCsv } from './csv-parse' -import { CsvPreviewError } from './csv-preview-error' +import { + CsvByteIndex, + CSV_MAX_COLUMNS, + CSV_RECORD_BYTES, + CSV_PAGE_CELLS +} from '../../document/csv-byte-index' +import { parseCsv } from '../../document/csv-parse' +import { CsvPreviewError } from '../../csv-preview-error' import type { CsvWorkerRequest, CsvWorkerResponse, diff --git a/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx b/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx deleted file mode 100644 index 3c423325e0f..00000000000 --- a/src/renderer/src/components/editor/csv/useCsvPagedPreview.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// @vitest-environment happy-dom -import { act, cleanup, renderHook, waitFor } from '@testing-library/react' -import { afterEach, expect, it, vi } from 'vitest' -import { useCsvPagedPreview } from './useCsvPagedPreview' - -const mocks = vi.hoisted(() => ({ - buildIndex: vi.fn(), - page: vi.fn(), - rows: vi.fn(), - close: vi.fn() -})) -vi.mock('./csv-paged-preview', () => ({ - CsvPagedPreview: class { - buildIndex = mocks.buildIndex - page = mocks.page - rows = mocks.rows - close = mocks.close - } -})) -afterEach(() => { - cleanup() - vi.clearAllMocks() -}) - -it('keeps the new viewport loader alive when a canceled delimiter scan rejects late', async () => { - let rejectOld!: (error: Error) => void - const oldScan = new Promise((_resolve, reject) => { - rejectOld = reject - }) - const pages = [{ start: 0, end: 20, firstRow: 0, rowCount: 4 }] - mocks.buildIndex - .mockReturnValueOnce(oldScan) - .mockResolvedValue({ pages, rowCount: 4, columnCount: 1 }) - mocks.page.mockResolvedValue([['id'], ['first'], ['second'], ['third']]) - mocks.rows.mockResolvedValue( - new Map([ - [1, ['second']], - [2, ['third']] - ]) - ) - const file = { - readArgs: { target: { kind: 'local' as const }, filePath: '/repo/a.csv' }, - snapshot: { size: 20, mtime: 1, isDirectory: false } - } - const { result, rerender } = renderHook(({ delimiter }) => useCsvPagedPreview(file, delimiter), { - initialProps: { delimiter: ',' } - }) - rerender({ delimiter: ';' }) - await waitFor(() => expect(result.current.header).toEqual(['id'])) - await act(async () => { - rejectOld(new Error('old scan canceled')) - }) - expect(result.current.error).toBeNull() - act(() => result.current.onVisibleRows(1, 2)) - await waitFor(() => expect(result.current.getRow(2)).toEqual(['third'])) - expect(mocks.rows).toHaveBeenCalledTimes(1) - expect(mocks.page).toHaveBeenCalledTimes(1) - expect(mocks.close).toHaveBeenCalledTimes(1) -}) diff --git a/src/renderer/src/components/editor/editor-lazy-views.ts b/src/renderer/src/components/editor/editor-lazy-views.ts index 73971845fb4..f030c6b5812 100644 --- a/src/renderer/src/components/editor/editor-lazy-views.ts +++ b/src/renderer/src/components/editor/editor-lazy-views.ts @@ -16,5 +16,5 @@ export const MediaViewer = lazy(() => import('./MediaViewer')) export const ImageDiffViewer = lazy(() => import('./diff/large-files/ImageDiffViewer')) export const MermaidViewer = lazy(() => import('./MermaidViewer')) export const CsvViewer = lazy(() => import('./csv/CsvViewer')) -export const CsvPagedViewer = lazy(() => import('./csv/CsvPagedViewer')) +export const CsvPagedViewer = lazy(() => import('./csv/preview/CsvPagedViewer')) export const IpynbViewer = lazy(() => import('./notebook/document/IpynbViewer')) diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts index 2b759fdcaea..b581ff894f8 100644 --- a/src/renderer/src/components/editor/editor-panel-content-types.ts +++ b/src/renderer/src/components/editor/editor-panel-content-types.ts @@ -2,7 +2,7 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { OpenFile } from '@/store/slices/editor/types/open-file' import type { EditorPanelContentLoadOptions } from './external-changes/useEditorPanelExternalContentEvents' import type { GitDiffResult } from '../../../../shared/git/diff/git-diff-compare-types' -import type { CsvFilePreview } from './csv/csv-file-content' +import type { CsvFilePreview } from './csv/preview/csv-file-content' import type { RuntimeFileReadArgs } from '@/runtime/files/runtime-file-client-types' /** Transient owner hydration/connection failure; retry instead of reading remote paths locally and latching access errors. */ diff --git a/src/renderer/src/components/editor/csv/csv-pending-save.test.ts b/src/renderer/src/components/editor/save/queue/csv-pending-save.test.ts similarity index 56% rename from src/renderer/src/components/editor/csv/csv-pending-save.test.ts rename to src/renderer/src/components/editor/save/queue/csv-pending-save.test.ts index ec5d418f89b..c803813ac6c 100644 --- a/src/renderer/src/components/editor/csv/csv-pending-save.test.ts +++ b/src/renderer/src/components/editor/save/queue/csv-pending-save.test.ts @@ -1,12 +1,12 @@ import { afterEach, expect, it, vi } from 'vitest' -import { createEditorSaveQueue } from '../save/queue/editor-save-queue' -import { registerPendingEditorFlush } from '../save/flush/editor-pending-flush' +import { createEditorSaveQueue } from './editor-save-queue' +import { registerPendingEditorFlush } from '../flush/editor-pending-flush' import { createEditorStore, createFakeEditorDisk, stubEditorWindowWithDisk, withLoadedBaselines -} from '../autosave/editor-autosave-controller-test-fixture' +} from '../../autosave/editor-autosave-controller-test-fixture' afterEach(() => vi.unstubAllGlobals()) @@ -42,34 +42,3 @@ it('a failed pending edit prevents the write and keeps the existing dirty draft' queue.dispose() } }) - -it('keeps a newer pending cell dirty when an earlier remote save completes', async () => { - const { disk, store, file, queue } = setup() - let releaseWrite: () => void = () => { - throw new Error('write not started') - } - disk.fs.writeFile.mockImplementationOnce( - () => - new Promise((resolve) => { - releaseWrite = resolve - }) - ) - let pending = false - const unregister = registerPendingEditorFlush( - file.id, - () => {}, - () => pending - ) - try { - const saving = queue.queueSave(file, 'fallback') - await vi.waitFor(() => expect(disk.fs.writeFile).toHaveBeenCalledTimes(1)) - pending = true - releaseWrite() - await saving - expect(store.getState().openFiles[0]?.isDirty).toBe(true) - expect(store.getState().editorDrafts[file.id]).toBe('header\nprevious') - } finally { - unregister() - queue.dispose() - } -}) diff --git a/src/renderer/src/components/editor/csv/csv-preview-save-safety.test.ts b/src/renderer/src/components/editor/save/queue/csv-preview-save-safety.test.ts similarity index 90% rename from src/renderer/src/components/editor/csv/csv-preview-save-safety.test.ts rename to src/renderer/src/components/editor/save/queue/csv-preview-save-safety.test.ts index 645d8ae2e55..6cb874340b5 100644 --- a/src/renderer/src/components/editor/csv/csv-preview-save-safety.test.ts +++ b/src/renderer/src/components/editor/save/queue/csv-preview-save-safety.test.ts @@ -1,8 +1,8 @@ // @vitest-environment happy-dom import { expect, it, vi } from 'vitest' import { createEditorStore } from '@/store/slices/editor/editor-slice-test-harness' -import { createEditorSaveQueue } from '../save/queue/editor-save-queue' -import { canAutoSaveOpenFile } from '../autosave/editor-autosave' +import { createEditorSaveQueue } from './editor-save-queue' +import { canAutoSaveOpenFile } from '../../autosave/editor-autosave' const { writeFile } = vi.hoisted(() => ({ writeFile: vi.fn() })) vi.mock('@/runtime/files/mutations/runtime-file-mutation-client', () => ({ diff --git a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts index bad3aa9741d..fb50a3d6771 100644 --- a/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelFileContentLoader.ts @@ -8,7 +8,7 @@ import { } from '@/lib/workspace/ownership/connection-context' import { useAppStore } from '@/store' import { getRuntimeFileReadScope } from '@/runtime/files/runtime-file-routing' -import { readEditorCsvFileContent } from './csv/csv-file-content' +import { readEditorCsvFileContent } from './csv/preview/csv-file-content' import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { requireRuntimeTargetForFileOwner } from '@/lib/files/access/file-owner-runtime-target' import type { RuntimeWorkspaceFileRoute } from '@/lib/files/access/runtime-workspace-file-route' diff --git a/src/renderer/src/lib/workspace/persistence/wsl-drive-unc-renderer-store-rewrites.ts b/src/renderer/src/lib/workspace/persistence/wsl-drive-unc-renderer-store-rewrites.ts index 724760c0465..b459e5756df 100644 --- a/src/renderer/src/lib/workspace/persistence/wsl-drive-unc-renderer-store-rewrites.ts +++ b/src/renderer/src/lib/workspace/persistence/wsl-drive-unc-renderer-store-rewrites.ts @@ -35,7 +35,7 @@ const STORE_REWRITES: Record Promise await Promise.all([ import('../../../components/editor/file-view-preference-storage'), import('../../../components/editor/pdf-scale-preference-storage'), - import('../../../components/editor/csv/csv-column-width-preferences') + import('../../../components/editor/csv/grid/columns/csv-column-width-preferences') ]) copyFileViewPreferences(PDF_SCALE_PREFERENCES_STORAGE_KEY, (key) => convertedFileViewPreferenceKey(key, keys) diff --git a/src/renderer/src/lib/workspace/persistence/wsl-move-renderer-store-rewrites.ts b/src/renderer/src/lib/workspace/persistence/wsl-move-renderer-store-rewrites.ts index 2fe1afaa344..7d77d5976d7 100644 --- a/src/renderer/src/lib/workspace/persistence/wsl-move-renderer-store-rewrites.ts +++ b/src/renderer/src/lib/workspace/persistence/wsl-move-renderer-store-rewrites.ts @@ -46,7 +46,7 @@ const STORE_REWRITES: Record< await Promise.all([ import('../../../components/editor/file-view-preference-storage'), import('../../../components/editor/pdf-scale-preference-storage'), - import('../../../components/editor/csv/csv-column-width-preferences') + import('../../../components/editor/csv/grid/columns/csv-column-width-preferences') ]) copyFileViewPreferences(PDF_SCALE_PREFERENCES_STORAGE_KEY, (key) => hostFileViewPreferenceKey(key, scope)