Files
orca/src/main/claude-usage/store.ts
T
Brennan Benson 175f10e32b Run local usage services in a supervised backend process (#40792)
* Move local usage services into a supervised backend process

* Own usage recovery and worker retirement lifetimes

* Preserve answered usage data during background refresh

* Run independent usage scans concurrently in the backend

* Limit backend startup paths to the delegated usage service

* Show usage failures through backend recovery

* Include usage failure strings in the English catalog

* Keep usage update details beside refresh failures

* Resolve usage freshness failure from the final producer snapshot

* Retain the forced refresh completion requirement

* Keep managed backend failures out of application crash reports

* Preserve usage recovery boundaries across backend reconnects

* Match the backend RPC cancellation signal in the usage seam proof

* Avoid serializing backend requests before port delivery

* Keep receiving request validation in the boundary schemas

* test: align usage transport coverage with renderer project

* fix: preserve unknown usage state and renderer retirement

* fix(usage): revalidate tracking before scan admission

* fix(usage): retry failed backend startup on demand

* test(usage): isolate stale backend ownership checks
2026-11-11 16:39:52 -08:00

146 lines
4.9 KiB
TypeScript

import { claudeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
import { join } from 'node:path'
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
ClaudeUsageDailyPoint,
ClaudeUsageRange,
ClaudeUsageScope,
ClaudeUsageSessionRow,
ClaudeUsageSnapshot,
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
import type { UsageStoreHost } from '../usage/usage-store-host'
import type { ClaudeUsagePersistedState } from './types'
import {
scanClaudeUsageFilesViaWorker,
splitUsageCacheFileViaWorker
} from '../usage/scan-worker/usage-scan-worker-spawn'
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
import { buildBreakdown, buildDaily, buildSummary } from './claude-usage-report-aggregation'
import { buildRecentSessions } from './claude-usage-session-rows'
import type { AutomationUsageLookupInput } from './claude-usage-automation-attribution'
import { resolveAutomationRunUsage } from './claude-usage-automation-attribution'
import { parseClaudeUsageReport, serializeClaudeUsageReport } from './persisted-usage-report'
// Pre-v6 totals lack stable fork ownership or the 1-hour cache-write split and must be rescanned.
// v7 stores protected column checkpoints on the worker and a separately protected report.
const SCHEMA_VERSION = 7
function getDefaultState(): ClaudeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
worktreeFingerprint: null,
processedFiles: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState {
if (state.schemaVersion === 6 || 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 class ClaudeUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
ClaudeUsagePersistedState,
'hasAnyClaudeData'
> {
constructor(host: UsageStoreHost) {
super(host, {
tokenUsage: {
provider: 'claude',
selectSessions: (state) => claudeTokenSessions(state.sessions)
},
logTag: '[claude-usage]',
resolveCacheFile: () => join(host.userDataPath, 'orca-claude-usage.json'),
createDefaultState: getDefaultState,
normalizeState: normalizePersistedState,
parseReport: parseClaudeUsageReport,
serializeReport: serializeClaudeUsageReport,
providerId: 'claude',
sourceKey: 'processedFiles',
dataPresenceKey: 'hasAnyClaudeData',
scan: async (worktrees, sourceCache) => {
const profileDirs = await host.claudeProfileDirs()
host.signal.throwIfAborted()
return scanClaudeUsageFilesViaWorker(worktrees, sourceCache, profileDirs)
},
splitCacheFile: splitUsageCacheFileViaWorker
})
}
getSnapshot(
scope: ClaudeUsageScope,
range: ClaudeUsageRange,
recentSessionLimit = 10
): ClaudeUsageSnapshot {
return {
scanState: this.getScanState(),
summary: buildSummary(this.state, scope, range),
daily: buildDaily(this.state, scope, range),
modelBreakdown: buildBreakdown(this.state, scope, range, 'model'),
projectBreakdown: buildBreakdown(this.state, scope, range, 'project'),
recentSessions: buildRecentSessions(this.state, scope, range, recentSessionLimit)
}
}
async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise<ClaudeUsageSummary> {
await this.refresh(false)
return buildSummary(this.state, scope, range)
}
async getDaily(
scope: ClaudeUsageScope,
range: ClaudeUsageRange
): Promise<ClaudeUsageDailyPoint[]> {
await this.refresh(false)
return buildDaily(this.state, scope, range)
}
async getBreakdown(
scope: ClaudeUsageScope,
range: ClaudeUsageRange,
kind: ClaudeUsageBreakdownKind
): Promise<ClaudeUsageBreakdownRow[]> {
await this.refresh(false)
return buildBreakdown(this.state, scope, range, kind)
}
async getRecentSessions(
scope: ClaudeUsageScope,
range: ClaudeUsageRange,
limit = 12
): Promise<ClaudeUsageSessionRow[]> {
await this.refresh(false)
return buildRecentSessions(this.state, scope, range, limit)
}
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
await this.whenLoaded()
return resolveAutomationRunUsage(input, {
getState: () => this.state,
refresh: (force) => this.refresh(force),
isScanning: () => this.getScanState().isScanning
})
}
}