src/dashboard/acu-usage.js
Rama master · réplica de lectura del repositorio original.
/**
* 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,
};
}