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
486 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Cache hits are counted, not only their bytes1/**
2 * Compute gating: whether a workspace may start something that costs g1t
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.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.
Cache hits are counted, not only their bytes7 *
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
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.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";
Cache hits are counted, not only their bytes45
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/**
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.53 * Codes the gate itself refuses with, besides billing's:
54 * `billing_unavailable` (a free workspace while billing cannot be reached).
Cache hits are counted, not only their bytes55 */
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.56export type GateRefusalCode = ComputeRefusalCode | "billing_unavailable";
Cache hits are counted, not only their bytes57
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;
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.71 /** The most one sandbox run may cost; zero means no cap. */
Cache hits are counted, not only their bytes72 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:
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.168 * the note people see before they try. Billing's `reserve` is the
169 * decision. Null when it can; the refusal when not.
Cache hits are counted, not only their bytes170 */
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
The contracts no longer describe g1t's own agents, and workspaces can keep their AI providers. Every consumer had already stopped using them, so the TypeScript and Rust contracts drop the workspace-agent, ability, look, skill-library, context, runner-agent and mention modules, the chat and docs RPCs agents posted and read through, the folio agent methods, the agent_waiting notification, agent.asked and memory.changed events, AI credit, token and gateway billing types, model routes and sessions, team agents, agent tokens, plan, review, message and confidence types, command rules, cost caps and confidence rules. The agents:run and models:* scopes are no longer offered by any preset, OAuth default or permission form, but a stored token or grant that names them still reads; and the removed operations are gone from the scope table, so the API's check that every operation has exactly one scope is strict again. What stored data needs in order to parse (old run and credential kinds, an agent team lead, retired rules and webhook events, old guardrail and integration settings) still parses, and a run's token is still PrincipalKind::Agent, which now means a run's token and the @g1t rows written before. The customize, marketplace, skills and claude-plugin contracts stay only until the mobile app drops its Customize screens.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));
Cache hits are counted, not only their bytes265}
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;
Merge branch 'worktree-agent-a985c01f43538af01'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;
Cache hits are counted, not only their bytes342const PRICE_SECONDS = 10 * 60;
343
Merge branch 'worktree-agent-a985c01f43538af01'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
Cache hits are counted, not only their bytes350export 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");
Merge branch 'worktree-agent-a985c01f43538af01'391 this.ents.set(slug, { value: ent, until: Date.now() + entitlementsKeptSeconds(ent) * 1000 });
Cache hits are counted, not only their bytes392 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}

This file's history is long; its oldest lines are credited to the oldest commit read.