Skip to content
486 linesCodeBlameRaw
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 */
32import type { ServiceBinding } from "./clients";
33import type { RepoPath } from "./repos";
34import type { Result } from "./result";
35
36/** What a workspace pays for. */
37export 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 */
44export type ComputeKind = "check" | "workflow" | "queue" | "deploy";
45
46/** What pays for a reservation. */
47export type PaidBy = "credit" | "trial" | "oss" | "on_demand";
48
49/** Why billing refused a reservation. */
50export 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 */
56export type GateRefusalCode = ComputeRefusalCode | "billing_unavailable";
57
58/** The compute side of what a workspace's plan gives it. */
59export 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
79export type Reservation = { id: string; paidBy: PaidBy };
80
81/** Compute the open-source pool can pay for, on public repositories. */
82export const OSS_KINDS: ReadonlySet<ComputeKind> = new Set(["check", "workflow", "queue"]);
83
84const REFUSAL_CODES: ReadonlySet<string> = new Set(["not_paid", "trial_used", "limit", "paused", "oss_pool_empty"]);
85
86// ---- Reading billing's answers ----------------------------------------------
87
88type Raw = Record<string, unknown>;
89
90/** A field in camelCase or snake_case. */
91function 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
97const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
98
99const 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 */
106export 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. */
132export 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 */
146export 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. */
157export 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. */
162export 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 */
171export 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. */
185export function billingPath(workspace: string): string {
186 return `/${workspace.toLowerCase()}/-/billing`;
187}
188
189const 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. */
197export 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 */
229export 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 */
255export const FALLBACK_SANDBOX_MICROS_PER_SECOND = 25;
256
257/** A sandbox for `minutes` at `microsPerSecond`. */
258export 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. */
263export 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. */
270export 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. */
276type PlanCache = {
277 match(key: string): Promise<Response | undefined>;
278 put(key: string, response: Response): Promise<void>;
279};
280
281const PLAN_MEMORY_SECONDS = 24 * 60 * 60;
282const 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 */
288export 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
317function planKey(workspace: string): string {
318 return `https://compute-gate.g1t.internal/plan/${encodeURIComponent(workspace)}`;
319}
320
321export 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. */
330export type Admission =
331 | { ok: true; reservation: Reservation | null; entitlements: ComputeEntitlements | null }
332 | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null };
333
334const 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 */
341const HOLDING_BACK_SECONDS = 3;
342const PRICE_SECONDS = 10 * 60;
343
344/** How long the gate keeps an entitlements answer, in seconds. */
345export 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
350export 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}