mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(usage): add Devin tracking backend
Backend Devin tracking implementation. Co-authored-by: Eddie Jaoude <eddie@jaoudestudios.com>
This commit is contained in:
@@ -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')
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 <DEVIN_HOME>/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 =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string[]> {
|
||||
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<DevinUsageProcessedFile> {
|
||||
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<SessionSidecarObservation> {
|
||||
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'
|
||||
}
|
||||
@@ -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<string, Partial<DevinSessionIndexRow>>): 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 })
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> | 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 }
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, number>()
|
||||
const byProject = new Map<string, number>()
|
||||
const projectLabels = new Map<string, string>()
|
||||
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<string, DevinUsageDailyPoint>()
|
||||
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<string, DevinUsageBreakdownRow>()
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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<string> {
|
||||
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<string, unknown>
|
||||
|
||||
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 <session>.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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<string, DevinUsagePersistedFile>()
|
||||
|
||||
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<string, DevinUsageSession>()
|
||||
const daily = new Map<string, DevinUsageDailyAggregate>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<Store, 'getRepos' | 'getAllWorktreeMeta'>) {
|
||||
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<DevinUsageSummary> {
|
||||
await this.refresh(false)
|
||||
return buildDevinSummary(this.state, scope, range)
|
||||
}
|
||||
|
||||
async getDaily(scope: DevinUsageScope, range: DevinUsageRange): Promise<DevinUsageDailyPoint[]> {
|
||||
await this.refresh(false)
|
||||
return buildDevinDaily(this.state, scope, range)
|
||||
}
|
||||
|
||||
async getBreakdown(
|
||||
scope: DevinUsageScope,
|
||||
range: DevinUsageRange,
|
||||
kind: DevinUsageBreakdownKind
|
||||
): Promise<DevinUsageBreakdownRow[]> {
|
||||
await this.refresh(false)
|
||||
return buildDevinBreakdown(this.state, scope, range, kind)
|
||||
}
|
||||
|
||||
async getRecentSessions(
|
||||
scope: DevinUsageScope,
|
||||
range: DevinUsageRange,
|
||||
limit = 12
|
||||
): Promise<DevinUsageSessionRow[]> {
|
||||
await this.refresh(false)
|
||||
return buildDevinRecentSessions(this.state, scope, range, limit)
|
||||
}
|
||||
}
|
||||
@@ -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<DevinUsageMetric>
|
||||
export type DevinUsageDailyAggregate = UsageDailyAggregate<DevinUsageMetric>
|
||||
export type DevinUsageLocationBreakdown = UsageLocationBreakdown<DevinUsageMetric>
|
||||
export type DevinUsageModelBreakdown = UsageModelBreakdown<DevinUsageMetric>
|
||||
export type DevinUsageLocationModelBreakdown = UsageLocationModelBreakdown<DevinUsageMetric>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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<string, DevinSessionIndexRow>
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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<typeof vi.fn>
|
||||
refreshGrok: ReturnType<typeof vi.fn>
|
||||
consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<Scope, Range, BreakdownKind>(
|
||||
export function registerUsageProviderHandlers(stores: UsageProviderStores): void {
|
||||
registerProviderHandlers('claudeUsage', stores.claudeUsage)
|
||||
registerProviderHandlers('codexUsage', stores.codexUsage)
|
||||
registerProviderHandlers('devinUsage', stores.devinUsage)
|
||||
registerProviderHandlers('openCodeUsage', stores.openCodeUsage)
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<ProviderRateLimits> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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.<digest>.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)
|
||||
}
|
||||
}
|
||||
@@ -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<RateLimitService['attach']>[0]
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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<ProviderRateLimits>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
@@ -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<ProviderRateLimits>()
|
||||
@@ -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<ProviderRateLimits>()
|
||||
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<ProviderRateLimits>()
|
||||
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<ProviderRateLimits>()
|
||||
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<ProviderRateLimits>()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -28,6 +28,10 @@ export abstract class RateLimitServiceAccountRefresh extends RateLimitServiceIna
|
||||
await this.fetchGrokOnly({ force: true })
|
||||
return this.getState()
|
||||
}
|
||||
async refreshDevin(): Promise<RateLimitState> {
|
||||
await this.fetchDevinOnly({ force: true })
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
invalidateMiniMaxCredentialState(): void {
|
||||
this.minimaxFetchGeneration += 1
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<void> {
|
||||
protected fetchAll(options?: { force?: boolean }): Promise<void> {
|
||||
return this.fetchProvider('all', options?.force ?? false)
|
||||
}
|
||||
|
||||
protected fetchCodexOnly(options?: { force?: boolean }): Promise<void> {
|
||||
return this.fetchProvider('codex', options?.force ?? false)
|
||||
}
|
||||
|
||||
protected fetchClaudeOnly(options?: { force?: boolean }): Promise<void> {
|
||||
return this.fetchProvider('claude', options?.force ?? false)
|
||||
}
|
||||
|
||||
protected fetchGrokOnly(options?: { force?: boolean }): Promise<void> {
|
||||
return this.fetchProvider('grok', options?.force ?? false)
|
||||
}
|
||||
|
||||
protected fetchDevinOnly(options?: { force?: boolean }): Promise<void> {
|
||||
return this.fetchProvider('devin', options?.force ?? false)
|
||||
}
|
||||
|
||||
private async fetchProvider(kind: FetchKind, force: boolean): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export abstract class RateLimitServiceResultPolicy extends RateLimitServiceFetch
|
||||
| 'minimax'
|
||||
| 'grok'
|
||||
| 'antigravity'
|
||||
| 'devin'
|
||||
): ProviderRateLimits {
|
||||
if (!current) {
|
||||
return {
|
||||
|
||||
@@ -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<typeof setInterval> | null = null
|
||||
protected deferredStartupRefreshTimer: ReturnType<typeof setTimeout> | 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<ActiveRateLimitProvider, number> = {
|
||||
@@ -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
|
||||
|
||||
@@ -83,7 +83,8 @@ export const MAX_ACTIVE_FAILURE_STREAK = 8
|
||||
export const INDIVIDUALLY_REFRESHABLE_PROVIDERS: ReadonlySet<ActiveRateLimitProvider> = 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<UsageScanWorkerValue>,
|
||||
worktrees: UsageScanWorktreeRef[],
|
||||
previous: DevinUsagePersistedFile[]
|
||||
): Promise<
|
||||
ProviderScanResult<DevinUsagePersistedFile, DevinUsageSession, DevinUsageDailyAggregate>
|
||||
> {
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<CodexRateLimitAccountsState>
|
||||
@@ -61,6 +61,9 @@ export type ClaudeAccountsApi = {
|
||||
export type GrokAccountsApi = {
|
||||
getStatus: () => Promise<GrokAccountStatus>
|
||||
}
|
||||
export type DevinAccountsApi = {
|
||||
getStatus: () => Promise<DevinAccountStatus>
|
||||
}
|
||||
|
||||
export type MinimaxCredentialsApi = {
|
||||
// Why: cookie + API key each live in their own safeStorage file, so the
|
||||
|
||||
@@ -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<Snapshot extends UsageProviderSnapshot, BreakdownKi
|
||||
export type ClaudeUsageApi = UsageProviderApi<ClaudeUsageSnapshot, ClaudeUsageBreakdownKind>
|
||||
|
||||
export type CodexUsageApi = UsageProviderApi<CodexUsageSnapshot, CodexUsageBreakdownKind>
|
||||
export type DevinUsageApi = UsageProviderApi<DevinUsageSnapshot, DevinUsageBreakdownKind>
|
||||
|
||||
export type OpenCodeUsageApi = UsageProviderApi<OpenCodeUsageSnapshot, OpenCodeUsageBreakdownKind>
|
||||
|
||||
@@ -53,6 +55,7 @@ export type RateLimitsApi = {
|
||||
setPollingInterval: (ms: number) => Promise<void>
|
||||
fetchInactiveClaudeAccounts: () => Promise<void>
|
||||
fetchInactiveCodexAccounts: () => Promise<void>
|
||||
refreshDevin: () => Promise<RateLimitState>
|
||||
refreshMiniMax: () => Promise<RateLimitState>
|
||||
refreshGrok: () => Promise<RateLimitState>
|
||||
onUpdate: (callback: (state: RateLimitState) => void) => () => void
|
||||
|
||||
@@ -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<DevinAccountStatus> => ipcRenderer.invoke('devinAccounts:getStatus')
|
||||
} satisfies PreloadApi['devinAccounts']
|
||||
@@ -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']
|
||||
@@ -23,6 +23,7 @@ export const rateLimitsApi = {
|
||||
ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'),
|
||||
refreshMiniMax: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshMiniMax'),
|
||||
refreshGrok: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshGrok'),
|
||||
refreshDevin: (): Promise<RateLimitState> => ipcRenderer.invoke('rateLimits:refreshDevin'),
|
||||
onUpdate: (callback: (state: RateLimitState) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state)
|
||||
ipcRenderer.on('rateLimits:update', listener)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string {
|
||||
return 'M'
|
||||
case 'grok':
|
||||
return 'R'
|
||||
case 'devin':
|
||||
return 'D'
|
||||
case 'codex':
|
||||
return 'X'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createRateLimitsApi(): NonNullable<Partial<PreloadApi>['rateLimi
|
||||
fetchInactiveCodexAccounts: () => Promise.resolve(),
|
||||
refreshMiniMax: () => Promise.resolve(empty),
|
||||
refreshGrok: () => Promise.resolve(empty),
|
||||
refreshDevin: () => Promise.resolve(empty),
|
||||
onUpdate: () => noopUnsubscribe
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -11,9 +11,11 @@ export function createEmptyRateLimitState(overrides: Partial<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: [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -9,6 +9,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
|
||||
'kimi',
|
||||
'minimax',
|
||||
'grok',
|
||||
'devin',
|
||||
'ssh',
|
||||
'resource-usage',
|
||||
'ports'
|
||||
|
||||
@@ -62,6 +62,7 @@ export type StatusBarItem =
|
||||
| 'kimi'
|
||||
| 'minimax'
|
||||
| 'grok'
|
||||
| 'devin'
|
||||
| 'ssh'
|
||||
| 'resource-usage'
|
||||
| 'ports'
|
||||
|
||||
Reference in New Issue
Block a user