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.
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 1 | /** |
| 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. | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 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 | */ | |
| 32 | import type { ServiceBinding } from "./clients"; | |
| 33 | import type { RepoPath } from "./repos"; | |
| 34 | import type { Result } from "./result"; | |
| 35 | ||
| 36 | /** What a workspace pays for. */ | |
| 37 | export 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 | */ | |
| 44 | export type ComputeKind = "check" | "workflow" | "queue" | "deploy"; | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 45 | |
| 46 | /** What pays for a reservation. */ | |
| 47 | export type PaidBy = "credit" | "trial" | "oss" | "on_demand"; | |
| 48 | ||
| 49 | /** Why billing refused a reservation. */ | |
| 50 | export 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). | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 55 | */ |
| 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. | 56 | export type GateRefusalCode = ComputeRefusalCode | "billing_unavailable"; |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 57 | |
| 58 | /** The compute side of what a workspace's plan gives it. */ | |
| 59 | export 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. */ |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 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 | ||
| 79 | export type Reservation = { id: string; paidBy: PaidBy }; | |
| 80 | ||
| 81 | /** Compute the open-source pool can pay for, on public repositories. */ | |
| 82 | export const OSS_KINDS: ReadonlySet<ComputeKind> = new Set(["check", "workflow", "queue"]); | |
| 83 | ||
| 84 | const REFUSAL_CODES: ReadonlySet<string> = new Set(["not_paid", "trial_used", "limit", "paused", "oss_pool_empty"]); | |
| 85 | ||
| 86 | // ---- Reading billing's answers ---------------------------------------------- | |
| 87 | ||
| 88 | type Raw = Record<string, unknown>; | |
| 89 | ||
| 90 | /** A field in camelCase or snake_case. */ | |
| 91 | function 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 | ||
| 97 | const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0); | |
| 98 | ||
| 99 | const 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 | */ | |
| 106 | export 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. */ | |
| 132 | export 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 | */ | |
| 146 | export 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. */ | |
| 157 | export 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. */ | |
| 162 | export 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. | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 170 | */ |
| 171 | export 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. */ | |
| 185 | export function billingPath(workspace: string): string { | |
| 186 | return `/${workspace.toLowerCase()}/-/billing`; | |
| 187 | } | |
| 188 | ||
| 189 | const 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. */ | |
| 197 | export 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 | */ | |
| 229 | export 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 | */ | |
| 255 | export const FALLBACK_SANDBOX_MICROS_PER_SECOND = 25; | |
| 256 | ||
| 257 | /** A sandbox for `minutes` at `microsPerSecond`. */ | |
| 258 | export 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. */ |
| 263 | export function actualMicros(seconds: number, microsPerSecond: number): number { | |
| 264 | return Math.ceil(Math.max(0, seconds) * Math.max(0, microsPerSecond)); | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 265 | } |
| 266 | ||
| 267 | // ---- The gate ------------------------------------------------------------------- | |
| 268 | ||
| 269 | /** Where the last plan seen for each workspace is kept, for when billing is down. */ | |
| 270 | export 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. */ | |
| 276 | type PlanCache = { | |
| 277 | match(key: string): Promise<Response | undefined>; | |
| 278 | put(key: string, response: Response): Promise<void>; | |
| 279 | }; | |
| 280 | ||
| 281 | const PLAN_MEMORY_SECONDS = 24 * 60 * 60; | |
| 282 | const 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 | */ | |
| 288 | export 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 | ||
| 317 | function planKey(workspace: string): string { | |
| 318 | return `https://compute-gate.g1t.internal/plan/${encodeURIComponent(workspace)}`; | |
| 319 | } | |
| 320 | ||
| 321 | export 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. */ | |
| 330 | export type Admission = | |
| 331 | | { ok: true; reservation: Reservation | null; entitlements: ComputeEntitlements | null } | |
| 332 | | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null }; | |
| 333 | ||
| 334 | const 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 | */ | |
| 341 | const HOLDING_BACK_SECONDS = 3; | |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 342 | const PRICE_SECONDS = 10 * 60; |
| 343 | ||
| Merge branch 'worktree-agent-a985c01f43538af01' | 344 | /** How long the gate keeps an entitlements answer, in seconds. */ |
| 345 | export 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 | ||
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 350 | export 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 }); |
| Security: postcss-selector-parser 7.1.6 and basic-ftp 6.2.2, pinned by overrides | 392 | await this.memory.put(slug, ent.plan); |
| 393 | return ent; | |
| 394 | } catch (error) { | |
| 395 | this.log("compute gate: entitlements unavailable", slug, String(error)); | |
| 396 | return null; | |
| 397 | } | |
| 398 | } | |
| 399 | ||
| 400 | /** What one second of a sandbox costs g1t, from the price book. */ | |
| 401 | async microsPerSecond(): Promise<number> { | |
| 402 | if (this.price && this.price.until > Date.now()) return this.price.value; | |
| 403 | let value = FALLBACK_SANDBOX_MICROS_PER_SECOND; | |
| 404 | try { | |
| 405 | const book = await this.call<{ prices?: { meter: string; costMicros?: number; cost_micros?: number }[] }>("prices", {}); | |
| 406 | const meter = book.prices?.find((price) => price.meter === "sandbox_second"); | |
| 407 | const cost = meter?.costMicros ?? meter?.cost_micros; | |
| 408 | if (typeof cost === "number" && cost > 0) value = cost; | |
| 409 | } catch (error) { | |
| 410 | this.log("compute gate: price book unavailable", String(error)); | |
| 411 | } | |
| 412 | this.price = { value, until: Date.now() + PRICE_SECONDS * 1000 }; | |
| 413 | return value; | |
| 414 | } | |
| 415 | ||
| 416 | /** | |
| 417 | * Asks billing to reserve what `request` is expected to cost. Refused | |
| 418 | * when billing refuses, or when billing cannot be reached for a workspace | |
| 419 | * that is not known to pay (see the module's comment). Never throws. | |
| 420 | */ | |
| 421 | async admit(request: ReserveRequest, known?: ComputeEntitlements | null): Promise<Admission> { | |
| 422 | const workspace = request.workspace.toLowerCase(); | |
| 423 | const ent = known === undefined ? await this.entitlements(workspace) : known; | |
| 424 | const refuse = (code: GateRefusalCode, message?: string | null): Admission => ({ | |
| 425 | ok: false, | |
| 426 | code, | |
| 427 | message: message || refusalMessage(code, workspace, request.kind, ent?.paused), | |
| 428 | entitlements: ent, | |
| 429 | }); | |
| 430 | if (ent?.paused) return refuse("paused"); | |
| 431 | const plan = ent?.plan ?? (await this.memory.get(workspace)); | |
| 432 | let answer: Result<unknown>; | |
| 433 | try { | |
| 434 | answer = await this.call<Result<unknown>>("reserve", { | |
| 435 | workspace, | |
| 436 | repo: request.repo, | |
| 437 | public: request.public, | |
| 438 | kind: request.kind, | |
| 439 | estimateMicros: Math.max(0, Math.ceil(request.estimateMicros)), | |
| 440 | }); | |
| 441 | } catch (error) { | |
| 442 | return this.unavailable(plan, refuse, ent, request, String(error)); | |
| 443 | } | |
| 444 | if (answer.ok) { | |
| 445 | const reservation = readReservation(answer.value); | |
| 446 | if (!reservation) return this.unavailable(plan, refuse, ent, request, "reserve answered without a reservation"); | |
| 447 | return { ok: true, reservation, entitlements: ent }; | |
| 448 | } | |
| 449 | const code = refusalCode(answer.error as { code?: unknown; reason?: unknown }); | |
| 450 | if (!code) return this.unavailable(plan, refuse, ent, request, answer.error.message); | |
| 451 | // A pause holds for every plan: g1t's own caps (a comped account's | |
| 452 | // monthly budget, the daily spend breaker) and staff holds included. | |
| 453 | if (alwaysPasses(plan) && code !== "paused") { | |
| 454 | this.log("compute gate: refusal ignored for", plan, workspace, request.kind, code, answer.error.message); | |
| 455 | return { ok: true, reservation: null, entitlements: ent }; | |
| 456 | } | |
| 457 | // Billing's own words when it gave them; they carry the link to act on. | |
| 458 | return refuse(code, answer.error.message || null); | |
| 459 | } | |
| 460 | ||
| 461 | private unavailable( | |
| 462 | plan: ComputePlan | null, | |
| 463 | refuse: (code: GateRefusalCode, message?: string | null) => Admission, | |
| 464 | ent: ComputeEntitlements | null, | |
| 465 | request: ReserveRequest, | |
| 466 | why: string, | |
| 467 | ): Admission { | |
| 468 | if (onBillingError(plan) === "allow") { | |
| 469 | this.log("compute gate: billing unavailable; allowed for", plan, request.workspace, request.kind, why); | |
| 470 | return { ok: true, reservation: null, entitlements: ent }; | |
| 471 | } | |
| 472 | this.log("compute gate: billing unavailable; refused for", plan ?? "unknown plan", request.workspace, request.kind, why); | |
| 473 | return refuse("billing_unavailable"); | |
| 474 | } | |
| 475 | ||
| 476 | /** Settles a reservation at what the work cost. Never throws; a failure is logged. */ | |
| 477 | async settle(reservationId: string, actual: number): Promise<void> { | |
| 478 | const micros = Math.max(0, Math.ceil(actual)); | |
| 479 | try { | |
| 480 | const settled = await this.call<Result<unknown>>("settle", { reservationId, actualMicros: micros }); | |
| 481 | if (settled && settled.ok === false) this.log("compute gate: settle refused", reservationId, settled.error.message); | |
| 482 | } catch (error) { | |
| 483 | this.log("compute gate: settle failed", reservationId, micros, String(error)); | |
| 484 | } | |
| 485 | } | |
| 486 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.