diff --git a/.oxlintrc.json b/.oxlintrc.json index d0b480c6b59..9601fee85cc 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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": { diff --git a/config/build-plugins/main-build-entry-points.ts b/config/build-plugins/main-build-entry-points.ts index 1535e239862..d62affdbd34 100644 --- a/config/build-plugins/main-build-entry-points.ts +++ b/config/build-plugins/main-build-entry-points.ts @@ -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 = { 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' ), diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 1a031934482..c6d796ebd17 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.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', diff --git a/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs b/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs index 5fd2dd67e4c..cf8e42f10ba 100644 --- a/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs +++ b/config/scripts/static-analysis/process-boundaries/check-runtime-electron-ratchet.mjs @@ -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'] }, diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 0c4e66706e0..0b01e9e1eb6 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -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", diff --git a/src/main/automations/run-usage-collection.ts b/src/main/automations/run-usage-collection.ts index 32fe2a51471..c47aa3876fa 100644 --- a/src/main/automations/run-usage-collection.ts +++ b/src/main/automations/run-usage-collection.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 { 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 { const { store, run } = input const usage = await collectAutomationRunUsage({ diff --git a/src/main/automations/runtime-automation-service.ts b/src/main/automations/runtime-automation-service.ts index 3a8f4b8d796..025b1530085 100644 --- a/src/main/automations/runtime-automation-service.ts +++ b/src/main/automations/runtime-automation-service.ts @@ -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 { diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 2e54dbd6db0..73fadb23e6a 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -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() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index e25759b4b10..36fb9ba7d14 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -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 diff --git a/src/main/backend-desktop/backend-bootstrap.test.ts b/src/main/backend-desktop/backend-bootstrap.test.ts new file mode 100644 index 00000000000..f899db4f8f8 --- /dev/null +++ b/src/main/backend-desktop/backend-bootstrap.test.ts @@ -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 +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) +}) diff --git a/src/main/backend-desktop/backend-bootstrap.ts b/src/main/backend-desktop/backend-bootstrap.ts new file mode 100644 index 00000000000..bdba1ccc03c --- /dev/null +++ b/src/main/backend-desktop/backend-bootstrap.ts @@ -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 + } +} diff --git a/src/main/backend-desktop/backend-desktop-capabilities.test.ts b/src/main/backend-desktop/backend-desktop-capabilities.test.ts new file mode 100644 index 00000000000..ea98e02f106 --- /dev/null +++ b/src/main/backend-desktop/backend-desktop-capabilities.test.ts @@ -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() + let finishLoading!: () => void + const loaded = new Promise((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' } + ] + ] + ] + }) +}) diff --git a/src/main/backend-desktop/backend-desktop-capabilities.ts b/src/main/backend-desktop/backend-desktop-capabilities.ts new file mode 100644 index 00000000000..0d8bef80826 --- /dev/null +++ b/src/main/backend-desktop/backend-desktop-capabilities.ts @@ -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 { + 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, + 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 +} diff --git a/src/main/backend-desktop/backend-supervisor.test.ts b/src/main/backend-desktop/backend-supervisor.test.ts new file mode 100644 index 00000000000..de7e80199d1 --- /dev/null +++ b/src/main/backend-desktop/backend-supervisor.test.ts @@ -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 + kill: ReturnType + })[] = [] + 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 + kill: ReturnType + })[] = [] + 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 + kill: ReturnType + })[] = [] + 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 +}) diff --git a/src/main/backend-desktop/backend-supervisor.ts b/src/main/backend-desktop/backend-supervisor.ts new file mode 100644 index 00000000000..4078fa1c92c --- /dev/null +++ b/src/main/backend-desktop/backend-supervisor.ts @@ -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 | null = null + private control: BackendPortRpc | null = null + private ready: Promise | null = null + private disposeFailedStartup?: () => void + private desiredRunning = true + private exited: Promise = Promise.resolve() + private restartTimer: ReturnType | undefined + private readonly failures = new ManagedOrcadEventWindow(10 * 60_000, Date.now) + private readonly documents = new Map() + private stopping?: Promise + + 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 + > + } + } + } + + stop(): Promise { + 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 { + 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((resolve) => { + settleExit = resolve + }) + let failed!: (error: Error) => void + let settled = false + let disposed = false + const ready = new Promise((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 + } +} diff --git a/src/main/backend/backend-entry.ts b/src/main/backend/backend-entry.ts new file mode 100644 index 00000000000..b171b367dea --- /dev/null +++ b/src/main/backend/backend-entry.ts @@ -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() +let control: BackendPortRpc | undefined +let usage: ReturnType | undefined +let stopping: Promise | undefined +let booting = false +let startup: Promise | undefined + +function stop(): Promise { + 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((resolve) => setTimeout(resolve, 250)) + } + } + throw abort.signal.reason +} + +async function bootstrap(raw: unknown, port: BackendNativePort): Promise { + 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 => { + 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() +}) diff --git a/src/main/backend/backend-message-port.ts b/src/main/backend/backend-message-port.ts new file mode 100644 index 00000000000..1e0d4e0ea6c --- /dev/null +++ b/src/main/backend/backend-message-port.ts @@ -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() + } +} diff --git a/src/main/backend/node-service-environment.ts b/src/main/backend/node-service-environment.ts new file mode 100644 index 00000000000..81b93068598 --- /dev/null +++ b/src/main/backend/node-service-environment.ts @@ -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') + } + } + } +} diff --git a/src/main/backend/service-composition.ts b/src/main/backend/service-composition.ts new file mode 100644 index 00000000000..a98433c52c2 --- /dev/null +++ b/src/main/backend/service-composition.ts @@ -0,0 +1,50 @@ +import { startOrcadEventLoopWatchdog } from '../orcad/orcad-event-loop-watchdog' + +export type BackendServiceCleanup = (state: { failed: boolean }) => void | Promise + +/** A service keeps its ownership fence until all accepted work and writers have retired. */ +export class BackendServiceLifetime { + private readonly cleanups: BackendServiceCleanup[] = [] + private stopping?: Promise + + 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 { + 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(options: { + supervised: boolean + registerCleanup: (cleanup: BackendServiceCleanup) => void + activate: (registerCleanup: (cleanup: BackendServiceCleanup) => void) => Promise +}): Promise { + const watchdog = startOrcadEventLoopWatchdog({ supervised: options.supervised }) + options.registerCleanup(() => watchdog?.stop()) + return options.activate(options.registerCleanup) +} diff --git a/src/main/backend/usage-service.test.ts b/src/main/backend/usage-service.test.ts new file mode 100644 index 00000000000..e19368928e0 --- /dev/null +++ b/src/main/backend/usage-service.test.ts @@ -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[] +let releasePending: (() => void)[] +function nextTurn(): Promise { + 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() + 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() + const scan = Promise.withResolvers() + 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() + 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 } + } + ) +}) diff --git a/src/main/backend/usage-service.ts b/src/main/backend/usage-service.ts new file mode 100644 index 00000000000..4e841b6b758 --- /dev/null +++ b/src/main/backend/usage-service.ts @@ -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 = { + whenLoaded(): Promise + 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( + handlers: Map, + prefix: UsageProviderKey, + store: QueryStore +): 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() + 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>() + const stop = async (): Promise => { + 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 => { + 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 { + const operation = ready.then(() => { + signal?.throwIfAborted() + return dispatch(method, params, privileged) + }) + active.add(operation) + void operation.finally(() => active.delete(operation)).catch(() => {}) + return operation + }, + stop + } +} diff --git a/src/main/claude-usage/persisted-usage-test-fixture.ts b/src/main/claude-usage/persisted-usage-test-fixture.ts index fb7ecb3bfe5..92863408410 100644 --- a/src/main/claude-usage/persisted-usage-test-fixture.ts +++ b/src/main/claude-usage/persisted-usage-test-fixture.ts @@ -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 diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index da663d189de..a6cda7d260b 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -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 }) } diff --git a/src/main/codex-usage/store-test-harness.ts b/src/main/codex-usage/store-test-harness.ts index 2cdd3f7937a..f62f3caaee8 100644 --- a/src/main/codex-usage/store-test-harness.ts +++ b/src/main/codex-usage/store-test-harness.ts @@ -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): 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() diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 35e98288382..d320753a4a5 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -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', diff --git a/src/main/crash-reporting/process-exits/process-gone-classification.ts b/src/main/crash-reporting/process-exits/process-gone-classification.ts index 72a8272b743..8b21c4b9beb 100644 --- a/src/main/crash-reporting/process-exits/process-gone-classification.ts +++ b/src/main/crash-reporting/process-exits/process-gone-classification.ts @@ -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']) diff --git a/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts b/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts index 7e18c41ce1e..110a8117cae 100644 --- a/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-exits/process-gone-recorder.test.ts @@ -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() diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index e6d650ab58c..7bf4a77b0e8 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -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) diff --git a/src/main/ipc/usage-provider-handlers.ts b/src/main/ipc/usage-provider-handlers.ts deleted file mode 100644 index f3d6dbe6669..00000000000 --- a/src/main/ipc/usage-provider-handlers.ts +++ /dev/null @@ -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 = { - whenLoaded: () => Promise - 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: Scope - range: Range -} - -function registerProviderHandlers( - prefix: UsageProviderChannelPrefix, - usage: UsageProviderHandlerStore -): 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 & { limit?: number }) => { - await usage.whenLoaded() - return usage.getSnapshot(args.scope, args.range, args.limit) - } - ) - ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs) => - usage.getSummary(args.scope, args.range) - ) - ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs) => - usage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - `${prefix}:getBreakdown`, - (_event, args: UsageRangeArgs & { kind: BreakdownKind }) => - usage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - `${prefix}:getRecentSessions`, - (_event, args: UsageRangeArgs & { 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) -} diff --git a/src/main/muse-usage/store.ts b/src/main/muse-usage/store.ts index 1fba5514dcc..26d68b56171 100644 --- a/src/main/muse-usage/store.ts +++ b/src/main/muse-usage/store.ts @@ -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', diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index 47393511a4e..6af7b270414 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -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', diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 7a0b14d5a46..cdaf0a5631d 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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 => Promise.resolve() @@ -44,7 +44,12 @@ export async function startOrcad(options: OrcadOptions = {}): Promise 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 void ): Promise> { 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') diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts index 8c5afd39de7..63ce0b05d38 100644 --- a/src/main/orcad/orcad-host-adapters.ts +++ b/src/main/orcad/orcad-host-adapters.ts @@ -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 diff --git a/src/main/orcad/orcad-runtime-lifetime.ts b/src/main/orcad/orcad-runtime-lifetime.ts index 0da34a2cefe..283adac3bb5 100644 --- a/src/main/orcad/orcad-runtime-lifetime.ts +++ b/src/main/orcad/orcad-runtime-lifetime.ts @@ -1,37 +1,4 @@ -export type OrcadRuntimeCleanup = (state: { failed: boolean }) => void | Promise - -/** 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 - - 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 { - 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' diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index 5fde1f3358a..14887a9ed1e 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -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') diff --git a/src/main/startup/main-process-account-services.ts b/src/main/startup/main-process-account-services.ts index 9afcffc4217..399359df921 100644 --- a/src/main/startup/main-process-account-services.ts +++ b/src/main/startup/main-process-account-services.ts @@ -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({ diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index e2f383b66ce..a4a94d511fa 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -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') } diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 7422887c61f..a1ecab2621b 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -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. diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 1b5a696a187..0aec0ea1f8e 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -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() diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index e2dd75c66aa..ecb42038b0a 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -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, diff --git a/src/main/startup/main-window-controller.ts b/src/main/startup/main-window-controller.ts index 94643295e52..e8366b54d63 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -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, diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index 9fe6d2164e4..5e997750b2b 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -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, diff --git a/src/main/startup/main-window-service-readiness.ts b/src/main/startup/main-window-service-readiness.ts index 09f7e281400..23df7466368 100644 --- a/src/main/startup/main-window-service-readiness.ts +++ b/src/main/startup/main-window-service-readiness.ts @@ -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'], diff --git a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts new file mode 100644 index 00000000000..caaceb5667a --- /dev/null +++ b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.test.ts @@ -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 +type TestScan = ( + worktrees: UsageScanWorktreeRef[], + sourceCache: UsageSourceCacheRef +) => Promise +type TestSplit = (request: UsageCacheSplitRequest) => Promise + +const NOW = Date.parse('2026-04-10T16:00:00.000Z') + +function makeState( + overrides: Partial> & { scanState?: Partial } = {} +): 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>, + 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> + let split: ReturnType> + let capture: ReturnType> + + beforeEach(() => { + capture = vi.fn(async () => true) + tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-')) + stores = [] + scan = vi.fn().mockResolvedValue(emptyScanResult()) + // Run the real cache split in-process; the worker only wraps it in a thread. + split = vi.fn(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().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() + 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() + .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().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>>() + 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() + const release = Promise.withResolvers() + 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() + } + ) +}) diff --git a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts index 23c12df6227..9bbada658b3 100644 --- a/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts +++ b/src/main/usage/provider-lifecycle/usage-provider-store-lifecycle.ts @@ -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 private readonly schemaVersion: number private scanPromise: Promise | 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 ) { 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 { + await this.scanPromise + } + async flush(): Promise { await this.loaded await this.inlineCache.settle() @@ -98,17 +101,22 @@ export abstract class UsageProviderStoreLifecycle< async refresh(force = false): Promise> { 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 { + private async runScan( + catalog: Awaited> + ): Promise { 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 { 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 { - const repos = this.store.getRepos() - return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) - } } function isMissingFileError(error: unknown): boolean { diff --git a/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts b/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts index 831c979be04..b0bc656652b 100644 --- a/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts +++ b/src/main/usage/reporting/token-usage/agent-token-usage-reporter.ts @@ -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 + private readonly getSessionIds: (providerSessionIds: readonly string[]) => Promise, + 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 { - 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. diff --git a/src/main/usage/scan-worker/usage-scan-worker-client.ts b/src/main/usage/scan-worker/usage-scan-worker-client.ts index 80dcde2868d..26e344c0353 100644 --- a/src/main/usage/scan-worker/usage-scan-worker-client.ts +++ b/src/main/usage/scan-worker/usage-scan-worker-client.ts @@ -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 = { /** Unavailable, timed-out or crashed workers reject; stores retain their previous projection instead of parsing on main. */ export class UsageScanWorkerClient { - private readonly queue: WorkerThreadRequestQueue + 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 { + this.queue.dispose() + await this.queue.retired() + } + /** sourceCache identifies provider records that stay on the worker. */ scan(body: UsageScanWorkerScanBody): Promise { return this.dispatch(body) @@ -79,10 +77,7 @@ export class UsageScanWorkerClient { } private async dispatch(body: UsageScanWorkerRequestBody): Promise { - 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) } diff --git a/src/main/usage/scan-worker/usage-scan-worker-spawn.ts b/src/main/usage/scan-worker/usage-scan-worker-spawn.ts index 64fcdcd242b..c835743e19f 100644 --- a/src/main/usage/scan-worker/usage-scan-worker-spawn.ts +++ b/src/main/usage/scan-worker/usage-scan-worker-spawn.ts @@ -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 { return getSharedClient().splitCacheFile(request) } + +export async function stopUsageScanWorker(): Promise { + const client = sharedClient + sharedClient = null + await client?.dispose() +} diff --git a/src/main/usage/usage-scan-request-scheduler.test.ts b/src/main/usage/usage-scan-request-scheduler.test.ts new file mode 100644 index 00000000000..ef765b4c73e --- /dev/null +++ b/src/main/usage/usage-scan-request-scheduler.test.ts @@ -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 = 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 + ) + }) +}) diff --git a/src/main/usage/usage-scan-request-scheduler.ts b/src/main/usage/usage-scan-request-scheduler.ts new file mode 100644 index 00000000000..8e7eca8a02e --- /dev/null +++ b/src/main/usage/usage-scan-request-scheduler.ts @@ -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() + private pending: ScanCall[] = [] + private nextId = 1 + private consecutiveDeaths = 0 + private disposed = false + private retirementWait: Promise | null = null + private readonly host: LazyWorkerThreadHost + + 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 { + 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 { + 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() + } +} diff --git a/src/main/usage/usage-store-host-test-fixture.ts b/src/main/usage/usage-store-host-test-fixture.ts new file mode 100644 index 00000000000..e844627e12f --- /dev/null +++ b/src/main/usage/usage-store-host-test-fixture.ts @@ -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) + } + } +} diff --git a/src/main/usage/usage-store-host.ts b/src/main/usage/usage-store-host.ts new file mode 100644 index 00000000000..b717713fa1d --- /dev/null +++ b/src/main/usage/usage-store-host.ts @@ -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 + capture(snapshot: AgentTokenUsage): Promise +} + +export type UsageStoreHost = { + signal: AbortSignal + userDataPath: string + readCatalog(): Promise + claudeProfileDirs(): Promise + telemetry: UsageTelemetryHost +} diff --git a/src/main/usage/worktree-attribution/usage-worktree-refs.ts b/src/main/usage/worktree-attribution/usage-worktree-refs.ts index 0afe3afb5bf..cf7548cc656 100644 --- a/src/main/usage/worktree-attribution/usage-worktree-refs.ts +++ b/src/main/usage/worktree-attribution/usage-worktree-refs.ts @@ -21,7 +21,7 @@ export function getUsageWorktreeFingerprint( } export function createWorktreeRefs( - repos: Repo[], + repos: readonly Pick[], worktreesByRepo: Map ): UsageScanWorktreeRef[] { const refs: UsageScanWorktreeRef[] = [] diff --git a/src/main/worker-threads/lazy-worker-thread-host.ts b/src/main/worker-threads/lazy-worker-thread-host.ts index 1f41cf8d4a0..779d14c3acc 100644 --- a/src/main/worker-threads/lazy-worker-thread-host.ts +++ b/src/main/worker-threads/lazy-worker-thread-host.ts @@ -6,7 +6,7 @@ export class LazyWorkerThreadHost { private idleTimer: NodeJS.Timeout | null = null private cleanupListeners: (() => void) | null = null private reportedUnavailable = false - private retiring: Promise | null = null + private retiring: { completion: Promise; failure?: unknown } | null = null constructor( private readonly options: { @@ -21,6 +21,8 @@ export class LazyWorkerThreadHost { 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 { 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; failure?: unknown } = { + completion: Promise.resolve() + } + this.retiring = retirement + let proveExit!: () => void + const exitProof = new Promise((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 { - return this.retiring ?? Promise.resolve() + async retired(): Promise { + const retirement = this.retiring + await retirement?.completion + if (this.retiring === retirement && retirement?.failure !== undefined) { + throw retirement.failure + } } scheduleIdleTeardown(): void { diff --git a/src/main/worker-threads/worker-thread-request-queue.test.ts b/src/main/worker-threads/worker-thread-request-queue.test.ts index efc44b7dc1f..28a07dff34a 100644 --- a/src/main/worker-threads/worker-thread-request-queue.test.ts +++ b/src/main/worker-threads/worker-thread-request-queue.test.ts @@ -63,7 +63,10 @@ const MAX_CONSECUTIVE_DEATHS = 3 function makeQueue( workers: FakeWorker[], - options: { awaitRetirement?: boolean; makeWorker?: () => FakeWorker } = {} + options: { + awaitRetirement?: boolean + makeWorker?: () => FakeWorker + } = {} ): WorkerThreadRequestQueue { return new WorkerThreadRequestQueue({ 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() diff --git a/src/main/worker-threads/worker-thread-request-queue.ts b/src/main/worker-threads/worker-thread-request-queue.ts index 702e88a3a82..bcc917ac0d6 100644 --- a/src/main/worker-threads/worker-thread-request-queue.ts +++ b/src/main/worker-threads/worker-thread-request-queue.ts @@ -129,8 +129,12 @@ export class WorkerThreadRequestQueue< } } + retired(): Promise { + 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. diff --git a/src/preload/api/agent-usage-api.ts b/src/preload/api/agent-usage-api.ts index 2d11a83590a..8696c17885e 100644 --- a/src/preload/api/agent-usage-api.ts +++ b/src/preload/api/agent-usage-api.ts @@ -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 = Pick< > export type UsageProviderApi = { - getScanState: () => Promise + onConnectionChanged?: (listener: BackendConnectionListener) => () => void + getScanState: (purpose?: BackendConnectionPurpose) => Promise setEnabled: (args: { enabled: boolean }) => Promise refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise + getSnapshot: ( + args: UsageQueryArgs & { limit?: number }, + purpose?: BackendConnectionPurpose + ) => Promise getSummary: (args: UsageQueryArgs) => Promise getDaily: (args: UsageQueryArgs) => Promise getBreakdown: ( diff --git a/src/preload/api/claude-usage-bridge.ts b/src/preload/api/claude-usage-bridge.ts index 1b98202d88c..aee5e62425e 100644 --- a/src/preload/api/claude-usage-bridge.ts +++ b/src/preload/api/claude-usage-bridge.ts @@ -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'] diff --git a/src/preload/api/codex-usage-bridge.ts b/src/preload/api/codex-usage-bridge.ts index 2575f2bb0e9..0705d66ccb5 100644 --- a/src/preload/api/codex-usage-bridge.ts +++ b/src/preload/api/codex-usage-bridge.ts @@ -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'] diff --git a/src/preload/api/muse-usage-bridge.ts b/src/preload/api/muse-usage-bridge.ts index 4c765eccff0..a741ddac1cb 100644 --- a/src/preload/api/muse-usage-bridge.ts +++ b/src/preload/api/muse-usage-bridge.ts @@ -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'] diff --git a/src/preload/api/open-code-usage-bridge.ts b/src/preload/api/open-code-usage-bridge.ts index cd3564d2b32..8b583be211d 100644 --- a/src/preload/api/open-code-usage-bridge.ts +++ b/src/preload/api/open-code-usage-bridge.ts @@ -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'] diff --git a/src/preload/backend-port-client.ts b/src/preload/backend-port-client.ts new file mode 100644 index 00000000000..dae6df4e82b --- /dev/null +++ b/src/preload/backend-port-client.ts @@ -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 | undefined +type PortAttempt = { + token: string + purpose: BackendConnectionPurpose + resolve(client: BackendPortRpc): void + reject(error: Error): void +} +let pendingPort: PortAttempt | undefined +const connectionListeners = new Set() +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 { + 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((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 => (await connect(purpose)).call(method, params) +} diff --git a/src/preload/usage-provider-api.ts b/src/preload/usage-provider-api.ts index 4a57c1bc061..3b9723a6aa0 100644 --- a/src/preload/usage-provider-api.ts +++ b/src/preload/usage-provider-api.ts @@ -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( - ipc: Pick, + ipc: { + invoke(channel: string, args?: unknown, purpose?: BackendConnectionPurpose): Promise + onConnectionChanged?: (listener: BackendConnectionListener) => () => void + }, prefix: Key ): PreloadApi[Key] export function createUsageProviderApi( - ipc: Pick, + ipc: { + invoke(channel: string, args?: unknown, purpose?: BackendConnectionPurpose): Promise + 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 } diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 1cbee7e6717..346e257d0ca 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -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 ( } @@ -107,6 +115,7 @@ export function ClaudeUsagePane(): React.JSX.Element { return ( diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 12239584349..4027a1bf95c 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -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 ( diff --git a/src/renderer/src/components/stats/MuseUsagePane.tsx b/src/renderer/src/components/stats/MuseUsagePane.tsx index 650bcb1c086..27f78a01a34 100644 --- a/src/renderer/src/components/stats/MuseUsagePane.tsx +++ b/src/renderer/src/components/stats/MuseUsagePane.tsx @@ -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 ( diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index f9d8efa719b..755f9c62308 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -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 ( diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index dcab63dc721..09e7a19e82d 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -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() + 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 { ) : ''}

