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