mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
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
This commit is contained in:
@@ -114,6 +114,22 @@
|
||||
"unicorn/throw-new-error": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["src/main/backend/**/*.ts"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["electron", "electron/*"],
|
||||
"message": "Backend services use the typed desktop capability bridge; only the shell imports Electron."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["**/*.{js,cjs,mjs,jsx,ts,tsx,mts,cts}"],
|
||||
"excludeFiles": [
|
||||
@@ -151,6 +167,47 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/main/backend/**/*.ts"],
|
||||
"excludeFiles": [
|
||||
"**/*.{test,spec}.{js,cjs,mjs,jsx,ts,tsx,mts,cts}",
|
||||
"**/__tests__/**",
|
||||
"src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "@linear/sdk",
|
||||
"allowTypeImports": true,
|
||||
"message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
|
||||
},
|
||||
{
|
||||
"name": "ssh2",
|
||||
"allowTypeImports": true,
|
||||
"message": "Value-importing ssh2 hoists it into the eager main-process require block and blocks startup (#35731). Use `import type` plus loadSsh2() from src/main/ssh/ssh2-module-load.ts."
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "(?i)(?:^|[/\\\\])(?:web-session-tabs-sync-test-harness|web-runtime-browser-creation-placement-test-rig)(?:\\.[cm]?[jt]sx?)?(?:[?#].*)?$",
|
||||
"message": "This module is unit-test support. Import the production web-session owner instead."
|
||||
},
|
||||
{
|
||||
"regex": "\\.fixture(?:\\.tsx?)?(?:[?#].*)?$",
|
||||
"message": "Component fixtures are dev-only and load through the fixture explorer's import.meta.glob; importing one would ship it in a production bundle."
|
||||
},
|
||||
{
|
||||
"group": ["electron", "electron/*"],
|
||||
"message": "Backend services use the typed desktop capability bridge; only the shell imports Electron."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/renderer/src/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CLI_MAIN_ENTRY_NAMES } from './cli-main-entry-names'
|
||||
/** Main-build Rollup inputs: output file name (without .js) to its source entry. */
|
||||
export const MAIN_BUILD_ENTRY_POINTS: Record<string, string> = {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'backend-entry': resolve('src/main/backend/backend-entry.ts'),
|
||||
'browser-extension-native-host-supervisor-entry': resolve(
|
||||
'src/main/browser/extensions/native-messaging/browser-extension-native-host-supervisor-entry.ts'
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ type OutputChunk = Rollup.OutputChunk
|
||||
|
||||
// Plain-Node processes and CLI modules cannot load Electron's API.
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'backend-entry',
|
||||
'browser-extension-native-host-supervisor-entry',
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
|
||||
@@ -23,6 +23,7 @@ const ENTRY_POINTS = [
|
||||
|
||||
// Whole lane directories cover new files automatically; only structured-chat files are plain-Node-safe inside runtime/.
|
||||
export const STRUCTURED_CHAT_LANES = [
|
||||
{ directory: ['src', 'main', 'backend'] },
|
||||
{ directory: ['src', 'main', 'native-chat'] },
|
||||
{ directory: ['src', 'main', 'claude'] },
|
||||
{ directory: ['src', 'main', 'codex'] },
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"../src/renderer/src/**/*.tsx",
|
||||
"../src/preload/api-types.ts",
|
||||
"../src/preload/api/**/*",
|
||||
"../src/preload/backend-port-client.ts",
|
||||
"../src/preload/browser-client-page-renderer-requests.ts",
|
||||
"../src/preload/browser-find-subscriptions.ts",
|
||||
"../src/preload/close-active-tab-payload-admission.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
|
||||
import type { Automation } from '../../shared/automations/automation-definition'
|
||||
import type {
|
||||
AutomationRun,
|
||||
@@ -5,8 +6,6 @@ import type {
|
||||
} from '../../shared/automations/history/automation-run'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
import type { AutomationRunWriter } from './automation-run-writer'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
|
||||
function createUnavailableAutomationUsage(
|
||||
collectedAt: number,
|
||||
@@ -54,8 +53,8 @@ export async function collectAutomationRunUsage({
|
||||
}: {
|
||||
automation: Automation | undefined
|
||||
run: AutomationRun
|
||||
claudeUsage: ClaudeUsageStore | null
|
||||
codexUsage: CodexUsageStore | null
|
||||
claudeUsage: AutomationUsageClient | null
|
||||
codexUsage: AutomationUsageClient | null
|
||||
}): Promise<AutomationRunUsage> {
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
@@ -83,23 +82,31 @@ export async function collectAutomationRunUsage({
|
||||
if (!claudeUsage) {
|
||||
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
|
||||
}
|
||||
return claudeUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
return claudeUsage
|
||||
.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
.catch(() =>
|
||||
unavailable('claude', 'scan_failed', 'Claude usage was unavailable when this run finished.')
|
||||
)
|
||||
}
|
||||
if (automation.agentId === 'codex') {
|
||||
if (!codexUsage) {
|
||||
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
|
||||
}
|
||||
return codexUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
return codexUsage
|
||||
.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
.catch(() =>
|
||||
unavailable('codex', 'scan_failed', 'Codex usage was unavailable when this run finished.')
|
||||
)
|
||||
}
|
||||
return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.')
|
||||
}
|
||||
@@ -108,8 +115,8 @@ export async function writeAutomationRunUsage(input: {
|
||||
store: Store
|
||||
runs: AutomationRunWriter
|
||||
run: AutomationRun
|
||||
claudeUsage: ClaudeUsageStore | null
|
||||
codexUsage: CodexUsageStore | null
|
||||
claudeUsage: AutomationUsageClient | null
|
||||
codexUsage: AutomationUsageClient | null
|
||||
}): Promise<AutomationRun> {
|
||||
const { store, run } = input
|
||||
const usage = await collectAutomationRunUsage({
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
|
||||
import { observeAutomationChatRun } from './runtime-chat-run-observer'
|
||||
import { readAutomationRunReceipt } from './automation-run-receipt'
|
||||
import { publishedLaunchTarget } from './automation-agent-run-result'
|
||||
import { launchAutomationChatAgent } from './automation-chat-agent-launch'
|
||||
import { tuiAgentDisplayName } from '../../shared/agent-launch/catalog/tui-agent-display-names'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
import type { AutomationRun } from '../../shared/automations/history/automation-run'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
@@ -29,8 +28,8 @@ const MAX_REMEMBERED_LAUNCHES = 256
|
||||
export function createRuntimeAutomationService(input: {
|
||||
store: Store
|
||||
runtime: OrcaRuntimeService
|
||||
claudeUsage?: ClaudeUsageStore
|
||||
codexUsage?: CodexUsageStore
|
||||
claudeUsage?: AutomationUsageClient
|
||||
codexUsage?: AutomationUsageClient
|
||||
/** Only server processes execute remote_host_service schedules. */
|
||||
allowRemoteHostScheduling: boolean
|
||||
}): AutomationService {
|
||||
|
||||
@@ -68,6 +68,49 @@ describe('AutomationService', () => {
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('keeps completed history when the usage backend disconnects', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Check',
|
||||
prompt: 'Check spend',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: Date.now()
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'manual')
|
||||
const service = new AutomationService(store, {
|
||||
claudeUsage: {
|
||||
getAutomationRunUsage: async () => {
|
||||
throw new Error('Backend disconnected')
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
const updated = await service.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'completed',
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
error: null
|
||||
})
|
||||
expect(updated).toMatchObject({
|
||||
status: 'completed',
|
||||
usage: { status: 'unavailable', provider: 'claude', unavailableReason: 'scan_failed' }
|
||||
})
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
usage: { status: 'unavailable' }
|
||||
})
|
||||
} finally {
|
||||
service.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not throw when retention evicts the run during usage collection', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
|
||||
import {
|
||||
AutomationDispatchCancelledError,
|
||||
requestAutomationDispatch,
|
||||
@@ -11,8 +12,6 @@ import {
|
||||
type AutomationRun
|
||||
} from '../../shared/automations/history/automation-run'
|
||||
import type { Automation } from '../../shared/automations/automation-definition'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import {
|
||||
captureLegacyRunTarget,
|
||||
resolveAutomationRunTarget,
|
||||
@@ -50,8 +49,8 @@ export class AutomationService {
|
||||
private evaluating = false
|
||||
private stopped = false
|
||||
private dispatchGeneration = 0
|
||||
private readonly claudeUsage: ClaudeUsageStore | null
|
||||
private readonly codexUsage: CodexUsageStore | null
|
||||
private readonly claudeUsage: AutomationUsageClient | null
|
||||
private readonly codexUsage: AutomationUsageClient | null
|
||||
private readonly allowRemoteHostScheduling: boolean
|
||||
private readonly terminalLauncher: AutomationTerminalLauncher | null
|
||||
private readonly chatDispatcher: AutomationLauncher | null
|
||||
@@ -69,8 +68,8 @@ export class AutomationService {
|
||||
store: Store,
|
||||
opts: {
|
||||
tickMs?: number
|
||||
claudeUsage?: ClaudeUsageStore
|
||||
codexUsage?: CodexUsageStore
|
||||
claudeUsage?: AutomationUsageClient
|
||||
codexUsage?: AutomationUsageClient
|
||||
allowRemoteHostScheduling?: boolean
|
||||
terminalLauncher?: AutomationTerminalLauncher
|
||||
chatDispatcher?: AutomationLauncher
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { getAppEnvironment, setAppEnvironment } from '../../shared/app-environment'
|
||||
import { BackendBootstrapSchema } from '../../shared/backend-service-contract'
|
||||
import { createDesktopBackendEnvironment } from '../backend/node-service-environment'
|
||||
import { getLogsDirectory } from '../observability/logs-directory'
|
||||
import { captureBackendBootstrap, capturedBootstrap } from './backend-bootstrap'
|
||||
|
||||
const { getPath } = vi.hoisted(() => ({ getPath: vi.fn<(name: string) => string>() }))
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath,
|
||||
getVersion: () => '1.2.3',
|
||||
getAppPath: () => '/installed/orca',
|
||||
isPackaged: true
|
||||
}
|
||||
}))
|
||||
|
||||
let originalEnvironment: ReturnType<typeof getAppEnvironment>
|
||||
beforeEach(() => {
|
||||
originalEnvironment = getAppEnvironment()
|
||||
getPath.mockImplementation((name) => {
|
||||
if (name !== 'userData') {
|
||||
throw new Error(`Failed to get ${name} path`)
|
||||
}
|
||||
return '/profiles/usage-profile'
|
||||
})
|
||||
})
|
||||
afterEach(() => setAppEnvironment(originalEnvironment))
|
||||
|
||||
it('starts the usage environment at its authoritative profile when optional folders are unavailable', () => {
|
||||
captureBackendBootstrap()
|
||||
const config = BackendBootstrapSchema.parse(capturedBootstrap)
|
||||
const host = createDesktopBackendEnvironment(config)
|
||||
setAppEnvironment(host.environment)
|
||||
|
||||
expect(getAppEnvironment().getPath('userData')).toBe('/profiles/usage-profile')
|
||||
expect(getLogsDirectory()).toBe(join('/profiles/usage-profile', 'logs'))
|
||||
expect(() => getAppEnvironment().getPath('downloads')).toThrow(
|
||||
'Desktop backend path is unsupported: downloads'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails startup preparation when the required profile is unavailable', () => {
|
||||
getPath.mockImplementation(() => {
|
||||
throw new Error('Failed to get userData path')
|
||||
})
|
||||
expect(captureBackendBootstrap).toThrow('Failed to get userData path')
|
||||
})
|
||||
|
||||
it('accepts legacy full and minimal snapshots without a build restriction while requiring the profile', () => {
|
||||
captureBackendBootstrap()
|
||||
const minimal = BackendBootstrapSchema.parse(capturedBootstrap)
|
||||
const legacy = {
|
||||
...minimal,
|
||||
build: 'older-shell',
|
||||
paths: {
|
||||
userData: '/profiles/legacy-profile',
|
||||
home: '/legacy/home',
|
||||
appData: '/legacy/appData',
|
||||
temp: '/legacy/temp',
|
||||
downloads: '/legacy/downloads',
|
||||
logs: '/legacy/logs',
|
||||
exe: '/legacy/orca'
|
||||
}
|
||||
}
|
||||
expect(
|
||||
createDesktopBackendEnvironment(BackendBootstrapSchema.parse(legacy)).environment.getPath(
|
||||
'userData'
|
||||
)
|
||||
).toBe('/profiles/legacy-profile')
|
||||
expect(createDesktopBackendEnvironment(minimal).environment.getPath('userData')).toBe(
|
||||
'/profiles/usage-profile'
|
||||
)
|
||||
expect(BackendBootstrapSchema.safeParse({ ...minimal, paths: {} }).success).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { app } from 'electron'
|
||||
import { BACKEND_CAPABILITIES, type BackendBootstrap } from '../../shared/backend-service-contract'
|
||||
|
||||
export let capturedBootstrap: BackendBootstrap | undefined
|
||||
|
||||
/** Capture at the same pre-name-change phase as the former usage path initializers. */
|
||||
export function captureBackendBootstrap(): void {
|
||||
capturedBootstrap = {
|
||||
protocol: 1,
|
||||
requiredCapabilities: [...BACKEND_CAPABILITIES],
|
||||
services: ['usage'],
|
||||
build: app.getVersion(),
|
||||
paths: {
|
||||
userData: app.getPath('userData')
|
||||
},
|
||||
appPath: app.getAppPath(),
|
||||
isPackaged: app.isPackaged
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { createBackendDesktopCapabilities } from './backend-desktop-capabilities'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ isTelemetryEnabled: vi.fn(), track: vi.fn() }))
|
||||
vi.mock('../claude-usage/transcript-file-discovery', () => ({
|
||||
claudeProfileTranscriptDirs: vi.fn()
|
||||
}))
|
||||
|
||||
it('loads remembered worktrees before sending the backend attribution catalog', async () => {
|
||||
const remembered = new Map<string, string>()
|
||||
let finishLoading!: () => void
|
||||
const loaded = new Promise<void>((resolve) => {
|
||||
finishLoading = resolve
|
||||
}).then(() => {
|
||||
remembered.set('repo-1::/ws/removed', 'removed')
|
||||
})
|
||||
const capability = createBackendDesktopCapabilities({
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: '/src/app', displayName: 'app', badgeColor: '#000000', addedAt: 0 }
|
||||
],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => remembered,
|
||||
whenLoaded: () => loaded
|
||||
})
|
||||
|
||||
const catalog = capability('usage.catalog', null)
|
||||
finishLoading()
|
||||
|
||||
expect(await catalog).toEqual({
|
||||
repoIds: ['repo-1'],
|
||||
worktreesByRepo: [
|
||||
[
|
||||
'repo-1',
|
||||
[
|
||||
{ worktreeId: 'repo-1::/src/app', path: '/src/app', displayName: 'app' },
|
||||
{ worktreeId: 'repo-1::/ws/removed', path: '/ws/removed', displayName: 'removed' }
|
||||
]
|
||||
]
|
||||
]
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BrowserWindow, type WebContents, type WebFrameMain } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema'
|
||||
import { claudeProfileTranscriptDirs } from '../claude-usage/transcript-file-discovery'
|
||||
import { loadKnownUsageWorktreesByRepo } from '../usage/worktree-attribution/usage-worktree-metadata'
|
||||
import { isTelemetryEnabled, track } from '../telemetry/client'
|
||||
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
|
||||
import { currentWorkerEntryLayout } from '../worker-threads/worker-thread-entry-path'
|
||||
import { isRendererDocumentNavigation } from '../window/renderer-document-navigation'
|
||||
|
||||
export function trustsBackendRenderer(contents: WebContents): boolean {
|
||||
if (
|
||||
contents.isDestroyed() ||
|
||||
contents.getType() !== 'window' ||
|
||||
!BrowserWindow.fromWebContents(contents)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const layout = currentWorkerEntryLayout(__dirname)
|
||||
const roots = process.env.ELECTRON_RENDERER_URL
|
||||
? [process.env.ELECTRON_RENDERER_URL]
|
||||
: ['index.html', 'space.html', 'popout.html'].map(
|
||||
(name) => pathToFileURL(join(layout.moduleDir, '../renderer', name)).href
|
||||
)
|
||||
return roots.some((root) => isRendererDocumentNavigation(root, contents.getURL()))
|
||||
}
|
||||
|
||||
export function createBackendDesktopCapabilities(
|
||||
store: UsageWorktreeSource
|
||||
): (method: string, params: unknown) => Promise<unknown> {
|
||||
return async (method, params) => {
|
||||
if (method === 'usage.catalog') {
|
||||
await store.whenLoaded()
|
||||
const repos = store.getRepos()
|
||||
return {
|
||||
repoIds: repos.map((repo) => repo.id),
|
||||
worktreesByRepo: [...loadKnownUsageWorktreesByRepo(store, repos)]
|
||||
}
|
||||
}
|
||||
if (method === 'usage.claudeProfileDirs') {
|
||||
return claudeProfileTranscriptDirs()
|
||||
}
|
||||
if (method === 'usage.telemetry.enabled') {
|
||||
return isTelemetryEnabled()
|
||||
}
|
||||
if (method === 'usage.telemetry.capture') {
|
||||
return track('agent_token_usage', agentTokenUsageSchema.parse(params))
|
||||
}
|
||||
throw new Error('This desktop capability is not supported.')
|
||||
}
|
||||
}
|
||||
|
||||
export type BackendDocument = {
|
||||
contents: WebContents
|
||||
frame: WebFrameMain
|
||||
token: string
|
||||
retire(): void
|
||||
}
|
||||
|
||||
export function trackBackendDocument(
|
||||
documents: Map<number, BackendDocument>,
|
||||
contents: WebContents,
|
||||
frame: WebFrameMain,
|
||||
token: string,
|
||||
retireToken: (token: string) => void
|
||||
): BackendDocument {
|
||||
documents.get(contents.id)?.retire()
|
||||
const document: BackendDocument = { contents, frame, token, retire: () => {} }
|
||||
const retire = (): void => {
|
||||
if (documents.get(contents.id) !== document) {
|
||||
return
|
||||
}
|
||||
documents.delete(contents.id)
|
||||
retireToken(document.token)
|
||||
contents.off('did-frame-navigate', navigation)
|
||||
contents.off('destroyed', retire)
|
||||
}
|
||||
const navigation = (
|
||||
_event: unknown,
|
||||
_url: string,
|
||||
_httpCode: number,
|
||||
_httpStatus: string,
|
||||
mainFrame: boolean
|
||||
): void => {
|
||||
if (mainFrame) {
|
||||
retire()
|
||||
}
|
||||
}
|
||||
document.retire = retire
|
||||
documents.set(contents.id, document)
|
||||
contents.on('did-frame-navigate', navigation)
|
||||
contents.once('destroyed', retire)
|
||||
return document
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
BACKEND_CONNECTION_CHANGED,
|
||||
BACKEND_PORT_DELIVERY,
|
||||
BACKEND_PORT_REQUEST
|
||||
} from '../../shared/backend-service-contract'
|
||||
const rig = vi.hoisted(() => ({
|
||||
fork: vi.fn(),
|
||||
handle: vi.fn(),
|
||||
call: vi.fn(),
|
||||
appOn: vi.fn(),
|
||||
appOff: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
app: { on: rig.appOn, off: rig.appOff },
|
||||
BrowserWindow: { fromWebContents: () => ({}) },
|
||||
ipcMain: { handle: rig.handle, removeHandler: vi.fn() },
|
||||
utilityProcess: { fork: rig.fork },
|
||||
MessageChannelMain: class {
|
||||
port1 = {}
|
||||
port2 = {}
|
||||
}
|
||||
}))
|
||||
vi.mock('./backend-bootstrap', () => ({ capturedBootstrap: {} }))
|
||||
vi.mock('../../shared/backend-port-rpc', () => ({
|
||||
BackendPortRpc: class {
|
||||
call = rig.call
|
||||
close() {}
|
||||
}
|
||||
}))
|
||||
vi.mock('../backend/backend-message-port', () => ({ backendMessagePort: () => ({}) }))
|
||||
vi.mock('../crash-reporting/breadcrumbs/durable-crash-breadcrumb', () => ({
|
||||
recordDurableCrashBreadcrumb: vi.fn()
|
||||
}))
|
||||
vi.mock('../worker-threads/worker-thread-entry-path', () => ({
|
||||
currentWorkerEntryLayout: () => ({ moduleDir: '/' }),
|
||||
resolveWorkerThreadEntryPath: () => 'backend-entry.js'
|
||||
}))
|
||||
vi.mock('../window/renderer-document-navigation', () => ({
|
||||
isRendererDocumentNavigation: () => true
|
||||
}))
|
||||
vi.mock('../usage/worktree-attribution/usage-worktree-metadata', () => ({
|
||||
loadKnownUsageWorktreesByRepo: vi.fn()
|
||||
}))
|
||||
vi.mock('../claude-usage/transcript-file-discovery', () => ({
|
||||
claudeProfileTranscriptDirs: vi.fn()
|
||||
}))
|
||||
vi.mock('../telemetry/client', () => ({ isTelemetryEnabled: vi.fn(), track: vi.fn() }))
|
||||
import { BackendSupervisor } from './backend-supervisor'
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('keeps ready-generated recovery behind owner backoff and the cap, then admits real demand', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: (EventEmitter & {
|
||||
postMessage: ReturnType<typeof vi.fn>
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
})[] = []
|
||||
rig.fork.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
|
||||
children.push(child)
|
||||
return child
|
||||
})
|
||||
const supervisor = new BackendSupervisor({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => new Map(),
|
||||
whenLoaded: async () => {}
|
||||
})
|
||||
const frame = { postMessage: vi.fn() }
|
||||
const sender = Object.assign(new EventEmitter(), {
|
||||
id: 2,
|
||||
mainFrame: frame,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///index.html'
|
||||
})
|
||||
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
|
||||
const event = { sender, senderFrame: frame }
|
||||
const token = '11111111-1111-1111-1111-111111111111'
|
||||
const joined = request(event, token, 'recovery')
|
||||
children[0].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
await joined
|
||||
for (let i = 0; i < 3; i++) {
|
||||
children[i].emit('exit', 1)
|
||||
await expect(request(event, token, 'recovery')).rejects.toThrow('Usage is unavailable')
|
||||
expect(children).toHaveLength(i + 1)
|
||||
if (i < 2) {
|
||||
const delay = i === 0 ? 500 : 1000
|
||||
await vi.advanceTimersByTimeAsync(delay - 1)
|
||||
expect(children).toHaveLength(i + 1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(children).toHaveLength(i + 2)
|
||||
const recovery = request(event, token, 'recovery')
|
||||
children[i + 1].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
await recovery
|
||||
}
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(children).toHaveLength(3)
|
||||
expect(frame.postMessage).toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
|
||||
connected: false,
|
||||
unavailable: true
|
||||
})
|
||||
await expect(request(event, token, 'invalid')).rejects.toThrow(
|
||||
'Invalid backend connection request'
|
||||
)
|
||||
const demand = request(event, token)
|
||||
expect(children).toHaveLength(4)
|
||||
children[3].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
await demand
|
||||
children[3].emit('exit', 1)
|
||||
rig.call.mockResolvedValueOnce({ inputTokens: 42 })
|
||||
const automation = supervisor.automationClient('claudeUsage').getAutomationRunUsage({
|
||||
worktreeId: null,
|
||||
terminalSessionId: null,
|
||||
startedAt: null,
|
||||
completedAt: null
|
||||
})
|
||||
expect(children).toHaveLength(5)
|
||||
children[4].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
await expect(automation).resolves.toEqual({ inputTokens: 42 })
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(children).toHaveLength(5)
|
||||
children[4].emit('exit', 1)
|
||||
await vi.advanceTimersByTimeAsync(499)
|
||||
expect(children).toHaveLength(5)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(children).toHaveLength(6)
|
||||
children[5].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
const stopped = supervisor.stop()
|
||||
children[5].emit('exit', 0)
|
||||
await stopped
|
||||
})
|
||||
it('publishes terminal unavailability on the third death and preserves explicit recovery', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: (EventEmitter & {
|
||||
postMessage: ReturnType<typeof vi.fn>
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
})[] = []
|
||||
rig.fork.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
|
||||
children.push(child)
|
||||
return child
|
||||
})
|
||||
const supervisor = new BackendSupervisor({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => new Map(),
|
||||
whenLoaded: async () => {}
|
||||
})
|
||||
const frame = { postMessage: vi.fn() }
|
||||
const sender = Object.assign(new EventEmitter(), {
|
||||
id: 1,
|
||||
mainFrame: frame,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///index.html'
|
||||
})
|
||||
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
|
||||
const initial = request({ sender, senderFrame: frame }, '11111111-1111-1111-1111-111111111111')
|
||||
children[0].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
await initial
|
||||
for (let i = 0; i < 3; i++) {
|
||||
children[i].emit('exit', 1)
|
||||
expect(frame.postMessage).toHaveBeenLastCalledWith(BACKEND_CONNECTION_CHANGED, {
|
||||
connected: false,
|
||||
unavailable: i === 2
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
if (i < 2) {
|
||||
children[i + 1].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
}
|
||||
}
|
||||
expect(children).toHaveLength(3)
|
||||
const retry = request({ sender, senderFrame: frame }, '22222222-2222-2222-2222-222222222222')
|
||||
children[3].emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
await retry
|
||||
expect(frame.postMessage).toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
|
||||
connected: true,
|
||||
unavailable: false
|
||||
})
|
||||
const stopped = supervisor.stop()
|
||||
children[3].emit('exit', 0)
|
||||
await stopped
|
||||
})
|
||||
|
||||
it('retires only the exited renderer document and rejects its pending port delivery', async () => {
|
||||
const child = Object.assign(new EventEmitter(), { postMessage: vi.fn(), kill: vi.fn() })
|
||||
rig.fork.mockReturnValue(child)
|
||||
const supervisor = new BackendSupervisor({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => new Map(),
|
||||
whenLoaded: async () => {}
|
||||
})
|
||||
const frame = { postMessage: vi.fn() }
|
||||
const sender = Object.assign(new EventEmitter(), {
|
||||
id: 3,
|
||||
mainFrame: frame,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///index.html'
|
||||
})
|
||||
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
|
||||
const gone = rig.appOn.mock.calls.find(([event]) => event === 'render-process-gone')?.[1]
|
||||
const token = '33333333-3333-3333-3333-333333333333'
|
||||
const initial = request({ sender, senderFrame: frame }, token)
|
||||
const rejected = expect(initial).rejects.toThrow('The document was replaced')
|
||||
gone({}, { id: 99 })
|
||||
expect(child.postMessage).not.toHaveBeenCalledWith({ type: 'retire', token })
|
||||
gone({}, sender)
|
||||
expect(child.postMessage).toHaveBeenCalledWith({ type: 'retire', token })
|
||||
child.emit('message', { type: 'ready', protocol: 1, capabilities: ['usage'] })
|
||||
await rejected
|
||||
expect(frame.postMessage.mock.calls.some(([channel]) => channel === BACKEND_PORT_DELIVERY)).toBe(
|
||||
false
|
||||
)
|
||||
await request({ sender, senderFrame: frame }, token)
|
||||
expect(
|
||||
child.postMessage.mock.calls.filter(([message]) => message.type === 'client')
|
||||
).toHaveLength(1)
|
||||
gone({}, sender)
|
||||
expect(
|
||||
child.postMessage.mock.calls.filter(([message]) => message.type === 'retire')
|
||||
).toHaveLength(2)
|
||||
const stopped = supervisor.stop()
|
||||
expect(rig.appOff).toHaveBeenCalledWith('render-process-gone', gone)
|
||||
child.emit('exit', 0)
|
||||
await stopped
|
||||
})
|
||||
|
||||
function startupFixture() {
|
||||
const children: (EventEmitter & {
|
||||
postMessage: ReturnType<typeof vi.fn>
|
||||
kill: ReturnType<typeof vi.fn>
|
||||
})[] = []
|
||||
rig.fork.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
postMessage: vi.fn(),
|
||||
kill: vi.fn(() => false)
|
||||
})
|
||||
children.push(child)
|
||||
return child
|
||||
})
|
||||
const supervisor = new BackendSupervisor({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => new Map(),
|
||||
whenLoaded: async () => {}
|
||||
})
|
||||
const frame = { postMessage: vi.fn() }
|
||||
const sender = Object.assign(new EventEmitter(), {
|
||||
id: 4,
|
||||
mainFrame: frame,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///index.html'
|
||||
})
|
||||
const request = rig.handle.mock.calls.find(([channel]) => channel === BACKEND_PORT_REQUEST)?.[1]
|
||||
const token = '44444444-4444-4444-4444-444444444444'
|
||||
const read = () =>
|
||||
supervisor.automationClient('claudeUsage').getAutomationRunUsage({
|
||||
worktreeId: null,
|
||||
terminalSessionId: null,
|
||||
startedAt: null,
|
||||
completedAt: null
|
||||
})
|
||||
return {
|
||||
children,
|
||||
supervisor,
|
||||
frame,
|
||||
read,
|
||||
request: (purpose = 'demand', contents = sender, requestToken = token) =>
|
||||
request({ sender: contents, senderFrame: contents.mainFrame }, requestToken, purpose)
|
||||
}
|
||||
}
|
||||
|
||||
it('retries a rejected signal-free launch on new demand without replay or false retirement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { children, supervisor, frame, read, request } = startupFixture()
|
||||
const ready = { type: 'ready', protocol: 1, capabilities: ['usage'] }
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
const demand = expect(read()).rejects.toThrow('Usage is still unavailable')
|
||||
const joined = expect(request('recovery')).rejects.toThrow('Usage is still unavailable')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await demand
|
||||
await joined
|
||||
children[attempt].emit('message', ready)
|
||||
expect(frame.postMessage).not.toHaveBeenCalledWith(BACKEND_CONNECTION_CHANGED, {
|
||||
connected: true,
|
||||
unavailable: false
|
||||
})
|
||||
await expect(request('recovery')).rejects.toThrow('Usage is still unavailable')
|
||||
expect(children).toHaveLength(attempt + 1)
|
||||
expect(rig.call).not.toHaveBeenCalled()
|
||||
}
|
||||
const retry = read()
|
||||
const port = request()
|
||||
children[4].emit('message', ready)
|
||||
rig.call.mockResolvedValueOnce({ inputTokens: 42 })
|
||||
await expect(retry).resolves.toEqual({ inputTokens: 42 })
|
||||
await port
|
||||
expect(rig.call).toHaveBeenCalledTimes(1)
|
||||
const notifications = frame.postMessage.mock.calls.slice()
|
||||
for (const child of children.slice(0, 4)) {
|
||||
child.kill.mockClear()
|
||||
child.emit('spawn')
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
child.emit('message', ready)
|
||||
child.emit('exit', 1)
|
||||
}
|
||||
expect(frame.postMessage.mock.calls).toEqual(notifications)
|
||||
const { recordDurableCrashBreadcrumb } =
|
||||
await import('../crash-reporting/breadcrumbs/durable-crash-breadcrumb')
|
||||
expect(recordDurableCrashBreadcrumb).toHaveBeenLastCalledWith('backend_process_exited', {
|
||||
pid: null,
|
||||
incarnation: null,
|
||||
exitCode: 1,
|
||||
expected: true
|
||||
})
|
||||
// A rejection after successful readiness cannot turn that owner into a failed startup.
|
||||
children[4].emit('error', new Error('late error'))
|
||||
rig.call.mockResolvedValueOnce({ inputTokens: 43 })
|
||||
await expect(read()).resolves.toEqual({ inputTokens: 43 })
|
||||
expect(children).toHaveLength(5)
|
||||
const stopped = supervisor.stop()
|
||||
let quit = false
|
||||
void stopped.then(() => {
|
||||
quit = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(quit).toBe(false)
|
||||
children[4].emit('exit', 0)
|
||||
await stopped
|
||||
await expect(read()).rejects.toThrow('Orca is shutting down')
|
||||
expect(children).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('rejects old requests when ownership changes across readiness instead of rerouting them', async () => {
|
||||
const { children, supervisor, frame, read, request } = startupFixture()
|
||||
const ready = { type: 'ready', protocol: 1, capabilities: ['usage'] }
|
||||
children[0].emit('message', ready)
|
||||
const oldRead = expect(read()).rejects.toThrow('Usage disconnected')
|
||||
const oldPort = expect(request()).rejects.toThrow('The document was replaced')
|
||||
children[0].emit('exit', 1)
|
||||
const replacementFrame = { postMessage: vi.fn() }
|
||||
const replacementSender = Object.assign(new EventEmitter(), {
|
||||
id: 5,
|
||||
mainFrame: replacementFrame,
|
||||
isDestroyed: () => false,
|
||||
getType: () => 'window',
|
||||
getURL: () => 'file:///index.html'
|
||||
})
|
||||
const token = '55555555-5555-5555-5555-555555555555'
|
||||
const replacement = request('demand', replacementSender, token)
|
||||
children[1].emit('message', ready)
|
||||
await oldRead
|
||||
await oldPort
|
||||
await replacement
|
||||
expect(rig.call).not.toHaveBeenCalled()
|
||||
expect(
|
||||
frame.postMessage.mock.calls.filter(([channel]) => channel === BACKEND_PORT_DELIVERY)
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
replacementFrame.postMessage.mock.calls.filter(([channel]) => channel === BACKEND_PORT_DELIVERY)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
children[1].postMessage.mock.calls.filter(([message]) => message.type === 'client')
|
||||
).toEqual([[{ type: 'client', token }, [{}]]])
|
||||
const stopped = supervisor.stop()
|
||||
children[1].emit('spawn')
|
||||
expect(children[1].kill).toHaveBeenCalled()
|
||||
children[1].emit('exit', 0)
|
||||
await stopped
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
import { app, ipcMain, MessageChannelMain, utilityProcess, type WebContents } from 'electron'
|
||||
import { recordDurableCrashBreadcrumb } from '../crash-reporting/breadcrumbs/durable-crash-breadcrumb'
|
||||
import { BackendPortRpc } from '../../shared/backend-port-rpc'
|
||||
import {
|
||||
BACKEND_CONNECTION_CHANGED,
|
||||
BACKEND_PORT_DELIVERY,
|
||||
BACKEND_PORT_REQUEST,
|
||||
BACKEND_UTILITY_PROCESS_NAME,
|
||||
type AutomationUsageClient
|
||||
} from '../../shared/backend-service-contract'
|
||||
import { backendMessagePort } from '../backend/backend-message-port'
|
||||
import {
|
||||
currentWorkerEntryLayout,
|
||||
resolveWorkerThreadEntryPath
|
||||
} from '../worker-threads/worker-thread-entry-path'
|
||||
import { ManagedOrcadEventWindow } from '../managed-server/lifecycle/orcad-managed-event-window'
|
||||
import { ORCAD_SHUTDOWN_DEADLINE_MS } from '../orcad/orcad-stop-deadlines'
|
||||
import {
|
||||
createBackendDesktopCapabilities,
|
||||
trustsBackendRenderer,
|
||||
trackBackendDocument,
|
||||
type BackendDocument
|
||||
} from './backend-desktop-capabilities'
|
||||
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
|
||||
|
||||
export { captureBackendBootstrap } from './backend-bootstrap'
|
||||
import { capturedBootstrap } from './backend-bootstrap'
|
||||
|
||||
export class BackendSupervisor {
|
||||
private child: ReturnType<typeof utilityProcess.fork> | null = null
|
||||
private control: BackendPortRpc | null = null
|
||||
private ready: Promise<void> | null = null
|
||||
private disposeFailedStartup?: () => void
|
||||
private desiredRunning = true
|
||||
private exited: Promise<void> = Promise.resolve()
|
||||
private restartTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private readonly failures = new ManagedOrcadEventWindow(10 * 60_000, Date.now)
|
||||
private readonly documents = new Map<number, BackendDocument>()
|
||||
private stopping?: Promise<void>
|
||||
|
||||
private readonly retireRenderer = (_event: unknown, contents: WebContents): void =>
|
||||
this.documents.get(contents.id)?.retire()
|
||||
|
||||
constructor(private readonly store: UsageWorktreeSource) {
|
||||
if (!capturedBootstrap) {
|
||||
throw new Error('Backend paths were not captured during preflight.')
|
||||
}
|
||||
app.on('render-process-gone', this.retireRenderer)
|
||||
ipcMain.handle(
|
||||
BACKEND_PORT_REQUEST,
|
||||
async (event, token: unknown, purpose: unknown = 'demand') => {
|
||||
if (
|
||||
typeof token !== 'string' ||
|
||||
!/^[0-9a-f-]{36}$/i.test(token) ||
|
||||
(purpose !== 'demand' && purpose !== 'recovery')
|
||||
) {
|
||||
throw new Error('Invalid backend connection request.')
|
||||
}
|
||||
const { sender, senderFrame } = event
|
||||
if (!senderFrame || senderFrame !== sender.mainFrame || !trustsBackendRenderer(sender)) {
|
||||
throw new Error('This document cannot connect to Orca.')
|
||||
}
|
||||
const document = trackBackendDocument(
|
||||
this.documents,
|
||||
sender,
|
||||
senderFrame,
|
||||
token,
|
||||
(retiredToken) => this.child?.postMessage({ type: 'retire', token: retiredToken })
|
||||
)
|
||||
if (!this.desiredRunning) {
|
||||
throw new Error('Orca is shutting down.')
|
||||
}
|
||||
const ready = purpose === 'recovery' ? this.ready : this.ensureReady(true)
|
||||
const child = this.child
|
||||
if (!ready) {
|
||||
throw new Error('Usage is unavailable. Try refreshing the usage view.')
|
||||
}
|
||||
await ready
|
||||
if (
|
||||
this.documents.get(sender.id) !== document ||
|
||||
!this.desiredRunning ||
|
||||
!child ||
|
||||
this.child !== child ||
|
||||
sender.isDestroyed() ||
|
||||
sender.mainFrame !== senderFrame
|
||||
) {
|
||||
throw new Error('The document was replaced before Orca connected.')
|
||||
}
|
||||
const { port1, port2 } = new MessageChannelMain()
|
||||
child.postMessage({ type: 'client', token: document.token }, [port1])
|
||||
senderFrame.postMessage(BACKEND_PORT_DELIVERY, { token: document.token }, [port2])
|
||||
return document.token
|
||||
}
|
||||
)
|
||||
void this.ensureReady(false).catch(() => {})
|
||||
}
|
||||
|
||||
automationClient(provider: 'claudeUsage' | 'codexUsage'): AutomationUsageClient {
|
||||
return {
|
||||
getAutomationRunUsage: async (input) => {
|
||||
const ready = this.ensureReady(true)
|
||||
const control = this.control
|
||||
await ready
|
||||
if (!this.desiredRunning || !control || this.control !== control) {
|
||||
throw new Error('Usage disconnected. Try again after it reconnects.')
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private usage service returns its typed automation result for this provider method.
|
||||
return (await control.call(`${provider}:getAutomationRunUsage`, input)) as Awaited<
|
||||
ReturnType<AutomationUsageClient['getAutomationRunUsage']>
|
||||
>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
this.stopping ??= (async () => {
|
||||
this.desiredRunning = false
|
||||
app.off('render-process-gone', this.retireRenderer)
|
||||
clearTimeout(this.restartTimer)
|
||||
this.restartTimer = undefined
|
||||
const child = this.child
|
||||
for (const document of this.documents.values()) {
|
||||
document.retire()
|
||||
}
|
||||
ipcMain.removeHandler(BACKEND_PORT_REQUEST)
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
const exited = this.exited
|
||||
const deadline = setTimeout(() => {
|
||||
if (this.child === child) {
|
||||
child.kill()
|
||||
}
|
||||
}, ORCAD_SHUTDOWN_DEADLINE_MS + 1_000)
|
||||
this.control?.close()
|
||||
await exited
|
||||
clearTimeout(deadline)
|
||||
})()
|
||||
return this.stopping
|
||||
}
|
||||
|
||||
private notify(connected: boolean, unavailable = false): void {
|
||||
for (const document of this.documents.values()) {
|
||||
if (!document.contents.isDestroyed() && document.contents.mainFrame === document.frame) {
|
||||
document.frame.postMessage(BACKEND_CONNECTION_CHANGED, { connected, unavailable })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureReady(explicit: boolean): Promise<void> {
|
||||
if (!this.desiredRunning) {
|
||||
throw new Error('Orca is shutting down.')
|
||||
}
|
||||
if (explicit) {
|
||||
this.disposeFailedStartup?.()
|
||||
}
|
||||
if (this.ready) {
|
||||
return this.ready
|
||||
}
|
||||
if (explicit) {
|
||||
this.failures.forget('backend')
|
||||
clearTimeout(this.restartTimer)
|
||||
this.restartTimer = undefined
|
||||
}
|
||||
if (this.failures.recent('backend').length >= 3) {
|
||||
throw new Error('Usage could not start. Try refreshing the usage view.')
|
||||
}
|
||||
const { port1, port2 } = new MessageChannelMain()
|
||||
const entry = resolveWorkerThreadEntryPath(
|
||||
currentWorkerEntryLayout(__dirname),
|
||||
'backend-entry.js'
|
||||
)
|
||||
const child = utilityProcess.fork(entry, [], {
|
||||
serviceName: BACKEND_UTILITY_PROCESS_NAME,
|
||||
stdio: ['ignore', 'ignore', 'pipe']
|
||||
})
|
||||
this.child = child
|
||||
this.control = new BackendPortRpc(
|
||||
backendMessagePort(port1),
|
||||
'desktop',
|
||||
createBackendDesktopCapabilities(this.store)
|
||||
)
|
||||
let childPid = child.pid
|
||||
child.once('spawn', () => {
|
||||
childPid = child.pid
|
||||
if (this.child !== child || !this.desiredRunning) {
|
||||
child.kill()
|
||||
}
|
||||
})
|
||||
let incarnation: string | undefined
|
||||
let settleExit!: () => void
|
||||
this.exited = new Promise<void>((resolve) => {
|
||||
settleExit = resolve
|
||||
})
|
||||
let failed!: (error: Error) => void
|
||||
let settled = false
|
||||
let disposed = false
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
failed = (error) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
if (this.child === child && this.ready === ready) {
|
||||
this.disposeFailedStartup = () => {
|
||||
disposed = true
|
||||
const control = this.control
|
||||
this.child = null
|
||||
this.control = null
|
||||
this.ready = null
|
||||
this.disposeFailedStartup = undefined
|
||||
clearTimeout(startupDeadline)
|
||||
control?.close()
|
||||
try {
|
||||
child.kill()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
reject(error)
|
||||
}
|
||||
child.on(
|
||||
'message',
|
||||
(message: {
|
||||
type?: string
|
||||
protocol?: number
|
||||
capabilities?: string[]
|
||||
incarnation?: string
|
||||
}) => {
|
||||
if (this.child !== child || settled || !this.desiredRunning) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
message.type === 'ready' &&
|
||||
message.protocol === 1 &&
|
||||
message.capabilities?.includes('usage')
|
||||
) {
|
||||
incarnation = message.incarnation
|
||||
settled = true
|
||||
clearTimeout(startupDeadline)
|
||||
resolve()
|
||||
this.notify(true)
|
||||
}
|
||||
if (message.type === 'failed') {
|
||||
failed(new Error('Usage could not start. Try refreshing the usage view.'))
|
||||
child.kill()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
this.ready = ready
|
||||
const startupDeadline = setTimeout(() => {
|
||||
failed(new Error('Usage is still unavailable. Try refreshing the usage view.'))
|
||||
if (this.child === child) {
|
||||
child.kill()
|
||||
}
|
||||
}, ORCAD_SHUTDOWN_DEADLINE_MS * 2)
|
||||
child.stderr?.on('data', (chunk: Buffer) =>
|
||||
console.warn(`[backend] ${chunk.toString().slice(0, 16_384)}`)
|
||||
)
|
||||
child.on('error', () => {
|
||||
failed(new Error('Usage could not start. Try refreshing the usage view.'))
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
recordDurableCrashBreadcrumb('backend_process_exited', {
|
||||
pid: childPid ?? null,
|
||||
incarnation: incarnation ?? null,
|
||||
exitCode: code,
|
||||
expected: disposed || !this.desiredRunning
|
||||
})
|
||||
clearTimeout(startupDeadline)
|
||||
failed(new Error('Usage disconnected. Try again after it reconnects.'))
|
||||
if (this.child === child) {
|
||||
this.control?.close()
|
||||
this.control = null
|
||||
this.child = null
|
||||
this.ready = null
|
||||
this.disposeFailedStartup = undefined
|
||||
if (this.desiredRunning) {
|
||||
this.failures.record('backend')
|
||||
}
|
||||
this.notify(false, this.desiredRunning && this.failures.recent('backend').length >= 3)
|
||||
if (this.desiredRunning) {
|
||||
if (this.failures.recent('backend').length < 3) {
|
||||
const restartTimer = setTimeout(
|
||||
() => {
|
||||
if (this.restartTimer !== restartTimer) {
|
||||
return
|
||||
}
|
||||
this.restartTimer = undefined
|
||||
void this.ensureReady(false).catch(() => {})
|
||||
},
|
||||
250 * 2 ** this.failures.recent('backend').length
|
||||
)
|
||||
this.restartTimer = restartTimer
|
||||
}
|
||||
}
|
||||
}
|
||||
settleExit()
|
||||
})
|
||||
child.postMessage({ type: 'bootstrap', config: capturedBootstrap }, [port2])
|
||||
return ready
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { BackendBootstrapSchema, BACKEND_CAPABILITIES } from '../../shared/backend-service-contract'
|
||||
import { BackendPortRpc } from '../../shared/backend-port-rpc'
|
||||
import { setAppEnvironment } from '../../shared/app-environment'
|
||||
import { agentTokenUsageSchema } from '../../shared/telemetry-agent-token-usage-schema'
|
||||
import { backendMessagePort, type BackendNativePort } from './backend-message-port'
|
||||
import { createDesktopBackendEnvironment } from './node-service-environment'
|
||||
import { BackendServiceLifetime, activateBackendServices } from './service-composition'
|
||||
import { acquireOrcadInstanceLock } from '../orcad/orcad-instance-lock'
|
||||
import { OrcadInstanceLockError } from '../orcad/orcad-data-root-privacy'
|
||||
import { ORCAD_SHUTDOWN_DEADLINE_MS } from '../orcad/orcad-stop-deadlines'
|
||||
import { clearWindowsStdHandleInheritance } from '../startup/windows-std-handle-inheritance'
|
||||
import { installWindowsIsolatedSpawner } from '../startup/windows-isolated-spawner'
|
||||
import { dropE2eSpawnLogOutsideE2e } from '../startup/e2e-spawn-log-env'
|
||||
import { ignoreInheritedWslHostsGateState } from '../../shared/wsl-host-internal-gate'
|
||||
import type { createUsageService } from './usage-service'
|
||||
import type { UsageWorktreeCatalog } from '../usage/usage-store-host'
|
||||
|
||||
dropE2eSpawnLogOutsideE2e()
|
||||
clearWindowsStdHandleInheritance()
|
||||
installWindowsIsolatedSpawner()
|
||||
ignoreInheritedWslHostsGateState()
|
||||
|
||||
const parent:
|
||||
| {
|
||||
on(
|
||||
event: 'message',
|
||||
listener: (event: { data: unknown; ports: BackendNativePort[] }) => void
|
||||
): unknown
|
||||
postMessage(message: unknown): void
|
||||
}
|
||||
| undefined = process.parentPort
|
||||
if (!parent) {
|
||||
throw new Error('Backend must be launched by its desktop supervisor.')
|
||||
}
|
||||
const incarnation = randomUUID()
|
||||
const lifetime = new BackendServiceLifetime()
|
||||
const abort = new AbortController()
|
||||
const clients = new Map<string, BackendPortRpc>()
|
||||
let control: BackendPortRpc | undefined
|
||||
let usage: ReturnType<typeof createUsageService> | undefined
|
||||
let stopping: Promise<void> | undefined
|
||||
let booting = false
|
||||
let startup: Promise<void> | undefined
|
||||
|
||||
function stop(): Promise<void> {
|
||||
stopping ??= (async () => {
|
||||
const deadline = setTimeout(() => process.exit(1), ORCAD_SHUTDOWN_DEADLINE_MS)
|
||||
abort.abort(new Error('Backend is stopping.'))
|
||||
for (const client of clients.values()) {
|
||||
client.close()
|
||||
}
|
||||
try {
|
||||
await startup?.catch(() => {})
|
||||
await lifetime.stop()
|
||||
parent!.postMessage({ type: 'stopped', incarnation })
|
||||
clearTimeout(deadline)
|
||||
process.exit(0)
|
||||
} catch {
|
||||
console.error(`[backend ${incarnation}] Shutdown did not finish cleanly.`)
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
return stopping
|
||||
}
|
||||
|
||||
async function acquireServiceLock(userDataPath: string) {
|
||||
const deadline = Date.now() + ORCAD_SHUTDOWN_DEADLINE_MS
|
||||
while (!abort.signal.aborted) {
|
||||
try {
|
||||
return acquireOrcadInstanceLock(join(userDataPath, 'backend-service'), {
|
||||
role: 'orcad',
|
||||
enforcePrivateDataRoot: false,
|
||||
onExclusivityLost: () => {
|
||||
void stop()
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof OrcadInstanceLockError) ||
|
||||
error.code !== 'orcad_instance_lock_held' ||
|
||||
Date.now() >= deadline
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
}
|
||||
throw abort.signal.reason
|
||||
}
|
||||
|
||||
async function bootstrap(raw: unknown, port: BackendNativePort): Promise<void> {
|
||||
if (booting) {
|
||||
port.close()
|
||||
throw new Error('Backend was already bootstrapped.')
|
||||
}
|
||||
booting = true
|
||||
const config = BackendBootstrapSchema.parse(raw)
|
||||
if (
|
||||
config.requiredCapabilities.some(
|
||||
(capability) => !BACKEND_CAPABILITIES.some((offered) => offered === capability)
|
||||
)
|
||||
) {
|
||||
throw new Error('Backend contract is unsupported.')
|
||||
}
|
||||
const host = createDesktopBackendEnvironment(config)
|
||||
setAppEnvironment(host.environment)
|
||||
control = new BackendPortRpc(
|
||||
backendMessagePort(port),
|
||||
incarnation,
|
||||
async (method, params, signal) => {
|
||||
if (method === 'backend.stop') {
|
||||
void stop()
|
||||
return null
|
||||
}
|
||||
if (!usage) {
|
||||
throw new Error('Usage is still starting. Try again shortly.')
|
||||
}
|
||||
await usage.ready
|
||||
return usage.invoke(method, params, true, signal)
|
||||
},
|
||||
() => {
|
||||
void stop()
|
||||
}
|
||||
)
|
||||
const lock = await acquireServiceLock(config.paths.userData)
|
||||
// Release only after services and their worker writers have retired.
|
||||
lifetime.add(({ failed }) => {
|
||||
if (!failed) {
|
||||
lock.release()
|
||||
}
|
||||
})
|
||||
abort.signal.throwIfAborted()
|
||||
lifetime.add(() => host.runQuitHandlers())
|
||||
await activateBackendServices({
|
||||
supervised: true,
|
||||
registerCleanup: (cleanup) => lifetime.add(cleanup),
|
||||
activate: async (registerCleanup) => {
|
||||
const { createUsageService } = await import('./usage-service')
|
||||
abort.signal.throwIfAborted()
|
||||
const call = async (method: string, params?: unknown): Promise<unknown> => {
|
||||
return control!.call(method, params)
|
||||
}
|
||||
usage = createUsageService({
|
||||
signal: abort.signal,
|
||||
userDataPath: config.paths.userData,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private control peer returns the catalog directly from its typed Store adapter.
|
||||
readCatalog: async () => (await call('usage.catalog')) as UsageWorktreeCatalog,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The private control peer returns the account owner's typed string array.
|
||||
claudeProfileDirs: async () => (await call('usage.claudeProfileDirs')) as string[],
|
||||
telemetry: {
|
||||
isEnabled: async () => (await call('usage.telemetry.enabled')) === true,
|
||||
capture: async (snapshot) =>
|
||||
(await call('usage.telemetry.capture', agentTokenUsageSchema.parse(snapshot))) === true
|
||||
}
|
||||
})
|
||||
registerCleanup(() => usage!.stop())
|
||||
await usage.ready
|
||||
}
|
||||
})
|
||||
abort.signal.throwIfAborted()
|
||||
parent!.postMessage({
|
||||
type: 'ready',
|
||||
protocol: 1,
|
||||
capabilities: BACKEND_CAPABILITIES,
|
||||
incarnation,
|
||||
build: config.build
|
||||
})
|
||||
}
|
||||
|
||||
parent.on('message', (event) => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the owning desktop can send parent messages; every consumed field is validated below.
|
||||
const message = event.data as { type?: unknown; config?: unknown; token?: unknown }
|
||||
const [port, ...surplus] = event.ports
|
||||
for (const extra of surplus) {
|
||||
extra.close()
|
||||
}
|
||||
if (message?.type === 'bootstrap' && port) {
|
||||
startup = bootstrap(message.config, port)
|
||||
void startup.catch(() => {
|
||||
console.error(`[backend ${incarnation}] Startup failed.`)
|
||||
parent!.postMessage({ type: 'failed', incarnation })
|
||||
void stop()
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message?.type === 'retire' && typeof message.token === 'string') {
|
||||
clients.get(message.token)?.close()
|
||||
port?.close()
|
||||
return
|
||||
}
|
||||
if (
|
||||
message?.type === 'client' &&
|
||||
typeof message.token === 'string' &&
|
||||
port &&
|
||||
usage &&
|
||||
!stopping
|
||||
) {
|
||||
const token = message.token
|
||||
clients.get(token)?.close()
|
||||
let client: BackendPortRpc
|
||||
client = new BackendPortRpc(
|
||||
backendMessagePort(port),
|
||||
incarnation,
|
||||
async (method, params, signal) => {
|
||||
await usage!.ready
|
||||
return usage!.invoke(method, params, false, signal)
|
||||
},
|
||||
() => clients.delete(token)
|
||||
)
|
||||
clients.set(token, client)
|
||||
return
|
||||
}
|
||||
port?.close()
|
||||
})
|
||||
process.on('SIGTERM', () => {
|
||||
void stop()
|
||||
})
|
||||
process.on('SIGINT', () => {
|
||||
void stop()
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { BackendPort } from '../../shared/backend-port-rpc'
|
||||
|
||||
export type BackendNativePort = {
|
||||
postMessage(message: unknown): void
|
||||
on(event: 'message', listener: (event: { data: unknown }) => void): unknown
|
||||
on(event: 'close', listener: () => void): unknown
|
||||
off(event: 'message', listener: (event: { data: unknown }) => void): unknown
|
||||
off(event: 'close', listener: () => void): unknown
|
||||
start(): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export function backendMessagePort(port: BackendNativePort): BackendPort {
|
||||
return {
|
||||
postMessage: (message) => port.postMessage(message),
|
||||
onMessage(listener) {
|
||||
const receive = (event: { data: unknown }): void => listener(event.data)
|
||||
port.on('message', receive)
|
||||
port.start()
|
||||
return () => {
|
||||
port.off('message', receive)
|
||||
}
|
||||
},
|
||||
onClose(listener) {
|
||||
port.on('close', listener)
|
||||
return () => {
|
||||
port.off('close', listener)
|
||||
}
|
||||
},
|
||||
close: () => port.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AppEnvironment } from '../../shared/app-environment'
|
||||
import type { BackendBootstrap } from '../../shared/backend-service-contract'
|
||||
|
||||
export function createDesktopBackendEnvironment(config: BackendBootstrap) {
|
||||
return createNodeServiceEnvironment({
|
||||
getPath(name) {
|
||||
if (name !== 'userData') {
|
||||
throw new Error(`Desktop backend path is unsupported: ${name}`)
|
||||
}
|
||||
return config.paths.userData
|
||||
},
|
||||
getAppPath: () => config.appPath,
|
||||
getVersion: () => config.build,
|
||||
isPackaged: () => config.isPackaged
|
||||
})
|
||||
}
|
||||
|
||||
export function createNodeServiceEnvironment(
|
||||
host: Pick<
|
||||
AppEnvironment,
|
||||
'getPath' | 'getAppPath' | 'getVersion' | 'isPackaged' | 'getCliLauncherPath'
|
||||
>
|
||||
): {
|
||||
environment: AppEnvironment
|
||||
runQuitHandlers(): void
|
||||
} {
|
||||
const quitHandlers: (() => void)[] = []
|
||||
return {
|
||||
environment: {
|
||||
...host,
|
||||
onWillQuit: (handler) => quitHandlers.push(handler),
|
||||
exit: (code = 0) => process.exit(code),
|
||||
getAppMetrics: () => []
|
||||
},
|
||||
runQuitHandlers() {
|
||||
const errors: unknown[] = []
|
||||
for (const handler of quitHandlers.splice(0)) {
|
||||
try {
|
||||
handler()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
throw new AggregateError(errors, 'backend_service_quit_handlers_failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { startOrcadEventLoopWatchdog } from '../orcad/orcad-event-loop-watchdog'
|
||||
|
||||
export type BackendServiceCleanup = (state: { failed: boolean }) => void | Promise<void>
|
||||
|
||||
/** A service keeps its ownership fence until all accepted work and writers have retired. */
|
||||
export class BackendServiceLifetime {
|
||||
private readonly cleanups: BackendServiceCleanup[] = []
|
||||
private stopping?: Promise<void>
|
||||
|
||||
constructor(private readonly release: () => void = () => {}) {}
|
||||
|
||||
add(cleanup: BackendServiceCleanup): void {
|
||||
if (this.stopping) {
|
||||
throw new Error('backend_service_lifetime_stopping')
|
||||
}
|
||||
this.cleanups.push(cleanup)
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
this.stopping ??= Promise.resolve().then(async () => {
|
||||
const errors: unknown[] = []
|
||||
for (const cleanup of this.cleanups.splice(0).toReversed()) {
|
||||
try {
|
||||
await cleanup({ failed: errors.length > 0 })
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'backend_service_cleanup_failed')
|
||||
}
|
||||
this.release()
|
||||
})
|
||||
return this.stopping
|
||||
}
|
||||
}
|
||||
|
||||
/** Both hosts enter service activation here without importing an inactive service set. */
|
||||
export async function activateBackendServices<T>(options: {
|
||||
supervised: boolean
|
||||
registerCleanup: (cleanup: BackendServiceCleanup) => void
|
||||
activate: (registerCleanup: (cleanup: BackendServiceCleanup) => void) => Promise<T>
|
||||
}): Promise<T> {
|
||||
const watchdog = startOrcadEventLoopWatchdog({ supervised: options.supervised })
|
||||
options.registerCleanup(() => watchdog?.stop())
|
||||
return options.activate(options.registerCleanup)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { createUsageService } from './usage-service'
|
||||
import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
|
||||
import type { ClaudeUsagePersistedState } from '../claude-usage/types'
|
||||
import type { UsageCacheSplitResult } from '../usage/cache/usage-source-cache-file'
|
||||
import {
|
||||
scanClaudeUsageFilesViaWorker,
|
||||
splitUsageCacheFileViaWorker,
|
||||
stopUsageScanWorker
|
||||
} from '../usage/scan-worker/usage-scan-worker-spawn'
|
||||
vi.mock('../usage/scan-worker/usage-scan-worker-spawn', () => ({
|
||||
scanClaudeUsageFilesViaWorker: vi.fn(),
|
||||
scanCodexUsageFilesViaWorker: vi.fn(),
|
||||
scanOpenCodeUsageDatabasesViaWorker: vi.fn(),
|
||||
scanMuseUsageFilesViaWorker: vi.fn(),
|
||||
splitUsageCacheFileViaWorker: vi.fn(),
|
||||
stopUsageScanWorker: vi.fn(async () => {})
|
||||
}))
|
||||
let directory: string
|
||||
let services: ReturnType<typeof createUsageService>[]
|
||||
let releasePending: (() => void)[]
|
||||
function nextTurn(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
function reportText(state: ClaudeUsagePersistedState): string {
|
||||
const { processedFiles: _files, ...report } = state
|
||||
return JSON.stringify(report)
|
||||
}
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'orca-backend-usage-'))
|
||||
services = []
|
||||
releasePending = []
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(async () => {
|
||||
releasePending.forEach((release) => release())
|
||||
await Promise.all(services.map((service) => service.stop()))
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
function historicalState(): ClaudeUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: 6,
|
||||
worktreeFingerprint: null,
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
dailyAggregates: [
|
||||
{
|
||||
day: '2026-10-09',
|
||||
model: null,
|
||||
projectKey: 'unscoped',
|
||||
projectLabel: 'Unknown location',
|
||||
repoId: null,
|
||||
worktreeId: null,
|
||||
turnCount: 1,
|
||||
zeroCacheReadTurnCount: 1,
|
||||
inputTokens: 7,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
cacheWrite1hTokens: 0
|
||||
}
|
||||
],
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: 1,
|
||||
lastScanError: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('awaits loading before the first disabled cached snapshot and all historical queries', async () => {
|
||||
const cached = historicalState()
|
||||
writeFileSync(
|
||||
join(directory, 'orca-claude-usage.json'),
|
||||
`${JSON.stringify(cached)}${' '.repeat(9 * 1024 * 1024)}`
|
||||
)
|
||||
const pending = Promise.withResolvers<UsageCacheSplitResult>()
|
||||
releasePending.push(() => pending.resolve({ reportText: reportText(cached), migrated: false }))
|
||||
vi.mocked(splitUsageCacheFileViaWorker).mockReturnValueOnce(pending.promise)
|
||||
const service = createUsageService(createTestUsageStoreHost({ userDataPath: directory }))
|
||||
services.push(service)
|
||||
const settled = vi.fn()
|
||||
const args = { scope: 'all', range: 'all', limit: 7, kind: 'model' }
|
||||
const requests = [
|
||||
'getSnapshot',
|
||||
'getScanState',
|
||||
'getSummary',
|
||||
'getDaily',
|
||||
'getBreakdown',
|
||||
'getRecentSessions'
|
||||
].map((query) =>
|
||||
Promise.resolve(service.invoke(`claudeUsage:${query}`, args, false)).then((value) => {
|
||||
settled()
|
||||
return value
|
||||
})
|
||||
)
|
||||
await nextTurn()
|
||||
expect(settled).not.toHaveBeenCalled()
|
||||
expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
|
||||
pending.resolve({ reportText: reportText(cached), migrated: false })
|
||||
const [snapshot, scanState, summary, daily] = await Promise.all(requests)
|
||||
expect(snapshot).toMatchObject({
|
||||
scanState: { enabled: false, isScanning: false, hasAnyClaudeData: true },
|
||||
summary: { turns: 1, inputTokens: 7 }
|
||||
})
|
||||
expect(scanState).toMatchObject({ enabled: false, isScanning: false, hasAnyClaudeData: true })
|
||||
expect(summary).toMatchObject({ turns: 1, inputTokens: 7 })
|
||||
expect(daily).toEqual([
|
||||
{ day: '2026-10-09', inputTokens: 7, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
])
|
||||
expect(scanClaudeUsageFilesViaWorker).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retires the scanner before releasing ownership and preserves enabled state without a shutdown error', async () => {
|
||||
const service = createUsageService(createTestUsageStoreHost({ userDataPath: directory }))
|
||||
services.push(service)
|
||||
await service.invoke('claudeUsage:setEnabled', { enabled: true }, false)
|
||||
const scanStarted = Promise.withResolvers<void>()
|
||||
const scan = Promise.withResolvers<never>()
|
||||
vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementationOnce(() => {
|
||||
scanStarted.resolve()
|
||||
return scan.promise
|
||||
})
|
||||
const refreshing = service.invoke('claudeUsage:refresh', { force: true }, false)
|
||||
await scanStarted.promise
|
||||
const retirement = Promise.withResolvers<void>()
|
||||
releasePending.push(() => {
|
||||
scan.reject(new Error('worker retired'))
|
||||
retirement.resolve()
|
||||
})
|
||||
vi.mocked(stopUsageScanWorker).mockImplementationOnce(() => retirement.promise)
|
||||
const stopped = vi.fn()
|
||||
const stopping = service.stop().then(stopped)
|
||||
await nextTurn()
|
||||
expect(stopped).not.toHaveBeenCalled()
|
||||
await expect(
|
||||
service.invoke('claudeUsage:setEnabled', { enabled: false }, false)
|
||||
).rejects.toThrow()
|
||||
scan.reject(new Error('worker retired'))
|
||||
retirement.resolve()
|
||||
await Promise.all([refreshing, stopping])
|
||||
expect(JSON.parse(readFileSync(join(directory, 'orca-claude-usage.json'), 'utf8'))).toMatchObject(
|
||||
{
|
||||
scanState: { enabled: true, lastScanError: null }
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { z } from 'zod'
|
||||
import { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import { CodexUsageStore } from '../codex-usage/store'
|
||||
import { OpenCodeUsageStore } from '../opencode-usage/store'
|
||||
import { MuseUsageStore } from '../muse-usage/store'
|
||||
import type { UsageStoreHost } from '../usage/usage-store-host'
|
||||
import { stopUsageScanWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
|
||||
import type { UsageProviderKey } from '../../shared/backend-service-contract'
|
||||
|
||||
const rangeSchema = z.object({
|
||||
scope: z.string().max(4096),
|
||||
range: z.string().max(128),
|
||||
limit: z.number().int().nonnegative().optional()
|
||||
})
|
||||
const automationSchema = z.object({
|
||||
worktreeId: z.string().nullable(),
|
||||
terminalSessionId: z.string().nullable(),
|
||||
startedAt: z.number().nullable(),
|
||||
completedAt: z.number().nullable()
|
||||
})
|
||||
type Handler = (params: unknown) => unknown
|
||||
|
||||
type QueryStore<Scope, Range, Kind> = {
|
||||
whenLoaded(): Promise<void>
|
||||
getScanState(): unknown
|
||||
setEnabled(enabled: boolean): unknown
|
||||
refresh(force?: boolean): unknown
|
||||
getSnapshot(scope: Scope, range: Range, limit?: number): unknown
|
||||
getSummary(scope: Scope, range: Range): unknown
|
||||
getDaily(scope: Scope, range: Range): unknown
|
||||
getBreakdown(scope: Scope, range: Range, kind: Kind): unknown
|
||||
getRecentSessions(scope: Scope, range: Range, limit?: number): unknown
|
||||
}
|
||||
|
||||
function registerUsageQueries<Scope extends string, Range extends string, Kind extends string>(
|
||||
handlers: Map<string, Handler>,
|
||||
prefix: UsageProviderKey,
|
||||
store: QueryStore<Scope, Range, Kind>
|
||||
): void {
|
||||
const query =
|
||||
(fn: (scope: Scope, range: Range, limit?: number) => unknown): Handler =>
|
||||
(raw) => {
|
||||
const args = rangeSchema.parse(raw)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Each registered provider uses the same public scope/range contract; the generic preserves its query signature.
|
||||
return fn(args.scope as Scope, args.range as Range, args.limit)
|
||||
}
|
||||
handlers.set(`${prefix}:getScanState`, () => store.getScanState())
|
||||
handlers.set(`${prefix}:setEnabled`, (raw) =>
|
||||
store.setEnabled(z.object({ enabled: z.boolean() }).parse(raw).enabled)
|
||||
)
|
||||
handlers.set(`${prefix}:refresh`, (raw) =>
|
||||
store.refresh(z.object({ force: z.boolean().optional() }).optional().parse(raw)?.force ?? false)
|
||||
)
|
||||
handlers.set(
|
||||
`${prefix}:getSnapshot`,
|
||||
query((scope, range, limit) => store.getSnapshot(scope, range, limit))
|
||||
)
|
||||
handlers.set(
|
||||
`${prefix}:getSummary`,
|
||||
query((scope, range) => store.getSummary(scope, range))
|
||||
)
|
||||
handlers.set(
|
||||
`${prefix}:getDaily`,
|
||||
query((scope, range) => store.getDaily(scope, range))
|
||||
)
|
||||
handlers.set(
|
||||
`${prefix}:getRecentSessions`,
|
||||
query((scope, range, limit) => store.getRecentSessions(scope, range, limit))
|
||||
)
|
||||
handlers.set(`${prefix}:getBreakdown`, (raw) => {
|
||||
const args = rangeSchema.extend({ kind: z.string().max(128) }).parse(raw)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: These are the provider API arguments shared by all four registered query stores.
|
||||
return store.getBreakdown(args.scope as Scope, args.range as Range, args.kind as Kind)
|
||||
})
|
||||
}
|
||||
|
||||
export function createUsageService(host: UsageStoreHost) {
|
||||
const stores = {
|
||||
claudeUsage: new ClaudeUsageStore(host),
|
||||
codexUsage: new CodexUsageStore(host),
|
||||
openCodeUsage: new OpenCodeUsageStore(host),
|
||||
museUsage: new MuseUsageStore(host)
|
||||
}
|
||||
const all = Object.values(stores)
|
||||
const handlers = new Map<string, Handler>()
|
||||
registerUsageQueries(handlers, 'claudeUsage', stores.claudeUsage)
|
||||
registerUsageQueries(handlers, 'codexUsage', stores.codexUsage)
|
||||
registerUsageQueries(handlers, 'openCodeUsage', stores.openCodeUsage)
|
||||
registerUsageQueries(handlers, 'museUsage', stores.museUsage)
|
||||
let accepting = true
|
||||
const active = new Set<Promise<unknown>>()
|
||||
const stop = async (): Promise<void> => {
|
||||
accepting = false
|
||||
for (const store of all) {
|
||||
store.stopScanAdmission()
|
||||
}
|
||||
await ready
|
||||
await stopUsageScanWorker()
|
||||
await Promise.allSettled(active)
|
||||
await Promise.all(all.map((store) => store.settleScansForShutdown()))
|
||||
await Promise.all(all.map((store) => store.flush()))
|
||||
}
|
||||
const ready = Promise.all(all.map((store) => store.whenLoaded())).then(() => {})
|
||||
const dispatch = async (
|
||||
method: string,
|
||||
params: unknown,
|
||||
privileged: boolean
|
||||
): Promise<unknown> => {
|
||||
if (!accepting) {
|
||||
throw new Error('Usage service is stopping. Try again after Orca reconnects.')
|
||||
}
|
||||
if (privileged && method === 'claudeUsage:getAutomationRunUsage') {
|
||||
return stores.claudeUsage.getAutomationRunUsage(automationSchema.parse(params))
|
||||
}
|
||||
if (privileged && method === 'codexUsage:getAutomationRunUsage') {
|
||||
return stores.codexUsage.getAutomationRunUsage(automationSchema.parse(params))
|
||||
}
|
||||
const handler = handlers.get(method)
|
||||
if (!handler) {
|
||||
throw new Error('This usage operation is not supported.')
|
||||
}
|
||||
return handler(params)
|
||||
}
|
||||
return {
|
||||
ready,
|
||||
invoke(
|
||||
method: string,
|
||||
params: unknown,
|
||||
privileged: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown> {
|
||||
const operation = ready.then(() => {
|
||||
signal?.throwIfAborted()
|
||||
return dispatch(method, params, privileged)
|
||||
})
|
||||
active.add(operation)
|
||||
void operation.finally(() => active.delete(operation)).catch(() => {})
|
||||
return operation
|
||||
},
|
||||
stop
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import { vi } from 'vitest'
|
||||
import type { ClaudeUsagePersistedFile, ClaudeUsagePersistedState } from './types'
|
||||
import { readClaudeUsageScanFile } from './transcript-record-parser'
|
||||
import { projectClaudeUsageScanFile } from './transcript-usage-projection'
|
||||
@@ -14,14 +15,14 @@ import {
|
||||
import { normalizeClaudeUsageSourceFiles } from './persisted-projection-validation'
|
||||
import { usageSourceCachePath } from '../usage/cache/usage-source-cache-file'
|
||||
|
||||
import { ClaudeUsageStore, initClaudeUsagePath } from './store'
|
||||
import { ClaudeUsageStore } from './store'
|
||||
import { listClaudeTranscriptFiles } from './transcript-file-discovery'
|
||||
import {
|
||||
scanClaudeUsageFilesViaWorker,
|
||||
splitUsageCacheFileViaWorker
|
||||
} from '../usage/scan-worker/usage-scan-worker-spawn'
|
||||
|
||||
export async function createPersistedUsageTestFixture(getPath: Mock<() => string>) {
|
||||
export async function createPersistedUsageTestFixture() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'orca-claude-persisted-usage-'))
|
||||
const transcript = join(directory, 'session.jsonl')
|
||||
const reportPath = join(directory, 'orca-claude-usage.json')
|
||||
@@ -32,25 +33,25 @@ export async function createPersistedUsageTestFixture(getPath: Mock<() => string
|
||||
reuse: true
|
||||
}
|
||||
const transcripts = [transcript]
|
||||
getPath.mockReturnValue(directory)
|
||||
initClaudeUsagePath()
|
||||
vi.mocked(listClaudeTranscriptFiles).mockReset()
|
||||
vi.mocked(listClaudeTranscriptFiles).mockImplementation(async () => [...transcripts].sort())
|
||||
vi.mocked(splitUsageCacheFileViaWorker).mockReset()
|
||||
vi.mocked(splitUsageCacheFileViaWorker).mockImplementation(splitClaudeUsageCacheFile)
|
||||
vi.mocked(scanClaudeUsageFilesViaWorker).mockReset()
|
||||
vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementation(async (worktrees, ref) => {
|
||||
const previous = await readClaudeUsageSourceCache(ref)
|
||||
const result = await scanClaudeUsageFiles(
|
||||
worktrees,
|
||||
previous.sources,
|
||||
undefined,
|
||||
[],
|
||||
previous.verifiedSources
|
||||
)
|
||||
await persistClaudeUsageSourceCache(ref, result.processedFiles, previous)
|
||||
return { sessions: result.sessions, dailyAggregates: result.dailyAggregates }
|
||||
})
|
||||
vi.mocked(scanClaudeUsageFilesViaWorker).mockImplementation(
|
||||
async (worktrees, ref, profileDirs) => {
|
||||
const previous = await readClaudeUsageSourceCache(ref)
|
||||
const result = await scanClaudeUsageFiles(
|
||||
worktrees,
|
||||
previous.sources,
|
||||
undefined,
|
||||
profileDirs,
|
||||
previous.verifiedSources
|
||||
)
|
||||
await persistClaudeUsageSourceCache(ref, result.processedFiles, previous)
|
||||
return { sessions: result.sessions, dailyAggregates: result.dailyAggregates }
|
||||
}
|
||||
)
|
||||
|
||||
function row(index: number, duplicate = false): string {
|
||||
return `${JSON.stringify({
|
||||
@@ -118,13 +119,7 @@ export async function createPersistedUsageTestFixture(getPath: Mock<() => string
|
||||
project,
|
||||
state,
|
||||
writeResumable,
|
||||
createStore: () =>
|
||||
new ClaudeUsageStore({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => new Map(),
|
||||
whenLoaded: async () => {}
|
||||
}),
|
||||
createStore: () => new ClaudeUsageStore(createTestUsageStoreHost({ userDataPath: directory })),
|
||||
decodedSources: async () => {
|
||||
const read = await readClaudeUsageSourceCache(sourceRef)
|
||||
return normalizeClaudeUsageSourceFiles(read.sources, read.verifiedSources).processedFiles
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { claudeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
ClaudeUsageBreakdownKind,
|
||||
@@ -12,7 +11,7 @@ import type {
|
||||
ClaudeUsageSummary
|
||||
} from '../../shared/claude-usage-types'
|
||||
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
|
||||
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
|
||||
import type { UsageStoreHost } from '../usage/usage-store-host'
|
||||
import type { ClaudeUsagePersistedState } from './types'
|
||||
import {
|
||||
scanClaudeUsageFilesViaWorker,
|
||||
@@ -29,9 +28,6 @@ import { parseClaudeUsageReport, serializeClaudeUsageReport } from './persisted-
|
||||
// v7 stores protected column checkpoints on the worker and a separately protected report.
|
||||
const SCHEMA_VERSION = 7
|
||||
|
||||
// Capture after configureDevUserDataPath(), before app.setName() changes the derived userData path.
|
||||
let _claudeUsageFile: string | null = null
|
||||
|
||||
function getDefaultState(): ClaudeUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
@@ -63,30 +59,19 @@ function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsageP
|
||||
}
|
||||
}
|
||||
|
||||
export function initClaudeUsagePath(): void {
|
||||
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
|
||||
}
|
||||
|
||||
function getClaudeUsageFile(): string {
|
||||
if (!_claudeUsageFile) {
|
||||
_claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json')
|
||||
}
|
||||
return _claudeUsageFile
|
||||
}
|
||||
|
||||
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedFiles',
|
||||
ClaudeUsagePersistedState,
|
||||
'hasAnyClaudeData'
|
||||
> {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
constructor(host: UsageStoreHost) {
|
||||
super(host, {
|
||||
tokenUsage: {
|
||||
provider: 'claude',
|
||||
selectSessions: (state) => claudeTokenSessions(state.sessions)
|
||||
},
|
||||
logTag: '[claude-usage]',
|
||||
resolveCacheFile: getClaudeUsageFile,
|
||||
resolveCacheFile: () => join(host.userDataPath, 'orca-claude-usage.json'),
|
||||
createDefaultState: getDefaultState,
|
||||
normalizeState: normalizePersistedState,
|
||||
parseReport: parseClaudeUsageReport,
|
||||
@@ -94,7 +79,11 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
|
||||
providerId: 'claude',
|
||||
sourceKey: 'processedFiles',
|
||||
dataPresenceKey: 'hasAnyClaudeData',
|
||||
scan: scanClaudeUsageFilesViaWorker,
|
||||
scan: async (worktrees, sourceCache) => {
|
||||
const profileDirs = await host.claudeProfileDirs()
|
||||
host.signal.throwIfAborted()
|
||||
return scanClaudeUsageFilesViaWorker(worktrees, sourceCache, profileDirs)
|
||||
},
|
||||
splitCacheFile: splitUsageCacheFileViaWorker
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, vi } from 'vitest'
|
||||
import type { Mock } from 'vitest'
|
||||
import { createTestUsageStoreHost } from '../usage/usage-store-host-test-fixture'
|
||||
import { scanCodexUsageFilesViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
|
||||
import { CodexUsageStore, initCodexUsagePath } from './store'
|
||||
import { CodexUsageStore } from './store'
|
||||
import type { CodexUsagePersistedState } from './types'
|
||||
|
||||
export function createEmptyScanResult() {
|
||||
@@ -66,39 +66,33 @@ export function createWorktreeUsageSession(worktreeId: string) {
|
||||
}
|
||||
|
||||
export function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUsageStore {
|
||||
const store = new CodexUsageStore({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getRememberedWorktreeDisplayNames: () => new Map(),
|
||||
whenLoaded: async () => {}
|
||||
})
|
||||
|
||||
;(store as unknown as { state: CodexUsagePersistedState }).state = {
|
||||
schemaVersion: 1,
|
||||
worktreeFingerprint: null,
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
dailyAggregates: [],
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
},
|
||||
...state
|
||||
}
|
||||
|
||||
return store
|
||||
writeFileSync(
|
||||
join(activeUserData, 'orca-codex-usage.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 7,
|
||||
worktreeFingerprint: null,
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
dailyAggregates: [],
|
||||
scanState: {
|
||||
enabled: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
},
|
||||
...state
|
||||
})
|
||||
)
|
||||
return new CodexUsageStore(createTestUsageStoreHost({ userDataPath: activeUserData }))
|
||||
}
|
||||
|
||||
/** The caller owns the hoisted Electron mock. */
|
||||
export function setupCodexUsageStoreEnv(getPathMock: Mock): { tempUserData: string } {
|
||||
let activeUserData: string
|
||||
export function setupCodexUsageStoreEnv(): { tempUserData: string } {
|
||||
const env = { tempUserData: '' }
|
||||
|
||||
beforeEach(() => {
|
||||
env.tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
|
||||
getPathMock.mockReturnValue(env.tempUserData)
|
||||
initCodexUsagePath()
|
||||
activeUserData = env.tempUserData
|
||||
vi.mocked(scanCodexUsageFilesViaWorker).mockReset()
|
||||
vi.mocked(scanCodexUsageFilesViaWorker).mockResolvedValue(createEmptyScanResult())
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
CodexUsageBreakdownKind,
|
||||
@@ -12,7 +11,7 @@ import type {
|
||||
CodexUsageSummary
|
||||
} from '../../shared/codex-usage-types'
|
||||
import type { AutomationRunUsage } from '../../shared/automations/history/automation-run'
|
||||
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
|
||||
import type { UsageStoreHost } from '../usage/usage-store-host'
|
||||
import type { CodexUsagePersistedState } from './types'
|
||||
import type { AutomationUsageLookupInput } from './codex-automation-run-attribution'
|
||||
import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider'
|
||||
@@ -24,8 +23,6 @@ import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-wo
|
||||
|
||||
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
|
||||
|
||||
let _codexUsageFile: string | null = null
|
||||
|
||||
function getDefaultState(): CodexUsagePersistedState {
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
@@ -63,30 +60,19 @@ export function normalizePersistedState(state: CodexUsagePersistedState): CodexU
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export class CodexUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedFiles',
|
||||
CodexUsagePersistedState,
|
||||
'hasAnyCodexData'
|
||||
> {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
constructor(host: UsageStoreHost) {
|
||||
super(host, {
|
||||
tokenUsage: {
|
||||
provider: 'codex',
|
||||
selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions)
|
||||
},
|
||||
logTag: '[codex-usage]',
|
||||
resolveCacheFile: getCodexUsageFile,
|
||||
resolveCacheFile: () => join(host.userDataPath, 'orca-codex-usage.json'),
|
||||
createDefaultState: getDefaultState,
|
||||
normalizeState: normalizePersistedState,
|
||||
sourceKey: 'processedFiles',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STT_UTILITY_PROCESS_NAME } from '../../speech/stt-worker-process'
|
||||
import { BACKEND_UTILITY_PROCESS_NAME } from '../../../shared/backend-service-contract'
|
||||
|
||||
export type ProcessGoneSource = 'renderer' | 'child'
|
||||
export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown'
|
||||
@@ -14,7 +15,10 @@ const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
|
||||
'video_capture.mojom.VideoCaptureService'
|
||||
])
|
||||
// Orca's own utility processes whose exits the app already handles and reports in place.
|
||||
const RECOVERABLE_ORCA_UTILITY_NAMES = new Set([STT_UTILITY_PROCESS_NAME])
|
||||
const RECOVERABLE_ORCA_UTILITY_NAMES = new Set([
|
||||
STT_UTILITY_PROCESS_NAME,
|
||||
BACKEND_UTILITY_PROCESS_NAME
|
||||
])
|
||||
const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed'])
|
||||
const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure'])
|
||||
|
||||
|
||||
@@ -67,6 +67,50 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('recordProcessGoneCrash', () => {
|
||||
it('reports no application crash for managed utilities with their own failure presentation', async () => {
|
||||
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
|
||||
const utility = (name: string, reason: string, exitCode: number) =>
|
||||
event({
|
||||
source: 'child',
|
||||
processType: 'Utility',
|
||||
reason,
|
||||
exitCode,
|
||||
details: { name, serviceName: 'node.mojom.NodeService', type: 'Utility' }
|
||||
})
|
||||
|
||||
for (const name of ['Orca Speech', 'Orca backend']) {
|
||||
for (const [reason, exitCode] of [
|
||||
['killed', 9],
|
||||
['crashed', 11],
|
||||
['oom', 1]
|
||||
] as const) {
|
||||
recordProcessGoneCrash(
|
||||
{ record, attachDetails } as never,
|
||||
utility(name, reason, exitCode),
|
||||
new ProcessGoneDedupe(),
|
||||
noMinidump
|
||||
)
|
||||
}
|
||||
}
|
||||
recordProcessGoneCrash(
|
||||
{ record, attachDetails } as never,
|
||||
utility('Some Other Service', 'crashed', 11),
|
||||
new ProcessGoneDedupe(),
|
||||
noMinidump
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
|
||||
expect(record.mock.calls[0][0].details).toMatchObject({ name: 'Some Other Service' })
|
||||
expect(getCrashBreadcrumbSnapshot()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'process_gone_suppressed',
|
||||
data: expect.objectContaining({ name: 'Orca backend', reason: 'killed', exitCode: 9 })
|
||||
})
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('allows the same renderer crash to retry after persistence fails', async () => {
|
||||
const record = vi
|
||||
.fn()
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { StatsCollector } from '../../stats/collector'
|
||||
import { registerFilesystemHandlers } from '../filesystem/filesystem'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../../text-generation/commit-message-agent-environment'
|
||||
import { registerFilesystemWatcherHandlers } from '../filesystem/watchers/filesystem-watcher-handlers'
|
||||
import { registerUsageProviderHandlers } from '../usage-provider-handlers'
|
||||
import { registerGitHubHandlers } from '../source-control/github'
|
||||
import { registerGitLabHandlers } from '../source-control/gitlab'
|
||||
import { registerHostedReviewHandlers } from '../source-control/hosted-review'
|
||||
@@ -85,10 +84,6 @@ import {
|
||||
setTrustedClipboardRendererWebContentsId
|
||||
} from '../../window/clipboard-ipc-handlers'
|
||||
import { isDashboardPopoutRenderer } from '../../window/dashboard-popout-window'
|
||||
import type { ClaudeUsageStore } from '../../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../../codex-usage/store'
|
||||
import type { OpenCodeUsageStore } from '../../opencode-usage/store'
|
||||
import type { MuseUsageStore } from '../../muse-usage/store'
|
||||
import type { RateLimitService } from '../../rate-limits/service/rate-limit-service'
|
||||
import type { CodexAccountService } from '../../codex-accounts/service'
|
||||
import type { ClaudeAccountService } from '../../claude-accounts/service'
|
||||
@@ -147,10 +142,6 @@ export function registerCoreHandlers(
|
||||
store: Store,
|
||||
runtime: OrcaRuntimeService,
|
||||
stats: StatsCollector,
|
||||
claudeUsage: ClaudeUsageStore,
|
||||
codexUsage: CodexUsageStore,
|
||||
openCodeUsage: OpenCodeUsageStore,
|
||||
museUsage: MuseUsageStore,
|
||||
codexAccounts: CodexAccountService,
|
||||
claudeAccounts: ClaudeAccountService,
|
||||
rateLimits: RateLimitService,
|
||||
@@ -188,7 +179,6 @@ export function registerCoreHandlers(
|
||||
})
|
||||
registerCliHandlers()
|
||||
registerPreflightHandlers(store)
|
||||
registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage, museUsage })
|
||||
registerCodexAccountHandlers(codexAccounts, () => store.getSettings())
|
||||
registerAgentHookHandlers(runtime, { getPtyIdForPaneKey })
|
||||
registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService)
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import type { OpenCodeUsageStore } from '../opencode-usage/store'
|
||||
import type { MuseUsageStore } from '../muse-usage/store'
|
||||
|
||||
type UsageProviderStores = {
|
||||
claudeUsage: ClaudeUsageStore
|
||||
codexUsage: CodexUsageStore
|
||||
openCodeUsage: OpenCodeUsageStore
|
||||
museUsage: MuseUsageStore
|
||||
}
|
||||
|
||||
type UsageProviderChannelPrefix = keyof UsageProviderStores
|
||||
|
||||
type UsageProviderHandlerStore<Scope, Range, BreakdownKind> = {
|
||||
whenLoaded: () => Promise<void>
|
||||
getScanState: () => unknown
|
||||
setEnabled: (enabled: boolean) => unknown
|
||||
refresh: (force?: boolean) => unknown
|
||||
getSnapshot: (scope: Scope, range: Range, limit?: number) => unknown
|
||||
getSummary: (scope: Scope, range: Range) => unknown
|
||||
getDaily: (scope: Scope, range: Range) => unknown
|
||||
getBreakdown: (scope: Scope, range: Range, kind: BreakdownKind) => unknown
|
||||
getRecentSessions: (scope: Scope, range: Range, limit?: number) => unknown
|
||||
}
|
||||
|
||||
type UsageRangeArgs<Scope, Range> = {
|
||||
scope: Scope
|
||||
range: Range
|
||||
}
|
||||
|
||||
function registerProviderHandlers<Scope, Range, BreakdownKind>(
|
||||
prefix: UsageProviderChannelPrefix,
|
||||
usage: UsageProviderHandlerStore<Scope, Range, BreakdownKind>
|
||||
): void {
|
||||
// Wait for worker hydration so getters cannot return persisted-state defaults.
|
||||
ipcMain.handle(`${prefix}:getScanState`, async () => {
|
||||
await usage.whenLoaded()
|
||||
return usage.getScanState()
|
||||
})
|
||||
ipcMain.handle(`${prefix}:setEnabled`, (_event, args: { enabled: boolean }) =>
|
||||
usage.setEnabled(args.enabled)
|
||||
)
|
||||
ipcMain.handle(`${prefix}:refresh`, (_event, args?: { force?: boolean }) =>
|
||||
usage.refresh(args?.force ?? false)
|
||||
)
|
||||
ipcMain.handle(
|
||||
`${prefix}:getSnapshot`,
|
||||
async (_event, args: UsageRangeArgs<Scope, Range> & { limit?: number }) => {
|
||||
await usage.whenLoaded()
|
||||
return usage.getSnapshot(args.scope, args.range, args.limit)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs<Scope, Range>) =>
|
||||
usage.getSummary(args.scope, args.range)
|
||||
)
|
||||
ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs<Scope, Range>) =>
|
||||
usage.getDaily(args.scope, args.range)
|
||||
)
|
||||
ipcMain.handle(
|
||||
`${prefix}:getBreakdown`,
|
||||
(_event, args: UsageRangeArgs<Scope, Range> & { kind: BreakdownKind }) =>
|
||||
usage.getBreakdown(args.scope, args.range, args.kind)
|
||||
)
|
||||
ipcMain.handle(
|
||||
`${prefix}:getRecentSessions`,
|
||||
(_event, args: UsageRangeArgs<Scope, Range> & { limit?: number }) =>
|
||||
usage.getRecentSessions(args.scope, args.range, args.limit)
|
||||
)
|
||||
}
|
||||
|
||||
export function registerUsageProviderHandlers(stores: UsageProviderStores): void {
|
||||
registerProviderHandlers('claudeUsage', stores.claudeUsage)
|
||||
registerProviderHandlers('codexUsage', stores.codexUsage)
|
||||
registerProviderHandlers('openCodeUsage', stores.openCodeUsage)
|
||||
registerProviderHandlers('museUsage', stores.museUsage)
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
MuseUsageBreakdownKind,
|
||||
@@ -10,7 +9,7 @@ import type {
|
||||
MuseUsageSnapshot,
|
||||
MuseUsageSummary
|
||||
} from '../../shared/muse-usage-types'
|
||||
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
|
||||
import type { UsageStoreHost } from '../usage/usage-store-host'
|
||||
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
|
||||
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
|
||||
import {
|
||||
@@ -27,26 +26,15 @@ import {
|
||||
} from './snapshot-rollups'
|
||||
import type { MuseUsageDailyAggregate, MuseUsagePersistedState, MuseUsageSession } from './types'
|
||||
|
||||
let museUsageFile: string | null = null
|
||||
|
||||
export function initMuseUsagePath(): void {
|
||||
museUsageFile = join(app.getPath('userData'), 'orca-muse-usage.json')
|
||||
}
|
||||
|
||||
function getMuseUsageFile(): string {
|
||||
museUsageFile ??= join(app.getPath('userData'), 'orca-muse-usage.json')
|
||||
return museUsageFile
|
||||
}
|
||||
|
||||
export class MuseUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedFiles',
|
||||
MuseUsagePersistedState,
|
||||
'hasAnyMuseData'
|
||||
> {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
constructor(host: UsageStoreHost) {
|
||||
super(host, {
|
||||
logTag: '[muse-usage]',
|
||||
resolveCacheFile: getMuseUsageFile,
|
||||
resolveCacheFile: () => join(host.userDataPath, 'orca-muse-usage.json'),
|
||||
createDefaultState: getDefaultMuseUsageState,
|
||||
normalizeState: normalizeMuseUsageState,
|
||||
sourceKey: 'processedFiles',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { codexOpenCodeTokenSessions } from '../usage/reporting/token-usage/agent-token-usage'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
OpenCodeUsageBreakdownKind,
|
||||
@@ -11,7 +10,7 @@ import type {
|
||||
OpenCodeUsageSnapshot,
|
||||
OpenCodeUsageSummary
|
||||
} from '../../shared/opencode-usage-types'
|
||||
import type { UsageWorktreeSource } from '../usage/worktree-attribution/usage-worktree-metadata'
|
||||
import type { UsageStoreHost } from '../usage/usage-store-host'
|
||||
import type {
|
||||
OpenCodeUsageDailyAggregate,
|
||||
OpenCodeUsagePersistedState,
|
||||
@@ -32,32 +31,19 @@ import {
|
||||
import { UsageProviderStoreLifecycle } from '../usage/provider-lifecycle/usage-provider-store-lifecycle'
|
||||
import { splitUsageCacheFileViaWorker } from '../usage/scan-worker/usage-scan-worker-spawn'
|
||||
|
||||
let _openCodeUsageFile: string | null = null
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedDatabases',
|
||||
OpenCodeUsagePersistedState,
|
||||
'hasAnyOpenCodeData'
|
||||
> {
|
||||
constructor(store: UsageWorktreeSource) {
|
||||
super(store, {
|
||||
constructor(host: UsageStoreHost) {
|
||||
super(host, {
|
||||
tokenUsage: {
|
||||
provider: 'opencode',
|
||||
selectSessions: (state) => codexOpenCodeTokenSessions(state.sessions)
|
||||
},
|
||||
logTag: '[opencode-usage]',
|
||||
resolveCacheFile: getOpenCodeUsageFile,
|
||||
resolveCacheFile: () => join(host.userDataPath, 'orca-opencode-usage.json'),
|
||||
createDefaultState: getDefaultState,
|
||||
normalizeState: normalizePersistedState,
|
||||
sourceKey: 'processedDatabases',
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
|
||||
import { restoreOrcadTerminalAuthority } from './orcad-restored-terminal-authority'
|
||||
import { installOrcadFeatureReadiness } from '../runtime/rpc/orcad-feature-readiness'
|
||||
import { ORCA_SERVE_SUPERVISED_ENV } from '../../shared/serve-supervised-terminal-durability'
|
||||
import { startOrcadEventLoopWatchdog } from './orcad-event-loop-watchdog'
|
||||
import { activateBackendServices } from '../backend/service-composition'
|
||||
import { isAgentStatusHooksEnabled } from '../../shared/agent-hook-listener/configuration/agent-status-hooks-setting'
|
||||
|
||||
let closeOrcadObservability = (): Promise<void> => Promise.resolve()
|
||||
@@ -44,7 +44,12 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
|
||||
process.env.ORCA_USER_DATA_PATH = userDataPath
|
||||
const { readiness, instance, stop } = await startOrcadWithHost(
|
||||
userDataPath,
|
||||
(registerCleanup) => startOrcadRuntime(options, registerCleanup),
|
||||
(registerCleanup) =>
|
||||
activateBackendServices({
|
||||
supervised: process.env[ORCA_SERVE_SUPERVISED_ENV] === '1',
|
||||
registerCleanup,
|
||||
activate: (cleanup) => activateOrcadRuntime(options, cleanup)
|
||||
}),
|
||||
async () => {
|
||||
try {
|
||||
runOrcadQuitHandlers()
|
||||
@@ -60,16 +65,11 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
|
||||
return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop }
|
||||
}
|
||||
|
||||
async function startOrcadRuntime(
|
||||
async function activateOrcadRuntime(
|
||||
options: OrcadOptions,
|
||||
registerCleanup: (cleanup: OrcadRuntimeCleanup) => void
|
||||
): Promise<Pick<OrcadHandle, 'readiness'>> {
|
||||
const { time: timed, report: reportStartupTiming } = createOrcadStartupTiming()
|
||||
// Read before startOrcadDaemon consumes the supervised flag; only a supervised server exits on a long stall.
|
||||
const eventLoopWatchdog = startOrcadEventLoopWatchdog({
|
||||
supervised: process.env[ORCA_SERVE_SUPERVISED_ENV] === '1'
|
||||
})
|
||||
registerCleanup(() => eventLoopWatchdog?.stop())
|
||||
// Overlap login-shell probing with imports and store startup.
|
||||
const loginEnvironment = adoptWslHostLoginEnvironmentLogged()
|
||||
const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { ORCAD_CLI_PACKAGE_FILENAME } from '../../shared/orcad-artifacts'
|
||||
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
|
||||
import { createNodeServiceEnvironment } from '../backend/node-service-environment'
|
||||
import { setSecretStore } from '../../shared/secret-store'
|
||||
import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths'
|
||||
import { getOrcadCliLauncherPath } from './orcad-cli-launcher'
|
||||
@@ -17,33 +18,15 @@ export function runOrcadQuitHandlers(): void {
|
||||
}
|
||||
|
||||
function createNodeAppEnvironment(): AppEnvironment {
|
||||
const quitHandlers: (() => void)[] = []
|
||||
// Synchronous quit handlers must not preempt the signal handler’s awaited runtime/browser teardown.
|
||||
quitRegisteredHandlers = (): void => {
|
||||
const errors: unknown[] = []
|
||||
for (const handler of quitHandlers.splice(0)) {
|
||||
try {
|
||||
handler()
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
// Failed handlers may leave writers running and keep the instance lock held.
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, 'orcad_quit_handlers_failed')
|
||||
}
|
||||
}
|
||||
return {
|
||||
const host = createNodeServiceEnvironment({
|
||||
getPath: resolveOrcadPath,
|
||||
getAppPath: () => resolveOrcadInstallRoot(),
|
||||
getVersion: () => process.env.ORCA_VERSION ?? '0.0.0-orcad',
|
||||
// Packaged gates production HTTPS/CLI/PATH policy; layout callers must inspect asar separately.
|
||||
isPackaged: () => true,
|
||||
getCliLauncherPath: getOrcadCliLauncherPath,
|
||||
onWillQuit: (handler) => quitHandlers.push(handler),
|
||||
exit: (code = 0) => process.exit(code),
|
||||
getAppMetrics: () => []
|
||||
}
|
||||
getCliLauncherPath: getOrcadCliLauncherPath
|
||||
})
|
||||
quitRegisteredHandlers = host.runQuitHandlers
|
||||
return host.environment
|
||||
}
|
||||
|
||||
let orcadSecretStore: OrcadSecretStore | null = null
|
||||
|
||||
@@ -1,37 +1,4 @@
|
||||
export type OrcadRuntimeCleanup = (state: { failed: boolean }) => void | Promise<void>
|
||||
|
||||
/** Failed teardown retains profile ownership: surviving writers cannot share a root with a new host. */
|
||||
export class OrcadRuntimeLifetime {
|
||||
private readonly cleanups: OrcadRuntimeCleanup[] = []
|
||||
private stopping?: Promise<void>
|
||||
|
||||
constructor(private readonly release: () => void = () => {}) {}
|
||||
|
||||
add(cleanup: OrcadRuntimeCleanup): void {
|
||||
if (this.stopping) {
|
||||
throw new Error('orcad_runtime_lifetime_stopping')
|
||||
}
|
||||
this.cleanups.push(cleanup)
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
this.stopping ??= Promise.resolve().then(async () => {
|
||||
const errors: unknown[] = []
|
||||
for (const cleanup of this.cleanups.splice(0).toReversed()) {
|
||||
try {
|
||||
await cleanup({ failed: errors.length > 0 })
|
||||
} catch (error) {
|
||||
errors.push(error)
|
||||
}
|
||||
}
|
||||
if (errors.length === 1) {
|
||||
throw errors[0]
|
||||
}
|
||||
if (errors.length > 1) {
|
||||
throw new AggregateError(errors, 'orcad_runtime_cleanup_failed')
|
||||
}
|
||||
this.release()
|
||||
})
|
||||
return this.stopping
|
||||
}
|
||||
}
|
||||
export {
|
||||
BackendServiceLifetime as OrcadRuntimeLifetime,
|
||||
type BackendServiceCleanup as OrcadRuntimeCleanup
|
||||
} from '../backend/service-composition'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
vi.mock('../backend-desktop/backend-supervisor')
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { runMainProcessPreflight } from './main-process-preflight'
|
||||
|
||||
@@ -181,9 +182,6 @@ vi.mock('../persistence/profile-state/offline-access/profile-state-active-locati
|
||||
getActiveProfileStateLocation: () => ({ profileId: 'active-profile' })
|
||||
}))
|
||||
vi.mock('../stats/collector')
|
||||
vi.mock('../claude-usage/store')
|
||||
vi.mock('../codex-usage/store')
|
||||
vi.mock('../opencode-usage/store')
|
||||
vi.mock('../browser/document-preview/doc-preview-protocol')
|
||||
vi.mock('../crash-reporting/capture/crashpad-capture')
|
||||
vi.mock('../crash-reporting/reports/crash-report-store')
|
||||
|
||||
@@ -31,13 +31,7 @@ const MODEL_CATALOG_ACCOUNT_SETTINGS = [
|
||||
|
||||
export function initializeMainProcessAccountServices(): void {
|
||||
const store = state.store
|
||||
if (
|
||||
!store ||
|
||||
!state.claudeUsage ||
|
||||
!state.codexUsage ||
|
||||
!state.openCodeUsage ||
|
||||
!state.museUsage
|
||||
) {
|
||||
if (!store || !state.claudeUsage || !state.codexUsage) {
|
||||
throw new Error('Usage stores must be initialized before account services')
|
||||
}
|
||||
state.codexSessionMigration = createCodexSessionMigrationScheduler({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BackendSupervisor } from '../backend-desktop/backend-supervisor'
|
||||
import { app, powerMonitor } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { AgentAwakeService } from '../power/agent-awake-service'
|
||||
@@ -20,12 +21,8 @@ import { recoverPendingSkillTransactions } from '../skills/transaction-state/ski
|
||||
import { initCohortClassifier } from '../telemetry/cohort-classifier'
|
||||
import { initOnboardingCohortClassifier } from '../telemetry/onboarding-cohort-classifier'
|
||||
import { StatsCollector } from '../stats/collector'
|
||||
import { feedAgentSessionStats } from '../stats/agent-session-stats-feed'
|
||||
import { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import { CodexUsageStore } from '../codex-usage/store'
|
||||
import { OpenCodeUsageStore } from '../opencode-usage/store'
|
||||
import { MuseUsageStore } from '../muse-usage/store'
|
||||
import { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history'
|
||||
import { feedAgentSessionStats } from '../stats/agent-session-stats-feed'
|
||||
import { installRepoMaintenanceIdleGate } from '../workspaces/catalog/repo-maintenance-idle-gate'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import { runAfterFirstWindowShown } from './first-window-deferral'
|
||||
@@ -152,8 +149,7 @@ export function initializeMainProcessObservers(): void {
|
||||
join(getCanonicalUserDataPath(), 'orca-usage-worktree-history.json')
|
||||
)
|
||||
state.usageWorktreeHistory = usageWorktrees
|
||||
state.claudeUsage = new ClaudeUsageStore(usageWorktrees)
|
||||
state.codexUsage = new CodexUsageStore(usageWorktrees)
|
||||
state.openCodeUsage = new OpenCodeUsageStore(usageWorktrees)
|
||||
state.museUsage = new MuseUsageStore(usageWorktrees)
|
||||
state.backend = new BackendSupervisor(usageWorktrees)
|
||||
state.claudeUsage = state.backend.automationClient('claudeUsage')
|
||||
state.codexUsage = state.backend.automationClient('codexUsage')
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { captureBackendBootstrap } from '../backend-desktop/backend-supervisor'
|
||||
import { antigravityHookService } from '../antigravity/hook-service'
|
||||
import { getRelocatedDaemonHost } from '../daemon/executable-host/windows/daemon-host-relocation'
|
||||
import { app, ipcMain, powerMonitor, session } from 'electron'
|
||||
@@ -80,10 +81,6 @@ import '../orca-profiles/profile-index-store'
|
||||
import { getProfileUserDataPath, initOrcaProfilePaths } from '../orca-profiles/profile-storage-paths'
|
||||
import { recoverPendingProfileProjectMoves } from '../orca-profiles/project-transfer/profile-project-move-intent'
|
||||
import { initStatsPath } from '../stats/collector'
|
||||
import { initClaudeUsagePath } from '../claude-usage/store'
|
||||
import { initCodexUsagePath } from '../codex-usage/store'
|
||||
import { initOpenCodeUsagePath } from '../opencode-usage/store'
|
||||
import { initMuseUsagePath } from '../muse-usage/store'
|
||||
import { registerDocPreviewSchemePrivileges } from '../browser/document-preview/doc-preview-protocol'
|
||||
import { PRIVILEGED_CUSTOM_SCHEMES } from './privileged-custom-schemes'
|
||||
import { startCrashpadCapture } from '../crash-reporting/capture/crashpad-capture'
|
||||
@@ -304,10 +301,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
)
|
||||
// Capture stats/usage paths before app naming can shift userData.
|
||||
initStatsPath()
|
||||
initClaudeUsagePath()
|
||||
initCodexUsagePath()
|
||||
initOpenCodeUsagePath()
|
||||
initMuseUsagePath()
|
||||
captureBackendBootstrap()
|
||||
// Register preview privileges before ready freezes the table or webviews lose fetch/secure-origin access.
|
||||
registerDocPreviewSchemePrivileges(PRIVILEGED_CUSTOM_SCHEMES)
|
||||
// Install Crashpad before first renderer; earlier CHECK failures have exit-code evidence only.
|
||||
|
||||
@@ -235,12 +235,8 @@ function installWillQuitHandler(): void {
|
||||
const sshShutdown = Promise.resolve(finalSaves).then((saves) => saves.sshShutdown)
|
||||
const storeFlush = Promise.resolve(finalSaves).then((saves) => saves.storeFlush)
|
||||
const watcherShutdown = Promise.resolve(finalSaves).then((saves) => saves.watcherShutdown)
|
||||
// Capture queued usage writes before awaiting teardown or quit can lose the last enabled/scan snapshot.
|
||||
const usageCacheFlush = Promise.all([
|
||||
state.claudeUsage?.flush(),
|
||||
state.codexUsage?.flush(),
|
||||
state.openCodeUsage?.flush(),
|
||||
state.museUsage?.flush(),
|
||||
state.backend?.stop(),
|
||||
state.usageWorktreeHistory?.flush()
|
||||
]).then(() => {})
|
||||
disposeDesktopBrowserBridge()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { BackendSupervisor } from '../backend-desktop/backend-supervisor'
|
||||
import type { AutomationUsageClient } from '../../shared/backend-service-contract'
|
||||
import { type BrowserWindow, type Tray, app } from 'electron'
|
||||
import type { Store } from '../persistence/loading-store/store'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import type { OpenCodeUsageStore } from '../opencode-usage/store'
|
||||
import type { MuseUsageStore } from '../muse-usage/store'
|
||||
import type { UsageWorktreeHistory } from '../usage/worktree-attribution/usage-worktree-history'
|
||||
import type { CodexAccountService } from '../codex-accounts/service'
|
||||
import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service'
|
||||
@@ -67,6 +65,14 @@ function createInitialProfileStateAdmission(): ProfileStateRuntimeAdmission | un
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createInitialBackendServices(): {
|
||||
backend: BackendSupervisor | null
|
||||
claudeUsage: AutomationUsageClient | null
|
||||
codexUsage: AutomationUsageClient | null
|
||||
} {
|
||||
return { backend: null, claudeUsage: null, codexUsage: null }
|
||||
}
|
||||
|
||||
/** Mutable composition-root state shared by startup, window, serve, and quit phases. */
|
||||
const desktopRelayStatus: MobileRelayStatusDetail = { status: 'offline' }
|
||||
|
||||
@@ -80,12 +86,8 @@ export const mainProcessState = {
|
||||
profileStateStartup: createInitialProfileStateStartup(),
|
||||
profileStateAdmission: createInitialProfileStateAdmission(),
|
||||
stats: null as StatsCollector | null,
|
||||
claudeUsage: null as ClaudeUsageStore | null,
|
||||
codexUsage: null as CodexUsageStore | null,
|
||||
openCodeUsage: null as OpenCodeUsageStore | null,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the store type assigned by main-process-observers.
|
||||
museUsage: null as MuseUsageStore | null,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot to the history assigned by main-process-observers.
|
||||
...createInitialBackendServices(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: widens the null slot assigned by main-process-observers.
|
||||
usageWorktreeHistory: null as UsageWorktreeHistory | null,
|
||||
codexAccounts: null as CodexAccountService | null,
|
||||
codexRuntimeHome: null as CodexRuntimeHomeService | null,
|
||||
|
||||
@@ -55,10 +55,6 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
|
||||
store: state.store,
|
||||
runtime: state.runtime,
|
||||
stats: state.stats,
|
||||
claudeUsage: state.claudeUsage,
|
||||
codexUsage: state.codexUsage,
|
||||
openCodeUsage: state.openCodeUsage,
|
||||
museUsage: state.museUsage,
|
||||
rateLimits: state.rateLimits,
|
||||
automations: state.automations,
|
||||
codexAccounts: state.codexAccounts,
|
||||
|
||||
@@ -34,10 +34,6 @@ export function attachMainWindowCoreServices(
|
||||
const store = state.store
|
||||
const runtime = state.runtime
|
||||
const stats = state.stats
|
||||
const claudeUsage = state.claudeUsage
|
||||
const codexUsage = state.codexUsage
|
||||
const openCodeUsage = state.openCodeUsage
|
||||
const museUsage = state.museUsage
|
||||
const codexAccounts = state.codexAccounts
|
||||
const claudeAccounts = state.claudeAccounts
|
||||
const rateLimits = state.rateLimits
|
||||
@@ -49,10 +45,6 @@ export function attachMainWindowCoreServices(
|
||||
!store ||
|
||||
!runtime ||
|
||||
!stats ||
|
||||
!claudeUsage ||
|
||||
!codexUsage ||
|
||||
!openCodeUsage ||
|
||||
!museUsage ||
|
||||
!codexAccounts ||
|
||||
!claudeAccounts ||
|
||||
!rateLimits ||
|
||||
@@ -69,10 +61,6 @@ export function attachMainWindowCoreServices(
|
||||
store,
|
||||
runtime,
|
||||
stats,
|
||||
claudeUsage,
|
||||
codexUsage,
|
||||
openCodeUsage,
|
||||
museUsage,
|
||||
codexAccounts,
|
||||
claudeAccounts,
|
||||
rateLimits,
|
||||
|
||||
@@ -3,10 +3,6 @@ const MAIN_WINDOW_SERVICE_REQUIREMENTS = [
|
||||
['store', 'Store must be initialized before opening the main window'],
|
||||
['runtime', 'Runtime must be initialized before opening the main window'],
|
||||
['stats', 'Stats must be initialized before opening the main window'],
|
||||
['claudeUsage', 'Claude usage store must be initialized before opening the main window'],
|
||||
['codexUsage', 'Codex usage store must be initialized before opening the main window'],
|
||||
['openCodeUsage', 'OpenCode usage store must be initialized before opening the main window'],
|
||||
['museUsage', 'Muse usage store must be initialized before opening the main window'],
|
||||
['rateLimits', 'Rate limit service must be initialized before opening the main window'],
|
||||
['automations', 'Automation service must be initialized before opening the main window'],
|
||||
['codexAccounts', 'Codex account service must be initialized before opening the main window'],
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { UsageStoreHost, UsageTelemetryHost } from '../usage-store-host'
|
||||
import { createTestUsageStoreHost } from '../usage-store-host-test-fixture'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { UsageScanWorktreeRef } from './usage-provider-contract'
|
||||
import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle'
|
||||
import {
|
||||
splitUsageCacheFile,
|
||||
type UsageCacheSplitRequest,
|
||||
type UsageCacheSplitResult,
|
||||
type UsageSourceCacheRef
|
||||
} from '../cache/usage-source-cache-file'
|
||||
|
||||
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, 'sessions' | 'dailyAggregates'>
|
||||
type TestScan = (
|
||||
worktrees: UsageScanWorktreeRef[],
|
||||
sourceCache: UsageSourceCacheRef
|
||||
) => Promise<TestScanResult>
|
||||
type TestSplit = (request: UsageCacheSplitRequest) => Promise<UsageCacheSplitResult>
|
||||
|
||||
const NOW = Date.parse('2026-04-10T16:00:00.000Z')
|
||||
|
||||
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 { sessions: [], dailyAggregates: [] }
|
||||
}
|
||||
|
||||
class TestUsageStore extends UsageProviderStoreLifecycle<
|
||||
'processedSources',
|
||||
TestState,
|
||||
'hasAnyTestData'
|
||||
> {
|
||||
constructor(
|
||||
cacheFile: string,
|
||||
scan: TestScan,
|
||||
splitCacheFile: TestSplit,
|
||||
capture: ReturnType<typeof vi.fn<UsageTelemetryHost['capture']>>,
|
||||
readCatalog?: UsageStoreHost['readCatalog'],
|
||||
isEnabled?: UsageTelemetryHost['isEnabled'],
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
super(
|
||||
{
|
||||
...createTestUsageStoreHost({
|
||||
userDataPath: dirname(cacheFile),
|
||||
telemetryEnabled: true,
|
||||
capture,
|
||||
signal
|
||||
}),
|
||||
...(readCatalog ? { readCatalog } : {}),
|
||||
...(isEnabled ? { telemetry: { isEnabled, capture } } : {})
|
||||
},
|
||||
{
|
||||
tokenUsage: {
|
||||
provider: 'claude',
|
||||
selectSessions: (state) =>
|
||||
state.sessions.map((session) => ({
|
||||
providerSessionId: session.id,
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
cached_input_tokens: 3,
|
||||
cache_write_input_tokens: 1
|
||||
}))
|
||||
},
|
||||
logTag: '[test-usage]',
|
||||
resolveCacheFile: () => cacheFile,
|
||||
createDefaultState: makeState,
|
||||
normalizeState: (state) => state,
|
||||
sourceKey: 'processedSources',
|
||||
dataPresenceKey: 'hasAnyTestData',
|
||||
scan,
|
||||
splitCacheFile
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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>>
|
||||
let split: ReturnType<typeof vi.fn<TestSplit>>
|
||||
let capture: ReturnType<typeof vi.fn<UsageTelemetryHost['capture']>>
|
||||
|
||||
beforeEach(() => {
|
||||
capture = vi.fn<UsageTelemetryHost['capture']>(async () => true)
|
||||
tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-'))
|
||||
stores = []
|
||||
scan = vi.fn<TestScan>().mockResolvedValue(emptyScanResult())
|
||||
// Run the real cache split in-process; the worker only wraps it in a thread.
|
||||
split = vi.fn<TestSplit>(splitUsageCacheFile)
|
||||
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.each([100, 1000])('uses one consistent catalog per refresh at %i worktrees', async (count) => {
|
||||
const worktrees = Array.from({ length: count }, (_, i) => ({
|
||||
worktreeId: `repo::/work/${i}`,
|
||||
path: `/work/${i}`,
|
||||
displayName: `Work ${i}`
|
||||
}))
|
||||
const readCatalog = vi.fn<UsageStoreHost['readCatalog']>().mockResolvedValue({
|
||||
repoIds: ['repo'],
|
||||
worktreesByRepo: [['repo', worktrees]]
|
||||
})
|
||||
const store = new TestUsageStore(
|
||||
join(tempDirectory, 'catalog.json'),
|
||||
scan,
|
||||
split,
|
||||
capture,
|
||||
readCatalog
|
||||
)
|
||||
stores.push(store)
|
||||
await store.whenLoaded()
|
||||
await store.refresh(true)
|
||||
expect(readCatalog).not.toHaveBeenCalled()
|
||||
await store.setEnabled(true)
|
||||
const blocked = Promise.withResolvers<TestScanResult>()
|
||||
scan.mockReturnValueOnce(blocked.promise)
|
||||
const first = store.refresh(true)
|
||||
const second = store.refresh(true)
|
||||
await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1))
|
||||
expect(readCatalog).toHaveBeenCalledTimes(2)
|
||||
expect(scan.mock.calls[0][0]).toHaveLength(count)
|
||||
expect(scan.mock.calls[0][0][0]).toMatchObject({ path: '/work/0' })
|
||||
blocked.resolve({ sessions: [{ id: 'catalog-result' }], dailyAggregates: [] })
|
||||
await Promise.all([first, second])
|
||||
expect(store.getState().sessions).toEqual([{ id: 'catalog-result' }])
|
||||
readCatalog.mockClear()
|
||||
await store.refresh()
|
||||
expect(readCatalog).toHaveBeenCalledTimes(1)
|
||||
expect(scan).toHaveBeenCalledTimes(1)
|
||||
store.stopScanAdmission()
|
||||
readCatalog.mockClear()
|
||||
await store.refresh(true)
|
||||
expect(readCatalog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a successful scan when the first async telemetry consent request rejects', async () => {
|
||||
const cacheFile = join(tempDirectory, 'consent-rejection.json')
|
||||
const consent = vi
|
||||
.fn<UsageTelemetryHost['isEnabled']>()
|
||||
.mockRejectedValue(new Error('No RPC credit'))
|
||||
const store = new TestUsageStore(cacheFile, scan, split, capture, undefined, consent)
|
||||
stores.push(store)
|
||||
await store.setEnabled(true)
|
||||
scan.mockResolvedValueOnce({
|
||||
sessions: [{ id: 'fresh' }],
|
||||
dailyAggregates: [{ day: '2026-04-10' }]
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await expect(store.refresh(true)).resolves.toMatchObject({
|
||||
isScanning: false,
|
||||
lastScanCompletedAt: NOW,
|
||||
lastScanError: null,
|
||||
hasAnyTestData: true
|
||||
})
|
||||
await store.flush()
|
||||
expect(store.getState().sessions).toEqual([{ id: 'fresh' }])
|
||||
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
|
||||
sessions: [{ id: 'fresh' }],
|
||||
dailyAggregates: [{ day: '2026-04-10' }],
|
||||
scanState: { lastScanCompletedAt: NOW, lastScanError: null }
|
||||
})
|
||||
expect(consent).toHaveBeenCalledTimes(1)
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops admission without persisting a host catalog abort as a scan failure', async () => {
|
||||
const cacheFile = join(tempDirectory, 'aborted-catalog.json')
|
||||
const abort = new AbortController()
|
||||
const readCatalog = vi.fn<UsageStoreHost['readCatalog']>().mockResolvedValue({
|
||||
repoIds: ['repo-1'],
|
||||
worktreesByRepo: [
|
||||
['repo-1', [{ worktreeId: 'repo-1::/workspace/a', path: '/workspace/a', displayName: 'A' }]]
|
||||
]
|
||||
})
|
||||
const store = new TestUsageStore(
|
||||
cacheFile,
|
||||
scan,
|
||||
split,
|
||||
capture,
|
||||
readCatalog,
|
||||
undefined,
|
||||
abort.signal
|
||||
)
|
||||
stores.push(store)
|
||||
await store.setEnabled(true)
|
||||
scan.mockResolvedValueOnce({ sessions: [{ id: 'scan-result' }], dailyAggregates: [] })
|
||||
await store.refresh(true)
|
||||
expect(scan.mock.calls[0]?.[0]).toEqual([
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/a',
|
||||
path: '/workspace/a',
|
||||
displayName: 'A'
|
||||
}
|
||||
])
|
||||
const catalog = Promise.withResolvers<Awaited<ReturnType<UsageStoreHost['readCatalog']>>>()
|
||||
readCatalog.mockReturnValueOnce(catalog.promise)
|
||||
const refresh = store.refresh(true)
|
||||
abort.abort(new Error('Backend stopping'))
|
||||
catalog.resolve({ repoIds: [], worktreesByRepo: [] })
|
||||
await expect(refresh).rejects.toThrow('Backend stopping')
|
||||
store.stopScanAdmission()
|
||||
await store.refresh(true)
|
||||
await store.flush()
|
||||
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
|
||||
sessions: [{ id: 'scan-result' }],
|
||||
scanState: { enabled: true, lastScanError: null }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ waitingOn: 'catalog', sessions: [{ id: 'prior' }], completedAt: 12_000 },
|
||||
{ waitingOn: 'scanner', sessions: [{ id: 'fresh' }], completedAt: NOW }
|
||||
])(
|
||||
'preserves disable while waiting on $waitingOn',
|
||||
async ({ waitingOn, sessions, completedAt }) => {
|
||||
const cacheFile = join(tempDirectory, `disable-${waitingOn}.json`)
|
||||
const entered = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const readCatalog = async () => {
|
||||
if (waitingOn === 'catalog') {
|
||||
entered.resolve()
|
||||
await release.promise
|
||||
}
|
||||
return { repoIds: [], worktreesByRepo: [] }
|
||||
}
|
||||
scan.mockImplementationOnce(async () => {
|
||||
if (waitingOn === 'scanner') {
|
||||
entered.resolve()
|
||||
await release.promise
|
||||
}
|
||||
return { sessions: [{ id: 'fresh' }], dailyAggregates: [] }
|
||||
})
|
||||
const store = new TestUsageStore(cacheFile, scan, split, capture, readCatalog)
|
||||
stores.push(store)
|
||||
await store.whenLoaded()
|
||||
store.replaceState(
|
||||
makeState({
|
||||
sessions: [{ id: 'prior' }],
|
||||
scanState: { enabled: true, lastScanCompletedAt: 12_000 }
|
||||
})
|
||||
)
|
||||
const refresh = store.refresh(true)
|
||||
await entered.promise
|
||||
await store.setEnabled(false)
|
||||
release.resolve()
|
||||
await expect(refresh).resolves.toMatchObject({
|
||||
enabled: false,
|
||||
isScanning: false,
|
||||
lastScanCompletedAt: completedAt,
|
||||
lastScanError: null
|
||||
})
|
||||
expect(store.getState().sessions).toEqual(sessions)
|
||||
await store.flush()
|
||||
expect(JSON.parse(readFileSync(cacheFile, 'utf8'))).toMatchObject({
|
||||
sessions,
|
||||
scanState: { enabled: false, lastScanCompletedAt: completedAt, lastScanError: null }
|
||||
})
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,19 +1,13 @@
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { AgentTokenUsageReporter } from '../reporting/token-usage/agent-token-usage-reporter'
|
||||
|
||||
import { isTelemetryEnabled } from '../../telemetry/client'
|
||||
import { join, parse } from 'node:path'
|
||||
import {
|
||||
AnalyticsSessionIdStore,
|
||||
type AnalyticsSessionId
|
||||
} from '../reporting/token-usage/analytics-session-id-store'
|
||||
import type { UsageStoreHost } from '../usage-store-host'
|
||||
import { UsageCacheSnapshotWriter } from '../cache/usage-cache-snapshot-writer'
|
||||
import {
|
||||
loadKnownUsageWorktreesByRepo,
|
||||
type UsageWorktreeSource
|
||||
} from '../worktree-attribution/usage-worktree-metadata'
|
||||
|
||||
import {
|
||||
createWorktreeRefs,
|
||||
getUsageWorktreeFingerprint
|
||||
@@ -40,20 +34,21 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
private readonly loaded: Promise<void>
|
||||
private readonly schemaVersion: number
|
||||
private scanPromise: Promise<void> | null = null
|
||||
private stopping = false
|
||||
private tokenReporter: AgentTokenUsageReporter | null = null
|
||||
private analyticsSessionIds: AnalyticsSessionIdStore | null = null
|
||||
private readonly writer: UsageCacheSnapshotWriter
|
||||
private readonly inlineCache = new UsageInlineCachePreservation()
|
||||
|
||||
constructor(
|
||||
private readonly store: UsageWorktreeSource,
|
||||
private readonly host: UsageStoreHost,
|
||||
private readonly config: UsageProviderStoreLifecycleConfig<SourceKey, State, DataPresenceKey>
|
||||
) {
|
||||
this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile)
|
||||
const defaults = config.createDefaultState()
|
||||
this.schemaVersion = defaults.schemaVersion
|
||||
this.state = defaults
|
||||
this.loaded = Promise.all([this.load(), store.whenLoaded()]).then(() => {})
|
||||
this.loaded = this.load()
|
||||
}
|
||||
|
||||
/** Resolves once persisted state is in memory; synchronous readers must wait for it first. */
|
||||
@@ -82,6 +77,14 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
}
|
||||
|
||||
/** Await queued cache writes so quit does not drop the final snapshot. */
|
||||
stopScanAdmission(): void {
|
||||
this.stopping = true
|
||||
}
|
||||
|
||||
async settleScansForShutdown(): Promise<void> {
|
||||
await this.scanPromise
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.loaded
|
||||
await this.inlineCache.settle()
|
||||
@@ -98,17 +101,22 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
|
||||
async refresh(force = false): Promise<PublicUsageProviderScanState<DataPresenceKey>> {
|
||||
await this.loaded
|
||||
if (!this.state.scanState.enabled) {
|
||||
if (this.stopping || !this.state.scanState.enabled) {
|
||||
return this.getScanState()
|
||||
}
|
||||
const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint()
|
||||
const catalog = await this.host.readCatalog()
|
||||
this.host.signal.throwIfAborted()
|
||||
const currentWorktreeFingerprint = getUsageWorktreeFingerprint(new Map(catalog.worktreesByRepo))
|
||||
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()
|
||||
if (this.stopping || !this.state.scanState.enabled) {
|
||||
return this.getScanState()
|
||||
}
|
||||
await this.runScan(catalog)
|
||||
return this.getScanState()
|
||||
}
|
||||
|
||||
@@ -219,7 +227,9 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
})
|
||||
}
|
||||
|
||||
private async runScan(): Promise<void> {
|
||||
private async runScan(
|
||||
catalog: Awaited<ReturnType<UsageStoreHost['readCatalog']>>
|
||||
): Promise<void> {
|
||||
if (this.scanPromise) {
|
||||
await this.scanPromise
|
||||
return
|
||||
@@ -231,15 +241,22 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
// 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 { repoIds, worktreesByRepo: entries } = catalog
|
||||
this.host.signal.throwIfAborted()
|
||||
const worktreesByRepo = new Map(entries)
|
||||
const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo)
|
||||
const result = await this.config.scan(createWorktreeRefs(repos, worktreesByRepo), {
|
||||
path: usageSourceCachePath(this.config.resolveCacheFile()),
|
||||
schemaVersion: this.schemaVersion,
|
||||
worktreeFingerprint,
|
||||
reuse: this.state.worktreeFingerprint === worktreeFingerprint
|
||||
})
|
||||
const result = await this.config.scan(
|
||||
createWorktreeRefs(
|
||||
repoIds.map((id) => ({ id })),
|
||||
worktreesByRepo
|
||||
),
|
||||
{
|
||||
path: usageSourceCachePath(this.config.resolveCacheFile()),
|
||||
schemaVersion: this.schemaVersion,
|
||||
worktreeFingerprint,
|
||||
reuse: this.state.worktreeFingerprint === worktreeFingerprint
|
||||
}
|
||||
)
|
||||
this.state.sessions = result.sessions
|
||||
this.state.dailyAggregates = result.dailyAggregates
|
||||
this.state.worktreeFingerprint = worktreeFingerprint
|
||||
@@ -251,6 +268,9 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
await this.writeToDisk().catch(() => {})
|
||||
await this.reportTokenUsage()
|
||||
} catch (error) {
|
||||
if (this.stopping || this.host.signal.aborted) {
|
||||
return
|
||||
}
|
||||
this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error)
|
||||
await this.writeToDisk().catch(() => {})
|
||||
} finally {
|
||||
@@ -263,16 +283,20 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
|
||||
private async reportTokenUsage(): Promise<void> {
|
||||
const config = this.config.tokenUsage
|
||||
if (!config || !isTelemetryEnabled() || !this.state.scanState.enabled) {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!(await this.host.telemetry.isEnabled()) || !this.state.scanState.enabled) {
|
||||
return
|
||||
}
|
||||
if (!this.tokenReporter) {
|
||||
const { dir, name } = parse(this.config.resolveCacheFile())
|
||||
this.tokenReporter = new AgentTokenUsageReporter(
|
||||
join(dir, `${name}-token-usage.json`),
|
||||
config.provider,
|
||||
(ids) => this.getAnalyticsSessionIds(ids)
|
||||
(ids) => this.getAnalyticsSessionIds(ids),
|
||||
this.host.telemetry
|
||||
)
|
||||
}
|
||||
await this.tokenReporter.report(config.selectSessions(this.state))
|
||||
@@ -283,11 +307,6 @@ export abstract class UsageProviderStoreLifecycle<
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async getCurrentWorktreeFingerprint(): Promise<string> {
|
||||
const repos = this.store.getRepos()
|
||||
return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos))
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
agentTokenUsageSchema,
|
||||
type AgentTokenUsage
|
||||
} from '../../../../shared/telemetry-agent-token-usage-schema'
|
||||
import { isTelemetryEnabled, track } from '../../../telemetry/client'
|
||||
import type { UsageTelemetryHost } from '../../usage-store-host'
|
||||
import { UsageCacheSnapshotWriter } from '../../cache/usage-cache-snapshot-writer'
|
||||
import type { AgentTokenSession } from './agent-token-usage'
|
||||
|
||||
@@ -26,7 +26,8 @@ export class AgentTokenUsageReporter {
|
||||
constructor(
|
||||
private readonly file: string,
|
||||
private readonly provider: AgentTokenUsage['provider'],
|
||||
private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise<string[]>
|
||||
private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise<string[]>,
|
||||
private readonly telemetry: UsageTelemetryHost
|
||||
) {
|
||||
this.writer = new UsageCacheSnapshotWriter('[agent-token-usage]', () => file)
|
||||
}
|
||||
@@ -43,7 +44,7 @@ export class AgentTokenUsageReporter {
|
||||
}
|
||||
|
||||
private async publish(sessions: AgentTokenSession[]): Promise<void> {
|
||||
if (!isTelemetryEnabled()) {
|
||||
if (!(await this.telemetry.isEnabled())) {
|
||||
return
|
||||
}
|
||||
const rows = sessions.flatMap(({ providerSessionId, ...counts }) => {
|
||||
@@ -51,7 +52,7 @@ export class AgentTokenUsageReporter {
|
||||
return parsed.success ? [{ providerSessionId, counts: parsed.data }] : []
|
||||
})
|
||||
const ids = await this.getSessionIds(rows.map((row) => row.providerSessionId))
|
||||
if (!isTelemetryEnabled()) {
|
||||
if (!(await this.telemetry.isEnabled())) {
|
||||
return
|
||||
}
|
||||
const previous = this.snapshots ?? (await this.load())
|
||||
@@ -91,7 +92,7 @@ export class AgentTokenUsageReporter {
|
||||
if (this.acceptedRevisions.get(snapshot.analytics_session_id) === snapshot.revision) {
|
||||
continue
|
||||
}
|
||||
if (!track('agent_token_usage', snapshot)) {
|
||||
if (!(await this.telemetry.capture(snapshot))) {
|
||||
break
|
||||
}
|
||||
// Queue acceptance only deduplicates this process; restart must retry undelivered snapshots.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WorkerThreadRequestQueue } from '../../worker-threads/worker-thread-request-queue'
|
||||
import { UsageScanRequestScheduler } from '../usage-scan-request-scheduler'
|
||||
import type { WorkerThreadFactory } from '../../worker-threads/worker-thread-transport'
|
||||
import type { ClaudeUsageDailyAggregate, ClaudeUsageSession } from '../../claude-usage/types'
|
||||
import type { CodexUsageDailyAggregate, CodexUsageSession } from '../../codex-usage/types'
|
||||
@@ -7,9 +7,7 @@ import type { MuseUsageDailyAggregate, MuseUsageSession } from '../../muse-usage
|
||||
import type { UsageScanWorktreeRef } from '../provider-lifecycle/usage-provider-contract'
|
||||
import type {
|
||||
UsageScanWorkerProviderId,
|
||||
UsageScanWorkerRequest,
|
||||
UsageScanWorkerRequestBody,
|
||||
UsageScanWorkerResponse,
|
||||
UsageScanWorkerScanBody,
|
||||
UsageScanWorkerValue
|
||||
} from './usage-scan-worker-protocol'
|
||||
@@ -18,10 +16,9 @@ import type {
|
||||
UsageCacheSplitResult,
|
||||
UsageSourceCacheRef
|
||||
} from '../cache/usage-source-cache-file'
|
||||
import { isUsageScanWorkerProgress } from './usage-scan-worker-protocol'
|
||||
import { createUsageScanWorkerTransport } from './usage-scan-worker-cache-cleanup'
|
||||
|
||||
// #20940: serialize provider scans with WorkerThreadRequestQueue; LazyWorkerThreadHost owns thread lifetime and the spawn module owns layout/singleton.
|
||||
// Provider-local ordering shares one worker; LazyWorkerThreadHost owns its lifetime.
|
||||
|
||||
// Cold corpora can exceed any total deadline; time only gaps between progress reports. A huge rollout is one gap, so keep the window generous.
|
||||
export const USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS = 10 * 60_000
|
||||
@@ -39,25 +36,26 @@ type ProviderScanResult<TSession, TDaily> = {
|
||||
|
||||
/** Unavailable, timed-out or crashed workers reject; stores retain their previous projection instead of parsing on main. */
|
||||
export class UsageScanWorkerClient {
|
||||
private readonly queue: WorkerThreadRequestQueue<UsageScanWorkerRequest, UsageScanWorkerResponse>
|
||||
private readonly queue: UsageScanRequestScheduler
|
||||
|
||||
constructor(options: { workerFactory: WorkerThreadFactory; log?: (message: string) => void }) {
|
||||
const log = options.log ?? ((message: string) => console.warn(message))
|
||||
this.queue = new WorkerThreadRequestQueue({
|
||||
this.queue = new UsageScanRequestScheduler({
|
||||
factory: () => createUsageScanWorkerTransport(options.workerFactory),
|
||||
idleTeardownMs: IDLE_TEARDOWN_MS,
|
||||
maxConsecutiveDeaths: MAX_CONSECUTIVE_DEATHS,
|
||||
createUnavailableError: (message) => new UsageScanWorkerUnavailableError(message),
|
||||
isProgress: isUsageScanWorkerProgress,
|
||||
describeTimeout: (timeoutMs) => `Usage scan worker reported no progress for ${timeoutMs}ms`,
|
||||
describeExit: (code) => `Usage scan worker exited with code ${code}`,
|
||||
describeCrashLoop: (lastError) => `Usage scan worker crashed repeatedly (${lastError})`,
|
||||
// Missing/resource-starved workers must fail closed; main-thread scanning would reintroduce event-loop blocking.
|
||||
onUnavailable: (err) =>
|
||||
log(`[usage-scan] worker unavailable; usage scans will report an error. ${message(err)}`)
|
||||
})
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.queue.dispose()
|
||||
await this.queue.retired()
|
||||
}
|
||||
|
||||
/** sourceCache identifies provider records that stay on the worker. */
|
||||
scan(body: UsageScanWorkerScanBody): Promise<UsageScanWorkerValue> {
|
||||
return this.dispatch(body)
|
||||
@@ -79,10 +77,7 @@ export class UsageScanWorkerClient {
|
||||
}
|
||||
|
||||
private async dispatch(body: UsageScanWorkerRequestBody): Promise<UsageScanWorkerValue> {
|
||||
const response = await this.queue.dispatch(
|
||||
(id) => ({ ...body, id }),
|
||||
USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS
|
||||
)
|
||||
const response = await this.queue.dispatch(body, USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { claudeProfileTranscriptDirs } from '../../claude-usage/transcript-file-discovery'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import { spawnWorkerThread } from '../../worker-threads/worker-thread-spawn'
|
||||
@@ -49,18 +48,15 @@ function getSharedClient(): UsageScanWorkerClient {
|
||||
|
||||
export function scanClaudeUsageFilesViaWorker(
|
||||
worktrees: UsageScanWorktreeRef[],
|
||||
sourceCache: UsageSourceCacheRef
|
||||
sourceCache: UsageSourceCacheRef,
|
||||
profileDirs: string[]
|
||||
): Promise<{
|
||||
sessions: ClaudeUsageSession[]
|
||||
dailyAggregates: ClaudeUsageDailyAggregate[]
|
||||
}> {
|
||||
return scanClaudeUsageOnWorker(
|
||||
(body) =>
|
||||
getSharedClient().scan(
|
||||
body.providerId === 'claude'
|
||||
? { ...body, profileDirs: claudeProfileTranscriptDirs() }
|
||||
: body
|
||||
),
|
||||
getSharedClient().scan(body.providerId === 'claude' ? { ...body, profileDirs } : body),
|
||||
worktrees,
|
||||
sourceCache
|
||||
)
|
||||
@@ -102,3 +98,9 @@ export function splitUsageCacheFileViaWorker(
|
||||
): Promise<UsageCacheSplitResult> {
|
||||
return getSharedClient().splitCacheFile(request)
|
||||
}
|
||||
|
||||
export async function stopUsageScanWorker(): Promise<void> {
|
||||
const client = sharedClient
|
||||
sharedClient = null
|
||||
await client?.dispose()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { Worker } from 'node:worker_threads'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
UsageScanWorkerClient,
|
||||
UsageScanWorkerUnavailableError,
|
||||
USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS
|
||||
} from './scan-worker/usage-scan-worker-client'
|
||||
import type {
|
||||
UsageScanWorkerProviderId,
|
||||
UsageScanWorkerRequest,
|
||||
UsageScanWorkerScanBody,
|
||||
UsageScanWorkerValue
|
||||
} from './scan-worker/usage-scan-worker-protocol'
|
||||
|
||||
class ScanWorker extends EventEmitter {
|
||||
posted: UsageScanWorkerRequest[] = []
|
||||
exit: Promise<number> = Promise.resolve(1)
|
||||
terminate = vi.fn(() => this.exit)
|
||||
unref(): void {}
|
||||
|
||||
postMessage(request: UsageScanWorkerRequest): void {
|
||||
this.posted.push(request)
|
||||
}
|
||||
|
||||
complete(index: number): void {
|
||||
const request = this.posted[index]
|
||||
const value: UsageScanWorkerValue =
|
||||
request.operation === 'scan'
|
||||
? { operation: 'scan', providerId: request.providerId, sessions: [], dailyAggregates: [] }
|
||||
: { operation: 'splitCacheFile', reportText: 'saved report', migrated: false }
|
||||
this.emit('message', { id: request.id, ok: true, value })
|
||||
}
|
||||
}
|
||||
|
||||
function body(providerId: UsageScanWorkerProviderId, population = 1): UsageScanWorkerScanBody {
|
||||
return {
|
||||
operation: 'scan',
|
||||
providerId,
|
||||
worktrees: Array.from({ length: population }, (_, i) => ({
|
||||
repoId: 'repo',
|
||||
worktreeId: `tree-${i}`,
|
||||
displayName: `tree-${i}`,
|
||||
path: `/tree-${i}`
|
||||
})),
|
||||
sourceCache: {
|
||||
path: `/${providerId}-sources.json`,
|
||||
schemaVersion: 1,
|
||||
worktreeFingerprint: '[]',
|
||||
reuse: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clients: UsageScanWorkerClient[] = []
|
||||
function fixture(): { client: UsageScanWorkerClient; workers: ScanWorker[] } {
|
||||
const workers: ScanWorker[] = []
|
||||
const client = new UsageScanWorkerClient({
|
||||
workerFactory: () => {
|
||||
const worker = new ScanWorker()
|
||||
workers.push(worker)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture implements every Worker member the host and transport use; no native Worker API is reached.
|
||||
return worker as unknown as Worker
|
||||
},
|
||||
log: () => {}
|
||||
})
|
||||
clients.push(client)
|
||||
return { client, workers }
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.splice(0).map((client) => client.dispose().catch(() => {})))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('usage scan scheduling through the production client', () => {
|
||||
it.each([100, 1000])(
|
||||
'admits only four providers at %i worktrees and routes unordered replies',
|
||||
async (population) => {
|
||||
const { client, workers } = fixture()
|
||||
const calls = [
|
||||
client.scan(body('claude', population)),
|
||||
client.scan(body('codex', population)),
|
||||
client.scan(body('opencode', population)),
|
||||
client.scan(body('muse', population))
|
||||
]
|
||||
const worker = workers[0]
|
||||
expect(worker.posted.map((request) => request.providerId)).toEqual([
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'muse'
|
||||
])
|
||||
worker.emit('message', { id: worker.posted[1].id, filesScanned: 1 })
|
||||
for (const index of [2, 0, 3, 1]) {
|
||||
worker.complete(index)
|
||||
}
|
||||
expect(await Promise.all(calls)).toEqual([
|
||||
{ operation: 'scan', providerId: 'claude', sessions: [], dailyAggregates: [] },
|
||||
{ operation: 'scan', providerId: 'codex', sessions: [], dailyAggregates: [] },
|
||||
{ operation: 'scan', providerId: 'opencode', sessions: [], dailyAggregates: [] },
|
||||
{ operation: 'scan', providerId: 'muse', sessions: [], dailyAggregates: [] }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps same-provider FIFO and makes a split an exclusive barrier without overtaking', async () => {
|
||||
const { client, workers } = fixture()
|
||||
const first = client.scan(body('claude'))
|
||||
const second = client.scan(body('claude'))
|
||||
const codex = client.scan(body('codex'))
|
||||
const split = client.splitCacheFile({ cacheFile: '/claude.json', sourceKey: 'processedFiles' })
|
||||
const muse = client.scan(body('muse'))
|
||||
const worker = workers[0]
|
||||
expect(worker.posted.map((request) => request.providerId)).toEqual(['claude', 'codex'])
|
||||
worker.complete(0)
|
||||
expect(worker.posted.map((request) => request.providerId)).toEqual([
|
||||
'claude',
|
||||
'codex',
|
||||
'claude'
|
||||
])
|
||||
worker.complete(2)
|
||||
expect(worker.posted).toHaveLength(3)
|
||||
worker.complete(1)
|
||||
expect(worker.posted[3].operation).toBe('splitCacheFile')
|
||||
expect(worker.posted).toHaveLength(4)
|
||||
worker.complete(3)
|
||||
expect(worker.posted[4].providerId).toBe('muse')
|
||||
worker.complete(4)
|
||||
await Promise.all([first, second, codex, muse])
|
||||
expect(await split).toEqual({ reportText: 'saved report', migrated: false })
|
||||
})
|
||||
|
||||
it('settles every posted call on one fault, parks only unposted work, and never replays', async () => {
|
||||
const { client, workers } = fixture()
|
||||
const posted = [
|
||||
client.scan(body('claude')),
|
||||
client.scan(body('codex')),
|
||||
client.scan(body('muse'))
|
||||
].map((promise) => promise.catch((error: unknown) => error))
|
||||
const pending = client.scan(body('claude'))
|
||||
const old = workers[0]
|
||||
let proveExit!: (code: number) => void
|
||||
old.exit = new Promise((resolve) => {
|
||||
proveExit = resolve
|
||||
})
|
||||
const failure = new Error('worker crashed')
|
||||
old.emit('error', failure)
|
||||
expect(await Promise.all(posted)).toEqual([failure, failure, failure])
|
||||
expect(old.terminate).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS + 1)
|
||||
expect(workers).toHaveLength(1)
|
||||
proveExit(1)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(workers).toHaveLength(2)
|
||||
expect(workers[1].posted.map((request) => request.providerId)).toEqual(['claude'])
|
||||
old.complete(0)
|
||||
workers[1].complete(0)
|
||||
expect(await pending).toEqual({
|
||||
operation: 'scan',
|
||||
providerId: 'claude',
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
})
|
||||
})
|
||||
|
||||
it('times each dispatched ID independently, ignoring other-provider and unknown progress', async () => {
|
||||
const { client, workers } = fixture()
|
||||
const claude = client.scan(body('claude')).catch((error: unknown) => error)
|
||||
const codex = client.scan(body('codex')).catch((error: unknown) => error)
|
||||
await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS / 2)
|
||||
workers[0].emit('message', { id: workers[0].posted[1].id, filesScanned: 1 })
|
||||
workers[0].emit('message', { id: 999, filesScanned: 1 })
|
||||
await vi.advanceTimersByTimeAsync(USAGE_SCAN_NO_PROGRESS_TIMEOUT_MS / 2 + 1)
|
||||
expect(await claude).toEqual(new Error('Usage scan worker reported no progress for 600000ms'))
|
||||
expect(await codex).toEqual(new Error('Usage scan worker reported no progress for 600000ms'))
|
||||
expect(workers[0].terminate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('counts a shared death once and caps consecutive worker deaths in one queued burst', async () => {
|
||||
const { client, workers } = fixture()
|
||||
const calls = [
|
||||
client.scan(body('claude')),
|
||||
client.scan(body('codex')),
|
||||
client.scan(body('opencode')),
|
||||
client.scan(body('muse')),
|
||||
client.scan(body('claude')),
|
||||
client.scan(body('claude')),
|
||||
client.scan(body('claude'))
|
||||
].map((promise) => promise.catch((error: unknown) => error))
|
||||
workers[0].emit('error', new Error('fault one'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(workers).toHaveLength(2)
|
||||
workers[1].emit('error', new Error('fault two'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(workers).toHaveLength(3)
|
||||
workers[2].emit('error', new Error('fault three'))
|
||||
const results = await Promise.all(calls)
|
||||
expect(results).toEqual([
|
||||
new Error('fault one'),
|
||||
new Error('fault one'),
|
||||
new Error('fault one'),
|
||||
new Error('fault one'),
|
||||
new Error('fault two'),
|
||||
new Error('fault three'),
|
||||
new Error('Usage scan worker crashed repeatedly (fault three)')
|
||||
])
|
||||
expect(workers).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps a request-level error local while the other provider completes', async () => {
|
||||
const { client, workers } = fixture()
|
||||
const claude = client.scan(body('claude')).catch((error: unknown) => error)
|
||||
const codex = client.scan(body('codex'))
|
||||
workers[0].emit('message', { id: workers[0].posted[0].id, ok: false, error: 'scan failed' })
|
||||
expect(await claude).toEqual(new Error('scan failed'))
|
||||
expect(workers[0].terminate).not.toHaveBeenCalled()
|
||||
workers[0].complete(1)
|
||||
expect(await codex).toEqual({
|
||||
operation: 'scan',
|
||||
providerId: 'codex',
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels a parked wake when disposed and cannot spawn after the exact exit', async () => {
|
||||
const { client, workers } = fixture()
|
||||
const active = client.scan(body('claude')).catch((error: unknown) => error)
|
||||
const pending = client.scan(body('claude')).catch((error: unknown) => error)
|
||||
let proveExit!: (code: number) => void
|
||||
workers[0].exit = new Promise((resolve) => {
|
||||
proveExit = resolve
|
||||
})
|
||||
workers[0].emit('error', new Error('worker crashed'))
|
||||
const disposed = client.dispose()
|
||||
expect(await active).toEqual(new Error('worker crashed'))
|
||||
expect(await pending).toEqual(new Error('Usage scan scheduler disposed'))
|
||||
proveExit(1)
|
||||
await disposed
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(workers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps failed termination fenced until actual exit and rejects strict disposal honestly', async () => {
|
||||
const { client, workers } = fixture()
|
||||
const first = client.scan(body('claude')).catch((error: unknown) => error)
|
||||
const pending = client.scan(body('claude')).catch((error: unknown) => error)
|
||||
const failure = new Error('termination failed')
|
||||
workers[0].exit = Promise.reject(failure)
|
||||
workers[0].emit('error', new Error('worker crashed'))
|
||||
expect(await first).toEqual(new Error('worker crashed'))
|
||||
expect(await pending).toBe(failure)
|
||||
expect(await client.scan(body('codex')).catch((error: unknown) => error)).toBe(failure)
|
||||
expect(workers).toHaveLength(1)
|
||||
await expect(client.dispose()).rejects.toBe(failure)
|
||||
workers[0].emit('exit', 1)
|
||||
await client.dispose()
|
||||
})
|
||||
|
||||
it('preserves unavailable classification without falling back to caller-thread scans', async () => {
|
||||
const client = new UsageScanWorkerClient({
|
||||
workerFactory: () => {
|
||||
throw new Error('cannot spawn')
|
||||
},
|
||||
log: () => {}
|
||||
})
|
||||
clients.push(client)
|
||||
await expect(client.scan(body('claude'))).rejects.toBeInstanceOf(
|
||||
UsageScanWorkerUnavailableError
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import { LazyWorkerThreadHost } from '../worker-threads/lazy-worker-thread-host'
|
||||
import type { WorkerThreadFactory } from '../worker-threads/worker-thread-transport'
|
||||
import {
|
||||
isUsageScanWorkerProgress,
|
||||
type UsageScanWorkerMessage,
|
||||
type UsageScanWorkerRequest,
|
||||
type UsageScanWorkerRequestBody,
|
||||
type UsageScanWorkerResponse
|
||||
} from './scan-worker/usage-scan-worker-protocol'
|
||||
|
||||
type ScanCall = {
|
||||
request: UsageScanWorkerRequest
|
||||
timeoutMs: number
|
||||
timer: NodeJS.Timeout | null
|
||||
resolve: (response: UsageScanWorkerResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/** Independent providers share a worker; each provider and cache split retain exclusive ownership. */
|
||||
export class UsageScanRequestScheduler {
|
||||
private readonly active = new Map<number, ScanCall>()
|
||||
private pending: ScanCall[] = []
|
||||
private nextId = 1
|
||||
private consecutiveDeaths = 0
|
||||
private disposed = false
|
||||
private retirementWait: Promise<void> | null = null
|
||||
private readonly host: LazyWorkerThreadHost<UsageScanWorkerMessage>
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
factory: WorkerThreadFactory
|
||||
idleTeardownMs: number
|
||||
maxConsecutiveDeaths: number
|
||||
createUnavailableError: (message: string) => Error
|
||||
onUnavailable: (error: unknown) => void
|
||||
}
|
||||
) {
|
||||
this.host = new LazyWorkerThreadHost({
|
||||
factory: options.factory,
|
||||
idleTeardownMs: options.idleTeardownMs,
|
||||
onMessage: (response) => this.onMessage(response),
|
||||
onError: (error) => this.onFault(error),
|
||||
onExit: (code) => this.onExit(code),
|
||||
isIdle: () => this.active.size === 0 && this.pending.length === 0,
|
||||
onUnavailable: options.onUnavailable,
|
||||
awaitRetirement: true,
|
||||
strictRetirement: true
|
||||
})
|
||||
}
|
||||
|
||||
dispatch(body: UsageScanWorkerRequestBody, timeoutMs: number): Promise<UsageScanWorkerResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.disposed) {
|
||||
reject(new Error('Usage scan scheduler disposed'))
|
||||
return
|
||||
}
|
||||
if (this.active.size === 0 && this.pending.length === 0) {
|
||||
this.consecutiveDeaths = 0
|
||||
}
|
||||
this.pending.push({
|
||||
request: { ...body, id: this.nextId++ },
|
||||
timeoutMs,
|
||||
timer: null,
|
||||
resolve,
|
||||
reject
|
||||
})
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.host.destroy()
|
||||
const calls = [...this.active.values(), ...this.pending]
|
||||
this.pending = []
|
||||
for (const call of calls) {
|
||||
this.settle(call, () => call.reject(new Error('Usage scan scheduler disposed')))
|
||||
}
|
||||
}
|
||||
|
||||
retired(): Promise<void> {
|
||||
return this.host.retired()
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
if (this.pending.length === 0) {
|
||||
if (this.active.size === 0) {
|
||||
this.host.scheduleIdleTeardown()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this.host.isRetiring) {
|
||||
this.waitForRetirement()
|
||||
return
|
||||
}
|
||||
while (this.pending.length > 0) {
|
||||
const index = this.nextEligible()
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
const worker = this.host.ensure()
|
||||
if (!worker) {
|
||||
this.rejectPending(this.options.createUnavailableError('worker spawn failed'))
|
||||
return
|
||||
}
|
||||
const call = this.pending.splice(index, 1)[0]
|
||||
this.active.set(call.request.id, call)
|
||||
this.host.clearIdleTimer()
|
||||
this.armDeadline(call)
|
||||
try {
|
||||
worker.postMessage(call.request)
|
||||
} catch (error) {
|
||||
this.onFault(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nextEligible(): number {
|
||||
const requests = [...this.active.values()].map((call) => call.request)
|
||||
if (requests.some((request) => request.operation === 'splitCacheFile')) {
|
||||
return -1
|
||||
}
|
||||
for (let index = 0; index < this.pending.length; index++) {
|
||||
const request = this.pending[index].request
|
||||
if (request.operation === 'splitCacheFile') {
|
||||
return index === 0 && requests.length === 0 ? index : -1
|
||||
}
|
||||
if (!requests.some((active) => active.providerId === request.providerId)) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private waitForRetirement(): void {
|
||||
if (this.retirementWait) {
|
||||
return
|
||||
}
|
||||
this.retirementWait = this.host.retired().then(
|
||||
() => {
|
||||
this.retirementWait = null
|
||||
this.pump()
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.retirementWait = null
|
||||
this.rejectPending(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private armDeadline(call: ScanCall): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
}
|
||||
call.timer = setTimeout(() => {
|
||||
if (this.active.get(call.request.id) === call) {
|
||||
this.onFault(new Error(`Usage scan worker reported no progress for ${call.timeoutMs}ms`))
|
||||
}
|
||||
}, call.timeoutMs)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
private onMessage(response: UsageScanWorkerMessage): void {
|
||||
const call = this.active.get(response.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
if (isUsageScanWorkerProgress(response)) {
|
||||
this.armDeadline(call)
|
||||
return
|
||||
}
|
||||
this.consecutiveDeaths = 0
|
||||
this.settle(call, () => call.resolve(response))
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private onExit(code: number): void {
|
||||
if (code === 0 && this.active.size === 0 && this.pending.length === 0) {
|
||||
this.host.destroy()
|
||||
return
|
||||
}
|
||||
this.onFault(new Error(`Usage scan worker exited with code ${code}`))
|
||||
}
|
||||
|
||||
private onFault(error: Error): void {
|
||||
const posted = [...this.active.values()]
|
||||
this.host.destroy()
|
||||
for (const call of posted) {
|
||||
this.settle(call, () => call.reject(error))
|
||||
}
|
||||
if (posted.length > 0 && ++this.consecutiveDeaths >= this.options.maxConsecutiveDeaths) {
|
||||
this.consecutiveDeaths = 0
|
||||
this.rejectPending(new Error(`Usage scan worker crashed repeatedly (${error.message})`))
|
||||
return
|
||||
}
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private rejectPending(error: Error): void {
|
||||
const pending = this.pending
|
||||
this.pending = []
|
||||
for (const call of pending) {
|
||||
this.settle(call, () => call.reject(error))
|
||||
}
|
||||
}
|
||||
|
||||
private settle(call: ScanCall, finish: () => void): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
this.active.delete(call.request.id)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { UsageStoreHost, UsageWorktreeCatalog } from './usage-store-host'
|
||||
|
||||
export function createTestUsageStoreHost(options: {
|
||||
userDataPath: string
|
||||
catalog?: UsageWorktreeCatalog
|
||||
profileDirs?: string[]
|
||||
telemetryEnabled?: boolean
|
||||
capture?: UsageStoreHost['telemetry']['capture']
|
||||
signal?: AbortSignal
|
||||
}): UsageStoreHost {
|
||||
return {
|
||||
userDataPath: options.userDataPath,
|
||||
signal: options.signal ?? new AbortController().signal,
|
||||
readCatalog: async () => options.catalog ?? { repoIds: [], worktreesByRepo: [] },
|
||||
claudeProfileDirs: async () => options.profileDirs ?? [],
|
||||
telemetry: {
|
||||
isEnabled: async () => options.telemetryEnabled ?? false,
|
||||
capture: options.capture ?? (async () => true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AgentTokenUsage } from '../../shared/telemetry-agent-token-usage-schema'
|
||||
import type { UsageWorktreeRef } from './worktree-attribution/usage-worktree-metadata'
|
||||
|
||||
export type UsageWorktreeCatalog = {
|
||||
repoIds: string[]
|
||||
worktreesByRepo: [string, UsageWorktreeRef[]][]
|
||||
}
|
||||
|
||||
export type UsageTelemetryHost = {
|
||||
isEnabled(): Promise<boolean>
|
||||
capture(snapshot: AgentTokenUsage): Promise<boolean>
|
||||
}
|
||||
|
||||
export type UsageStoreHost = {
|
||||
signal: AbortSignal
|
||||
userDataPath: string
|
||||
readCatalog(): Promise<UsageWorktreeCatalog>
|
||||
claudeProfileDirs(): Promise<string[]>
|
||||
telemetry: UsageTelemetryHost
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function getUsageWorktreeFingerprint(
|
||||
}
|
||||
|
||||
export function createWorktreeRefs(
|
||||
repos: Repo[],
|
||||
repos: readonly Pick<Repo, 'id'>[],
|
||||
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
|
||||
): UsageScanWorktreeRef[] {
|
||||
const refs: UsageScanWorktreeRef[] = []
|
||||
|
||||
@@ -6,7 +6,7 @@ export class LazyWorkerThreadHost<TResponse> {
|
||||
private idleTimer: NodeJS.Timeout | null = null
|
||||
private cleanupListeners: (() => void) | null = null
|
||||
private reportedUnavailable = false
|
||||
private retiring: Promise<void> | null = null
|
||||
private retiring: { completion: Promise<void>; failure?: unknown } | null = null
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
@@ -21,6 +21,8 @@ export class LazyWorkerThreadHost<TResponse> {
|
||||
onUnavailable: (error: unknown) => void
|
||||
/** Native calls can delay termination; wait for exit before spawning another worker (#24572). */
|
||||
awaitRetirement?: boolean
|
||||
/** Usage ownership fences require exact exit proof after termination fails. */
|
||||
strictRetirement?: boolean
|
||||
}
|
||||
) {}
|
||||
|
||||
@@ -76,17 +78,42 @@ export class LazyWorkerThreadHost<TResponse> {
|
||||
this.cleanupListeners?.()
|
||||
this.cleanupListeners = null
|
||||
worker.removeAllListeners()
|
||||
// A rejected terminate still ends retirement, so it cannot latch spawning off.
|
||||
const exited = worker.terminate().catch(() => undefined)
|
||||
if (this.options.awaitRetirement) {
|
||||
this.retiring = exited.then(() => {
|
||||
this.retiring = null
|
||||
})
|
||||
if (!this.options.awaitRetirement) {
|
||||
void worker.terminate().catch(() => {})
|
||||
return
|
||||
}
|
||||
const retirement: { completion: Promise<void>; failure?: unknown } = {
|
||||
completion: Promise.resolve()
|
||||
}
|
||||
this.retiring = retirement
|
||||
let proveExit!: () => void
|
||||
const exitProof = new Promise<void>((resolve) => {
|
||||
proveExit = resolve
|
||||
})
|
||||
const onExit = (): void => {
|
||||
worker.off('exit', onExit)
|
||||
if (this.retiring === retirement) {
|
||||
this.retiring = null
|
||||
}
|
||||
proveExit()
|
||||
}
|
||||
worker.on('exit', onExit)
|
||||
const terminated = worker.terminate().then(onExit, (error: unknown) => {
|
||||
if (this.options.strictRetirement) {
|
||||
retirement.failure = error
|
||||
} else {
|
||||
onExit()
|
||||
}
|
||||
})
|
||||
retirement.completion = Promise.race([exitProof, terminated])
|
||||
}
|
||||
|
||||
retired(): Promise<void> {
|
||||
return this.retiring ?? Promise.resolve()
|
||||
async retired(): Promise<void> {
|
||||
const retirement = this.retiring
|
||||
await retirement?.completion
|
||||
if (this.retiring === retirement && retirement?.failure !== undefined) {
|
||||
throw retirement.failure
|
||||
}
|
||||
}
|
||||
|
||||
scheduleIdleTeardown(): void {
|
||||
|
||||
@@ -63,7 +63,10 @@ const MAX_CONSECUTIVE_DEATHS = 3
|
||||
|
||||
function makeQueue(
|
||||
workers: FakeWorker[],
|
||||
options: { awaitRetirement?: boolean; makeWorker?: () => FakeWorker } = {}
|
||||
options: {
|
||||
awaitRetirement?: boolean
|
||||
makeWorker?: () => FakeWorker
|
||||
} = {}
|
||||
): WorkerThreadRequestQueue<Request, Response> {
|
||||
return new WorkerThreadRequestQueue<Request, Response>({
|
||||
awaitRetirement: options.awaitRetirement,
|
||||
@@ -434,6 +437,7 @@ describe('WorkerThreadRequestQueue', () => {
|
||||
await vi.advanceTimersByTimeAsync(TIMEOUT_MS)
|
||||
expect(await first).toMatchObject({ message: `timed out after ${TIMEOUT_MS}ms` })
|
||||
|
||||
await expect(queue.retired()).resolves.toBeUndefined()
|
||||
const next = send(queue, 'next')
|
||||
expect(workers).toHaveLength(2)
|
||||
workers[1].respond()
|
||||
|
||||
@@ -129,8 +129,12 @@ export class WorkerThreadRequestQueue<
|
||||
}
|
||||
}
|
||||
|
||||
retired(): Promise<void> {
|
||||
return this.host.retired()
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.active || this.queue.length === 0) {
|
||||
if (this.disposed || this.active || this.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
// A shared signal is already aborted before its remaining listeners run.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
BackendConnectionListener,
|
||||
BackendConnectionPurpose
|
||||
} from '../../shared/backend-service-contract'
|
||||
import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../../shared/claude-usage-types'
|
||||
import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../../shared/codex-usage-types'
|
||||
import type {
|
||||
@@ -25,10 +29,14 @@ export type UsageQueryArgs<Snapshot extends UsageProviderSnapshot> = Pick<
|
||||
>
|
||||
|
||||
export type UsageProviderApi<Snapshot extends UsageProviderSnapshot, BreakdownKind> = {
|
||||
getScanState: () => Promise<Snapshot['scanState']>
|
||||
onConnectionChanged?: (listener: BackendConnectionListener) => () => void
|
||||
getScanState: (purpose?: BackendConnectionPurpose) => Promise<Snapshot['scanState']>
|
||||
setEnabled: (args: { enabled: boolean }) => Promise<Snapshot['scanState']>
|
||||
refresh: (args?: { force?: boolean }) => Promise<Snapshot['scanState']>
|
||||
getSnapshot: (args: UsageQueryArgs<Snapshot> & { limit?: number }) => Promise<Snapshot>
|
||||
getSnapshot: (
|
||||
args: UsageQueryArgs<Snapshot> & { limit?: number },
|
||||
purpose?: BackendConnectionPurpose
|
||||
) => Promise<Snapshot>
|
||||
getSummary: (args: UsageQueryArgs<Snapshot>) => Promise<Snapshot['summary']>
|
||||
getDaily: (args: UsageQueryArgs<Snapshot>) => Promise<Snapshot['daily']>
|
||||
getBreakdown: (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { backendUsageTransport } from '../backend-port-client'
|
||||
import { createUsageProviderApi } from '../usage-provider-api'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const claudeUsageApi = createUsageProviderApi(
|
||||
ipcRenderer,
|
||||
backendUsageTransport,
|
||||
'claudeUsage'
|
||||
) satisfies PreloadApi['claudeUsage']
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { backendUsageTransport } from '../backend-port-client'
|
||||
import { createUsageProviderApi } from '../usage-provider-api'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const codexUsageApi = createUsageProviderApi(
|
||||
ipcRenderer,
|
||||
backendUsageTransport,
|
||||
'codexUsage'
|
||||
) satisfies PreloadApi['codexUsage']
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { backendUsageTransport } from '../backend-port-client'
|
||||
import { createUsageProviderApi } from '../usage-provider-api'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const museUsageApi = createUsageProviderApi(
|
||||
ipcRenderer,
|
||||
backendUsageTransport,
|
||||
'museUsage'
|
||||
) satisfies PreloadApi['museUsage']
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { backendUsageTransport } from '../backend-port-client'
|
||||
import { createUsageProviderApi } from '../usage-provider-api'
|
||||
import type { PreloadApi } from '../api-types'
|
||||
|
||||
export const openCodeUsageApi = createUsageProviderApi(
|
||||
ipcRenderer,
|
||||
backendUsageTransport,
|
||||
'openCodeUsage'
|
||||
) satisfies PreloadApi['openCodeUsage']
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { BackendPortRpc, type BackendPort } from '../shared/backend-port-rpc'
|
||||
import {
|
||||
BACKEND_CONNECTION_CHANGED,
|
||||
BACKEND_PORT_DELIVERY,
|
||||
BACKEND_PORT_REQUEST,
|
||||
type BackendConnectionPurpose,
|
||||
type BackendConnectionStatus,
|
||||
type BackendConnectionListener
|
||||
} from '../shared/backend-service-contract'
|
||||
|
||||
let connection: BackendPortRpc | undefined
|
||||
let connecting: Promise<BackendPortRpc> | undefined
|
||||
type PortAttempt = {
|
||||
token: string
|
||||
purpose: BackendConnectionPurpose
|
||||
resolve(client: BackendPortRpc): void
|
||||
reject(error: Error): void
|
||||
}
|
||||
let pendingPort: PortAttempt | undefined
|
||||
const connectionListeners = new Set<BackendConnectionListener>()
|
||||
let connected: boolean | undefined
|
||||
let unavailable = false
|
||||
|
||||
function publishConnection(next: boolean): void {
|
||||
if (next) {
|
||||
unavailable = false
|
||||
}
|
||||
if (connected === next) {
|
||||
return
|
||||
}
|
||||
connected = next
|
||||
if (!next) {
|
||||
const previous = connection
|
||||
connection = undefined
|
||||
connecting = undefined
|
||||
const attempt = pendingPort
|
||||
pendingPort = undefined
|
||||
previous?.close()
|
||||
attempt?.reject(new Error('Usage disconnected. Try again after it reconnects.'))
|
||||
}
|
||||
for (const listener of connectionListeners) {
|
||||
listener(next)
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.on(BACKEND_PORT_DELIVERY, (event, data: { token?: string }) => {
|
||||
const [port, ...surplus] = event.ports
|
||||
for (const extra of surplus) {
|
||||
extra.close()
|
||||
}
|
||||
if (!port || !pendingPort || pendingPort.token !== data?.token) {
|
||||
port?.close()
|
||||
return
|
||||
}
|
||||
const adapter: BackendPort = {
|
||||
postMessage: (message) => port.postMessage(message),
|
||||
onMessage(listener) {
|
||||
const receive = (event: MessageEvent): void => listener(event.data)
|
||||
port.addEventListener('message', receive)
|
||||
port.start()
|
||||
return () => port.removeEventListener('message', receive)
|
||||
},
|
||||
onClose(listener) {
|
||||
port.addEventListener('close', listener)
|
||||
return () => port.removeEventListener('close', listener)
|
||||
},
|
||||
close: () => port.close()
|
||||
}
|
||||
const previous = connection
|
||||
connection = undefined
|
||||
previous?.close()
|
||||
const client = new BackendPortRpc(
|
||||
adapter,
|
||||
'renderer',
|
||||
async () => {
|
||||
throw new Error('Renderer capabilities are not available on this port.')
|
||||
},
|
||||
() => {
|
||||
if (connection === client) {
|
||||
publishConnection(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
connection = client
|
||||
const attempt = pendingPort
|
||||
pendingPort = undefined
|
||||
attempt.resolve(client)
|
||||
publishConnection(true)
|
||||
})
|
||||
|
||||
function requestPort(attempt: PortAttempt): void {
|
||||
void ipcRenderer
|
||||
.invoke(BACKEND_PORT_REQUEST, attempt.token, attempt.purpose)
|
||||
.catch((error: Error) => {
|
||||
if (pendingPort === attempt) {
|
||||
pendingPort = undefined
|
||||
attempt.reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function connect(purpose: BackendConnectionPurpose = 'demand'): Promise<BackendPortRpc> {
|
||||
if (connection) {
|
||||
return Promise.resolve(connection)
|
||||
}
|
||||
if (connecting) {
|
||||
if (purpose === 'demand' && pendingPort?.purpose === 'recovery') {
|
||||
pendingPort = { ...pendingPort, token: crypto.randomUUID(), purpose }
|
||||
requestPort(pendingPort)
|
||||
}
|
||||
return connecting
|
||||
}
|
||||
const attemptPromise = new Promise<BackendPortRpc>((resolve, reject) => {
|
||||
const attempt = { token: crypto.randomUUID(), purpose, resolve, reject }
|
||||
pendingPort = attempt
|
||||
requestPort(attempt)
|
||||
})
|
||||
const result = attemptPromise.finally(() => {
|
||||
if (connecting === result) {
|
||||
connecting = undefined
|
||||
}
|
||||
})
|
||||
connecting = result
|
||||
return result
|
||||
}
|
||||
|
||||
ipcRenderer.on(BACKEND_CONNECTION_CHANGED, (_event, state: BackendConnectionStatus) => {
|
||||
publishConnection(state.connected)
|
||||
if (!state.connected && state.unavailable && !unavailable) {
|
||||
unavailable = true
|
||||
for (const listener of connectionListeners) {
|
||||
listener(false, { unavailable: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const backendUsageTransport = {
|
||||
onConnectionChanged(listener: BackendConnectionListener): () => void {
|
||||
connectionListeners.add(listener)
|
||||
listener(connected ?? false, { initial: true, unavailable })
|
||||
return () => {
|
||||
connectionListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
invoke: async (
|
||||
method: string,
|
||||
params?: unknown,
|
||||
purpose?: BackendConnectionPurpose
|
||||
): Promise<unknown> => (await connect(purpose)).call(method, params)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { IpcRenderer } from 'electron'
|
||||
import type {
|
||||
BackendConnectionListener,
|
||||
BackendConnectionPurpose
|
||||
} from '../shared/backend-service-contract'
|
||||
import type { PreloadApi } from './api-types'
|
||||
|
||||
type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage' | 'museUsage'
|
||||
@@ -6,24 +9,37 @@ type UsageProviderApi = PreloadApi[UsageProviderApiKey]
|
||||
type UsageRangeArgs = { scope: string; range: string }
|
||||
|
||||
export function createUsageProviderApi<Key extends UsageProviderApiKey>(
|
||||
ipc: Pick<IpcRenderer, 'invoke'>,
|
||||
ipc: {
|
||||
invoke(channel: string, args?: unknown, purpose?: BackendConnectionPurpose): Promise<unknown>
|
||||
onConnectionChanged?: (listener: BackendConnectionListener) => () => void
|
||||
},
|
||||
prefix: Key
|
||||
): PreloadApi[Key]
|
||||
export function createUsageProviderApi(
|
||||
ipc: Pick<IpcRenderer, 'invoke'>,
|
||||
ipc: {
|
||||
invoke(channel: string, args?: unknown, purpose?: BackendConnectionPurpose): Promise<unknown>
|
||||
onConnectionChanged?: (listener: BackendConnectionListener) => () => void
|
||||
},
|
||||
prefix: UsageProviderApiKey
|
||||
): UsageProviderApi {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The prefix selects its typed service methods; only the transport boundary erases their return types.
|
||||
return {
|
||||
getScanState: () => ipc.invoke(`${prefix}:getScanState`),
|
||||
...(ipc.onConnectionChanged ? { onConnectionChanged: ipc.onConnectionChanged } : {}),
|
||||
getScanState: (purpose?: BackendConnectionPurpose) =>
|
||||
purpose
|
||||
? ipc.invoke(`${prefix}:getScanState`, undefined, purpose)
|
||||
: ipc.invoke(`${prefix}:getScanState`),
|
||||
setEnabled: (args: { enabled: boolean }) => ipc.invoke(`${prefix}:setEnabled`, args),
|
||||
refresh: (args?: { force?: boolean }) => ipc.invoke(`${prefix}:refresh`, args),
|
||||
getSnapshot: (args: UsageRangeArgs & { limit?: number }) =>
|
||||
ipc.invoke(`${prefix}:getSnapshot`, args),
|
||||
getSnapshot: (args: UsageRangeArgs & { limit?: number }, purpose?: BackendConnectionPurpose) =>
|
||||
purpose
|
||||
? ipc.invoke(`${prefix}:getSnapshot`, args, purpose)
|
||||
: ipc.invoke(`${prefix}:getSnapshot`, args),
|
||||
getSummary: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getSummary`, args),
|
||||
getDaily: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getDaily`, args),
|
||||
getBreakdown: (args: UsageRangeArgs & { kind: string }) =>
|
||||
ipc.invoke(`${prefix}:getBreakdown`, args),
|
||||
getRecentSessions: (args: UsageRangeArgs & { limit?: number }) =>
|
||||
ipc.invoke(`${prefix}:getRecentSessions`, args)
|
||||
}
|
||||
} as UsageProviderApi
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usageFailureMessage } from './usage-failure-message'
|
||||
import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
|
||||
import {
|
||||
Activity,
|
||||
Coins,
|
||||
@@ -54,6 +55,8 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const intlLocale = useIntlLocale()
|
||||
const scanState = useAppStore((state) => state.claudeUsageScanState)
|
||||
const failure = useAppStore((state) => state.claudeUsageFailure)
|
||||
const failureMessage = usageFailureMessage(failure, t)
|
||||
const summary = useAppStore((state) => state.claudeUsageSummary)
|
||||
const daily = useAppStore((state) => state.claudeUsageDaily)
|
||||
const modelBreakdown = useAppStore((state) => state.claudeUsageModelBreakdown)
|
||||
@@ -61,16 +64,14 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
const recentSessions = useAppStore((state) => state.claudeUsageRecentSessions)
|
||||
const scope = useAppStore((state) => state.claudeUsageScope)
|
||||
const range = useAppStore((state) => state.claudeUsageRange)
|
||||
const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage)
|
||||
const observeClaudeUsage = useAppStore((state) => state.observeClaudeUsage)
|
||||
const setClaudeUsageEnabled = useAppStore((state) => state.setClaudeUsageEnabled)
|
||||
const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
|
||||
const setClaudeUsageScope = useAppStore((state) => state.setClaudeUsageScope)
|
||||
const setClaudeUsageRange = useAppStore((state) => state.setClaudeUsageRange)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchClaudeUsage()
|
||||
}, [fetchClaudeUsage])
|
||||
useUsageObservation(observeClaudeUsage)
|
||||
|
||||
const handleSetEnabled = (enabled: boolean): void => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
@@ -87,6 +88,9 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled={false}
|
||||
failure={failureMessage}
|
||||
stateKnown={scanState !== null}
|
||||
retry={{ label: t('usage.failure.retry', 'Refresh'), onRefresh: refreshClaudeUsage }}
|
||||
title={title}
|
||||
disabledDescription={t(
|
||||
'auto.components.stats.ClaudeUsagePane.0cb1a36d7d',
|
||||
@@ -98,7 +102,11 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
|
||||
if (
|
||||
!failureMessage &&
|
||||
!summary &&
|
||||
(scanState.isScanning || scanState.lastScanCompletedAt === null)
|
||||
) {
|
||||
return <ClaudeUsageLoadingState />
|
||||
}
|
||||
|
||||
@@ -107,6 +115,7 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled
|
||||
failure={failureMessage}
|
||||
title={title}
|
||||
status={
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usageFailureMessage } from './usage-failure-message'
|
||||
import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
|
||||
import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react'
|
||||
import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -46,6 +47,8 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const intlLocale = useIntlLocale()
|
||||
const scanState = useAppStore((state) => state.codexUsageScanState)
|
||||
const failure = useAppStore((state) => state.codexUsageFailure)
|
||||
const failureMessage = usageFailureMessage(failure, t)
|
||||
const summary = useAppStore((state) => state.codexUsageSummary)
|
||||
const daily = useAppStore((state) => state.codexUsageDaily)
|
||||
const modelBreakdown = useAppStore((state) => state.codexUsageModelBreakdown)
|
||||
@@ -53,16 +56,14 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
const recentSessions = useAppStore((state) => state.codexUsageRecentSessions)
|
||||
const scope = useAppStore((state) => state.codexUsageScope)
|
||||
const range = useAppStore((state) => state.codexUsageRange)
|
||||
const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage)
|
||||
const observeCodexUsage = useAppStore((state) => state.observeCodexUsage)
|
||||
const setCodexUsageEnabled = useAppStore((state) => state.setCodexUsageEnabled)
|
||||
const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
|
||||
const setCodexUsageScope = useAppStore((state) => state.setCodexUsageScope)
|
||||
const setCodexUsageRange = useAppStore((state) => state.setCodexUsageRange)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchCodexUsage()
|
||||
}, [fetchCodexUsage])
|
||||
useUsageObservation(observeCodexUsage)
|
||||
|
||||
const handleSetEnabled = (enabled: boolean): void => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
@@ -79,6 +80,9 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled={false}
|
||||
failure={failureMessage}
|
||||
stateKnown={scanState !== null}
|
||||
retry={{ label: t('usage.failure.retry', 'Refresh'), onRefresh: refreshCodexUsage }}
|
||||
title={title}
|
||||
disabledDescription={t(
|
||||
'auto.components.stats.CodexUsagePane.13badcd8f2',
|
||||
@@ -90,7 +94,11 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
|
||||
if (
|
||||
!failureMessage &&
|
||||
!summary &&
|
||||
(scanState.isScanning || scanState.lastScanCompletedAt === null)
|
||||
) {
|
||||
return (
|
||||
<ClaudeUsageLoadingState
|
||||
title={title}
|
||||
@@ -110,6 +118,7 @@ export function CodexUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled
|
||||
failure={failureMessage}
|
||||
title={title}
|
||||
status={
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usageFailureMessage } from './usage-failure-message'
|
||||
import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
|
||||
import { Activity, Brain, DatabaseZap, FolderKanban, Sigma, Sparkles } from 'lucide-react'
|
||||
import type { MuseUsageRange, MuseUsageScope } from '../../../../shared/muse-usage-types'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -45,6 +46,8 @@ export function MuseUsagePane(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const intlLocale = useIntlLocale()
|
||||
const scanState = useAppStore((state) => state.museUsageScanState)
|
||||
const failure = useAppStore((state) => state.museUsageFailure)
|
||||
const failureMessage = usageFailureMessage(failure, t)
|
||||
const summary = useAppStore((state) => state.museUsageSummary)
|
||||
const daily = useAppStore((state) => state.museUsageDaily)
|
||||
const modelBreakdown = useAppStore((state) => state.museUsageModelBreakdown)
|
||||
@@ -52,16 +55,14 @@ export function MuseUsagePane(): React.JSX.Element {
|
||||
const recentSessions = useAppStore((state) => state.museUsageRecentSessions)
|
||||
const scope = useAppStore((state) => state.museUsageScope)
|
||||
const range = useAppStore((state) => state.museUsageRange)
|
||||
const fetchMuseUsage = useAppStore((state) => state.fetchMuseUsage)
|
||||
const observeMuseUsage = useAppStore((state) => state.observeMuseUsage)
|
||||
const setMuseUsageEnabled = useAppStore((state) => state.setMuseUsageEnabled)
|
||||
const refreshMuseUsage = useAppStore((state) => state.refreshMuseUsage)
|
||||
const setMuseUsageScope = useAppStore((state) => state.setMuseUsageScope)
|
||||
const setMuseUsageRange = useAppStore((state) => state.setMuseUsageRange)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchMuseUsage()
|
||||
}, [fetchMuseUsage])
|
||||
useUsageObservation(observeMuseUsage)
|
||||
|
||||
const handleSetEnabled = (enabled: boolean): void => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
@@ -78,6 +79,9 @@ export function MuseUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled={false}
|
||||
failure={failureMessage}
|
||||
stateKnown={scanState !== null}
|
||||
retry={{ label: t('usage.failure.retry', 'Refresh'), onRefresh: refreshMuseUsage }}
|
||||
title={title}
|
||||
disabledDescription={t(
|
||||
'auto.components.stats.MuseUsagePane.disabledDescription',
|
||||
@@ -89,7 +93,11 @@ export function MuseUsagePane(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
|
||||
if (
|
||||
!failureMessage &&
|
||||
!summary &&
|
||||
(scanState.isScanning || scanState.lastScanCompletedAt === null)
|
||||
) {
|
||||
return (
|
||||
<ClaudeUsageLoadingState
|
||||
title={title}
|
||||
@@ -104,6 +112,7 @@ export function MuseUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled
|
||||
failure={failureMessage}
|
||||
title={title}
|
||||
status={
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import { usageFailureMessage } from './usage-failure-message'
|
||||
import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
|
||||
import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react'
|
||||
import type {
|
||||
OpenCodeUsageRange,
|
||||
@@ -50,6 +51,8 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const intlLocale = useIntlLocale()
|
||||
const scanState = useAppStore((state) => state.openCodeUsageScanState)
|
||||
const failure = useAppStore((state) => state.openCodeUsageFailure)
|
||||
const failureMessage = usageFailureMessage(failure, t)
|
||||
const summary = useAppStore((state) => state.openCodeUsageSummary)
|
||||
const daily = useAppStore((state) => state.openCodeUsageDaily)
|
||||
const modelBreakdown = useAppStore((state) => state.openCodeUsageModelBreakdown)
|
||||
@@ -57,16 +60,14 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
const recentSessions = useAppStore((state) => state.openCodeUsageRecentSessions)
|
||||
const scope = useAppStore((state) => state.openCodeUsageScope)
|
||||
const range = useAppStore((state) => state.openCodeUsageRange)
|
||||
const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage)
|
||||
const observeOpenCodeUsage = useAppStore((state) => state.observeOpenCodeUsage)
|
||||
const setOpenCodeUsageEnabled = useAppStore((state) => state.setOpenCodeUsageEnabled)
|
||||
const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage)
|
||||
const setOpenCodeUsageScope = useAppStore((state) => state.setOpenCodeUsageScope)
|
||||
const setOpenCodeUsageRange = useAppStore((state) => state.setOpenCodeUsageRange)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOpenCodeUsage()
|
||||
}, [fetchOpenCodeUsage])
|
||||
useUsageObservation(observeOpenCodeUsage)
|
||||
|
||||
const handleSetEnabled = (enabled: boolean): void => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
@@ -83,6 +84,9 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled={false}
|
||||
failure={failureMessage}
|
||||
stateKnown={scanState !== null}
|
||||
retry={{ label: t('usage.failure.retry', 'Refresh'), onRefresh: refreshOpenCodeUsage }}
|
||||
title={title}
|
||||
disabledDescription={t(
|
||||
'auto.components.stats.OpenCodeUsagePane.b8b3522436',
|
||||
@@ -94,7 +98,11 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) {
|
||||
if (
|
||||
!failureMessage &&
|
||||
!summary &&
|
||||
(scanState.isScanning || scanState.lastScanCompletedAt === null)
|
||||
) {
|
||||
return (
|
||||
<ClaudeUsageLoadingState
|
||||
title={title}
|
||||
@@ -109,6 +117,7 @@ export function OpenCodeUsagePane(): React.JSX.Element {
|
||||
return (
|
||||
<UsageTrackingPaneShell
|
||||
enabled
|
||||
failure={failureMessage}
|
||||
title={title}
|
||||
status={
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { usageFailureMessage } from './usage-failure-message'
|
||||
import { useUsageObservation } from '../../store/slices/usage/use-usage-observation'
|
||||
import { useMemo } from 'react'
|
||||
import { Activity, CalendarDays, Coins, DatabaseZap, RefreshCw, Sparkles } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { Badge } from '../ui/badge'
|
||||
@@ -36,22 +38,26 @@ function formatUpdatedAt(
|
||||
export function UsageOverviewPane(): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const intlLocale = useIntlLocale()
|
||||
const claudeFailure = useAppStore((state) => state.claudeUsageFailure)
|
||||
const claudeScanState = useAppStore((state) => state.claudeUsageScanState)
|
||||
const claudeSummary = useAppStore((state) => state.claudeUsageSummary)
|
||||
const claudeDaily = useAppStore((state) => state.claudeUsageDaily)
|
||||
const codexFailure = useAppStore((state) => state.codexUsageFailure)
|
||||
const codexScanState = useAppStore((state) => state.codexUsageScanState)
|
||||
const codexSummary = useAppStore((state) => state.codexUsageSummary)
|
||||
const codexDaily = useAppStore((state) => state.codexUsageDaily)
|
||||
const openCodeFailure = useAppStore((state) => state.openCodeUsageFailure)
|
||||
const openCodeScanState = useAppStore((state) => state.openCodeUsageScanState)
|
||||
const openCodeSummary = useAppStore((state) => state.openCodeUsageSummary)
|
||||
const openCodeDaily = useAppStore((state) => state.openCodeUsageDaily)
|
||||
const museFailure = useAppStore((state) => state.museUsageFailure)
|
||||
const museScanState = useAppStore((state) => state.museUsageScanState)
|
||||
const museSummary = useAppStore((state) => state.museUsageSummary)
|
||||
const museDaily = useAppStore((state) => state.museUsageDaily)
|
||||
const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage)
|
||||
const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage)
|
||||
const fetchOpenCodeUsage = useAppStore((state) => state.fetchOpenCodeUsage)
|
||||
const fetchMuseUsage = useAppStore((state) => state.fetchMuseUsage)
|
||||
const observeClaudeUsage = useAppStore((state) => state.observeClaudeUsage)
|
||||
const observeCodexUsage = useAppStore((state) => state.observeCodexUsage)
|
||||
const observeOpenCodeUsage = useAppStore((state) => state.observeOpenCodeUsage)
|
||||
const observeMuseUsage = useAppStore((state) => state.observeMuseUsage)
|
||||
const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage)
|
||||
const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage)
|
||||
const refreshOpenCodeUsage = useAppStore((state) => state.refreshOpenCodeUsage)
|
||||
@@ -62,12 +68,10 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
const enableMuseUsage = useAppStore((state) => state.enableMuseUsage)
|
||||
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
|
||||
|
||||
useEffect(() => {
|
||||
void fetchClaudeUsage()
|
||||
void fetchCodexUsage()
|
||||
void fetchOpenCodeUsage()
|
||||
void fetchMuseUsage()
|
||||
}, [fetchClaudeUsage, fetchCodexUsage, fetchOpenCodeUsage, fetchMuseUsage])
|
||||
useUsageObservation(observeClaudeUsage)
|
||||
useUsageObservation(observeCodexUsage)
|
||||
useUsageObservation(observeOpenCodeUsage)
|
||||
useUsageObservation(observeMuseUsage)
|
||||
|
||||
const overview = useMemo(
|
||||
() =>
|
||||
@@ -114,12 +118,44 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
)
|
||||
const isScanning = overview.providers.some((provider) => provider.isScanning)
|
||||
|
||||
const failures = [
|
||||
{ name: 'Claude', message: usageFailureMessage(claudeFailure, t) },
|
||||
{ name: 'Codex', message: usageFailureMessage(codexFailure, t) },
|
||||
{ name: 'OpenCode', message: usageFailureMessage(openCodeFailure, t) },
|
||||
{ name: 'Muse', message: usageFailureMessage(museFailure, t) }
|
||||
]
|
||||
const failureGroups = new Map<string, string[]>()
|
||||
for (const { name, message } of failures) {
|
||||
if (message) {
|
||||
failureGroups.set(message, [...(failureGroups.get(message) ?? []), name])
|
||||
}
|
||||
}
|
||||
const knownProviders = {
|
||||
claude: claudeScanState,
|
||||
codex: codexScanState,
|
||||
opencode: openCodeScanState,
|
||||
muse: museScanState
|
||||
}
|
||||
const allProvidersKnown = Object.values(knownProviders).every(Boolean)
|
||||
const canRefresh =
|
||||
overview.hasAnyEnabledProvider ||
|
||||
failureGroups.size > 0 ||
|
||||
Object.values(knownProviders).some((state) => !state)
|
||||
|
||||
const handleRefresh = (): void => {
|
||||
void Promise.all([
|
||||
claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(),
|
||||
codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve(),
|
||||
openCodeScanState?.enabled ? refreshOpenCodeUsage() : Promise.resolve(),
|
||||
museScanState?.enabled ? refreshMuseUsage() : Promise.resolve()
|
||||
claudeScanState?.enabled || claudeFailure || !claudeScanState
|
||||
? refreshClaudeUsage()
|
||||
: Promise.resolve(),
|
||||
codexScanState?.enabled || codexFailure || !codexScanState
|
||||
? refreshCodexUsage()
|
||||
: Promise.resolve(),
|
||||
openCodeScanState?.enabled || openCodeFailure || !openCodeScanState
|
||||
? refreshOpenCodeUsage()
|
||||
: Promise.resolve(),
|
||||
museScanState?.enabled || museFailure || !museScanState
|
||||
? refreshMuseUsage()
|
||||
: Promise.resolve()
|
||||
])
|
||||
}
|
||||
|
||||
@@ -140,6 +176,11 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
)
|
||||
: ''}
|
||||
</p>
|
||||
{[...failureGroups].map(([message, names]) => (
|
||||
<p key={message} role="alert" className="mt-1 text-xs text-destructive">
|
||||
{names.join(', ')}: {message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -147,7 +188,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleRefresh}
|
||||
disabled={!overview.hasAnyEnabledProvider || isScanning}
|
||||
disabled={!canRefresh || isScanning}
|
||||
aria-label={t(
|
||||
'auto.components.stats.UsageOverviewPane.e06d1baf5c',
|
||||
'Refresh usage overview'
|
||||
@@ -162,7 +203,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!overview.hasAnyEnabledProvider ? (
|
||||
{allProvidersKnown && !overview.hasAnyEnabledProvider ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5">
|
||||
<div className="max-w-xl space-y-3">
|
||||
<div>
|
||||
@@ -183,6 +224,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void enableClaudeUsage()
|
||||
}}
|
||||
disabled={!claudeScanState}
|
||||
>
|
||||
{t('auto.components.stats.UsageOverviewPane.0ea0cae435', 'Enable Claude')}
|
||||
</Button>
|
||||
@@ -193,6 +235,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void enableCodexUsage()
|
||||
}}
|
||||
disabled={!codexScanState}
|
||||
>
|
||||
{t('auto.components.stats.UsageOverviewPane.2f1ee2878b', 'Enable Codex')}
|
||||
</Button>
|
||||
@@ -203,6 +246,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void enableOpenCodeUsage()
|
||||
}}
|
||||
disabled={!openCodeScanState}
|
||||
>
|
||||
{t('auto.components.stats.UsageOverviewPane.2d13e57f72', 'Enable OpenCode')}
|
||||
</Button>
|
||||
@@ -213,13 +257,14 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
void enableMuseUsage()
|
||||
}}
|
||||
disabled={!museScanState}
|
||||
>
|
||||
{t('auto.components.stats.UsageOverviewPane.enableMuse', 'Enable Muse')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : overview.hasAnyEnabledProvider ? (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
@@ -244,21 +289,21 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!overview.hasAnyData ? (
|
||||
{!overview.hasAnyData && allProvidersKnown ? (
|
||||
<div className="mt-4 rounded-lg border border-dashed border-border/60 bg-card/30 px-4 py-5 text-sm text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.stats.UsageOverviewPane.noLocalUsageYet',
|
||||
'No local Claude, Codex, OpenCode, or Muse usage found yet. The overview will populate after the next agent session writes token logs.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : overview.hasAnyData ? (
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
|
||||
<DailyIntensityGrid days={recentDays} bestDay={overview.bestDay} />
|
||||
<TokenMixBar overview={overview} />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
@@ -268,11 +313,13 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
{t('auto.components.stats.UsageOverviewPane.33f7b043d2', 'Providers')}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'auto.components.stats.UsageOverviewPane.providersEnabledWithData',
|
||||
'{{enabled}} enabled - {{withData}} with data',
|
||||
{ enabled: overview.enabledProviderCount, withData: overview.dataProviderCount }
|
||||
)}
|
||||
{allProvidersKnown
|
||||
? t(
|
||||
'auto.components.stats.UsageOverviewPane.providersEnabledWithData',
|
||||
'{{enabled}} enabled - {{withData}} with data',
|
||||
{ enabled: overview.enabledProviderCount, withData: overview.dataProviderCount }
|
||||
)
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
@@ -290,6 +337,7 @@ export function UsageOverviewPane(): React.JSX.Element {
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
totalTokens={overview.totalTokens}
|
||||
stateKnown={!!knownProviders[provider.id]}
|
||||
onEnable={() => {
|
||||
recordFeatureInteraction('usage-tracking')
|
||||
if (provider.id === 'claude') {
|
||||
|
||||
@@ -45,6 +45,9 @@ export function UsageFilterRadioGroup<Value extends string>({
|
||||
|
||||
type UsageTrackingPaneShellProps = {
|
||||
title: ReactNode
|
||||
failure?: string | null
|
||||
stateKnown?: boolean
|
||||
retry?: { label: string; onRefresh: () => void | Promise<void> }
|
||||
enableLabel: string
|
||||
onEnabledChange: (enabled: boolean) => void
|
||||
} & (
|
||||
@@ -78,9 +81,20 @@ export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): Reac
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">{props.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{props.disabledDescription}</p>
|
||||
{props.failure && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{props.failure}
|
||||
</p>
|
||||
)}
|
||||
{props.failure && props.retry && (
|
||||
<Button variant="outline" size="sm" onClick={() => void props.retry?.onRefresh()}>
|
||||
{props.retry.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={false}
|
||||
disabled={props.stateKnown === false}
|
||||
aria-label={props.enableLabel}
|
||||
onCheckedChange={props.onEnabledChange}
|
||||
/>
|
||||
@@ -95,6 +109,11 @@ export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): Reac
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">{props.title}</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{props.status}</p>
|
||||
{props.failure && (
|
||||
<p role="alert" className="mt-1 text-xs text-destructive">
|
||||
{props.failure}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
{props.headerAction}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { UsageFailure } from '../../store/slices/usage/usage-provider-types'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
|
||||
export function usageFailureMessage(failure: UsageFailure | null, t: Translate): string | null {
|
||||
if (failure?.terminal) {
|
||||
return t('usage.failure.unavailable', 'Usage is unavailable. Try Refresh to reconnect.')
|
||||
}
|
||||
if (failure?.setting) {
|
||||
return t(
|
||||
'usage.failure.setting',
|
||||
'The tracking change could not be confirmed. Try changing it again.'
|
||||
)
|
||||
}
|
||||
if (failure?.refresh) {
|
||||
return t(
|
||||
'usage.failure.refresh',
|
||||
'The previous Refresh could not be completed. Try Refresh again.'
|
||||
)
|
||||
}
|
||||
if (failure?.read) {
|
||||
return t('usage.failure.read', 'Usage could not be loaded. Try Refresh again.')
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ClaudeUsagePane } from './ClaudeUsagePane'
|
||||
import { CodexUsagePane } from './CodexUsagePane'
|
||||
import { OpenCodeUsagePane } from './OpenCodeUsagePane'
|
||||
import { MuseUsagePane } from './MuseUsagePane'
|
||||
import { UsageOverviewPane } from './UsageOverviewPane'
|
||||
import type { AppState } from '../../store/types'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import es from '../../i18n/locales/es.json'
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Setup replaces this placeholder before any usage component renders.
|
||||
const fixture = vi.hoisted(() => ({ state: {} as AppState }))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (select: (state: AppState) => unknown) => select(fixture.state)
|
||||
}))
|
||||
const providers = [
|
||||
['claude', 'Claude', ClaudeUsagePane],
|
||||
['codex', 'Codex', CodexUsagePane],
|
||||
['openCode', 'OpenCode', OpenCodeUsagePane],
|
||||
['muse', 'Muse', MuseUsagePane]
|
||||
] as const
|
||||
function setup(kind: 'unknown' | 'disabled' | 'empty' | 'cached') {
|
||||
const values: Record<string, unknown> = { recordFeatureInteraction: vi.fn() }
|
||||
for (const [prefix, name] of providers) {
|
||||
Object.assign(values, {
|
||||
[`${prefix}UsageFailure`]: { terminal: false, read: false, refresh: true, setting: false },
|
||||
[`${prefix}UsageScanState`]:
|
||||
kind === 'unknown'
|
||||
? null
|
||||
: {
|
||||
enabled: kind !== 'disabled',
|
||||
isScanning: false,
|
||||
lastScanCompletedAt: kind === 'empty' ? null : 1,
|
||||
lastScanError: kind === 'cached' ? 'source scan failed' : null,
|
||||
[`hasAny${name}Data`]: false
|
||||
},
|
||||
[`${prefix}UsageSummary`]: null,
|
||||
[`${prefix}UsageDaily`]: [],
|
||||
[`${prefix}UsageScope`]: 'orca',
|
||||
[`${prefix}UsageRange`]: '30d',
|
||||
[`observe${name}Usage`]: () => () => {},
|
||||
[`refresh${name}Usage`]: vi.fn(),
|
||||
[`enable${name}Usage`]: vi.fn(),
|
||||
[`set${name}UsageEnabled`]: vi.fn()
|
||||
})
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the populated usage surface selectors are exercised.
|
||||
fixture.state = values as unknown as AppState
|
||||
}
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it.each(providers)(
|
||||
'%s exposes failure and Refresh in unknown, disabled, empty and cached states',
|
||||
(_prefix, name, Pane) => {
|
||||
for (const kind of ['unknown', 'disabled', 'empty', 'cached'] as const) {
|
||||
setup(kind)
|
||||
const view = render(<Pane />)
|
||||
expect(screen.getByRole('alert').textContent).toBe(
|
||||
'The previous Refresh could not be completed. Try Refresh again.'
|
||||
)
|
||||
if (kind === 'cached') {
|
||||
expect(screen.getByText(/Updated/).textContent).toContain('source scan failed')
|
||||
}
|
||||
const retry = screen.getByRole('button', { name: /Refresh/ })
|
||||
expect(retry.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(retry)
|
||||
expect(fixture.state[`refresh${name}Usage`]).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('switch').hasAttribute('disabled')).toBe(kind === 'unknown')
|
||||
view.unmount()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('deduplicates overview failure, retries unknown providers without enabling, and removes recovered warnings', () => {
|
||||
setup('unknown')
|
||||
const view = render(
|
||||
<TooltipProvider>
|
||||
<UsageOverviewPane />
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1)
|
||||
expect(screen.queryByText('Start tracking tokens')).toBeNull()
|
||||
expect(screen.queryByText('0 enabled - 0 with data')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh usage overview' }))
|
||||
for (const [prefix, name] of providers) {
|
||||
expect(fixture.state[`refresh${name}Usage`]).toHaveBeenCalledTimes(1)
|
||||
expect(fixture.state[`enable${name}Usage`]).not.toHaveBeenCalled()
|
||||
fixture.state[`${prefix}UsageFailure`] = null
|
||||
}
|
||||
view.rerender(
|
||||
<TooltipProvider>
|
||||
<UsageOverviewPane />
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
setup('disabled')
|
||||
view.rerender(
|
||||
<TooltipProvider>
|
||||
<UsageOverviewPane />
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.getByText('Start tracking tokens')).toBeTruthy()
|
||||
expect(screen.getByText('0 enabled - 0 with data')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders translated failure and retry on the provider and overview', async () => {
|
||||
i18n.addResourceBundle('es', 'translation', es)
|
||||
await i18n.changeLanguage('es')
|
||||
setup('unknown')
|
||||
const view = render(<ClaudeUsagePane />)
|
||||
expect(screen.getByRole('alert').textContent).toBe(
|
||||
'No se pudo completar la actualización anterior. Pulsa Actualizar de nuevo.'
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Actualizar' }))
|
||||
expect(fixture.state.refreshClaudeUsage).toHaveBeenCalledTimes(1)
|
||||
view.unmount()
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UsageOverviewPane />
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.queryByText('Empezar a rastrear tokens')).toBeNull()
|
||||
expect(screen.getByRole('alert').textContent).toContain(
|
||||
'No se pudo completar la actualización anterior.'
|
||||
)
|
||||
})
|
||||
@@ -240,11 +240,13 @@ export function DailyIntensityGrid({
|
||||
export function ProviderUsageRow({
|
||||
provider,
|
||||
totalTokens,
|
||||
onEnable
|
||||
onEnable,
|
||||
stateKnown = true
|
||||
}: {
|
||||
provider: UsageProviderOverview
|
||||
totalTokens: number
|
||||
onEnable: () => void
|
||||
stateKnown?: boolean
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
const intlLocale = useIntlLocale()
|
||||
@@ -262,7 +264,7 @@ export function ProviderUsageRow({
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h5 className="truncate text-sm font-semibold text-foreground">{provider.label}</h5>
|
||||
<Badge variant={statusVariant}>{status}</Badge>
|
||||
<Badge variant={statusVariant}>{stateKnown ? status : '—'}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{provider.topModel ??
|
||||
@@ -271,7 +273,7 @@ export function ProviderUsageRow({
|
||||
</p>
|
||||
</div>
|
||||
{!provider.enabled ? (
|
||||
<Button variant="outline" size="xs" onClick={onEnable}>
|
||||
<Button variant="outline" size="xs" onClick={onEnable} disabled={!stateKnown}>
|
||||
{t('auto.components.stats.usage.overview.sections.57d1448ef8', 'Enable')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -25277,5 +25277,14 @@
|
||||
"notGitRepository": "Not a valid Git repository",
|
||||
"importFailed": "Repository could not be imported"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"failure": {
|
||||
"unavailable": "Usage is unavailable. Try Refresh to reconnect.",
|
||||
"setting": "The tracking change could not be confirmed. Try changing it again.",
|
||||
"refresh": "The previous Refresh could not be completed. Try Refresh again.",
|
||||
"read": "Usage could not be loaded. Try Refresh again.",
|
||||
"retry": "Refresh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24207,5 +24207,14 @@
|
||||
"dismiss": "Descartar",
|
||||
"cancelDownload": "Cancelar descarga"
|
||||
}
|
||||
},
|
||||
"usage": {
|
||||
"failure": {
|
||||
"unavailable": "El uso no está disponible. Pulsa Actualizar para reconectar.",
|
||||
"setting": "No se pudo confirmar el cambio de seguimiento. Intenta cambiarlo de nuevo.",
|
||||
"refresh": "No se pudo completar la actualización anterior. Pulsa Actualizar de nuevo.",
|
||||
"read": "No se pudo cargar el uso. Pulsa Actualizar de nuevo.",
|
||||
"retry": "Actualizar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { MessageChannel, type MessagePort } from 'node:worker_threads'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { BackendPortRpc, type BackendPort } from '../../../../../shared/backend-port-rpc'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import { createClaudeUsageSlice } from './usage-provider-slices'
|
||||
import { createUsageProviderApi } from '../../../../../preload/usage-provider-api'
|
||||
import type { AppState } from '../../types'
|
||||
import type { ClaudeUsageScanState } from '../../../../../shared/claude-usage-types'
|
||||
import {
|
||||
BACKEND_CONNECTION_CHANGED,
|
||||
BACKEND_PORT_DELIVERY
|
||||
} from '../../../../../shared/backend-service-contract'
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (...args: unknown[]) => void>(),
|
||||
invoke: vi.fn(async (_channel: string, _token: string, _purpose?: string) => {})
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
ipcRenderer: {
|
||||
on: (channel: string, listener: (...args: unknown[]) => void) => {
|
||||
electron.handlers.set(channel, listener)
|
||||
},
|
||||
invoke: electron.invoke
|
||||
}
|
||||
}))
|
||||
|
||||
const servers: BackendPortRpc[] = []
|
||||
const ports: MessagePort[] = []
|
||||
const detach: (() => void)[] = []
|
||||
|
||||
function emit(channel: string, data: unknown, delivered: MessagePort[] = []): void {
|
||||
electron.handlers.get(channel)?.({ ports: delivered }, data)
|
||||
}
|
||||
|
||||
function deliver(invoke: (method: string, params?: unknown) => Promise<unknown>): () => void {
|
||||
const channel = new MessageChannel()
|
||||
const added = vi.spyOn(channel.port2, 'addEventListener')
|
||||
ports.push(channel.port1, channel.port2)
|
||||
const adapter: BackendPort = {
|
||||
postMessage: (message) => channel.port1.postMessage(message),
|
||||
onMessage(listener) {
|
||||
channel.port1.on('message', listener)
|
||||
return () => channel.port1.off('message', listener)
|
||||
},
|
||||
onClose(listener) {
|
||||
channel.port1.on('close', listener)
|
||||
return () => channel.port1.off('close', listener)
|
||||
},
|
||||
close: () => channel.port1.close()
|
||||
}
|
||||
servers.push(new BackendPortRpc(adapter, 'backend', invoke))
|
||||
emit(BACKEND_PORT_DELIVERY, { token: electron.invoke.mock.lastCall?.[1] }, [channel.port2])
|
||||
const close = added.mock.calls.find(([event]) => event === 'close')?.[1]
|
||||
return () => {
|
||||
if (typeof close === 'function') {
|
||||
close.call(channel.port2, new Event('close'))
|
||||
} else {
|
||||
close?.handleEvent(new Event('close'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
detach.splice(0).forEach((unsubscribe) => unsubscribe())
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
servers.splice(0).forEach((server) => server.close())
|
||||
ports.splice(0).forEach((port) => port.close())
|
||||
electron.handlers.clear()
|
||||
electron.invoke.mockClear()
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('publishes port loss before rejection and preserves fresh work through duplicate loss', async () => {
|
||||
const { backendUsageTransport: transport } =
|
||||
await import('../../../../../preload/backend-port-client')
|
||||
const events: string[] = []
|
||||
let fresh: Promise<unknown> | undefined
|
||||
detach.push(
|
||||
transport.onConnectionChanged((connected, detail) => {
|
||||
if (detail?.initial || detail?.unavailable) {
|
||||
return
|
||||
}
|
||||
events.push(connected ? 'ready' : 'lost')
|
||||
if (!connected) {
|
||||
fresh = transport.invoke('snapshot')
|
||||
}
|
||||
})
|
||||
)
|
||||
const started = Promise.withResolvers<void>()
|
||||
const held = Promise.withResolvers<unknown>()
|
||||
const first = transport.invoke('snapshot').catch(() => events.push('rejected'))
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: true })
|
||||
const closeAgain = deliver(async () => {
|
||||
started.resolve()
|
||||
return held.promise
|
||||
})
|
||||
await started.promise
|
||||
servers[0].close()
|
||||
await first
|
||||
expect(events).toEqual(['ready', 'lost', 'rejected'])
|
||||
expect(electron.invoke).toHaveBeenCalledTimes(2)
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
deliver(async () => ({ inputTokens: 123 }))
|
||||
await expect(fresh).resolves.toEqual({ inputTokens: 123 })
|
||||
closeAgain()
|
||||
expect(events).toEqual(['ready', 'lost', 'rejected', 'ready'])
|
||||
held.resolve(null)
|
||||
await expect(transport.invoke('snapshot')).resolves.toEqual({ inputTokens: 123 })
|
||||
expect(electron.invoke).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a replacement handshake when the old handshake rejects after loss', async () => {
|
||||
const { backendUsageTransport: transport } =
|
||||
await import('../../../../../preload/backend-port-client')
|
||||
let fresh: Promise<unknown> | undefined
|
||||
detach.push(
|
||||
transport.onConnectionChanged((connected, detail) => {
|
||||
if (detail?.initial || detail?.unavailable) {
|
||||
return
|
||||
}
|
||||
if (!connected) {
|
||||
fresh = transport.invoke('snapshot')
|
||||
}
|
||||
})
|
||||
)
|
||||
const first = transport.invoke('snapshot')
|
||||
const rejected = expect(first).rejects.toThrow('Usage disconnected')
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
await rejected
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
deliver(async () => ({ inputTokens: 123 }))
|
||||
await expect(fresh).resolves.toEqual({ inputTokens: 123 })
|
||||
expect(electron.invoke).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('initializes terminal presentation without RPCs and preserves a fresh handshake through terminal escalation', async () => {
|
||||
const { backendUsageTransport: transport } =
|
||||
await import('../../../../../preload/backend-port-client')
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
const fresh = transport.invoke('snapshot')
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false, unavailable: true })
|
||||
const statuses: unknown[] = []
|
||||
detach.push(
|
||||
transport.onConnectionChanged((connected, detail) => statuses.push({ connected, detail }))
|
||||
)
|
||||
expect(statuses).toEqual([{ connected: false, detail: { initial: true, unavailable: true } }])
|
||||
expect(electron.invoke).toHaveBeenCalledTimes(1)
|
||||
deliver(async () => ({ inputTokens: 123 }))
|
||||
await expect(fresh).resolves.toEqual({ inputTokens: 123 })
|
||||
expect(statuses.at(-1)).toEqual({ connected: true, detail: undefined })
|
||||
})
|
||||
|
||||
it('clears terminal metadata on main ready without port delivery or mounted demand', async () => {
|
||||
const { backendUsageTransport: transport } =
|
||||
await import('../../../../../preload/backend-port-client')
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false, unavailable: true })
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: true })
|
||||
const statuses: unknown[] = []
|
||||
detach.push(
|
||||
transport.onConnectionChanged((connected, detail) => statuses.push({ connected, detail }))
|
||||
)
|
||||
expect(statuses).toEqual([{ connected: true, detail: { initial: true, unavailable: false } }])
|
||||
expect(electron.invoke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('promotes pending recovery once and ignores its stale delivery and rejection', async () => {
|
||||
const { backendUsageTransport: transport } =
|
||||
await import('../../../../../preload/backend-port-client')
|
||||
const old = Promise.withResolvers<void>()
|
||||
electron.invoke.mockImplementationOnce(() => old.promise)
|
||||
const recovery = transport.invoke('snapshot', undefined, 'recovery')
|
||||
const oldToken = electron.invoke.mock.lastCall?.[1]
|
||||
const demand = transport.invoke('snapshot')
|
||||
const sharedDemand = transport.invoke('snapshot')
|
||||
expect(electron.invoke.mock.calls.map((call) => call[2])).toEqual(['recovery', 'demand'])
|
||||
expect(electron.invoke.mock.lastCall?.[1]).not.toBe(oldToken)
|
||||
const stale = new MessageChannel()
|
||||
ports.push(stale.port1, stale.port2)
|
||||
const close = vi.spyOn(stale.port2, 'close')
|
||||
emit(BACKEND_PORT_DELIVERY, { token: oldToken }, [stale.port2])
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
old.reject(new Error('Old document replaced'))
|
||||
await Promise.resolve()
|
||||
deliver(async () => ({ inputTokens: 123 }))
|
||||
await expect(Promise.all([recovery, demand, sharedDemand])).resolves.toEqual([
|
||||
{ inputTokens: 123 },
|
||||
{ inputTokens: 123 },
|
||||
{ inputTokens: 123 }
|
||||
])
|
||||
expect(electron.invoke).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels pending recovery on loss without rearming, and admits fresh post-loss demand', async () => {
|
||||
const { backendUsageTransport: transport } =
|
||||
await import('../../../../../preload/backend-port-client')
|
||||
const old = Promise.withResolvers<void>()
|
||||
electron.invoke.mockImplementationOnce(() => old.promise)
|
||||
const recovery = transport.invoke('snapshot', undefined, 'recovery')
|
||||
const failed = expect(recovery).rejects.toThrow('Usage disconnected')
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
await failed
|
||||
expect(electron.invoke).toHaveBeenCalledTimes(1)
|
||||
const fresh = transport.invoke('snapshot')
|
||||
old.reject(new Error('Old acquisition failed'))
|
||||
await Promise.resolve()
|
||||
deliver(async () => 123)
|
||||
await expect(fresh).resolves.toBe(123)
|
||||
expect(electron.invoke.mock.calls.map((call) => call[2])).toEqual(['recovery', 'demand'])
|
||||
})
|
||||
|
||||
it('carries mounted recovery purpose through both real usage reads without sending it to the service', async () => {
|
||||
const { backendUsageTransport: transport } =
|
||||
await import('../../../../../preload/backend-port-client')
|
||||
const invoke = vi.spyOn(transport, 'invoke')
|
||||
const scanState: ClaudeUsageScanState = {
|
||||
enabled: true,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: 1,
|
||||
lastScanError: null,
|
||||
hasAnyClaudeData: true
|
||||
}
|
||||
const snapshot = {
|
||||
scanState,
|
||||
summary: { inputTokens: 123 },
|
||||
daily: [],
|
||||
modelBreakdown: [],
|
||||
projectBreakdown: [],
|
||||
recentSessions: []
|
||||
}
|
||||
const received = vi.fn(async (method: string, _params?: unknown) =>
|
||||
method.endsWith(':getSnapshot') ? snapshot : scanState
|
||||
)
|
||||
vi.stubGlobal('window', {
|
||||
api: { claudeUsage: createUsageProviderApi(transport, 'claudeUsage') }
|
||||
})
|
||||
const store = createStore<AppState>()((...args) => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This fixture uses only the production usage slice's public state/actions.
|
||||
return createClaudeUsageSlice(...args) as AppState
|
||||
})
|
||||
detach.push(store.getState().observeClaudeUsage())
|
||||
await vi.waitFor(() => expect(electron.invoke).toHaveBeenCalledTimes(1))
|
||||
expect(electron.invoke.mock.lastCall?.[2]).toBe('demand')
|
||||
deliver(received)
|
||||
await store.getState().fetchClaudeUsage()
|
||||
invoke.mockClear()
|
||||
received.mockClear()
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: false })
|
||||
emit(BACKEND_CONNECTION_CHANGED, { connected: true })
|
||||
await vi.waitFor(() => expect(electron.invoke).toHaveBeenCalledTimes(2))
|
||||
expect(electron.invoke.mock.lastCall?.[2]).toBe('recovery')
|
||||
deliver(received)
|
||||
await vi.waitFor(() => expect(received).toHaveBeenCalledTimes(2))
|
||||
expect(invoke.mock.calls).toEqual([
|
||||
['claudeUsage:getScanState', undefined, 'recovery'],
|
||||
['claudeUsage:getSnapshot', { scope: 'orca', range: '30d', limit: 10 }, 'recovery']
|
||||
])
|
||||
expect(received.mock.calls).toEqual([
|
||||
['claudeUsage:getScanState', undefined, expect.any(AbortSignal)],
|
||||
['claudeUsage:getSnapshot', { scope: 'orca', range: '30d', limit: 10 }, expect.any(AbortSignal)]
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
BackendConnectionListener,
|
||||
BackendConnectionPurpose
|
||||
} from '../../../../../shared/backend-service-contract'
|
||||
import type {
|
||||
ClaudeUsageRange,
|
||||
ClaudeUsageScope,
|
||||
ClaudeUsageSnapshot
|
||||
} from '../../../../../shared/claude-usage-types'
|
||||
import type {
|
||||
CodexUsageRange,
|
||||
CodexUsageScope,
|
||||
CodexUsageSnapshot
|
||||
} from '../../../../../shared/codex-usage-types'
|
||||
import type {
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageSnapshot
|
||||
} from '../../../../../shared/opencode-usage-types'
|
||||
import type {
|
||||
MuseUsageRange,
|
||||
MuseUsageScope,
|
||||
MuseUsageSnapshot
|
||||
} from '../../../../../shared/muse-usage-types'
|
||||
import type { UsageSnapshot, UsageProviderTypes, UsageFailure } from './usage-provider-types'
|
||||
|
||||
export type UsageData<T extends UsageProviderTypes<string, string, UsageSnapshot>> = {
|
||||
failure: UsageFailure | null
|
||||
scope: T['scope']
|
||||
range: T['range']
|
||||
scanState: T['snapshot']['scanState'] | null
|
||||
summary: T['snapshot']['summary'] | null
|
||||
daily: T['snapshot']['daily']
|
||||
modelBreakdown: T['snapshot']['modelBreakdown']
|
||||
projectBreakdown: T['snapshot']['projectBreakdown']
|
||||
recentSessions: T['snapshot']['recentSessions']
|
||||
}
|
||||
|
||||
export type UsageApi<T extends UsageProviderTypes<string, string, UsageSnapshot>> = {
|
||||
onConnectionChanged?: (listener: BackendConnectionListener) => () => void
|
||||
getScanState: (purpose?: BackendConnectionPurpose) => Promise<T['snapshot']['scanState']>
|
||||
setEnabled: (args: { enabled: boolean }) => Promise<T['snapshot']['scanState']>
|
||||
refresh: (args?: { force?: boolean }) => Promise<T['snapshot']['scanState']>
|
||||
getSnapshot: (
|
||||
args: {
|
||||
scope: T['scope']
|
||||
range: T['range']
|
||||
limit?: number
|
||||
},
|
||||
purpose?: BackendConnectionPurpose
|
||||
) => Promise<T['snapshot']>
|
||||
}
|
||||
|
||||
export type ProviderUsageSlice<
|
||||
Prefix extends string,
|
||||
Name extends string,
|
||||
T extends UsageProviderTypes<string, string, UsageSnapshot>
|
||||
> = {
|
||||
[K in keyof UsageData<T> as `${Prefix}Usage${Capitalize<K & string>}`]: UsageData<T>[K]
|
||||
} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise<void>> &
|
||||
Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise<void>> &
|
||||
Record<`set${Name}UsageRange`, (range: T['range']) => Promise<void>> &
|
||||
Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise<void>> &
|
||||
Record<`observe${Name}Usage`, () => () => void> &
|
||||
Record<`enable${Name}Usage`, () => Promise<void>> &
|
||||
Record<`refresh${Name}Usage`, () => Promise<void>>
|
||||
|
||||
export type UsageProviderConfig<
|
||||
Prefix extends string,
|
||||
Name extends string,
|
||||
T extends UsageProviderTypes<string, string, UsageSnapshot>
|
||||
> = {
|
||||
prefix: Prefix
|
||||
name: Name
|
||||
initialScope: T['scope']
|
||||
initialRange: T['range']
|
||||
getApi: () => UsageApi<T>
|
||||
hasCachedData: (scanState: T['snapshot']['scanState']) => boolean
|
||||
}
|
||||
export type ClaudeUsageTypes = UsageProviderTypes<
|
||||
ClaudeUsageScope,
|
||||
ClaudeUsageRange,
|
||||
ClaudeUsageSnapshot
|
||||
>
|
||||
export type CodexUsageTypes = UsageProviderTypes<
|
||||
CodexUsageScope,
|
||||
CodexUsageRange,
|
||||
CodexUsageSnapshot
|
||||
>
|
||||
export type OpenCodeUsageTypes = UsageProviderTypes<
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageSnapshot
|
||||
>
|
||||
|
||||
export type MuseUsageTypes = UsageProviderTypes<MuseUsageScope, MuseUsageRange, MuseUsageSnapshot>
|
||||
|
||||
export type ClaudeUsageSlice = ProviderUsageSlice<'claude', 'Claude', ClaudeUsageTypes>
|
||||
export type CodexUsageSlice = ProviderUsageSlice<'codex', 'Codex', CodexUsageTypes>
|
||||
export type OpenCodeUsageSlice = ProviderUsageSlice<'openCode', 'OpenCode', OpenCodeUsageTypes>
|
||||
export type MuseUsageSlice = ProviderUsageSlice<'muse', 'Muse', MuseUsageTypes>
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { AppState } from '../../types'
|
||||
import type { UsageSnapshot, UsageProviderTypes, UsageFailure } from './usage-provider-types'
|
||||
import type { UsageData } from './usage-provider-slice-contract'
|
||||
|
||||
const usageDataFields = [
|
||||
'failure',
|
||||
'scope',
|
||||
'range',
|
||||
'scanState',
|
||||
'summary',
|
||||
'daily',
|
||||
'modelBreakdown',
|
||||
'projectBreakdown',
|
||||
'recentSessions'
|
||||
] as const satisfies readonly (keyof UsageData<UsageProviderTypes<string, string, UsageSnapshot>>)[]
|
||||
|
||||
function usageDataKey(prefix: string, field: string): string {
|
||||
return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}`
|
||||
}
|
||||
|
||||
export function readUsageData<T extends UsageProviderTypes<string, string, UsageSnapshot>>(
|
||||
state: AppState,
|
||||
prefix: string
|
||||
): UsageData<T> {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: AppState contains each configured provider field; the prefix is fixed by the slice factory.
|
||||
const values = state as unknown as Record<string, unknown>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixed field list maps the configured provider keys back to their data fields.
|
||||
return Object.fromEntries(
|
||||
usageDataFields.map((field) => [field, values[usageDataKey(prefix, field)]])
|
||||
) as UsageData<T>
|
||||
}
|
||||
|
||||
export function createUsagePatch<T extends UsageProviderTypes<string, string, UsageSnapshot>>(
|
||||
prefix: string,
|
||||
patch: Partial<UsageData<T>>
|
||||
): Partial<AppState> {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixed field list maps the configured provider keys back to their data fields.
|
||||
return Object.fromEntries(
|
||||
usageDataFields
|
||||
.filter((field) => field in patch)
|
||||
.map((field) => [usageDataKey(prefix, field), patch[field]])
|
||||
) as Partial<AppState>
|
||||
}
|
||||
|
||||
export function createInitialUsageData<T extends UsageProviderTypes<string, string, UsageSnapshot>>(
|
||||
scope: T['scope'],
|
||||
range: T['range']
|
||||
): UsageData<T> {
|
||||
return {
|
||||
failure: null,
|
||||
scope,
|
||||
range,
|
||||
scanState: null,
|
||||
summary: null,
|
||||
daily: [],
|
||||
modelBreakdown: [],
|
||||
projectBreakdown: [],
|
||||
recentSessions: []
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeUsageFailure(
|
||||
previous: UsageFailure | null,
|
||||
patch: Partial<UsageFailure>
|
||||
): UsageFailure | null {
|
||||
const next = {
|
||||
terminal: false,
|
||||
read: false,
|
||||
refresh: false,
|
||||
setting: false,
|
||||
...previous,
|
||||
...patch
|
||||
}
|
||||
if (!next.terminal && !next.read && !next.refresh && !next.setting) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
previous &&
|
||||
previous.terminal === next.terminal &&
|
||||
previous.read === next.read &&
|
||||
previous.refresh === next.refresh &&
|
||||
previous.setting === next.setting
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type {
|
||||
UsageData,
|
||||
UsageApi,
|
||||
ProviderUsageSlice,
|
||||
UsageProviderConfig
|
||||
} from './usage-provider-slice-contract'
|
||||
import type { AppState } from '../../types'
|
||||
|
||||
import type { UsageSnapshot, UsageProviderTypes, UsageFailure } from './usage-provider-types'
|
||||
|
||||
import {
|
||||
mergeUsageFailure,
|
||||
readUsageData,
|
||||
createUsagePatch,
|
||||
createInitialUsageData
|
||||
} from './usage-provider-slice-state'
|
||||
|
||||
export function createUsageProviderSlice<
|
||||
Prefix extends string,
|
||||
Name extends string,
|
||||
T extends UsageProviderTypes<string, string, UsageSnapshot>
|
||||
>(
|
||||
config: UsageProviderConfig<Prefix, Name, T>
|
||||
): StateCreator<AppState, [], [], ProviderUsageSlice<Prefix, Name, T>> {
|
||||
return (set, get) => {
|
||||
const update = (patch: Partial<UsageData<T>>): void =>
|
||||
set(createUsagePatch(config.prefix, patch))
|
||||
const read = (): UsageData<T> => readUsageData<T>(get(), config.prefix)
|
||||
|
||||
const updateFailure = (patch: Partial<UsageFailure>): void => {
|
||||
const previous = read().failure
|
||||
const failure = mergeUsageFailure(previous, patch)
|
||||
if (failure !== previous) {
|
||||
update({ failure })
|
||||
}
|
||||
}
|
||||
|
||||
const stopLoading = (): void => {
|
||||
const state = read().scanState
|
||||
if (state?.isScanning) {
|
||||
update({ scanState: { ...state, isScanning: false } })
|
||||
}
|
||||
}
|
||||
|
||||
type Intent = 0 | 1 | 2
|
||||
type Operation = {
|
||||
intent: Intent
|
||||
phase: 'reading' | 'refreshing' | 'projecting'
|
||||
disconnected: boolean
|
||||
readOnFailure: boolean
|
||||
promise: Promise<void>
|
||||
}
|
||||
let operation: Operation | null = null
|
||||
let pending: Intent | null = null
|
||||
const queueIntent = (intent: Intent): void => {
|
||||
pending = pending === null || intent > pending ? intent : pending
|
||||
}
|
||||
const markLoading = (): void => {
|
||||
const state = read().scanState
|
||||
if (state?.enabled) {
|
||||
update({ scanState: { ...state, isScanning: true } })
|
||||
}
|
||||
}
|
||||
const project = async (
|
||||
api: UsageApi<T>,
|
||||
current: Operation,
|
||||
beforeRefresh: boolean
|
||||
): Promise<T['snapshot']['scanState'] | undefined> => {
|
||||
while (!current.disconnected) {
|
||||
const { scope, range } = read()
|
||||
const snapshot = await api.getSnapshot(
|
||||
{ scope, range, limit: 10 },
|
||||
current.intent === 0 ? 'recovery' : undefined
|
||||
)
|
||||
if (current.disconnected) {
|
||||
return
|
||||
}
|
||||
if (scope !== read().scope || range !== read().range) {
|
||||
continue
|
||||
}
|
||||
const loading = pending === 2 || (beforeRefresh && current.intent === 2)
|
||||
if (
|
||||
beforeRefresh &&
|
||||
current.intent > 0 &&
|
||||
snapshot.scanState.lastScanCompletedAt === null &&
|
||||
!config.hasCachedData(snapshot.scanState)
|
||||
) {
|
||||
update({ scanState: { ...snapshot.scanState, isScanning: true, lastScanError: null } })
|
||||
} else {
|
||||
update({
|
||||
...snapshot,
|
||||
scanState: loading ? { ...snapshot.scanState, isScanning: true } : snapshot.scanState
|
||||
})
|
||||
}
|
||||
return snapshot.scanState
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const request = (intent: Intent, recovery = false): Promise<void> => {
|
||||
if (intent === 2) {
|
||||
markLoading()
|
||||
}
|
||||
if (operation) {
|
||||
if (recovery) {
|
||||
operation.readOnFailure = true
|
||||
if (operation.disconnected) {
|
||||
queueIntent(0)
|
||||
}
|
||||
} else if (
|
||||
operation.disconnected ||
|
||||
intent > operation.intent ||
|
||||
(intent > 0 && operation.phase === 'projecting')
|
||||
) {
|
||||
queueIntent(intent)
|
||||
}
|
||||
return operation.promise
|
||||
}
|
||||
const current: Operation = {
|
||||
intent,
|
||||
phase: 'reading',
|
||||
disconnected: false,
|
||||
readOnFailure: false,
|
||||
promise: Promise.resolve()
|
||||
}
|
||||
operation = current
|
||||
const unsubscribe = config.getApi()?.onConnectionChanged?.((connected, detail) => {
|
||||
if (detail?.initial || detail?.unavailable) {
|
||||
return
|
||||
}
|
||||
if (!connected && operation === current) {
|
||||
if (current.intent === 2 || pending === 2) {
|
||||
updateFailure({ refresh: true })
|
||||
}
|
||||
current.disconnected = true
|
||||
current.readOnFailure = false
|
||||
pending = null
|
||||
stopLoading()
|
||||
}
|
||||
})
|
||||
// Publish the operation before the first port handshake can notify mounted readers.
|
||||
current.promise = Promise.resolve()
|
||||
.then(async () => {
|
||||
let next: Intent | null = intent
|
||||
while (next !== null) {
|
||||
current.intent = next
|
||||
current.phase = 'reading'
|
||||
current.disconnected = false
|
||||
current.readOnFailure = false
|
||||
pending = null
|
||||
try {
|
||||
const api = config.getApi()
|
||||
const state = await api.getScanState(next === 0 ? 'recovery' : undefined)
|
||||
if (current.disconnected) {
|
||||
throw new Error('Usage disconnected.')
|
||||
}
|
||||
if (state) {
|
||||
update({
|
||||
scanState:
|
||||
(next === 2 || pending === 2) && state.enabled
|
||||
? { ...state, isScanning: true }
|
||||
: state
|
||||
})
|
||||
if (state.enabled) {
|
||||
const baseline = await project(api, current, next > 0)
|
||||
if (current.disconnected) {
|
||||
throw new Error('Usage disconnected.')
|
||||
}
|
||||
if (next > 0) {
|
||||
current.phase = 'refreshing'
|
||||
const olderFailure = read().failure
|
||||
const refreshed = await api.refresh({ force: next === 2 })
|
||||
if (current.disconnected) {
|
||||
throw new Error('Usage disconnected.')
|
||||
}
|
||||
current.phase = 'projecting'
|
||||
const completed = await project(api, current, false)
|
||||
if (current.disconnected) {
|
||||
throw new Error('Usage disconnected.')
|
||||
}
|
||||
if (
|
||||
completed?.enabled &&
|
||||
!completed.isScanning &&
|
||||
!completed.lastScanError &&
|
||||
((next === 2 &&
|
||||
refreshed &&
|
||||
!refreshed.isScanning &&
|
||||
!refreshed.lastScanError) ||
|
||||
(next === 1 &&
|
||||
olderFailure === read().failure &&
|
||||
baseline &&
|
||||
completed.lastScanCompletedAt !== null &&
|
||||
(baseline.lastScanCompletedAt === null ||
|
||||
completed.lastScanCompletedAt > baseline.lastScanCompletedAt)))
|
||||
) {
|
||||
updateFailure({ refresh: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
updateFailure({ read: false })
|
||||
}
|
||||
} catch (error) {
|
||||
updateFailure({ read: true, ...(next === 2 ? { refresh: true } : {}) })
|
||||
if (pending !== 2) {
|
||||
stopLoading()
|
||||
}
|
||||
if (current.readOnFailure) {
|
||||
queueIntent(0)
|
||||
}
|
||||
console.error(`Failed to fetch ${config.name} usage:`, error)
|
||||
}
|
||||
next = pending
|
||||
}
|
||||
if (operation === current) {
|
||||
operation = null
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
unsubscribe?.()
|
||||
if (operation === current) {
|
||||
operation = null
|
||||
}
|
||||
})
|
||||
return current.promise
|
||||
}
|
||||
const fetchUsage = (opts?: { forceRefresh?: boolean }): Promise<void> =>
|
||||
request(opts?.forceRefresh ? 2 : 1)
|
||||
const observeUsage = (): (() => void) => {
|
||||
const unsubscribe = config.getApi()?.onConnectionChanged?.((connected, detail) => {
|
||||
if (detail?.initial) {
|
||||
updateFailure({ terminal: detail.unavailable === true })
|
||||
return
|
||||
}
|
||||
if (detail?.unavailable) {
|
||||
updateFailure({ terminal: true })
|
||||
return
|
||||
}
|
||||
if (connected) {
|
||||
updateFailure({ terminal: false })
|
||||
void request(0, true)
|
||||
} else {
|
||||
stopLoading()
|
||||
}
|
||||
})
|
||||
void fetchUsage()
|
||||
return () => unsubscribe?.()
|
||||
}
|
||||
|
||||
const setEnabled = async (enabled: boolean): Promise<void> => {
|
||||
try {
|
||||
const nextScanState = (await config.getApi().setEnabled({ enabled })) as
|
||||
| T['snapshot']['scanState']
|
||||
| undefined
|
||||
if (!nextScanState) {
|
||||
return
|
||||
}
|
||||
updateFailure(
|
||||
enabled
|
||||
? { setting: false, read: false, terminal: false }
|
||||
: { setting: false, read: false, terminal: false, refresh: false }
|
||||
)
|
||||
update({
|
||||
scanState: enabled
|
||||
? {
|
||||
...nextScanState,
|
||||
isScanning: true,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
: nextScanState,
|
||||
summary: null,
|
||||
daily: [],
|
||||
modelBreakdown: [],
|
||||
projectBreakdown: [],
|
||||
recentSessions: []
|
||||
})
|
||||
if (enabled) {
|
||||
await fetchUsage({ forceRefresh: true })
|
||||
}
|
||||
} catch (error) {
|
||||
updateFailure({ setting: true })
|
||||
stopLoading()
|
||||
console.error(`Failed to update ${config.name} usage setting:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The configured prefix/name maps the declared data and action keys for this provider.
|
||||
return {
|
||||
...createUsagePatch(
|
||||
config.prefix,
|
||||
createInitialUsageData<T>(config.initialScope, config.initialRange)
|
||||
),
|
||||
[`set${config.name}UsageEnabled`]: setEnabled,
|
||||
[`set${config.name}UsageScope`]: async (scope: T['scope']) => {
|
||||
update({ scope })
|
||||
await fetchUsage()
|
||||
},
|
||||
[`set${config.name}UsageRange`]: async (range: T['range']) => {
|
||||
update({ range })
|
||||
await fetchUsage()
|
||||
},
|
||||
[`fetch${config.name}Usage`]: fetchUsage,
|
||||
[`observe${config.name}Usage`]: observeUsage,
|
||||
[`enable${config.name}Usage`]: () => setEnabled(true),
|
||||
[`refresh${config.name}Usage`]: () => fetchUsage({ forceRefresh: true })
|
||||
} as ProviderUsageSlice<Prefix, Name, T>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import { createClaudeUsageSlice } from './usage-provider-slices'
|
||||
import { useUsageObservation } from './use-usage-observation'
|
||||
import type {
|
||||
BackendConnectionListener,
|
||||
BackendConnectionDetail
|
||||
} from '../../../../../shared/backend-service-contract'
|
||||
import type { AppState } from '../../types'
|
||||
import type { ClaudeUsageScanState } from '../../../../../shared/claude-usage-types'
|
||||
|
||||
const scanState: ClaudeUsageScanState = {
|
||||
enabled: true,
|
||||
isScanning: false,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: 1,
|
||||
lastScanError: null,
|
||||
hasAnyClaudeData: true
|
||||
}
|
||||
const snapshot = {
|
||||
scanState,
|
||||
summary: { turns: 1, inputTokens: 123 },
|
||||
daily: [],
|
||||
modelBreakdown: [],
|
||||
projectBreakdown: [],
|
||||
recentSessions: []
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function fixture() {
|
||||
const listeners = new Set<BackendConnectionListener>()
|
||||
let status = { connected: false, unavailable: false }
|
||||
const api = {
|
||||
onConnectionChanged: (listener: BackendConnectionListener) => {
|
||||
listeners.add(listener)
|
||||
listener(status.connected, { initial: true, unavailable: status.unavailable })
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
setEnabled: vi.fn(async ({ enabled }: { enabled: boolean }) => ({ ...scanState, enabled })),
|
||||
getScanState: vi.fn(async () => scanState),
|
||||
getSnapshot: vi.fn(async (_selection: { scope: string; range: string }) => snapshot),
|
||||
refresh: vi.fn(async (_options?: { force?: boolean }) => scanState)
|
||||
}
|
||||
vi.stubGlobal('api', { claudeUsage: api })
|
||||
const store = createStore<AppState>()((...args) => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture uses only this production slice's public state/actions.
|
||||
return createClaudeUsageSlice(...args) as AppState
|
||||
})
|
||||
return {
|
||||
store,
|
||||
api,
|
||||
notify: (connected: boolean, detail?: BackendConnectionDetail) => {
|
||||
status = { connected, unavailable: detail?.unavailable === true }
|
||||
for (const listener of listeners) {
|
||||
listener(connected, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it.each([1, 10])(
|
||||
'hydrates only %i mounted outputs and does no hidden reconnect work',
|
||||
async (count) => {
|
||||
const { api, notify } = fixture()
|
||||
const stores = Array.from({ length: count }, () =>
|
||||
createStore<AppState>()((...args) => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the usage slice is exercised.
|
||||
return createClaudeUsageSlice(...args) as AppState
|
||||
})
|
||||
)
|
||||
const hooks = stores.map((store) =>
|
||||
renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
|
||||
)
|
||||
await act(async () => {
|
||||
await Promise.all(stores.map((store) => store.getState().fetchClaudeUsage()))
|
||||
})
|
||||
api.getScanState.mockClear()
|
||||
api.getSnapshot.mockClear()
|
||||
api.refresh.mockClear()
|
||||
await act(async () => {
|
||||
notify(false)
|
||||
notify(true)
|
||||
})
|
||||
expect(api.getScanState).toHaveBeenCalledTimes(count)
|
||||
expect(api.getSnapshot).toHaveBeenCalledTimes(count)
|
||||
expect(api.refresh).not.toHaveBeenCalled()
|
||||
expect(stores[0].getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
for (const hook of hooks) {
|
||||
hook.unmount()
|
||||
}
|
||||
api.getScanState.mockClear()
|
||||
api.getSnapshot.mockClear()
|
||||
await act(async () => {
|
||||
notify(false)
|
||||
notify(true)
|
||||
})
|
||||
expect(api.getScanState).not.toHaveBeenCalled()
|
||||
expect(api.getSnapshot).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each([1, 10])('drops lost queued force after %i outputs unmount', async (count) => {
|
||||
const { api, notify } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const refresh = deferred<typeof scanState>()
|
||||
api.refresh.mockReturnValue(refresh.promise)
|
||||
const stores = Array.from({ length: count }, () =>
|
||||
createStore<AppState>()((...args) => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the usage slice is exercised.
|
||||
return createClaudeUsageSlice(...args) as AppState
|
||||
})
|
||||
)
|
||||
const hooks = stores.map((store) =>
|
||||
renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(api.refresh).toHaveBeenCalledTimes(count)
|
||||
const forced = stores.map((store) => store.getState().refreshClaudeUsage())
|
||||
for (const hook of hooks) {
|
||||
hook.unmount()
|
||||
}
|
||||
api.getScanState.mockClear()
|
||||
api.getSnapshot.mockClear()
|
||||
api.refresh.mockClear()
|
||||
await act(async () => {
|
||||
notify(false)
|
||||
refresh.reject(new Error('port closed'))
|
||||
await Promise.all(forced)
|
||||
notify(true)
|
||||
})
|
||||
expect(api.getScanState).not.toHaveBeenCalled()
|
||||
expect(api.getSnapshot).not.toHaveBeenCalled()
|
||||
expect(api.refresh).not.toHaveBeenCalled()
|
||||
expect(stores[0].getState().claudeUsageScanState?.isScanning).toBe(false)
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves admitted work after unmount and same-connection failure=%s',
|
||||
async (fail) => {
|
||||
const { api, store } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const refresh = deferred<typeof scanState>()
|
||||
api.refresh.mockReturnValueOnce(refresh.promise)
|
||||
const hook = renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
|
||||
await act(async () => {})
|
||||
expect(api.refresh).toHaveBeenCalledTimes(1)
|
||||
const forced = store.getState().refreshClaudeUsage()
|
||||
hook.unmount()
|
||||
if (fail) {
|
||||
refresh.reject(new Error('scan failed on the same connection'))
|
||||
} else {
|
||||
refresh.resolve(scanState)
|
||||
}
|
||||
await forced
|
||||
expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
|
||||
expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
}
|
||||
)
|
||||
|
||||
it('retains fresh explicit intent after loss while the old call is rejecting', async () => {
|
||||
const { api, store, notify } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const refresh = deferred<typeof scanState>()
|
||||
api.refresh.mockReturnValueOnce(refresh.promise)
|
||||
const hook = renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
|
||||
await act(async () => {})
|
||||
const oldForce = store.getState().refreshClaudeUsage()
|
||||
hook.unmount()
|
||||
notify(false)
|
||||
const fresh = store.getState().refreshClaudeUsage()
|
||||
refresh.reject(new Error('old port closed'))
|
||||
await Promise.all([oldForce, fresh])
|
||||
expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
|
||||
expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
})
|
||||
|
||||
it('retains mounted recovery after the first state request fails', async () => {
|
||||
const { store, api, notify } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
api.getScanState.mockRejectedValueOnce(new Error('first connection failed'))
|
||||
renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
|
||||
await act(async () => {
|
||||
await store.getState().fetchClaudeUsage()
|
||||
})
|
||||
expect(store.getState().claudeUsageScanState).toBeNull()
|
||||
await act(async () => {
|
||||
notify(true)
|
||||
})
|
||||
expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
expect(api.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('joins the startup-ready signal and bounds selection storms while retaining a late force', async () => {
|
||||
const { store, api, notify } = fixture()
|
||||
const refresh = deferred<typeof scanState>()
|
||||
api.refresh.mockReturnValueOnce(refresh.promise)
|
||||
renderHook(() => useUsageObservation(store.getState().observeClaudeUsage))
|
||||
await act(async () => {
|
||||
notify(true)
|
||||
})
|
||||
expect(api.getScanState).toHaveBeenCalledTimes(1)
|
||||
const requests = Array.from({ length: 40 }, (_, i) =>
|
||||
store.getState().setClaudeUsageRange(i === 39 ? 'all' : '7d')
|
||||
)
|
||||
const forced = store.getState().refreshClaudeUsage()
|
||||
await act(async () => {
|
||||
refresh.resolve(scanState)
|
||||
await Promise.all([...requests, forced])
|
||||
})
|
||||
expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
|
||||
expect(store.getState().claudeUsageRange).toBe('all')
|
||||
expect(api.getSnapshot.mock.calls.at(-1)?.[0]).toMatchObject({ range: 'all' })
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'owns queued explicit refresh across ordinary failure=%s',
|
||||
async (failed) => {
|
||||
const { store, api } = fixture()
|
||||
const background = deferred<typeof scanState>()
|
||||
const forcedState = deferred<typeof scanState>()
|
||||
const forcedScan = deferred<typeof scanState>()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
api.getScanState.mockResolvedValueOnce(scanState).mockReturnValueOnce(forcedState.promise)
|
||||
api.refresh.mockReturnValueOnce(background.promise).mockReturnValueOnce(forcedScan.promise)
|
||||
const first = store.getState().fetchClaudeUsage()
|
||||
await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1))
|
||||
expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
|
||||
|
||||
const forced = store.getState().refreshClaudeUsage()
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
|
||||
const busy: boolean[] = []
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
busy.push(state.claudeUsageScanState?.isScanning ?? false)
|
||||
})
|
||||
if (failed) {
|
||||
background.reject(new Error('Catalog unavailable'))
|
||||
} else {
|
||||
background.resolve(scanState)
|
||||
}
|
||||
await vi.waitFor(() => expect(api.getScanState).toHaveBeenCalledTimes(2))
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
|
||||
expect(busy.every(Boolean)).toBe(true)
|
||||
forcedState.resolve(scanState)
|
||||
await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(2))
|
||||
expect(busy.length).toBeGreaterThan(0)
|
||||
expect(busy.every(Boolean)).toBe(true)
|
||||
expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
unsubscribe()
|
||||
forcedScan.resolve(scanState)
|
||||
await Promise.all([first, forced])
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('shows first-load scanning without masking a reported scan on answered data', async () => {
|
||||
const { store, api } = fixture()
|
||||
const background = deferred<typeof scanState>()
|
||||
api.getSnapshot.mockResolvedValueOnce({
|
||||
...snapshot,
|
||||
scanState: { ...scanState, lastScanCompletedAt: null, hasAnyClaudeData: false }
|
||||
})
|
||||
api.refresh.mockReturnValueOnce(background.promise)
|
||||
const first = store.getState().fetchClaudeUsage()
|
||||
await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(1))
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
|
||||
background.resolve(scanState)
|
||||
await first
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
|
||||
|
||||
const scanning = deferred<typeof scanState>()
|
||||
api.getScanState.mockResolvedValueOnce({ ...scanState, isScanning: true })
|
||||
api.getSnapshot.mockResolvedValueOnce({
|
||||
...snapshot,
|
||||
scanState: { ...scanState, isScanning: true }
|
||||
})
|
||||
api.refresh.mockReturnValueOnce(scanning.promise)
|
||||
const next = store.getState().fetchClaudeUsage()
|
||||
await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(2))
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(true)
|
||||
scanning.resolve(scanState)
|
||||
await next
|
||||
})
|
||||
|
||||
it('does not publish an obsolete selection or swallow force after refresh has finished', async () => {
|
||||
const { store, api } = fixture()
|
||||
const projection = deferred<typeof snapshot>()
|
||||
api.getSnapshot.mockResolvedValueOnce(snapshot).mockReturnValueOnce(projection.promise)
|
||||
const first = store.getState().fetchClaudeUsage()
|
||||
await vi.waitFor(() => expect(api.getSnapshot).toHaveBeenCalledTimes(2))
|
||||
const selected = store.getState().setClaudeUsageRange('all')
|
||||
const forced = store.getState().refreshClaudeUsage()
|
||||
projection.resolve({ ...snapshot, summary: { turns: 2, inputTokens: 999 } })
|
||||
await Promise.all([first, selected, forced])
|
||||
expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([false, true])
|
||||
expect(api.getSnapshot.mock.calls.at(-1)?.[0]).toMatchObject({ range: 'all' })
|
||||
})
|
||||
|
||||
it('shows lost Refresh through read-only recovery and clears it only after a successful explicit retry', async () => {
|
||||
const { api, store, notify } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const stop = store.getState().observeClaudeUsage()
|
||||
await store.getState().fetchClaudeUsage()
|
||||
const held = deferred<typeof scanState>()
|
||||
api.refresh.mockReturnValueOnce(held.promise)
|
||||
const refresh = store.getState().refreshClaudeUsage()
|
||||
await vi.waitFor(() => expect(api.refresh).toHaveBeenCalledTimes(2))
|
||||
notify(false)
|
||||
notify(false, { unavailable: true })
|
||||
held.reject(new Error('Usage disconnected'))
|
||||
await refresh
|
||||
expect(store.getState().claudeUsageFailure).toMatchObject({ terminal: true, refresh: true })
|
||||
expect(store.getState().claudeUsageScanState?.isScanning).toBe(false)
|
||||
api.refresh.mockClear()
|
||||
notify(true)
|
||||
await store.getState().fetchClaudeUsage()
|
||||
expect(api.refresh).not.toHaveBeenCalled()
|
||||
expect(store.getState().claudeUsageFailure).toMatchObject({ terminal: false, refresh: true })
|
||||
expect(store.getState().claudeUsageSummary).toMatchObject({ inputTokens: 123 })
|
||||
await store.getState().refreshClaudeUsage()
|
||||
expect(api.refresh.mock.calls.map(([options]) => options?.force)).toEqual([true])
|
||||
expect(store.getState().claudeUsageFailure).toBeNull()
|
||||
stop()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'skip',
|
||||
'scanning',
|
||||
'error',
|
||||
'completed',
|
||||
'first-completion',
|
||||
'intermediate-scanning'
|
||||
] as const)(
|
||||
'resolves old Refresh failure only with a newer settled producer result: %s',
|
||||
async (outcome) => {
|
||||
const { api, store } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
api.refresh.mockRejectedValueOnce(new Error('lost Refresh'))
|
||||
await store.getState().refreshClaudeUsage()
|
||||
expect(store.getState().claudeUsageFailure?.refresh).toBe(true)
|
||||
const baseline = {
|
||||
...scanState,
|
||||
lastScanCompletedAt: outcome === 'first-completion' ? null : 1
|
||||
}
|
||||
const final = {
|
||||
...scanState,
|
||||
lastScanCompletedAt: outcome === 'skip' ? 1 : 2,
|
||||
isScanning: outcome === 'scanning',
|
||||
lastScanError: outcome === 'error' ? 'scan failed' : null
|
||||
}
|
||||
api.getSnapshot
|
||||
.mockResolvedValueOnce({ ...snapshot, scanState: baseline })
|
||||
.mockResolvedValueOnce({ ...snapshot, scanState: final })
|
||||
api.refresh.mockResolvedValueOnce(
|
||||
outcome === 'intermediate-scanning' ? { ...final, isScanning: true } : final
|
||||
)
|
||||
await store.getState().fetchClaudeUsage()
|
||||
expect(store.getState().claudeUsageFailure?.refresh ?? false).toBe(
|
||||
outcome !== 'completed' &&
|
||||
outcome !== 'first-completion' &&
|
||||
outcome !== 'intermediate-scanning'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('retains unacknowledged setting writes through cache reads and never enables a disabled retry', async () => {
|
||||
const { api, store, notify } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
api.setEnabled.mockRejectedValueOnce(new Error('lost acknowledgement'))
|
||||
await store.getState().setClaudeUsageEnabled(false)
|
||||
const stop = store.getState().observeClaudeUsage()
|
||||
await store.getState().fetchClaudeUsage()
|
||||
expect(store.getState().claudeUsageFailure?.setting).toBe(true)
|
||||
notify(false, { unavailable: true })
|
||||
stop()
|
||||
notify(true)
|
||||
api.getScanState.mockResolvedValue({ ...scanState, enabled: false })
|
||||
const remount = store.getState().observeClaudeUsage()
|
||||
await store.getState().fetchClaudeUsage()
|
||||
expect(store.getState().claudeUsageFailure).toMatchObject({ terminal: false, setting: true })
|
||||
api.refresh.mockClear()
|
||||
await store.getState().refreshClaudeUsage()
|
||||
expect(api.refresh).not.toHaveBeenCalled()
|
||||
expect(api.setEnabled).toHaveBeenCalledTimes(1)
|
||||
await store.getState().setClaudeUsageEnabled(false)
|
||||
expect(store.getState().claudeUsageFailure).toBeNull()
|
||||
remount()
|
||||
})
|
||||
|
||||
it('does not treat an unsuccessful forced command as ordinary newer-scan recovery', async () => {
|
||||
const { api, store } = fixture()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
api.refresh.mockRejectedValueOnce(new Error('lost Refresh'))
|
||||
await store.getState().refreshClaudeUsage()
|
||||
api.getSnapshot.mockResolvedValueOnce(snapshot).mockResolvedValueOnce({
|
||||
...snapshot,
|
||||
scanState: { ...scanState, lastScanCompletedAt: 2 }
|
||||
})
|
||||
api.refresh.mockResolvedValueOnce({ ...scanState, lastScanError: 'scan failed' })
|
||||
await store.getState().refreshClaudeUsage()
|
||||
expect(store.getState().claudeUsageFailure?.refresh).toBe(true)
|
||||
})
|
||||
@@ -1,283 +1,17 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import { createUsageProviderSlice } from './usage-provider-slice'
|
||||
import type {
|
||||
ClaudeUsageRange,
|
||||
ClaudeUsageScope,
|
||||
ClaudeUsageSnapshot
|
||||
} from '../../../../../shared/claude-usage-types'
|
||||
import type {
|
||||
CodexUsageRange,
|
||||
CodexUsageScope,
|
||||
CodexUsageSnapshot
|
||||
} from '../../../../../shared/codex-usage-types'
|
||||
import type {
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageSnapshot
|
||||
} from '../../../../../shared/opencode-usage-types'
|
||||
import type {
|
||||
MuseUsageRange,
|
||||
MuseUsageScope,
|
||||
MuseUsageSnapshot
|
||||
} from '../../../../../shared/muse-usage-types'
|
||||
import type { AppState } from '../../types'
|
||||
ClaudeUsageTypes,
|
||||
CodexUsageTypes,
|
||||
OpenCodeUsageTypes,
|
||||
MuseUsageTypes
|
||||
} from './usage-provider-slice-contract'
|
||||
|
||||
type UsageSnapshot = {
|
||||
scanState: {
|
||||
enabled: boolean
|
||||
isScanning: boolean
|
||||
lastScanCompletedAt: number | null
|
||||
lastScanError: string | null
|
||||
}
|
||||
summary: object
|
||||
daily: object[]
|
||||
modelBreakdown: object[]
|
||||
projectBreakdown: object[]
|
||||
recentSessions: object[]
|
||||
}
|
||||
|
||||
type UsageProviderTypes<
|
||||
Scope extends string,
|
||||
Range extends string,
|
||||
Snapshot extends UsageSnapshot
|
||||
> = {
|
||||
scope: Scope
|
||||
range: Range
|
||||
snapshot: Snapshot
|
||||
}
|
||||
|
||||
type UsageData<T extends UsageProviderTypes<string, string, UsageSnapshot>> = {
|
||||
scope: T['scope']
|
||||
range: T['range']
|
||||
scanState: T['snapshot']['scanState'] | null
|
||||
summary: T['snapshot']['summary'] | null
|
||||
daily: T['snapshot']['daily']
|
||||
modelBreakdown: T['snapshot']['modelBreakdown']
|
||||
projectBreakdown: T['snapshot']['projectBreakdown']
|
||||
recentSessions: T['snapshot']['recentSessions']
|
||||
}
|
||||
|
||||
type UsageApi<T extends UsageProviderTypes<string, string, UsageSnapshot>> = {
|
||||
getScanState: () => Promise<T['snapshot']['scanState']>
|
||||
setEnabled: (args: { enabled: boolean }) => Promise<T['snapshot']['scanState']>
|
||||
refresh: (args?: { force?: boolean }) => Promise<T['snapshot']['scanState']>
|
||||
getSnapshot: (args: {
|
||||
scope: T['scope']
|
||||
range: T['range']
|
||||
limit?: number
|
||||
}) => Promise<T['snapshot']>
|
||||
}
|
||||
|
||||
type ProviderUsageSlice<
|
||||
Prefix extends string,
|
||||
Name extends string,
|
||||
T extends UsageProviderTypes<string, string, UsageSnapshot>
|
||||
> = {
|
||||
[K in keyof UsageData<T> as `${Prefix}Usage${Capitalize<K & string>}`]: UsageData<T>[K]
|
||||
} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise<void>> &
|
||||
Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise<void>> &
|
||||
Record<`set${Name}UsageRange`, (range: T['range']) => Promise<void>> &
|
||||
Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise<void>> &
|
||||
Record<`enable${Name}Usage`, () => Promise<void>> &
|
||||
Record<`refresh${Name}Usage`, () => Promise<void>>
|
||||
|
||||
type UsageProviderConfig<
|
||||
Prefix extends string,
|
||||
Name extends string,
|
||||
T extends UsageProviderTypes<string, string, UsageSnapshot>
|
||||
> = {
|
||||
prefix: Prefix
|
||||
name: Name
|
||||
initialScope: T['scope']
|
||||
initialRange: T['range']
|
||||
getApi: () => UsageApi<T>
|
||||
hasCachedData: (scanState: T['snapshot']['scanState']) => boolean
|
||||
}
|
||||
|
||||
const usageDataFields = [
|
||||
'scope',
|
||||
'range',
|
||||
'scanState',
|
||||
'summary',
|
||||
'daily',
|
||||
'modelBreakdown',
|
||||
'projectBreakdown',
|
||||
'recentSessions'
|
||||
] as const satisfies readonly (keyof UsageData<UsageProviderTypes<string, string, UsageSnapshot>>)[]
|
||||
|
||||
function usageDataKey(prefix: string, field: string): string {
|
||||
return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}`
|
||||
}
|
||||
|
||||
function readUsageData<T extends UsageProviderTypes<string, string, UsageSnapshot>>(
|
||||
state: AppState,
|
||||
prefix: string
|
||||
): UsageData<T> {
|
||||
const values = state as unknown as Record<string, unknown>
|
||||
return Object.fromEntries(
|
||||
usageDataFields.map((field) => [field, values[usageDataKey(prefix, field)]])
|
||||
) as UsageData<T>
|
||||
}
|
||||
|
||||
function createUsagePatch<T extends UsageProviderTypes<string, string, UsageSnapshot>>(
|
||||
prefix: string,
|
||||
patch: Partial<UsageData<T>>
|
||||
): Partial<AppState> {
|
||||
return Object.fromEntries(
|
||||
usageDataFields
|
||||
.filter((field) => field in patch)
|
||||
.map((field) => [usageDataKey(prefix, field), patch[field]])
|
||||
) as Partial<AppState>
|
||||
}
|
||||
|
||||
function createUsageProviderSlice<
|
||||
Prefix extends string,
|
||||
Name extends string,
|
||||
T extends UsageProviderTypes<string, string, UsageSnapshot>
|
||||
>(
|
||||
config: UsageProviderConfig<Prefix, Name, T>
|
||||
): StateCreator<AppState, [], [], ProviderUsageSlice<Prefix, Name, T>> {
|
||||
return (set, get) => {
|
||||
const update = (patch: Partial<UsageData<T>>): void =>
|
||||
set(createUsagePatch(config.prefix, patch))
|
||||
const read = (): UsageData<T> => readUsageData<T>(get(), config.prefix)
|
||||
|
||||
const fetchUsage = async (opts?: { forceRefresh?: boolean }): Promise<void> => {
|
||||
try {
|
||||
const api = config.getApi()
|
||||
const scanState = (await api.getScanState()) as T['snapshot']['scanState'] | undefined
|
||||
// Desktop-only usage APIs resolve undefined in paired web clients.
|
||||
if (!scanState) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = read()
|
||||
const preserveLoading =
|
||||
opts?.forceRefresh === true &&
|
||||
current.scanState?.enabled === true &&
|
||||
current.summary === null
|
||||
update({
|
||||
scanState: preserveLoading
|
||||
? {
|
||||
...scanState,
|
||||
isScanning: true,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
: scanState
|
||||
})
|
||||
if (!scanState.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const selection = read()
|
||||
const snapshot = await api.getSnapshot({
|
||||
scope: selection.scope,
|
||||
range: selection.range,
|
||||
limit: 10
|
||||
})
|
||||
if (
|
||||
snapshot.scanState.lastScanCompletedAt !== null ||
|
||||
config.hasCachedData(snapshot.scanState)
|
||||
) {
|
||||
update({
|
||||
...snapshot,
|
||||
scanState:
|
||||
opts?.forceRefresh === true
|
||||
? { ...snapshot.scanState, isScanning: true }
|
||||
: snapshot.scanState
|
||||
})
|
||||
} else {
|
||||
update({ scanState: { ...scanState, isScanning: true, lastScanError: null } })
|
||||
}
|
||||
|
||||
await api.refresh({ force: opts?.forceRefresh ?? false })
|
||||
const refreshedSelection = read()
|
||||
update(
|
||||
await api.getSnapshot({
|
||||
scope: refreshedSelection.scope,
|
||||
range: refreshedSelection.range,
|
||||
limit: 10
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch ${config.name} usage:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const setEnabled = async (enabled: boolean): Promise<void> => {
|
||||
try {
|
||||
const nextScanState = (await config.getApi().setEnabled({ enabled })) as
|
||||
| T['snapshot']['scanState']
|
||||
| undefined
|
||||
if (!nextScanState) {
|
||||
return
|
||||
}
|
||||
update({
|
||||
scanState: enabled
|
||||
? {
|
||||
...nextScanState,
|
||||
isScanning: true,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
: nextScanState,
|
||||
summary: null,
|
||||
daily: [],
|
||||
modelBreakdown: [],
|
||||
projectBreakdown: [],
|
||||
recentSessions: []
|
||||
})
|
||||
if (enabled) {
|
||||
await fetchUsage({ forceRefresh: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to update ${config.name} usage setting:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const initialData: UsageData<T> = {
|
||||
scope: config.initialScope,
|
||||
range: config.initialRange,
|
||||
scanState: null,
|
||||
summary: null,
|
||||
daily: [],
|
||||
modelBreakdown: [],
|
||||
projectBreakdown: [],
|
||||
recentSessions: []
|
||||
}
|
||||
|
||||
return {
|
||||
...createUsagePatch(config.prefix, initialData),
|
||||
[`set${config.name}UsageEnabled`]: setEnabled,
|
||||
[`set${config.name}UsageScope`]: async (scope: T['scope']) => {
|
||||
update({ scope })
|
||||
await fetchUsage()
|
||||
},
|
||||
[`set${config.name}UsageRange`]: async (range: T['range']) => {
|
||||
update({ range })
|
||||
await fetchUsage()
|
||||
},
|
||||
[`fetch${config.name}Usage`]: fetchUsage,
|
||||
[`enable${config.name}Usage`]: () => setEnabled(true),
|
||||
[`refresh${config.name}Usage`]: () => fetchUsage({ forceRefresh: true })
|
||||
} as ProviderUsageSlice<Prefix, Name, T>
|
||||
}
|
||||
}
|
||||
|
||||
type ClaudeUsageTypes = UsageProviderTypes<ClaudeUsageScope, ClaudeUsageRange, ClaudeUsageSnapshot>
|
||||
type CodexUsageTypes = UsageProviderTypes<CodexUsageScope, CodexUsageRange, CodexUsageSnapshot>
|
||||
type OpenCodeUsageTypes = UsageProviderTypes<
|
||||
OpenCodeUsageScope,
|
||||
OpenCodeUsageRange,
|
||||
OpenCodeUsageSnapshot
|
||||
>
|
||||
|
||||
type MuseUsageTypes = UsageProviderTypes<MuseUsageScope, MuseUsageRange, MuseUsageSnapshot>
|
||||
|
||||
export type ClaudeUsageSlice = ProviderUsageSlice<'claude', 'Claude', ClaudeUsageTypes>
|
||||
export type CodexUsageSlice = ProviderUsageSlice<'codex', 'Codex', CodexUsageTypes>
|
||||
export type OpenCodeUsageSlice = ProviderUsageSlice<'openCode', 'OpenCode', OpenCodeUsageTypes>
|
||||
export type MuseUsageSlice = ProviderUsageSlice<'muse', 'Muse', MuseUsageTypes>
|
||||
export type {
|
||||
ClaudeUsageSlice,
|
||||
CodexUsageSlice,
|
||||
OpenCodeUsageSlice,
|
||||
MuseUsageSlice
|
||||
} from './usage-provider-slice-contract'
|
||||
|
||||
export const createClaudeUsageSlice = createUsageProviderSlice<
|
||||
'claude',
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export type UsageFailure = {
|
||||
terminal: boolean
|
||||
read: boolean
|
||||
refresh: boolean
|
||||
setting: boolean
|
||||
}
|
||||
|
||||
export type UsageSnapshot = {
|
||||
scanState: {
|
||||
enabled: boolean
|
||||
isScanning: boolean
|
||||
lastScanCompletedAt: number | null
|
||||
lastScanError: string | null
|
||||
}
|
||||
summary: object
|
||||
daily: object[]
|
||||
modelBreakdown: object[]
|
||||
projectBreakdown: object[]
|
||||
recentSessions: object[]
|
||||
}
|
||||
|
||||
export type UsageProviderTypes<
|
||||
Scope extends string,
|
||||
Range extends string,
|
||||
Snapshot extends UsageSnapshot
|
||||
> = {
|
||||
scope: Scope
|
||||
range: Range
|
||||
snapshot: Snapshot
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function useUsageObservation(observe: () => () => void): void {
|
||||
useEffect(() => observe(), [observe])
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { MessageChannel, type MessagePort } from 'node:worker_threads'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { BackendPortRpc, type BackendPort } from './backend-port-rpc'
|
||||
|
||||
const connections: BackendPortRpc[] = []
|
||||
const ports: MessagePort[] = []
|
||||
function adapt(port: MessagePort): BackendPort {
|
||||
ports.push(port)
|
||||
return {
|
||||
postMessage: (message) => port.postMessage(message),
|
||||
onMessage(listener) {
|
||||
port.on('message', listener)
|
||||
return () => {
|
||||
port.off('message', listener)
|
||||
}
|
||||
},
|
||||
onClose(listener) {
|
||||
port.on('close', listener)
|
||||
return () => {
|
||||
port.off('close', listener)
|
||||
}
|
||||
},
|
||||
close: () => port.close()
|
||||
}
|
||||
}
|
||||
function pair(invoke: (method: string, params: unknown, signal: AbortSignal) => Promise<unknown>) {
|
||||
const channel = new MessageChannel()
|
||||
const server = new BackendPortRpc(adapt(channel.port1), 'backend-1', invoke)
|
||||
const client = new BackendPortRpc(adapt(channel.port2), 'renderer-1', async () => null)
|
||||
connections.push(server, client)
|
||||
return { client, server }
|
||||
}
|
||||
afterEach(() => {
|
||||
connections.splice(0).forEach((connection) => connection.close())
|
||||
ports.splice(0).forEach((port) => port.close())
|
||||
})
|
||||
it('transfers a report directly and rejects outstanding calls on document disconnect', async () => {
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
const release = Promise.withResolvers<unknown>()
|
||||
const { client, server } = pair(async (method, params, signal) => {
|
||||
if (method === 'snapshot') {
|
||||
return { summary: { inputTokens: 123 }, selection: params }
|
||||
}
|
||||
started.resolve(signal)
|
||||
return release.promise
|
||||
})
|
||||
await expect(client.call('snapshot', { range: '30d' })).resolves.toEqual({
|
||||
summary: { inputTokens: 123 },
|
||||
selection: { range: '30d' }
|
||||
})
|
||||
const pending = client.call('refresh')
|
||||
const rejected = expect(pending).rejects.toThrow()
|
||||
const signal = await started.promise
|
||||
server.close()
|
||||
await rejected
|
||||
expect(signal.aborted).toBe(true)
|
||||
release.resolve(null)
|
||||
})
|
||||
it('bounds pending requests and admits the next request after consumption', async () => {
|
||||
{
|
||||
const release = Promise.withResolvers<void>()
|
||||
const { client, server } = pair(async () => {
|
||||
await release.promise
|
||||
return { inputTokens: 123 }
|
||||
})
|
||||
const calls = Array.from({ length: 32 }, () => client.call('snapshot'))
|
||||
await expect(client.call('snapshot')).rejects.toThrow()
|
||||
release.resolve()
|
||||
expect(await Promise.all(calls)).toEqual(
|
||||
Array.from({ length: 32 }, () => ({ inputTokens: 123 }))
|
||||
)
|
||||
await expect(client.call('snapshot')).resolves.toEqual({ inputTokens: 123 })
|
||||
client.close()
|
||||
server.close()
|
||||
}
|
||||
})
|
||||
it('does not let an early acknowledgement bypass the in-flight request bound', async () => {
|
||||
const channel = new MessageChannel()
|
||||
const pending = Promise.withResolvers<unknown>()
|
||||
const invoked = Promise.withResolvers<void>()
|
||||
const disconnected = Promise.withResolvers<void>()
|
||||
const invoke = vi.fn(async () => {
|
||||
invoked.resolve()
|
||||
return pending.promise
|
||||
})
|
||||
connections.push(
|
||||
new BackendPortRpc(adapt(channel.port1), 'backend-1', invoke, () => disconnected.resolve())
|
||||
)
|
||||
ports.push(channel.port2)
|
||||
channel.port2.postMessage({ id: '1', method: 'refresh' })
|
||||
await invoked.promise
|
||||
channel.port2.postMessage({ ack: '1' })
|
||||
await disconnected.promise
|
||||
expect(invoke).toHaveBeenCalledTimes(1)
|
||||
pending.resolve(null)
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { BackendRequestSchema } from './backend-service-contract'
|
||||
import { RuntimeRpcEnvelopeSchema } from './runtime-rpc-envelope'
|
||||
|
||||
export type BackendPort = {
|
||||
postMessage(message: unknown): void
|
||||
onMessage(listener: (message: unknown) => void): () => void
|
||||
onClose(listener: () => void): () => void
|
||||
close(): void
|
||||
}
|
||||
|
||||
type Pending = { resolve(value: unknown): void; reject(error: Error): void }
|
||||
const MAX_REQUESTS = 32
|
||||
|
||||
/** Responses retain a credit until consumed, bounding a slow document's native port queue. */
|
||||
export class BackendPortRpc {
|
||||
private readonly pending = new Map<string, Pending>()
|
||||
private readonly admitted = new Map<string, boolean>()
|
||||
private readonly lifetime = new AbortController()
|
||||
private nextId = 1
|
||||
private closed = false
|
||||
private readonly detach: (() => void)[]
|
||||
|
||||
constructor(
|
||||
private readonly port: BackendPort,
|
||||
private readonly incarnation: string,
|
||||
private readonly invoke: (
|
||||
method: string,
|
||||
params: unknown,
|
||||
signal: AbortSignal
|
||||
) => Promise<unknown>,
|
||||
private readonly onDisconnect: () => void = () => {}
|
||||
) {
|
||||
this.detach = [
|
||||
port.onMessage((message) => {
|
||||
void this.receive(message).catch(() => this.close())
|
||||
}),
|
||||
port.onClose(() => this.close())
|
||||
]
|
||||
}
|
||||
|
||||
call(method: string, params?: unknown): Promise<unknown> {
|
||||
if (this.closed) {
|
||||
return Promise.reject(new Error('Orca disconnected. Try again after it reconnects.'))
|
||||
}
|
||||
if (this.pending.size >= MAX_REQUESTS) {
|
||||
return Promise.reject(
|
||||
new Error('Too many usage requests. Try again when the current requests finish.')
|
||||
)
|
||||
}
|
||||
const id = String(this.nextId++)
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
try {
|
||||
const request = { id, method, params }
|
||||
this.port.postMessage(request)
|
||||
} catch (error) {
|
||||
this.pending.delete(id)
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
this.lifetime.abort()
|
||||
for (const detach of this.detach) {
|
||||
detach()
|
||||
}
|
||||
this.port.close()
|
||||
for (const call of this.pending.values()) {
|
||||
call.reject(new Error('Orca disconnected. Try again after it reconnects.'))
|
||||
}
|
||||
this.pending.clear()
|
||||
this.admitted.clear()
|
||||
this.onDisconnect()
|
||||
}
|
||||
|
||||
private async receive(message: unknown): Promise<void> {
|
||||
if (this.closed) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
message &&
|
||||
typeof message === 'object' &&
|
||||
'ack' in message &&
|
||||
typeof message.ack === 'string'
|
||||
) {
|
||||
if (this.admitted.get(message.ack) !== true) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
this.admitted.delete(message.ack)
|
||||
return
|
||||
}
|
||||
const response = RuntimeRpcEnvelopeSchema.safeParse(message)
|
||||
if (response.success && !('_keepalive' in response.data)) {
|
||||
const reply = response.data
|
||||
const call = this.pending.get(reply.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.pending.delete(reply.id)
|
||||
this.port.postMessage({ ack: reply.id })
|
||||
if (reply.ok) {
|
||||
call.resolve(reply.result)
|
||||
} else {
|
||||
call.reject(Object.assign(new Error(reply.error.message), { code: reply.error.code }))
|
||||
}
|
||||
return
|
||||
}
|
||||
const parsed = BackendRequestSchema.safeParse(message)
|
||||
if (
|
||||
!parsed.success ||
|
||||
this.admitted.size >= MAX_REQUESTS ||
|
||||
this.admitted.has(parsed.data.id)
|
||||
) {
|
||||
this.close()
|
||||
return
|
||||
}
|
||||
const request = parsed.data
|
||||
this.admitted.set(request.id, false)
|
||||
let reply
|
||||
try {
|
||||
reply = {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: await this.invoke(request.method, request.params, this.lifetime.signal),
|
||||
_meta: { runtimeId: this.incarnation }
|
||||
}
|
||||
} catch (error) {
|
||||
reply = {
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_error',
|
||||
message: error instanceof Error ? error.message : 'Usage operation failed. Try again.'
|
||||
},
|
||||
_meta: { runtimeId: this.incarnation }
|
||||
}
|
||||
}
|
||||
if (!this.closed) {
|
||||
try {
|
||||
this.admitted.set(request.id, true)
|
||||
this.port.postMessage(reply)
|
||||
} catch {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { z } from 'zod'
|
||||
import type { RuntimeRpcResponse } from './runtime-rpc-envelope'
|
||||
import type { AutomationRunUsage } from './automations/history/automation-run'
|
||||
|
||||
export const BACKEND_PORT_REQUEST = 'backend:connect'
|
||||
export const BACKEND_PORT_DELIVERY = 'backend:port'
|
||||
export const BACKEND_CONNECTION_CHANGED = 'backend:connectionChanged'
|
||||
export const BACKEND_UTILITY_PROCESS_NAME = 'Orca backend'
|
||||
|
||||
export type BackendConnectionPurpose = 'demand' | 'recovery'
|
||||
export type BackendConnectionStatus = { connected: boolean; unavailable?: boolean }
|
||||
export type BackendConnectionDetail = { initial?: boolean; unavailable?: boolean }
|
||||
export type BackendConnectionListener = (
|
||||
connected: boolean,
|
||||
detail?: BackendConnectionDetail
|
||||
) => void
|
||||
|
||||
export const UsageProviderSchema = z.enum([
|
||||
'claudeUsage',
|
||||
'codexUsage',
|
||||
'openCodeUsage',
|
||||
'museUsage'
|
||||
])
|
||||
export type UsageProviderKey = z.infer<typeof UsageProviderSchema>
|
||||
export const UsageActionSchema = z.enum([
|
||||
'getScanState',
|
||||
'setEnabled',
|
||||
'refresh',
|
||||
'getSnapshot',
|
||||
'getSummary',
|
||||
'getDaily',
|
||||
'getBreakdown',
|
||||
'getRecentSessions'
|
||||
])
|
||||
export type UsageAction = z.infer<typeof UsageActionSchema>
|
||||
export type BackendResponse = RuntimeRpcResponse<unknown>
|
||||
|
||||
export const BackendBootstrapSchema = z
|
||||
.object({
|
||||
protocol: z.literal(1),
|
||||
requiredCapabilities: z.array(z.string().max(128)).max(32),
|
||||
services: z.tuple([z.literal('usage')]),
|
||||
build: z.string().max(256),
|
||||
paths: z.object({
|
||||
userData: z.string().min(1)
|
||||
}),
|
||||
appPath: z.string().min(1),
|
||||
isPackaged: z.boolean()
|
||||
})
|
||||
.strip()
|
||||
export type BackendBootstrap = z.infer<typeof BackendBootstrapSchema>
|
||||
export const BACKEND_CAPABILITIES = [
|
||||
'usage',
|
||||
'usage.catalog',
|
||||
'usage.claudeProfileDirs',
|
||||
'usage.telemetry'
|
||||
] as const
|
||||
|
||||
export const BackendRequestSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).max(128),
|
||||
method: z.string().min(1).max(128),
|
||||
params: z.unknown().optional()
|
||||
})
|
||||
.strip()
|
||||
|
||||
export type AutomationUsageInput = {
|
||||
worktreeId: string | null
|
||||
terminalSessionId: string | null
|
||||
startedAt: number | null
|
||||
completedAt: number | null
|
||||
}
|
||||
export type AutomationUsageClient = {
|
||||
getAutomationRunUsage(input: AutomationUsageInput): Promise<AutomationRunUsage>
|
||||
}
|
||||
Reference in New Issue
Block a user