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.

1,425 linesCodeBlameRaw
1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type BumpArgs,
6 UPDATE_BRANCH_PREFIX,
7 type RunKind,
8 agentsClient,
9 type G1tEvent,
10 type QueueJob,
11 type RepoPath,
12 type Result,
13 type ServiceBinding,
14 type User,
15 type Viewer,
16 type ComputeEntitlements,
17 type ComputeKind,
18 ComputeGate,
19 actualMicros,
20 eventsClient,
21 refusalMessage,
22 sandboxEstimateMicros,
23 billingClient,
24 fail,
25 ok,
26 reposClient,
27 workClient,
28 type InstanceType,
29 STANDARD_INSTANCE,
30 instanceNamed,
31 priceScale,
32 runnerMinutes,
33} from "@g1t/contracts";
34
35import { BUMP_MINUTES, BUMP_TOKEN_TTL_SECONDS, bumpEnv, bumpProblem, bumpSandboxName, systemActor } from "./bump";
36import { BACKUP_MINUTES, backupEnv, backupPace, backupSandboxName } from "./backup";
37import { holdCredentials, revokeCredentials, runCredential } from "./credentials";
38import { cancelTask, enqueueTask, handedOverStep, selfHostedRoute, taskEnv, taskRepo } from "./self-hosted";
39import { answered, capReached, describeError, type Owed, recordOwed, stoppedShort, tellStopped, withinTimeCap } from "./lifecycle";
40import {
41 ABUSE_EXIT_CODE,
42 ABUSE_HOST,
43 ABUSE_MESSAGE,
44 ALARM_GRACE_SECONDS,
45 type PlanLimits,
46 type RunGuard,
47 abuse,
48 buildGuard,
49 dockerFor,
50 egress,
51 guardFor,
52 harnessEnv,
53 reportRun,
54 SANDBOX_BINDINGS,
55 sandboxNamespace,
56 throughWorker,
57 timeCapMessage,
58 withPlanLimits,
59} from "./guard";
60import { newlyMisdirected } from "./broker";
61import { relay, relayDeps } from "./broker-relay";
62import { forward } from "./forward";
63import { EGRESS_ENV, jobNamespaceOf } from "./egress";
64
65// Outbound interception, which brokered secrets and abuse reports use,
66// needs this exported.
67export { ContainerProxy } from "@cloudflare/containers";
68
69export interface RunnerEnv {
70 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
71 /**
72 * Workflow jobs that ask with `runs-on` for `g1t-2core` (the standard
73 * machine, kept as a name so the label keeps working) or `g1t-4core` (a
74 * larger instance type): the same image, in classes of their own.
75 */
76 SANDBOX_2CORE?: DurableObjectNamespace<Sandbox2Core>;
77 SANDBOX_4CORE?: DurableObjectNamespace<Sandbox4Core>;
78 IDENTITY: ServiceBinding;
79 REPOS: ServiceBinding;
80 WORK: ServiceBinding;
81 BILLING: ServiceBinding;
82 /** GitHub Actions jobs: told when a job's sandbox dies without reporting. */
83 ACTIONS: ServiceBinding;
84 /** Told when a deploy sandbox dies without reporting. */
85 DEPLOYMENTS: ServiceBinding;
86 /** The event bus: `abuse.flagged`, for g1t's staff. */
87 EVENTS?: ServiceBinding;
88 CLOUDFLARE_ACCOUNT_ID: string;
89 /**
90 * `off` stops passing any sandbox's HTTP and HTTPS through the Worker: a
91 * switch for the operator, should it misbehave. Brokered secrets' stand-
92 * ins then go out as they are, unfilled. Anything else passes the
93 * requests of workflow jobs that may be given brokered secrets.
94 */
95 EGRESS?: string;
96 /**
97 * `off` stops sandboxes watching themselves for mining (crates/runner
98 * abuse.rs): a switch for the operator, should it stop real work.
99 * Anything else leaves it on. Miners named in commands are refused
100 * either way.
101 */
102 ABUSE_WATCH?: string;
103 /**
104 * `off` leaves workflow jobs without a Docker Engine of their own
105 * (crates/runner docker/): a switch for the operator. Anything else
106 * gives each job one, started the first time it is used.
107 */
108 DOCKER?: string;
109 /**
110 * Nightly backups (backup.ts): how many queued backups one sweep starts
111 * (`0`: none, backups off here), and how many may run at once.
112 */
113 BACKUPS_PER_SWEEP?: string;
114 BACKUPS_RUNNING?: string;
115}
116
117/**
118 * What a sandbox is doing. Runs of a work item's commands (`checks`) are from
119 * before checks were workflows; a sandbox that was started for work g1t no
120 * longer does is told apart by its kind and only cleaned up.
121 */
122type Run =
123 | { kind: "checks"; runId: string; token: string }
124 /** One combined state of a merge queue, being built and checked. */
125 | { kind: "queue"; entryId: string; token: string }
126 /** Whether a pull request merges cleanly: two commits merged, nothing pushed. */
127 | { kind: "mergecheck"; pullId: string; token: string }
128 /** One job of a GitHub Actions workflow. */
129 | { kind: "actions"; jobId: string; token: string }
130 /** A build of one commit, deployed to g1t.page. */
131 | { kind: "deploy"; deployId: string; token: string }
132 /**
133 * A security update: one package raised in its lockfiles and pushed to
134 * its branch. The security service opens the pull request when it hears
135 * the push, so a failure has no one to tell.
136 */
137 | { kind: "bump"; repo: RepoPath; branch: string }
138 /**
139 * A repository's nightly backup: a bundle cut and sent to the repos
140 * service. g1t's own work, never charged to the workspace.
141 */
142 | { kind: "backup"; jobId: string; token: string };
143/**
144 * Whose sandbox time it is, reported when the sandbox stops, and the
145 * machine it ran on when it was not the standard one.
146 */
147type Meter = {
148 workspace: string;
149 repo: string;
150 description: string;
151 instance?: string | null;
152};
153/** A stopped sandbox's time, as billing records it (`recordSandbox`). */
154type SandboxUsage = Parameters<ReturnType<typeof billingClient>["recordSandbox"]>[0];
155/**
156 * What billing reserved for a sandbox's work (`ComputeGate.admit`), settled
157 * when it stops at what it cost: its seconds.
158 */
159type Held = { id: string; workspace: string; microsPerSecond: number };
160/**
161 * A sandbox that is not tracked as a run but still has a time cap: a
162 * workflow job, a deploy build or an update, for `minutes` at most.
163 */
164type Build = {
165 kind: "actions" | "deploy" | "bump";
166 minutes: number;
167 /**
168 * A workflow job that may be given brokered secrets: its HTTP and HTTPS
169 * pass the Worker, which fills them in.
170 */
171 brokered?: boolean;
172};
173/** Deploy builds are metered by the Deployments plan, not here. */
174type RunRequest = Run & {
175 envVars: Record<string, string>;
176 meter?: Meter;
177 track?: Track;
178 /** The workspace's plan's caps, applied under its guardrails' (lower of each). */
179 limits?: PlanLimits;
180 reservation?: Held | null;
181 build?: Build;
182 /** Whose sandbox it is, when it has no meter: for `abuse.flagged`. */
183 owner?: { workspace: string; repo: string };
184 /**
185 * The labels of the workspace's self-hosted runners this work goes to
186 * instead of a container (self-hosted.ts). Null or absent: a container.
187 */
188 selfHosted?: string[] | null;
189};
190
191/** What a sandbox is, as billing meters it. */
192function computeKindOf(kind: Run["kind"]): ComputeKind | null {
193 switch (kind) {
194 case "checks":
195 case "mergecheck":
196 // A security update resolves lockfiles, as cheap as a check.
197 case "bump":
198 return "check";
199 case "queue":
200 return "queue";
201 case "actions":
202 return "workflow";
203 case "deploy":
204 return "deploy";
205 // Not metered: a backup is g1t's own cost; nor is a sandbox of a kind
206 // g1t no longer starts, which ran before it stopped starting them.
207 default:
208 return null;
209 }
210}
211
212/** One gate per isolate, so entitlements and prices are kept between calls. */
213let gate: ComputeGate | null = null;
214function gateFor(env: { BILLING: ServiceBinding }): ComputeGate {
215 gate ??= new ComputeGate(env.BILLING);
216 return gate;
217}
218
219/**
220 * What to record the sandbox as, so people can watch it: a run of the
221 * merge queue.
222 */
223type Track = {
224 actor: User;
225 repo: RepoPath;
226 kind: RunKind;
227 number?: number | null;
228 pullId?: string | null;
229 title?: string | null;
230 startedBy?: string | null;
231};
232/** The run a sandbox reports to, kept so it can be closed when it stops. */
233type TrackedRun = { runId: string; token: string };
234
235function meter(repo: RepoPath, description: string): Meter {
236 return { workspace: repo.namespace, repo: `${repo.namespace}/${repo.name}`, description };
237}
238
239/** What the deployments service asks a sandbox to build. */
240type DeployJob = {
241 deployId: string;
242 /** The workspace the project is in, which pays. */
243 workspace?: string;
244 /** What the deployments service reserved for the build, settled when it stops. */
245 reservation?: string | null;
246 /** The price it reserved at, per second. */
247 microsPerSecond?: number | null;
248 /** The plan's longest run, in minutes; the build gets the lower of this and its own. */
249 maxRunMinutes?: number | null;
250 /** Lets the sandbox, and nothing else, report this build. */
251 token: string;
252 /** Whose access reads the commit. */
253 actor: User;
254 /** The repository the commit is in: the pull request's fork, or the repository. */
255 source: RepoPath;
256 /**
257 * The project's repository, whose guardrails the build runs under, and
258 * its id. A preview's `source` is its pull request's working copy, so
259 * the two differ. Older callers send only `source`.
260 */
261 repo?: RepoPath | null;
262 repoId?: string | null;
263 commit: string;
264 /** Where in the repository the project lives; empty for all of it. */
265 rootDir?: string;
266 buildCommand?: string | null;
267 outputDir?: string | null;
268 /** What it is built as (`auto`, `static`, `workers` or `docker`), and its Dockerfile's path when set. */
269 buildAs?: string | null;
270 dockerfile?: string | null;
271 /** The repository's variables for deploy builds. */
272 buildEnv?: Record<string, string>;
273 /** Its secrets for deploy builds: set like variables, and redacted from the log. */
274 buildSecrets?: Record<string, string>;
275};
276
277/** Long enough to install and build; then the read token stops working. */
278const DEPLOY_TOKEN_TTL_SECONDS = 30 * 60;
279
280/** Long enough to clone, build and test a merge queue's state; then the token stops working. */
281const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
282
283/** Long enough to clone and merge two commits; then the read token stops working. */
284const MERGECHECK_TOKEN_TTL_SECONDS = 10 * 60;
285
286/**
287 * One sandbox, for one piece of work: a workflow job, a build, a state of
288 * the merge queue, a merge check, an update or a backup. The image's entrypoint
289 * is the g1t runner, which does the work and exits; this class only starts
290 * it and cleans up if it dies without reporting.
291 */
292export class AttemptSandbox extends Container<RunnerEnv> {
293 // Past the longest default time cap (90 minutes) and its alarm. A run whose guardrails allow longer (up to 240 minutes) is kept
294 // past it by `onActivityExpired`, so only its own cap ends it. A finished
295 // run's process exits and stops the sandbox well before this.
296 sleepAfter = "100m";
297 // HTTPS to an intercepted host goes through its handler too (guard.ts).
298 interceptHttps = true;
299 static {
300 // Assigned, not declared: a class field would hide the setter that
301 // registers the handler with the containers library. Which one a
302 // sandbox uses is chosen as it starts (`run`).
303 AttemptSandbox.outboundHandlers = { egress, abuse };
304 }
305
306 async run(request: RunRequest): Promise<void> {
307 const { envVars, meter, track, limits, reservation, build, owner, selfHosted, ...run } = request;
308 // What billing reserved is settled however this ends, once.
309 if (reservation) await this.ctx.storage.put("reservation", reservation);
310 let guard: RunGuard | null;
311 try {
312 // A tracked run gets its project's guardrails; no sandbox for one
313 // starts without them. Workflow jobs, deploy builds and updates get
314 // the time cap they were given. The plan's caps apply under them:
315 // the lower of each.
316 guard = track
317 ? withPlanLimits(await guardFor(this.env.WORK, track.repo, track.kind), limits)
318 : build
319 ? withPlanLimits(buildGuard(build.minutes), limits)
320 : null;
321 } catch (error) {
322 // Thrown to the caller, which says why the work did not start; logged
323 // here too, so a sandbox that never started is traceable on its own.
324 console.error("sandbox not started", run.kind, describeError(error));
325 await this.settle(0);
326 throw error;
327 }
328 await this.ctx.storage.put("run", run);
329 // What an earlier attempt in this object left: its reason, its cap
330 // and its cap's alarm, which would otherwise stop this attempt when
331 // the earlier one's time ran out (re-runs use the same object).
332 await this.ctx.storage.delete(["abuse", "stopReason", "remote", "misdirected", "timeCap"]);
333 this.deleteSchedules("timeUp");
334 if (meter) await this.ctx.storage.put("meter", { ...meter, started: Date.now() });
335 await this.ctx.storage.put("started", Date.now());
336 const who = meter ? { workspace: meter.workspace, repo: meter.repo } : owner;
337 if (who) await this.ctx.storage.put("owner", { ...who, kind: track?.kind ?? run.kind });
338 const tracked = track ? await this.openRun(track, envVars, guard) : null;
339 // Its credentials are tied to the run, and revoked when it stops.
340 await holdCredentials(this.env.IDENTITY, this.ctx.storage, envVars, tracked?.runId ?? null);
341 try {
342 const vars = tracked ? { ...envVars, AGENT_RUN: tracked.runId, AGENT_RUN_TOKEN: tracked.token } : envVars;
343 // The workspace's own runner, not a container: the same environment,
344 // handed over as a task.
345 const repo = selfHosted?.length ? taskRepo(track, meter, owner) : null;
346 if (selfHosted?.length && repo) {
347 const harness = guard ? harnessEnv(guard, vars) : {};
348 const minutes = guard?.minutes ?? limits?.minutes ?? 60;
349 await enqueueTask(this.env.ACTIONS, {
350 sandbox: this.ctx.id.toString(),
351 repo,
352 kind: track?.kind ?? run.kind,
353 title: track?.title ?? meter?.description ?? `${run.kind} in ${repo.namespace}/${repo.name}`,
354 labels: selfHosted,
355 env: taskEnv({ ...vars, ...harness }),
356 timeoutMinutes: minutes,
357 runId: tracked?.runId ?? null,
358 });
359 await this.ctx.storage.put("remote", true);
360 if (tracked) await reportRun(this.env.WORK, tracked, { steps: [handedOverStep(selfHosted)] });
361 if (guard) {
362 await this.ctx.storage.put("timeCap", guard.minutes);
363 await this.schedule(guard.minutes * 60 + ALARM_GRACE_SECONDS, "timeUp");
364 }
365 return;
366 }
367 // Every sandbox reaches the internet. A workflow job that may be
368 // given brokered secrets sends its HTTP and HTTPS through `egress`,
369 // which fills them in from the job's stand-ins (asked for with the
370 // job's token) and sends every request on, to any host; any other
371 // sandbox has only `ABUSE_HOST` intercepted, so it can report that it
372 // stopped itself for mining. Both are chosen here, as it starts: hosts
373 // named on the class up front (`outboundByHost` in a static block)
374 // left sandboxes that never picked up their jobs (rolled back
375 // 2026-10-13).
376 const brokering = run.kind === "actions" && throughWorker(build, this.env.EGRESS);
377 this.enableInternet = true;
378 if (brokering && run.kind === "actions") {
379 await this.setOutboundHandler("egress", { job: { id: run.jobId, token: run.token } });
380 } else if (this.env.EGRESS?.trim().toLowerCase() !== "off") {
381 await this.setOutboundByHost(ABUSE_HOST, "abuse").catch((error: unknown) => console.log("abuse reports not routed", String(error)));
382 }
383 const harness = guard && !build ? harnessEnv(guard, vars) : {};
384 // Once any of its traffic is intercepted, Cloudflare passes all of it
385 // through this Worker, and HTTPS is re-signed on the way
386 // (`interceptHttps`); so a sandbox with either handler trusts that
387 // certificate, or every HTTPS request it makes fails
388 // (SELF_SIGNED_CERT_IN_CHAIN, as on 2026-10-13).
389 const intercepted = brokering || this.env.EGRESS?.trim().toLowerCase() !== "off";
390 const trust = intercepted ? EGRESS_ENV : {};
391 const watch: Record<string, string> = this.env.ABUSE_WATCH === "off" ? { G1T_ABUSE: "off" } : {};
392 await this.start({ envVars: { ...vars, ...harness, ...trust, ...watch }, enableInternet: true });
393 // A backup has no guardrails, but still a time cap.
394 const cap = guard?.minutes ?? (run.kind === "backup" ? BACKUP_MINUTES : null);
395 if (cap) {
396 await this.ctx.storage.put("timeCap", cap);
397 await this.schedule(cap * 60 + ALARM_GRACE_SECONDS, "timeUp");
398 }
399 } catch (error) {
400 console.error("sandbox not started", run.kind, describeError(error));
401 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
402 if (tracked) await this.closeRun("failed", `The sandbox could not start: ${String(error)}`);
403 await this.settle(0);
404 throw error;
405 }
406 }
407
408 /** Settles what billing reserved for this sandbox at `micros`, once. */
409 private async settle(micros: number): Promise<void> {
410 const held = await this.ctx.storage.get<Held>("reservation");
411 if (!held) return;
412 await this.ctx.storage.delete("reservation");
413 await gateFor(this.env).settle(held.id, micros);
414 }
415
416 /** Settles the reservation at what the sandbox cost: its seconds at the price billing reserved at. */
417 private async settleStopped(started: number | undefined): Promise<void> {
418 const held = await this.ctx.storage.get<Held>("reservation");
419 if (!held) return;
420 const seconds = started ? Math.max(1, Math.ceil((Date.now() - started) / 1000)) : 0;
421 await this.settle(actualMicros(seconds, held.microsPerSecond));
422 }
423
424 /**
425 * The sandbox stopped itself because it looked like it was mining
426 * (crates/runner abuse.rs), or exited saying so. Stops the run with
427 * `ABUSE_MESSAGE`, tells g1t's staff with `abuse.flagged`, and destroys
428 * the sandbox. Once.
429 */
430 async flagAbuse(verdict: unknown): Promise<void> {
431 if (await this.ctx.storage.get<boolean>("abuse")) return;
432 await this.ctx.storage.put("abuse", true);
433 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
434 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "abuse", error: ABUSE_MESSAGE });
435 const owner = await this.ctx.storage.get<{ workspace: string; repo: string | null; kind: string }>("owner");
436 console.log("abuse flagged", owner?.workspace, owner?.repo, owner?.kind, JSON.stringify(verdict));
437 if (this.env.EVENTS && owner) {
438 await eventsClient(this.env.EVENTS)
439 .publish([
440 {
441 type: "abuse.flagged",
442 source: "runner",
443 // Never on a repository's timeline or its webhooks.
444 repoId: null,
445 actor: null,
446 data: {
447 workspace: owner.workspace,
448 repo: owner.repo ?? null,
449 run: tracked?.runId ?? null,
450 kind: owner.kind,
451 sandbox: this.ctx.id.toString(),
452 metrics: verdict && typeof verdict === "object" ? (verdict as Record<string, unknown>) : null,
453 },
454 },
455 ])
456 .catch((error: unknown) => console.log("abuse.flagged not published", String(error)));
457 }
458 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed for abuse", String(error)));
459 }
460
461 /**
462 * Records the run, which the sandbox then reports its steps to. Never
463 * stops the sandbox from starting: without a record it just goes unseen.
464 */
465 private async openRun(track: Track, envVars: Record<string, string>, guard: RunGuard | null): Promise<TrackedRun | null> {
466 const opened = await agentsClient(this.env.WORK)
467 .openRun({
468 ...track,
469 model: null,
470 sandbox: this.ctx.id.toString(),
471 timeCapMinutes: guard?.minutes ?? null,
472 })
473 .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } }));
474 if (!opened.ok) {
475 console.log("run not recorded", track.kind, opened.error.message);
476 return null;
477 }
478 await this.ctx.storage.put("agentRun", opened.value);
479 return opened.value;
480 }
481
482 /**
483 * A workflow job sent a brokered secret's stand-in to a host it is not
484 * for: said once per secret and host, in the job's log.
485 */
486 async noteMisdirected(name: string, host: string): Promise<void> {
487 const run = await this.ctx.storage.get<Run>("run");
488 if (run?.kind !== "actions") return;
489 const noted = newlyMisdirected((await this.ctx.storage.get<string[]>("misdirected")) ?? [], name, host);
490 if (!noted) return;
491 await this.ctx.storage.put("misdirected", noted.seen);
492 try {
493 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
494 method: "POST",
495 headers: { "content-type": "application/json" },
496 body: JSON.stringify({ job: run.jobId, token: run.token, report: { kind: "note", text: noted.note } }),
497 });
498 await answered("job_report", response);
499 } catch (error) {
500 console.log("misdirected secret not noted", name, String(error));
501 }
502 }
503
504 /** The run's time cap has passed: stop it, as stopped for time. */
505 async timeUp(): Promise<void> {
506 // It already stopped (nothing to stop), or the run in it now is a later
507 // attempt whose cap has not passed: this alarm is an earlier attempt's,
508 // left by a version of g1t that did not clear it, and the run now has
509 // its own.
510 const started = await this.ctx.storage.get<number>("started");
511 const minutes = (await this.ctx.storage.get<number>("timeCap")) ?? 0;
512 if (!capReached(started, minutes, Date.now())) {
513 if (started) console.log("an earlier attempt's time cap passed; the run now keeps going", this.ctx.id.toString());
514 return;
515 }
516 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
517 // A workflow job or a build has no run to halt: it fails saying why.
518 await this.ctx.storage.put("stopReason", timeCapMessage(minutes));
519 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "time", error: timeCapMessage(minutes) });
520 if (await this.ctx.storage.get<boolean>("remote")) {
521 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), timeCapMessage(minutes));
522 await this.remoteEnded(1, null);
523 return;
524 }
525 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed at its time cap", String(error)));
526 }
527
528 /**
529 * Stops this sandbox's work: its container, or the task a self-hosted
530 * runner holds, which it hears about on its next poll.
531 */
532 async halt(reason: string | null): Promise<void> {
533 if (await this.ctx.storage.get<boolean>("remote")) {
534 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), reason);
535 await this.remoteEnded(1, reason);
536 return;
537 }
538 // A container already gone has nothing to stop; one that will not stop
539 // is logged, and its time cap still ends it.
540 await this.destroy().catch((error: unknown) => console.error("sandbox not destroyed", reason, describeError(error)));
541 }
542
543 /**
544 * The library's `sleepAfter` has passed. The runner never fetches its
545 * container, so to the library every sandbox looks idle: a run inside its
546 * time cap keeps going, and the cap's own alarm (`timeUp`) ends it. Only
547 * a sandbox with no cap is stopped for inactivity.
548 */
549 override async onActivityExpired(): Promise<void> {
550 const started = await this.ctx.storage.get<number>("started");
551 const cap = await this.ctx.storage.get<number>("timeCap");
552 if (withinTimeCap(started, cap, Date.now(), ALARM_GRACE_SECONDS)) return;
553 await super.onActivityExpired();
554 }
555
556 /**
557 * A container that crashed or could not be reached, as the library tells
558 * it. Logged at error level with the sandbox, never thrown: the library
559 * ignores what this throws, and the stop that follows is handled by
560 * `onStop` or by `run`, which says why the work did not start.
561 */
562 override onError(error: unknown): void {
563 console.error("sandbox container error", this.ctx.id.toString(), describeError(error));
564 }
565
566 /**
567 * The library's alarm: scheduled callbacks, the container's keep-alive,
568 * and `onStop` once it has stopped. A failure is logged with the retry it
569 * was, then thrown so Cloudflare tries the alarm again.
570 */
571 override async alarm(alarmProps?: AlarmInvocationInfo): Promise<void> {
572 try {
573 await super.alarm(alarmProps);
574 } catch (error) {
575 console.error("sandbox alarm failed", this.ctx.id.toString(), `retry ${alarmProps?.retryCount ?? 0}`, describeError(error));
576 throw error;
577 }
578 }
579
580 /**
581 * A self-hosted runner's task ended (the actions service says so, or g1t
582 * stopped it): everything a container's stop does, once.
583 */
584 async remoteEnded(exitCode: number, reason: string | null): Promise<void> {
585 if (!(await this.ctx.storage.get<boolean>("remote"))) return;
586 await this.ctx.storage.delete("remote");
587 await this.ctx.storage.put("selfHostedEnded", true);
588 if (exitCode !== 0 && reason && !(await this.ctx.storage.get<string>("stopReason"))) {
589 await this.ctx.storage.put("stopReason", reason);
590 }
591 await this.onStop({ exitCode, reason: "exit" } as StopParams);
592 await this.ctx.storage.delete("selfHostedEnded");
593 }
594
595 /**
596 * Ends the run's record, once. Returns the status it ended with:
597 * `stopped` when a person stopped it first.
598 */
599 private async closeRun(outcome: "succeeded" | "failed", error?: string): Promise<string | null> {
600 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
601 if (!tracked) return null;
602 await this.ctx.storage.delete("agentRun");
603 const closed = await agentsClient(this.env.WORK)
604 .closeRun(tracked.runId, tracked.token, outcome, error)
605 .catch(() => null);
606 return closed?.ok ? closed.value : null;
607 }
608
609 /**
610 * Reports how long the sandbox ran, once, whatever it exited with. The
611 * usage is fixed now and kept (`owed:<reference>`) until billing
612 * confirms it, tried again from the object's alarm while it does not: a
613 * re-run in this object writes a new `meter`, never over what is owed.
614 * Billing records a reference once, so a try whose answer was lost
615 * cannot charge twice. The reservation is settled apart (`settleStopped`).
616 */
617 private async meterStop(): Promise<void> {
618 const metered = await this.ctx.storage.get<Meter & { started: number }>("meter");
619 if (!metered) return;
620 const seconds = Math.max(1, Math.ceil((Date.now() - metered.started) / 1000));
621 const run = await this.ctx.storage.get<Run>("run");
622 // On the workspace's own runner: its minutes, at $0.
623 const selfHosted = (await this.ctx.storage.get<boolean>("selfHostedEnded")) ?? false;
624 const usage: SandboxUsage = {
625 workspace: metered.workspace,
626 seconds,
627 description: selfHosted ? `${metered.description} on a self-hosted runner` : metered.description,
628 repo: metered.repo,
629 reference: `sandbox/${this.ctx.id.toString()}/${metered.started}`,
630 // Whether g1t's open-source pool may pay for it.
631 kind: run ? computeKindOf(run.kind) : null,
632 selfHosted,
633 instance: metered.instance ?? null,
634 };
635 // One write: the meter becomes what is owed.
636 await this.ctx.storage.put<Owed<SandboxUsage>>(`owed:${usage.reference}`, { usage, attempts: 0, since: Date.now() });
637 await this.ctx.storage.delete("meter");
638 await this.recordOwedTime();
639 }
640
641 /**
642 * Records the sandbox time billing has not confirmed, and schedules the
643 * next try for what it still has not. Never throws: it runs in the stop
644 * hook and in the object's alarm.
645 */
646 async recordOwedTime(): Promise<void> {
647 const owed = [...(await this.ctx.storage.list<Owed<SandboxUsage>>({ prefix: "owed:" })).values()];
648 if (!owed.length) return;
649 const billing = billingClient(this.env.BILLING);
650 const result = await recordOwed(
651 owed,
652 async (usage) => {
653 const recorded = await billing.recordSandbox(usage);
654 if (!recorded.ok) console.log("sandbox time not recorded yet", usage.workspace, usage.seconds, recorded.error.message);
655 return recorded.ok;
656 },
657 Date.now(),
658 );
659 for (const entry of result.recorded) await this.ctx.storage.delete(`owed:${entry.usage.reference}`);
660 for (const entry of result.kept) await this.ctx.storage.put(`owed:${entry.usage.reference}`, entry);
661 for (const entry of result.lost) {
662 console.error("sandbox time not recorded, given up", entry.usage.reference, entry.usage.workspace, entry.usage.seconds, `after ${entry.attempts} tries`);
663 await this.ctx.storage.delete(`owed:${entry.usage.reference}`);
664 }
665 this.deleteSchedules("recordOwedTime");
666 if (result.retryInSeconds !== null) {
667 await this.schedule(result.retryInSeconds, "recordOwedTime").catch((error: unknown) =>
668 console.error("sandbox time retry not scheduled", this.ctx.id.toString(), describeError(error)),
669 );
670 }
671 }
672
673 override async onStop({ exitCode, reason }: StopParams): Promise<void> {
674 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
675 const started = await this.ctx.storage.get<number>("started");
676 await this.meterStop();
677 // It stopped itself for mining, and could not say so before it went.
678 if (exitCode === ABUSE_EXIT_CODE && !(await this.ctx.storage.get<boolean>("abuse"))) {
679 await this.flagAbuse(null);
680 }
681 const flagged = (await this.ctx.storage.get<boolean>("abuse")) ?? false;
682 // Why it stopped, when g1t stopped it: said in place of a plain failure.
683 const why = flagged ? ABUSE_MESSAGE : ((await this.ctx.storage.get<string>("stopReason")) ?? null);
684 // Stopped by g1t, it failed whatever exit code the library passed on.
685 const short = stoppedShort(exitCode, why);
686 await this.closeRun(short ? "failed" : "succeeded", short ? (why ?? `The sandbox exited with ${exitCode}.`) : undefined);
687 await this.settleStopped(started);
688 await this.ctx.storage.delete("started");
689 if (!short) return;
690 const run = await this.ctx.storage.get<Run>("run");
691 console.log("sandbox stopped", run?.kind, "exit", exitCode, reason);
692 if (!run) return;
693 // Never thrown: this runs in the sandbox's alarm, which a throw would
694 // fail, retry and count as an error, running all of the above again.
695 // What could not be told is logged, and the sweep catches it up.
696 await tellStopped(run.kind, () => this.reportStopped(run, why, exitCode));
697 }
698
699 /**
700 * Tells whoever is waiting on the sandbox's work that it stopped without
701 * finishing it. Each is refused harmlessly when the sandbox reported its
702 * end before it stopped. Throws when the service could not be reached.
703 */
704 private async reportStopped(run: Run, why: string | null, exitCode: number): Promise<void> {
705 if (run.kind === "actions") {
706 // Refused harmlessly if the job reported its end before it stopped.
707 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
708 method: "POST",
709 headers: { "content-type": "application/json" },
710 body: JSON.stringify({
711 job: run.jobId,
712 token: run.token,
713 report: { kind: "done", conclusion: "failure", reason: why ?? "The runner stopped before the job finished." },
714 }),
715 });
716 await answered("job_report", response);
717 return;
718 }
719 // Nothing was pushed, so no pull request opens; why is in its log.
720 if (run.kind === "bump") return;
721 if (run.kind === "backup") {
722 // Refused harmlessly if the sandbox reported before it stopped; the
723 // job is otherwise tried again later tonight.
724 await reposClient(this.env.REPOS).failBackup(run.jobId, run.token, why ?? `The sandbox exited with ${exitCode}.`);
725 return;
726 }
727 if (run.kind === "deploy") {
728 // Refused harmlessly if the build reported its end before it stopped.
729 const response = await this.env.DEPLOYMENTS.fetch(`https://deployments/jobs/${run.deployId}/fail`, {
730 method: "POST",
731 headers: { "content-type": "application/json" },
732 body: JSON.stringify({ token: run.token, message: why ?? "The build stopped before it finished." }),
733 });
734 await answered("deploy fail", response);
735 return;
736 }
737 const work = workClient(this.env.WORK);
738 if (run.kind === "checks") {
739 // Refused harmlessly if the run did report before it stopped.
740 await work.reportChecks(run.runId, run.token, {
741 error: why ?? "The sandbox stopped before the checks finished.",
742 });
743 return;
744 }
745 if (run.kind === "queue") {
746 // Refused harmlessly if the state was reported before it stopped.
747 await work.failQueue(run.entryId, run.token, why ?? "The sandbox stopped before the state was checked.");
748 return;
749 }
750 if (run.kind === "mergecheck") {
751 // Refused harmlessly if the probe reported before it stopped.
752 await work.failMergecheck(run.pullId, run.token, why ?? "The sandbox stopped before the merge check finished.");
753 return;
754 }
755 // A sandbox of a kind g1t no longer starts, which ran from before: it
756 // has nobody left to tell.
757 }
758}
759
760/**
761 * What the compute gate decided for one start: go, with what billing
762 * reserved and the plan's caps; or not, refused with what to tell people.
763 */
764type Granted = {
765 ok: true;
766 held: Held | null;
767 limits: PlanLimits;
768 /** The labels of the self-hosted runners it goes to; null for a sandbox. */
769 route: string[] | null;
770};
771type Admitted = Granted | { ok: false; code: string; message: string };
772
773/**
774 * The guardrails' default time cap of each kind of run, for estimating what
775 * it may cost before it starts; the sandbox applies the project's own.
776 */
777const DEFAULT_MINUTES: Record<"queue" | "mergecheck", number> = {
778 queue: 45,
779 mergecheck: 10,
780};
781
782/** A plan's cap on one run, for the sandbox to apply under its guardrails'. */
783function limitsOf(ent: ComputeEntitlements | null): PlanLimits {
784 return { minutes: ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null };
785}
786
787/** The shorter of a kind's time cap and the plan's, for an estimate. */
788function estimateMinutes(minutes: number, ent: ComputeEntitlements | null): number {
789 return ent && ent.maxRunMinutes > 0 ? Math.min(minutes, ent.maxRunMinutes) : minutes;
790}
791
792/** A start the gate did not let through, as a result for whoever asked. */
793function notAdmitted(admitted: Exclude<Admitted, Granted>): Result<never> {
794 return fail("payment_required", admitted.message);
795}
796
797/**
798 * Larger sandboxes for workflow jobs that ask for one in `runs-on`: the
799 * same image and behaviour on a larger Containers instance type, each a
800 * class of its own (wrangler.jsonc). Outbound handlers are registered by
801 * class, so each registers its own.
802 */
803export class Sandbox2Core extends AttemptSandbox {
804 static {
805 Sandbox2Core.outboundHandlers = { egress, abuse };
806 }
807}
808export class Sandbox4Core extends AttemptSandbox {
809 static {
810 Sandbox4Core.outboundHandlers = { egress, abuse };
811 }
812}
813
814/**
815 * What the actions service sends to start a job (`StartJobArgs`). Callers
816 * from before may also send `workflow`, `environment` and `trusted`, for
817 * workflow-only domains, which are gone: they are ignored.
818 */
819type ActionsJobArgs = {
820 job: string;
821 token: string;
822 repo: RepoPath;
823 timeoutMinutes: number;
824 /** The machine its `runs-on` asked for, by label; absent, the standard one. */
825 instance?: string | null;
826 /** Whether it may be given brokered secrets, which its requests need filled in. */
827 brokered?: boolean;
828};
829
830export default class RunnerService extends WorkerEntrypoint<RunnerEnv> {
831 /**
832 * The JSON protocol the Rust services speak: `POST /rpc/<method>` with the
833 * arguments as the body. Only bound services can.
834 */
835 async fetch(request: Request): Promise<Response> {
836 const { pathname } = new URL(request.url);
837 if (request.method === "POST" && pathname === "/rpc/start_actions_job") {
838 return Response.json(await this.startActionsJob((await request.json()) as ActionsJobArgs));
839 }
840 if (request.method === "POST" && pathname === "/rpc/stop_actions_job") {
841 const args = (await request.json()) as { job: string };
842 // Whichever machine it asked for: the job's object in every namespace.
843 await Promise.all(
844 Object.keys(SANDBOX_BINDINGS).map((className) => {
845 const namespace = sandboxNamespace(this.env, className) as unknown as DurableObjectNamespace<AttemptSandbox>;
846 return namespace
847 .get(namespace.idFromName(`actions:${args.job}`))
848 .destroy()
849 .catch(() => undefined);
850 }),
851 );
852 return Response.json(ok(true));
853 }
854 // A self-hosted runner's task ended: the sandbox that handed it over
855 // does what it does when a container stops.
856 if (request.method === "POST" && pathname === "/rpc/task_ended") {
857 const args = (await request.json()) as { sandbox: string; exitCode: number; reason?: string | null };
858 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromString(args.sandbox));
859 await sandbox.remoteEnded(args.exitCode, args.reason ?? null);
860 return Response.json(ok(true));
861 }
862 // A self-hosted job's request to a brokered secret's host, which the
863 // API passes on as it came (broker-relay.ts).
864 if (request.method === "POST" && pathname === "/rpc/broker") {
865 try {
866 return await relay(
867 request,
868 relayDeps(this.env.ACTIONS, (upstream) => forward(upstream)),
869 );
870 } catch (error) {
871 console.log("broker relay failed", String(error));
872 return Response.json({ error: { code: "unavailable", message: "g1t could not relay the request." } }, { status: 503 });
873 }
874 }
875 if (request.method === "POST" && pathname === "/rpc/bump") {
876 return Response.json(await this.startBump(await request.json()));
877 }
878 if (request.method === "POST" && pathname === "/rpc/start_deploy") {
879 return Response.json(await this.startDeploy((await request.json()) as DeployJob));
880 }
881 return new Response("Not found\n", { status: 404 });
882 }
883
884 // ---- The compute gate (@g1t/contracts compute.ts) -------------------------
885
886 /** Whether `repo` is public: what g1t's open-source pool can pay for. */
887 private async isPublic(repo: RepoPath): Promise<boolean> {
888 const found = await reposClient(this.env.REPOS)
889 .get(repo, null)
890 .catch(() => null);
891 return Boolean(found?.ok && !found.value.isPrivate);
892 }
893
894 /**
895 * Whether a sandbox (the merge queue, a
896 * merge check, a workflow job) may start in `repo`, with what it may cost
897 * for `minutes` reserved. Public repositories' checks, workflows and
898 * queue can be paid by the open-source pool. Never throws.
899 */
900 private async admitSandbox(
901 kind: ComputeKind,
902 repo: RepoPath,
903 minutes: number,
904 instance: InstanceType = STANDARD_INSTANCE,
905 { selfHosted = true }: { selfHosted?: boolean } = {},
906 ): Promise<Admitted> {
907 const workspace = repo.namespace.toLowerCase();
908 const compute = gateFor(this.env);
909 const ent = await compute.entitlements(workspace);
910 if (ent?.paused) return { ok: false, code: "paused", message: refusalMessage("paused", workspace, kind, ent.paused) };
911 // Checks and the merge queue go to the workspace's own runners when it
912 // says so, and cost nothing there. Workflow jobs choose with `runs-on`.
913 const route = selfHosted && (kind === "check" || kind === "queue") ? await selfHostedRoute(this.env.ACTIONS, repo) : null;
914 if (route) return { ok: true, held: null, limits: limitsOf(ent), route };
915 const [standardMicros, isPublic] = await Promise.all([compute.microsPerSecond(), this.isPublic(repo)]);
916 // Reserved for its time limit at the larger of what it may cost (the
917 // sandbox price, scaled to its machine) and its minutes at the
918 // machine's rate, which is what it is charged.
919 const limit = estimateMinutes(minutes, ent);
920 const microsPerSecond = standardMicros * priceScale(instance);
921 const estimateMicros = Math.max(sandboxEstimateMicros(limit, microsPerSecond), runnerMinutes(limit * 60) * instance.microsPerMinute);
922 const admission = await compute.admit({ workspace, repo, public: isPublic, kind, estimateMicros }, ent);
923 if (!admission.ok) return { ok: false, code: admission.code, message: admission.message };
924 return {
925 ok: true,
926 held: admission.reservation ? { id: admission.reservation.id, workspace, microsPerSecond } : null,
927 limits: limitsOf(ent),
928 route: null,
929 };
930 }
931
932 /** Gives back what was reserved for a start that never reached its sandbox. */
933 private async release(held: Held | null): Promise<void> {
934 if (held) await gateFor(this.env).settle(held.id, 0);
935 }
936
937 /**
938 * Runs `start`, giving back what was reserved if it fails. A sandbox that
939 * could not start has given it back already; settling twice at nothing
940 * is harmless.
941 */
942 private async holding<T>(granted: Granted, start: () => Promise<T>): Promise<T> {
943 try {
944 return await start();
945 } catch (error) {
946 await this.release(granted.held);
947 throw error;
948 }
949 }
950
951 /**
952 * Starts one job of a GitHub Actions workflow in a sandbox of its own.
953 * The sandbox fetches the job, its contexts and its secrets with the
954 * job's token, and reports back to the actions service through the API.
955 * Jobs run on g1t's machines, so only for workspaces that may use them.
956 */
957 private async startActionsJob(args: ActionsJobArgs): Promise<Result<true>> {
958 // The machine its `runs-on` asked for; the standard one otherwise.
959 const instance = instanceNamed(args.instance);
960 // Workflow jobs run on g1t's machines: only as the workspace's plan
961 // allows, or on a public repository, from the open-source pool.
962 const admitted = await this.admitSandbox("workflow", args.repo, args.timeoutMinutes, instance);
963 if (!admitted.ok) return fail("payment_required", `Not started: ${admitted.message}`);
964 const namespace = this.jobNamespace(instance);
965 if (!namespace) {
966 await this.release(admitted.held);
967 return fail("invalid", `Not started: ${instance.label} machines are not available here.`);
968 }
969 const sandbox = namespace.get(namespace.idFromName(`actions:${args.job}`));
970 const on = instance === STANDARD_INSTANCE ? "" : ` on ${instance.label}`;
971 try {
972 await sandbox.run({
973 kind: "actions",
974 jobId: args.job,
975 token: args.token,
976 reservation: admitted.held,
977 limits: admitted.limits,
978 // The job's own time limit, and whether its requests pass the
979 // Worker for its brokered secrets.
980 build: {
981 kind: "actions",
982 minutes: Math.max(1, args.timeoutMinutes),
983 brokered: args.brokered === true,
984 },
985 meter: {
986 ...meter(args.repo, `A workflow job in ${args.repo.namespace}/${args.repo.name}${on}`),
987 instance: instance === STANDARD_INSTANCE ? null : instance.label,
988 },
989 envVars: {
990 MODE: "actions",
991 G1T_API: "https://api.g1t.sh",
992 ACTIONS_JOB: args.job,
993 ACTIONS_TOKEN: args.token,
994 // Docker of the job's own, inside its sandbox (crates/runner docker/).
995 G1T_DOCKER: dockerFor(this.env.DOCKER),
996 },
997 });
998 } catch (error) {
999 // A sandbox that could not start, or stopped at once: the job fails
1000 // with why, rather than waiting to be noticed.
1001 return {
1002 ok: false,
1003 error: { code: "conflict", message: `The runner could not start the job: ${String(error).replace(/^Error: /, "")}` },
1004 };
1005 }
1006 // `true`, not null: an outcome needs a value.
1007 return ok(true);
1008 }
1009
1010 /** The sandboxes of a label: the class the catalogue names for it, by its binding. */
1011 private jobNamespace(instance: InstanceType): DurableObjectNamespace<AttemptSandbox> | null {
1012 return jobNamespaceOf(this.env, instance) as DurableObjectNamespace<AttemptSandbox> | null;
1013 }
1014
1015 /**
1016 * Builds one commit in a sandbox of its own and deploys it to g1t.page.
1017 * Asked by the deployments service, which has already checked that the
1018 * workspace pays for Deployments; that plan is what
1019 * lets a build use g1t's machines.
1020 */
1021 private async startDeploy(job: DeployJob): Promise<Result<true>> {
1022 // To read the commit, which may be private, as whoever pushed it.
1023 const token = await runCredential(this.env.IDENTITY, {
1024 onBehalfOf: job.actor,
1025 repo: job.source,
1026 kind: "deploy",
1027 use: "runner",
1028 read: [job.source],
1029 ttlSeconds: DEPLOY_TOKEN_TTL_SECONDS,
1030 });
1031 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`deploy:${job.deployId}`));
1032 const workspace = (job.workspace ?? job.source.namespace).toLowerCase();
1033 // The project the build is for, and who it is charged to.
1034 const project = job.repo ?? job.source;
1035 try {
1036 await sandbox.run({
1037 kind: "deploy",
1038 deployId: job.deployId,
1039 token: job.token,
1040 reservation: job.reservation ? { id: job.reservation, workspace, microsPerSecond: job.microsPerSecond ?? 0 } : null,
1041 limits: { minutes: job.maxRunMinutes ?? null },
1042 // For as long as its read token lasts.
1043 build: { kind: "deploy", minutes: DEPLOY_TOKEN_TTL_SECONDS / 60 },
1044 owner: { workspace, repo: `${project.namespace}/${project.name}` },
1045 envVars: {
1046 MODE: "deploy",
1047 G1T_API: "https://api.g1t.sh",
1048 DEPLOY_ID: job.deployId,
1049 DEPLOY_TOKEN: job.token,
1050 G1T_USER: job.actor.username,
1051 G1T_TOKEN: token,
1052 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
1053 GIT_COMMIT: job.commit,
1054 ROOT_DIR: job.rootDir ?? "",
1055 BUILD_COMMAND: job.buildCommand ?? "",
1056 OUTPUT_DIR: job.outputDir ?? "",
1057 BUILD_AS: job.buildAs ?? "auto",
1058 DOCKERFILE: job.dockerfile ?? "",
1059 // A container build's own Docker Engine (crates/runner docker/),
1060 // started only if the project is built as one.
1061 G1T_DOCKER: dockerFor(this.env.DOCKER),
1062 BUILD_ENV: JSON.stringify(job.buildEnv ?? {}),
1063 BUILD_SECRETS: JSON.stringify(job.buildSecrets ?? {}),
1064 },
1065 });
1066 } catch (error) {
1067 return {
1068 ok: false,
1069 error: { code: "conflict", message: `The runner could not start the build: ${String(error).replace(/^Error: /, "")}` },
1070 };
1071 }
1072 return ok(true);
1073 }
1074
1075 /**
1076 * Makes a security update in a sandbox of its own (crates/runner
1077 * bump.rs): raises one package to a fixed version in the lockfiles
1078 * named, commits that as g1t and pushes it to its `g1t/security/…`
1079 * branch. A version update (`kind: "version"`) raises one or more
1080 * packages the same way, to the branch its dependency update file names,
1081 * which is never the default one. Asked by the security service, which
1082 * opens the pull request when it hears the push; nothing here opens one.
1083 * Admitted, reserved and metered like checks, always in g1t's sandbox (a
1084 * self-hosted runner may not know the mode), under the project's network
1085 * list plus the package registries. Returns whether the sandbox started.
1086 */
1087 private async startBump(input: unknown): Promise<Result<boolean>> {
1088 const problem = bumpProblem(input, UPDATE_BRANCH_PREFIX);
1089 if (problem) return fail("invalid", problem);
1090 const args = input as BumpArgs;
1091 const repo = args.repo;
1092 const what = args.kind === "version" ? "version update" : "security update";
1093 const actor = systemActor(repo.namespace);
1094 const closed = await this.closedRepo(actor, repo);
1095 if (closed) return closed;
1096 const admitted = await this.admitSandbox("check", repo, BUMP_MINUTES, STANDARD_INSTANCE, { selfHosted: false });
1097 if (!admitted.ok) return notAdmitted(admitted);
1098 try {
1099 const defaultBranch = await this.defaultBranch(repo, actor);
1100 // From its `target-branch`, which its pull request merges into, or
1101 // the default branch.
1102 const base = args.base ?? defaultBranch;
1103 // A version update names its own branch, which is never the one it
1104 // starts from, nor the default one.
1105 if (args.kind === "version" && (args.branch === defaultBranch || args.branch === base)) {
1106 await this.release(admitted.held);
1107 return fail("invalid", `A version update cannot push to ${args.branch}, the branch it starts from.`);
1108 }
1109 // As g1t, for the workspace: reads the repository and pushes this
1110 // branch only, with no API operations.
1111 const token = await runCredential(this.env.IDENTITY, {
1112 onBehalfOf: actor,
1113 repo,
1114 kind: "bump",
1115 use: "runner",
1116 read: [repo],
1117 push: [{ repo, branch: args.branch }],
1118 ttlSeconds: BUMP_TOKEN_TTL_SECONDS,
1119 });
1120 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(bumpSandboxName(args)));
1121 await sandbox.run({
1122 kind: "bump",
1123 repo,
1124 branch: args.branch,
1125 reservation: admitted.held,
1126 limits: admitted.limits,
1127 build: { kind: "bump", minutes: BUMP_MINUTES },
1128 meter: meter(repo, `${args.kind === "version" ? "Version" : "Security"} update in ${repo.namespace}/${repo.name}`),
1129 envVars: bumpEnv(args, base, token),
1130 });
1131 } catch (error) {
1132 await this.release(admitted.held);
1133 return fail("conflict", `The runner could not start the ${what}: ${String(error).replace(/^Error: /, "")}`);
1134 }
1135 return ok(true);
1136 }
1137
1138 /**
1139 * Events from the bus. Each one that could change what a pull request
1140 * needs next moves it along: the work service decides whether it can
1141 * merge by the repository's settings, and merges it if so. Merge checks
1142 * and the merge queue's states start here too.
1143 */
1144 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
1145 for (const message of batch.messages) {
1146 const event = message.body;
1147 switch (event.type) {
1148 case "pull.opened":
1149 case "pull.ready":
1150 case "pull.updated":
1151 case "checks.completed":
1152 case "review.completed":
1153 case "pull.mergeability":
1154 await this.advance(event.data.pullId);
1155 break;
1156 // Its head or its target moved and both changed the same files:
1157 // find out whether it still merges cleanly.
1158 case "pull.mergecheck":
1159 await this.startMergecheck(event.data.pullId);
1160 break;
1161 // Something joined, left or landed: test the next batch if none is.
1162 case "queue.changed":
1163 await this.buildQueue(event.data.repoId);
1164 break;
1165 // A person approved or asked for changes, which may let it merge.
1166 case "comment.created":
1167 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
1168 break;
1169 // The branch the others would land on has moved.
1170 case "pull.merged":
1171 await this.advanceAll(event.data.repoId);
1172 break;
1173 // Read-only or gone: the runs there stop.
1174 case "repo.archived":
1175 case "repo.deleted":
1176 await this.stopRunsIn(event.data.repoId);
1177 break;
1178 }
1179 message.ack();
1180 }
1181 }
1182
1183 /** A sweep, for steps whose trigger was missed or whose sandbox died, and the nightly backups. */
1184 async scheduled(): Promise<void> {
1185 await this.advanceAll();
1186 await this.startBackups().catch((error: unknown) => console.log("backups not started", String(error)));
1187 }
1188
1189 /**
1190 * Starts a few of the nightly backups the repos service queued, each in
1191 * a sandbox of its own that holds only its job's token: the sandbox asks
1192 * for a read-only git credential itself, when it is ready to clone. No
1193 * plan is asked and nothing is metered: backups are g1t's own work.
1194 */
1195 private async startBackups(): Promise<void> {
1196 const pace = backupPace(this.env.BACKUPS_PER_SWEEP, this.env.BACKUPS_RUNNING);
1197 if (pace.perSweep === 0) return;
1198 const repos = reposClient(this.env.REPOS);
1199 for (const claim of await repos.claimBackups(pace.perSweep, pace.running)) {
1200 try {
1201 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(backupSandboxName(claim)));
1202 await sandbox.run({
1203 kind: "backup",
1204 jobId: claim.jobId,
1205 token: claim.token,
1206 // For `abuse.flagged`: whose repository it was.
1207 owner: { workspace: claim.path.namespace, repo: `${claim.path.namespace}/${claim.path.name}` },
1208 envVars: backupEnv(claim, "https://api.g1t.sh"),
1209 });
1210 } catch (error) {
1211 await repos.failBackup(claim.jobId, claim.token, `The sandbox could not start: ${String(error)}`).catch(() => null);
1212 }
1213 }
1214 }
1215
1216 private async advanceAll(repoId?: string): Promise<void> {
1217 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
1218 for (const pullId of pulls) await this.advance(pullId);
1219 }
1220
1221 /**
1222 * Merges a pull request by the repository's settings when it is ready:
1223 * the work service decides and merges, and claims the step, so calling
1224 * this twice merges nothing twice.
1225 */
1226 private async advance(pullId: string): Promise<void> {
1227 const next = await workClient(this.env.WORK).advance(pullId);
1228 // g1t no longer reviews, revises or catches up pull requests itself.
1229 if (next.action !== "none") console.log("lifecycle step not taken", pullId, next.action);
1230 }
1231
1232 /**
1233 * Starts the next batch of a repository's merge queue, if it has one
1234 * ready: a sandbox per entry, all at once, each building the default
1235 * branch with that entry and everything ahead of it.
1236 */
1237 private async buildQueue(repoId: string): Promise<void> {
1238 const work = workClient(this.env.WORK);
1239 const jobs = await work.queueBuild(repoId);
1240 // Merge queue sandboxes, like any other, only as the workspace's plan
1241 // allows: refused states fail at once, saying why. A state whose
1242 // sandbox could not start fails at once too, rather than holding the
1243 // queue until it times out.
1244 await Promise.all(
1245 jobs.map(async (job) => {
1246 const admitted = await this.admitSandbox("queue", job.repo, DEFAULT_MINUTES.queue);
1247 if (!admitted.ok) {
1248 await work.failQueue(job.entryId, job.token, `Not started: ${admitted.message}`);
1249 return;
1250 }
1251 await this.holding(admitted, () => this.startQueueRun(job, admitted)).catch((error: unknown) =>
1252 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
1253 );
1254 }),
1255 );
1256 }
1257
1258 private async startQueueRun(job: QueueJob, granted: Granted): Promise<void> {
1259 // To read the changes and push the tested state, as a member.
1260 // Reads each queued change; pushes only the queue's own branch.
1261 const token = await runCredential(this.env.IDENTITY, {
1262 onBehalfOf: job.actor,
1263 repo: job.repo,
1264 kind: "queue",
1265 use: "runner",
1266 number: job.stack.at(-1)?.number ?? null,
1267 read: job.stack.map((item) => item.source),
1268 push: [{ repo: job.repo, branch: job.branch }],
1269 ttlSeconds: CHECKS_TOKEN_TTL_SECONDS,
1270 });
1271 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1272 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
1273 await sandbox.run({
1274 kind: "queue",
1275 entryId: job.entryId,
1276 token: job.token,
1277 reservation: granted.held,
1278 limits: granted.limits,
1279 selfHosted: granted.route,
1280 track: {
1281 actor: job.actor,
1282 repo: job.repo,
1283 kind: "queue",
1284 number: job.stack.at(-1)?.number ?? null,
1285 title: `Merge queue: ${job.stack.map((item) => `#${item.number}`).join(" + ")}`,
1286 },
1287 meter: meter(job.repo, `Merge queue on ${job.repo.namespace}/${job.repo.name}`),
1288 envVars: {
1289 MODE: "queue",
1290 G1T_API: "https://api.g1t.sh",
1291 QUEUE_ENTRY: job.entryId,
1292 QUEUE_TOKEN: job.token,
1293 G1T_USER: job.actor.username,
1294 G1T_TOKEN: token,
1295 BASE_REMOTE: remote(job.repo),
1296 BASE_COMMIT: job.baseCommit,
1297 QUEUE_BRANCH: job.branch,
1298 STACK: JSON.stringify(
1299 job.stack.map((item) => ({
1300 number: item.number,
1301 title: item.title,
1302 remote: remote(item.source),
1303 branch: item.branch,
1304 commit: item.commit,
1305 })),
1306 ),
1307 CHECKS: JSON.stringify(job.checks),
1308 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
1309 },
1310 });
1311 }
1312
1313 /**
1314 * Merges a pull request's head into its target in a sandbox of its own,
1315 * pushing nothing, to find the files that conflict.
1316 * The work service decides when one is needed and how many may run.
1317 */
1318 private async startMergecheck(pullId: string): Promise<void> {
1319 const work = workClient(this.env.WORK);
1320 const started = await work.startMergecheck(pullId);
1321 if (!started.ok) return;
1322 const job = started.value;
1323 let granted: Granted | null = null;
1324 try {
1325 // Like any sandbox, only as the workspace's plan allows.
1326 const admitted = await this.admitSandbox("check", job.repo, DEFAULT_MINUTES.mergecheck);
1327 if (!admitted.ok) throw new Error(`Not started: ${admitted.message}`);
1328 granted = admitted;
1329 // To read the change, which may be private, as whoever opened it.
1330 const token = await runCredential(this.env.IDENTITY, {
1331 onBehalfOf: job.author,
1332 repo: job.repo,
1333 kind: "mergecheck",
1334 use: "runner",
1335 number: job.number,
1336 read: [job.repo, job.source],
1337 ttlSeconds: MERGECHECK_TOKEN_TTL_SECONDS,
1338 });
1339 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1340 // One sandbox per pair of commits: asking twice starts nothing twice.
1341 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`mergecheck-${job.pullId}-${job.head}-${job.base}`));
1342 await sandbox.run({
1343 kind: "mergecheck",
1344 pullId: job.pullId,
1345 token: job.token,
1346 reservation: granted.held,
1347 limits: granted.limits,
1348 selfHosted: granted.route,
1349 meter: meter(job.repo, `Merge check of ${job.repo.namespace}/${job.repo.name}#${job.number}`),
1350 envVars: {
1351 MODE: "mergecheck",
1352 G1T_API: "https://api.g1t.sh",
1353 MERGECHECK_PULL: job.pullId,
1354 MERGECHECK_TOKEN: job.token,
1355 G1T_USER: job.author.username,
1356 G1T_TOKEN: token,
1357 BASE_REMOTE: remote(job.repo),
1358 BASE_COMMIT: job.base,
1359 HEAD_REMOTE: remote(job.source),
1360 HEAD_BRANCH: job.branch,
1361 HEAD_COMMIT: job.head,
1362 },
1363 });
1364 } catch (error) {
1365 if (granted) await this.release(granted.held);
1366 await work.failMergecheck(job.pullId, job.token, error instanceof Error ? error.message : String(error));
1367 }
1368 }
1369
1370 /**
1371 * A refusal if `repo` takes no work from anyone: it is archived, so
1372 * read-only, or it was deleted (repos hides a deleted one, so it is not
1373 * found). Null when repos cannot answer now; the other checks still run.
1374 */
1375 private async closedRepo(actor: User, repo: RepoPath): Promise<Result<never> | null> {
1376 const repos = reposClient(this.env.REPOS);
1377 const found = await repos.get(repo, actor).catch(() => null);
1378 if (!found) return null;
1379 if (!found.ok) {
1380 return found.error.code === "not_found" ? fail("not_found", `There is no repository at ${repo.namespace}/${repo.name}, or it was deleted.`) : null;
1381 }
1382 const status = await repos.statusById(found.value.id).catch(() => null);
1383 if (status?.deleted) {
1384 return fail("not_found", `${found.value.namespace}/${found.value.name} was deleted. An owner can restore it from the workspace's settings.`);
1385 }
1386 if (status?.archived || found.value.archivedAt) {
1387 return fail("forbidden", `${found.value.namespace}/${found.value.name} is archived, so it is read-only. An owner can unarchive it in its settings.`);
1388 }
1389 return null;
1390 }
1391
1392 /**
1393 * Stops every run in a repository that was archived or deleted: the
1394 * work service marks them stopped when it hears of it, and lists them
1395 * here (`runs_in_repo`, by id, so a deleted repository's runs are found
1396 * too), and each sandbox is destroyed. Never throws.
1397 */
1398 private async stopRunsIn(repoId: string): Promise<void> {
1399 try {
1400 const response = await this.env.WORK.fetch("https://work/rpc/runs_in_repo", {
1401 method: "POST",
1402 headers: { "content-type": "application/json" },
1403 body: JSON.stringify({ repoId }),
1404 });
1405 if (!response.ok) return;
1406 const runs = (await response.json()) as { runId: string; sandbox: string | null }[];
1407 for (const run of runs) {
1408 if (!run.sandbox) continue;
1409 try {
1410 await this.env.SANDBOX.get(this.env.SANDBOX.idFromString(run.sandbox)).halt("The repository was archived or deleted.");
1411 } catch (error) {
1412 // Already gone, or never started.
1413 console.log("sandbox not destroyed", run.runId, String(error));
1414 }
1415 }
1416 } catch (error) {
1417 console.error("could not stop the runs in", repoId, error);
1418 }
1419 }
1420
1421 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
1422 const found = await reposClient(this.env.REPOS).get(repo, viewer);
1423 return found.ok ? found.value.defaultBranch : "main";
1424 }
1425}