+ {[...failureGroups].map(([message, names]) => ( +

+ {names.join(', ')}: {message} +

+ ))} @@ -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 { - {!overview.hasAnyEnabledProvider ? ( + {allProvidersKnown && !overview.hasAnyEnabledProvider ? (
@@ -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')} @@ -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')} @@ -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')} @@ -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')}
- ) : ( + ) : overview.hasAnyEnabledProvider ? ( <>
- {!overview.hasAnyData ? ( + {!overview.hasAnyData && allProvidersKnown ? (
{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.' )}
- ) : ( + ) : overview.hasAnyData ? (
- )} + ) : null} - )} + ) : null}
@@ -268,11 +313,13 @@ export function UsageOverviewPane(): React.JSX.Element { {t('auto.components.stats.UsageOverviewPane.33f7b043d2', 'Providers')}

- {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 } + ) + : '—'}

@@ -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') { diff --git a/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx index 501c946bca3..f7f86d637e4 100644 --- a/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx +++ b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx @@ -45,6 +45,9 @@ export function UsageFilterRadioGroup({ type UsageTrackingPaneShellProps = { title: ReactNode + failure?: string | null + stateKnown?: boolean + retry?: { label: string; onRefresh: () => void | Promise } enableLabel: string onEnabledChange: (enabled: boolean) => void } & ( @@ -78,9 +81,20 @@ export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): Reac

