Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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