Ctrl K
ComunidadGuías…

src/dashboard/acu-usage.js

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

Archivo original ↗Preguntar a KiBot
/**
 * Org-scoped ACU & usage reader (Q3 / REPORT C1).
 *
 * `app.devin.ai/{org}/billing/*` is ORGANIZATION-scoped: every account that
 * belongs to the same org sees identical quota, limits and daily usage. The
 * dashboard therefore resolves each account's org first and reads the ledger
 * ONCE per org — never once per account, and never sums across accounts that
 * share an org (that would multiply the same ACUs by the member count).
 *
 * The raw org id never leaves this module: callers receive a stable
 * `orgref-<hash>` and per-account refs. Tokens, emails and raw upstream bodies
 * are not part of the payload.
 *
 * Read-only: every upstream call goes through `readDevinJson` (GET only).
 */

import { getAccountInternal, getAccountList } from '../auth.js';
import { accountRef } from '../log-safety.js';
import { orgRefOf, rememberAccountOrg } from '../org-identity.js';
import {
  ReadError,
  httpStatusForKind,
  resolveOrgId,
  worstError,
} from './devin-read.js';
import { readQuotaBurnDown } from './quota-burn-down.js';

/** Upper bound on membership resolutions per panel load. */
export const MAX_ACCOUNTS = 40;
/** Membership resolution concurrency (one GET per account on a cold cache). */
export const RESOLVE_CONCURRENCY = 4;
/** Org reads concurrency (each org read is four GETs). */
export const ORG_CONCURRENCY = 2;

const SESSION_TOKEN_PREFIX = 'devin-session-token$';