{props.title}

{props.disabledDescription}

+ {props.failure && ( +

+ {props.failure} +

+ )} + {props.failure && props.retry && ( + + )}
@@ -95,6 +109,11 @@ export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): Reac

{props.title}

{props.status}

+ {props.failure && ( +

+ {props.failure} +

+ )}
{props.headerAction} diff --git a/src/renderer/src/components/stats/usage-failure-message.ts b/src/renderer/src/components/stats/usage-failure-message.ts new file mode 100644 index 00000000000..ceadfd92119 --- /dev/null +++ b/src/renderer/src/components/stats/usage-failure-message.ts @@ -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 +} diff --git a/src/renderer/src/components/stats/usage-failure-presentation.test.tsx b/src/renderer/src/components/stats/usage-failure-presentation.test.tsx new file mode 100644 index 00000000000..4c76fcf24cb --- /dev/null +++ b/src/renderer/src/components/stats/usage-failure-presentation.test.tsx @@ -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 = { 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() + 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( + + + + ) + 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( + + + + ) + expect(screen.queryByRole('alert')).toBeNull() + setup('disabled') + view.rerender( + + + + ) + 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() + 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( + + + + ) + expect(screen.queryByText('Empezar a rastrear tokens')).toBeNull() + expect(screen.getByRole('alert').textContent).toContain( + 'No se pudo completar la actualización anterior.' + ) +}) diff --git a/src/renderer/src/components/stats/usage-overview-sections.tsx b/src/renderer/src/components/stats/usage-overview-sections.tsx index 7d2be14f577..1faf1a231b6 100644 --- a/src/renderer/src/components/stats/usage-overview-sections.tsx +++ b/src/renderer/src/components/stats/usage-overview-sections.tsx @@ -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({
{provider.label}
- {status} + {stateKnown ? status : '—'}

{provider.topModel ?? @@ -271,7 +273,7 @@ export function ProviderUsageRow({

{!provider.enabled ? ( - ) : null} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index e637bd705ac..83de3d8862c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" + } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3d6d54aef13..bb38e5be6ec 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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" + } } } diff --git a/src/renderer/src/store/slices/usage/usage-backend-port.test.ts b/src/renderer/src/store/slices/usage/usage-backend-port.test.ts new file mode 100644 index 00000000000..63e8af26347 --- /dev/null +++ b/src/renderer/src/store/slices/usage/usage-backend-port.test.ts @@ -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 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): () => 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 | 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() + const held = Promise.withResolvers() + 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 | 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() + 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() + 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()((...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)] + ]) +}) diff --git a/src/renderer/src/store/slices/usage/usage-provider-slice-contract.ts b/src/renderer/src/store/slices/usage/usage-provider-slice-contract.ts new file mode 100644 index 00000000000..b524de54301 --- /dev/null +++ b/src/renderer/src/store/slices/usage/usage-provider-slice-contract.ts @@ -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> = { + 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> = { + onConnectionChanged?: (listener: BackendConnectionListener) => () => void + getScanState: (purpose?: BackendConnectionPurpose) => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: ( + args: { + scope: T['scope'] + range: T['range'] + limit?: number + }, + purpose?: BackendConnectionPurpose + ) => Promise +} + +export type ProviderUsageSlice< + Prefix extends string, + Name extends string, + T extends UsageProviderTypes +> = { + [K in keyof UsageData as `${Prefix}Usage${Capitalize}`]: UsageData[K] +} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise> & + Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise> & + Record<`set${Name}UsageRange`, (range: T['range']) => Promise> & + Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise> & + Record<`observe${Name}Usage`, () => () => void> & + Record<`enable${Name}Usage`, () => Promise> & + Record<`refresh${Name}Usage`, () => Promise> + +export type UsageProviderConfig< + Prefix extends string, + Name extends string, + T extends UsageProviderTypes +> = { + prefix: Prefix + name: Name + initialScope: T['scope'] + initialRange: T['range'] + getApi: () => UsageApi + 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 + +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> diff --git a/src/renderer/src/store/slices/usage/usage-provider-slice-state.ts b/src/renderer/src/store/slices/usage/usage-provider-slice-state.ts new file mode 100644 index 00000000000..f2965441106 --- /dev/null +++ b/src/renderer/src/store/slices/usage/usage-provider-slice-state.ts @@ -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>)[] + +function usageDataKey(prefix: string, field: string): string { + return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}` +} + +export function readUsageData>( + state: AppState, + prefix: string +): UsageData { + // 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 + // 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 +} + +export function createUsagePatch>( + prefix: string, + patch: Partial> +): Partial { + // 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 +} + +export function createInitialUsageData>( + scope: T['scope'], + range: T['range'] +): UsageData { + return { + failure: null, + scope, + range, + scanState: null, + summary: null, + daily: [], + modelBreakdown: [], + projectBreakdown: [], + recentSessions: [] + } +} + +export function mergeUsageFailure( + previous: UsageFailure | null, + patch: Partial +): 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 +} diff --git a/src/renderer/src/store/slices/usage/usage-provider-slice.ts b/src/renderer/src/store/slices/usage/usage-provider-slice.ts new file mode 100644 index 00000000000..1a74f0251a8 --- /dev/null +++ b/src/renderer/src/store/slices/usage/usage-provider-slice.ts @@ -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 +>( + config: UsageProviderConfig +): StateCreator> { + return (set, get) => { + const update = (patch: Partial>): void => + set(createUsagePatch(config.prefix, patch)) + const read = (): UsageData => readUsageData(get(), config.prefix) + + const updateFailure = (patch: Partial): 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 + } + 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, + current: Operation, + beforeRefresh: boolean + ): Promise => { + 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 => { + 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 => + 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 => { + 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(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 + } +} diff --git a/src/renderer/src/store/slices/usage/usage-provider-slices.test.ts b/src/renderer/src/store/slices/usage/usage-provider-slices.test.ts new file mode 100644 index 00000000000..15d4db6d8c6 --- /dev/null +++ b/src/renderer/src/store/slices/usage/usage-provider-slices.test.ts @@ -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() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} +function fixture() { + const listeners = new Set() + 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()((...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()((...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() + api.refresh.mockReturnValue(refresh.promise) + const stores = Array.from({ length: count }, () => + createStore()((...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() + 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() + 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() + 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() + const forcedState = deferred() + const forcedScan = deferred() + 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() + 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() + 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() + 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() + 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) +}) diff --git a/src/renderer/src/store/slices/usage/usage-provider-slices.ts b/src/renderer/src/store/slices/usage/usage-provider-slices.ts index 164eb61df6d..5cb6c708181 100644 --- a/src/renderer/src/store/slices/usage/usage-provider-slices.ts +++ b/src/renderer/src/store/slices/usage/usage-provider-slices.ts @@ -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> = { - 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> = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: T['scope'] - range: T['range'] - limit?: number - }) => Promise -} - -type ProviderUsageSlice< - Prefix extends string, - Name extends string, - T extends UsageProviderTypes -> = { - [K in keyof UsageData as `${Prefix}Usage${Capitalize}`]: UsageData[K] -} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise> & - Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise> & - Record<`set${Name}UsageRange`, (range: T['range']) => Promise> & - Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise> & - Record<`enable${Name}Usage`, () => Promise> & - Record<`refresh${Name}Usage`, () => Promise> - -type UsageProviderConfig< - Prefix extends string, - Name extends string, - T extends UsageProviderTypes -> = { - prefix: Prefix - name: Name - initialScope: T['scope'] - initialRange: T['range'] - getApi: () => UsageApi - hasCachedData: (scanState: T['snapshot']['scanState']) => boolean -} - -const usageDataFields = [ - 'scope', - 'range', - 'scanState', - 'summary', - 'daily', - 'modelBreakdown', - 'projectBreakdown', - 'recentSessions' -] as const satisfies readonly (keyof UsageData>)[] - -function usageDataKey(prefix: string, field: string): string { - return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}` -} - -function readUsageData>( - state: AppState, - prefix: string -): UsageData { - const values = state as unknown as Record - return Object.fromEntries( - usageDataFields.map((field) => [field, values[usageDataKey(prefix, field)]]) - ) as UsageData -} - -function createUsagePatch>( - prefix: string, - patch: Partial> -): Partial { - return Object.fromEntries( - usageDataFields - .filter((field) => field in patch) - .map((field) => [usageDataKey(prefix, field), patch[field]]) - ) as Partial -} - -function createUsageProviderSlice< - Prefix extends string, - Name extends string, - T extends UsageProviderTypes ->( - config: UsageProviderConfig -): StateCreator> { - return (set, get) => { - const update = (patch: Partial>): void => - set(createUsagePatch(config.prefix, patch)) - const read = (): UsageData => readUsageData(get(), config.prefix) - - const fetchUsage = async (opts?: { forceRefresh?: boolean }): Promise => { - 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 => { - 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 = { - 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 - } -} - -type ClaudeUsageTypes = UsageProviderTypes -type CodexUsageTypes = UsageProviderTypes -type OpenCodeUsageTypes = UsageProviderTypes< - OpenCodeUsageScope, - OpenCodeUsageRange, - OpenCodeUsageSnapshot -> - -type MuseUsageTypes = UsageProviderTypes - -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', diff --git a/src/renderer/src/store/slices/usage/usage-provider-types.ts b/src/renderer/src/store/slices/usage/usage-provider-types.ts new file mode 100644 index 00000000000..3b45a97ddaf --- /dev/null +++ b/src/renderer/src/store/slices/usage/usage-provider-types.ts @@ -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 +} diff --git a/src/renderer/src/store/slices/usage/use-usage-observation.ts b/src/renderer/src/store/slices/usage/use-usage-observation.ts new file mode 100644 index 00000000000..151b723cf36 --- /dev/null +++ b/src/renderer/src/store/slices/usage/use-usage-observation.ts @@ -0,0 +1,5 @@ +import { useEffect } from 'react' + +export function useUsageObservation(observe: () => () => void): void { + useEffect(() => observe(), [observe]) +} diff --git a/src/shared/backend-port-rpc.test.ts b/src/shared/backend-port-rpc.test.ts new file mode 100644 index 00000000000..4fa3e35546f --- /dev/null +++ b/src/shared/backend-port-rpc.test.ts @@ -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) { + 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() + const release = Promise.withResolvers() + 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() + 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() + const invoked = Promise.withResolvers() + const disconnected = Promise.withResolvers() + 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) +}) diff --git a/src/shared/backend-port-rpc.ts b/src/shared/backend-port-rpc.ts new file mode 100644 index 00000000000..dc73589b2c8 --- /dev/null +++ b/src/shared/backend-port-rpc.ts @@ -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() + private readonly admitted = new Map() + 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, + 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 { + 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 { + 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() + } + } + } +} diff --git a/src/shared/backend-service-contract.ts b/src/shared/backend-service-contract.ts new file mode 100644 index 00000000000..0775ed3708a --- /dev/null +++ b/src/shared/backend-service-contract.ts @@ -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 +export const UsageActionSchema = z.enum([ + 'getScanState', + 'setEnabled', + 'refresh', + 'getSnapshot', + 'getSummary', + 'getDaily', + 'getBreakdown', + 'getRecentSessions' +]) +export type UsageAction = z.infer +export type BackendResponse = RuntimeRpcResponse + +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 +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 +}