/** * DevIn panel — one truthful snapshot of the Devin surface the proxy can see. * * WHY: the Devin state is spread across four pages and two layers. The switches * live in Settings, LS/detach health on Overview, ACU on Usage, pricing in Model * Control, and per-account turns only inside the account drawer. During an * incident nobody can answer "what is Devin doing right now" in one screen. * * WHAT THIS MODULE DELIBERATELY DOES NOT DO: * - No upstream call. Every field is in-process state (config switches, the LS * pool, the detached registry, the per-account billed-turn rings) or a * cached upstream-derived summary (drought). The route is safe to open on a * cold pool and costs no quota. * - No invented sessions. Cascade trajectories live behind local-LS gRPC * (`GetAllCascadeTrajectories` / `GetCascadeTranscriptForTrajectoryId`) and * ACP runs are not registered anywhere, so both are reported as * `available:false` WITH a machine-readable reason. An empty array would * read as "no sessions", which is a different and false claim. * * The builders are pure: the route gathers inputs (auth/langserver/runtime * config) and passes them in, so every shape below is executable in a test * without a server, a token or a clock. */ /** Rows the panel renders for the fleet turn list (merged, newest first). */ export const TURN_ROW_CAP = 50; /** Per-account ring read. ACCOUNT_REQUEST_CAP is 100; read the whole ring so * the last-hour tally is complete for the retained window, then slice for display. */ export const TURN_PER_ACCOUNT_LIMIT = 100; /** "Recent" = the trailing hour, matching the connect-metrics copy. */ export const TURN_WINDOW_MS = 60 * 60 * 1000; /** Accounts scanned per request. Sorted by lastUsed first, so the cap only * drops accounts that have never served a turn. */ export const MAX_SCAN_ACCOUNTS = 500; /** Effective-value rows the runtime card shows (same whitelist as * GET /runtime-env-status — the credential-store switches stay read-only). */ export const RUNTIME_SWITCH_KEYS = Object.freeze([ 'devinConnect', 'devinOnly', 'devinCliMode', 'allowClientTools', 'loginHostFallback', 'autoRelogin', ]); /** Quota/spend knobs from GET /quota-config (the only writer is PUT /quota-config). */ export const QUOTA_KNOB_KEYS = Object.freeze([ 'cooldownEnabled', 'cooldownMs', 'dryThreshold', 'spendOnDemand', 'onDemandReserveUsd', ]); /** Machine-readable reasons a Devin source has no rows to show. The UI maps * these to copy; it must never render them as zeros. */ export const SOURCE_REASONS = Object.freeze({ /** Reachable only through the local language server's binary gRPC surface. */ LS_ONLY: 'ls_only', /** Runs exist but no registry/counter keeps them after the turn ends. */ NOT_TRACKED: 'not_tracked', /** Billed successes only: aborts/429s never reach spend accounting. */ BILLED_ONLY: 'billed_successes_only', /** Process-local ring: a restart is a new window. */ PROCESS_LOCAL: 'process_local', }); const MAX_TEXT = 160; function num(value) { return typeof value === 'number' && Number.isFinite(value) ? value : null; } function str(value, max = MAX_TEXT) { if (typeof value !== 'string') return null; const t = value.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ').trim(); return t ? t.slice(0, max) : null; } function obj(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : null; } function knobRow(key, tunables, overrides) { const override = overrides && Object.prototype.hasOwnProperty.call(overrides, key) ? overrides[key] : null; return { key, value: tunables && Object.prototype.hasOwnProperty.call(tunables, key) ? tunables[key] : null, override, // Same vocabulary as /runtime-env-status: "env" = no runtime-config // override (the effective value may still be a code default). source: override === null ? 'env' : 'config', overridden: override !== null, }; } // ─── 1. Runtime ──────────────────────────────────────────────────────────── /** * Devin runtime: switches, credential-store boundary, LS pool, detach cache. * * @param {object} input * @param {number|null} [input.uptimeSec] * @param {number|null} [input.startedAt] * @param {object} [input.switches] key → {value, override, source} * @param {boolean} [input.credStoreEnabled] * @param {boolean} [input.remoteCredStore] * @param {object|null} [input.langServer] getLsStatus() * @param {object|null} [input.detach] {enabled, ...registry.stats()} */ export function buildRuntimeSection({ uptimeSec = null, startedAt = null, switches = {}, credStoreEnabled = false, remoteCredStore = false, langServer = null, detach = null, } = {}) { const rows = RUNTIME_SWITCH_KEYS.map((key) => { const row = obj(switches[key]) || {}; return { key, value: row.value ?? null, source: row.source === 'config' ? 'config' : 'env', overridden: row.override !== null && row.override !== undefined, }; }); const instances = Array.isArray(langServer?.instances) ? langServer.instances : []; const activeRequests = instances.reduce((n, i) => n + (num(i?.activeRequests) || 0), 0); const admission = obj(langServer?.admissionStats); const detachStats = obj(detach); const counters = detachStats ? { ...detachStats } : null; if (counters) { delete counters.enabled; delete counters.entries; delete counters.bytes; delete counters.draining; } return { uptimeSec: num(uptimeSec), startedAt: num(startedAt), switches: rows, credentials: { // The two credential-store switches are an env-only security boundary: // reported as presence booleans, never writable through the dashboard. credStoreEnabled: !!credStoreEnabled, remoteCredStore: !!remoteCredStore, writable: false, }, langServer: { running: !!langServer?.running, pid: num(langServer?.pid), port: num(langServer?.port), instances: instances.length, ready: instances.filter((i) => i?.ready).length, activeRequests, totalRssBytes: num(langServer?.totalRssBytes), admission: admission ? { startAttempts: num(admission.startAttempts), startSuccesses: num(admission.startSuccesses), startFailures: num(admission.startFailures), poolWaits: num(admission.poolWaits), memoryWaits: num(admission.memoryWaits), poolExhausted: num(admission.poolExhausted), memoryGuardBlocks: num(admission.memoryGuardBlocks), evictions: num(admission.evictions), lastFailure: admission.lastFailure ? { kind: str(admission.lastFailure.kind, 40), errorType: str(admission.lastFailure.errorType, 60), at: num(admission.lastFailure.at), } : null, } : null, }, detach: detachStats ? { enabled: detachStats.enabled === true, entries: num(detachStats.entries), bytes: num(detachStats.bytes), draining: detachStats.draining === true, counters, } : { enabled: false, entries: null, bytes: null, draining: null, counters: null }, }; } // ─── 2. Sessions / trajectories ──────────────────────────────────────────── /** * Merge the per-account billed-turn rings into one fleet view. * * `accounts` is `[{id, ref, lastUsed}]` (ref = the dashboard hash, never the raw * account id or email). `readRequests(id, {limit})` is injected so the builder * stays pure; the route passes `getAccountRequests`. `totalAccounts` is the * pool size before the route's scan cap; when it exceeds the rows handed in, * the section reports `capped:true` so the UI can say "N / M" instead of * implying the whole pool was read. * * Totals come from each ring's own `summary` (whole retained window), while the * last-hour tally is computed over the rows we actually read. Reading the full * ring per account (TURN_PER_ACCOUNT_LIMIT === ACCOUNT_REQUEST_CAP) is what * makes that tally complete rather than a sample of the newest rows. */ export function buildTurnsSection({ accounts = [], totalAccounts = null, readRequests = null, now = Date.now(), rowCap = TURN_ROW_CAP, perAccount = TURN_PER_ACCOUNT_LIMIT, windowMs = TURN_WINDOW_MS, } = {}) { const items = []; let retained = 0; let accountsWithTurns = 0; let promptTokens = 0; let completionTokens = 0; let totalTokens = 0; let credit = 0; let acu = 0; let lastTs = 0; const window = { ms: windowMs, turns: 0, totalTokens: 0, credit: 0, acu: 0 }; const cutoff = now - windowMs; const scan = Array.isArray(accounts) ? accounts : []; for (const acct of scan) { if (!acct || !acct.id || typeof readRequests !== 'function') continue; let page = null; try { page = readRequests(acct.id, { limit: perAccount }); } catch { // A broken ring must not fail the whole panel; the account simply // contributes no rows and stays counted in `scanned`. continue; } if (!page) continue; const sum = obj(page.summary) || {}; const ringTotal = num(sum.requests) || 0; if (ringTotal > 0) { accountsWithTurns++; retained += ringTotal; promptTokens += num(sum.promptTokens) || 0; completionTokens += num(sum.completionTokens) || 0; totalTokens += num(sum.totalTokens) || 0; credit += num(sum.credit) || 0; acu += num(sum.acu) || 0; lastTs = Math.max(lastTs, num(sum.lastTs) || 0); } const rows = Array.isArray(page.items) ? page.items : []; for (const row of rows) { if (!row || !Number.isFinite(row.ts)) continue; // This section is "billed turns": the shared ring also retains failed // attempts (billed:false), which carry zero cost and must not inflate the // last-hour turn count or the rendered turn table. if (row.billed === false) continue; const entry = { ts: row.ts, accountRef: str(acct.ref, 40), model: str(row.model, 120), promptTokens: num(row.promptTokens), completionTokens: num(row.completionTokens), totalTokens: num(row.totalTokens), credit: num(row.credit), acu: num(row.acu), }; items.push(entry); if (row.ts >= cutoff) { window.turns++; window.totalTokens += entry.totalTokens || 0; window.credit += entry.credit || 0; window.acu += entry.acu || 0; } } } items.sort((a, b) => b.ts - a.ts); const scanned = scan.length; const total = num(totalAccounts); return { available: true, scanned, // The scan is bounded. Reporting only `scanned` would let a 500-account // cap read as "the whole pool was checked", so the unfiltered count and a // flag ride along; the UI says "N / M" and names the cap when it bites. totalAccounts: total == null ? scanned : total, capped: total != null && total > scanned, accountsWithTurns, retained, summary: { promptTokens, completionTokens, totalTokens, credit, acu, lastTs: lastTs || null }, window, shown: Math.min(items.length, Math.max(0, Number(rowCap) || 0)), items: items.slice(0, Math.max(0, Number(rowCap) || 0)), }; } /** * The session source table. `proxyTurns` is the only available source; the * other two are named so the panel can say WHY, not just show nothing. */ export function buildSessionSources() { return [ { id: 'proxyTurns', available: true, scope: 'process', notes: [SOURCE_REASONS.BILLED_ONLY, SOURCE_REASONS.PROCESS_LOCAL], }, { id: 'cascadeTrajectories', available: false, reason: SOURCE_REASONS.LS_ONLY, rpcs: [ 'LanguageServerService/GetAllCascadeTrajectories', 'LanguageServerService/GetCascadeTranscriptForTrajectoryId', 'LanguageServerService/GetCascadeTrajectorySteps', ], }, { id: 'acpSessions', available: false, reason: SOURCE_REASONS.NOT_TRACKED, }, ]; } /** Compose the sessions section: real turns + the source table. */ export function buildSessionsSection({ turns = null, sources = buildSessionSources() } = {}) { return { sources, turns: turns || buildTurnsSection(), }; } // ─── 3. Quota & spend policy ─────────────────────────────────────────────── /** * Quota snapshot: the drought decision and its inputs (all cached/local), plus * the quota/spend knobs the settings page does not surface. */ export function buildQuotaSection({ drought = null, tunables = null, overrides = null } = {}) { const d = obj(drought) || {}; return { drought: { active: d.drought === true, threshold: num(d.threshold), activeAccounts: num(d.activeAccounts), knownAccounts: num(d.knownAccounts), quotaKnownAccounts: num(d.quotaKnownAccounts), droughtAccounts: num(d.droughtAccounts), coverageMet: d.coverageMet === true, lowestWeeklyPercent: num(d.lowestWeeklyPercent), lowestDailyPercent: num(d.lowestDailyPercent), restrictEnabled: d.restrictEnabled === true, restrictionFailOpen: d.restrictionFailOpen === true, freeTierModels: Array.isArray(d.freeTierModels) ? d.freeTierModels.map((m) => str(m, 120)).filter(Boolean).slice(0, 40) : [], }, knobs: QUOTA_KNOB_KEYS.map((key) => knobRow(key, tunables, overrides)), }; } // ─── Aggregate ───────────────────────────────────────────────────────────── /** * Assemble the panel payload from already-built sections. Keeping this a thin * wrapper (instead of one mega-builder) lets each section be tested alone. */ export function buildDevinPanel({ now = Date.now(), runtime = null, sessions = null, quota = null } = {}) { return { ok: true, generatedAt: now, runtime: runtime || buildRuntimeSection(), sessions: sessions || buildSessionsSection(), quota: quota || buildQuotaSection(), }; }