function num(value) {
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

/**
 * Hash the org id. The browser gets a stable, non-reversible handle so it can
 * dedupe/select orgs without receiving the upstream identifier.
 *
 * Re-exported from `org-identity.js` so the ACU panel and the pool-balance KPI
 * name the same org with the same handle (one hashing convention, not two).
 */
export { orgRefOf };

/**
 * Build the three ACU cells the panel renders.
 *
 * Truth rules:
 * - `max_acu_limit: null` is "unlimited" on the wire, never zero.
 * - an absent `cycle.total` is unknown (`—`), never a fabricated zero.
 * - `remaining` is DERIVED (limit − used) and says so; it is not an
 *   upstream-reported balance. Unlimited limit ⇒ unlimited remaining.
 * - `overageCredits` is a SIGNED USD balance. A negative value is an overdrawn
 *   on-demand balance (a real debt state, not a rounding error), so it is
 *   passed through unchanged and the panel renders the sign rather than
 *   clamping it to zero.
 */
export function deriveAcuCells({ limits, cycle } = {}) {
  const rawLimit = limits?.maxAcuLimit;
  const rawTotal = cycle?.total;
  const unlimited = rawLimit === null;

  const acuLimit = {
    value: num(rawLimit),
    known: num(rawLimit) !== null,
    unlimited,
    reason: num(rawLimit) !== null ? 'reported' : (unlimited ? 'unlimited' : 'absent_on_wire'),
  };
  const used = {
    value: num(rawTotal),
    known: num(rawTotal) !== null,
    reason: num(rawTotal) !== null ? 'reported' : 'absent_on_wire',
  };

  let remaining;
  if (unlimited) {
    remaining = { value: null, known: false, unlimited: true, derived: false, reason: 'unlimited' };
  } else if (!acuLimit.known) {
    remaining = { value: null, known: false, unlimited: false, derived: false, reason: 'limit_absent' };
  } else if (!used.known) {
    remaining = { value: null, known: false, unlimited: false, derived: false, reason: 'usage_absent' };
  } else {
    remaining = {
      value: Math.max(0, acuLimit.value - used.value),
      known: true,
      unlimited: false,
      derived: true,
      over: used.value > acuLimit.value,
      reason: 'derived',
    };
  }

  return { acuLimit, used, remaining };
}

/** Latest reported day, or null when the upstream returned no rows. */
export function latestDayOf(days) {
  if (!Array.isArray(days) || days.length === 0) return null;
  let latest = null;
  for (const day of days) {
    if (!day || typeof day.date !== 'string') continue;
    if (!latest || day.date > latest.date) latest = day;
  }
  if (!latest) return null;
  return {
    date: latest.date,
    amount: num(latest.amount),
    cumulative: num(latest.cumulative),
  };
}

/** Run `fn` over `items` with a bounded number of concurrent workers. */
async function mapLimit(items, limit, fn) {
  const out = new Array(items.length);
  let next = 0;
  const width = Math.max(1, Math.min(limit, items.length));
  const workers = Array.from({ length: width }, async () => {
    while (true) {
      const index = next++;
      if (index >= items.length) return;
      out[index] = await fn(items[index], index);
    }
  });
  await Promise.all(workers);
  return out;
}

/**
 * Candidate accounts for an org-scoped read.
 *
 * Only `devin-session-token$…` credentials are tried: app.devin.ai's REST
 * surface rejects Windsurf API keys, and probing every pool account would turn
 * one panel open into N guaranteed 401s. When no such account exists, the
 * deploy-level Devin token is used (the same fallback as `/usage`).
 */
function collectCandidates(maxAccounts) {
  const out = [];
  const seenTokens = new Set();
  for (const summary of getAccountList({ view: 'summary' })) {
    if (summary.status !== 'active') continue;
    const account = getAccountInternal(summary.id);
    const token = String(account?.apiKey || '').trim();
    if (!token || !token.startsWith(SESSION_TOKEN_PREFIX)) continue;
    if (seenTokens.has(token)) continue;
    seenTokens.add(token);
    out.push({ id: account.id, ref: accountRef(account.id), token });
  }

  if (out.length === 0) {
    const envToken = String(
      process.env.DEVIN_CONNECT_TOKEN
      || process.env.DEVIN_BACKEND_TOKEN
      || process.env.WINDSURF_API_KEY
      || '',
    ).trim();
    if (envToken) out.push({ id: 'env-token', ref: accountRef('env-token'), token: envToken });
  }

  const truncated = out.length > maxAccounts;
  return { items: out.slice(0, Math.max(0, maxAccounts)), truncated };
}

/**
 * Read one org with its representative account, falling back to another member
 * only when the failure is credential-shaped. A 403 / unresolved org is
 * org-wide: retrying a sibling cannot change the answer.
 */
async function readOrgWithFallback(group, { force, fetchImpl, now }) {
  let last = null;
  for (const member of group.accounts) {
    const token = String(member.token || getAccountInternal(member.id)?.apiKey || '').trim();
    if (!token) {
      last = { ok: false, error: 'no_account', httpStatus: 503 };
      continue;
    }
    const provided = { ok: true, accountId: member.id, token, accountRef: member.ref };
    last = await readQuotaBurnDown({
      account: provided,
      orgId: group.orgId,
      force,
      fetchImpl,
      now,
    });
    if (last.ok) return last;
    if (last.error === 'forbidden' || last.error === 'org_unresolved' || last.error === 'not_found') break;
  }
  return last;
}

/**
 * Read the ACU ledger for every distinct org reachable from the active pool.
 *
 * Returns `{ok:true, orgs:[…]}` even when some orgs fail — each org entry says
 * `ok:false` with its own reason, so the panel can render a per-org error state
 * without discarding the orgs that did answer.
 */
export async function readAcuUsageByOrg({
  force = false,
  fetchImpl = null,
  now = Date.now(),
  maxAccounts = MAX_ACCOUNTS,
} = {}) {
  const candidates = collectCandidates(maxAccounts);
  if (candidates.items.length === 0) {
    return {
      ok: false,
      error: 'no_account',
      httpStatus: 503,
      generatedAt: now,
      accountsScanned: 0,
      truncated: false,
      orgs: [],
    };
  }

  const resolved = await mapLimit(candidates.items, RESOLVE_CONCURRENCY, async (candidate) => {
    try {
      const orgId = await resolveOrgId({ token: candidate.token, fetchImpl, force });
      return { ...candidate, orgId };
    } catch (err) {
      return {
        ...candidate,
        error: err instanceof ReadError ? err.kind : 'upstream',
        status: err instanceof ReadError ? err.status : null,
      };
    }
  });

  const groups = new Map();
  const failures = [];
  for (const item of resolved) {
    if (!item.orgId) {
      failures.push({ ref: item.ref, error: item.error || 'upstream' });
      continue;
    }
    if (!groups.has(item.orgId)) {
      groups.set(item.orgId, {
        orgId: item.orgId,
        orgRef: orgRefOf(item.orgId),
        accounts: [],
      });
    }
    // Publish the mapping so pool-level arithmetic (the balance KPI) dedupes by
    // the same billing owner this panel reads. Raw org id stays server-side.
    rememberAccountOrg(item.id, item.orgId);
    groups.get(item.orgId).accounts.push({ id: item.id, ref: item.ref, token: item.token });
  }

  if (groups.size === 0) {
    const kind = worstError(failures.map((failure) => failure.error)) || 'upstream';
    return {
      ok: false,
      error: kind,
      httpStatus: httpStatusForKind(kind),
      generatedAt: now,
      accountsScanned: candidates.items.length,
      truncated: candidates.truncated,
      unresolvedAccounts: failures.length,
      orgs: [],
    };
  }

  const orgs = await mapLimit([...groups.values()], ORG_CONCURRENCY, async (group) => {
    const usage = await readOrgWithFallback(group, { force, fetchImpl, now });
    // Tokens are server-side only; the browser gets the account ids/refs it
    // already sees on the accounts panel.
    const base = {
      orgRef: group.orgRef,
      accounts: group.accounts.map(({ id, ref }) => ({ id, ref })),
    };

    if (!usage || !usage.ok) {
      return {
        ...base,
        ok: false,
        error: usage?.error || 'upstream',
        httpStatus: usage?.httpStatus || httpStatusForKind(usage?.error || 'upstream'),
        sources: usage?.sources || {},
      };
    }

    return {
      ...base,
      ok: true,
      accountId: usage.accountId,
      accountRef: usage.accountRef,
      cached: usage.cached,
      stale: usage.stale,
      partial: usage.partial,
      plan: usage.plan,
      billingValid: usage.billingValid,
      billingMethod: usage.billingMethod,
      availableCredits: usage.availableCredits,
      overageCredits: usage.overageCredits,
      quota: usage.quota,
      limits: usage.limits,
      cycle: usage.cycle,
      days: usage.days,
      products: usage.products,
      reset: usage.reset,
      serverNow: usage.serverNow,
      derived: {
        ...deriveAcuCells({ limits: usage.limits, cycle: usage.cycle }),
        latestDay: latestDayOf(usage.days),
      },
      sources: usage.sources,
    };
  });

  return {
    ok: true,
    generatedAt: now,
    accountsScanned: candidates.items.length,
    truncated: candidates.truncated,
    unresolvedAccounts: failures.length,
    orgs,
  };
}