pr_01m47d24b0e6n91zwymwxg0vpx/packages/contracts/src/compute.ts
| 1 | /** |
| 2 | * Compute gating: whether a workspace may start something that costs g1t |
| 3 | * real money, and with what caps. Every place compute starts (agent runs, |
| 4 | * checks, the merge queue, merge checks and catch-ups in the runner, |
| 5 | * workflow jobs, deploy builds and context embeddings) asks the billing |
| 6 | * service through `ComputeGate.admit` first, and settles what it reserved |
| 7 | * when the work ends. |
| 8 | * |
| 9 | * The billing service owns the decision (`reserve`); this module only |
| 10 | * shapes the call, words the refusal, and decides what happens when billing |
| 11 | * cannot be reached: |
| 12 | * |
| 13 | * - **Free workspaces fail closed.** If billing errors, nothing starts: |
| 14 | * a free workspace never gets compute because billing was down. |
| 15 | * - **Paid, internal and enterprise workspaces fail open.** If billing |
| 16 | * errors, the work starts without a reservation and the error is logged, |
| 17 | * so a billing blip never stops paying customers. The plan is read from |
| 18 | * `entitlements`, or, when that call fails too, from the last plan seen |
| 19 | * for the workspace (kept in the isolate and in the Cache API for a day). |
| 20 | * A workspace whose plan is not known is treated as free. |
| 21 | * - **Internal and enterprise plans always pass.** A refusal from billing |
| 22 | * for one is logged, not shown. |
| 23 | * |
| 24 | * Estimates and settlements are at what the work costs g1t, before the |
| 25 | * margin: billing applies the margin as it does to every other meter. |
| 26 | * |
| 27 | * Only type imports, so services' unit tests can load it on its own. |
| 28 | * |
| 29 | * Mirrors `entitlements`, `reserve` and `settle` in the billing service |
| 30 | * (`g1t_contracts::billing`), which speak camelCase. Answers are read in |
| 31 | * either case, so an older or newer billing never reads as no plan. |
| 32 | */ |
| 33 | import type { ServiceBinding } from "./clients"; |
| 34 | import type { RepoPath } from "./repos"; |
| 35 | import type { Result } from "./result"; |
| 36 | |
| 37 | /** What a workspace pays for. */ |
| 38 | export type ComputePlan = "free" | "paid" | "internal" | "enterprise"; |
| 39 | |
| 40 | /** What kind of compute is being started. */ |
| 41 | export type ComputeKind = "agent" | "check" | "workflow" | "queue" | "deploy" | "embedding"; |
| 42 | |
| 43 | /** What pays for a reservation. */ |
| 44 | export type PaidBy = "credit" | "trial" | "oss" | "on_demand"; |
| 45 | |
| 46 | /** Why billing refused a reservation. */ |
| 47 | export type ComputeRefusalCode = "not_paid" | "trial_used" | "limit" | "paused" | "oss_pool_empty"; |
| 48 | |
| 49 | /** |
| 50 | * Codes the gate itself refuses with, besides billing's: `issue_cap` (an |
| 51 | * issue's agents spent their cap), `billing_unavailable` (a free workspace |
| 52 | * while billing cannot be reached). |
| 53 | */ |
| 54 | export type GateRefusalCode = ComputeRefusalCode | "issue_cap" | "billing_unavailable"; |
| 55 | |
| 56 | /** The compute side of what a workspace's plan gives it. */ |
| 57 | export type ComputeEntitlements = { |
| 58 | plan: ComputePlan; |
| 59 | /** Whether its plan includes compute at all. */ |
| 60 | compute: boolean; |
| 61 | /** The one-time trial's usage left. */ |
| 62 | trialMicrosLeft: number; |
| 63 | /** Whether the workspace passed the card check the trial and open-source pool need. */ |
| 64 | trialVerified: boolean; |
| 65 | /** A paid workspace in its first month: tighter caps. */ |
| 66 | firstMonth: boolean; |
| 67 | /** Agent runs at once; zero means no cap. */ |
| 68 | maxConcurrentAgents: number; |
| 69 | /** The longest any sandbox may run, in minutes; zero means no cap. */ |
| 70 | maxRunMinutes: number; |
| 71 | /** The most one agent run may cost; zero means no cap. */ |
| 72 | runCapMicros: number; |
| 73 | /** The most the agents on one issue may cost in all; zero means no cap. */ |
| 74 | issueCapMicros: number; |
| 75 | ceilingMicros: number; |
| 76 | exposureMicros: number; |
| 77 | /** Why g1t paused the workspace's compute; null when it is not paused. */ |
| 78 | paused: string | null; |
| 79 | }; |
| 80 | |
| 81 | export type Reservation = { id: string; paidBy: PaidBy }; |
| 82 | |
| 83 | /** Compute the open-source pool can pay for, on public repositories. */ |
| 84 | export const OSS_KINDS: ReadonlySet<ComputeKind> = new Set(["check", "workflow", "queue"]); |
| 85 | |
| 86 | const REFUSAL_CODES: ReadonlySet<string> = new Set(["not_paid", "trial_used", "limit", "paused", "oss_pool_empty"]); |
| 87 | |
| 88 | // ---- Reading billing's answers ---------------------------------------------- |
| 89 | |
| 90 | type Raw = Record<string, unknown>; |
| 91 | |
| 92 | /** A field in camelCase or snake_case. */ |
| 93 | function field(raw: Raw, camel: string): unknown { |
| 94 | if (camel in raw) return raw[camel]; |
| 95 | const snake = camel.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); |
| 96 | return raw[snake]; |
| 97 | } |
| 98 | |
| 99 | const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0); |
| 100 | |
| 101 | const PLANS: ReadonlySet<string> = new Set(["free", "paid", "internal", "enterprise"]); |
| 102 | |
| 103 | /** |
| 104 | * Billing's `entitlements`, as this module reads them. Null when they do |
| 105 | * not say the plan (the billing service has not got the compute contract |
| 106 | * yet, or answered something else). |
| 107 | */ |
| 108 | export function readEntitlements(answer: unknown): ComputeEntitlements | null { |
| 109 | if (!answer || typeof answer !== "object") return null; |
| 110 | let raw = answer as Raw; |
| 111 | // An outcome, if billing wraps it in one. |
| 112 | if ("ok" in raw && ("value" in raw || "error" in raw)) { |
| 113 | if (raw.ok !== true || !raw.value || typeof raw.value !== "object") return null; |
| 114 | raw = raw.value as Raw; |
| 115 | } |
| 116 | const plan = field(raw, "plan"); |
| 117 | if (typeof plan !== "string" || !PLANS.has(plan)) return null; |
| 118 | const paused = field(raw, "paused"); |
| 119 | return { |
| 120 | plan: plan as ComputePlan, |
| 121 | compute: field(raw, "compute") === true, |
| 122 | trialMicrosLeft: num(field(raw, "trialMicrosLeft")), |
| 123 | trialVerified: field(raw, "trialVerified") === true, |
| 124 | firstMonth: field(raw, "firstMonth") === true, |
| 125 | maxConcurrentAgents: num(field(raw, "maxConcurrentAgents")), |
| 126 | maxRunMinutes: num(field(raw, "maxRunMinutes")), |
| 127 | runCapMicros: num(field(raw, "runCapMicros")), |
| 128 | issueCapMicros: num(field(raw, "issueCapMicros")), |
| 129 | ceilingMicros: num(field(raw, "ceilingMicros")), |
| 130 | exposureMicros: num(field(raw, "exposureMicros")), |
| 131 | paused: typeof paused === "string" && paused.trim() ? paused.trim() : null, |
| 132 | }; |
| 133 | } |
| 134 | |
| 135 | /** A reservation from billing's `reserve`, or null if it is not one. */ |
| 136 | export function readReservation(value: unknown): Reservation | null { |
| 137 | if (!value || typeof value !== "object") return null; |
| 138 | const raw = value as Raw; |
| 139 | const id = field(raw, "id"); |
| 140 | if (typeof id !== "string" || !id) return null; |
| 141 | const paidBy = field(raw, "paidBy"); |
| 142 | return { id, paidBy: (typeof paidBy === "string" ? paidBy : "credit") as PaidBy }; |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Billing's refusal code, if a failure is a refusal rather than an error. |
| 147 | * Accepts the code itself, a `reason` beside a generic code, and |
| 148 | * `payment_required`, which billing has used for refusals before. |
| 149 | */ |
| 150 | export function refusalCode(failure: { code?: unknown; reason?: unknown } | null | undefined): ComputeRefusalCode | null { |
| 151 | if (!failure) return null; |
| 152 | for (const candidate of [failure.code, failure.reason]) { |
| 153 | if (typeof candidate === "string" && REFUSAL_CODES.has(candidate)) return candidate as ComputeRefusalCode; |
| 154 | } |
| 155 | return failure.code === "payment_required" ? "not_paid" : null; |
| 156 | } |
| 157 | |
| 158 | // ---- Who can run what ------------------------------------------------------- |
| 159 | |
| 160 | /** Plans that never wait on billing. */ |
| 161 | export function alwaysPasses(plan: ComputePlan | null | undefined): boolean { |
| 162 | return plan === "internal" || plan === "enterprise"; |
| 163 | } |
| 164 | |
| 165 | /** What happens when billing cannot be reached: paying plans go on, the rest stop. */ |
| 166 | export function onBillingError(plan: ComputePlan | null | undefined): "allow" | "refuse" { |
| 167 | return plan === "paid" || alwaysPasses(plan) ? "allow" : "refuse"; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Whether a workspace can start `kind` at all, from its entitlements alone: |
| 172 | * the note people see before they try, and the check for work too small to |
| 173 | * reserve one by one (a search query's embedding). Billing's `reserve` is |
| 174 | * the decision for everything else. Null when it can; the refusal when not. |
| 175 | */ |
| 176 | export function localRefusal( |
| 177 | ent: ComputeEntitlements, |
| 178 | kind: ComputeKind, |
| 179 | isPublic: boolean, |
| 180 | ): ComputeRefusalCode | null { |
| 181 | if (ent.paused) return "paused"; |
| 182 | if (ent.plan !== "free") return null; |
| 183 | if (ent.compute) return null; |
| 184 | if (ent.trialVerified && ent.trialMicrosLeft > 0) return null; |
| 185 | if (isPublic && OSS_KINDS.has(kind) && ent.trialVerified) return null; |
| 186 | return ent.trialVerified && ent.trialMicrosLeft <= 0 ? "trial_used" : "not_paid"; |
| 187 | } |
| 188 | |
| 189 | /** Where a workspace's owners start the plan or the trial. */ |
| 190 | export function billingPath(workspace: string): string { |
| 191 | return `/${workspace.toLowerCase()}/-/billing`; |
| 192 | } |
| 193 | |
| 194 | const WHAT: Record<ComputeKind, string> = { |
| 195 | agent: "Agents need", |
| 196 | check: "Checks run in g1t's sandboxes, which need", |
| 197 | workflow: "Workflows run in g1t's sandboxes, which need", |
| 198 | queue: "The merge queue runs in g1t's sandboxes, which needs", |
| 199 | deploy: "Deployments need", |
| 200 | embedding: "Semantic search needs", |
| 201 | }; |
| 202 | |
| 203 | /** What people are told when compute is refused: plain, with what to do and where. */ |
| 204 | export function refusalMessage( |
| 205 | code: GateRefusalCode, |
| 206 | workspace: string, |
| 207 | kind: ComputeKind, |
| 208 | detail?: string | null, |
| 209 | ): string { |
| 210 | const link = billingPath(workspace); |
| 211 | switch (code) { |
| 212 | case "not_paid": { |
| 213 | const oss = OSS_KINDS.has(kind) |
| 214 | ? " Public repositories can use g1t's open-source pool instead, after a card check." |
| 215 | : ""; |
| 216 | return `${WHAT[kind]} a paid workspace.${oss} Start the $20 plan or try it with $5 of free usage after a card check: ${link}`; |
| 217 | } |
| 218 | case "trial_used": |
| 219 | return `This workspace has used its $5 of free usage. Start the $20 plan to keep going: ${link}`; |
| 220 | case "limit": |
| 221 | return `This workspace reached its spend limit for the month, so nothing new starts. An owner can raise it: ${link}#limit`; |
| 222 | case "paused": |
| 223 | return `g1t paused compute for this workspace${detail ? `: ${detail}` : ""}. Contact support@g1t.sh to have it looked at.`; |
| 224 | case "oss_pool_empty": |
| 225 | return `g1t's open-source pool is used up for this month, so checks and workflows on public repositories wait until next month. Start the $20 plan to run them now: ${link}`; |
| 226 | case "issue_cap": |
| 227 | return `${detail ?? "This issue's agents reached its spending cap"}. An owner can raise the cap per issue: ${link}#caps`; |
| 228 | case "billing_unavailable": |
| 229 | return "g1t could not reach its billing service, so it did not start this. Try again in a minute."; |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * The note people see before they try to start `kind`, when their |
| 235 | * workspace's plan would refuse it: plain, with where to fix it. Null when |
| 236 | * it would go ahead. |
| 237 | */ |
| 238 | export function computeNote( |
| 239 | ent: ComputeEntitlements, |
| 240 | kind: ComputeKind, |
| 241 | isPublic: boolean, |
| 242 | workspace: string, |
| 243 | ): string | null { |
| 244 | const refused = localRefusal(ent, kind, isPublic); |
| 245 | if (!refused) return null; |
| 246 | const link = billingPath(workspace); |
| 247 | if (refused === "paused") return refusalMessage("paused", workspace, kind, ent.paused); |
| 248 | if (refused === "trial_used") { |
| 249 | return `This workspace has used its free trial. ${WHAT[kind]} a paid workspace: ${link}`; |
| 250 | } |
| 251 | if (OSS_KINDS.has(kind) && isPublic) { |
| 252 | return `${WHAT[kind]} a paid workspace or the free trial. On a public repository, g1t's open-source pool runs them after a card check: ${link}`; |
| 253 | } |
| 254 | return `${WHAT[kind]} a paid workspace or the free trial: ${link}`; |
| 255 | } |
| 256 | |
| 257 | // ---- Estimates -------------------------------------------------------------- |
| 258 | |
| 259 | /** The kinds of agent run, as the runner names them. */ |
| 260 | export type AgentRunKind = "implement" | "revise" | "review" | "answer" | "update" | "plan" | "reply"; |
| 261 | |
| 262 | /** |
| 263 | * What the model part of each kind of agent run costs g1t, on average, from |
| 264 | * measured runs: implement about $0.094, review $0.07, plan $0.104. The |
| 265 | * rest are scaled from those by how much they do. |
| 266 | */ |
| 267 | export const MODEL_ESTIMATE_MICROS: Record<AgentRunKind, number> = { |
| 268 | implement: 100_000, |
| 269 | revise: 100_000, |
| 270 | review: 70_000, |
| 271 | plan: 100_000, |
| 272 | update: 50_000, |
| 273 | answer: 30_000, |
| 274 | reply: 30_000, |
| 275 | }; |
| 276 | |
| 277 | /** |
| 278 | * What one second of a sandbox costs g1t, in millionths of a dollar, when |
| 279 | * the price book cannot be read: Containers standard-1 with its Durable |
| 280 | * Object, rounded up. |
| 281 | */ |
| 282 | export const FALLBACK_SANDBOX_MICROS_PER_SECOND = 25; |
| 283 | |
| 284 | /** What Workers AI's embedding model costs g1t per token. */ |
| 285 | export const EMBEDDING_MICROS_PER_TOKEN = 0.067; |
| 286 | |
| 287 | /** A sandbox for `minutes` at `microsPerSecond`. */ |
| 288 | export function sandboxEstimateMicros(minutes: number, microsPerSecond: number): number { |
| 289 | return Math.ceil(Math.max(0, minutes) * 60 * Math.max(0, microsPerSecond)); |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * An agent run: its model's average, unless the workspace's own provider |
| 294 | * pays for the model, plus its sandbox for its whole time cap. |
| 295 | */ |
| 296 | export function agentEstimateMicros( |
| 297 | task: AgentRunKind, |
| 298 | minutes: number, |
| 299 | microsPerSecond: number, |
| 300 | ownModel = false, |
| 301 | ): number { |
| 302 | return (ownModel ? 0 : MODEL_ESTIMATE_MICROS[task]) + sandboxEstimateMicros(minutes, microsPerSecond); |
| 303 | } |
| 304 | |
| 305 | /** Embedding `tokens` of text. */ |
| 306 | export function embeddingEstimateMicros(tokens: number): number { |
| 307 | return Math.ceil(Math.max(0, tokens) * EMBEDDING_MICROS_PER_TOKEN); |
| 308 | } |
| 309 | |
| 310 | /** What a sandbox that ran `seconds` cost, plus a model's cost in dollars. */ |
| 311 | export function actualMicros(seconds: number, microsPerSecond: number, modelUsd = 0): number { |
| 312 | const model = Number.isFinite(modelUsd) && modelUsd > 0 ? modelUsd * 1_000_000 : 0; |
| 313 | return Math.ceil(Math.max(0, seconds) * Math.max(0, microsPerSecond) + model); |
| 314 | } |
| 315 | |
| 316 | // ---- Caps --------------------------------------------------------------------- |
| 317 | |
| 318 | /** The lower of two caps, where null, zero or less means no cap. */ |
| 319 | export function lowerCap(a: number | null | undefined, b: number | null | undefined): number | null { |
| 320 | const caps = [a, b].filter((cap): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0); |
| 321 | return caps.length ? Math.min(...caps) : null; |
| 322 | } |
| 323 | |
| 324 | /** A run's time cap: the guardrails' and the plan's, whichever is lower. */ |
| 325 | export function runMinutes(guardMinutes: number, ent: ComputeEntitlements | null): number { |
| 326 | return lowerCap(guardMinutes, ent?.maxRunMinutes) ?? guardMinutes; |
| 327 | } |
| 328 | |
| 329 | /** A run's cost cap in dollars: the guardrails' and the plan's, whichever is lower. */ |
| 330 | export function runBudgetUsd(guardBudgetUsd: number | null, ent: ComputeEntitlements | null): number | null { |
| 331 | const planCap = ent && ent.runCapMicros > 0 ? ent.runCapMicros / 1_000_000 : null; |
| 332 | return lowerCap(guardBudgetUsd, planCap); |
| 333 | } |
| 334 | |
| 335 | // ---- Agents at once --------------------------------------------------------------- |
| 336 | |
| 337 | /** How a run waiting for room says so; `isWaiting` recognises it. */ |
| 338 | export const WAITING_PREFIX = "Waiting for a free slot"; |
| 339 | |
| 340 | /** Whether another agent run fits under the workspace's cap. */ |
| 341 | export function slotFree(activeAgents: number, ent: ComputeEntitlements | null): boolean { |
| 342 | if (!ent || ent.maxConcurrentAgents <= 0 || alwaysPasses(ent.plan)) return true; |
| 343 | return activeAgents < ent.maxConcurrentAgents; |
| 344 | } |
| 345 | |
| 346 | export function waitingMessage(max: number): string { |
| 347 | return `${WAITING_PREFIX}: this workspace runs ${max} ${max === 1 ? "agent" : "agents"} at a time and all are busy. It starts by itself when one finishes.`; |
| 348 | } |
| 349 | |
| 350 | export function isWaiting(message: string | null | undefined): boolean { |
| 351 | return typeof message === "string" && message.startsWith(WAITING_PREFIX); |
| 352 | } |
| 353 | |
| 354 | /** Why an issue's agents may not start again: they spent its cap. Null when they may. */ |
| 355 | export function issueCapReached(spentMicros: number, ent: ComputeEntitlements | null, issue: number): string | null { |
| 356 | if (!ent || ent.issueCapMicros <= 0 || alwaysPasses(ent.plan)) return null; |
| 357 | if (spentMicros < ent.issueCapMicros) return null; |
| 358 | const dollars = (micros: number) => `$${(micros / 1_000_000).toFixed(2)}`; |
| 359 | return `The agents on #${issue} have spent ${dollars(spentMicros)}, its cap of ${dollars(ent.issueCapMicros)}, so g1t-agent will not start on it again`; |
| 360 | } |
| 361 | |
| 362 | // ---- The gate ------------------------------------------------------------------- |
| 363 | |
| 364 | /** Where the last plan seen for each workspace is kept, for when billing is down. */ |
| 365 | export interface PlanMemory { |
| 366 | get(workspace: string): Promise<ComputePlan | null>; |
| 367 | put(workspace: string, plan: ComputePlan): Promise<void>; |
| 368 | } |
| 369 | |
| 370 | /** The part of the Cache API this uses. */ |
| 371 | type PlanCache = { |
| 372 | match(key: string): Promise<Response | undefined>; |
| 373 | put(key: string, response: Response): Promise<void>; |
| 374 | }; |
| 375 | |
| 376 | const PLAN_MEMORY_SECONDS = 24 * 60 * 60; |
| 377 | const isolatePlans = new Map<string, { plan: ComputePlan; until: number }>(); |
| 378 | |
| 379 | /** |
| 380 | * The last plan seen, in this isolate and in the Cache API (where the |
| 381 | * runtime has one), for a day. |
| 382 | */ |
| 383 | export const defaultPlanMemory: PlanMemory = { |
| 384 | async get(workspace) { |
| 385 | const kept = isolatePlans.get(workspace); |
| 386 | if (kept && kept.until > Date.now()) return kept.plan; |
| 387 | const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default; |
| 388 | if (!cache) return null; |
| 389 | try { |
| 390 | const hit = await cache.match(planKey(workspace)); |
| 391 | const plan = hit ? await hit.text() : null; |
| 392 | return plan && PLANS.has(plan) ? (plan as ComputePlan) : null; |
| 393 | } catch { |
| 394 | return null; |
| 395 | } |
| 396 | }, |
| 397 | async put(workspace, plan) { |
| 398 | isolatePlans.set(workspace, { plan, until: Date.now() + PLAN_MEMORY_SECONDS * 1000 }); |
| 399 | const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default; |
| 400 | if (!cache) return; |
| 401 | try { |
| 402 | await cache.put( |
| 403 | planKey(workspace), |
| 404 | new Response(plan, { headers: { "cache-control": `max-age=${PLAN_MEMORY_SECONDS}` } }), |
| 405 | ); |
| 406 | } catch { |
| 407 | // Remembering is a convenience; the isolate's copy is enough. |
| 408 | } |
| 409 | }, |
| 410 | }; |
| 411 | |
| 412 | function planKey(workspace: string): string { |
| 413 | return `https://compute-gate.g1t.internal/plan/${encodeURIComponent(workspace)}`; |
| 414 | } |
| 415 | |
| 416 | export type ReserveRequest = { |
| 417 | workspace: string; |
| 418 | repo: RepoPath; |
| 419 | public: boolean; |
| 420 | kind: ComputeKind; |
| 421 | estimateMicros: number; |
| 422 | }; |
| 423 | |
| 424 | /** The gate's answer: go ahead (with what was reserved, if anything), or why not. */ |
| 425 | export type Admission = |
| 426 | | { ok: true; reservation: Reservation | null; entitlements: ComputeEntitlements | null } |
| 427 | | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null }; |
| 428 | |
| 429 | const ENTITLEMENTS_SECONDS = 30; |
| 430 | const PRICE_SECONDS = 10 * 60; |
| 431 | |
| 432 | export class ComputeGate { |
| 433 | private ents = new Map<string, { value: ComputeEntitlements; until: number }>(); |
| 434 | private price: { value: number; until: number } | null = null; |
| 435 | |
| 436 | private readonly billing: ServiceBinding; |
| 437 | private readonly memory: PlanMemory; |
| 438 | private readonly log: (...args: unknown[]) => void; |
| 439 | |
| 440 | // Plain fields, not parameter properties: Node's type stripping, which |
| 441 | // the services' tests run under, does not take those. |
| 442 | constructor( |
| 443 | billing: ServiceBinding, |
| 444 | memory: PlanMemory = defaultPlanMemory, |
| 445 | log: (...args: unknown[]) => void = console.log, |
| 446 | ) { |
| 447 | this.billing = billing; |
| 448 | this.memory = memory; |
| 449 | this.log = log; |
| 450 | } |
| 451 | |
| 452 | private async call<T>(method: string, args: object): Promise<T> { |
| 453 | const response = await this.billing.fetch(`https://service/rpc/${method}`, { |
| 454 | method: "POST", |
| 455 | headers: { "content-type": "application/json" }, |
| 456 | body: JSON.stringify(args), |
| 457 | }); |
| 458 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 459 | return (await response.json()) as T; |
| 460 | } |
| 461 | |
| 462 | /** |
| 463 | * The workspace's entitlements, kept for half a minute. Null when billing |
| 464 | * cannot say; never throws. |
| 465 | */ |
| 466 | async entitlements(workspace: string): Promise<ComputeEntitlements | null> { |
| 467 | const slug = workspace.toLowerCase(); |
| 468 | const kept = this.ents.get(slug); |
| 469 | if (kept && kept.until > Date.now()) return kept.value; |
| 470 | try { |
| 471 | const ent = readEntitlements(await this.call<unknown>("entitlements", { workspace: slug })); |
| 472 | if (!ent) throw new Error("entitlements did not say the workspace's plan"); |
| 473 | this.ents.set(slug, { value: ent, until: Date.now() + ENTITLEMENTS_SECONDS * 1000 }); |
| 474 | await this.memory.put(slug, ent.plan); |
| 475 | return ent; |
| 476 | } catch (error) { |
| 477 | this.log("compute gate: entitlements unavailable", slug, String(error)); |
| 478 | return null; |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | /** What one second of a sandbox costs g1t, from the price book. */ |
| 483 | async microsPerSecond(): Promise<number> { |
| 484 | if (this.price && this.price.until > Date.now()) return this.price.value; |
| 485 | let value = FALLBACK_SANDBOX_MICROS_PER_SECOND; |
| 486 | try { |
| 487 | const book = await this.call<{ prices?: { meter: string; costMicros?: number; cost_micros?: number }[] }>("prices", {}); |
| 488 | const meter = book.prices?.find((price) => price.meter === "sandbox_second"); |
| 489 | const cost = meter?.costMicros ?? meter?.cost_micros; |
| 490 | if (typeof cost === "number" && cost > 0) value = cost; |
| 491 | } catch (error) { |
| 492 | this.log("compute gate: price book unavailable", String(error)); |
| 493 | } |
| 494 | this.price = { value, until: Date.now() + PRICE_SECONDS * 1000 }; |
| 495 | return value; |
| 496 | } |
| 497 | |
| 498 | /** |
| 499 | * Asks billing to reserve what `request` is expected to cost. Refused |
| 500 | * when billing refuses, or when billing cannot be reached for a workspace |
| 501 | * that is not known to pay (see the module's comment). Never throws. |
| 502 | */ |
| 503 | async admit(request: ReserveRequest, known?: ComputeEntitlements | null): Promise<Admission> { |
| 504 | const workspace = request.workspace.toLowerCase(); |
| 505 | const ent = known === undefined ? await this.entitlements(workspace) : known; |
| 506 | const refuse = (code: GateRefusalCode, message?: string | null): Admission => ({ |
| 507 | ok: false, |
| 508 | code, |
| 509 | message: message || refusalMessage(code, workspace, request.kind, ent?.paused), |
| 510 | entitlements: ent, |
| 511 | }); |
| 512 | if (ent?.paused) return refuse("paused"); |
| 513 | const plan = ent?.plan ?? (await this.memory.get(workspace)); |
| 514 | let answer: Result<unknown>; |
| 515 | try { |
| 516 | answer = await this.call<Result<unknown>>("reserve", { |
| 517 | workspace, |
| 518 | repo: request.repo, |
| 519 | public: request.public, |
| 520 | kind: request.kind, |
| 521 | estimateMicros: Math.max(0, Math.ceil(request.estimateMicros)), |
| 522 | }); |
| 523 | } catch (error) { |
| 524 | return this.unavailable(plan, refuse, ent, request, String(error)); |
| 525 | } |
| 526 | if (answer.ok) { |
| 527 | const reservation = readReservation(answer.value); |
| 528 | if (!reservation) return this.unavailable(plan, refuse, ent, request, "reserve answered without a reservation"); |
| 529 | return { ok: true, reservation, entitlements: ent }; |
| 530 | } |
| 531 | const code = refusalCode(answer.error as { code?: unknown; reason?: unknown }); |
| 532 | if (!code) return this.unavailable(plan, refuse, ent, request, answer.error.message); |
| 533 | if (alwaysPasses(plan)) { |
| 534 | this.log("compute gate: refusal ignored for", plan, workspace, request.kind, code, answer.error.message); |
| 535 | return { ok: true, reservation: null, entitlements: ent }; |
| 536 | } |
| 537 | // Billing's own words when it gave them; they carry the link to act on. |
| 538 | return refuse(code, answer.error.message || null); |
| 539 | } |
| 540 | |
| 541 | private unavailable( |
| 542 | plan: ComputePlan | null, |
| 543 | refuse: (code: GateRefusalCode, message?: string | null) => Admission, |
| 544 | ent: ComputeEntitlements | null, |
| 545 | request: ReserveRequest, |
| 546 | why: string, |
| 547 | ): Admission { |
| 548 | if (onBillingError(plan) === "allow") { |
| 549 | this.log("compute gate: billing unavailable; allowed for", plan, request.workspace, request.kind, why); |
| 550 | return { ok: true, reservation: null, entitlements: ent }; |
| 551 | } |
| 552 | this.log("compute gate: billing unavailable; refused for", plan ?? "unknown plan", request.workspace, request.kind, why); |
| 553 | return refuse("billing_unavailable"); |
| 554 | } |
| 555 | |
| 556 | /** Settles a reservation at what the work cost. Never throws; a failure is logged. */ |
| 557 | async settle(reservationId: string, actual: number): Promise<void> { |
| 558 | const micros = Math.max(0, Math.ceil(actual)); |
| 559 | try { |
| 560 | const settled = await this.call<Result<unknown>>("settle", { reservationId, actualMicros: micros }); |
| 561 | if (settled && settled.ok === false) this.log("compute gate: settle refused", reservationId, settled.error.message); |
| 562 | } catch (error) { |
| 563 | this.log("compute gate: settle failed", reservationId, micros, String(error)); |
| 564 | } |
| 565 | } |
| 566 | } |