Skip to content
487 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: the standard sandbox (1 vCPU, 4 GiB, 20 GB)
253 * with its vCPU busy and its Durable Object, 32.96 at list prices, rounded
254 * up.
255 */
256export const FALLBACK_SANDBOX_MICROS_PER_SECOND = 33;
257
258/** A sandbox for `minutes` at `microsPerSecond`. */
259export function sandboxEstimateMicros(minutes: number, microsPerSecond: number): number {
260 return Math.ceil(Math.max(0, minutes) * 60 * Math.max(0, microsPerSecond));
261}
262
263/** What a sandbox that ran `seconds` cost. */
264export function actualMicros(seconds: number, microsPerSecond: number): number {
265 return Math.ceil(Math.max(0, seconds) * Math.max(0, microsPerSecond));
266}
267
268// ---- The gate -------------------------------------------------------------------
269
270/** Where the last plan seen for each workspace is kept, for when billing is down. */
271export interface PlanMemory {
272 get(workspace: string): Promise<ComputePlan | null>;
273 put(workspace: string, plan: ComputePlan): Promise<void>;
274}
275
276/** The part of the Cache API this uses. */
277type PlanCache = {
278 match(key: string): Promise<Response | undefined>;
279 put(key: string, response: Response): Promise<void>;
280};
281
282const PLAN_MEMORY_SECONDS = 24 * 60 * 60;
283const isolatePlans = new Map<string, { plan: ComputePlan; until: number }>();
284
285/**
286 * The last plan seen, in this isolate and in the Cache API (where the
287 * runtime has one), for a day.
288 */
289export const defaultPlanMemory: PlanMemory = {
290 async get(workspace) {
291 const kept = isolatePlans.get(workspace);
292 if (kept && kept.until > Date.now()) return kept.plan;
293 const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default;
294 if (!cache) return null;
295 try {
296 const hit = await cache.match(planKey(workspace));
297 const plan = hit ? await hit.text() : null;
298 return plan && PLANS.has(plan) ? (plan as ComputePlan) : null;
299 } catch {
300 return null;
301 }
302 },
303 async put(workspace, plan) {
304 isolatePlans.set(workspace, { plan, until: Date.now() + PLAN_MEMORY_SECONDS * 1000 });
305 const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default;
306 if (!cache) return;
307 try {
308 await cache.put(
309 planKey(workspace),
310 new Response(plan, { headers: { "cache-control": `max-age=${PLAN_MEMORY_SECONDS}` } }),
311 );
312 } catch {
313 // Remembering is a convenience; the isolate's copy is enough.
314 }
315 },
316};
317
318function planKey(workspace: string): string {
319 return `https://compute-gate.g1t.internal/plan/${encodeURIComponent(workspace)}`;
320}
321
322export type ReserveRequest = {
323 workspace: string;
324 repo: RepoPath;
325 public: boolean;
326 kind: ComputeKind;
327 estimateMicros: number;
328};
329
330/** The gate's answer: go ahead (with what was reserved, if anything), or why not. */
331export type Admission =
332 | { ok: true; reservation: Reservation | null; entitlements: ComputeEntitlements | null }
333 | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null };
334
335const ENTITLEMENTS_SECONDS = 30;
336/**
337 * An answer that holds compute back (paused, or a free workspace without
338 * compute) is kept only a few seconds: the owner who just resumed it or
339 * added a plan presses Run next, and must not be refused by a copy from
340 * before they did.
341 */
342const HOLDING_BACK_SECONDS = 3;
343const PRICE_SECONDS = 10 * 60;
344
345/** How long the gate keeps an entitlements answer, in seconds. */
346export function entitlementsKeptSeconds(ent: ComputeEntitlements): number {
347 const holdsBack = Boolean(ent.paused) || (ent.plan === "free" && !ent.compute);
348 return holdsBack ? HOLDING_BACK_SECONDS : ENTITLEMENTS_SECONDS;
349}
350
351export class ComputeGate {
352 private ents = new Map<string, { value: ComputeEntitlements; until: number }>();
353 private price: { value: number; until: number } | null = null;
354
355 private readonly billing: ServiceBinding;
356 private readonly memory: PlanMemory;
357 private readonly log: (...args: unknown[]) => void;
358
359 // Plain fields, not parameter properties: Node's type stripping, which
360 // the services' tests run under, does not take those.
361 constructor(
362 billing: ServiceBinding,
363 memory: PlanMemory = defaultPlanMemory,
364 log: (...args: unknown[]) => void = console.log,
365 ) {
366 this.billing = billing;
367 this.memory = memory;
368 this.log = log;
369 }
370
371 private async call<T>(method: string, args: object): Promise<T> {
372 const response = await this.billing.fetch(`https://service/rpc/${method}`, {
373 method: "POST",
374 headers: { "content-type": "application/json" },
375 body: JSON.stringify(args),
376 });
377 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
378 return (await response.json()) as T;
379 }
380
381 /**
382 * The workspace's entitlements, kept for half a minute. Null when billing
383 * cannot say; never throws.
384 */
385 async entitlements(workspace: string): Promise<ComputeEntitlements | null> {
386 const slug = workspace.toLowerCase();
387 const kept = this.ents.get(slug);
388 if (kept && kept.until > Date.now()) return kept.value;
389 try {
390 const ent = readEntitlements(await this.call<unknown>("entitlements", { workspace: slug }));
391 if (!ent) throw new Error("entitlements did not say the workspace's plan");
392 this.ents.set(slug, { value: ent, until: Date.now() + entitlementsKeptSeconds(ent) * 1000 });
393 await this.memory.put(slug, ent.plan);
394 return ent;
395 } catch (error) {
396 this.log("compute gate: entitlements unavailable", slug, String(error));
397 return null;
398 }
399 }
400
401 /** What one second of a sandbox costs g1t, from the price book. */
402 async microsPerSecond(): Promise<number> {
403 if (this.price && this.price.until > Date.now()) return this.price.value;
404 let value = FALLBACK_SANDBOX_MICROS_PER_SECOND;
405 try {
406 const book = await this.call<{ prices?: { meter: string; costMicros?: number; cost_micros?: number }[] }>("prices", {});
407 const meter = book.prices?.find((price) => price.meter === "sandbox_second");
408 const cost = meter?.costMicros ?? meter?.cost_micros;
409 if (typeof cost === "number" && cost > 0) value = cost;
410 } catch (error) {
411 this.log("compute gate: price book unavailable", String(error));
412 }
413 this.price = { value, until: Date.now() + PRICE_SECONDS * 1000 };
414 return value;
415 }
416
417 /**
418 * Asks billing to reserve what `request` is expected to cost. Refused
419 * when billing refuses, or when billing cannot be reached for a workspace
420 * that is not known to pay (see the module's comment). Never throws.
421 */
422 async admit(request: ReserveRequest, known?: ComputeEntitlements | null): Promise<Admission> {
423 const workspace = request.workspace.toLowerCase();
424 const ent = known === undefined ? await this.entitlements(workspace) : known;
425 const refuse = (code: GateRefusalCode, message?: string | null): Admission => ({
426 ok: false,
427 code,
428 message: message || refusalMessage(code, workspace, request.kind, ent?.paused),
429 entitlements: ent,
430 });
431 if (ent?.paused) return refuse("paused");
432 const plan = ent?.plan ?? (await this.memory.get(workspace));
433 let answer: Result<unknown>;
434 try {
435 answer = await this.call<Result<unknown>>("reserve", {
436 workspace,
437 repo: request.repo,
438 public: request.public,
439 kind: request.kind,
440 estimateMicros: Math.max(0, Math.ceil(request.estimateMicros)),
441 });
442 } catch (error) {
443 return this.unavailable(plan, refuse, ent, request, String(error));
444 }
445 if (answer.ok) {
446 const reservation = readReservation(answer.value);
447 if (!reservation) return this.unavailable(plan, refuse, ent, request, "reserve answered without a reservation");
448 return { ok: true, reservation, entitlements: ent };
449 }
450 const code = refusalCode(answer.error as { code?: unknown; reason?: unknown });
451 if (!code) return this.unavailable(plan, refuse, ent, request, answer.error.message);
452 // A pause holds for every plan: g1t's own caps (a comped account's
453 // monthly budget, the daily spend breaker) and staff holds included.
454 if (alwaysPasses(plan) && code !== "paused") {
455 this.log("compute gate: refusal ignored for", plan, workspace, request.kind, code, answer.error.message);
456 return { ok: true, reservation: null, entitlements: ent };
457 }
458 // Billing's own words when it gave them; they carry the link to act on.
459 return refuse(code, answer.error.message || null);
460 }
461
462 private unavailable(
463 plan: ComputePlan | null,
464 refuse: (code: GateRefusalCode, message?: string | null) => Admission,
465 ent: ComputeEntitlements | null,
466 request: ReserveRequest,
467 why: string,
468 ): Admission {
469 if (onBillingError(plan) === "allow") {
470 this.log("compute gate: billing unavailable; allowed for", plan, request.workspace, request.kind, why);
471 return { ok: true, reservation: null, entitlements: ent };
472 }
473 this.log("compute gate: billing unavailable; refused for", plan ?? "unknown plan", request.workspace, request.kind, why);
474 return refuse("billing_unavailable");
475 }
476
477 /** Settles a reservation at what the work cost. Never throws; a failure is logged. */
478 async settle(reservationId: string, actual: number): Promise<void> {
479 const micros = Math.max(0, Math.ceil(actual));
480 try {
481 const settled = await this.call<Result<unknown>>("settle", { reservationId, actualMicros: micros });
482 if (settled && settled.ok === false) this.log("compute gate: settle refused", reservationId, settled.error.message);
483 } catch (error) {
484 this.log("compute gate: settle failed", reservationId, micros, String(error));
485 }
486 }
487}