refactor(usage): share provider store lifecycle (#13558)

This commit is contained in:
Neil
2026-08-10 20:38:31 -07:00
committed by GitHub
parent 556f469b0b
commit 65e2b5b598
13 changed files with 696 additions and 945 deletions
+25 -122
View File
@@ -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<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
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<void>((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<typeof Scanner>()),
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<ClaudeUsagePersistedState>): ClaudeUsageStore {
const store = new ClaudeUsageStore({
function createBackingStore(): ConstructorParameters<typeof ClaudeUsageStore>[0] {
return {
getRepos: () => [],
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined
} as never)
getAllWorktreeMeta: () => ({})
}
}
function createStoreWithState(state: Partial<ClaudeUsagePersistedState>): 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<void>
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')
})
})
+37 -187
View File
@@ -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, UsageWorktreeRef[]>): 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<void> | 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<void> {
// 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<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<ClaudeUsageScanState> {
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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
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<ClaudeUsageScanState> {
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<void> {
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<ClaudeUsageSummary> {
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<string> {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
}
@@ -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
+9 -154
View File
@@ -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<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
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<void>((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<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<CodexUsagePersistedState>): 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<ScanResult>()
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<void>
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 () => {
+21 -168
View File
@@ -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 scanners 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, UsageWorktreeRef[]>): 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<void> | 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<void> {
// 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<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<CodexUsageScanState> {
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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
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<CodexUsageScanState> {
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<void> {
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<CodexUsageSummary> {
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<string> {
const repos = this.store.getRepos()
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
return getWorktreeFingerprint(worktreesByRepo)
}
}
+8 -143
View File
@@ -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<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
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<void>((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<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<OpenCodeUsagePersistedState>): 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<ScanResult>()
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<void>
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 () => {
+22 -169
View File
@@ -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, UsageWorktreeRef[]>): 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<void> | 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<void> {
// 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<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<OpenCodeUsageScanState> {
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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
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<OpenCodeUsageScanState> {
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<void> {
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<string> {
const repos = this.store.getRepos()
return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
}
}
@@ -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()
})
})
+24
View File
@@ -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)
}
@@ -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<typeof FsPromises>('node:fs/promises')
return {
...actual,
open: (async (...args: Parameters<typeof actual.open>) => {
if (args[1] === 'w') {
writeProbe.opens += 1
if (writeProbe.blocked) {
await new Promise<void>((resolve) => writeProbe.waiters.push(resolve))
}
}
return actual.open(...args)
}) as typeof actual.open,
rename: ((...args: Parameters<typeof actual.rename>) => {
writeProbe.renames += 1
return actual.rename(...args)
}) as typeof actual.rename
}
})
type TestSource = { id: string }
type TestSession = { id: string }
type TestDailyAggregate = { day: string }
type TestScanState = {
enabled: boolean
lastScanStartedAt: number | null
lastScanCompletedAt: number | null
lastScanError: string | null
}
type TestState = {
schemaVersion: number
worktreeFingerprint: string | null
processedSources: TestSource[]
sessions: TestSession[]
dailyAggregates: TestDailyAggregate[]
scanState: TestScanState
}
type TestScanResult = Pick<TestState, 'processedSources' | 'sessions' | 'dailyAggregates'>
type TestScan = (
worktrees: UsageScanWorktreeRef[],
previous: TestSource[]
) => Promise<TestScanResult>
const NOW = Date.parse('2026-04-10T16:00:00.000Z')
const EMPTY_WORKTREE_FINGERPRINT = '[]'
function makeState(
overrides: Partial<Omit<TestState, 'scanState'>> & { scanState?: Partial<TestScanState> } = {}
): TestState {
const { scanState, ...stateOverrides } = overrides
return {
schemaVersion: 1,
worktreeFingerprint: null,
processedSources: [],
sessions: [],
dailyAggregates: [],
...stateOverrides,
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null,
...scanState
}
}
}
function emptyScanResult(): TestScanResult {
return { processedSources: [], sessions: [], dailyAggregates: [] }
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve
})
return { promise, resolve }
}
class TestUsageStore extends UsageProviderStoreLifecycle<
'processedSources',
TestState,
'hasAnyTestData'
> {
constructor(cacheFile: string, scan: TestScan) {
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<typeof vi.fn<TestScan>>
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<TestScan>().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<TestScanResult>()
scan.mockReturnValueOnce(pendingScan.promise)
const store = createStore()
store.replaceState(
makeState({ scanState: { enabled: true, lastScanError: 'previous failure' } })
)
const firstRefresh = store.refresh(true)
const secondRefresh = store.refresh(true)
await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1))
expect(store.getScanState()).toMatchObject({
isScanning: true,
lastScanStartedAt: NOW,
lastScanError: null
})
expect(writeProbe.opens).toBe(0)
pendingScan.resolve({
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'
)
})
})
@@ -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<SourceKey extends string> = {
worktreeFingerprint: string | null
sessions: unknown[]
dailyAggregates: unknown[]
scanState: UsageProviderScanState
} & Record<SourceKey, unknown[]>
type UsageProviderScanProjection<
SourceKey extends string,
State extends UsageProviderStoreState<SourceKey>
> = Pick<State, SourceKey | 'sessions' | 'dailyAggregates'>
type UsageProviderStoreLifecycleConfig<
SourceKey extends string,
State extends UsageProviderStoreState<SourceKey>,
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<UsageProviderScanProjection<SourceKey, State>>
}
type PublicUsageProviderScanState<DataPresenceKey extends string> = UsageProviderScanState & {
isScanning: boolean
} & Record<DataPresenceKey, boolean>
export abstract class UsageProviderStoreLifecycle<
SourceKey extends string,
State extends UsageProviderStoreState<SourceKey>,
DataPresenceKey extends string
> {
protected state: State
private scanPromise: Promise<void> | null = null
private readonly writer: UsageCacheSnapshotWriter
constructor(
private readonly store: Pick<Store, 'getRepos' | 'getAllWorktreeMeta'>,
private readonly config: UsageProviderStoreLifecycleConfig<SourceKey, State, DataPresenceKey>
) {
this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile)
this.state = this.load()
}
getScanState(): PublicUsageProviderScanState<DataPresenceKey> {
return {
...this.state.scanState,
isScanning: this.scanPromise !== null,
[this.config.dataPresenceKey]:
this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0
} as PublicUsageProviderScanState<DataPresenceKey>
}
/** Await queued cache writes so quit does not drop the final snapshot. */
flush(): Promise<void> {
return this.writer.flush()
}
async setEnabled(enabled: boolean): Promise<PublicUsageProviderScanState<DataPresenceKey>> {
this.state.scanState.enabled = enabled
await this.writeToDisk()
return this.getScanState()
}
async refresh(force = false): Promise<PublicUsageProviderScanState<DataPresenceKey>> {
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<void> {
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<void> {
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<string> {
const repos = this.store.getRepos()
return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
}
}
@@ -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<string, UsageWorktreeRef[]>([
[
'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<string, UsageWorktreeRef[]>([
['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))
})
})
+19
View File
@@ -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, UsageWorktreeRef[]>
): 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<string, { path: string; worktreeId: string; displayName: string }[]>