mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
508 lines
16 KiB
TypeScript
508 lines
16 KiB
TypeScript
/* 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. */
|
|
import { app } from 'electron'
|
|
import { dirname, join } from 'path'
|
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
|
|
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, scanOpenCodeUsageDatabases } from './scanner'
|
|
|
|
const SCHEMA_VERSION = 1
|
|
const STALE_MS = 5 * 60_000
|
|
|
|
let _openCodeUsageFile: string | null = null
|
|
|
|
function getDefaultState(): OpenCodeUsagePersistedState {
|
|
return {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
worktreeFingerprint: null,
|
|
processedDatabases: [],
|
|
sessions: [],
|
|
dailyAggregates: [],
|
|
scanState: {
|
|
enabled: false,
|
|
lastScanStartedAt: null,
|
|
lastScanCompletedAt: null,
|
|
lastScanError: null
|
|
}
|
|
}
|
|
}
|
|
|
|
export function normalizePersistedState(
|
|
state: OpenCodeUsagePersistedState
|
|
): OpenCodeUsagePersistedState {
|
|
if (state.schemaVersion !== SCHEMA_VERSION) {
|
|
return getDefaultState()
|
|
}
|
|
return {
|
|
...state,
|
|
processedDatabases: (state.processedDatabases ?? []).map((database) => ({
|
|
...database,
|
|
sessions: (database.sessions ?? []).map(normalizeSessionCost),
|
|
dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost)
|
|
})),
|
|
sessions: state.sessions.map(normalizeSessionCost),
|
|
dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost)
|
|
}
|
|
}
|
|
|
|
export function initOpenCodeUsagePath(): void {
|
|
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
|
|
}
|
|
|
|
function getOpenCodeUsageFile(): string {
|
|
if (!_openCodeUsageFile) {
|
|
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
|
|
}
|
|
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
|
|
}
|
|
return (left ?? 0) + (right ?? 0)
|
|
}
|
|
|
|
function normalizeDailyAggregateCost(
|
|
entry: OpenCodeUsageDailyAggregate
|
|
): OpenCodeUsageDailyAggregate {
|
|
return {
|
|
...entry,
|
|
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
|
}
|
|
}
|
|
|
|
function normalizeSessionCost(
|
|
session: OpenCodeUsagePersistedState['sessions'][number]
|
|
): OpenCodeUsagePersistedState['sessions'][number] {
|
|
return {
|
|
...session,
|
|
estimatedCostUsd: session.estimatedCostUsd ?? null,
|
|
locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({
|
|
...entry,
|
|
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
|
})),
|
|
modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({
|
|
...entry,
|
|
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
|
})),
|
|
locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({
|
|
...entry,
|
|
estimatedCostUsd: entry.estimatedCostUsd ?? null
|
|
}))
|
|
}
|
|
}
|
|
|
|
export class OpenCodeUsageStore {
|
|
private state: OpenCodeUsagePersistedState
|
|
private readonly store: Store
|
|
private scanPromise: Promise<void> | null = null
|
|
|
|
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(): void {
|
|
const usageFile = getOpenCodeUsageFile()
|
|
const dir = dirname(usageFile)
|
|
if (!existsSync(dir)) {
|
|
mkdirSync(dir, { recursive: true })
|
|
}
|
|
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
|
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
|
renameSync(tmpFile, usageFile)
|
|
}
|
|
|
|
async setEnabled(enabled: boolean): Promise<OpenCodeUsageScanState> {
|
|
this.state.scanState.enabled = enabled
|
|
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
|
|
}
|
|
}
|
|
|
|
getSnapshot(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange,
|
|
recentSessionLimit = 10
|
|
): OpenCodeUsageSnapshot {
|
|
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<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
|
|
this.writeToDisk()
|
|
|
|
this.scanPromise = (async () => {
|
|
try {
|
|
const repos = this.store.getRepos()
|
|
const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos)
|
|
const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo)
|
|
const result = await scanOpenCodeUsageDatabases(
|
|
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
|
|
this.writeToDisk()
|
|
} catch (error) {
|
|
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
|
this.writeToDisk()
|
|
} finally {
|
|
this.scanPromise = null
|
|
}
|
|
})()
|
|
|
|
await this.scanPromise
|
|
}
|
|
|
|
async getSummary(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange
|
|
): Promise<OpenCodeUsageSummary> {
|
|
await this.refresh(false)
|
|
return this.buildSummary(scope, range)
|
|
}
|
|
|
|
private buildSummary(scope: OpenCodeUsageScope, range: OpenCodeUsageRange): OpenCodeUsageSummary {
|
|
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: number | null = null
|
|
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
|
|
estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd)
|
|
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 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,
|
|
topModel,
|
|
topProject,
|
|
hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0
|
|
}
|
|
}
|
|
|
|
async getDaily(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange
|
|
): Promise<OpenCodeUsageDailyPoint[]> {
|
|
await this.refresh(false)
|
|
return this.buildDaily(scope, range)
|
|
}
|
|
|
|
private buildDaily(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange
|
|
): OpenCodeUsageDailyPoint[] {
|
|
const byDay = new Map<string, OpenCodeUsageDailyPoint>()
|
|
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: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange,
|
|
kind: OpenCodeUsageBreakdownKind
|
|
): Promise<OpenCodeUsageBreakdownRow[]> {
|
|
await this.refresh(false)
|
|
return this.buildBreakdown(scope, range, kind)
|
|
}
|
|
|
|
private buildBreakdown(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange,
|
|
kind: OpenCodeUsageBreakdownKind
|
|
): OpenCodeUsageBreakdownRow[] {
|
|
const rows = new Map<string, OpenCodeUsageBreakdownRow>()
|
|
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
|
|
}
|
|
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.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd)
|
|
rows.set(key, existing)
|
|
}
|
|
|
|
if (kind === 'model') {
|
|
for (const session of filteredSessions) {
|
|
for (const entry of session.modelBreakdown) {
|
|
const row = rows.get(entry.modelKey)
|
|
if (row) {
|
|
row.sessions++
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for (const session of filteredSessions) {
|
|
for (const entry of session.locationBreakdown) {
|
|
const row = rows.get(entry.locationKey)
|
|
if (row) {
|
|
row.sessions++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
|
|
}
|
|
|
|
async getRecentSessions(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange,
|
|
limit = 10
|
|
): Promise<OpenCodeUsageSessionRow[]> {
|
|
await this.refresh(false)
|
|
return this.buildRecentSessions(scope, range, limit)
|
|
}
|
|
|
|
private buildRecentSessions(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange,
|
|
limit = 10
|
|
): OpenCodeUsageSessionRow[] {
|
|
return this.getFilteredSessions(scope, range)
|
|
.slice(0, limit)
|
|
.map(
|
|
(session): OpenCodeUsageSessionRow => ({
|
|
sessionId: session.sessionId,
|
|
lastActiveAt: session.lastTimestamp,
|
|
durationMinutes: Math.max(
|
|
0,
|
|
Math.round(
|
|
(new Date(session.lastTimestamp).getTime() -
|
|
new Date(session.firstTimestamp).getTime()) /
|
|
60_000
|
|
)
|
|
),
|
|
projectLabel: session.primaryProjectLabel,
|
|
model: session.primaryModel,
|
|
events: session.eventCount,
|
|
inputTokens: session.totalInputTokens,
|
|
cachedInputTokens: session.totalCachedInputTokens,
|
|
outputTokens: session.totalOutputTokens,
|
|
reasoningOutputTokens: session.totalReasoningOutputTokens,
|
|
totalTokens: session.totalTokens
|
|
})
|
|
)
|
|
}
|
|
|
|
private getFilteredDaily(
|
|
scope: OpenCodeUsageScope,
|
|
range: OpenCodeUsageRange
|
|
): OpenCodeUsageDailyAggregate[] {
|
|
const cutoff = getRangeCutoff(range)
|
|
return this.state.dailyAggregates.filter((row) => {
|
|
if (scope === 'orca' && !row.worktreeId) {
|
|
return false
|
|
}
|
|
if (cutoff && row.day < cutoff) {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) {
|
|
const cutoff = getRangeCutoff(range)
|
|
return this.state.sessions.filter((session) => {
|
|
if (scope === 'orca' && !session.primaryWorktreeId) {
|
|
return false
|
|
}
|
|
if (cutoff) {
|
|
const day = getLocalDay(session.lastTimestamp)
|
|
if (!day || day < cutoff) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
private async getCurrentWorktreeFingerprint(): Promise<string> {
|
|
const repos = this.store.getRepos()
|
|
return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
|
|
}
|
|
}
|