mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
refactor(usage): share provider store lifecycle (#13558)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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 }[]>
|
||||
|
||||
Reference in New Issue
Block a user