From cfe769e4bc7c2480d440100aa8a69d31814ab436 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:19:51 -0700 Subject: [PATCH] feat(usage): add Devin tracking backend Backend Devin tracking implementation. Co-authored-by: Eddie Jaoude --- ...ssion-scanner-agent-root-overrides.test.ts | 2 +- .../ai-vault/session-scanner-agent-sources.ts | 11 +- src/main/devin-accounts/status.ts | 27 ++ .../devin-transcript-discovery.test.ts | 38 ++ .../devin-usage/devin-transcript-discovery.ts | 81 +++++ .../devin-transcript-parse.test.ts | 259 ++++++++++++++ .../devin-usage/devin-transcript-parse.ts | 139 ++++++++ .../devin-usage/devin-usage-aggregation.ts | 24 ++ .../devin-usage-event-attribution.ts | 10 + .../devin-usage-projections.test.ts | 115 ++++++ .../devin-usage/devin-usage-projections.ts | 209 +++++++++++ src/main/devin-usage/devin-usage-provider.ts | 17 + .../devin-usage/persisted-state-schema.ts | 84 +++++ src/main/devin-usage/scanner.test.ts | 337 ++++++++++++++++++ src/main/devin-usage/scanner.ts | 108 ++++++ src/main/devin-usage/store.test.ts | 50 +++ src/main/devin-usage/store.ts | 113 ++++++ src/main/devin-usage/types.ts | 64 ++++ src/main/devin/devin-cli-data-dir.test.ts | 43 +++ src/main/devin/devin-cli-data-dir.ts | 29 ++ src/main/devin/sessions-index.ts | 140 ++++++++ src/main/ipc/devin-accounts.ts | 9 + src/main/ipc/rate-limits.test.ts | 20 +- src/main/ipc/rate-limits.ts | 1 + .../register-core-handlers.test.ts | 13 + .../register-core-handlers.ts | 6 +- src/main/ipc/usage-provider-handlers.test.ts | 6 +- src/main/ipc/usage-provider-handlers.ts | 3 + .../rate-limits/devin-credentials.test.ts | 95 +++++ src/main/rate-limits/devin-credentials.ts | 108 ++++++ src/main/rate-limits/devin-fetcher.test.ts | 310 ++++++++++++++++ src/main/rate-limits/devin-fetcher.ts | 197 ++++++++++ .../devin-user-status-wire.test.ts | 14 + .../rate-limits/devin-user-status-wire.ts | 219 ++++++++++++ .../rate-limit-service-test-harness.ts | 5 + .../service-account-target-selection.test.ts | 8 + .../service-antigravity-usage.test.ts | 8 + .../service-devin-identity.test.ts | 76 ++++ .../service-inactive-account-previews.test.ts | 8 + .../service-live-claude-usage.test.ts | 8 + .../rate-limits/service-minimax-usage.test.ts | 8 + .../service-refresh-orchestration.test.ts | 83 ++++- .../service-window-activation.test.ts | 39 ++ .../service/service-account-refresh.ts | 4 + .../service/service-configuration.ts | 1 + .../service/service-fetch-control.ts | 7 +- .../service/service-fetch-queue.ts | 331 ++++++----------- .../service/service-full-cycle-application.ts | 34 +- .../service/service-full-cycle-preparation.ts | 28 +- .../rate-limits/service/service-polling.ts | 6 +- .../service/service-provider-cycles.ts | 39 ++ .../service/service-result-policy.ts | 1 + src/main/rate-limits/service/service-state.ts | 26 +- src/main/rate-limits/service/service-types.ts | 4 +- src/main/startup/main-process-observers.ts | 2 + src/main/startup/main-process-preflight.ts | 2 + src/main/startup/main-process-quit.ts | 1 + src/main/startup/main-process-state.ts | 3 + src/main/startup/main-window-controller.ts | 1 + src/main/startup/main-window-core-services.ts | 3 + .../startup/main-window-service-readiness.ts | 1 + src/main/usage/usage-scan-worker-client.ts | 18 + src/main/usage/usage-scan-worker-entry.ts | 10 + src/main/usage/usage-scan-worker-protocol.ts | 14 +- src/main/usage/usage-scan-worker-spawn.ts | 25 ++ src/preload/api-types.ts | 11 +- src/preload/api/agent-account-api.ts | 5 +- src/preload/api/agent-usage-api.ts | 3 + src/preload/api/devin-accounts-bridge.ts | 7 + src/preload/api/devin-usage-bridge.ts | 8 + src/preload/api/rate-limits-bridge.ts | 1 + src/preload/index.ts | 4 + src/preload/usage-provider-api.test.ts | 2 +- src/preload/usage-provider-api.ts | 2 +- .../status-bar/StatusBarProviderSegment.tsx | 2 + .../usage-provider-settings-target.ts | 7 +- .../web/preload-api/web-rate-limits-api.ts | 1 + src/shared/devin-usage-types.ts | 75 ++++ src/shared/persisted-ui-state-types.ts | 2 + src/shared/rate-limit-state-factory.ts | 2 + src/shared/rate-limit-types.test.ts | 2 + src/shared/rate-limit-types.ts | 11 + src/shared/rpc-contract/client-ui-params.ts | 2 + src/shared/status-bar-defaults.ts | 1 + src/shared/ui-chrome-types.ts | 1 + 85 files changed, 3589 insertions(+), 265 deletions(-) create mode 100644 src/main/devin-accounts/status.ts create mode 100644 src/main/devin-usage/devin-transcript-discovery.test.ts create mode 100644 src/main/devin-usage/devin-transcript-discovery.ts create mode 100644 src/main/devin-usage/devin-transcript-parse.test.ts create mode 100644 src/main/devin-usage/devin-transcript-parse.ts create mode 100644 src/main/devin-usage/devin-usage-aggregation.ts create mode 100644 src/main/devin-usage/devin-usage-event-attribution.ts create mode 100644 src/main/devin-usage/devin-usage-projections.test.ts create mode 100644 src/main/devin-usage/devin-usage-projections.ts create mode 100644 src/main/devin-usage/devin-usage-provider.ts create mode 100644 src/main/devin-usage/persisted-state-schema.ts create mode 100644 src/main/devin-usage/scanner.test.ts create mode 100644 src/main/devin-usage/scanner.ts create mode 100644 src/main/devin-usage/store.test.ts create mode 100644 src/main/devin-usage/store.ts create mode 100644 src/main/devin-usage/types.ts create mode 100644 src/main/devin/devin-cli-data-dir.test.ts create mode 100644 src/main/devin/devin-cli-data-dir.ts create mode 100644 src/main/devin/sessions-index.ts create mode 100644 src/main/ipc/devin-accounts.ts create mode 100644 src/main/rate-limits/devin-credentials.test.ts create mode 100644 src/main/rate-limits/devin-credentials.ts create mode 100644 src/main/rate-limits/devin-fetcher.test.ts create mode 100644 src/main/rate-limits/devin-fetcher.ts create mode 100644 src/main/rate-limits/devin-user-status-wire.test.ts create mode 100644 src/main/rate-limits/devin-user-status-wire.ts create mode 100644 src/main/rate-limits/service-devin-identity.test.ts create mode 100644 src/preload/api/devin-accounts-bridge.ts create mode 100644 src/preload/api/devin-usage-bridge.ts create mode 100644 src/shared/devin-usage-types.ts diff --git a/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts b/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts index a75a437611a..8c1a03d167d 100644 --- a/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts +++ b/src/main/ai-vault/session-scanner-agent-root-overrides.test.ts @@ -52,7 +52,7 @@ const CASES = [ agent: 'devin', envVar: 'DEVIN_HOME', absolute: '/srv/devin', - absoluteRoot: join('/srv/devin', 'transcripts'), + absoluteRoot: join('/srv/devin', 'cli', 'transcripts'), defaultRoot: () => join(homedir(), '.local', 'share', 'devin', 'cli', 'transcripts') }, { diff --git a/src/main/ai-vault/session-scanner-agent-sources.ts b/src/main/ai-vault/session-scanner-agent-sources.ts index ff3abaddae9..862f8ab300b 100644 --- a/src/main/ai-vault/session-scanner-agent-sources.ts +++ b/src/main/ai-vault/session-scanner-agent-sources.ts @@ -16,6 +16,7 @@ import { claudeProjectsRootDirs, OMP_SESSIONS_DIR, sessionRootDirs } from './ses import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts' import type { AiVaultScanOptions } from './session-scanner-types' import { normalizeAgentSessionsDir, primeAgentSessionsDirFromEnv } from './session-scanner-values' +import { resolveDevinTranscriptsDir } from '../devin/devin-cli-data-dir' export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') const CODEX_SESSIONS_DIR = join( @@ -42,14 +43,8 @@ const PI_SESSIONS_DIR = normalizeAgentSessionsDir( // dedicated sessions-root override, so resolution differs from Pi/OMP in shape // as well as in variable name. const PRIME_AGENT_SESSIONS_DIR = primeAgentSessionsDirFromEnv() -// Why: Devin ATIF transcripts are stored under /transcripts. -const DEVIN_TRANSCRIPTS_DIR = join( - resolveAbsoluteDirOverride( - process.env.DEVIN_HOME, - join(homedir(), '.local', 'share', 'devin', 'cli') - ), - 'transcripts' -) +// Devin transcripts share the same root as the usage scanner. +export const DEVIN_TRANSCRIPTS_DIR = resolveDevinTranscriptsDir() const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') const CLINE_SESSIONS_DIR = diff --git a/src/main/devin-accounts/status.ts b/src/main/devin-accounts/status.ts new file mode 100644 index 00000000000..63ab60f0c53 --- /dev/null +++ b/src/main/devin-accounts/status.ts @@ -0,0 +1,27 @@ +import type { DevinAccountStatus, ProviderRateLimits } from '../../shared/rate-limit-types' +import { readDevinCredentials } from '../rate-limits/devin-credentials' + +export function getDevinAccountStatus(limits: ProviderRateLimits | null): DevinAccountStatus { + const readResult = readDevinCredentials() + if (readResult.status === 'missing') { + return { signedIn: false, email: null, tokenFresh: false, plan: null, error: null } + } + if (readResult.status === 'error') { + return { + signedIn: false, + email: null, + tokenFresh: false, + plan: null, + error: readResult.error + } + } + const delegatedRefreshRequired = + limits?.usageMetadata?.failureKind === 'delegated-refresh-required' + return { + signedIn: true, + email: limits?.usageMetadata?.authProvenance ?? null, + tokenFresh: !delegatedRefreshRequired, + plan: limits?.planType ?? null, + error: delegatedRefreshRequired ? limits.error : null + } +} diff --git a/src/main/devin-usage/devin-transcript-discovery.test.ts b/src/main/devin-usage/devin-transcript-discovery.test.ts new file mode 100644 index 00000000000..9ccfadfa2a6 --- /dev/null +++ b/src/main/devin-usage/devin-transcript-discovery.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-error' + +const { wslGatedReaddir } = vi.hoisted(() => ({ wslGatedReaddir: vi.fn() })) + +vi.mock('../native-chat/wsl-transcript-fs-access', () => ({ + wslGatedReaddir, + wslGatedStat: vi.fn() +})) + +import { listDevinTranscriptFiles } from './devin-transcript-discovery' + +describe('listDevinTranscriptFiles', () => { + it('returns an empty list only when the transcripts dir is missing', async () => { + wslGatedReaddir.mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOENT' })) + await expect(listDevinTranscriptFiles()).resolves.toEqual([]) + + wslGatedReaddir.mockRejectedValue(Object.assign(new Error('nope'), { code: 'ENOTDIR' })) + await expect(listDevinTranscriptFiles()).resolves.toEqual([]) + }) + + it('propagates transient filesystem failures so cached usage survives', async () => { + const eacces = Object.assign(new Error('denied'), { code: 'EACCES' }) + wslGatedReaddir.mockRejectedValue(eacces) + await expect(listDevinTranscriptFiles()).rejects.toBe(eacces) + + const eio = Object.assign(new Error('io'), { code: 'EIO' }) + wslGatedReaddir.mockRejectedValue(eio) + await expect(listDevinTranscriptFiles()).rejects.toBe(eio) + }) + + it('propagates a WSL refusal instead of swallowing it as empty', async () => { + const refusal = new WslTranscriptFsError('timeout', 'slow share') + wslGatedReaddir.mockRejectedValue(refusal) + + await expect(listDevinTranscriptFiles()).rejects.toBe(refusal) + }) +}) diff --git a/src/main/devin-usage/devin-transcript-discovery.ts b/src/main/devin-usage/devin-transcript-discovery.ts new file mode 100644 index 00000000000..19628b6445d --- /dev/null +++ b/src/main/devin-usage/devin-transcript-discovery.ts @@ -0,0 +1,81 @@ +import { join } from 'node:path' +import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access' +import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate' +import type { SessionSidecarObservation } from '../ai-vault/session-sidecar-stat' +import { resolveDevinTranscriptsDir } from '../devin/devin-cli-data-dir' +import type { DevinUsageProcessedFile } from './types' + +// Why gated: a DEVIN_HOME override can point the transcripts root at a +// \\wsl$ UNC path, where a raw syscall on a stalled distro would hang the +// whole scan (STA-4049). +export async function listDevinTranscriptFiles(): Promise { + const transcriptsDir = resolveDevinTranscriptsDir() + try { + const entries = await wslGatedReaddir(transcriptsDir, 'scan') + return entries + .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json')) + .map((entry) => join(transcriptsDir, entry.name)) + .sort() + } catch (error) { + // Why: only a genuinely missing transcripts dir means "no data". A + // transient EACCES/EIO/WSL refusal must surface as a scan error so the + // store keeps the previous projection instead of caching an empty one. + if (isMissingFsError(error)) { + return [] + } + throw error + } +} + +export async function getProcessedFileInfo(filePath: string): Promise { + const fileStat = await wslGatedStat(filePath, 'scan') + return { + path: filePath, + mtimeMs: fileStat.mtimeMs, + size: fileStat.size + } +} + +/** + * Observe the sessions.db a transcripts dir is indexed by, so a db-only change + * (working_directory edit, hidden toggle) re-attributes cached files. Only a + * genuinely missing db is 'none' — every other failure must retry next scan. + */ +export async function observeDevinSessionsDb( + transcriptsDir: string +): Promise { + const dbPath = join(transcriptsDir, '..', 'sessions.db') + // Why wal first: WAL-mode commits land in sessions.db-wal while the db's own + // stat stays put until checkpoint, so the wal is the fresher signal — same + // rule the AI Vault applies via devinSessionsDbDependencyPath. + try { + const walPath = `${dbPath}-wal` + const walStat = await wslGatedStat(walPath, 'scan') + if (walStat.isFile()) { + return { path: walPath, mtimeMs: walStat.mtimeMs, sizeBytes: walStat.size } + } + } catch (error) { + if (!isMissingFsError(error)) { + return 'unknown' + } + } + try { + const dbStat = await wslGatedStat(dbPath, 'scan') + return dbStat.isFile() + ? { path: dbPath, mtimeMs: dbStat.mtimeMs, sizeBytes: dbStat.size } + : 'none' + } catch (error) { + return isMissingFsError(error) ? 'none' : 'unknown' + } +} + +function isMissingFsError(error: unknown): boolean { + if (error instanceof WslTranscriptFsError) { + return false + } + const code = + error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' + ? error.code + : null + return code === 'ENOENT' || code === 'ENOTDIR' +} diff --git a/src/main/devin-usage/devin-transcript-parse.test.ts b/src/main/devin-usage/devin-transcript-parse.test.ts new file mode 100644 index 00000000000..441953ae337 --- /dev/null +++ b/src/main/devin-usage/devin-transcript-parse.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest' +import type { DevinSessionIndexRow, DevinSessionsIndex } from '../devin/sessions-index' +import { parseDevinTranscriptForUsage } from './devin-transcript-parse' + +function indexOf(rows: Record>): DevinSessionsIndex { + const index: DevinSessionsIndex = new Map() + for (const [id, row] of Object.entries(rows)) { + index.set(id, { + workingDirectory: null, + title: null, + model: null, + createdAt: null, + lastActivityAt: null, + hidden: false, + ...row + }) + } + return index +} + +describe('parseDevinTranscriptForUsage', () => { + it('treats ATIF cached_tokens as a subset of prompt_tokens', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/s1.json', + JSON.stringify({ + session_id: 's1', + agent: { model_name: 'swe-2' }, + steps: [ + { + timestamp: '2026-09-01T10:00:00.123456Z', + metrics: { prompt_tokens: 100, cached_tokens: 30, completion_tokens: 20 } + } + ] + }), + null + ) + + expect(parsed?.events).toHaveLength(1) + expect(parsed?.events[0]).toMatchObject({ + sessionId: 's1', + model: 'swe-2', + inputTokens: 100, + cachedInputTokens: 30, + outputTokens: 20, + totalTokens: 120, + estimatedCostUsd: null + }) + }) + + it('folds legacy additive cache buckets into input', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/s2.json', + JSON.stringify({ + session_id: 's2', + steps: [ + { + metadata: { + created_at: '2026-09-01T10:00:00Z', + metrics: { + input_tokens: 50, + output_tokens: 10, + cache_read_tokens: 30, + cache_creation_tokens: 5 + } + } + } + ] + }), + null + ) + + expect(parsed?.events[0]).toMatchObject({ + inputTokens: 85, + cachedInputTokens: 30, + outputTokens: 10, + totalTokens: 95 + }) + }) + + it('folds current total_input_tokens with its separate cache buckets', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/current.json', + JSON.stringify({ + session_id: 'current', + steps: [ + { + timestamp: '2026-09-01T10:00:00Z', + metrics: { + total_input_tokens: 50, + cache_read_input_tokens: 30, + cache_creation_input_tokens: 5, + output_tokens: 10 + } + } + ] + }), + null + ) + + expect(parsed?.events[0]).toMatchObject({ + inputTokens: 85, + cachedInputTokens: 30, + outputTokens: 10, + totalTokens: 95 + }) + }) + + it('keeps ATIF cache creation out of both cached input and prompt tokens', () => { + // Real transcripts carry cached_tokens (read) alongside a separate, + // larger cache_creation_input_tokens; prompt_tokens already includes both. + const parsed = parseDevinTranscriptForUsage( + '/transcripts/s2b.json', + JSON.stringify({ + session_id: 's2b', + steps: [ + { + timestamp: '2026-09-01T10:00:00Z', + metrics: { + prompt_tokens: 25866, + completion_tokens: 174, + cached_tokens: 12018, + cache_creation_input_tokens: 13844 + } + } + ] + }), + null + ) + + expect(parsed?.events[0]).toMatchObject({ + inputTokens: 25866, + cachedInputTokens: 12018, + outputTokens: 174, + totalTokens: 26040 + }) + }) + + it('does not double-count metrics present in both metadata and step metrics', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/s3.json', + JSON.stringify({ + session_id: 's3', + steps: [ + { + timestamp: '2026-09-01T10:00:00Z', + metadata: { metrics: { prompt_tokens: 40, completion_tokens: 8 } }, + metrics: { prompt_tokens: 40, cached_tokens: 12, completion_tokens: 8 } + } + ] + }), + null + ) + + expect(parsed?.events[0]).toMatchObject({ + inputTokens: 40, + cachedInputTokens: 12, + totalTokens: 48 + }) + }) + + it('uses sessions.db for cwd, model fallback, and hidden flag', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/s4.json', + JSON.stringify({ + session_id: 's4', + steps: [ + { + timestamp: '2026-09-01T10:00:00Z', + metrics: { prompt_tokens: 10, completion_tokens: 5 } + } + ] + }), + indexOf({ + s4: { workingDirectory: 'D:\\Project\\orca', model: 'swe-2-high', hidden: true } + }) + ) + + expect(parsed?.hidden).toBe(true) + expect(parsed?.events[0]).toMatchObject({ cwd: 'D:\\Project\\orca', model: 'swe-2-high' }) + }) + + it('prefers transcript working_directory over the db row', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/s5.json', + JSON.stringify({ + session_id: 's5', + working_directory: '/home/user/repo', + steps: [ + { + timestamp: '2026-09-01T10:00:00Z', + metrics: { prompt_tokens: 10, completion_tokens: 5 } + } + ] + }), + indexOf({ s5: { workingDirectory: 'D:\\other', model: null, hidden: false } }) + ) + + expect(parsed?.events[0].cwd).toBe('/home/user/repo') + }) + + it('skips steps with no tokens or no timestamp and rejects invalid JSON', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/s6.json', + JSON.stringify({ + session_id: 's6', + steps: [ + { timestamp: '2026-09-01T10:00:00Z' }, + { metrics: { prompt_tokens: 10, completion_tokens: 1 } }, + { + timestamp: '2026-09-01T11:00:00Z', + metrics: { prompt_tokens: 7, completion_tokens: 3 } + } + ] + }), + null + ) + + expect(parsed?.events).toHaveLength(1) + expect(parsed?.events[0].totalTokens).toBe(10) + + expect(parseDevinTranscriptForUsage('/t/x.json', '{not json', null)).toBeNull() + expect(parseDevinTranscriptForUsage('/t/x.json', '42', null)).toBeNull() + }) + + it('falls back to the filename for the session id', () => { + const parsed = parseDevinTranscriptForUsage( + '/transcripts/file-id.json', + JSON.stringify({ + steps: [ + { + timestamp: '2026-09-01T10:00:00Z', + metrics: { prompt_tokens: 1, completion_tokens: 1 } + } + ] + }), + null + ) + + expect(parsed?.sessionId).toBe('file-id') + expect(parsed?.events[0].sessionId).toBe('file-id') + }) + it('honors an explicit zero before lower-priority fallback metrics', () => { + const parsed = parseDevinTranscriptForUsage( + 'zero.json', + JSON.stringify({ + session_id: 'zero', + steps: [ + { + timestamp: '2026-09-18T00:00:00Z', + metadata: { total_input_tokens: 0, output_tokens: 1 }, + metrics: { prompt_tokens: 100, completion_tokens: 50 } + } + ] + }), + null + ) + expect(parsed?.events[0]).toMatchObject({ inputTokens: 0, outputTokens: 1, totalTokens: 1 }) + }) +}) diff --git a/src/main/devin-usage/devin-transcript-parse.ts b/src/main/devin-usage/devin-transcript-parse.ts new file mode 100644 index 00000000000..2913f48df5b --- /dev/null +++ b/src/main/devin-usage/devin-transcript-parse.ts @@ -0,0 +1,139 @@ +import type { DevinSessionsIndex } from '../devin/sessions-index' +import { asRecord } from '../ai-vault/session-scanner-record-value' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' +import { arrayValue, extractString } from '../ai-vault/session-scanner-values' +import type { DevinUsageParsedEvent } from './types' + +export type DevinTranscriptUsageParse = { + sessionId: string + /** True when sessions.db marks the session hidden in Devin's own UI. */ + hidden: boolean + events: DevinUsageParsedEvent[] +} + +const DEVIN_INPUT_KEYS = ['total_input_tokens', 'input_tokens', 'prompt_tokens'] as const +const DEVIN_OUTPUT_KEYS = ['output_tokens', 'completion_tokens'] as const +const DEVIN_CACHE_READ_KEYS = [ + 'cache_read_tokens', + 'cache_read_input_tokens', + 'cached_tokens' +] as const +const DEVIN_CACHE_WRITE_KEYS = ['cache_creation_tokens', 'cache_creation_input_tokens'] as const +const DEVIN_REASONING_KEYS = ['reasoning_tokens', 'reasoning_output_tokens'] as const + +/** + * Read one metric bucket from the first source that reports a non-negative integer, + * so a step carrying both legacy metadata metrics and ATIF step-level metrics + * never double-counts. Mirrors the AI Vault's devinStepTokenTotal sources. + */ +function firstDevinMetric( + sources: readonly (Record | null)[], + keys: readonly string[] +): { value: number; key: string | null } { + for (const source of sources) { + if (!source) { + continue + } + for (const key of keys) { + const value = source[key] + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) { + return { value, key } + } + } + } + return { value: 0, key: null } +} + +/** + * Fold one ATIF transcript into usage events. Devin reports metrics per LLM + * step: `prompt_tokens`/`cached_tokens`/`completion_tokens` (ATIF ≥1.7, where + * cached is a subset of prompt) or the legacy `input_tokens`/`output_tokens`/ + * `cache_*` split (where cache is additive). Both are normalized so + * `cached ⊆ input` and `total = input + output` hold for every event. + */ +export function parseDevinTranscriptForUsage( + filePath: string, + content: string, + sessionsIndex: DevinSessionsIndex | null +): DevinTranscriptUsageParse | null { + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + return null + } + const record = asRecord(parsed) + if (!record) { + return null + } + const sessionId = + extractString(record.session_id) ?? + extractString(record.sessionId) ?? + sessionIdFromFileName(filePath) + const dbRow = sessionsIndex?.get(sessionId) ?? sessionsIndex?.get(sessionIdFromFileName(filePath)) + const agentRecord = asRecord(record.agent) + const sessionModel = + extractString(agentRecord?.model_name) ?? + extractString(agentRecord?.model) ?? + extractString(record.generation_model) ?? + dbRow?.model ?? + null + // Why: Windows transcripts carry no cwd; the sessions.db row is the + // attribution source there (same as the AI Vault merge). + const sessionCwd = extractString(record.working_directory) ?? dbRow?.workingDirectory ?? null + + const events: DevinUsageParsedEvent[] = [] + for (const step of arrayValue(record.steps)) { + const stepRecord = asRecord(step) + if (!stepRecord) { + continue + } + const metadata = asRecord(stepRecord.metadata) + const sources = [metadata, asRecord(metadata?.metrics), asRecord(stepRecord.metrics)] + const input = firstDevinMetric(sources, DEVIN_INPUT_KEYS) + const output = firstDevinMetric(sources, DEVIN_OUTPUT_KEYS) + const cacheRead = firstDevinMetric(sources, DEVIN_CACHE_READ_KEYS) + const cacheWrite = firstDevinMetric(sources, DEVIN_CACHE_WRITE_KEYS) + const reasoning = firstDevinMetric(sources, DEVIN_REASONING_KEYS) + // cachedInputTokens is the cache-read bucket only, matching the shared + // contract (Claude/Codex count reads, not cache writes); creation still + // bills as input below. ATIF `cached_tokens` is the read subset, not the + // combined cached figure — real transcripts carry a separate, larger + // cache_creation_input_tokens alongside it. + const cachedTokens = cacheRead.value + // ATIF prompt_tokens already includes cache, while total_input_tokens is + // the base input bucket used with separate cache read/write fields. + const inputTokens = + input.key === 'prompt_tokens' ? input.value : input.value + cacheRead.value + cacheWrite.value + const outputTokens = output.value + const totalTokens = inputTokens + outputTokens + if (totalTokens <= 0) { + continue + } + const timestamp = extractString(stepRecord.timestamp) ?? extractString(metadata?.created_at) + if (!timestamp) { + continue + } + const extra = asRecord(stepRecord.extra) + const metrics = asRecord(stepRecord.metrics) + events.push({ + sessionId, + timestamp, + model: + extractString(stepRecord.model_name) ?? + extractString(extra?.generation_model) ?? + extractString(metadata?.generation_model) ?? + extractString(metrics?.generation_model) ?? + sessionModel, + cwd: sessionCwd, + estimatedCostUsd: null, + inputTokens, + cachedInputTokens: Math.min(cachedTokens, inputTokens), + outputTokens, + reasoningOutputTokens: reasoning.value, + totalTokens + }) + } + + return { sessionId, hidden: dbRow?.hidden === true, events } +} diff --git a/src/main/devin-usage/devin-usage-aggregation.ts b/src/main/devin-usage/devin-usage-aggregation.ts new file mode 100644 index 00000000000..d9a70071277 --- /dev/null +++ b/src/main/devin-usage/devin-usage-aggregation.ts @@ -0,0 +1,24 @@ +import { createUsageEventAggregation } from '../usage/usage-event-aggregation' +import type { DevinUsageAttributedEvent, DevinUsageMetric } from './types' + +export const devinUsageAggregation = createUsageEventAggregation< + DevinUsageAttributedEvent, + DevinUsageMetric +>({ + metric: { + empty: () => ({ estimatedCostUsd: null }), + fromEvent: (event) => ({ estimatedCostUsd: event.estimatedCostUsd }), + fold: (target, source) => { + if (target.estimatedCostUsd === null && source.estimatedCostUsd === null) { + return + } + target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + (source.estimatedCostUsd ?? 0) + } + }, + cloneSessionForMerge: (session) => ({ + ...session, + locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })), + modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })), + locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry })) + }) +}) diff --git a/src/main/devin-usage/devin-usage-event-attribution.ts b/src/main/devin-usage/devin-usage-event-attribution.ts new file mode 100644 index 00000000000..a8fa166bdf1 --- /dev/null +++ b/src/main/devin-usage/devin-usage-event-attribution.ts @@ -0,0 +1,10 @@ +import { attributeUsageEvent } from '../usage/usage-event-attribution' +import type { UsageWorktreeResolver } from '../usage/usage-worktree-resolver' +import type { DevinUsageAttributedEvent, DevinUsageParsedEvent } from './types' + +export function attributeDevinUsageEvent( + event: DevinUsageParsedEvent, + resolveWorktree: UsageWorktreeResolver +): DevinUsageAttributedEvent | null { + return attributeUsageEvent(event, resolveWorktree) +} diff --git a/src/main/devin-usage/devin-usage-projections.test.ts b/src/main/devin-usage/devin-usage-projections.test.ts new file mode 100644 index 00000000000..e40cce2470b --- /dev/null +++ b/src/main/devin-usage/devin-usage-projections.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { devinUsageAggregation } from './devin-usage-aggregation' +import { + buildDevinBreakdown, + buildDevinDaily, + buildDevinRecentSessions, + buildDevinSummary +} from './devin-usage-projections' +import type { DevinUsageAttributedEvent, DevinUsagePersistedState } from './types' + +function event(overrides: Partial = {}): DevinUsageAttributedEvent { + return { + sessionId: 's1', + timestamp: '2026-09-18T10:00:00Z', + day: '2026-09-18', + model: 'model-a', + cwd: '/repo/a', + projectKey: 'worktree:a', + projectLabel: 'Same name', + repoId: 'repo-a', + worktreeId: 'a', + inputTokens: 10, + cachedInputTokens: 3, + outputTokens: 5, + reasoningOutputTokens: 2, + totalTokens: 15, + estimatedCostUsd: null, + ...overrides + } +} + +function state(events: DevinUsageAttributedEvent[]): DevinUsagePersistedState { + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedFiles: [], + ...devinUsageAggregation.aggregate(events), + scanState: { + enabled: true, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +const mixedLocations = () => + state([ + event(), + event({ + model: 'model-b', + cwd: '/outside', + projectKey: 'cwd:/outside', + projectLabel: 'Outside', + repoId: null, + worktreeId: null + }), + event({ sessionId: 's2', model: 'model-b' }), + event({ sessionId: 's2', model: 'model-b', timestamp: '2026-09-18T10:01:00Z' }) + ]) + +describe('Devin usage projections', () => { + it('does not pool distinct projects that have the same display name', () => { + const snapshot = state([ + event({ totalTokens: 15 }), + event({ sessionId: 's2', projectKey: 'worktree:b', worktreeId: 'b', totalTokens: 15 }), + event({ + sessionId: 's3', + projectKey: 'worktree:c', + worktreeId: 'c', + projectLabel: 'Winner', + totalTokens: 20 + }) + ]) + expect(buildDevinSummary(snapshot, 'all', 'all').topProject).toBe('Winner') + expect(buildDevinBreakdown(snapshot, 'all', 'all', 'project')).toHaveLength(3) + }) + + it('counts each scoped model once per session, excluding models used only elsewhere', () => { + expect(buildDevinBreakdown(mixedLocations(), 'orca', 'all', 'model')).toEqual([ + expect.objectContaining({ key: 'model-b', sessions: 1, events: 2 }), + expect.objectContaining({ key: 'model-a', sessions: 1, events: 1 }) + ]) + expect(buildDevinBreakdown(mixedLocations(), 'all', 'all', 'model')[0]).toMatchObject({ + key: 'model-b', + sessions: 2, + events: 3 + }) + }) + + it('labels recent sessions using only models in the selected scope', () => { + expect( + buildDevinRecentSessions(mixedLocations(), 'orca', 'all', 10).find( + (row) => row.sessionId === 's1' + ) + ).toMatchObject({ model: 'model-a', totalTokens: 15 }) + expect( + buildDevinRecentSessions(mixedLocations(), 'all', 'all', 10).find( + (row) => row.sessionId === 's1' + ) + ).toMatchObject({ model: 'Mixed models', totalTokens: 30 }) + }) + + it('preserves reasoning tokens throughout the scoped projections', () => { + const snapshot = mixedLocations() + expect(buildDevinSummary(snapshot, 'orca', 'all').reasoningOutputTokens).toBe(6) + expect(buildDevinDaily(snapshot, 'orca', 'all')[0].reasoningOutputTokens).toBe(6) + expect(buildDevinBreakdown(snapshot, 'orca', 'all', 'model')[0].reasoningOutputTokens).toBe(4) + expect(buildDevinBreakdown(snapshot, 'orca', 'all', 'project')[0].reasoningOutputTokens).toBe(6) + expect( + buildDevinRecentSessions(snapshot, 'orca', 'all', 10).find((row) => row.sessionId === 's1') + ?.reasoningOutputTokens + ).toBe(2) + }) +}) diff --git a/src/main/devin-usage/devin-usage-projections.ts b/src/main/devin-usage/devin-usage-projections.ts new file mode 100644 index 00000000000..5176b5e5851 --- /dev/null +++ b/src/main/devin-usage/devin-usage-projections.ts @@ -0,0 +1,209 @@ +import { highestUsageKey } from '../usage/highest-usage-key' +import { filterUsageDaily, filterUsageSessions } from '../usage/usage-scope-filters' +import type { + DevinUsageBreakdownKind, + DevinUsageBreakdownRow, + DevinUsageDailyPoint, + DevinUsageRange, + DevinUsageScope, + DevinUsageSessionRow, + DevinUsageSummary +} from '../../shared/devin-usage-types' +import type { DevinUsagePersistedState } from './types' + +function filteredDaily( + state: DevinUsagePersistedState, + scope: DevinUsageScope, + range: DevinUsageRange +) { + return filterUsageDaily(state.dailyAggregates, scope, range) +} + +function filteredSessions( + state: DevinUsagePersistedState, + scope: DevinUsageScope, + range: DevinUsageRange +) { + return filterUsageSessions(state.sessions, scope, range) +} + +export function buildDevinSummary( + state: DevinUsagePersistedState, + scope: DevinUsageScope, + range: DevinUsageRange +): DevinUsageSummary { + const daily = filteredDaily(state, scope, range) + const sessions = filteredSessions(state, scope, range) + let inputTokens = 0 + let cachedInputTokens = 0 + let outputTokens = 0 + let reasoningOutputTokens = 0 + let totalTokens = 0 + let events = 0 + const byModel = new Map() + const byProject = new Map() + const projectLabels = new Map() + for (const row of daily) { + inputTokens += row.inputTokens + cachedInputTokens += row.cachedInputTokens + outputTokens += row.outputTokens + reasoningOutputTokens += row.reasoningOutputTokens + totalTokens += row.totalTokens + events += row.eventCount + const model = row.model ?? 'Unknown model' + byModel.set(model, (byModel.get(model) ?? 0) + row.totalTokens) + byProject.set(row.projectKey, (byProject.get(row.projectKey) ?? 0) + row.totalTokens) + projectLabels.set(row.projectKey, row.projectLabel) + } + const topProjectKey = highestUsageKey(byProject) + return { + scope, + range, + sessions: sessions.length, + events, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens, + estimatedCostUsd: null, + topModel: highestUsageKey(byModel), + topProject: topProjectKey ? (projectLabels.get(topProjectKey) ?? topProjectKey) : null, + hasAnyDevinData: sessions.length > 0 || daily.length > 0 + } +} + +export function buildDevinDaily( + state: DevinUsagePersistedState, + scope: DevinUsageScope, + range: DevinUsageRange +): DevinUsageDailyPoint[] { + const rows = new Map() + for (const entry of filteredDaily(state, scope, range)) { + const row = rows.get(entry.day) ?? { + day: entry.day, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0 + } + row.inputTokens += entry.inputTokens + row.cachedInputTokens += entry.cachedInputTokens + row.outputTokens += entry.outputTokens + row.reasoningOutputTokens += entry.reasoningOutputTokens + row.totalTokens += entry.totalTokens + rows.set(entry.day, row) + } + return [...rows.values()].sort((a, b) => a.day.localeCompare(b.day)) +} + +export function buildDevinBreakdown( + state: DevinUsagePersistedState, + scope: DevinUsageScope, + range: DevinUsageRange, + kind: DevinUsageBreakdownKind +): DevinUsageBreakdownRow[] { + const rows = new Map() + for (const entry of filteredDaily(state, scope, range)) { + const key = kind === 'model' ? (entry.model ?? 'unknown') : entry.projectKey + const row = rows.get(key) ?? { + key, + label: kind === 'model' ? (entry.model ?? 'Unknown model') : entry.projectLabel, + sessions: 0, + events: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + estimatedCostUsd: null, + hasInferredPricing: false + } + row.events += entry.eventCount + row.inputTokens += entry.inputTokens + row.cachedInputTokens += entry.cachedInputTokens + row.outputTokens += entry.outputTokens + row.reasoningOutputTokens += entry.reasoningOutputTokens + row.totalTokens += entry.totalTokens + rows.set(key, row) + } + for (const session of filteredSessions(state, scope, range)) { + const seen = new Set() + if (kind === 'model') { + for (const entry of session.locationModelBreakdown) { + if (scope === 'orca' && entry.worktreeId === null) { + continue + } + if (seen.has(entry.modelKey)) { + continue + } + const row = rows.get(entry.modelKey) + if (row) { + row.sessions++ + } + seen.add(entry.modelKey) + } + continue + } + for (const entry of session.locationBreakdown) { + if (scope === 'orca' && entry.worktreeId === null) { + continue + } + if (seen.has(entry.locationKey)) { + continue + } + const row = rows.get(entry.locationKey) + if (row) { + row.sessions++ + } + seen.add(entry.locationKey) + } + } + return [...rows.values()].sort((a, b) => b.totalTokens - a.totalTokens) +} + +export function buildDevinRecentSessions( + state: DevinUsagePersistedState, + scope: DevinUsageScope, + range: DevinUsageRange, + limit: number +): DevinUsageSessionRow[] { + return filteredSessions(state, scope, range) + .slice(0, Number.isFinite(limit) ? Math.max(0, Math.min(100, Math.floor(limit))) : 10) + .map((session) => { + const locations = session.locationBreakdown.filter( + (entry) => scope === 'all' || entry.worktreeId !== null + ) + const models = new Map( + session.locationModelBreakdown + .filter((entry) => scope === 'all' || entry.worktreeId !== null) + .map((entry) => [entry.modelKey, entry.modelLabel]) + ) + return { + sessionId: session.sessionId, + lastActiveAt: session.lastTimestamp, + durationMinutes: Math.max( + 0, + Math.round( + (Date.parse(session.lastTimestamp) - Date.parse(session.firstTimestamp)) / 60_000 + ) + ), + projectLabel: + locations.length > 1 + ? 'Multiple locations' + : (locations[0]?.projectLabel ?? session.primaryProjectLabel), + model: models.size > 1 ? 'Mixed models' : (models.values().next().value ?? null), + events: locations.reduce((sum, entry) => sum + entry.eventCount, 0), + inputTokens: locations.reduce((sum, entry) => sum + entry.inputTokens, 0), + cachedInputTokens: locations.reduce((sum, entry) => sum + entry.cachedInputTokens, 0), + outputTokens: locations.reduce((sum, entry) => sum + entry.outputTokens, 0), + reasoningOutputTokens: locations.reduce( + (sum, entry) => sum + entry.reasoningOutputTokens, + 0 + ), + totalTokens: locations.reduce((sum, entry) => sum + entry.totalTokens, 0), + hasInferredPricing: false + } + }) +} diff --git a/src/main/devin-usage/devin-usage-provider.ts b/src/main/devin-usage/devin-usage-provider.ts new file mode 100644 index 00000000000..20a8f70ea51 --- /dev/null +++ b/src/main/devin-usage/devin-usage-provider.ts @@ -0,0 +1,17 @@ +import type { UsageProvider } from '../usage/usage-provider-contract' +import { scanDevinUsageFilesViaWorker } from '../usage/usage-scan-worker-spawn' +import type { DevinUsageDailyAggregate, DevinUsagePersistedFile, DevinUsageSession } from './types' + +export const DEVIN_USAGE_SCHEMA_VERSION = 2 + +export const devinUsageProvider = { + id: 'devin', + label: 'Devin', + schemaVersion: DEVIN_USAGE_SCHEMA_VERSION, + scan: scanDevinUsageFilesViaWorker +} satisfies UsageProvider< + 'processedFiles', + DevinUsagePersistedFile, + DevinUsageSession, + DevinUsageDailyAggregate +> diff --git a/src/main/devin-usage/persisted-state-schema.ts b/src/main/devin-usage/persisted-state-schema.ts new file mode 100644 index 00000000000..91ffc282cee --- /dev/null +++ b/src/main/devin-usage/persisted-state-schema.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' + +const count = z.number().finite().nonnegative() +const nullableText = z.string().nullable() +const tokens = { + eventCount: count, + inputTokens: count, + cachedInputTokens: count, + outputTokens: count, + reasoningOutputTokens: count, + totalTokens: count, + estimatedCostUsd: count.nullable() +} +const location = z.object({ + ...tokens, + locationKey: z.string(), + projectLabel: z.string(), + repoId: nullableText, + worktreeId: nullableText +}) +const model = z.object({ ...tokens, modelKey: z.string(), modelLabel: z.string() }) +const locationModel = model.extend({ + locationKey: z.string(), + repoId: nullableText, + worktreeId: nullableText +}) +const session = z.object({ + sessionId: z.string(), + firstTimestamp: z.string(), + lastTimestamp: z.string(), + primaryModel: nullableText, + hasMixedModels: z.boolean(), + primaryProjectLabel: z.string(), + hasMixedLocations: z.boolean(), + primaryWorktreeId: nullableText, + primaryRepoId: nullableText, + eventCount: count, + totalInputTokens: count, + totalCachedInputTokens: count, + totalOutputTokens: count, + totalReasoningOutputTokens: count, + totalTokens: count, + estimatedCostUsd: count.nullable(), + locationBreakdown: z.array(location), + modelBreakdown: z.array(model), + locationModelBreakdown: z.array(locationModel) +}) +const daily = z.object({ + ...tokens, + day: z.string(), + model: nullableText, + projectKey: z.string(), + projectLabel: z.string(), + repoId: nullableText, + worktreeId: nullableText +}) +const sidecar = z.union([ + z.literal('none'), + z.literal('unknown'), + z.object({ path: z.string(), mtimeMs: z.number().finite(), sizeBytes: count }) +]) +const file = z.object({ + path: z.string(), + mtimeMs: z.number().finite(), + size: count, + sessionId: z.string(), + sessionsDb: sidecar.optional(), + sessions: z.array(session), + dailyAggregates: z.array(daily) +}) + +export const devinUsagePersistedStateSchema = z.object({ + schemaVersion: z.number().int(), + worktreeFingerprint: nullableText, + processedFiles: z.array(file), + sessions: z.array(session), + dailyAggregates: z.array(daily), + scanState: z.object({ + enabled: z.boolean(), + lastScanStartedAt: count.nullable(), + lastScanCompletedAt: count.nullable(), + lastScanError: nullableText + }) +}) diff --git a/src/main/devin-usage/scanner.test.ts b/src/main/devin-usage/scanner.test.ts new file mode 100644 index 00000000000..ab2cafa41c2 --- /dev/null +++ b/src/main/devin-usage/scanner.test.ts @@ -0,0 +1,337 @@ +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import SyncDatabase from '../sqlite/sync-database' +import * as transcriptFs from '../native-chat/wsl-transcript-fs-access' +import { resetDevinSessionsIndexCacheForTests } from '../devin/sessions-index' +import { scanDevinUsageFiles } from './scanner' + +let tempDirs: string[] = [] +const originalDevinHome = process.env.DEVIN_HOME + +afterEach(async () => { + process.env.DEVIN_HOME = originalDevinHome + resetDevinSessionsIndexCacheForTests() + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +async function makeTranscriptsDir(): Promise { + const home = await mkdtemp(join(tmpdir(), 'orca-devin-usage-')) + tempDirs.push(home) + process.env.DEVIN_HOME = home + const dir = join(home, 'cli', 'transcripts') + await mkdir(dir, { recursive: true }) + return dir +} + +type TranscriptOverrides = Record + +function transcript(overrides: TranscriptOverrides = {}): string { + return JSON.stringify({ + session_id: 's1', + working_directory: '/repo/main', + agent: { model_name: 'swe-2' }, + steps: [ + { + timestamp: '2026-09-01T10:00:00Z', + metrics: { prompt_tokens: 100, cached_tokens: 25, completion_tokens: 40 } + }, + { + timestamp: '2026-09-01T10:05:00Z', + metrics: { prompt_tokens: 50, completion_tokens: 10 } + } + ], + ...overrides + }) +} + +const WORKTREES = [{ repoId: 'r1', worktreeId: 'w1', path: '/repo/main', displayName: 'main' }] + +// The Devin CLI sessions.db schema, written out in full because the reader +// probes every column it names. +const DEVIN_SESSIONS_SCHEMA = ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + working_directory TEXT, + backend_type TEXT, + model TEXT, + agent_mode TEXT, + created_at INTEGER, + last_activity_at INTEGER, + title TEXT, + main_chain_id TEXT, + shell_last_seen_index INTEGER, + cogs_json TEXT, + workspace_dirs TEXT, + hidden INTEGER, + metadata TEXT + ); +` + +function writeDevinSessionsDb( + dbPath: string, + rows: readonly { id: string; working_directory?: string | null; hidden?: number | null }[] +): void { + const db = new SyncDatabase(dbPath) + try { + db.exec(DEVIN_SESSIONS_SCHEMA) + db.exec('DELETE FROM sessions') + const insert = db.prepare( + 'INSERT INTO sessions (id, working_directory, hidden) VALUES (?, ?, ?)' + ) + for (const row of rows) { + insert.run(row.id, row.working_directory ?? null, row.hidden ?? 0) + } + } finally { + db.close() + } +} + +describe('scanDevinUsageFiles', () => { + it('aggregates events into sessions and daily aggregates with worktree attribution', async () => { + const dir = await makeTranscriptsDir() + await writeFile(join(dir, 's1.json'), transcript()) + + const result = await scanDevinUsageFiles(WORKTREES) + + expect(result.processedFiles).toHaveLength(1) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + sessionId: 's1', + primaryWorktreeId: 'w1', + primaryModel: 'swe-2', + eventCount: 2, + totalInputTokens: 150, + totalCachedInputTokens: 25, + totalOutputTokens: 50, + totalTokens: 200 + }) + expect(result.dailyAggregates).toHaveLength(1) + expect(result.dailyAggregates[0]).toMatchObject({ + day: '2026-09-01', + worktreeId: 'w1', + eventCount: 2, + totalTokens: 200 + }) + }) + + it('reuses unchanged files on a rescan and dedupes copied transcripts', async () => { + const dir = await makeTranscriptsDir() + const file = join(dir, 's1.json') + await writeFile(file, transcript()) + + const first = await scanDevinUsageFiles(WORKTREES) + expect(first.sessions).toHaveLength(1) + + // A duplicate transcript carrying the same session_id must not double-count. + await writeFile( + join(dir, 's1-copy.json'), + transcript({ + steps: [ + { timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } } + ] + }) + ) + const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles) + + expect(second.sessions).toHaveLength(1) + expect(second.sessions[0].totalTokens).toBe(200) + expect(second.sessions[0].eventCount).toBe(2) + }) + + it('lets the canonical .json claim ahead of a lexicographically earlier copy', async () => { + const dir = await makeTranscriptsDir() + // 's1-copy.json' sorts before 's1.json' ('-' < '.'), so a naive + // sorted-order claim would hand the session to the stale copy forever. + await writeFile( + join(dir, 's1-copy.json'), + transcript({ + steps: [ + { timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } } + ] + }) + ) + await writeFile(join(dir, 's1.json'), transcript()) + + const result = await scanDevinUsageFiles(WORKTREES) + + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0].totalTokens).toBe(200) + }) + + it('hands the claim back to the canonical file after a copy owned it', async () => { + const dir = await makeTranscriptsDir() + const copy = join(dir, 's1-copy.json') + await writeFile(copy, transcript()) + + // Only the copy exists: it owns the session. + const first = await scanDevinUsageFiles(WORKTREES) + expect(first.sessions[0]?.totalTokens).toBe(200) + + // The canonical transcript appears; the copy must not freeze the session. + await writeFile(join(dir, 's1.json'), transcript()) + const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles) + + expect(second.sessions[0]?.totalTokens).toBe(200) + expect(second.sessions).toHaveLength(1) + }) + + it('lets a deferred copy reclaim its session once the owner disappears', async () => { + const dir = await makeTranscriptsDir() + const canonical = join(dir, 's1.json') + await writeFile(canonical, transcript()) + await writeFile( + join(dir, 's1-copy.json'), + transcript({ + steps: [ + { timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } } + ] + }) + ) + + const first = await scanDevinUsageFiles(WORKTREES) + expect(first.sessions[0]?.totalTokens).toBe(200) + + await rm(canonical) + const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles) + + expect(second.sessions).toHaveLength(1) + expect(second.sessions[0]?.totalTokens).toBe(10) + }) + + it('lets a deferred copy reclaim its session when the owner stops parsing', async () => { + const dir = await makeTranscriptsDir() + const canonical = join(dir, 's1.json') + await writeFile(canonical, transcript()) + await writeFile( + join(dir, 's1-copy.json'), + transcript({ + steps: [ + { timestamp: '2026-09-01T10:00:00Z', metrics: { prompt_tokens: 5, completion_tokens: 5 } } + ] + }) + ) + + const first = await scanDevinUsageFiles(WORKTREES) + expect(first.sessions[0]?.totalTokens).toBe(200) + + // The owner stays listed but turns unreadable — without a reclaim pass + // the copy would sit deferred with the session lost forever. + await writeFile(canonical, '{ torn write') + const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles) + + expect(second.sessions).toHaveLength(1) + expect(second.sessions[0]?.totalTokens).toBe(10) + }) + + it('skips a corrupt transcript without sinking the rest of the scan', async () => { + const dir = await makeTranscriptsDir() + await writeFile(join(dir, 'broken.json'), '{ not json') + await writeFile(join(dir, 's1.json'), transcript()) + + const result = await scanDevinUsageFiles(WORKTREES) + + expect(result.processedFiles).toHaveLength(1) + expect(result.sessions[0]?.sessionId).toBe('s1') + }) + + it('returns an empty projection when no transcripts exist', async () => { + await makeTranscriptsDir() + const result = await scanDevinUsageFiles(WORKTREES) + expect(result.processedFiles).toEqual([]) + expect(result.sessions).toEqual([]) + expect(result.dailyAggregates).toEqual([]) + }) + + it('re-attributes an unchanged transcript when only sessions.db moves', async () => { + const dir = await makeTranscriptsDir() + // No working_directory in the transcript (the Windows ATIF shape) — the db + // row is the only attribution source. + await writeFile(join(dir, 's1.json'), transcript({ working_directory: null })) + const dbPath = join(dir, '..', 'sessions.db') + writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/main' }]) + + const first = await scanDevinUsageFiles(WORKTREES) + expect(first.sessions[0]?.primaryWorktreeId).toBe('w1') + + writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/other' }]) + // Why: pin the mtime so the sidecar change is visible regardless of fs + // timestamp granularity — size alone stays identical across rewrites. + const later = new Date('2026-09-02T00:00:00Z') + await utimes(dbPath, later, later) + + const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles) + expect(second.sessions[0]?.primaryWorktreeId).toBeNull() + expect(second.sessions[0]?.primaryProjectLabel).toBe('repo/other') + }) + + it('drops a session from the projection once sessions.db marks it hidden', async () => { + const dir = await makeTranscriptsDir() + await writeFile(join(dir, 's1.json'), transcript({ working_directory: null })) + const dbPath = join(dir, '..', 'sessions.db') + writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/main', hidden: 0 }]) + + const first = await scanDevinUsageFiles(WORKTREES) + expect(first.sessions).toHaveLength(1) + + writeDevinSessionsDb(dbPath, [{ id: 's1', working_directory: '/repo/main', hidden: 1 }]) + const later = new Date('2026-09-02T00:00:00Z') + await utimes(dbPath, later, later) + + const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles) + expect(second.sessions).toEqual([]) + expect(second.dailyAggregates).toEqual([]) + }) + + it('observes sessions.db-wal while the db runs in WAL mode', async () => { + const dir = await makeTranscriptsDir() + await writeFile(join(dir, 's1.json'), transcript({ working_directory: null })) + const dbPath = join(dir, '..', 'sessions.db') + const db = new SyncDatabase(dbPath) + try { + db.exec(DEVIN_SESSIONS_SCHEMA) + db.pragma('journal_mode = WAL') + db.prepare('INSERT INTO sessions (id, working_directory) VALUES (?, ?)').run( + 's1', + '/repo/main' + ) + + // The open connection keeps sessions.db-wal on disk; the observation + // must ride the wal stat, not the untouched db file. + const result = await scanDevinUsageFiles(WORKTREES) + expect(result.sessions[0]?.primaryWorktreeId).toBe('w1') + expect(result.processedFiles[0]?.sessionsDb).toMatchObject({ + path: `${dbPath}-wal` + }) + } finally { + db.close() + } + }) + it('reads no unchanged transcripts and only one changed file across a large cached scan', async () => { + const dir = await makeTranscriptsDir() + await Promise.all( + Array.from({ length: 250 }, (_, i) => + writeFile(join(dir, `s${i}.json`), transcript({ session_id: `s${i}` })) + ) + ) + const read = vi.spyOn(transcriptFs, 'wslGatedReadFile') + try { + const first = await scanDevinUsageFiles(WORKTREES) + expect(read).toHaveBeenCalledTimes(250) + expect(first.sessions).toHaveLength(250) + read.mockClear() + const second = await scanDevinUsageFiles(WORKTREES, first.processedFiles) + expect(read).not.toHaveBeenCalled() + await writeFile( + join(dir, 's1.json'), + transcript({ session_id: 's1', working_directory: '/changed' }) + ) + await scanDevinUsageFiles(WORKTREES, second.processedFiles) + expect(read).toHaveBeenCalledTimes(1) + } finally { + read.mockRestore() + } + }) +}) diff --git a/src/main/devin-usage/scanner.ts b/src/main/devin-usage/scanner.ts new file mode 100644 index 00000000000..3c7d760a6d1 --- /dev/null +++ b/src/main/devin-usage/scanner.ts @@ -0,0 +1,108 @@ +import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { devinSessionsIndexForSidecar } from '../devin/sessions-index' +import { sessionIdFromFileName } from '../ai-vault/session-scanner-accumulator' +import { sidecarUnchanged } from '../ai-vault/session-sidecar-stat' +import { createUsageWorktreeResolver } from '../usage/usage-worktree-resolver' +import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract' +import { resolveDevinTranscriptsDir } from '../devin/devin-cli-data-dir' +import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access' +import { + getProcessedFileInfo, + listDevinTranscriptFiles, + observeDevinSessionsDb +} from './devin-transcript-discovery' +import { parseDevinTranscriptForUsage } from './devin-transcript-parse' +import { attributeDevinUsageEvent } from './devin-usage-event-attribution' +import { devinUsageAggregation } from './devin-usage-aggregation' +import type { DevinUsageDailyAggregate, DevinUsagePersistedFile, DevinUsageSession } from './types' + +export async function scanDevinUsageFiles( + worktrees: UsageScanWorktreeRef[], + previous: DevinUsagePersistedFile[] = [], + onFilesScanned?: (count: number) => void +): Promise<{ + processedFiles: DevinUsagePersistedFile[] + sessions: DevinUsageSession[] + dailyAggregates: DevinUsageDailyAggregate[] +}> { + const filePaths = await listDevinTranscriptFiles() + const sessionsDb = await observeDevinSessionsDb(resolveDevinTranscriptsDir()) + const { index, unreadable } = devinSessionsIndexForSidecar(sessionsDb) + if (unreadable) { + throw new Error('Unable to read Devin session metadata; retry the scan') + } + const previousByPath = new Map(previous.map((file) => [file.path, file])) + const resolveWorktree = await createUsageWorktreeResolver(worktrees) + const processedFiles: DevinUsagePersistedFile[] = [] + const ownerBySession = new Map() + + for (const [position, path] of filePaths.entries()) { + try { + const info = await getProcessedFileInfo(path) + const cached = previousByPath.get(path) + let file: DevinUsagePersistedFile + if ( + cached && + typeof cached.sessionId === 'string' && + cached.mtimeMs === info.mtimeMs && + cached.size === info.size && + sidecarUnchanged(cached.sessionsDb, sessionsDb) + ) { + file = cached + } else { + const parsed = parseDevinTranscriptForUsage( + path, + await wslGatedReadFile(path, 'utf-8', 'scan'), + index + ) + if (!parsed) { + continue + } + const events = parsed.hidden + ? [] + : parsed.events + .map((event) => attributeDevinUsageEvent(event, resolveWorktree)) + .filter((event) => event !== null) + file = { + ...info, + sessionId: parsed.sessionId, + sessionsDb, + ...devinUsageAggregation.aggregate(events) + } + } + processedFiles.push(file) + // Cache every file's aggregates; choose one copy per session only when projecting. + if (!ownerBySession.has(file.sessionId) || sessionIdFromFileName(path) === file.sessionId) { + ownerBySession.set(file.sessionId, file) + } + } catch (error) { + if ( + !( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ) + ) { + throw error + } + } finally { + onFilesScanned?.(1) + if ((position + 1) % 10 === 0) { + await yieldToEventLoop() + } + } + } + + const sessions = new Map() + const daily = new Map() + for (const file of ownerBySession.values()) { + devinUsageAggregation.mergeSessions(sessions, file.sessions) + devinUsageAggregation.mergeDailyAggregates(daily, file.dailyAggregates) + } + return { + processedFiles, + sessions: devinUsageAggregation.finalizeSessions(sessions), + dailyAggregates: devinUsageAggregation.sortDailyAggregates(daily) + } +} diff --git a/src/main/devin-usage/store.test.ts b/src/main/devin-usage/store.test.ts new file mode 100644 index 00000000000..fa12fbf9fa8 --- /dev/null +++ b/src/main/devin-usage/store.test.ts @@ -0,0 +1,50 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getPathMock } = vi.hoisted(() => ({ getPathMock: vi.fn() })) +vi.mock('electron', () => ({ app: { getPath: getPathMock } })) +vi.mock('../usage/usage-scan-worker-spawn', () => ({ scanDevinUsageFilesViaWorker: vi.fn() })) + +import { DEVIN_USAGE_SCHEMA_VERSION } from './devin-usage-provider' +import { DevinUsageStore, initDevinUsagePath, normalizeDevinUsageState } from './store' + +describe('DevinUsageStore persisted state', () => { + let userData: string + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-devin-store-')) + getPathMock.mockReturnValue(userData) + initDevinUsagePath() + }) + + afterEach(() => rmSync(userData, { recursive: true, force: true })) + + it('drops malformed projections without losing the enabled preference', () => { + const state = normalizeDevinUsageState({ + schemaVersion: DEVIN_USAGE_SCHEMA_VERSION, + scanState: { enabled: true }, + sessions: [{ locationBreakdown: null, modelBreakdown: [], locationModelBreakdown: [] }], + dailyAggregates: [{ day: 'bad' }], + processedFiles: [] + }) + expect(state.scanState.enabled).toBe(true) + expect(state.sessions).toEqual([]) + expect(state.dailyAggregates).toEqual([]) + }) + + it('migrates an old schema by clearing aggregates while preserving opt-in state', () => { + writeFileSync( + join(userData, 'orca-devin-usage.json'), + JSON.stringify({ + schemaVersion: 1, + scanState: { enabled: true }, + sessions: [{ locationBreakdown: [], modelBreakdown: [], locationModelBreakdown: [] }] + }) + ) + const store = new DevinUsageStore({ getRepos: () => [], getAllWorktreeMeta: () => ({}) }) + expect(store.getScanState().enabled).toBe(true) + expect(store.getScanState().hasAnyDevinData).toBe(false) + }) +}) diff --git a/src/main/devin-usage/store.ts b/src/main/devin-usage/store.ts new file mode 100644 index 00000000000..48b41d2c619 --- /dev/null +++ b/src/main/devin-usage/store.ts @@ -0,0 +1,113 @@ +import { app } from 'electron' +import { asRecord } from '../ai-vault/session-scanner-record-value' +import { devinUsagePersistedStateSchema } from './persisted-state-schema' +import { join } from 'node:path' +import type { + DevinUsageBreakdownKind, + DevinUsageBreakdownRow, + DevinUsageDailyPoint, + DevinUsageRange, + DevinUsageScope, + DevinUsageSessionRow, + DevinUsageSnapshot, + DevinUsageSummary +} from '../../shared/devin-usage-types' +import type { Store } from '../persistence' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' +import { DEVIN_USAGE_SCHEMA_VERSION, devinUsageProvider } from './devin-usage-provider' +import { + buildDevinBreakdown, + buildDevinDaily, + buildDevinRecentSessions, + buildDevinSummary +} from './devin-usage-projections' +import type { DevinUsagePersistedState } from './types' + +let usageFile: string | null = null + +function defaultState(): DevinUsagePersistedState { + return { + schemaVersion: DEVIN_USAGE_SCHEMA_VERSION, + worktreeFingerprint: null, + processedFiles: [], + sessions: [], + dailyAggregates: [], + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null + } + } +} + +export function normalizeDevinUsageState(value: unknown): DevinUsagePersistedState { + const parsed = devinUsagePersistedStateSchema.safeParse(value) + if (parsed.success && parsed.data.schemaVersion === DEVIN_USAGE_SCHEMA_VERSION) { + return parsed.data + } + const defaults = defaultState() + defaults.scanState.enabled = asRecord(asRecord(value)?.scanState)?.enabled === true + return defaults +} + +export function initDevinUsagePath(): void { + usageFile = join(app.getPath('userData'), 'orca-devin-usage.json') +} + +export class DevinUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + DevinUsagePersistedState, + 'hasAnyDevinData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[devin-usage]', + resolveCacheFile: () => usageFile ?? join(app.getPath('userData'), 'orca-devin-usage.json'), + createDefaultState: defaultState, + normalizeState: (state) => normalizeDevinUsageState(state), + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyDevinData', + scan: devinUsageProvider.scan + }) + } + + getSnapshot(scope: DevinUsageScope, range: DevinUsageRange, limit = 10): DevinUsageSnapshot { + return { + scanState: this.getScanState(), + summary: buildDevinSummary(this.state, scope, range), + daily: buildDevinDaily(this.state, scope, range), + modelBreakdown: buildDevinBreakdown(this.state, scope, range, 'model'), + projectBreakdown: buildDevinBreakdown(this.state, scope, range, 'project'), + recentSessions: buildDevinRecentSessions(this.state, scope, range, limit) + } + } + + async getSummary(scope: DevinUsageScope, range: DevinUsageRange): Promise { + await this.refresh(false) + return buildDevinSummary(this.state, scope, range) + } + + async getDaily(scope: DevinUsageScope, range: DevinUsageRange): Promise { + await this.refresh(false) + return buildDevinDaily(this.state, scope, range) + } + + async getBreakdown( + scope: DevinUsageScope, + range: DevinUsageRange, + kind: DevinUsageBreakdownKind + ): Promise { + await this.refresh(false) + return buildDevinBreakdown(this.state, scope, range, kind) + } + + async getRecentSessions( + scope: DevinUsageScope, + range: DevinUsageRange, + limit = 12 + ): Promise { + await this.refresh(false) + return buildDevinRecentSessions(this.state, scope, range, limit) + } +} diff --git a/src/main/devin-usage/types.ts b/src/main/devin-usage/types.ts new file mode 100644 index 00000000000..9526f366253 --- /dev/null +++ b/src/main/devin-usage/types.ts @@ -0,0 +1,64 @@ +import type { + UsageDailyAggregate, + UsageLocationBreakdown, + UsageLocationModelBreakdown, + UsageModelBreakdown, + UsageSession +} from '../usage/usage-rollup-records' +import type { SessionSidecarObservation } from '../ai-vault/session-sidecar-stat' + +export type DevinUsageMetric = { estimatedCostUsd: number | null } + +export type DevinUsageProcessedFile = { + path: string + mtimeMs: number + size: number +} + +export type DevinUsageSession = UsageSession +export type DevinUsageDailyAggregate = UsageDailyAggregate +export type DevinUsageLocationBreakdown = UsageLocationBreakdown +export type DevinUsageModelBreakdown = UsageModelBreakdown +export type DevinUsageLocationModelBreakdown = UsageLocationModelBreakdown + +export type DevinUsagePersistedFile = DevinUsageProcessedFile & { + sessionsDb?: SessionSidecarObservation + sessionId: string + sessions: DevinUsageSession[] + dailyAggregates: DevinUsageDailyAggregate[] +} + +export type DevinUsagePersistedState = { + schemaVersion: number + worktreeFingerprint: string | null + processedFiles: DevinUsagePersistedFile[] + sessions: DevinUsageSession[] + dailyAggregates: DevinUsageDailyAggregate[] + scanState: { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + } +} + +export type DevinUsageParsedEvent = { + sessionId: string + timestamp: string + model: string | null + cwd: string | null + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: number | null +} + +export type DevinUsageAttributedEvent = DevinUsageParsedEvent & { + day: string + projectKey: string + projectLabel: string + repoId: string | null + worktreeId: string | null +} diff --git a/src/main/devin/devin-cli-data-dir.test.ts b/src/main/devin/devin-cli-data-dir.test.ts new file mode 100644 index 00000000000..a895ba14e00 --- /dev/null +++ b/src/main/devin/devin-cli-data-dir.test.ts @@ -0,0 +1,43 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveDevinCliDataDir } from './devin-cli-data-dir' + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('Devin CLI data root', () => { + it.each(['win32', 'linux', 'darwin'])('honors the root override on %s', (platform) => { + vi.stubGlobal('process', { ...process, platform }) + vi.stubEnv('DEVIN_HOME', join(homedir(), 'custom-devin')) + expect(resolveDevinCliDataDir()).toBe(join(homedir(), 'custom-devin', 'cli')) + }) + + it.each(['win32', 'linux', 'darwin'])('uses the platform data directory on %s', (platform) => { + vi.stubGlobal('process', { ...process, platform }) + vi.stubEnv('DEVIN_HOME', '') + vi.stubEnv('APPDATA', join(homedir(), 'appdata')) + vi.stubEnv('XDG_DATA_HOME', join(homedir(), 'xdg')) + expect(resolveDevinCliDataDir()).toBe( + join( + homedir(), + platform === 'win32' + ? 'appdata' + : platform === 'darwin' + ? 'Library/Application Support' + : 'xdg', + 'devin', + 'cli' + ) + ) + }) + + it('ignores relative environment paths', () => { + vi.stubGlobal('process', { ...process, platform: 'linux' }) + vi.stubEnv('DEVIN_HOME', 'relative') + vi.stubEnv('XDG_DATA_HOME', 'relative') + expect(resolveDevinCliDataDir()).toBe(join(homedir(), '.local', 'share', 'devin', 'cli')) + }) +}) diff --git a/src/main/devin/devin-cli-data-dir.ts b/src/main/devin/devin-cli-data-dir.ts new file mode 100644 index 00000000000..a58aee42fb7 --- /dev/null +++ b/src/main/devin/devin-cli-data-dir.ts @@ -0,0 +1,29 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { resolveAbsoluteDirOverride } from '../../shared/absolute-dir-override' + +// DEVIN_HOME overrides the root containing credentials.toml and the cli directory. +export function resolveDevinCliDataDir(): string { + const platformDataDir = + process.platform === 'win32' + ? process.env.APPDATA + : process.platform === 'darwin' + ? join(homedir(), 'Library', 'Application Support') + : process.env.XDG_DATA_HOME + const resolvedPlatformDataDir = resolveAbsoluteDirOverride( + platformDataDir, + process.platform === 'win32' + ? join(homedir(), 'AppData', 'Roaming') + : process.platform === 'darwin' + ? join(homedir(), 'Library', 'Application Support') + : join(homedir(), '.local', 'share') + ) + return join( + resolveAbsoluteDirOverride(process.env.DEVIN_HOME, join(resolvedPlatformDataDir, 'devin')), + 'cli' + ) +} + +export function resolveDevinTranscriptsDir(): string { + return join(resolveDevinCliDataDir(), 'transcripts') +} diff --git a/src/main/devin/sessions-index.ts b/src/main/devin/sessions-index.ts new file mode 100644 index 00000000000..01f17ef86d4 --- /dev/null +++ b/src/main/devin/sessions-index.ts @@ -0,0 +1,140 @@ +import { columnExists, tableExists } from '../opencode-usage/schema-helpers' +import { readOpenCodeDatabase } from '../ai-vault/session-scanner-opencode-sqlite-open' +import type { SessionSidecarObservation } from '../ai-vault/session-sidecar-stat' +import { asRecord } from '../ai-vault/session-scanner-record-value' +import { numberValue } from '../ai-vault/session-scanner-token-values' +import { extractString } from '../ai-vault/session-scanner-values' + +// Why: Devin CLI keeps a tiny `sessions` table in sessions.db beside the +// transcripts dir — the transcript holds no cwd on Windows installs, so the db +// is what lets a Devin session group under a workspace. One row per session_id +// (the transcript filename), timestamps in unix SECONDS. + +export type DevinSessionIndexRow = { + workingDirectory: string | null + title: string | null + model: string | null + createdAt: string | null + lastActivityAt: string | null + // The user hid the session in Devin's own UI; the listing honors that. + hidden: boolean +} + +export type DevinSessionsIndex = Map + +// Optional in older schemas; `id` is the only required column. +const DEVIN_SESSION_TABLE = 'sessions' +const DEVIN_SESSION_OPTIONAL_COLUMNS = [ + 'working_directory', + 'title', + 'model', + 'created_at', + 'last_activity_at', + 'hidden' +] as const + +// One index per observed db stat, so all transcripts under a root share a +// single open per scan and a db the transcript mtimes cannot see still +// re-merges when its own stat moves. +const INDEX_CACHE_LIMIT = 8 +const indexCache = new Map< + string, + { observationPath: string; mtimeMs: number; sizeBytes: number; index: DevinSessionsIndex } +>() + +function devinSessionsDbPathForSidecarPath(sidecarPath: string): string { + return sidecarPath.endsWith('-wal') ? sidecarPath.slice(0, -'-wal'.length) : sidecarPath +} + +export function devinSessionsIndexForSidecar(sidecar: SessionSidecarObservation | undefined): { + index: DevinSessionsIndex | null + unreadable: boolean +} { + if (sidecar === undefined || sidecar === 'none') { + return { index: null, unreadable: false } + } + if (sidecar === 'unknown') { + // The stat already failed this scan; an open would ride the same stalled + // share. Retry next scan instead of paying it per transcript. + return { index: null, unreadable: true } + } + const dbPath = devinSessionsDbPathForSidecarPath(sidecar.path) + const cached = indexCache.get(dbPath) + if ( + cached && + cached.observationPath === sidecar.path && + cached.mtimeMs === sidecar.mtimeMs && + cached.sizeBytes === sidecar.sizeBytes + ) { + indexCache.delete(dbPath) + indexCache.set(dbPath, cached) + return { index: cached.index, unreadable: false } + } + try { + const index = readDevinSessionsIndex(dbPath) + if (indexCache.size >= INDEX_CACHE_LIMIT) { + const oldest = indexCache.keys().next().value + if (oldest !== undefined) { + indexCache.delete(oldest) + } + } + indexCache.set(dbPath, { + observationPath: sidecar.path, + mtimeMs: sidecar.mtimeMs, + sizeBytes: sidecar.sizeBytes, + index + }) + return { index, unreadable: false } + } catch { + // Deliberately uncached: contention is transient, and caching a failure + // under an unchanged stat would refuse enrichment until the db moved. + return { index: null, unreadable: true } + } +} + +export function resetDevinSessionsIndexCacheForTests(): void { + indexCache.clear() +} + +function readDevinSessionsIndex(dbPath: string): DevinSessionsIndex { + return readOpenCodeDatabase({ + dbPath, + read: (db) => { + const index: DevinSessionsIndex = new Map() + if (!tableExists(db, DEVIN_SESSION_TABLE) || !columnExists(db, DEVIN_SESSION_TABLE, 'id')) { + return index + } + const columns = DEVIN_SESSION_OPTIONAL_COLUMNS.filter((column) => + columnExists(db, DEVIN_SESSION_TABLE, column) + ) + const statement = db.prepare( + `SELECT id${columns.map((column) => `, ${column}`).join('')} FROM ${DEVIN_SESSION_TABLE}` + ) + for (const row of statement.all()) { + const record = asRecord(row) + const id = record ? extractString(record.id) : null + if (!record || !id) { + continue + } + index.set(id, { + workingDirectory: extractString(record.working_directory), + title: extractString(record.title), + model: extractString(record.model), + createdAt: unixSecondsToIso(record.created_at), + lastActivityAt: unixSecondsToIso(record.last_activity_at), + hidden: numberValue(record.hidden) !== 0 + }) + } + return index + } + }) +} + +function unixSecondsToIso(value: unknown): string | null { + const seconds = numberValue(value) + if (seconds <= 0) { + return null + } + const date = new Date(seconds * 1000) + return Number.isFinite(date.getTime()) ? date.toISOString() : null +} diff --git a/src/main/ipc/devin-accounts.ts b/src/main/ipc/devin-accounts.ts new file mode 100644 index 00000000000..9f1e10d92b2 --- /dev/null +++ b/src/main/ipc/devin-accounts.ts @@ -0,0 +1,9 @@ +import { ipcMain } from 'electron' +import type { RateLimitService } from '../rate-limits/service' +import { getDevinAccountStatus } from '../devin-accounts/status' + +export function registerDevinAccountHandlers(rateLimits: RateLimitService): void { + ipcMain.handle('devinAccounts:getStatus', () => + getDevinAccountStatus(rateLimits.getState().devin ?? null) + ) +} diff --git a/src/main/ipc/rate-limits.test.ts b/src/main/ipc/rate-limits.test.ts index 1224d4e8275..644ba5cf8e0 100644 --- a/src/main/ipc/rate-limits.test.ts +++ b/src/main/ipc/rate-limits.test.ts @@ -14,6 +14,7 @@ vi.mock('electron', () => ({ import { registerRateLimitHandlers } from './rate-limits' import type { RateLimitService } from '../rate-limits/service' +import { createEmptyRateLimitState } from '../../shared/rate-limit-state-factory' import type { RateLimitState } from '../../shared/rate-limit-types' import type { CodexAccountService } from '../codex-accounts/service' @@ -27,14 +28,10 @@ function makeCodexAccounts() { } } -function makeService(): { - service: RateLimitService - refresh: ReturnType - refreshGrok: ReturnType - consumeCodexRateLimitResetCredit: ReturnType -} { +function makeService() { const refresh = vi.fn(() => Promise.resolve({} as RateLimitState)) const refreshGrok = vi.fn(() => Promise.resolve({} as RateLimitState)) + const refreshDevin = vi.fn(() => Promise.resolve(createEmptyRateLimitState())) const consumeCodexRateLimitResetCredit = vi.fn(() => Promise.resolve({ outcome: 'noCredit', state: {} as RateLimitState }) ) @@ -42,6 +39,7 @@ function makeService(): { getState: vi.fn(() => ({}) as RateLimitState), refresh, refreshGrok, + refreshDevin, refreshCodexForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), refreshClaudeForTarget: vi.fn(() => Promise.resolve({} as RateLimitState)), consumeCodexRateLimitResetCredit, @@ -53,6 +51,7 @@ function makeService(): { service: service as unknown as RateLimitService, refresh, refreshGrok, + refreshDevin, consumeCodexRateLimitResetCredit } } @@ -78,6 +77,7 @@ describe('registerRateLimitHandlers', () => { expect(ipcState.handleHandlers.has('rateLimits:refresh')).toBe(true) expect(ipcState.handleHandlers.has('rateLimits:refreshMiniMax')).toBe(true) expect(ipcState.handleHandlers.has('rateLimits:refreshGrok')).toBe(true) + expect(ipcState.handleHandlers.has('rateLimits:refreshDevin')).toBe(true) }) it('registers a refreshGrok channel that delegates to refreshGrok()', async () => { @@ -88,6 +88,14 @@ describe('registerRateLimitHandlers', () => { await handler!({}) expect(refreshGrok).toHaveBeenCalledTimes(1) }) + it('registers a refreshDevin channel that delegates to refreshDevin()', async () => { + const { service, refreshDevin } = makeService() + registerRateLimitHandlers(service, makeCodexAccounts().service) + const handler = ipcState.handleHandlers.get('rateLimits:refreshDevin') + expect(handler).toBeDefined() + await handler!({}) + expect(refreshDevin).toHaveBeenCalledTimes(1) + }) it('serializes desktop reset consumption through CodexAccountService', async () => { const { service, consumeCodexRateLimitResetCredit } = makeService() diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index 7882c398a0e..fdf256a3dc5 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -30,4 +30,5 @@ export function registerRateLimitHandlers( ) ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok()) + ipcMain.handle('rateLimits:refreshDevin', () => rateLimits.refreshDevin()) } diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 870eac98d46..cd20b31478d 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -38,6 +38,7 @@ const { registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, + registerDevinAccountHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -105,6 +106,7 @@ const { registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), + registerDevinAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -343,6 +345,9 @@ vi.mock('../minimax-credentials', () => ({ vi.mock('../grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) +vi.mock('../devin-accounts', () => ({ + registerDevinAccountHandlers: registerDevinAccountHandlersMock +})) vi.mock('../../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock @@ -469,6 +474,7 @@ describe('registerCoreHandlers', () => { const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } + const devinUsage = { marker: 'devinUsage' } const openCodeUsage = { marker: 'openCodeUsage' } const codexAccounts = { marker: 'codexAccounts', runtimeHomeService: { marker: 'runtimeHome' } } const claudeAccounts = { marker: 'claudeAccounts' } @@ -483,6 +489,8 @@ describe('registerCoreHandlers', () => { stats as never, claudeUsage as never, codexUsage as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Registration only forwards this mock; no concrete store methods execute in this test. + devinUsage as never, openCodeUsage as never, codexAccounts as never, claudeAccounts as never, @@ -507,6 +515,7 @@ describe('registerCoreHandlers', () => { expect(registerUsageProviderHandlersMock).toHaveBeenCalledWith({ claudeUsage, codexUsage, + devinUsage, openCodeUsage }) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) @@ -524,6 +533,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() + expect(registerDevinAccountHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() @@ -643,6 +653,7 @@ describe('registerCoreHandlers', () => { const stats2 = { marker: 'stats2' } const claudeUsage2 = { marker: 'claudeUsage2' } const codexUsage2 = { marker: 'codexUsage2' } + const devinUsage2 = { marker: 'devinUsage2' } const openCodeUsage2 = { marker: 'openCodeUsage2' } const codexAccounts2 = { marker: 'codexAccounts2' } const claudeAccounts2 = { marker: 'claudeAccounts2' } @@ -654,6 +665,8 @@ describe('registerCoreHandlers', () => { stats2 as never, claudeUsage2 as never, codexUsage2 as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Registration only forwards this mock; no concrete store methods execute in this test. + devinUsage2 as never, openCodeUsage2 as never, codexAccounts2 as never, claudeAccounts2 as never, 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 cb08d7a511f..34e2b41b851 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -64,6 +64,7 @@ import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' +import { registerDevinAccountHandlers } from '../devin-accounts' import { registerUpdaterHandlers } from '../../window/attach-main-window-services' import { registerClipboardHandlers, @@ -72,6 +73,7 @@ import { import { isDashboardPopoutRenderer } from '../../window/dashboard-popout-window' import type { ClaudeUsageStore } from '../../claude-usage/store' import type { CodexUsageStore } from '../../codex-usage/store' +import type { DevinUsageStore } from '../../devin-usage/store' import type { OpenCodeUsageStore } from '../../opencode-usage/store' import type { RateLimitService } from '../../rate-limits/service' import type { CodexAccountService } from '../../codex-accounts/service' @@ -112,6 +114,7 @@ export function registerCoreHandlers( stats: StatsCollector, claudeUsage: ClaudeUsageStore, codexUsage: CodexUsageStore, + devinUsage: DevinUsageStore, openCodeUsage: OpenCodeUsageStore, codexAccounts: CodexAccountService, claudeAccounts: ClaudeAccountService, @@ -142,7 +145,7 @@ export function registerCoreHandlers( registerAppHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch }) registerCliHandlers() registerPreflightHandlers() - registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage }) + registerUsageProviderHandlers({ claudeUsage, codexUsage, devinUsage, openCodeUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) @@ -150,6 +153,7 @@ export function registerCoreHandlers( registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() + registerDevinAccountHandlers(rateLimits) registerRateLimitHandlers(rateLimits, codexAccounts) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/ipc/usage-provider-handlers.test.ts b/src/main/ipc/usage-provider-handlers.test.ts index 54733b87c26..e954375a7de 100644 --- a/src/main/ipc/usage-provider-handlers.test.ts +++ b/src/main/ipc/usage-provider-handlers.test.ts @@ -21,13 +21,16 @@ describe('usage provider IPC handlers', () => { const claudeUsage = createUsage() const codexUsage = createUsage() const openCodeUsage = createUsage() + const devinUsage = createUsage() registerUsageProviderHandlers({ claudeUsage: claudeUsage as never, codexUsage: codexUsage as never, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Registration only forwards this mock; no concrete store methods execute in this test. + devinUsage: devinUsage as never, openCodeUsage: openCodeUsage as never }) - const prefixes = ['claudeUsage', 'codexUsage', 'openCodeUsage'] + const prefixes = ['claudeUsage', 'codexUsage', 'devinUsage', 'openCodeUsage'] const suffixes = Object.keys(claudeUsage) expect(handle.mock.calls.map(([channel]) => channel)).toEqual( prefixes.flatMap((prefix) => suffixes.map((suffix) => `${prefix}:${suffix}`)) @@ -41,6 +44,7 @@ describe('usage provider IPC handlers', () => { } call('claudeUsage', 'getScanState') call('codexUsage', 'getScanState') + call('devinUsage', 'getScanState') call('openCodeUsage', 'getScanState') call('claudeUsage', 'setEnabled', { enabled: true }) call('claudeUsage', 'refresh') diff --git a/src/main/ipc/usage-provider-handlers.ts b/src/main/ipc/usage-provider-handlers.ts index 58496f1c8ad..94703835886 100644 --- a/src/main/ipc/usage-provider-handlers.ts +++ b/src/main/ipc/usage-provider-handlers.ts @@ -1,11 +1,13 @@ import { ipcMain } from 'electron' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import type { DevinUsageStore } from '../devin-usage/store' import type { OpenCodeUsageStore } from '../opencode-usage/store' type UsageProviderStores = { claudeUsage: ClaudeUsageStore codexUsage: CodexUsageStore + devinUsage: DevinUsageStore openCodeUsage: OpenCodeUsageStore } @@ -64,5 +66,6 @@ function registerProviderHandlers( export function registerUsageProviderHandlers(stores: UsageProviderStores): void { registerProviderHandlers('claudeUsage', stores.claudeUsage) registerProviderHandlers('codexUsage', stores.codexUsage) + registerProviderHandlers('devinUsage', stores.devinUsage) registerProviderHandlers('openCodeUsage', stores.openCodeUsage) } diff --git a/src/main/rate-limits/devin-credentials.test.ts b/src/main/rate-limits/devin-credentials.test.ts new file mode 100644 index 00000000000..1e7020ebabf --- /dev/null +++ b/src/main/rate-limits/devin-credentials.test.ts @@ -0,0 +1,95 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { getDevinCredentialsPath, readDevinCredentials } from './devin-credentials' + +const originalDevinHome = process.env.DEVIN_HOME +let tempDirs: string[] = [] + +afterEach(async () => { + process.env.DEVIN_HOME = originalDevinHome + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +async function makeDevinRoot(toml: string | null): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-devin-creds-')) + tempDirs.push(root) + process.env.DEVIN_HOME = root + if (toml !== null) { + await writeFile(join(root, 'credentials.toml'), toml) + } + return root +} + +describe('readDevinCredentials', () => { + it('resolves credentials.toml at the devin root', async () => { + const root = await makeDevinRoot(null) + expect(getDevinCredentialsPath()).toBe(join(root, 'credentials.toml')) + }) + + it('reports missing when no credentials file exists', async () => { + await makeDevinRoot(null) + expect(readDevinCredentials()).toEqual({ status: 'missing' }) + }) + + it('reads credentials from the legacy CLI-directory override layout', async () => { + const root = await makeDevinRoot(null) + await mkdir(join(root, 'cli'), { recursive: true }) + await writeFile(join(root, 'cli', 'credentials.toml'), 'windsurf_api_key = "legacy-token"\n') + expect(readDevinCredentials()).toEqual({ + status: 'ok', + credentials: { + sessionToken: 'devin-session-token$legacy-token', + apiServerUrl: 'https://server.codeium.com' + } + }) + }) + + it('reports missing for a token-less file — signed out, not corrupt', async () => { + await makeDevinRoot('other_key = "value"\n') + expect(readDevinCredentials()).toEqual({ status: 'missing' }) + }) + + it('normalizes the session token prefix and defaults the API server', async () => { + await makeDevinRoot('windsurf_api_key = "raw-token"\n') + expect(readDevinCredentials()).toEqual({ + status: 'ok', + credentials: { + sessionToken: 'devin-session-token$raw-token', + apiServerUrl: 'https://server.codeium.com' + } + }) + }) + + it('honours a custom https api_server_url', async () => { + await makeDevinRoot( + 'windsurf_api_key = "devin-session-token$tok"\napi_server_url = "https://staging.example.com"\n' + ) + expect(readDevinCredentials()).toEqual({ + status: 'ok', + credentials: { + sessionToken: 'devin-session-token$tok', + apiServerUrl: 'https://staging.example.com' + } + }) + }) + + it('rejects an http api_server_url — the token must never travel cleartext', async () => { + await makeDevinRoot('windsurf_api_key = "tok"\napi_server_url = "http://server.codeium.com"\n') + const result = readDevinCredentials() + expect(result.status).toBe('error') + expect(result).not.toEqual({ status: 'ok' }) + }) + + it('rejects a non-URL api_server_url', async () => { + await makeDevinRoot('windsurf_api_key = "tok"\napi_server_url = "not a url"\n') + expect(readDevinCredentials().status).toBe('error') + }) + + it('reports a malformed credentials line as an error, not as signed out', async () => { + await makeDevinRoot('windsurf_api_key = unquoted\n') + expect(readDevinCredentials().status).toBe('error') + }) +}) diff --git a/src/main/rate-limits/devin-credentials.ts b/src/main/rate-limits/devin-credentials.ts new file mode 100644 index 00000000000..c6c8da595a2 --- /dev/null +++ b/src/main/rate-limits/devin-credentials.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' +import { resolveDevinCliDataDir } from '../devin/devin-cli-data-dir' + +// Why: the Devin CLI stores credentials.toml next to its cli data dir +// (%APPDATA%\devin\credentials.toml vs %APPDATA%\devin\cli on Windows; +// $XDG_DATA_HOME/devin/credentials.toml on posix). +export function getDevinCredentialsPath(): string { + return getDevinCredentialsPaths()[0] +} + +/** Return the current location first, followed by the pre-CLI override layout. */ +export function getDevinCredentialsPaths(): string[] { + const cliDir = resolveDevinCliDataDir() + const paths = [join(dirname(cliDir), 'credentials.toml'), join(cliDir, 'credentials.toml')] + if (process.platform === 'darwin' && !process.env.DEVIN_HOME && !process.env.XDG_DATA_HOME) { + paths.push(join(homedir(), '.local', 'share', 'devin', 'credentials.toml')) + } + return paths +} + +export type DevinCredentials = { + sessionToken: string + /** Connect-RPC host the CLI calls (api_server_url); defaults to server.codeium.com. */ + apiServerUrl: string +} + +export type DevinCredentialsReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; credentials: DevinCredentials } + +const DEFAULT_DEVIN_API_SERVER = 'https://server.codeium.com' +const DEVIN_SESSION_TOKEN_PREFIX = 'devin-session-token$' + +function getDevinCredentialsReadError(err: unknown): string { + if (err instanceof SyntaxError) { + return 'Devin credentials file is invalid' + } + // Why: filesystem errors include the full path; renderer/mobile surfaces + // should not expose local usernames or a custom DEVIN_HOME. + return 'Unable to read Devin credentials file' +} + +// credentials.toml is a flat `key = "value"` file written by the Devin CLI; a +// line parser avoids pulling a TOML dependency for two fields. +function parseCredentialsToml(raw: string): DevinCredentials | null { + let sessionToken: string | null = null + let apiServerUrl: string | null = null + for (const line of raw.split(/\r?\n/)) { + const match = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*"(.*)"\s*$/) + if (!match) { + // Why: a credential key that does not parse distinguishes a corrupt file + // (report 'error') from a signed-out one (report 'missing'). + if (/^\s*(windsurf_api_key|api_server_url)\b/.test(line)) { + throw new SyntaxError('malformed credentials line') + } + continue + } + if (match[1] === 'windsurf_api_key' && match[2].length > 0) { + sessionToken = match[2] + } else if (match[1] === 'api_server_url' && match[2].length > 0) { + apiServerUrl = match[2] + } + } + if (!sessionToken) { + return null + } + let normalizedApiServerUrl: URL + try { + normalizedApiServerUrl = new URL(apiServerUrl ?? DEFAULT_DEVIN_API_SERVER) + } catch { + throw new SyntaxError('malformed api_server_url') + } + // Why: the session token rides in the request body — an http:// override + // would send it cleartext to whatever host the file names. + if ( + normalizedApiServerUrl.protocol !== 'https:' || + normalizedApiServerUrl.username || + normalizedApiServerUrl.password || + normalizedApiServerUrl.search || + normalizedApiServerUrl.hash + ) { + throw new SyntaxError('Devin API server must use HTTPS') + } + return { + sessionToken: sessionToken.startsWith(DEVIN_SESSION_TOKEN_PREFIX) + ? sessionToken + : `${DEVIN_SESSION_TOKEN_PREFIX}${sessionToken}`, + apiServerUrl: normalizedApiServerUrl.href.replace(/\/+$/, '') + } +} + +export function readDevinCredentials(): DevinCredentialsReadResult { + const path = getDevinCredentialsPaths().find((candidate) => existsSync(candidate)) + if (!path) { + return { status: 'missing' } + } + try { + const credentials = parseCredentialsToml(readFileSync(path, 'utf-8')) + // Why: a token-less file means signed out, not a failure — 'error' would + // keep a status-bar alert visible for a user who simply logged out. + return credentials ? { status: 'ok', credentials } : { status: 'missing' } + } catch (err) { + return { status: 'error', error: getDevinCredentialsReadError(err) } + } +} diff --git a/src/main/rate-limits/devin-fetcher.test.ts b/src/main/rate-limits/devin-fetcher.test.ts new file mode 100644 index 00000000000..57af272a7da --- /dev/null +++ b/src/main/rate-limits/devin-fetcher.test.ts @@ -0,0 +1,310 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) +const files = vi.hoisted<{ + credentials: string | null + readError: Error | null +}>(() => ({ credentials: null, readError: null })) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock } +})) + +vi.mock('node:fs', () => ({ + existsSync: (path: string) => path.endsWith('credentials.toml') && files.credentials !== null, + readFileSync: (path: string) => { + if (files.readError) { + throw files.readError + } + if (path.endsWith('credentials.toml')) { + if (files.credentials === null) { + throw new Error('ENOENT') + } + return files.credentials + } + throw new Error('ENOENT') + } +})) + +vi.mock('node:os', () => ({ homedir: () => '/home/test' })) + +import { fetchDevinRateLimits } from './devin-fetcher' + +function protoResponse(body: Uint8Array, status = 200): Response { + return new Response(Buffer.from(body), { status }) +} + +function encVarint(value: number): Uint8Array { + const bytes: number[] = [] + let v = value >>> 0 + while (v > 0x7f) { + bytes.push((v & 0x7f) | 0x80) + v >>>= 7 + } + bytes.push(v) + return Uint8Array.from(bytes) +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +function varField(num: number, value: number): Uint8Array { + return concatBytes([encVarint(num << 3), encVarint(value)]) +} + +function strField(num: number, value: string): Uint8Array { + const bytes = new TextEncoder().encode(value) + return concatBytes([encVarint((num << 3) | 2), encVarint(bytes.length), bytes]) +} + +function msgField(num: number, message: Uint8Array): Uint8Array { + return concatBytes([encVarint((num << 3) | 2), encVarint(message.length), message]) +} + +// Mirrors GetUserStatusResponse{user_status{email=7, plan_status=13{ +// plan_info=1{plan_name=2}, daily=14, weekly=15, resets=17/18}}}. +function userStatusResponse(planStatus: Uint8Array, email = 'dev@example.com'): Uint8Array { + const userStatus = concatBytes([strField(7, email), msgField(13, planStatus)]) + return msgField(1, userStatus) +} + +function quotaPlanStatus( + overrides: { daily?: number; weekly?: number; dailyReset?: number; weeklyReset?: number } = {} +): Uint8Array { + const planInfo = strField(2, 'Pro') + return concatBytes([ + msgField(1, planInfo), + varField(14, overrides.daily ?? 98), + varField(15, overrides.weekly ?? 47), + varField(17, overrides.dailyReset ?? 1_900_000_000), + varField(18, overrides.weeklyReset ?? 1_900_500_000) + ]) +} + +function credentialsToml(extra = ''): string { + return `windsurf_api_key = "devin-session-token$test-token"\napi_server_url = "https://server.codeium.com"\n${extra}` +} + +describe('fetchDevinRateLimits', () => { + beforeEach(() => { + netFetchMock.mockReset() + files.credentials = null + files.readError = null + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('returns unavailable when credentials.toml is missing', async () => { + const result = await fetchDevinRateLimits() + expect(result.provider).toBe('devin') + expect(result.status).toBe('unavailable') + expect(result.error).toMatch(/devin login/i) + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('returns unavailable when credentials.toml has no session token', async () => { + files.credentials = 'api_server_url = "https://server.codeium.com"\n' + const result = await fetchDevinRateLimits() + expect(result.status).toBe('unavailable') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('returns error when the credentials file cannot be read', async () => { + files.credentials = credentialsToml() + files.readError = new Error('EACCES') + const result = await fetchDevinRateLimits() + expect(result.status).toBe('error') + expect(result.error).toBe('Unable to read Devin credentials file') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('maps daily and weekly quota windows from the user status response', async () => { + files.credentials = credentialsToml() + netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus()))) + + const result = await fetchDevinRateLimits() + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + expect(result.session?.usedPercent).toBe(2) + expect(result.session?.windowMinutes).toBe(1440) + expect(result.session?.resetsAt).toBe(1_900_000_000_000) + expect(result.weekly?.usedPercent).toBe(53) + expect(result.weekly?.windowMinutes).toBe(10_080) + expect(result.weekly?.resetsAt).toBe(1_900_500_000_000) + expect(result.planType).toBe('Pro') + expect(result.usageMetadata).toEqual({ + source: 'oauth', + authProvenance: 'dev@example.com', + credentialSource: 'credentials.toml' + }) + }) + + it('posts the CLI identity tuple to the seat management endpoint', async () => { + files.credentials = credentialsToml() + netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus()))) + + await fetchDevinRateLimits() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/proto', + 'Connect-Protocol-Version': '1' + }) + }) + ) + const body: Buffer = netFetchMock.mock.calls[0][1].body + expect(body.includes('devin-session-token$test-token')).toBe(true) + expect(body.includes('devin-cli')).toBe(true) + expect(body.includes('chisel')).toBe(true) + expect(body.includes('3000.6.2')).toBe(true) + }) + + it('normalizes a session token missing the devin-session-token prefix', async () => { + files.credentials = 'windsurf_api_key = "raw-token"\n' + netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus()))) + + await fetchDevinRateLimits() + const body: Buffer = netFetchMock.mock.calls[0][1].body + expect(body.includes('devin-session-token$raw-token')).toBe(true) + }) + + it('uses the configured api_server_url', async () => { + files.credentials = + 'windsurf_api_key = "devin-session-token$test-token"\napi_server_url = "https://devin.example.com/"\n' + netFetchMock.mockResolvedValueOnce(protoResponse(userStatusResponse(quotaPlanStatus()))) + + await fetchDevinRateLimits() + expect(netFetchMock).toHaveBeenCalledWith( + 'https://devin.example.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', + expect.anything() + ) + }) + + it.each([401, 403])( + 'reports an expired session as delegated refresh on HTTP %i', + async (status) => { + files.credentials = credentialsToml() + netFetchMock.mockResolvedValueOnce(protoResponse(new Uint8Array(), status)) + + const result = await fetchDevinRateLimits() + expect(result.status).toBe('error') + expect(result.error).toMatch(/run devin on the computer running Orca/i) + expect(result.usageMetadata).toEqual({ + failureKind: 'delegated-refresh-required', + source: 'oauth' + }) + } + ) + + it('surfaces other HTTP failures as errors', async () => { + files.credentials = credentialsToml() + netFetchMock.mockResolvedValueOnce(protoResponse(new Uint8Array(), 500)) + + const result = await fetchDevinRateLimits() + expect(result.status).toBe('error') + expect(result.error).toBe('Devin usage request failed (HTTP 500)') + }) + + it('returns error for an undecodable response', async () => { + files.credentials = credentialsToml() + netFetchMock.mockResolvedValueOnce(protoResponse(Uint8Array.from([0xff, 0xff, 0xff]))) + + const result = await fetchDevinRateLimits() + expect(result.status).toBe('error') + expect(result.error).toBe('Devin usage response was not a valid user status') + }) + + it('reports plans without quota windows as unavailable', async () => { + files.credentials = credentialsToml() + // plan_status present but no dated quota fields — credit-billed plans omit them. + netFetchMock.mockResolvedValueOnce( + protoResponse(userStatusResponse(msgField(1, strField(2, 'Teams')))) + ) + + const result = await fetchDevinRateLimits() + expect(result.status).toBe('unavailable') + expect(result.session).toBeNull() + expect(result.weekly).toBeNull() + expect(result.error).toMatch(/did not report quota windows/i) + }) + + it('propagates fetch failures as errors', async () => { + files.credentials = credentialsToml() + netFetchMock.mockRejectedValueOnce(new Error('network down')) + + const result = await fetchDevinRateLimits() + expect(result.status).toBe('error') + expect(result.error).toBe('Devin usage request failed — check your connection and retry') + }) + + it('aborts the status request when the caller aborts', async () => { + files.credentials = credentialsToml() + const controller = new AbortController() + let requestSignal: AbortSignal | undefined + netFetchMock.mockImplementationOnce((_url, init: RequestInit) => { + requestSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) + + const resultPromise = fetchDevinRateLimits({ signal: controller.signal }) + await Promise.resolve() + + expect(requestSignal?.aborted).toBe(false) + controller.abort() + expect(requestSignal?.aborted).toBe(true) + + const result = await resultPromise + expect(result.status).toBe('error') + expect(result.error).toBe('Devin usage request failed — check your connection and retry') + }) + it('rejects redirects before forwarding the credential-bearing body', async () => { + files.credentials = credentialsToml() + netFetchMock.mockRejectedValueOnce(new Error('redirect to https://private.invalid/secret')) + const result = await fetchDevinRateLimits() + expect(netFetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: 'error' }) + ) + expect(result.error).not.toContain('private.invalid') + }) + + it('cancels oversized responses before reading the body', async () => { + files.credentials = credentialsToml() + const cancel = vi.fn() + netFetchMock.mockResolvedValueOnce( + new Response(new ReadableStream({ cancel }), { + headers: { 'content-length': '1048577' } + }) + ) + expect((await fetchDevinRateLimits()).status).toBe('error') + expect(cancel).toHaveBeenCalledOnce() + }) + + it('cancels unread HTTP error bodies', async () => { + files.credentials = credentialsToml() + const cancel = vi.fn() + netFetchMock.mockResolvedValueOnce( + new Response(new ReadableStream({ cancel }), { status: 401 }) + ) + expect((await fetchDevinRateLimits()).usageMetadata?.failureKind).toBe( + 'delegated-refresh-required' + ) + expect(cancel).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/rate-limits/devin-fetcher.ts b/src/main/rate-limits/devin-fetcher.ts new file mode 100644 index 00000000000..80bf8fb80a8 --- /dev/null +++ b/src/main/rate-limits/devin-fetcher.ts @@ -0,0 +1,197 @@ +import { net } from 'electron' +import { readFetchResponseBytesWithinLimit } from '../../shared/fetch-response-body' +import { cancelUnreadResponseBody } from '../lib/unread-response-body' +import type { + ProviderRateLimits, + RateLimitWindow, + UsageRateLimitMetadata +} from '../../shared/rate-limit-types' +import { + readDevinCredentials, + type DevinCredentials, + type DevinCredentialsReadResult +} from './devin-credentials' +import { + decodeGetUserStatusQuota, + encodeGetUserStatusRequest, + type DevinUserStatusQuota +} from './devin-user-status-wire' + +// Why: Devin has no REST usage endpoint — plan tier and the daily/weekly quota +// windows come from the same SeatManagementService/GetUserStatus unary +// Connect-RPC the CLI issues for `devin auth status`. The request body is raw +// (unframed) protobuf; see devin-user-status-wire.ts for the schema notes. +const GET_USER_STATUS_PATH = '/exa.seat_management_pb.SeatManagementService/GetUserStatus' +const API_TIMEOUT_MS = 10_000 + +const DAILY_WINDOW_MINUTES = 1440 +const WEEKLY_WINDOW_MINUTES = 10_080 +// Why: the backend gates GetUserStatus on a released-CLI identity tuple, and +// the CLI persists no version file Orca can read, so pin a known-good release. +const DEVIN_CLI_IDENTITY_VERSION = '3000.6.2' + +function result( + status: ProviderRateLimits['status'], + error: string | null, + usageMetadata?: UsageRateLimitMetadata +): ProviderRateLimits { + return { + provider: 'devin', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status, + ...(usageMetadata ? { usageMetadata } : {}) + } +} + +function parseResetDescription(unixSeconds: number | null): string | null { + if (unixSeconds === null || !Number.isFinite(unixSeconds)) { + return null + } + const date = new Date(unixSeconds * 1000) + const isToday = date.toDateString() === new Date().toDateString() + return isToday + ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + : date.toLocaleDateString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' }) +} + +function mapQuotaWindow( + remainingPercent: number | null, + resetAtUnix: number | null, + windowMinutes: number +): RateLimitWindow | null { + if (remainingPercent === null) { + return null + } + const resetSeconds = resetAtUnix !== null && resetAtUnix <= 8_640_000_000_000 ? resetAtUnix : null + return { + usedPercent: Math.min(100, Math.max(0, 100 - remainingPercent)), + windowMinutes, + resetsAt: resetSeconds !== null ? resetSeconds * 1000 : null, + resetDescription: parseResetDescription(resetSeconds) + } +} + +function quotaResult(quota: DevinUserStatusQuota): ProviderRateLimits { + const session = mapQuotaWindow( + quota.dailyQuotaRemainingPercent, + quota.dailyQuotaResetAtUnix, + DAILY_WINDOW_MINUTES + ) + const weekly = mapQuotaWindow( + quota.weeklyQuotaRemainingPercent, + quota.weeklyQuotaResetAtUnix, + WEEKLY_WINDOW_MINUTES + ) + if (!session && !weekly) { + // Why: a signed-in plan that reports no dated quota windows (credit-billed + // plans omit them) has no visible quota. 'unavailable' also clears the + // devinAuthConfigured probe in the apply step, so the bar hides instead + // of pinning a permanent "--" slot. + return { + ...result('unavailable', 'Devin did not report quota windows for this account', { + source: 'oauth', + authProvenance: quota.email ?? 'Devin account', + credentialSource: 'credentials.toml' + }), + planType: quota.planName + } + } + return { + provider: 'devin', + session, + weekly, + planType: quota.planName, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { + source: 'oauth', + authProvenance: quota.email ?? 'Devin account', + credentialSource: 'credentials.toml' + } + } +} + +function expiredSessionResult(): ProviderRateLimits { + // Why: the session token lives in credentials.toml and is rotated by the + // Devin CLI on its next run — Orca must never refresh it. Report the + // delegated-refresh failure so the UI tells the user to run `devin`. + return result( + 'error', + 'Devin session expired — run devin on the computer running Orca, then retry usage.', + { failureKind: 'delegated-refresh-required', source: 'oauth' } + ) +} + +async function fetchUserStatus( + credentials: DevinCredentials, + signal: AbortSignal | undefined +): Promise< + { kind: 'quota'; quota: DevinUserStatusQuota } | { kind: 'result'; result: ProviderRateLimits } +> { + const requestSignal = signal + ? AbortSignal.any([signal, AbortSignal.timeout(API_TIMEOUT_MS)]) + : AbortSignal.timeout(API_TIMEOUT_MS) + const requestBody = encodeGetUserStatusRequest( + credentials.sessionToken, + DEVIN_CLI_IDENTITY_VERSION + ) + const res = await net.fetch(`${credentials.apiServerUrl}${GET_USER_STATUS_PATH}`, { + method: 'POST', + redirect: 'error', + headers: { + 'Content-Type': 'application/proto', + 'Connect-Protocol-Version': '1', + Accept: '*/*' + }, + body: Buffer.from(requestBody), + signal: requestSignal + }) + if (!res.ok) { + await cancelUnreadResponseBody(res) + } + if (res.status === 401 || res.status === 403) { + return { kind: 'result', result: expiredSessionResult() } + } + if (!res.ok) { + return { + kind: 'result', + result: result('error', `Devin usage request failed (HTTP ${res.status})`) + } + } + const quota = decodeGetUserStatusQuota(await readFetchResponseBytesWithinLimit(res, 1024 * 1024)) + if (!quota) { + return { + kind: 'result', + result: result('error', 'Devin usage response was not a valid user status') + } + } + return { kind: 'quota', quota } +} + +// Why read-only: the session token is written by `devin login`/the CLI's own +// session lifecycle; Orca only reads it and calls the same status endpoint the +// CLI does. A rejected token means the CLI must run again — never refresh it. +export async function fetchDevinRateLimits( + options: { signal?: AbortSignal; credentialsReadResult?: DevinCredentialsReadResult } = {} +): Promise { + const readResult = options.credentialsReadResult ?? readDevinCredentials() + if (readResult.status === 'missing') { + return result('unavailable', 'Not signed in to Devin — run devin login') + } + if (readResult.status === 'error') { + return result('error', readResult.error) + } + try { + const outcome = await fetchUserStatus(readResult.credentials, options.signal) + if (outcome.kind === 'result') { + return outcome.result + } + return quotaResult(outcome.quota) + } catch { + return result('error', 'Devin usage request failed — check your connection and retry') + } +} diff --git a/src/main/rate-limits/devin-user-status-wire.test.ts b/src/main/rate-limits/devin-user-status-wire.test.ts new file mode 100644 index 00000000000..2a3a98bce6d --- /dev/null +++ b/src/main/rate-limits/devin-user-status-wire.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { decodeGetUserStatusQuota } from './devin-user-status-wire' + +describe('Devin protobuf admission', () => { + it.each([ + { bytes: [0, 0] }, + { bytes: [10, 255, 255, 255, 255, 255, 255, 255, 255, 255, 2] }, + { bytes: [10, 2, 128, 128] }, + { bytes: [10, 127] }, + { bytes: Array.from({ length: 100_000 }, () => 128) } + ])('rejects malformed or oversized varints without scanning the entire input', ({ bytes }) => { + expect(decodeGetUserStatusQuota(Uint8Array.from(bytes))).toBeNull() + }) +}) diff --git a/src/main/rate-limits/devin-user-status-wire.ts b/src/main/rate-limits/devin-user-status-wire.ts new file mode 100644 index 00000000000..ebe07a61084 --- /dev/null +++ b/src/main/rate-limits/devin-user-status-wire.ts @@ -0,0 +1,219 @@ +// Wire codec for the Devin CLI's SeatManagementService/GetUserStatus call. +// +// Why: Devin ships no REST quota endpoint. The CLI asks the Cascade backend +// (server.codeium.com) for user status via a unary Connect-RPC call whose body +// is raw (unframed) protobuf — `exa.seat_management_pb.GetUserStatusRequest` +// carrying a `codeium_common_pb.Metadata` with the CLI identity tuple and the +// session token from credentials.toml. The backend gates the CLI surface on +// that identity (ide_name=devin-cli, ide_type=chisel), so a bare token call is +// rejected. Field numbers below follow the published exa/codeium proto schema. +// +// Those numbers were confirmed on 2026-09-18 against a live +// GetUserStatus response (devin 3000.10.31) and against the CLI's own on-disk +// cache at ~/.cache/devin/cli/user_status..bin (JSON envelope +// `{version, identity_digest, fetched_at_secs, payload: base64 UserStatus}`), +// which is a possible zero-network fallback for a future change. + +const DEVIN_CLI_IDE_NAME = 'devin-cli' +const DEVIN_CLI_IDE_TYPE = 'chisel' +const DEVIN_CLI_EXTENSION_NAME = 'chisel' +const DEVIN_CLI_LOCALE = 'en' + +export type DevinUserStatusQuota = { + /** 0–100 remaining for the daily quota window, when reported. */ + dailyQuotaRemainingPercent: number | null + /** 0–100 remaining for the weekly quota window, when reported. */ + weeklyQuotaRemainingPercent: number | null + /** Unix seconds when the daily window resets. */ + dailyQuotaResetAtUnix: number | null + /** Unix seconds when the weekly window resets. */ + weeklyQuotaResetAtUnix: number | null + /** Plan display name (e.g. "Pro") from plan_status.plan_info.plan_name. */ + planName: string | null + /** Account email, for provenance labelling. */ + email: string | null +} + +type ProtoField = + | { num: number; wire: 0; value: bigint } + | { num: number; wire: 1 | 5; value: Uint8Array } + | { num: number; wire: 2; value: Uint8Array } + +function encodeVarint(value: number): Uint8Array { + const bytes: number[] = [] + let v = value >>> 0 + while (v > 0x7f) { + bytes.push((v & 0x7f) | 0x80) + v >>>= 7 + } + bytes.push(v) + return Uint8Array.from(bytes) +} + +function encodeStringField(num: number, value: string): Uint8Array { + const bytes = new TextEncoder().encode(value) + const tag = encodeVarint((num << 3) | 2) + const len = encodeVarint(bytes.length) + const out = new Uint8Array(tag.length + len.length + bytes.length) + out.set(tag, 0) + out.set(len, tag.length) + out.set(bytes, tag.length + len.length) + return out +} + +function concatBytes(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, part) => sum + part.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const part of parts) { + out.set(part, offset) + offset += part.length + } + return out +} + +// Builds GetUserStatusRequest{metadata{…}} — the only message member the +// request schema defines (field 1, length-delimited). +export function encodeGetUserStatusRequest(sessionToken: string, cliVersion: string): Uint8Array { + const metadata = concatBytes([ + encodeStringField(1, DEVIN_CLI_IDE_NAME), // ide_name + encodeStringField(2, cliVersion), // extension_version + encodeStringField(3, sessionToken), // api_key — wire format carries the devin-session-token$ prefix + encodeStringField(4, DEVIN_CLI_LOCALE), // locale + encodeStringField( + 5, + process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'windows' : 'linux' + ), // os + encodeStringField(7, cliVersion), // ide_version + encodeStringField(12, DEVIN_CLI_EXTENSION_NAME), // extension_name + encodeStringField(28, DEVIN_CLI_IDE_TYPE) // ide_type — 'chisel' unlocks the Devin CLI surface + ]) + const tag = encodeVarint(0x0a) // field 1, wire 2 + const len = encodeVarint(metadata.length) + return concatBytes([tag, len, metadata]) +} + +function readVarint(buf: Uint8Array, pos: number): [bigint, number] { + let result = 0n + let shift = 0n + while (pos < buf.length && shift < 70n) { + const byte = buf[pos++] + if (shift === 63n && byte > 1) { + throw new Error('invalid varint') + } + result |= BigInt(byte & 0x7f) << shift + if ((byte & 0x80) === 0) { + return [result, pos] + } + shift += 7n + } + throw new Error('truncated varint') +} + +function readProtoFields(buf: Uint8Array): ProtoField[] { + const fields: ProtoField[] = [] + let pos = 0 + while (pos < buf.length) { + const [tag, afterTag] = readVarint(buf, pos) + pos = afterTag + if (tag >> 3n === 0n || tag >> 3n > 0x1fffffffn) { + throw new Error('invalid field number') + } + const num = Number(tag >> 3n) + const wire = Number(tag & 7n) + if (wire === 0) { + const [value, next] = readVarint(buf, pos) + pos = next + fields.push({ num, wire: 0, value }) + } else if (wire === 2) { + const [len, afterLen] = readVarint(buf, pos) + if (len > BigInt(buf.length - afterLen)) { + throw new Error('truncated length-delimited field') + } + const length = Number(len) + pos = afterLen + length + if (pos > buf.length) { + throw new Error('truncated length-delimited field') + } + fields.push({ num, wire: 2, value: buf.subarray(afterLen, pos) }) + } else if (wire === 1 || wire === 5) { + const size = wire === 1 ? 8 : 4 + if (pos + size > buf.length) { + throw new Error('truncated fixed-width field') + } + fields.push({ num, wire, value: buf.subarray(pos, pos + size) }) + pos += size + } else { + // Why: wire types 3/4 (groups) are unused in proto3 output; bail rather + // than silently misaligning the rest of the message. + throw new Error(`unsupported protobuf wire type ${wire}`) + } + } + return fields +} + +function fieldBytes(fields: ProtoField[], num: number): Uint8Array | null { + const field = fields.find((f) => f.num === num) + return field?.wire === 2 ? field.value : null +} + +function fieldVarint(fields: ProtoField[], num: number): number | null { + const field = fields.find((f) => f.num === num) + return field?.wire === 0 && field.value <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(field.value) + : null +} + +function fieldString(fields: ProtoField[], num: number): string | null { + const bytes = fieldBytes(fields, num) + return bytes ? new TextDecoder().decode(bytes) : null +} + +// Parses GetUserStatusResponse → user_status.plan_status quota fields plus the +// plan/account labels the status bar shows. Returns null when the payload is +// not a decodable user-status message. +export function decodeGetUserStatusQuota(buf: Uint8Array): DevinUserStatusQuota | null { + let userStatusBytes: Uint8Array | null + try { + userStatusBytes = fieldBytes(readProtoFields(buf), 1) + } catch { + return null + } + if (!userStatusBytes) { + return null + } + let userStatus: ProtoField[] + let planStatusBytes: Uint8Array | null + try { + userStatus = readProtoFields(userStatusBytes) + planStatusBytes = fieldBytes(userStatus, 13) + } catch { + return null + } + if (!planStatusBytes) { + return null + } + let planStatus: ProtoField[] + try { + planStatus = readProtoFields(planStatusBytes) + } catch { + return null + } + const planInfoBytes = fieldBytes(planStatus, 1) + let planName: string | null = null + if (planInfoBytes) { + try { + planName = fieldString(readProtoFields(planInfoBytes), 2) + } catch { + planName = null + } + } + return { + dailyQuotaRemainingPercent: fieldVarint(planStatus, 14), + weeklyQuotaRemainingPercent: fieldVarint(planStatus, 15), + dailyQuotaResetAtUnix: fieldVarint(planStatus, 17), + weeklyQuotaResetAtUnix: fieldVarint(planStatus, 18), + planName, + email: fieldString(userStatus, 7) + } +} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 041ede99b62..4fbfc1cc31b 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -7,6 +7,8 @@ import { fetchGeminiRateLimits } from './gemini-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' +import { fetchDevinRateLimits } from './devin-fetcher' +import { readDevinCredentials } from './devin-credentials' import { readGrokAuthSession } from './grok-auth' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' @@ -89,6 +91,7 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) + vi.mocked(fetchDevinRateLimits).mockImplementation(async () => unavailableProvider('devin')) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -106,8 +109,10 @@ export function resetRateLimitProviderMocks(): void { error: null, status: 'unavailable' }) + vi.mocked(fetchDevinRateLimits).mockResolvedValue(unavailableProvider('devin')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) + vi.mocked(readDevinCredentials).mockReturnValue({ status: 'missing' }) } type RateLimitWindow = Parameters[0] diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 9241831bdf6..1f86a433a9a 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -44,6 +44,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./devin-fetcher', () => ({ + fetchDevinRateLimits: vi.fn() +})) + +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db0..193bb453609 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -43,6 +43,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./devin-fetcher', () => ({ + fetchDevinRateLimits: vi.fn() +})) + +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-devin-identity.test.ts b/src/main/rate-limits/service-devin-identity.test.ts new file mode 100644 index 00000000000..311072a8703 --- /dev/null +++ b/src/main/rate-limits/service-devin-identity.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { RateLimitService } from './service' +import { fetchDevinRateLimits } from './devin-fetcher' +import { readDevinCredentials } from './devin-credentials' +import { deferred } from './rate-limit-service-test-harness' + +vi.mock('./devin-fetcher', () => ({ fetchDevinRateLimits: vi.fn() })) +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + +const quota = (usedPercent: number): ProviderRateLimits => ({ + provider: 'devin', + session: { usedPercent, windowMinutes: 1440, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: Date.now(), + error: null, + status: 'ok' +}) + +describe('Devin credential identity fencing', () => { + beforeEach(() => vi.clearAllMocks()) + + it('drops an in-flight Devin-only result when credentials rotate', async () => { + const oldCredentials = { + status: 'ok' as const, + credentials: { sessionToken: 'old', apiServerUrl: 'https://server.codeium.com' } + } + const newCredentials = { + status: 'ok' as const, + credentials: { sessionToken: 'new', apiServerUrl: 'https://server.codeium.com' } + } + let current = oldCredentials + const pending = deferred() + vi.mocked(fetchDevinRateLimits).mockImplementationOnce(async () => { + current = newCredentials + return pending.promise + }) + vi.mocked(readDevinCredentials).mockImplementation(() => current) + const service = new RateLimitService() + const refresh = service.refreshDevin() + pending.resolve(quota(12)) + await refresh + await pending.promise + expect(service.getState().devin).toBeNull() + expect(service.getState().devinAuthConfigured).toBe(true) + }) + it('does not carry the old account quota into a failed refresh for a new account', async () => { + vi.mocked(readDevinCredentials).mockReturnValue({ + status: 'ok', + credentials: { + sessionToken: 'first', + apiServerUrl: 'https://server.codeium.com' + } + }) + vi.mocked(fetchDevinRateLimits).mockResolvedValueOnce(quota(12)) + const service = new RateLimitService() + await service.refreshDevin() + vi.mocked(readDevinCredentials).mockReturnValue({ + status: 'ok', + credentials: { + sessionToken: 'second', + apiServerUrl: 'https://server.codeium.com' + } + }) + vi.mocked(fetchDevinRateLimits).mockResolvedValueOnce({ + ...quota(12), + status: 'error', + session: null, + error: 'Offline' + }) + await service.refreshDevin() + expect(service.getState().devin?.session).toBeNull() + }) +}) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 3c629351382..5ff8340ee48 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -51,6 +51,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./devin-fetcher', () => ({ + fetchDevinRateLimits: vi.fn() +})) + +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 668cfc113b9..59d88d97190 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -48,6 +48,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./devin-fetcher', () => ({ + fetchDevinRateLimits: vi.fn() +})) + +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 819b4c93f5d..402cd0ed414 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -45,6 +45,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./devin-fetcher', () => ({ + fetchDevinRateLimits: vi.fn() +})) + +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 44f4b05b3ba..a22ab961841 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -8,6 +8,8 @@ import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' +import { fetchDevinRateLimits } from './devin-fetcher' +import { readDevinCredentials } from './devin-credentials' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' import { deferred, @@ -15,7 +17,8 @@ import { flushMicrotasks, mockFreshBackgroundProviderFetches, okProvider, - resetRateLimitProviderMocks + resetRateLimitProviderMocks, + unavailableProvider } from './rate-limit-service-test-harness' vi.mock('./claude-fetcher', () => ({ @@ -52,6 +55,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./devin-fetcher', () => ({ + fetchDevinRateLimits: vi.fn() +})) + +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) @@ -119,6 +130,25 @@ describe('RateLimitService', () => { expect(service.getState().grok?.status).toBe('ok') }) + it('preserves credential presence when a signed-in plan reports no quota windows', async () => { + vi.mocked(readDevinCredentials).mockReturnValue({ + status: 'ok', + credentials: { sessionToken: 'tok', apiServerUrl: 'https://server.codeium.com' } + }) + vi.mocked(fetchDevinRateLimits).mockResolvedValue( + unavailableProvider('devin', 'Devin did not report quota windows for this account') + ) + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 0)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 0)) + const service = new RateLimitService() + + expect(service.getState().devinAuthConfigured).toBe(true) + await serviceInternals(service).fetchAll() + + expect(service.getState().devinAuthConfigured).toBe(true) + expect(service.getState().devin?.status).toBe('unavailable') + }) + it('does not refetch Claude when a Codex account switch is queued during fetchAll', async () => { const service = new RateLimitService() const firstClaude = deferred() @@ -264,6 +294,57 @@ describe('RateLimitService', () => { expect(fetchCodexRateLimits).toHaveBeenCalledTimes(2) }) + it('runs a queued Devin refresh after an active Devin fetch settles', async () => { + const service = new RateLimitService() + const firstDevin = deferred() + vi.mocked(fetchDevinRateLimits).mockImplementationOnce(() => firstDevin.promise) + + const activeRefresh = service.refreshDevin() + await flushMicrotasks() + const queuedRefresh = service.refreshDevin() + + firstDevin.resolve(okProvider('devin', 10)) + await activeRefresh + await queuedRefresh + + expect(fetchDevinRateLimits).toHaveBeenCalledTimes(2) + }) + + it('runs a queued provider refresh after an active Devin fetch settles', async () => { + const service = new RateLimitService() + const firstDevin = deferred() + vi.mocked(fetchDevinRateLimits).mockImplementationOnce(() => firstDevin.promise) + + const activeRefresh = service.refreshDevin() + await flushMicrotasks() + const queuedRefresh = service.refreshGrok() + + firstDevin.resolve(okProvider('devin', 10)) + await activeRefresh + await queuedRefresh + + expect(fetchDevinRateLimits).toHaveBeenCalledTimes(1) + expect(fetchGrokRateLimits).toHaveBeenCalledTimes(1) + }) + + it('runs a queued full refresh after an active Devin fetch settles', async () => { + const service = new RateLimitService() + const firstDevin = deferred() + vi.mocked(fetchDevinRateLimits).mockImplementationOnce(() => firstDevin.promise) + + const activeRefresh = service.refreshDevin() + await flushMicrotasks() + const queuedRefresh = service.refresh() + + firstDevin.resolve(okProvider('devin', 10)) + await activeRefresh + await queuedRefresh + + expect(fetchDevinRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(fetchCodexRateLimits).toHaveBeenCalledTimes(1) + }) + it('publishes non-Grok provider results before a slow Grok fetch completes', async () => { const service = new RateLimitService() const grok = deferred() diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index ec813a53d94..05ab2cbb238 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -8,6 +8,7 @@ import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' +import { fetchDevinRateLimits } from './devin-fetcher' import { asRateLimitWindow, deferred, @@ -53,6 +54,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./devin-fetcher', () => ({ + fetchDevinRateLimits: vi.fn() +})) + +vi.mock('./devin-credentials', () => ({ + readDevinCredentials: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) @@ -540,6 +549,36 @@ describe('RateLimitService', () => { } }) + it('retries a failing Devin fetch on its own cycle without re-reading Claude', async () => { + vi.useFakeTimers() + try { + vi.mocked(fetchClaudeRateLimits).mockResolvedValue(okProvider('claude', 12)) + vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 24)) + vi.mocked(fetchDevinRateLimits).mockResolvedValue(errorProvider('devin', 'HTTP 502')) + + const service = new RateLimitService() + const window = new FakeRateLimitWindow() + service.attach(asRateLimitWindow(window)) + service.start({ fetchImmediately: false }) + + await vi.advanceTimersByTimeAsync(1000) + expect(fetchDevinRateLimits).toHaveBeenCalledTimes(1) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + expect(service.getState().devin?.status).toBe('error') + + // Why: Devin has a dedicated fetch cycle, so recovering it must not pull + // Claude's tight-budget endpoint along the way the Kimi full-fetch does. + window.emit('focus') + await vi.advanceTimersByTimeAsync(0) + expect(fetchDevinRateLimits).toHaveBeenCalledTimes(2) + expect(fetchClaudeRateLimits).toHaveBeenCalledTimes(1) + + service.stop() + } finally { + vi.useRealTimers() + } + }) + it('debounces unavailable providers on active window events', async () => { vi.useFakeTimers() try { diff --git a/src/main/rate-limits/service/service-account-refresh.ts b/src/main/rate-limits/service/service-account-refresh.ts index 9e758a98518..76e52a5559e 100644 --- a/src/main/rate-limits/service/service-account-refresh.ts +++ b/src/main/rate-limits/service/service-account-refresh.ts @@ -28,6 +28,10 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna await this.fetchGrokOnly({ force: true }) return this.getState() } + async refreshDevin(): Promise { + await this.fetchDevinOnly({ force: true }) + return this.getState() + } invalidateMiniMaxCredentialState(): void { this.minimaxFetchGeneration += 1 diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 655ba9b93d8..4557de74236 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -126,6 +126,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco minimaxCookieConfigured: hasMiniMaxSessionCookie(), minimaxApiKeyConfigured: hasMiniMaxApiKey(), grokAuthConfigured: this.grokAuthConfigured, + devinAuthConfigured: this.devinAuthConfigured, claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( diff --git a/src/main/rate-limits/service/service-fetch-control.ts b/src/main/rate-limits/service/service-fetch-control.ts index cab01551def..2a388713873 100644 --- a/src/main/rate-limits/service/service-fetch-control.ts +++ b/src/main/rate-limits/service/service-fetch-control.ts @@ -7,7 +7,8 @@ export abstract class RateLimitServiceFetchControl extends RateLimitServiceState !this.fullFetchQueued && !this.codexOnlyFetchQueued && !this.claudeOnlyFetchQueued && - !this.grokOnlyFetchQueued + !this.grokOnlyFetchQueued && + !this.devinOnlyFetchQueued ) { return Promise.resolve() } @@ -23,7 +24,8 @@ export abstract class RateLimitServiceFetchControl extends RateLimitServiceState this.fullFetchQueued || this.codexOnlyFetchQueued || this.claudeOnlyFetchQueued || - this.grokOnlyFetchQueued + this.grokOnlyFetchQueued || + this.devinOnlyFetchQueued ) { return } @@ -68,6 +70,7 @@ export abstract class RateLimitServiceFetchControl extends RateLimitServiceState this.codexOnlyFetchQueued = false this.claudeOnlyFetchQueued = false this.grokOnlyFetchQueued = false + this.devinOnlyFetchQueued = false } protected resolveAndClearFetchIdleWaiters(): void { diff --git a/src/main/rate-limits/service/service-fetch-queue.ts b/src/main/rate-limits/service/service-fetch-queue.ts index 49531ee3fb1..be93e043fcc 100644 --- a/src/main/rate-limits/service/service-fetch-queue.ts +++ b/src/main/rate-limits/service/service-fetch-queue.ts @@ -1,245 +1,114 @@ import { RateLimitServiceProviderCycles } from './service-provider-cycles' +type FetchKind = 'all' | 'codex' | 'claude' | 'grok' | 'devin' + export abstract class RateLimitServiceFetchQueue extends RateLimitServiceProviderCycles { - protected async fetchAll(options?: { force?: boolean }): Promise { + protected fetchAll(options?: { force?: boolean }): Promise { + return this.fetchProvider('all', options?.force ?? false) + } + + protected fetchCodexOnly(options?: { force?: boolean }): Promise { + return this.fetchProvider('codex', options?.force ?? false) + } + + protected fetchClaudeOnly(options?: { force?: boolean }): Promise { + return this.fetchProvider('claude', options?.force ?? false) + } + + protected fetchGrokOnly(options?: { force?: boolean }): Promise { + return this.fetchProvider('grok', options?.force ?? false) + } + + protected fetchDevinOnly(options?: { force?: boolean }): Promise { + return this.fetchProvider('devin', options?.force ?? false) + } + + private async fetchProvider(kind: FetchKind, force: boolean): Promise { if (this.isFetching) { - if (options?.force) { + if (force) { + this.queueFetch(kind) + return this.waitForFetchIdle() + } + return + } + this.isFetching = true + try { + let next: FetchKind | null = kind + let cycleForce = force + while (next !== null) { + const current = next + const signal = await this.runWithFetchAbortSignal((signal) => + this.runProviderCycle(current, signal, cycleForce) + ) + if (signal.aborted) { + break + } + // Read live flags after every await; requests can arrive during any provider's cycle. + next = this.takeQueuedFetch() + cycleForce = true + } + } finally { + this.isFetching = false + this.resolveFetchIdleWaiters() + } + } + + private runProviderCycle(kind: FetchKind, signal: AbortSignal, force: boolean): Promise { + switch (kind) { + case 'all': + return this.runFetchAllCycle(signal, { force }) + case 'codex': + return this.runFetchCodexOnlyCycle(signal) + case 'claude': + return this.runFetchClaudeOnlyCycle(signal, { force }) + case 'grok': + return this.runFetchGrokOnlyCycle(signal) + case 'devin': + return this.runFetchDevinOnlyCycle(signal) + } + } + + private queueFetch(kind: FetchKind): void { + switch (kind) { + case 'all': this.fullFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - shouldContinue = true - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - protected async fetchCodexOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { + break + case 'codex': this.codexOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - shouldContinue = true - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() - } - } - - protected async fetchClaudeOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { + break + case 'claude': this.claudeOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return - } - this.isFetching = true - - try { - let shouldContinue = true - // Why: only user-directed (force) fetches may bypass a provider's Retry-After gate; queued reruns inherit force because only forced calls queue them. - let cycleForce = options?.force ?? false - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: cycleForce }) - ) - shouldContinue = false - cycleForce = true - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - const grokSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - if (grokSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() + break + case 'grok': + this.grokOnlyFetchQueued = true + break + case 'devin': + this.devinOnlyFetchQueued = true + break } } - protected async fetchGrokOnly(options?: { force?: boolean }): Promise { - if (this.isFetching) { - if (options?.force) { - this.grokOnlyFetchQueued = true - return this.waitForFetchIdle() - } - return + private takeQueuedFetch(): FetchKind | null { + if (this.fullFetchQueued) { + this.fullFetchQueued = false + return 'all' } - this.isFetching = true - - try { - let shouldContinue = true - while (shouldContinue) { - const signal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchGrokOnlyCycle(fetchSignal) - ) - shouldContinue = false - if (signal.aborted) { - break - } - if (this.fullFetchQueued) { - this.fullFetchQueued = false - const fullSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchAllCycle(fetchSignal, { force: true }) - ) - if (fullSignal.aborted) { - break - } - continue - } - if (this.grokOnlyFetchQueued) { - this.grokOnlyFetchQueued = false - shouldContinue = true - } - if (this.codexOnlyFetchQueued) { - this.codexOnlyFetchQueued = false - const codexSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchCodexOnlyCycle(fetchSignal) - ) - if (codexSignal.aborted) { - break - } - } - if (this.claudeOnlyFetchQueued) { - this.claudeOnlyFetchQueued = false - const claudeSignal = await this.runWithFetchAbortSignal((fetchSignal) => - this.runFetchClaudeOnlyCycle(fetchSignal, { force: true }) - ) - if (claudeSignal.aborted) { - break - } - } - } - } finally { - this.isFetching = false - this.resolveFetchIdleWaiters() + if (this.codexOnlyFetchQueued) { + this.codexOnlyFetchQueued = false + return 'codex' } + if (this.claudeOnlyFetchQueued) { + this.claudeOnlyFetchQueued = false + return 'claude' + } + if (this.grokOnlyFetchQueued) { + this.grokOnlyFetchQueued = false + return 'grok' + } + if (this.devinOnlyFetchQueued) { + this.devinOnlyFetchQueued = false + return 'devin' + } + return null } } diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38f..a7d86f8ce27 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,6 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' +import { readDevinCredentials } from '../devin-credentials' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -34,7 +35,10 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + devinResultPromise, + devinCredentialFingerprint, + previousDevin } = prepared if (signal.aborted) { return @@ -211,5 +215,33 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) + + const devinResult = await devinResultPromise + if (signal.aborted) { + return + } + const latestDevinCredentials = readDevinCredentials() + if (devinCredentialFingerprint !== this.getDevinCredentialFingerprint(latestDevinCredentials)) { + this.devinAuthConfigured = latestDevinCredentials.status === 'ok' + this.updateState({ ...this.state, devin: null }) + return + } + const devin = + devinResult.status === 'fulfilled' + ? devinResult.value + : ({ + provider: 'devin', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + devinResult.reason instanceof Error ? devinResult.reason.message : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) + this.trackActiveFailureStreak('devin', devin) + this.updateState({ + ...this.state, + devin: this.applyStalePolicy(devin, previousDevin) + }) } } diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 7551652bca1..90c08098aa6 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -3,6 +3,8 @@ import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' +import { fetchDevinRateLimits } from '../devin-fetcher' +import { readDevinCredentials } from '../devin-credentials' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher' import { RateLimitServiceFetchPolicy } from './service-fetch-policy' @@ -41,6 +43,11 @@ export type FetchAllCyclePrepared = { grokResultPromise: Promise< { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } > + devinResultPromise: Promise< + { status: 'fulfilled'; value: ProviderRateLimits } | { status: 'rejected'; reason: unknown } + > + previousDevin: ProviderRateLimits | null + devinCredentialFingerprint: string } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -86,6 +93,12 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. const grokAuthReadResult = readGrokAuthSession() this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + const devinCredentialsReadResult = readDevinCredentials() + this.devinAuthConfigured = devinCredentialsReadResult.status === 'ok' + const devinCredentialFingerprint = this.getDevinCredentialFingerprint( + devinCredentialsReadResult + ) + const previousDevin = this.previousDevinSnapshot(devinCredentialFingerprint) // Discard stale data on config change — it belongs to a different session/workspace. const currentConfigHash = `${cookie}|${workspaceIdOverride}` @@ -121,7 +134,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), - grok: this.withFetchingStatus(previousState.grok, 'grok') + grok: this.withFetchingStatus(previousState.grok, 'grok'), + devin: this.withFetchingStatus(previousDevin, 'devin') }) const missingWslCodexHome = @@ -133,6 +147,13 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (value) => ({ status: 'fulfilled', value }) as const, (reason) => ({ status: 'rejected', reason }) as const ) + const devinResultPromise = fetchDevinRateLimits({ + signal, + credentialsReadResult: devinCredentialsReadResult + }).then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. const claudeFetchGated = @@ -202,7 +223,10 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ kimiResult, miniMaxResult ], - grokResultPromise + grokResultPromise, + devinResultPromise, + previousDevin, + devinCredentialFingerprint } } } diff --git a/src/main/rate-limits/service/service-polling.ts b/src/main/rate-limits/service/service-polling.ts index c861726cc52..2b91411f1f1 100644 --- a/src/main/rate-limits/service/service-polling.ts +++ b/src/main/rate-limits/service/service-polling.ts @@ -78,7 +78,8 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue kimi: this.state.kimi, minimax: this.state.minimax, grok: this.state.grok, - antigravity: this.state.antigravity + antigravity: this.state.antigravity, + devin: this.state.devin } return Object.entries(byProvider).map(([provider, limits]) => ({ provider: provider as ActiveRateLimitProvider, @@ -171,6 +172,9 @@ export abstract class RateLimitServicePolling extends RateLimitServiceFetchQueue if (plan.providers.includes('grok')) { await this.fetchGrokOnly() } + if (plan.providers.includes('devin')) { + await this.fetchDevinOnly() + } } protected async refreshIfWindowActive(): Promise { diff --git a/src/main/rate-limits/service/service-provider-cycles.ts b/src/main/rate-limits/service/service-provider-cycles.ts index 75c56d96e67..d40cef58a86 100644 --- a/src/main/rate-limits/service/service-provider-cycles.ts +++ b/src/main/rate-limits/service/service-provider-cycles.ts @@ -3,6 +3,8 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' +import { fetchDevinRateLimits } from '../devin-fetcher' +import { readDevinCredentials } from '../devin-credentials' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFullCycleApplication { @@ -180,4 +182,41 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul grok: this.applyStalePolicy(grok, previousState.grok) }) } + protected async runFetchDevinOnlyCycle(signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + const credentialsReadResult = readDevinCredentials() + const credentialFingerprint = this.getDevinCredentialFingerprint(credentialsReadResult) + const previousDevin = this.previousDevinSnapshot(credentialFingerprint) + this.devinAuthConfigured = credentialsReadResult.status === 'ok' + this.updateState({ + ...this.state, + devin: this.withFetchingStatus(previousDevin, 'devin') + }) + const devin = await fetchDevinRateLimits({ signal, credentialsReadResult }).catch( + (err): ProviderRateLimits => ({ + provider: 'devin', + session: null, + weekly: null, + updatedAt: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + status: 'error' + }) + ) + if (signal.aborted) { + return + } + const latestCredentials = readDevinCredentials() + if (credentialFingerprint !== this.getDevinCredentialFingerprint(latestCredentials)) { + this.devinAuthConfigured = latestCredentials.status === 'ok' + this.updateState({ ...this.state, devin: null }) + return + } + this.trackActiveFailureStreak('devin', devin) + this.updateState({ + ...this.state, + devin: this.applyStalePolicy(devin, previousDevin) + }) + } } diff --git a/src/main/rate-limits/service/service-result-policy.ts b/src/main/rate-limits/service/service-result-policy.ts index db00053fdcf..3f6e32fad5f 100644 --- a/src/main/rate-limits/service/service-result-policy.ts +++ b/src/main/rate-limits/service/service-result-policy.ts @@ -92,6 +92,7 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch | 'minimax' | 'grok' | 'antigravity' + | 'devin' ): ProviderRateLimits { if (!current) { return { diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index c7e9aa4751a..c4e1ab5066f 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -21,6 +21,7 @@ import { DEFAULT_POLL_MS } from './service-types' import { readGrokAuthSession } from '../grok-auth' +import { readDevinCredentials, type DevinCredentialsReadResult } from '../devin-credentials' export abstract class RateLimitServiceState { protected state: InternalRateLimitState = { @@ -31,9 +32,26 @@ export abstract class RateLimitServiceState { kimi: null, antigravity: null, minimax: null, - grok: null + grok: null, + devin: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + protected devinAuthConfigured = readDevinCredentials().status === 'ok' + + protected devinSnapshotCredential: string | null = null + + protected getDevinCredentialFingerprint(result: DevinCredentialsReadResult): string { + return result.status === 'ok' + ? `ok\u0000${result.credentials.apiServerUrl}\u0000${result.credentials.sessionToken}` + : result.status + } + + protected previousDevinSnapshot(fingerprint: string): ProviderRateLimits | null { + const previous = this.devinSnapshotCredential === fingerprint ? this.state.devin : null + this.devinSnapshotCredential = fingerprint + return previous + } + protected devinOnlyFetchQueued = false protected pollInterval: number = DEFAULT_POLL_MS protected timer: ReturnType | null = null protected deferredStartupRefreshTimer: ReturnType | null = null @@ -46,7 +64,8 @@ export abstract class RateLimitServiceState { kimi: 0, minimax: 0, grok: 0, - antigravity: 0 + antigravity: 0, + devin: 0 } // Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result. protected activeFailureStreakByProvider: Record = { @@ -57,7 +76,8 @@ export abstract class RateLimitServiceState { kimi: 0, minimax: 0, grok: 0, - antigravity: 0 + antigravity: 0, + devin: 0 } protected mainWindow: BrowserWindow | null = null protected detachWindowListeners: (() => void) | null = null diff --git a/src/main/rate-limits/service/service-types.ts b/src/main/rate-limits/service/service-types.ts index 414fa9b8dfd..aeef0db86ef 100644 --- a/src/main/rate-limits/service/service-types.ts +++ b/src/main/rate-limits/service/service-types.ts @@ -83,7 +83,8 @@ export const MAX_ACTIVE_FAILURE_STREAK = 8 export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet = new Set([ 'claude', 'codex', - 'grok' + 'grok', + 'devin' ]) export const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes — after this, stale data is dropped // Why: usage-endpoint 429 windows can outlast the generic threshold (Retry-After ~1h); quota is informational, so a stale snapshot beats a bare "Limited". @@ -107,6 +108,7 @@ export type InternalRateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null + devin: ProviderRateLimits | null } export function normalizePollingInterval(ms: number): number { diff --git a/src/main/startup/main-process-observers.ts b/src/main/startup/main-process-observers.ts index 86f7992e10d..1ceb7243e7d 100644 --- a/src/main/startup/main-process-observers.ts +++ b/src/main/startup/main-process-observers.ts @@ -16,6 +16,7 @@ import { StatsCollector } from '../stats/collector' import { AgentSessionTransitionRecorder } from '../stats/agent-session-transition-recorder' import { ClaudeUsageStore } from '../claude-usage/store' import { CodexUsageStore } from '../codex-usage/store' +import { DevinUsageStore } from '../devin-usage/store' import { OpenCodeUsageStore } from '../opencode-usage/store' import { installRepoMaintenanceIdleGate } from '../repo-maintenance-idle-gate' import { mainProcessState as state } from './main-process-state' @@ -123,5 +124,6 @@ export function initializeMainProcessObservers(): void { agentHookServer.subscribePaneStatusClear((clear) => agentSessionRecorder.onCleared(clear)) state.claudeUsage = new ClaudeUsageStore(store) state.codexUsage = new CodexUsageStore(store) + state.devinUsage = new DevinUsageStore(store) state.openCodeUsage = new OpenCodeUsageStore(store) } diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 6fc05172764..7667a096592 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -71,6 +71,7 @@ import { initStatsPath } from '../stats/collector' import { initClaudeUsagePath } from '../claude-usage/store' import { initCodexUsagePath } from '../codex-usage/store' import { initOpenCodeUsagePath } from '../opencode-usage/store' +import { initDevinUsagePath } from '../devin-usage/store' import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol' import { startCrashpadCapture } from '../crash-reporting/crashpad-capture' import { CrashReportStore } from '../crash-reporting/crash-report-store' @@ -286,6 +287,7 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b initClaudeUsagePath() initCodexUsagePath() initOpenCodeUsagePath() + initDevinUsagePath() // Why: Electron freezes the privileged scheme table at ready, so the doc-preview // scheme must be declared here or its webview loses fetch/secure-origin privileges. registerDocPreviewSchemePrivileges() diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 89623a4f7f3..e64047fe5a3 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -206,6 +206,7 @@ function installWillQuitHandler(): void { const usageCacheFlush = Promise.all([ state.claudeUsage?.flush(), state.codexUsage?.flush(), + state.devinUsage?.flush(), state.openCodeUsage?.flush() ]).then(() => {}) const browserClientHostShutdown = shutdownPairedRuntimeBrowserClientHosts() diff --git a/src/main/startup/main-process-state.ts b/src/main/startup/main-process-state.ts index 2b9361a6d51..969ee98785c 100644 --- a/src/main/startup/main-process-state.ts +++ b/src/main/startup/main-process-state.ts @@ -4,6 +4,7 @@ import type { Store } from '../persistence' import type { StatsCollector } from '../stats/collector' import type { ClaudeUsageStore } from '../claude-usage/store' import type { CodexUsageStore } from '../codex-usage/store' +import type { DevinUsageStore } from '../devin-usage/store' import type { OpenCodeUsageStore } from '../opencode-usage/store' import type { CodexAccountService } from '../codex-accounts/service' import type { CodexRuntimeHomeService } from '../codex-accounts/runtime-home-service' @@ -54,6 +55,8 @@ export const mainProcessState = { stats: null as StatsCollector | null, claudeUsage: null as ClaudeUsageStore | null, codexUsage: null as CodexUsageStore | null, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Startup assigns the concrete store before usage handlers are registered. + devinUsage: null as DevinUsageStore | null, openCodeUsage: null as OpenCodeUsageStore | 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 8ea245fc256..56af3d0751f 100644 --- a/src/main/startup/main-window-controller.ts +++ b/src/main/startup/main-window-controller.ts @@ -56,6 +56,7 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {} stats: state.stats, claudeUsage: state.claudeUsage, codexUsage: state.codexUsage, + devinUsage: state.devinUsage, openCodeUsage: state.openCodeUsage, rateLimits: state.rateLimits, automations: state.automations, diff --git a/src/main/startup/main-window-core-services.ts b/src/main/startup/main-window-core-services.ts index d3ece383ee3..cf17ce25089 100644 --- a/src/main/startup/main-window-core-services.ts +++ b/src/main/startup/main-window-core-services.ts @@ -29,6 +29,7 @@ export function attachMainWindowCoreServices( const stats = state.stats const claudeUsage = state.claudeUsage const codexUsage = state.codexUsage + const devinUsage = state.devinUsage const openCodeUsage = state.openCodeUsage const codexAccounts = state.codexAccounts const claudeAccounts = state.claudeAccounts @@ -43,6 +44,7 @@ export function attachMainWindowCoreServices( !stats || !claudeUsage || !codexUsage || + !devinUsage || !openCodeUsage || !codexAccounts || !claudeAccounts || @@ -60,6 +62,7 @@ export function attachMainWindowCoreServices( stats, claudeUsage, codexUsage, + devinUsage, openCodeUsage, codexAccounts, claudeAccounts, diff --git a/src/main/startup/main-window-service-readiness.ts b/src/main/startup/main-window-service-readiness.ts index 2adfd612ba7..c1ac256c272 100644 --- a/src/main/startup/main-window-service-readiness.ts +++ b/src/main/startup/main-window-service-readiness.ts @@ -8,6 +8,7 @@ const MAIN_WINDOW_SERVICE_REQUIREMENTS = [ ['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'], + ['devinUsage', 'Devin usage store must be initialized before opening the main window'], ['openCodeUsage', 'OpenCode 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'], diff --git a/src/main/usage/usage-scan-worker-client.ts b/src/main/usage/usage-scan-worker-client.ts index 3423f22b864..f0d77df6521 100644 --- a/src/main/usage/usage-scan-worker-client.ts +++ b/src/main/usage/usage-scan-worker-client.ts @@ -10,6 +10,11 @@ import type { CodexUsagePersistedFile, CodexUsageSession } from '../codex-usage/types' +import type { + DevinUsageDailyAggregate, + DevinUsagePersistedFile, + DevinUsageSession +} from '../devin-usage/types' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedDatabase, @@ -150,6 +155,19 @@ export async function scanCodexUsageOnWorker( } return value } +export async function scanDevinUsageOnWorker( + scan: (body: UsageScanWorkerRequestBody) => Promise, + worktrees: UsageScanWorktreeRef[], + previous: DevinUsagePersistedFile[] +): Promise< + ProviderScanResult +> { + const value = await scan({ providerId: 'devin', worktrees, previous }) + if (value.providerId !== 'devin') { + throw wrongProvider('devin', value.providerId) + } + return value +} /** * Scan OpenCode usage databases on the shared worker. diff --git a/src/main/usage/usage-scan-worker-entry.ts b/src/main/usage/usage-scan-worker-entry.ts index 5835ba8d96e..c21c02f2c42 100644 --- a/src/main/usage/usage-scan-worker-entry.ts +++ b/src/main/usage/usage-scan-worker-entry.ts @@ -1,6 +1,7 @@ import { parentPort } from 'node:worker_threads' import { scanClaudeUsageFiles } from '../claude-usage/scanner' import { scanCodexUsageFiles } from '../codex-usage/scanner' +import { scanDevinUsageFiles } from '../devin-usage/scanner' import { scanOpenCodeUsageDatabases } from '../opencode-usage/scanner' import type { UsageScanWorkerProgress, @@ -79,6 +80,15 @@ async function runScan( dailyAggregates: result.dailyAggregates } } + case 'devin': { + const result = await scanDevinUsageFiles(request.worktrees, request.previous, onFilesScanned) + return { + providerId: 'devin', + source: result.processedFiles, + sessions: result.sessions, + dailyAggregates: result.dailyAggregates + } + } case 'opencode': { const result = await scanOpenCodeUsageDatabases( request.worktrees, diff --git a/src/main/usage/usage-scan-worker-protocol.ts b/src/main/usage/usage-scan-worker-protocol.ts index bbfd84977a2..9f9c7dc8d11 100644 --- a/src/main/usage/usage-scan-worker-protocol.ts +++ b/src/main/usage/usage-scan-worker-protocol.ts @@ -8,6 +8,11 @@ import type { CodexUsagePersistedFile, CodexUsageSession } from '../codex-usage/types' +import type { + DevinUsageDailyAggregate, + DevinUsagePersistedFile, + DevinUsageSession +} from '../devin-usage/types' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedDatabase, @@ -26,7 +31,7 @@ import type { UsageScanWorktreeRef } from './usage-provider-contract' * than `UsageProviderId`: a `plugin:` provider supplies its own scan function, * which is not in this bundle and cannot be named on the wire. */ -export type UsageScanWorkerProviderId = 'claude' | 'codex' | 'opencode' +export type UsageScanWorkerProviderId = 'claude' | 'codex' | 'devin' | 'opencode' /** Request body per provider; `previous` is that provider's own per-source cache. */ export type UsageScanWorkerRequestBody = @@ -36,6 +41,7 @@ export type UsageScanWorkerRequestBody = previous: ClaudeUsagePersistedFile[] } | { providerId: 'codex'; worktrees: UsageScanWorktreeRef[]; previous: CodexUsagePersistedFile[] } + | { providerId: 'devin'; worktrees: UsageScanWorktreeRef[]; previous: DevinUsagePersistedFile[] } | { providerId: 'opencode' worktrees: UsageScanWorktreeRef[] @@ -62,6 +68,12 @@ export type UsageScanWorkerValue = sessions: CodexUsageSession[] dailyAggregates: CodexUsageDailyAggregate[] } + | { + providerId: 'devin' + source: DevinUsagePersistedFile[] + sessions: DevinUsageSession[] + dailyAggregates: DevinUsageDailyAggregate[] + } | { providerId: 'opencode' source: OpenCodeUsagePersistedDatabase[] diff --git a/src/main/usage/usage-scan-worker-spawn.ts b/src/main/usage/usage-scan-worker-spawn.ts index bfcd18a8989..37b52cccac1 100644 --- a/src/main/usage/usage-scan-worker-spawn.ts +++ b/src/main/usage/usage-scan-worker-spawn.ts @@ -11,6 +11,11 @@ import type { CodexUsagePersistedFile, CodexUsageSession } from '../codex-usage/types' +import type { + DevinUsageDailyAggregate, + DevinUsagePersistedFile, + DevinUsageSession +} from '../devin-usage/types' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedDatabase, @@ -20,6 +25,7 @@ import type { UsageScanWorktreeRef } from './usage-provider-contract' import { scanClaudeUsageOnWorker, scanCodexUsageOnWorker, + scanDevinUsageOnWorker, scanOpenCodeUsageOnWorker, UsageScanWorkerClient } from './usage-scan-worker-client' @@ -101,6 +107,25 @@ export async function scanCodexUsageFilesViaWorker( dailyAggregates: value.dailyAggregates } } +export async function scanDevinUsageFilesViaWorker( + worktrees: UsageScanWorktreeRef[], + previous: DevinUsagePersistedFile[] = [] +): Promise<{ + processedFiles: DevinUsagePersistedFile[] + sessions: DevinUsageSession[] + dailyAggregates: DevinUsageDailyAggregate[] +}> { + const value = await scanDevinUsageOnWorker( + (body) => getSharedClient().scan(body), + worktrees, + previous + ) + return { + processedFiles: value.source, + sessions: value.sessions, + dailyAggregates: value.dailyAggregates + } +} /** * Scan OpenCode usage databases through the shared worker client. diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 75e849bf740..52ec5c45a49 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2,6 +2,7 @@ import type { ClaudeAccountsApi, CodexAccountsApi, CodexConfigSyncApi, + DevinAccountsApi, GrokAccountsApi, MinimaxCredentialsApi } from './api/agent-account-api' @@ -11,6 +12,7 @@ import type { AgentAwakeApi, AgentStatusApi, AgentTrustApi } from './api/agent-s import type { ClaudeUsageApi, CodexUsageApi, + DevinUsageApi, OpenCodeUsageApi, RateLimitsApi } from './api/agent-usage-api' @@ -101,6 +103,7 @@ export type PreloadApi = { keybindings: KeybindingsApi codexAccounts: CodexAccountsApi claudeAccounts: ClaudeAccountsApi + devinAccounts: DevinAccountsApi cli: CliApi codexConfigSync: CodexConfigSyncApi agentTrust: AgentTrustApi @@ -129,6 +132,7 @@ export type PreloadApi = { memory: MemoryApi claudeUsage: ClaudeUsageApi codexUsage: CodexUsageApi + devinUsage: DevinUsageApi openCodeUsage: OpenCodeUsageApi aiVault: AiVaultApi nativeChat: NativeChatApi @@ -151,7 +155,12 @@ export type PreloadApi = { speech: SpeechApi } -export type { ClaudeUsageApi, CodexUsageApi, OpenCodeUsageApi } from './api/agent-usage-api' +export type { + ClaudeUsageApi, + CodexUsageApi, + DevinUsageApi, + OpenCodeUsageApi +} from './api/agent-usage-api' export type { AiVaultApi } from './api/ai-vault-api' export type { AutomationsApi, ExternalAutomationManagerResult } from './api/automation-api' export type { AppApi } from './api/app-api' diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index f041e41f8b7..9aeb0d2edd6 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -3,7 +3,7 @@ import type { CodexRateLimitAccountsState } from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' -import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { DevinAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types' export type CodexAccountsApi = { list: () => Promise @@ -61,6 +61,9 @@ export type ClaudeAccountsApi = { export type GrokAccountsApi = { getStatus: () => Promise } +export type DevinAccountsApi = { + getStatus: () => Promise +} export type MinimaxCredentialsApi = { // Why: cookie + API key each live in their own safeStorage file, so the diff --git a/src/preload/api/agent-usage-api.ts b/src/preload/api/agent-usage-api.ts index 59322ad9bad..8db2a83ad67 100644 --- a/src/preload/api/agent-usage-api.ts +++ b/src/preload/api/agent-usage-api.ts @@ -1,5 +1,6 @@ import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../../shared/claude-usage-types' import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../../shared/codex-usage-types' +import type { DevinUsageBreakdownKind, DevinUsageSnapshot } from '../../shared/devin-usage-types' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageSnapshot @@ -41,6 +42,7 @@ export type UsageProviderApi export type CodexUsageApi = UsageProviderApi +export type DevinUsageApi = UsageProviderApi export type OpenCodeUsageApi = UsageProviderApi @@ -53,6 +55,7 @@ export type RateLimitsApi = { setPollingInterval: (ms: number) => Promise fetchInactiveClaudeAccounts: () => Promise fetchInactiveCodexAccounts: () => Promise + refreshDevin: () => Promise refreshMiniMax: () => Promise refreshGrok: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void diff --git a/src/preload/api/devin-accounts-bridge.ts b/src/preload/api/devin-accounts-bridge.ts new file mode 100644 index 00000000000..7f491bc8c8a --- /dev/null +++ b/src/preload/api/devin-accounts-bridge.ts @@ -0,0 +1,7 @@ +import { ipcRenderer } from 'electron' +import type { DevinAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' + +export const devinAccountsApi = { + getStatus: (): Promise => ipcRenderer.invoke('devinAccounts:getStatus') +} satisfies PreloadApi['devinAccounts'] diff --git a/src/preload/api/devin-usage-bridge.ts b/src/preload/api/devin-usage-bridge.ts new file mode 100644 index 00000000000..0b60687ed0f --- /dev/null +++ b/src/preload/api/devin-usage-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import { createUsageProviderApi } from '../usage-provider-api' +import type { PreloadApi } from '../api-types' + +export const devinUsageApi = createUsageProviderApi( + ipcRenderer, + 'devinUsage' +) satisfies PreloadApi['devinUsage'] diff --git a/src/preload/api/rate-limits-bridge.ts b/src/preload/api/rate-limits-bridge.ts index 37af13f0006..4a2bfd5769c 100644 --- a/src/preload/api/rate-limits-bridge.ts +++ b/src/preload/api/rate-limits-bridge.ts @@ -23,6 +23,7 @@ export const rateLimitsApi = { ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), + refreshDevin: (): Promise => ipcRenderer.invoke('rateLimits:refreshDevin'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) diff --git a/src/preload/index.ts b/src/preload/index.ts index f7bb5a30bcc..2cf735ba31f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -69,6 +69,7 @@ import { statsApi } from './api/stats-bridge' import { memoryApi } from './api/memory-bridge' import { claudeUsageApi } from './api/claude-usage-bridge' import { codexUsageApi } from './api/codex-usage-bridge' +import { devinUsageApi } from './api/devin-usage-bridge' import { openCodeUsageApi } from './api/open-code-usage-bridge' import { aiVaultApi } from './api/ai-vault-bridge' import { nativeChatApi } from './api/native-chat-bridge' @@ -77,6 +78,7 @@ import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' +import { devinAccountsApi } from './api/devin-accounts-bridge' import { sshApi } from './api/ssh-bridge' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -167,6 +169,7 @@ const api = { memory: memoryApi, claudeUsage: claudeUsageApi, codexUsage: codexUsageApi, + devinUsage: devinUsageApi, openCodeUsage: openCodeUsageApi, aiVault: aiVaultApi, nativeChat: nativeChatApi, @@ -175,6 +178,7 @@ const api = { rateLimits: rateLimitsApi, minimaxCredentials: minimaxCredentialsApi, grokAccounts: grokAccountsApi, + devinAccounts: devinAccountsApi, ssh: sshApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/preload/usage-provider-api.test.ts b/src/preload/usage-provider-api.test.ts index 0ce594cbb9f..a53e0de14ad 100644 --- a/src/preload/usage-provider-api.test.ts +++ b/src/preload/usage-provider-api.test.ts @@ -6,7 +6,7 @@ describe('usage provider preload API', () => { const invoke = vi.fn().mockResolvedValue(undefined) const query = { scope: 'orca' as const, range: '30d' as const } - for (const prefix of ['claudeUsage', 'codexUsage', 'openCodeUsage'] as const) { + for (const prefix of ['claudeUsage', 'codexUsage', 'devinUsage', 'openCodeUsage'] as const) { const api = createUsageProviderApi({ invoke } as never, prefix) await api.getScanState() await api.setEnabled({ enabled: true }) diff --git a/src/preload/usage-provider-api.ts b/src/preload/usage-provider-api.ts index b80d72850c0..b08d453fc39 100644 --- a/src/preload/usage-provider-api.ts +++ b/src/preload/usage-provider-api.ts @@ -1,7 +1,7 @@ import type { IpcRenderer } from 'electron' import type { PreloadApi } from './api-types' -type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage' +type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'devinUsage' | 'openCodeUsage' type UsageProviderApi = PreloadApi[UsageProviderApiKey] type UsageRangeArgs = { scope: string; range: string } diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 24de1094534..9920d98c73a 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -82,6 +82,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { return 'M' case 'grok': return 'R' + case 'devin': + return 'D' case 'codex': return 'X' } diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 59ea7104249..cc04ddbf2dd 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -18,8 +18,13 @@ export function getUsageProviderAccountsSectionId( return 'accounts-minimax' case 'grok': return 'accounts-grok' + case 'devin': + // Why: the Devin section is read-only status plus a refresh, like Grok's — + // the deep link is where the "run devin login" instructions live. + return 'accounts-devin' case 'kimi': - // Why: Orca must not mutate Kimi's CLI-owned credential lifecycle. + // Why: Orca must not mutate CLI-owned credential lifecycles; Kimi + // refreshes its own session file, so there is no accounts section. return null } } diff --git a/src/renderer/src/web/preload-api/web-rate-limits-api.ts b/src/renderer/src/web/preload-api/web-rate-limits-api.ts index d5fe7bc9080..15e22abffe7 100644 --- a/src/renderer/src/web/preload-api/web-rate-limits-api.ts +++ b/src/renderer/src/web/preload-api/web-rate-limits-api.ts @@ -16,6 +16,7 @@ export function createRateLimitsApi(): NonNullable['rateLimi fetchInactiveCodexAccounts: () => Promise.resolve(), refreshMiniMax: () => Promise.resolve(empty), refreshGrok: () => Promise.resolve(empty), + refreshDevin: () => Promise.resolve(empty), onUpdate: () => noopUnsubscribe } } diff --git a/src/shared/devin-usage-types.ts b/src/shared/devin-usage-types.ts new file mode 100644 index 00000000000..ca9bc682620 --- /dev/null +++ b/src/shared/devin-usage-types.ts @@ -0,0 +1,75 @@ +export type DevinUsageScope = 'orca' | 'all' +export type DevinUsageRange = '7d' | '30d' | '90d' | 'all' +export type DevinUsageBreakdownKind = 'model' | 'project' + +export type DevinUsageScanState = { + enabled: boolean + isScanning: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null + hasAnyDevinData: boolean +} + +export type DevinUsageSummary = { + scope: DevinUsageScope + range: DevinUsageRange + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: null + topModel: string | null + topProject: string | null + hasAnyDevinData: boolean +} + +export type DevinUsageDailyPoint = { + day: string + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} + +export type DevinUsageBreakdownRow = { + key: string + label: string + sessions: number + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + estimatedCostUsd: null + hasInferredPricing: false +} + +export type DevinUsageSessionRow = { + sessionId: string + lastActiveAt: string + durationMinutes: number + projectLabel: string + model: string | null + events: number + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number + hasInferredPricing: false +} + +export type DevinUsageSnapshot = { + scanState: DevinUsageScanState + summary: DevinUsageSummary + daily: DevinUsageDailyPoint[] + modelBreakdown: DevinUsageBreakdownRow[] + projectBreakdown: DevinUsageBreakdownRow[] + recentSessions: DevinUsageSessionRow[] +} diff --git a/src/shared/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts index db5bd7ac377..84d29eaacbc 100644 --- a/src/shared/persisted-ui-state-types.ts +++ b/src/shared/persisted-ui-state-types.ts @@ -118,6 +118,8 @@ export type PersistedUIState = { _antigravityStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Grok status item. */ _grokStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Devin status item. */ + _devinStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] statusBarVisible: boolean /** Why: this is client-side presentation, not a provider/account or execution-host setting. */ diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index bf8d97e541e..47fc11ba06b 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -11,9 +11,11 @@ export function createEmptyRateLimitState(overrides: Partial = { antigravity: null, minimax: null, grok: null, + devin: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, grokAuthConfigured: false, + devinAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index f11dc638d41..63524689b89 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -16,9 +16,11 @@ describe('RateLimitState', () => { antigravity: null, minimax: null, grok: null, + devin: null, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, grokAuthConfigured: false, + devinAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5744e3e6749..0563be7bdb6 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -55,6 +55,7 @@ export type ProviderRateLimits = { | 'minimax' | 'grok' | 'antigravity' + | 'devin' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -114,6 +115,13 @@ export type GrokAccountStatus = { tokenFresh: boolean error: string | null } +export type DevinAccountStatus = { + signedIn: boolean + email: string | null + tokenFresh: boolean + plan: string | null + error: string | null +} export type RateLimitState = { claude: ProviderRateLimits | null @@ -124,6 +132,7 @@ export type RateLimitState = { antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null + devin: ProviderRateLimits | null /** * True when a MiniMax session cookie is persisted on disk. The cookie lives * outside GlobalSettings, so this flag is the durable signal that the @@ -140,6 +149,8 @@ export type RateLimitState = { minimaxApiKeyConfigured: boolean /** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */ grokAuthConfigured: boolean + /** True when main finds a Devin CLI credentials file (credentials.toml under DEVIN_HOME). */ + devinAuthConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] diff --git a/src/shared/rpc-contract/client-ui-params.ts b/src/shared/rpc-contract/client-ui-params.ts index df84a06a256..0195577b260 100644 --- a/src/shared/rpc-contract/client-ui-params.ts +++ b/src/shared/rpc-contract/client-ui-params.ts @@ -69,6 +69,7 @@ export const StatusBarItem = z.enum([ 'kimi', 'minimax', 'grok', + 'devin', 'ssh', 'resource-usage', 'ports' @@ -182,6 +183,7 @@ export const UiUpdateFields = z _minimaxStatusBarDefaultAdded: z.boolean().optional(), _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), + _devinStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), statusBarUsageMode: z.enum(['verbose', 'compact']).optional(), diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 73d72a70ab2..d653c5f6625 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -9,6 +9,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'kimi', 'minimax', 'grok', + 'devin', 'ssh', 'resource-usage', 'ports' diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts index fe6157d870b..78bb8b7f0c1 100644 --- a/src/shared/ui-chrome-types.ts +++ b/src/shared/ui-chrome-types.ts @@ -62,6 +62,7 @@ export type StatusBarItem = | 'kimi' | 'minimax' | 'grok' + | 'devin' | 'ssh' | 'resource-usage' | 'ports'