diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 751ee0c0dae..3609399ac33 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,20 +1,12 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } 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' import type * as Scanner from './scanner' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -23,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', async (importOriginal) => ({ ...(await importOriginal()), scanClaudeUsageFiles: vi.fn() @@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({ import { ClaudeUsageStore, initClaudeUsagePath } from './store' import { scanClaudeUsageFiles } from './scanner' -function createStoreWithState(state: Partial): ClaudeUsageStore { - const store = new ClaudeUsageStore({ +function createBackingStore(): ConstructorParameters[0] { + return { getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + } +} + +function createStoreWithState(state: Partial): ClaudeUsageStore { + const store = new ClaudeUsageStore(createBackingStore()) ;(store as unknown as { state: ClaudeUsagePersistedState }).state = { schemaVersion: 1, @@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-')) getPathMock.mockReturnValue(tempUserData) initClaudeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanClaudeUsageFiles).mockReset() vi.mocked(scanClaudeUsageFiles).mockResolvedValue({ processedFiles: [], @@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) + it('defaults a null legacy opt-in while invalidating the cache', () => { + writeFileSync( + join(tempUserData, 'orca-claude-usage.json'), + JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } }) + ) + + const store = new ClaudeUsageStore(createBackingStore()) + + expect(store.getScanState().enabled).toBe(false) + }) + it('reports no data for Orca scope when only non-Orca usage exists', async () => { const store = createStoreWithState({ sessions: [ @@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => { expect(refreshMock).toHaveBeenCalledWith(false) }) - it('persists setEnabled via async durable write without leaving tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: false, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - - await store.setEnabled(true) - - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - const persisted = JSON.parse( - readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8') - ) - expect(persisted.scanState.enabled).toBe(true) - // Pretty-print preserved for human inspection of the analytics cache. - expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: ClaudeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('persists a successful refresh with one full-cache write', async () => { + it('adapts Claude scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => { await store.refresh(true) - // Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - ).toMatchObject({ - lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) + expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], []) + expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 180f7660b6b..66085504089 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */ +/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' -import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, ClaudeUsageDailyPoint, ClaudeUsageRange, - ClaudeUsageScanState, ClaudeUsageScope, ClaudeUsageSessionRow, ClaudeUsageSnapshot, @@ -16,16 +13,15 @@ import type { } from '../../shared/claude-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count // after fork reclaim (#8006). const SCHEMA_VERSION = 5 -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() @@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState { } } +function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState { + if (state.schemaVersion === SCHEMA_VERSION) { + return state + } + // Scanner changes invalidate totals, but preserving enabled keeps existing tracking on. + const defaults = getDefaultState() + return { + ...defaults, + scanState: { + ...defaults.scanState, + enabled: state.scanState.enabled ?? defaults.scanState.enabled + } + } +} + export function initClaudeUsagePath(): void { _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json') } @@ -288,120 +299,22 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: ClaudeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class ClaudeUsageStore { - private state: ClaudeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): ClaudeUsagePersistedState { - try { - const usageFile = getClaudeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState - if (parsed.schemaVersion !== SCHEMA_VERSION) { - // Why: scanner semantics affect persisted totals, so old Claude caches - // must be rebuilt after parser/source changes instead of reused briefly. - // Preserve scanState.enabled so existing users keep tracking on across - // schema bumps; the next refresh will repopulate the analytics. - const defaults = getDefaultState() - return { - ...defaults, - scanState: { - ...defaults.scanState, - enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled - } - } - } - return { - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - } - } catch (error) { - // Why: Claude usage is a local analytics feature, not primary workspace - // state. A corrupt cache should degrade to a fresh rebuild instead of - // preventing Orca from booting, but we leave the file on disk for debugging. - console.error('[claude-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): ClaudeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class ClaudeUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + ClaudeUsagePersistedState, + 'hasAnyClaudeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[claude-usage]', + resolveCacheFile: getClaudeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyClaudeData', + jsonIndent: 2, + scan: scanClaudeUsageFiles + }) } getSnapshot( @@ -419,63 +332,6 @@ export class ClaudeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - // Why: assign scanPromise before any await so concurrent refresh shares one scan. - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await scanClaudeUsageFiles( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -843,7 +699,7 @@ export class ClaudeUsageStore { } private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -856,12 +712,12 @@ export class ClaudeUsageStore { } private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { // Why: daily aggregates use local calendar days, so session filtering has // to use the same conversion or the sessions table/counts can disagree // with the chart around UTC day boundaries. - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -883,10 +739,4 @@ export class ClaudeUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/codex-usage/store-snapshot.benchmark.test.ts b/src/main/codex-usage/store-snapshot.benchmark.test.ts index 6ecd39963b9..dbd1482f34c 100644 --- a/src/main/codex-usage/store-snapshot.benchmark.test.ts +++ b/src/main/codex-usage/store-snapshot.benchmark.test.ts @@ -16,8 +16,8 @@ vi.mock('electron', () => ({ function createStoreWithState(state: CodexUsagePersistedState): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = state return store diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 83e5a43fabd..dc5d7b9fe56 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -1,25 +1,12 @@ /* eslint-disable max-lines */ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +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 { - CodexUsageDailyAggregate, - CodexUsagePersistedFile, - CodexUsagePersistedState, - CodexUsageSession -} from './types' +import type { CodexUsagePersistedState } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -28,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanCodexUsageFiles: vi.fn() })) @@ -58,27 +22,7 @@ vi.mock('./scanner', () => ({ import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store' import { scanCodexUsageFiles } from './scanner' -type ScanResult = { - processedFiles: CodexUsagePersistedFile[] - sessions: CodexUsageSession[] - dailyAggregates: CodexUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void - reject: (reason?: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - return { promise, resolve, reject } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedFiles: [], sessions: [], @@ -89,9 +33,8 @@ function createEmptyScanResult(): ScanResult { function createStoreWithState(state: Partial): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = { schemaVersion: 1, @@ -118,11 +61,6 @@ describe('CodexUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-')) getPathMock.mockReturnValue(tempUserData) initCodexUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanCodexUsageFiles).mockReset() vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -134,7 +72,7 @@ describe('CodexUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one compact async disk write', async () => { + it('adapts Codex scans to compact cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -147,92 +85,9 @@ describe('CodexUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8') + expect(scanCodexUsageFiles).toHaveBeenCalledWith([], []) expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson))) - expect(persistedJson).not.toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: CodexUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => { diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 10aad99fbbf..c1784f556a1 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */ +/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, CodexUsageDailyPoint, CodexUsageRange, - CodexUsageScanState, CodexUsageScope, CodexUsageSessionRow, CodexUsageSnapshot, @@ -16,13 +13,12 @@ import type { } from '../../shared/codex-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -308,31 +304,6 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: CodexUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - type ScopedCodexUsageModelRow = { modelKey: string modelLabel: string @@ -345,78 +316,21 @@ type ScopedCodexUsageModelRow = { totalTokens: number } -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class CodexUsageStore { - private state: CodexUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): CodexUsagePersistedState { - try { - const usageFile = getCodexUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as CodexUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[codex-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan. - return this.writer.write(() => JSON.stringify(this.state)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): CodexUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyCodexData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class CodexUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + CodexUsagePersistedState, + 'hasAnyCodexData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[codex-usage]', + resolveCacheFile: getCodexUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyCodexData', + scan: codexUsageProvider.scan + }) } getSnapshot( @@ -434,61 +348,6 @@ export class CodexUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await codexUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -888,7 +747,7 @@ export class CodexUsageStore { } private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -901,9 +760,9 @@ export class CodexUsageStore { } private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -975,10 +834,4 @@ export class CodexUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index 6f3759efa7f..d565327fd18 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -1,24 +1,15 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +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 { OpenCodeUsageDailyAggregate, - OpenCodeUsagePersistedDatabase, OpenCodeUsagePersistedState, OpenCodeUsageSession } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -27,29 +18,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanOpenCodeUsageDatabases: vi.fn() })) @@ -57,24 +25,7 @@ vi.mock('./scanner', () => ({ import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store' import { scanOpenCodeUsageDatabases } from './scanner' -type ScanResult = { - processedDatabases: OpenCodeUsagePersistedDatabase[] - sessions: OpenCodeUsageSession[] - dailyAggregates: OpenCodeUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { promise, resolve } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedDatabases: [], sessions: [], @@ -101,9 +52,8 @@ function getDefaultState(): OpenCodeUsagePersistedState { function createStoreWithState(state: Partial): OpenCodeUsageStore { const store = new OpenCodeUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { ...getDefaultState(), @@ -212,11 +162,6 @@ describe('OpenCodeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-store-')) getPathMock.mockReturnValue(tempUserData) initOpenCodeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanOpenCodeUsageDatabases).mockReset() vi.mocked(scanOpenCodeUsageDatabases).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -228,7 +173,7 @@ describe('OpenCodeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one full-cache write', async () => { + it('adapts OpenCode scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ scanState: { enabled: true, @@ -240,89 +185,9 @@ describe('OpenCodeUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole multi-MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8') + expect(scanOpenCodeUsageDatabases).toHaveBeenCalledWith([], []) expect(persistedJson).toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanOpenCodeUsageDatabases).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: OpenCodeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-opencode-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index c85fa519ba5..b87357f9b0a 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,27 +1,23 @@ -/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, OpenCodeUsageDailyPoint, OpenCodeUsageRange, - OpenCodeUsageScanState, OpenCodeUsageScope, OpenCodeUsageSessionRow, OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 let _openCodeUsageFile: string | null = null @@ -70,47 +66,6 @@ function getOpenCodeUsageFile(): string { return _openCodeUsageFile } -function getRangeCutoff(range: OpenCodeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - function addCost(left: number | null, right: number | null): number | null { if (left === null && right === null) { return null @@ -148,62 +103,22 @@ function normalizeSessionCost( } } -export class OpenCodeUsageStore { - private state: OpenCodeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the multi-MB usage JSON must not block the Electron main thread; the writer serializes - // writes and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[opencode-usage]', getOpenCodeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): OpenCodeUsagePersistedState { - try { - const usageFile = getOpenCodeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): OpenCodeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< + 'processedDatabases', + OpenCodeUsagePersistedState, + 'hasAnyOpenCodeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[opencode-usage]', + resolveCacheFile: getOpenCodeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedDatabases', + dataPresenceKey: 'hasAnyOpenCodeData', + jsonIndent: 2, + scan: openCodeUsageProvider.scan + }) } getSnapshot( @@ -221,63 +136,6 @@ export class OpenCodeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole cache before a single result - // changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await openCodeUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint - ? this.state.processedDatabases - : [] - ) - this.state.processedDatabases = result.processedDatabases - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary( scope: OpenCodeUsageScope, range: OpenCodeUsageRange @@ -479,7 +337,7 @@ export class OpenCodeUsageStore { scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyAggregate[] { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((row) => { if (scope === 'orca' && !row.worktreeId) { return false @@ -492,13 +350,13 @@ export class OpenCodeUsageStore { } private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { if (scope === 'orca' && !session.primaryWorktreeId) { return false } if (cutoff) { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day || day < cutoff) { return false } @@ -506,9 +364,4 @@ export class OpenCodeUsageStore { return true }) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) - } } diff --git a/src/main/usage/usage-calendar-range.test.ts b/src/main/usage/usage-calendar-range.test.ts new file mode 100644 index 00000000000..73645c2ccc1 --- /dev/null +++ b/src/main/usage/usage-calendar-range.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getLocalUsageDay, getUsageRangeCutoff } from './usage-calendar-range' + +describe('usage calendar ranges', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 3, 10, 12)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ['7d', '2026-04-04'], + ['30d', '2026-03-12'], + ['90d', '2026-01-11'], + ['all', null] + ] as const)('uses an inclusive local-calendar cutoff for %s', (range, expected) => { + expect(getUsageRangeCutoff(range)).toBe(expected) + }) + + it('maps timestamps to local calendar days and rejects invalid values', () => { + const localTimestamp = new Date(2026, 3, 4, 23, 30).toISOString() + + expect(getLocalUsageDay(localTimestamp)).toBe('2026-04-04') + expect(getLocalUsageDay('not-a-date')).toBeNull() + }) +}) diff --git a/src/main/usage/usage-calendar-range.ts b/src/main/usage/usage-calendar-range.ts new file mode 100644 index 00000000000..255ef245be5 --- /dev/null +++ b/src/main/usage/usage-calendar-range.ts @@ -0,0 +1,24 @@ +type UsageCalendarRange = '7d' | '30d' | '90d' | 'all' + +function formatLocalDay(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getUsageRangeCutoff(range: UsageCalendarRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const cutoff = new Date() + cutoff.setHours(0, 0, 0, 0) + cutoff.setDate(cutoff.getDate() - (days - 1)) + return formatLocalDay(cutoff) +} + +export function getLocalUsageDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + return Number.isNaN(parsed.getTime()) ? null : formatLocalDay(parsed) +} diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts new file mode 100644 index 00000000000..02a5571363d --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.test.ts @@ -0,0 +1,293 @@ +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' + +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[], + previous: TestSource[] +) => 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 { processedSources: [], 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) { + super( + { + getRepos: () => [], + getAllWorktreeMeta: () => ({}) + }, + { + logTag: '[test-usage]', + resolveCacheFile: () => cacheFile, + createDefaultState: makeState, + normalizeState: (state) => state, + sourceKey: 'processedSources', + dataPresenceKey: 'hasAnyTestData', + scan + } + ) + } + + replaceState(state: TestState): void { + this.state = state + } + + getState(): TestState { + return this.state + } +} + +describe('UsageProviderStoreLifecycle', () => { + let tempDirectory: string + let stores: TestUsageStore[] + let scan: ReturnType> + + function createStore( + cacheFile = join(tempDirectory, `usage-${stores.length}.json`) + ): TestUsageStore { + const store = new TestUsageStore(cacheFile, scan) + 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()) + 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('skips disabled and fresh matching states', async () => { + const store = createStore() + + await store.refresh() + expect(scan).not.toHaveBeenCalled() + + store.replaceState( + makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + await store.refresh() + + expect(scan).not.toHaveBeenCalled() + }) + + it('invalidates prior sources on fingerprint changes and reuses them for forced scans', async () => { + const store = createStore() + const refreshedSources = [{ id: 'refreshed' }] + scan.mockResolvedValueOnce({ + processedSources: refreshedSources, + sessions: [], + dailyAggregates: [] + }) + store.replaceState( + makeState({ + worktreeFingerprint: 'outdated', + processedSources: [{ id: 'stale' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + + await store.refresh() + expect(scan).toHaveBeenLastCalledWith([], []) + + scan.mockClear() + await store.refresh(true) + + expect(scan).toHaveBeenCalledWith([], refreshedSources) + }) + + 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({ + processedSources: [{ id: 'source' }], + 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().processedSources).toEqual([{ id: 'source' }]) + }) + + 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, + processedSources: [{ id: 'source' }], + 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, + processedSources: previousState.processedSources, + 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-provider-store-lifecycle.ts b/src/main/usage/usage-provider-store-lifecycle.ts new file mode 100644 index 00000000000..850d9e61bda --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { Store } from '../persistence' +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' + +const STALE_MS = 5 * 60_000 + +type UsageProviderScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} + +type UsageProviderStoreState = { + worktreeFingerprint: string | null + sessions: unknown[] + dailyAggregates: unknown[] + scanState: UsageProviderScanState +} & Record + +type UsageProviderScanProjection< + SourceKey extends string, + State extends UsageProviderStoreState +> = Pick + +type UsageProviderStoreLifecycleConfig< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> = { + logTag: string + resolveCacheFile: () => string + createDefaultState: () => State + normalizeState: (state: State) => State + sourceKey: SourceKey + dataPresenceKey: DataPresenceKey + jsonIndent?: number + scan: ( + worktrees: UsageScanWorktreeRef[], + previous: State[SourceKey] + ) => Promise> +} + +type PublicUsageProviderScanState = UsageProviderScanState & { + isScanning: boolean +} & Record + +export abstract class UsageProviderStoreLifecycle< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> { + protected state: State + private scanPromise: Promise | null = null + private readonly writer: UsageCacheSnapshotWriter + + constructor( + private readonly store: Pick, + private readonly config: UsageProviderStoreLifecycleConfig + ) { + this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile) + this.state = this.load() + } + + getScanState(): PublicUsageProviderScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + [this.config.dataPresenceKey]: + this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } as PublicUsageProviderScanState + } + + /** Await queued cache writes so quit does not drop the final snapshot. */ + flush(): Promise { + return this.writer.flush() + } + + async setEnabled(enabled: boolean): Promise> { + this.state.scanState.enabled = enabled + await this.writeToDisk() + return this.getScanState() + } + + async refresh(force = false): Promise> { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + if (!force && this.state.scanState.lastScanCompletedAt) { + const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { + return this.getScanState() + } + } + await this.runScan() + return this.getScanState() + } + + protected writeToDisk(): Promise { + return this.writer.write(() => JSON.stringify(this.state, null, this.config.jsonIndent)) + } + + private load(): State { + const defaults = this.config.createDefaultState() + try { + const cacheFile = this.config.resolveCacheFile() + if (!existsSync(cacheFile)) { + return defaults + } + const parsed = JSON.parse(readFileSync(cacheFile, 'utf-8')) as State + return this.config.normalizeState({ + ...defaults, + ...parsed, + scanState: { ...defaults.scanState, ...parsed.scanState } + }) + } catch (error) { + console.error(`${this.config.logTag} Failed to load persisted state, starting fresh:`, error) + return defaults + } + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + + // Assign before yielding so concurrent refreshes share one scan. + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo) + const result = await this.config.scan( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state[this.config.sourceKey] + : this.config.createDefaultState()[this.config.sourceKey] + ) + this.state[this.config.sourceKey] = result[this.config.sourceKey] + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + // Persistence failures do not turn a successful source scan into a scan failure. + await this.writeToDisk().catch(() => {}) + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + await this.writeToDisk().catch(() => {}) + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/usage/usage-worktree-refs.test.ts b/src/main/usage/usage-worktree-refs.test.ts new file mode 100644 index 00000000000..56efe7e4fb8 --- /dev/null +++ b/src/main/usage/usage-worktree-refs.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' +import { getUsageWorktreeFingerprint } from './usage-worktree-refs' + +function worktree(worktreeId: string, path: string, displayName: string): UsageWorktreeRef { + return { worktreeId, path, displayName } +} + +describe('getUsageWorktreeFingerprint', () => { + it('is stable across repo and worktree ordering but changes with identity metadata', () => { + const first = new Map([ + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two'), + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One') + ] + ], + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]] + ]) + const reordered = new Map([ + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]], + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One'), + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two') + ] + ] + ]) + const renamed = new Map(reordered) + renamed.set('repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'Renamed A')]) + + expect(getUsageWorktreeFingerprint(first)).toBe(getUsageWorktreeFingerprint(reordered)) + expect(getUsageWorktreeFingerprint(renamed)).not.toBe(getUsageWorktreeFingerprint(first)) + }) +}) diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/usage-worktree-refs.ts index 0b4347a90a5..f76ed7c0a58 100644 --- a/src/main/usage/usage-worktree-refs.ts +++ b/src/main/usage/usage-worktree-refs.ts @@ -1,6 +1,25 @@ import type { Repo } from '../../shared/types' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' import type { UsageScanWorktreeRef } from './usage-provider-contract' +export function getUsageWorktreeFingerprint( + worktreesByRepo: Map +): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + export function createWorktreeRefs( repos: Repo[], worktreesByRepo: Map