Skip to content

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

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