mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
* fix(worktrees): prevent deletion from blocking Orca * test(worktrees): loosen async history-delete event-loop bound for CI The main-thread safety check failed on a loaded runner when a single timer gap hit ~48ms under the prior 30ms threshold. Keep the bound well below a recursive sync-rm stall without treating CI jitter as a block. * test(worktrees): measure history-delete critical path, not timer gaps setInterval gaps during async rm of thousands of files still flake under CI scheduling. deleteWorktreeHistoryDir is sync and must only rename, so assert that critical-path wall time stays well below a recursive walk. * fix(worktrees): prevent deletion from blocking Orca Add timeout-based draining of watcher closes so SSH round-trip delays don't indefinitely block the worktree removal path. Also: order durable temp-file sweeps ahead of writes to reclaim orphans before accumulation, skip own-process temps to avoid deleting live writes, swallow persistence errors so disk failures don't cascade to query callers, and measure history-deletion progress by loop turns rather than timer gaps to detect blocking on CI runners. * fix(worktrees): prevent deletion from blocking Orca Worktree deletion can now proceed even if filesystem watchers or history cleanup operations hang, preventing Orca from freezing. Changes: - Fence install slots with tokens instead of counters so removals can abandon wedged installs without corrupting later removals - Timeout-bound watcher unsubscribe operations with a shared drain budget - Move JSON serialization of large usage caches from queue-time to write-time to avoid blocking main thread - Async tombstone + schedule history tree deletion instead of blocking recursive rmSync during GC, preventing main-thread stalls ~10s after startup * Extract usage cache writer into reusable durable snapshot class Consolidates serialized durable-write and generation-veto logic from three usage stores into UsageCacheSnapshotWriter. Eliminates duplication, centralizes multi-MB JSON serialization on the main thread via write-queue serialization, and vetoes superseded snapshots to avoid wasted rewrites. * fix(worktrees): prevent deletion from blocking Orca Worktree deletion used to recursively delete large session trees (hundreds of MB) on the critical path, stalling the event loop. Instead, rename trees into a `.pending-delete` tombstone queue and reclaim them asynchronously off the removal's critical path. Extracted host tree removal into a reusable helper (`removeHostTree`) that centralizes Windows retry logic. Added usage-cache flush on quit to prevent data loss when scans complete right before shutdown. Improved watcher removal deadline management with reserved tail slices for the final unsubscribe, and added retry logic for tombstone removals that fail once under transient Windows locking. * fix(history): retry failed session tree removals Tombstoned session trees whose removal fails transiently (e.g., EBUSY under Windows AV) are now re-queued in-process with bounded exponential backoff instead of sitting until the next HistoryManager construction. Prevents a single stuck tree from blocking the entire Orca process.
987 lines
33 KiB
TypeScript
987 lines
33 KiB
TypeScript
/* 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. */
|
||
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,
|
||
CodexUsageSummary
|
||
} 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, scanCodexUsageFiles } from './scanner'
|
||
|
||
// Why: v5 keys Codex ownership on raw token_count identity without session id
|
||
// so forks that rewrite session_meta still match. Older caches used session-
|
||
// scoped keys and can double-count after fork/resume (#8006).
|
||
const SCHEMA_VERSION = 5
|
||
const STALE_MS = 5 * 60_000
|
||
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
|
||
|
||
let _codexUsageFile: string | null = null
|
||
|
||
type TieredPrice = { threshold: number; price: number }
|
||
type CodexModelPricing = {
|
||
input: number
|
||
cachedInput: number
|
||
output: number
|
||
inputTiers?: TieredPrice[]
|
||
cachedInputTiers?: TieredPrice[]
|
||
outputTiers?: TieredPrice[]
|
||
}
|
||
|
||
type AutomationUsageLookupInput = {
|
||
worktreeId: string | null
|
||
terminalSessionId: string | null
|
||
startedAt: number | null
|
||
completedAt: number | null
|
||
}
|
||
|
||
const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000
|
||
|
||
const MODEL_PRICING: Record<string, CodexModelPricing> = {
|
||
'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 },
|
||
'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 },
|
||
'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 },
|
||
'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 },
|
||
'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 },
|
||
'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
|
||
'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 },
|
||
'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
|
||
'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 },
|
||
'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 },
|
||
'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 },
|
||
'gpt-5.4-pro': {
|
||
input: 30,
|
||
cachedInput: 30,
|
||
output: 180,
|
||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
|
||
},
|
||
'gpt-5.4': {
|
||
input: 2.5,
|
||
cachedInput: 0.25,
|
||
output: 15,
|
||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
|
||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
|
||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
|
||
},
|
||
'gpt-5.5-pro': {
|
||
input: 30,
|
||
cachedInput: 30,
|
||
output: 180,
|
||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
|
||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
|
||
},
|
||
'gpt-5.5': {
|
||
input: 5,
|
||
cachedInput: 0.5,
|
||
output: 30,
|
||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
|
||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
|
||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
|
||
},
|
||
'gpt-5.6-sol': {
|
||
input: 5,
|
||
cachedInput: 0.5,
|
||
output: 30,
|
||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
|
||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
|
||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
|
||
},
|
||
'gpt-5.6-terra': {
|
||
input: 2.5,
|
||
cachedInput: 0.25,
|
||
output: 15,
|
||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
|
||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
|
||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
|
||
},
|
||
'gpt-5.6-luna': {
|
||
input: 1,
|
||
cachedInput: 0.1,
|
||
output: 6,
|
||
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
|
||
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }],
|
||
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }]
|
||
}
|
||
}
|
||
|
||
const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none']
|
||
|
||
function getDefaultState(): CodexUsagePersistedState {
|
||
return {
|
||
schemaVersion: SCHEMA_VERSION,
|
||
worktreeFingerprint: null,
|
||
processedFiles: [],
|
||
sessions: [],
|
||
dailyAggregates: [],
|
||
scanState: {
|
||
enabled: false,
|
||
lastScanStartedAt: null,
|
||
lastScanCompletedAt: null,
|
||
lastScanError: null
|
||
}
|
||
}
|
||
}
|
||
|
||
export function normalizePersistedState(state: CodexUsagePersistedState): CodexUsagePersistedState {
|
||
if (state.schemaVersion !== SCHEMA_VERSION) {
|
||
// Why: Orca-scoped Codex projections now depend on locationModelBreakdown.
|
||
// Reusing an older cache would silently serve wrong model/session rows
|
||
// until the next forced rescan, so schema changes must invalidate stale
|
||
// persisted analytics instead of best-effort patching partial data.
|
||
// 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: state.scanState?.enabled ?? defaults.scanState.enabled
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
...state,
|
||
sessions: state.sessions.map((session) => ({
|
||
...session,
|
||
locationModelBreakdown: session.locationModelBreakdown ?? []
|
||
}))
|
||
}
|
||
}
|
||
|
||
export function initCodexUsagePath(): void {
|
||
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
|
||
}
|
||
|
||
function getCodexUsageFile(): string {
|
||
if (!_codexUsageFile) {
|
||
_codexUsageFile = join(app.getPath('userData'), 'orca-codex-usage.json')
|
||
}
|
||
return _codexUsageFile
|
||
}
|
||
|
||
function stripParenthesizedReasoningTier(model: string): string | null {
|
||
const match = model.match(/^(.*)\(([^()]*)\)$/)
|
||
if (!match) {
|
||
return model
|
||
}
|
||
const tier = match[2].trim().toLowerCase()
|
||
if (!REASONING_TIER_SUFFIXES.includes(tier)) {
|
||
return null
|
||
}
|
||
return match[1]
|
||
}
|
||
|
||
function stripDashReasoningTiers(model: string): string {
|
||
let current = model
|
||
for (let index = 0; index < 4; index++) {
|
||
const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`))
|
||
if (!suffix) {
|
||
return current
|
||
}
|
||
current = current.slice(0, -suffix.length - 1)
|
||
}
|
||
return current
|
||
}
|
||
|
||
function normalizeModelForPricing(model: string | null): string | null {
|
||
if (!model) {
|
||
return null
|
||
}
|
||
|
||
const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim())
|
||
if (!lower) {
|
||
return null
|
||
}
|
||
|
||
const normalized = stripDashReasoningTiers(lower)
|
||
if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') {
|
||
return 'gpt-5'
|
||
}
|
||
if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) {
|
||
return 'gpt-5.1-codex-max'
|
||
}
|
||
if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) {
|
||
return 'gpt-5.1-codex'
|
||
}
|
||
if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) {
|
||
return 'gpt-5.1'
|
||
}
|
||
if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) {
|
||
return 'gpt-5.2-codex'
|
||
}
|
||
if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) {
|
||
return 'gpt-5.2'
|
||
}
|
||
if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) {
|
||
return 'gpt-5.3-codex-spark'
|
||
}
|
||
if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) {
|
||
return 'gpt-5.3-codex'
|
||
}
|
||
if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) {
|
||
return 'gpt-5.3'
|
||
}
|
||
if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) {
|
||
return 'gpt-5.4-mini'
|
||
}
|
||
if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) {
|
||
return 'gpt-5.4-nano'
|
||
}
|
||
if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) {
|
||
return 'gpt-5.4-pro'
|
||
}
|
||
if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) {
|
||
return 'gpt-5.4'
|
||
}
|
||
if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) {
|
||
return 'gpt-5.5-pro'
|
||
}
|
||
if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) {
|
||
return 'gpt-5.5'
|
||
}
|
||
if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) {
|
||
return 'gpt-5.6-sol'
|
||
}
|
||
if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) {
|
||
return 'gpt-5.6-terra'
|
||
}
|
||
if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) {
|
||
return 'gpt-5.6-luna'
|
||
}
|
||
// Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a
|
||
// `gpt-5.6-` prefix match would swallow the tier IDs above and any future
|
||
// cheaper variant.
|
||
if (normalized === 'gpt-5.6') {
|
||
return 'gpt-5.6-sol'
|
||
}
|
||
return null
|
||
}
|
||
|
||
function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number {
|
||
let cost = 0
|
||
let lowerBound = 0
|
||
let activePrice = basePrice
|
||
for (const tier of tiers) {
|
||
if (tokens <= tier.threshold) {
|
||
return cost + Math.max(tokens - lowerBound, 0) * activePrice
|
||
}
|
||
cost += (tier.threshold - lowerBound) * activePrice
|
||
lowerBound = tier.threshold
|
||
activePrice = tier.price
|
||
}
|
||
return cost + Math.max(tokens - lowerBound, 0) * activePrice
|
||
}
|
||
|
||
function estimateCostUsd(
|
||
model: string | null,
|
||
inputTokens: number,
|
||
cachedInputTokens: number,
|
||
outputTokens: number
|
||
): number | null {
|
||
const normalized = normalizeModelForPricing(model)
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
const pricing = MODEL_PRICING[normalized]
|
||
const clampedCached = Math.min(cachedInputTokens, inputTokens)
|
||
// Why: Codex cached tokens are part of the input bucket. Charge uncached
|
||
// input on (input-cached) so cached tokens are not billed once at full input
|
||
// price and again at cache-read price.
|
||
const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0)
|
||
return (
|
||
(calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) +
|
||
calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) +
|
||
calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) /
|
||
1_000_000
|
||
)
|
||
}
|
||
|
||
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
|
||
hasInferredPricing: boolean
|
||
eventCount: number
|
||
inputTokens: number
|
||
cachedInputTokens: number
|
||
outputTokens: number
|
||
reasoningOutputTokens: number
|
||
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
|
||
}
|
||
}
|
||
|
||
getSnapshot(
|
||
scope: CodexUsageScope,
|
||
range: CodexUsageRange,
|
||
recentSessionLimit = 10
|
||
): CodexUsageSnapshot {
|
||
return {
|
||
scanState: this.getScanState(),
|
||
summary: this.buildSummary(scope, range),
|
||
daily: this.buildDaily(scope, range),
|
||
modelBreakdown: this.buildBreakdown(scope, range, 'model'),
|
||
projectBreakdown: this.buildBreakdown(scope, range, 'project'),
|
||
recentSessions: this.buildRecentSessions(scope, range, recentSessionLimit)
|
||
}
|
||
}
|
||
|
||
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 scanCodexUsageFiles(
|
||
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)
|
||
}
|
||
|
||
private buildSummary(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageSummary {
|
||
const filteredDaily = this.getFilteredDaily(scope, range)
|
||
const filteredSessions = this.getFilteredSessions(scope, range)
|
||
|
||
let inputTokens = 0
|
||
let cachedInputTokens = 0
|
||
let outputTokens = 0
|
||
let reasoningOutputTokens = 0
|
||
let totalTokens = 0
|
||
let events = 0
|
||
let estimatedCostUsd = 0
|
||
let hasAnyBillableCost = false
|
||
const byModel = new Map<string, number>()
|
||
const byProject = new Map<string, number>()
|
||
|
||
for (const row of filteredDaily) {
|
||
inputTokens += row.inputTokens
|
||
cachedInputTokens += row.cachedInputTokens
|
||
outputTokens += row.outputTokens
|
||
reasoningOutputTokens += row.reasoningOutputTokens
|
||
totalTokens += row.totalTokens
|
||
events += row.eventCount
|
||
byModel.set(
|
||
row.model ?? 'Unknown model',
|
||
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
|
||
)
|
||
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
|
||
const cost = estimateCostUsd(
|
||
row.model,
|
||
row.inputTokens,
|
||
row.cachedInputTokens,
|
||
row.outputTokens
|
||
)
|
||
if (cost !== null) {
|
||
hasAnyBillableCost = true
|
||
estimatedCostUsd += cost
|
||
}
|
||
}
|
||
|
||
const topModel =
|
||
[...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
||
const topProject =
|
||
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
|
||
|
||
return {
|
||
scope,
|
||
range,
|
||
sessions: filteredSessions.length,
|
||
events,
|
||
inputTokens,
|
||
cachedInputTokens,
|
||
outputTokens,
|
||
reasoningOutputTokens,
|
||
totalTokens,
|
||
estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null,
|
||
topModel,
|
||
topProject,
|
||
hasAnyCodexData: filteredSessions.length > 0 || filteredDaily.length > 0
|
||
}
|
||
}
|
||
|
||
async getDaily(scope: CodexUsageScope, range: CodexUsageRange): Promise<CodexUsageDailyPoint[]> {
|
||
await this.refresh(false)
|
||
return this.buildDaily(scope, range)
|
||
}
|
||
|
||
private buildDaily(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageDailyPoint[] {
|
||
const byDay = new Map<string, CodexUsageDailyPoint>()
|
||
for (const row of this.getFilteredDaily(scope, range)) {
|
||
const existing = byDay.get(row.day) ?? {
|
||
day: row.day,
|
||
inputTokens: 0,
|
||
cachedInputTokens: 0,
|
||
outputTokens: 0,
|
||
reasoningOutputTokens: 0,
|
||
totalTokens: 0
|
||
}
|
||
existing.inputTokens += row.inputTokens
|
||
existing.cachedInputTokens += row.cachedInputTokens
|
||
existing.outputTokens += row.outputTokens
|
||
existing.reasoningOutputTokens += row.reasoningOutputTokens
|
||
existing.totalTokens += row.totalTokens
|
||
byDay.set(row.day, existing)
|
||
}
|
||
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
|
||
}
|
||
|
||
async getBreakdown(
|
||
scope: CodexUsageScope,
|
||
range: CodexUsageRange,
|
||
kind: CodexUsageBreakdownKind
|
||
): Promise<CodexUsageBreakdownRow[]> {
|
||
await this.refresh(false)
|
||
return this.buildBreakdown(scope, range, kind)
|
||
}
|
||
|
||
private buildBreakdown(
|
||
scope: CodexUsageScope,
|
||
range: CodexUsageRange,
|
||
kind: CodexUsageBreakdownKind
|
||
): CodexUsageBreakdownRow[] {
|
||
const rows = new Map<string, CodexUsageBreakdownRow>()
|
||
const filteredDaily = this.getFilteredDaily(scope, range)
|
||
const filteredSessions = this.getFilteredSessions(scope, range)
|
||
|
||
for (const daily of filteredDaily) {
|
||
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
|
||
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
|
||
const existing = rows.get(key) ?? {
|
||
key,
|
||
label,
|
||
sessions: 0,
|
||
events: 0,
|
||
inputTokens: 0,
|
||
cachedInputTokens: 0,
|
||
outputTokens: 0,
|
||
reasoningOutputTokens: 0,
|
||
totalTokens: 0,
|
||
estimatedCostUsd: null,
|
||
hasInferredPricing: false
|
||
}
|
||
existing.events += daily.eventCount
|
||
existing.inputTokens += daily.inputTokens
|
||
existing.cachedInputTokens += daily.cachedInputTokens
|
||
existing.outputTokens += daily.outputTokens
|
||
existing.reasoningOutputTokens += daily.reasoningOutputTokens
|
||
existing.totalTokens += daily.totalTokens
|
||
existing.hasInferredPricing ||= daily.hasInferredPricing
|
||
rows.set(key, existing)
|
||
}
|
||
|
||
for (const session of filteredSessions) {
|
||
if (kind === 'model') {
|
||
const seen = new Set<string>()
|
||
for (const model of this.getScopedSessionModels(session, scope)) {
|
||
if (seen.has(model.modelKey)) {
|
||
continue
|
||
}
|
||
seen.add(model.modelKey)
|
||
const row = rows.get(model.modelKey)
|
||
if (row) {
|
||
row.sessions++
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
const matchingLocations = session.locationBreakdown.filter((entry) =>
|
||
scope === 'all' ? true : entry.worktreeId !== null
|
||
)
|
||
const seen = new Set<string>()
|
||
for (const location of matchingLocations) {
|
||
if (seen.has(location.locationKey)) {
|
||
continue
|
||
}
|
||
seen.add(location.locationKey)
|
||
const row = rows.get(location.locationKey)
|
||
if (row) {
|
||
row.sessions++
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const row of rows.values()) {
|
||
row.estimatedCostUsd = estimateCostUsd(
|
||
kind === 'model' ? row.key : null,
|
||
row.inputTokens,
|
||
row.cachedInputTokens,
|
||
row.outputTokens
|
||
)
|
||
}
|
||
|
||
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
|
||
}
|
||
|
||
async getRecentSessions(
|
||
scope: CodexUsageScope,
|
||
range: CodexUsageRange,
|
||
limit = 12
|
||
): Promise<CodexUsageSessionRow[]> {
|
||
await this.refresh(false)
|
||
return this.buildRecentSessions(scope, range, limit)
|
||
}
|
||
|
||
private buildRecentSessions(
|
||
scope: CodexUsageScope,
|
||
range: CodexUsageRange,
|
||
limit = 12
|
||
): CodexUsageSessionRow[] {
|
||
return this.getFilteredSessions(scope, range)
|
||
.slice(0, limit)
|
||
.map((session) => {
|
||
const matchingLocations = session.locationBreakdown.filter((entry) =>
|
||
scope === 'all' ? true : entry.worktreeId !== null
|
||
)
|
||
const scopedLocations =
|
||
matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown
|
||
const totals = scopedLocations.reduce(
|
||
(acc, entry) => {
|
||
acc.events += entry.eventCount
|
||
acc.inputTokens += entry.inputTokens
|
||
acc.cachedInputTokens += entry.cachedInputTokens
|
||
acc.outputTokens += entry.outputTokens
|
||
acc.reasoningOutputTokens += entry.reasoningOutputTokens
|
||
acc.totalTokens += entry.totalTokens
|
||
acc.hasInferredPricing ||= entry.hasInferredPricing
|
||
return acc
|
||
},
|
||
{
|
||
events: 0,
|
||
inputTokens: 0,
|
||
cachedInputTokens: 0,
|
||
outputTokens: 0,
|
||
reasoningOutputTokens: 0,
|
||
totalTokens: 0,
|
||
hasInferredPricing: false
|
||
}
|
||
)
|
||
const durationMinutes = Math.max(
|
||
0,
|
||
Math.round(
|
||
(new Date(session.lastTimestamp).getTime() -
|
||
new Date(session.firstTimestamp).getTime()) /
|
||
60_000
|
||
)
|
||
)
|
||
return {
|
||
sessionId: session.sessionId,
|
||
lastActiveAt: session.lastTimestamp,
|
||
durationMinutes,
|
||
projectLabel:
|
||
scopedLocations.length > 1
|
||
? 'Multiple locations'
|
||
: (scopedLocations[0]?.projectLabel ?? session.primaryProjectLabel),
|
||
model: this.getScopedSessionPrimaryModel(session, scope),
|
||
events: totals.events,
|
||
inputTokens: totals.inputTokens,
|
||
cachedInputTokens: totals.cachedInputTokens,
|
||
outputTokens: totals.outputTokens,
|
||
reasoningOutputTokens: totals.reasoningOutputTokens,
|
||
totalTokens: totals.totalTokens,
|
||
hasInferredPricing: session.hasInferredPricing || totals.hasInferredPricing
|
||
}
|
||
})
|
||
}
|
||
|
||
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
|
||
const collectedAt = Date.now()
|
||
const unavailable = (
|
||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||
unavailableMessage: string
|
||
): AutomationRunUsage => ({
|
||
status: 'unavailable',
|
||
provider: 'codex',
|
||
model: null,
|
||
inputTokens: null,
|
||
outputTokens: null,
|
||
cacheReadTokens: null,
|
||
cacheWriteTokens: null,
|
||
reasoningOutputTokens: null,
|
||
totalTokens: null,
|
||
estimatedCostUsd: null,
|
||
estimatedCostSource: null,
|
||
providerSessionId: null,
|
||
attribution: null,
|
||
collectedAt,
|
||
unavailableReason,
|
||
unavailableMessage
|
||
})
|
||
|
||
if (!this.state.scanState.enabled) {
|
||
return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.')
|
||
}
|
||
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
|
||
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
|
||
}
|
||
|
||
const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt))
|
||
if (scanState.lastScanError) {
|
||
return unavailable('scan_failed', scanState.lastScanError)
|
||
}
|
||
|
||
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
|
||
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
|
||
const candidates = this.state.sessions.filter((session) => {
|
||
const first = new Date(session.firstTimestamp).getTime()
|
||
const last = new Date(session.lastTimestamp).getTime()
|
||
if (!Number.isFinite(first) || !Number.isFinite(last)) {
|
||
return false
|
||
}
|
||
if (session.sessionId === input.terminalSessionId) {
|
||
return true
|
||
}
|
||
if (first < windowStart || first > windowEnd || last > windowEnd) {
|
||
return false
|
||
}
|
||
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
|
||
})
|
||
|
||
if (candidates.length === 0) {
|
||
return unavailable('no_matching_session', 'No Codex usage session matched this run.')
|
||
}
|
||
if (candidates.length > 1) {
|
||
return unavailable(
|
||
'ambiguous_session',
|
||
'Multiple Codex usage sessions matched this run window.'
|
||
)
|
||
}
|
||
|
||
const session = candidates[0]
|
||
const scopedLocations = session.locationBreakdown.filter(
|
||
(entry) => entry.worktreeId === input.worktreeId
|
||
)
|
||
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
|
||
const totals = locations.reduce(
|
||
(acc, entry) => {
|
||
acc.events += entry.eventCount
|
||
acc.inputTokens += entry.inputTokens
|
||
acc.cachedInputTokens += entry.cachedInputTokens
|
||
acc.outputTokens += entry.outputTokens
|
||
acc.reasoningOutputTokens += entry.reasoningOutputTokens
|
||
acc.totalTokens += entry.totalTokens
|
||
return acc
|
||
},
|
||
{
|
||
events: 0,
|
||
inputTokens: 0,
|
||
cachedInputTokens: 0,
|
||
outputTokens: 0,
|
||
reasoningOutputTokens: 0,
|
||
totalTokens: 0
|
||
}
|
||
)
|
||
const scopedModelRows = session.locationModelBreakdown.filter(
|
||
(entry) => entry.worktreeId === input.worktreeId
|
||
)
|
||
const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown
|
||
const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))]
|
||
let estimatedCostUsd = 0
|
||
let hasKnownCost = false
|
||
if (scopedModelRows.length > 0) {
|
||
for (const modelRow of scopedModelRows) {
|
||
const cost = estimateCostUsd(
|
||
modelRow.modelKey,
|
||
modelRow.inputTokens,
|
||
modelRow.cachedInputTokens,
|
||
modelRow.outputTokens
|
||
)
|
||
if (cost !== null) {
|
||
hasKnownCost = true
|
||
estimatedCostUsd += cost
|
||
}
|
||
}
|
||
} else if (!session.hasMixedModels) {
|
||
const cost = estimateCostUsd(
|
||
session.primaryModel,
|
||
totals.inputTokens,
|
||
totals.cachedInputTokens,
|
||
totals.outputTokens
|
||
)
|
||
if (cost !== null) {
|
||
hasKnownCost = true
|
||
estimatedCostUsd += cost
|
||
}
|
||
}
|
||
|
||
return {
|
||
status: 'known',
|
||
provider: 'codex',
|
||
model:
|
||
modelLabels.length === 1
|
||
? modelLabels[0]
|
||
: session.hasMixedModels
|
||
? 'Mixed models'
|
||
: session.primaryModel,
|
||
inputTokens: totals.inputTokens,
|
||
outputTokens: totals.outputTokens,
|
||
cacheReadTokens: totals.cachedInputTokens,
|
||
cacheWriteTokens: null,
|
||
reasoningOutputTokens: totals.reasoningOutputTokens,
|
||
totalTokens: totals.totalTokens,
|
||
estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null,
|
||
estimatedCostSource: hasKnownCost ? 'api_equivalent' : null,
|
||
providerSessionId: session.sessionId,
|
||
// Why: Orca terminal tab ids and Codex usage session ids are different
|
||
// systems today, so attribution is intentionally limited to one local
|
||
// provider session in the run's worktree/time window.
|
||
attribution: 'provider_session_time_window',
|
||
collectedAt,
|
||
unavailableReason: null,
|
||
unavailableMessage: null
|
||
}
|
||
}
|
||
|
||
private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) {
|
||
const cutoff = getRangeCutoff(range)
|
||
return this.state.dailyAggregates.filter((entry) => {
|
||
if (cutoff && entry.day < cutoff) {
|
||
return false
|
||
}
|
||
if (scope === 'orca' && entry.worktreeId === null) {
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) {
|
||
const cutoff = getRangeCutoff(range)
|
||
return this.state.sessions.filter((session) => {
|
||
const day = getLocalDay(session.lastTimestamp)
|
||
if (!day) {
|
||
return false
|
||
}
|
||
if (cutoff && day < cutoff) {
|
||
return false
|
||
}
|
||
if (scope === 'orca') {
|
||
return session.locationBreakdown.some((entry) => entry.worktreeId !== null)
|
||
}
|
||
return true
|
||
})
|
||
}
|
||
|
||
private getScopedSessionModels(
|
||
session: CodexUsagePersistedState['sessions'][number],
|
||
scope: CodexUsageScope
|
||
): ScopedCodexUsageModelRow[] {
|
||
if (scope === 'all' || session.locationModelBreakdown.length === 0) {
|
||
return session.modelBreakdown
|
||
}
|
||
|
||
const rows = new Map<string, ScopedCodexUsageModelRow>()
|
||
for (const entry of session.locationModelBreakdown) {
|
||
if (entry.worktreeId === null) {
|
||
continue
|
||
}
|
||
const existing = rows.get(entry.modelKey) ?? {
|
||
modelKey: entry.modelKey,
|
||
modelLabel: entry.modelLabel,
|
||
hasInferredPricing: false,
|
||
eventCount: 0,
|
||
inputTokens: 0,
|
||
cachedInputTokens: 0,
|
||
outputTokens: 0,
|
||
reasoningOutputTokens: 0,
|
||
totalTokens: 0
|
||
}
|
||
existing.hasInferredPricing ||= entry.hasInferredPricing
|
||
existing.eventCount += entry.eventCount
|
||
existing.inputTokens += entry.inputTokens
|
||
existing.cachedInputTokens += entry.cachedInputTokens
|
||
existing.outputTokens += entry.outputTokens
|
||
existing.reasoningOutputTokens += entry.reasoningOutputTokens
|
||
existing.totalTokens += entry.totalTokens
|
||
rows.set(entry.modelKey, existing)
|
||
}
|
||
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
|
||
}
|
||
|
||
private getScopedSessionPrimaryModel(
|
||
session: CodexUsagePersistedState['sessions'][number],
|
||
scope: CodexUsageScope
|
||
): string | null {
|
||
const scopedModels = this.getScopedSessionModels(session, scope)
|
||
if (scopedModels.length === 0) {
|
||
return session.primaryModel
|
||
}
|
||
if (scopedModels.length === 1) {
|
||
return scopedModels[0]?.modelLabel ?? null
|
||
}
|
||
return 'Mixed models'
|
||
}
|
||
|
||
private shouldForceAutomationUsageScan(completedAt: number): boolean {
|
||
const { lastScanCompletedAt, lastScanError } = this.state.scanState
|
||
// Why: attribution needs a scan after the run finishes, but repeated
|
||
// lookups after that point should not rescan all Codex session history.
|
||
return (
|
||
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)
|
||
}
|
||
}
|