Ctrl K
ComunidadGuías…

src/dashboard/devin-panel.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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(),
  };
}