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
487 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.

Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged1/**
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.
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged7 *
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";
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged45
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).
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged55 */
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";
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged57
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. */
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged72 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.
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged170 */
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
Merge branch 'worktree-agent-a56293a6f836cd343'252 * the price book cannot be read: the standard sandbox (standard-3: 2 vCPU,
253 * 8 GiB, 16 GB) with both vCPUs busy and its Durable Object, 62.68 at list
254 * prices, rounded up.
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged255 */
Merge branch 'worktree-agent-a56293a6f836cd343'256export const FALLBACK_SANDBOX_MICROS_PER_SECOND = 63;
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged257
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
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.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));
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged266}
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;
Merge branch 'worktree-agent-a985c01f43538af01'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;
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged343const PRICE_SECONDS = 10 * 60;
344
Merge branch 'worktree-agent-a985c01f43538af01'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
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged351export 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");
Merge branch 'worktree-agent-a985c01f43538af01'392 this.ents.set(slug, { value: ent, until: Date.now() + entitlementsKeptSeconds(ent) * 1000 });
Billing and Usage reconcile: own-provider runs leave Billing's at-price total, and Usage's not-charged part is at price less charged393 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}

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