mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* perf(usage): keep per-source usage caches off the main thread The usage stores kept every provider's per-source scan cache (processedFiles / processedDatabases) inside the one JSON file main loads at startup. On a large Claude history that file is 56 MB, 43 MB of it per-source records only a scan reads, so main paid a sync read+parse at launch, a structured clone of the whole cache to and from the scan worker on every scan, and a pretty-printed rewrite after every scan. The usage scan worker now owns those records in a sidecar (`<cache>-sources.json`): it reads it before a scan, writes it after (compact, durable rename), and returns only sessions and daily aggregates. Main keeps and writes only that report, compact. A cache still carrying inline records is split on the worker the first time it is loaded (above 8 MB); smaller ones parse on main and rescan cold. Sync IPC getters and automation lookups wait for the load. Synthetic 56 MB cache, same machine, interleaved runs: - first launch: 108 ms sync parse -> 18 ms main busy, 4 ms longest block - later launches: ~69 ms -> ~7 ms - forced scan, longest main-thread block: 89 ms -> 11 ms (real built app, headless: 84-94 ms -> 11-13 ms) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(usage): preserve cache attribution and legacy warm scans * fix(usage): reclaim cache temps after worker retirement --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Neil <neil@stably.ai>
376 lines
10 KiB
TypeScript
376 lines
10 KiB
TypeScript
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,
|
|
OpenCodeUsagePersistedState,
|
|
OpenCodeUsageSession
|
|
} from './types'
|
|
|
|
const { getPathMock } = vi.hoisted(() => ({
|
|
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
app: {
|
|
getPath: getPathMock
|
|
}
|
|
}))
|
|
|
|
vi.mock('../usage/usage-scan-worker-spawn', () => ({
|
|
scanOpenCodeUsageDatabasesViaWorker: vi.fn(),
|
|
splitUsageCacheFileViaWorker: vi.fn()
|
|
}))
|
|
|
|
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './store'
|
|
import { OPENCODE_USAGE_SCHEMA_VERSION } from './opencode-usage-provider'
|
|
import { normalizePersistedState } from './persisted-state-normalization'
|
|
import { scanOpenCodeUsageDatabasesViaWorker } from '../usage/usage-scan-worker-spawn'
|
|
|
|
function createEmptyScanResult() {
|
|
return {
|
|
sessions: [],
|
|
dailyAggregates: []
|
|
}
|
|
}
|
|
|
|
function getDefaultState(): OpenCodeUsagePersistedState {
|
|
return {
|
|
schemaVersion: OPENCODE_USAGE_SCHEMA_VERSION,
|
|
worktreeFingerprint: null,
|
|
processedDatabases: [],
|
|
sessions: [],
|
|
dailyAggregates: [],
|
|
scanState: {
|
|
enabled: false,
|
|
lastScanStartedAt: null,
|
|
lastScanCompletedAt: null,
|
|
lastScanError: null
|
|
}
|
|
}
|
|
}
|
|
|
|
function createStoreWithState(state: Partial<OpenCodeUsagePersistedState>): OpenCodeUsageStore {
|
|
const store = new OpenCodeUsageStore({
|
|
getRepos: () => [],
|
|
getAllWorktreeMeta: () => ({})
|
|
})
|
|
|
|
;(store as unknown as { state: OpenCodeUsagePersistedState }).state = {
|
|
...getDefaultState(),
|
|
...state
|
|
}
|
|
|
|
return store
|
|
}
|
|
|
|
function makeSession(overrides: Partial<OpenCodeUsageSession> = {}): OpenCodeUsageSession {
|
|
const worktreeId = overrides.primaryWorktreeId ?? 'repo-1::/workspace/repo'
|
|
const repoId = overrides.primaryRepoId ?? 'repo-1'
|
|
const projectLabel = overrides.primaryProjectLabel ?? 'Repo'
|
|
const model = overrides.primaryModel ?? 'anthropic/claude-sonnet-4-5'
|
|
return {
|
|
sessionId: 'session-1',
|
|
firstTimestamp: '2026-04-09T10:00:00.000Z',
|
|
lastTimestamp: '2026-04-09T10:10:00.000Z',
|
|
primaryModel: model,
|
|
hasMixedModels: false,
|
|
primaryProjectLabel: projectLabel,
|
|
hasMixedLocations: false,
|
|
primaryWorktreeId: worktreeId,
|
|
primaryRepoId: repoId,
|
|
eventCount: 1,
|
|
totalInputTokens: 1000,
|
|
totalCachedInputTokens: 400,
|
|
totalOutputTokens: 250,
|
|
totalReasoningOutputTokens: 100,
|
|
totalTokens: 1350,
|
|
estimatedCostUsd: 0.05,
|
|
locationBreakdown: [
|
|
{
|
|
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
|
projectLabel,
|
|
repoId,
|
|
worktreeId,
|
|
eventCount: 1,
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 400,
|
|
outputTokens: 250,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 1350,
|
|
estimatedCostUsd: 0.05
|
|
}
|
|
],
|
|
modelBreakdown: [
|
|
{
|
|
modelKey: model ?? 'unknown',
|
|
modelLabel: model ?? 'Unknown model',
|
|
eventCount: 1,
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 400,
|
|
outputTokens: 250,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 1350,
|
|
estimatedCostUsd: 0.05
|
|
}
|
|
],
|
|
locationModelBreakdown: [
|
|
{
|
|
locationKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
|
modelKey: model ?? 'unknown',
|
|
modelLabel: model ?? 'Unknown model',
|
|
repoId,
|
|
worktreeId,
|
|
eventCount: 1,
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 400,
|
|
outputTokens: 250,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 1350,
|
|
estimatedCostUsd: 0.05
|
|
}
|
|
],
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function makeDaily(
|
|
overrides: Partial<OpenCodeUsageDailyAggregate> = {}
|
|
): OpenCodeUsageDailyAggregate {
|
|
const worktreeId = overrides.worktreeId ?? 'repo-1::/workspace/repo'
|
|
return {
|
|
day: '2026-04-09',
|
|
model: 'anthropic/claude-sonnet-4-5',
|
|
projectKey: worktreeId ? `worktree:${worktreeId}` : 'cwd:/outside/repo',
|
|
projectLabel: worktreeId ? 'Repo' : 'outside/repo',
|
|
repoId: worktreeId ? 'repo-1' : null,
|
|
worktreeId,
|
|
eventCount: 1,
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 400,
|
|
outputTokens: 250,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 1350,
|
|
estimatedCostUsd: 0.05,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
describe('OpenCodeUsageStore', () => {
|
|
let tempUserData: string
|
|
|
|
beforeEach(() => {
|
|
tempUserData = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-store-'))
|
|
getPathMock.mockReturnValue(tempUserData)
|
|
initOpenCodeUsagePath()
|
|
vi.mocked(scanOpenCodeUsageDatabasesViaWorker).mockReset()
|
|
vi.mocked(scanOpenCodeUsageDatabasesViaWorker).mockResolvedValue(createEmptyScanResult())
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
rmSync(tempUserData, { recursive: true, force: true })
|
|
})
|
|
|
|
it('hands OpenCode scans the worker-owned source cache and persists a compact report', async () => {
|
|
const store = createStoreWithState({
|
|
scanState: {
|
|
enabled: true,
|
|
lastScanStartedAt: null,
|
|
lastScanCompletedAt: null,
|
|
lastScanError: null
|
|
}
|
|
})
|
|
|
|
await store.refresh(true)
|
|
|
|
const persistedJson = readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8')
|
|
expect(scanOpenCodeUsageDatabasesViaWorker).toHaveBeenCalledWith([], {
|
|
path: join(tempUserData, 'orca-opencode-usage-sources.json'),
|
|
schemaVersion: OPENCODE_USAGE_SCHEMA_VERSION,
|
|
worktreeFingerprint: '[]',
|
|
reuse: false
|
|
})
|
|
expect(persistedJson).not.toContain('\n')
|
|
})
|
|
|
|
it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => {
|
|
const store = createStoreWithState({
|
|
sessions: [
|
|
makeSession({
|
|
primaryProjectLabel: 'outside/repo',
|
|
primaryWorktreeId: null,
|
|
primaryRepoId: null
|
|
})
|
|
],
|
|
dailyAggregates: [
|
|
makeDaily({
|
|
projectKey: 'cwd:/outside/repo',
|
|
projectLabel: 'outside/repo',
|
|
repoId: null,
|
|
worktreeId: null
|
|
})
|
|
]
|
|
})
|
|
|
|
const summary = await store.getSummary('orca', '30d')
|
|
|
|
expect(summary.hasAnyOpenCodeData).toBe(false)
|
|
expect(summary.sessions).toBe(0)
|
|
expect(summary.events).toBe(0)
|
|
})
|
|
|
|
it('uses recorded OpenCode costs and token totals without model pricing inference', async () => {
|
|
const store = createStoreWithState({
|
|
sessions: [
|
|
makeSession({ sessionId: 'session-1' }),
|
|
makeSession({
|
|
sessionId: 'session-2',
|
|
primaryModel: 'openai/gpt-5.5',
|
|
totalTokens: 2000,
|
|
estimatedCostUsd: null,
|
|
modelBreakdown: [
|
|
{
|
|
modelKey: 'openai/gpt-5.5',
|
|
modelLabel: 'openai/gpt-5.5',
|
|
eventCount: 1,
|
|
inputTokens: 1500,
|
|
cachedInputTokens: 200,
|
|
outputTokens: 500,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 2000,
|
|
estimatedCostUsd: null
|
|
}
|
|
]
|
|
})
|
|
],
|
|
dailyAggregates: [
|
|
makeDaily(),
|
|
makeDaily({
|
|
model: 'openai/gpt-5.5',
|
|
eventCount: 2,
|
|
inputTokens: 1500,
|
|
cachedInputTokens: 200,
|
|
outputTokens: 500,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 2000,
|
|
estimatedCostUsd: null
|
|
})
|
|
]
|
|
})
|
|
|
|
const summary = await store.getSummary('orca', '30d')
|
|
const daily = await store.getDaily('orca', '30d')
|
|
const breakdown = await store.getBreakdown('orca', '30d', 'model')
|
|
|
|
expect(summary).toMatchObject({
|
|
sessions: 2,
|
|
events: 3,
|
|
inputTokens: 2500,
|
|
cachedInputTokens: 600,
|
|
outputTokens: 750,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 3350,
|
|
estimatedCostUsd: 0.05,
|
|
topModel: 'openai/gpt-5.5',
|
|
topProject: 'Repo',
|
|
hasAnyOpenCodeData: true
|
|
})
|
|
expect(daily).toEqual([
|
|
{
|
|
day: '2026-04-09',
|
|
inputTokens: 2500,
|
|
cachedInputTokens: 600,
|
|
outputTokens: 750,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 3350
|
|
}
|
|
])
|
|
expect(breakdown.find((row) => row.key === 'openai/gpt-5.5')).toMatchObject({
|
|
sessions: 1,
|
|
estimatedCostUsd: null
|
|
})
|
|
})
|
|
|
|
it('returns recent sessions with OpenCode event and token fields', async () => {
|
|
const store = createStoreWithState({
|
|
sessions: [makeSession()],
|
|
dailyAggregates: [makeDaily()]
|
|
})
|
|
|
|
const sessions = await store.getRecentSessions('orca', '30d', 5)
|
|
|
|
expect(sessions).toEqual([
|
|
{
|
|
sessionId: 'session-1',
|
|
lastActiveAt: '2026-04-09T10:10:00.000Z',
|
|
durationMinutes: 10,
|
|
projectLabel: 'Repo',
|
|
model: 'anthropic/claude-sonnet-4-5',
|
|
events: 1,
|
|
inputTokens: 1000,
|
|
cachedInputTokens: 400,
|
|
outputTokens: 250,
|
|
reasoningOutputTokens: 100,
|
|
totalTokens: 1350
|
|
}
|
|
])
|
|
})
|
|
|
|
it.each([true, false])('rebuilds v2 caches without changing enabled=%s', (enabled) => {
|
|
const oldState = {
|
|
...getDefaultState(),
|
|
schemaVersion: 2,
|
|
sessions: [makeSession()],
|
|
dailyAggregates: [makeDaily()],
|
|
scanState: { ...getDefaultState().scanState, enabled, lastScanCompletedAt: 123 }
|
|
}
|
|
expect(normalizePersistedState(oldState)).toEqual({
|
|
...getDefaultState(),
|
|
scanState: { ...getDefaultState().scanState, enabled }
|
|
})
|
|
})
|
|
|
|
it('normalizes persisted OpenCode state by schema version', () => {
|
|
expect(
|
|
normalizePersistedState({
|
|
...getDefaultState(),
|
|
schemaVersion: 0,
|
|
processedDatabases: [
|
|
{
|
|
path: '/tmp/opencode.db',
|
|
mtimeMs: 1,
|
|
size: 2,
|
|
sessions: [makeSession()],
|
|
dailyAggregates: [makeDaily()],
|
|
ownedSessionIds: ['session-1'],
|
|
hasDeferredClaims: false
|
|
}
|
|
],
|
|
sessions: [makeSession()],
|
|
dailyAggregates: [makeDaily()]
|
|
})
|
|
).toEqual(getDefaultState())
|
|
|
|
expect(
|
|
normalizePersistedState({
|
|
...getDefaultState(),
|
|
processedDatabases: [
|
|
{
|
|
path: '/tmp/opencode.db',
|
|
mtimeMs: 1,
|
|
size: 2,
|
|
sessions: [],
|
|
dailyAggregates: [],
|
|
ownedSessionIds: [],
|
|
hasDeferredClaims: false
|
|
}
|
|
]
|
|
}).processedDatabases
|
|
).toHaveLength(1)
|
|
})
|
|
})
|