mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Budget admission and run accounting decide whether any task in a company may start > - #19497 made pending accounting an admission hold and settles a stopped run's lost receipt as an unpriced charge > - Restart-chaos waves still stopped whole companies. A lost Codex receipt with complete token counts settled as unpriced because the rate card only prices complete receipts. Under the default blocking policy that hard-stopped the company at 0.38 of 3000 cents. In another storm, a resumed run's continuation was refused by its own task's recovering-accounting hold. Separately, unpriced charges with zero tokens or a known partial cost hard-stopped a company at 120 of 2000 cents > - This pull request prices a lost receipt's observed usage from the rate card, lets a task's continuation pass its own recovering accounting, and stops restart-derived or evidence-free unpriced charges from tripping the blocking policy > - The benefit is that a restart no longer pauses a company by itself, while genuinely unpriced usage still blocks ## Linked Issues or Issue Description Follow-up to #19497. Refs #19606. **What happened?** - Wave A (K6 crash loop): native Codex run `5037b8d1` was killed mid-turn. Its receipt was lost, but `usage_json` held gpt-6.1-sol with 28690 input and 220 output tokens. The charge was written `cost_status=unpriced`, `pricing_provenance.source=unknown`, because `priceCodexReceipt` returns early when `!receipt.complete`. 0.2 s later the default `unpricedUsagePolicy=block` opened a hard incident at 0.38 of 3000 cents. The company was paused, and 6/6 tasks were blocked with `canRetry=false`. - Wave B (K1 storm): Claude run `2a7a8abf` resumed after the restart. `reserveRunBudget` refused its interaction continuation with "New work must wait while a native run recovers its unfinished accounting", so the task could not settle its own recovery. A 0-token unpriced charge and an unpriced charge with a known 0.81-cent cost then hard-stopped the company at 120 of 2000 cents. **What did you expect?** A restart-lost receipt with known numbers is priced as an estimate, and no restart-derived accounting state pauses a company by itself. A task's own continuation is never held by its own accounting. Real usage above the limit, and genuinely unpriced usage, still stop work. **Steps to reproduce** 1. Give a company a hard-stop budget with the default unpriced-usage policy. 2. Kill a native Codex turn after it has reported tokens, so the final receipt is lost and native recovery ends in terminal failure with a verified stop. 3. Observe an unpriced charge for known tokens, a hard incident, and a paused company. ## What Changed - `run-cost-accounting.ts`: when a lost receipt settles and its usage has known tokens and no known cost, price it with `priceCodexReceipt`/`priceAnthropicReceipt` as a complete per-run checkpoint. The charge is `estimated`, its provenance keeps the rate-card source, and its evidence says "final receipt lost; observed usage is a lower bound". The estimate is also written to the run's usage so a later receipt reconciles against it. Zero observed tokens, and per-model usage, stay unpriced (unknown is never shown as zero, and aggregate totals never take one model's rate). - `budget-exposure.ts`: - The pending/recovering accounting hold excludes the run being admitted and earlier runs of the same task (same `native_issue_id`). A task's continuation and finalization can therefore settle its own accounting. Other tasks stay held, and those runs still count as spend. - The blocking unpriced-usage policy no longer counts these unpriced charges: the settled final charge of a lost receipt (restart-derived, any usage; later growth still counts), a charge with a known partial cost, and metered API or subscription billing with no observed tokens. Unknown billing with no tokens (for example Cursor Cloud) and complete-receipt usage that has no price still block. - `budgets.ts` / `budget-reservations.ts`: admission accepts the admitted `runId` and passes it to the exposure read. `reserveRunBudget`, the final dispatch recheck and the read-only admitted recheck all supply it, so a continuation that reserves can also dispatch. - Daytona wave s12 (run f1d98917, a blocked remote recovery with a 0-cent held reservation and every lease destroyed) is closed by #19606. A Retry for that same task passes the hold through the same-task exclusion here. ## Verification - `closed-native-receipt-accounting.test.ts` (embedded Postgres): - Lost Codex (gpt-6.1-sol) and Claude (claude-sonnet-5) receipts with observed tokens settle as `estimated` rate-card charges with the lower-bound evidence. The company stays active and admission is clear under the blocking policy. - A lost receipt for a model with no rate card stays unpriced but opens no incident and no pause. - A task's continuation reserves budget past its own recovering accounting, while another task's run is still held. - Unpriced charges with no tokens on metered billing, or with a known partial cost, do not hard-stop. - Complete-receipt usage with no price still blocks. - The six new cases fail on master and pass with this change. - Existing budget and accounting suites pass locally: budget-enforcement-scoping, budgets-service, heartbeat-cost-accounting, run-cost-accounting, native-stop-accounting, cost-data-quality, budget-live-attempts, budget-live-usage. Server typecheck passes. ## Risks - A lost receipt's estimate is a lower bound. The provenance says so, and a later complete receipt still reconciles. - Restart-derived unpriced charges no longer trip the blocking policy. Usage lost with a killed provider is not counted against that policy, which is consistent with budget enforcement being best effort. Unknown-billing and complete-receipt unpriced usage still block. - The same-task exemption relies on `native_issue_id`. Legacy runs without it keep the old behavior. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file editing, embedded Postgres tests). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
221 lines
17 KiB
TypeScript
221 lines
17 KiB
TypeScript
import { addCents, normalizeCents, usdToUnits, unitsToCents } from "@paperclipai/shared";
|
|
import { and, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm";
|
|
import { budgetPolicies, budgetReservations, browserUseRuns, browserUseSessions, costEvents, heartbeatRuns, issues, nativeRunFinalizations, type Db } from "@paperclipai/db";
|
|
import { withBudgetSpan } from "./budget-tracing.js";
|
|
|
|
export interface BudgetExposure {
|
|
ledgerCents: string;
|
|
liveObservedCents: string;
|
|
effectiveHeldCents: string;
|
|
pendingTerminalRunCount: number;
|
|
unpricedEventCount: number;
|
|
/** Unconfirmed POSTs or unfinished uncapped browser work, separate from ledger data quality. */
|
|
unknownBrowserRunCount: number;
|
|
}
|
|
|
|
export type SpendPolicy = Pick<typeof budgetPolicies.$inferSelect, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">;
|
|
export type ObservedSpend = BudgetExposure & {
|
|
windowStart: Date; windowEnd: Date; total: number; totalExact: string;
|
|
pendingRunCount: number; recoveringRunCount: number;
|
|
};
|
|
|
|
export function budgetWindow(kind: string, now = new Date()) {
|
|
return kind === "lifetime" ? { start: new Date(0), end: new Date(Date.UTC(9999, 0, 1)) }
|
|
: { start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) };
|
|
}
|
|
|
|
/** Only normalized per-run prices are exposure. An incomplete receipt can
|
|
* contain a known partial price while still being unpriced for settlement. */
|
|
export function knownRunCostCents(usage: Record<string, unknown>): string | null {
|
|
if (usage.billingType === "subscription_included" || usage.billingType === "subscription") return "0";
|
|
if (usage.usageSource === "session_cumulative" || usage.usageBasis === "session_cumulative") return null;
|
|
for (const value of [usage.cacheAdjustedCostUsd, usage.costUsdExact, usage.costUsd]) {
|
|
if ((typeof value !== "number" || !Number.isFinite(value) || value < 0) && typeof value !== "string") continue;
|
|
try {
|
|
const amount = usdToUnits(value);
|
|
if (amount >= 0n) return unitsToCents(amount);
|
|
} catch { /* An older malformed field cannot erase a reliable later field. */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** SQL compatibility for active runs written before the exposure projection.
|
|
* Numeric text is bounded and checked before casting legacy JSON fields. */
|
|
function normalizedNumericCents(value: SQL, usd = false) {
|
|
const scale = usd ? 1_000_000_000 : 10_000_000;
|
|
return sql`case when length(${value}) <= 160 and ${value} ~ '^[+]?[0-9]+([.][0-9]+)?([eE][+-]?[0-9]{1,3})?$'
|
|
then case when abs(coalesce(substring(${value} from '[eE]([+-]?[0-9]+)$'), '0')::int) <= 100
|
|
then case when round((${value})::numeric * ${scale}) <= 999999999999999999999999::numeric
|
|
then round((${value})::numeric * ${scale}) / 10000000::numeric else null end
|
|
else null end else null end`;
|
|
}
|
|
|
|
const usage = heartbeatRuns.usageJson;
|
|
const projected = normalizedNumericCents(sql`${usage}->'budgetExposure'->>'costCents'`);
|
|
const hasProjection = sql`jsonb_typeof(${usage}->'budgetExposure') = 'object'
|
|
and jsonb_typeof(${usage}->'budgetExposure'->'sourceId') = 'string'
|
|
and jsonb_typeof(${usage}->'budgetExposure'->'sequence') = 'number'`;
|
|
const legacy = sql`case when coalesce(${usage}->>'usageSource', 'per_run') <> 'session_cumulative'
|
|
and coalesce(${usage}->>'usageBasis', 'per_run') <> 'session_cumulative' then
|
|
coalesce(${normalizedNumericCents(sql`${usage}->>'cacheAdjustedCostUsd'`, true)},
|
|
${normalizedNumericCents(sql`${usage}->>'costUsdExact'`, true)}, ${normalizedNumericCents(sql`${usage}->>'costUsd'`, true)}) else null end`;
|
|
// The projection already separates attempts and cumulative/session counters.
|
|
// Raw compatibility fields are a fallback, never stronger monetary evidence.
|
|
// A canonical unknown projection must remain unknown, not inherit an unsafe
|
|
// cumulative aggregate retained only for old accounting compatibility.
|
|
const liveCost = sql`case when ${heartbeatRuns.costAccountedAt} is null and ${heartbeatRuns.id} is not null
|
|
and coalesce(${usage}->>'billingType', '') not in ('subscription_included', 'subscription')
|
|
then case when ${hasProjection} then coalesce(${projected}, 0)
|
|
else coalesce(${projected}, ${legacy}, 0) end else 0 end`;
|
|
|
|
/** Batched ledger, held-operation and unfinished-run aggregates. Never scans
|
|
* agents or receipt history; each policy retains its own scope and window. */
|
|
async function readObservedSpends(db: Db, policies: SpendPolicy[], now: Date, excludeRunId?: string | null): Promise<ObservedSpend[]> {
|
|
const windows = policies.map(policy => budgetWindow(policy.windowKind, now));
|
|
const billable = policies.map((policy, index) => ({ policy, index })).filter(({ policy }) => policy.metric === "billed_cents");
|
|
const ledgerColumns: Record<string, SQL> = {}, heldColumns: Record<string, SQL> = {}, runColumns: Record<string, SQL> = {}, browserColumns: Record<string, SQL> = {};
|
|
const ledgerPredicates: SQL[] = [], heldPredicates: SQL[] = [], runPredicates: SQL[] = [], browserPredicates: SQL[] = [];
|
|
for (const { policy, index } of billable) {
|
|
const { start, end } = windows[index];
|
|
const ledger = and(eq(costEvents.companyId, policy.companyId),
|
|
policy.scopeType === "agent" ? eq(costEvents.agentId, policy.scopeId) : undefined,
|
|
policy.scopeType === "project" ? eq(costEvents.projectId, policy.scopeId) : undefined,
|
|
policy.windowKind === "calendar_month_utc" ? and(gte(costEvents.occurredAt, start), lt(costEvents.occurredAt, end)) : undefined)!;
|
|
const held = and(eq(budgetReservations.companyId, policy.companyId), eq(budgetReservations.state, "held"),
|
|
policy.scopeType === "agent" ? eq(budgetReservations.agentId, policy.scopeId) : undefined,
|
|
policy.scopeType === "project" ? eq(budgetReservations.projectId, policy.scopeId) : undefined)!;
|
|
// Hosted browser work is a separate paid operation from its parent run.
|
|
// Its durable hold survives unknown POST outcomes and monthly rollover.
|
|
const browserHold = sql`${browserUseRuns.result}->'paperclipBudgetHold'`;
|
|
const hasBrowserHold = sql`coalesce(jsonb_typeof(${browserHold}) = 'object'
|
|
and ${browserHold}->>'invocationId' = ${browserUseRuns.invocationId}::text, false)`;
|
|
// Earlier browser rows did not persist a cap/dispatch projection. Their
|
|
// unfinished work remains unknown until a terminal receipt is accounted.
|
|
const legacyBrowserPending = sql`not ${hasBrowserHold} and ${browserUseRuns.eventsDrained} = 0
|
|
and (${browserUseRuns.providerRunId} is not null or ${browserUseRuns.status} in ('creating', 'unknown', 'queued', 'dispatching', 'running'))`;
|
|
const browser = and(eq(browserUseRuns.companyId, policy.companyId), eq(browserUseSessions.companyId, policy.companyId),
|
|
sql`((${hasBrowserHold} and ${browserHold}->>'state' = 'held') or (${legacyBrowserPending}))`,
|
|
policy.scopeType === "agent" ? eq(browserUseSessions.agentId, policy.scopeId) : undefined,
|
|
policy.scopeType === "project" ? sql`case when ${hasBrowserHold} then ${browserHold}->>'projectId'
|
|
else ${issues.projectId}::text end = ${policy.scopeId}` : undefined)!;
|
|
browserPredicates.push(browser);
|
|
browserColumns[`held${index}`] = sql`coalesce(sum(${normalizedNumericCents(sql`${browserHold}->>'amountCents'`)}) filter (where ${browser}), 0)::text`;
|
|
// A successful uncapped POST still has no bounded monetary exposure until
|
|
// terminal accounting. Introducing a budget cannot treat that work as free.
|
|
// Older holds had no explicit flag: zero meant an uncapped dispatch. New
|
|
// capped zero holds are distinct, and unstarted reservations are not spend.
|
|
const uncapped = sql`case when ${browserHold}->>'uncapped' = 'false' then false
|
|
when ${browserHold}->>'uncapped' = 'true' then true
|
|
else coalesce(${normalizedNumericCents(sql`${browserHold}->>'amountCents'`)}, 0) = 0 end`;
|
|
const dispatched = sql`(${browserUseRuns.providerRunId} is not null or ${browserHold}->>'dispatchCommittedAt' is not null)`;
|
|
browserColumns[`unknown${index}`] = sql`count(*) filter (where ${browser} and
|
|
((${legacyBrowserPending}) or ${browserHold}->>'billingUnknown' = 'true' or (${uncapped} and ${dispatched})))::int`;
|
|
const inWindow = policy.windowKind === "calendar_month_utc"
|
|
? sql`coalesce(${heartbeatRuns.finishedAt}, ${now.toISOString()}::timestamptz) >= ${start.toISOString()}::timestamptz
|
|
and coalesce(${heartbeatRuns.finishedAt}, ${now.toISOString()}::timestamptz) < ${end.toISOString()}::timestamptz` : sql`true`;
|
|
const lateReceiptDebt = sql`coalesce(${usage}->>'accountingLateReceiptPending', 'false') = 'true'`;
|
|
const run = and(eq(heartbeatRuns.companyId, policy.companyId), or(eq(heartbeatRuns.costAccountingPending, true), lateReceiptDebt),
|
|
policy.scopeType === "agent" ? eq(heartbeatRuns.agentId, policy.scopeId) : undefined,
|
|
policy.scopeType === "project" ? sql`${usage}->'ledgerScope'->>'projectId' = ${policy.scopeId}` : undefined)!;
|
|
// A task never waits on its own unfinished accounting: the run being
|
|
// admitted and earlier runs of the same task (its recovery generation) are
|
|
// what its continuation and finalization settle. They still count as spend.
|
|
// coalesce keeps this a definite boolean when the admitted run has no task.
|
|
const ownTask = excludeRunId ? sql`coalesce(${heartbeatRuns.id} = ${excludeRunId}::uuid or (${heartbeatRuns.nativeIssueId} is not null
|
|
and ${heartbeatRuns.nativeIssueId} = (select admitted_run.native_issue_id from ${heartbeatRuns} admitted_run
|
|
where admitted_run.id = ${excludeRunId}::uuid and admitted_run.company_id = ${heartbeatRuns.companyId})), false)` : sql`false`;
|
|
const terminal = and(run, inArray(heartbeatRuns.status, ["succeeded", "failed", "timed_out", "cancelled", "interrupted"]), or(inWindow, lateReceiptDebt),
|
|
sql`not ${ownTask}`)!;
|
|
// Keep correlated subqueries in nested SQL chunks. Drizzle strips column
|
|
// qualifiers from a single-table selection's direct column chunks.
|
|
const hasReservation = sql`exists (select 1 from ${budgetReservations}
|
|
where ${budgetReservations.companyId} = ${heartbeatRuns.companyId}
|
|
and ${budgetReservations.runId} = ${heartbeatRuns.id} and ${budgetReservations.state} = 'held')`;
|
|
// A terminal failure whose final receipt never closed is still recovering
|
|
// accounting: once its provider stop is verified, accounting settles the
|
|
// known part as unpriced. That window holds new work; it is not a hard stop.
|
|
const closingReceipt = sql`${usage}->>'accountingReceiptReady' = 'false'
|
|
and ${usage}->>'accountingCaptureFailed' is distinct from 'true'`;
|
|
const recovering = sql`not (${lateReceiptDebt}) and exists (select 1 from ${nativeRunFinalizations}
|
|
where ${nativeRunFinalizations.runId} = ${heartbeatRuns.id}
|
|
and ${nativeRunFinalizations.companyId} = ${heartbeatRuns.companyId}
|
|
and ${nativeRunFinalizations.resultId} is null
|
|
and (${nativeRunFinalizations.phase} <> 'terminal_failure' or (${closingReceipt})))`;
|
|
ledgerPredicates.push(ledger); heldPredicates.push(held); runPredicates.push(run);
|
|
ledgerColumns[`total${index}`] = sql`coalesce(sum(${costEvents.costCents}) filter (where ${ledger}), 0)::text`;
|
|
// "Usage with no reliable price" means observed usage that could not be
|
|
// priced. These unpriced charges are not that, so they never trip the
|
|
// blocking policy (they stay visible in Costs):
|
|
// - a receipt lost with its stopped provider (restart-derived, any usage);
|
|
// - a charge that carries a known partial cost;
|
|
// - metered API or subscription billing with no observed tokens.
|
|
// Unknown billing with no tokens (for example Cursor Cloud) still counts.
|
|
// The lost-receipt exemption covers only that run's settled final charge;
|
|
// later growth from a complete receipt is ordinary usage.
|
|
const exemptFromUnpricedBlock = sql`((${costEvents.idempotencyKey} = 'heartbeat:' || ${costEvents.heartbeatRunId}::text || ':final'
|
|
and exists (select 1 from ${heartbeatRuns} lost_receipt_run where lost_receipt_run.id = ${costEvents.heartbeatRunId}
|
|
and lost_receipt_run.company_id = ${costEvents.companyId}
|
|
and lost_receipt_run.usage_json->>'accountingReceiptUnavailable' = 'true'))
|
|
or ${costEvents.costCents} > 0
|
|
or (${costEvents.billingType} in ('metered_api', 'api', 'subscription') and ${costEvents.inputTokens} = 0 and ${costEvents.cachedInputTokens} = 0
|
|
and ${costEvents.outputTokens} = 0))`;
|
|
ledgerColumns[`unpriced${index}`] = sql`count(*) filter (where ${ledger} and ${costEvents.costStatus} = 'unpriced' and ${costEvents.billingType} <> 'subscription_included'
|
|
and not ${exemptFromUnpricedBlock})::int`;
|
|
runColumns[`live${index}`] = sql`coalesce(sum(${liveCost}) filter (where ${run} and ${inWindow}), 0)::text`;
|
|
runColumns[`unreserved${index}`] = sql`coalesce(sum(${liveCost}) filter (where ${run} and not ${hasReservation}), 0)::text`;
|
|
// Reservations carry over UTC windows. A reservation and its running
|
|
// observation describe one operation, so count their maximum, not both.
|
|
heldColumns[`held${index}`] = sql`coalesce(sum(greatest(${budgetReservations.amountCents}, ${liveCost})) filter (where ${held}), 0)::text`;
|
|
runColumns[`pending${index}`] = sql`count(*) filter (where ${terminal})::int`;
|
|
runColumns[`recovering${index}`] = sql`count(*) filter (where ${terminal} and ${heartbeatRuns.runtimeMode} = 'native' and ${recovering})::int`;
|
|
}
|
|
// A single statement preserves hold-to-ledger atomicity even when a caller
|
|
// owns an otherwise READ COMMITTED transaction. Alias dynamic columns before
|
|
// JSON projection so the driver does not collapse repeated aggregate names.
|
|
const columns = (values: Record<string, SQL>) => Object.fromEntries(Object.entries(values).map(([key, value]) => [key, value.as(key)]));
|
|
let ledger: Record<string, string> = {}, held: Record<string, string> = {}, runs: Record<string, string> = {}, browser: Record<string, string> = {};
|
|
if (billable.length) {
|
|
const ledgerQuery = db.select(columns(ledgerColumns)).from(costEvents).where(or(...ledgerPredicates));
|
|
const heldQuery = db.select(columns(heldColumns)).from(budgetReservations)
|
|
.leftJoin(heartbeatRuns, and(eq(heartbeatRuns.id, budgetReservations.runId), eq(heartbeatRuns.companyId, budgetReservations.companyId)))
|
|
.where(or(...heldPredicates));
|
|
const runsQuery = db.select(columns(runColumns)).from(heartbeatRuns).where(or(...runPredicates));
|
|
const browserQuery = db.select(columns(browserColumns)).from(browserUseRuns)
|
|
.innerJoin(browserUseSessions, and(eq(browserUseSessions.id, browserUseRuns.sessionId), eq(browserUseSessions.companyId, browserUseSessions.companyId)))
|
|
.leftJoin(issues, and(eq(issues.id, browserUseSessions.issueId), eq(issues.companyId, browserUseSessions.companyId)))
|
|
.where(or(...browserPredicates));
|
|
const [row] = await db.execute<{ ledger: Record<string, string>; held: Record<string, string>; runs: Record<string, string>; browser: Record<string, string> }>(sql`
|
|
select row_to_json(ledger) as ledger, row_to_json(held) as held,
|
|
row_to_json(runs) as runs, row_to_json(browser) as browser
|
|
from (${ledgerQuery}) ledger cross join (${heldQuery}) held
|
|
cross join (${runsQuery}) runs cross join (${browserQuery}) browser`);
|
|
({ ledger, held, runs, browser } = row);
|
|
}
|
|
return policies.map((_, index) => {
|
|
const ledgerCents = normalizeCents(String(ledger[`total${index}`] ?? 0));
|
|
const liveObservedCents = normalizeCents(String(runs[`live${index}`] ?? 0));
|
|
const totalExact = addCents(ledgerCents, liveObservedCents);
|
|
const pendingRunCount = Number(runs[`pending${index}`] ?? 0);
|
|
return { windowStart: windows[index].start, windowEnd: windows[index].end,
|
|
ledgerCents, liveObservedCents, effectiveHeldCents: addCents(String(held[`held${index}`] ?? 0), String(runs[`unreserved${index}`] ?? 0), String(browser[`held${index}`] ?? 0)),
|
|
total: Number(totalExact), totalExact, pendingRunCount, pendingTerminalRunCount: pendingRunCount,
|
|
unpricedEventCount: Number(ledger[`unpriced${index}`] ?? 0), unknownBrowserRunCount: Number(browser[`unknown${index}`] ?? 0), recoveringRunCount: Number(runs[`recovering${index}`] ?? 0) };
|
|
});
|
|
}
|
|
|
|
export async function computeObservedSpend(db: Db, policy: SpendPolicy, now = new Date()) {
|
|
return (await computeObservedSpends(db, [policy], now))[0];
|
|
}
|
|
|
|
export function computeObservedSpends(db: Db, policies: SpendPolicy[], now = new Date(), excludeRunId?: string | null): Promise<ObservedSpend[]> {
|
|
return withBudgetSpan("budget.exposure.evaluate", async () => {
|
|
// Each policy contributes several aggregate columns. Bound statement width
|
|
// below PostgreSQL's target-list limit. Multi-scope evaluators/reports own
|
|
// a repeatable-read transaction; individual admission batches fit one SQL.
|
|
const result: ObservedSpend[] = [];
|
|
for (let offset = 0; offset < policies.length; offset += 128)
|
|
result.push(...await readObservedSpends(db, policies.slice(offset, offset + 128), now, excludeRunId));
|
|
return result;
|
|
}, { "budget.policy_count": policies.length });
|
|
}
|