Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 1 | /** |
| 2 | * g1t's own work on a workspace's self-hosted runners. When a workspace (or | |
| 3 | * one of its repositories) says so under Settings, Runners, an agent's run, | |
| 4 | * checks, a review, a merge check or the merge queue is not started in a | |
| 5 | * sandbox here: the sandbox's Durable Object hands the same environment to | |
| 6 | * the actions service as a task, a runner of the workspace's with the | |
| 7 | * right labels takes it on its next poll, and the actions service tells | |
| 8 | * the Durable Object how it ended (`task_ended`), which then does exactly | |
| 9 | * what it does when a container stops. The model calls still go through | |
| 10 | * g1t's model proxy with the run's own credential, so spend, budgets and | |
| 11 | * the audit log work as they do in a sandbox. Network guardrails cannot be | |
| 12 | * enforced on someone else's machine, and the run says so. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { RepoPath, ServiceBinding } from "@g1t/contracts"; | |
| 16 | ||
| 17 | /** The actions service's JSON protocol. */ | |
| 18 | async function call<T>(actions: ServiceBinding, method: string, args: object): Promise<T> { | |
| 19 | const response = await actions.fetch(`https://actions/rpc/${method}`, { | |
| 20 | method: "POST", | |
| 21 | headers: { "content-type": "application/json" }, | |
| 22 | body: JSON.stringify(args), | |
| 23 | }); | |
| 24 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); | |
| 25 | return (await response.json()) as T; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * The labels g1t's own work in `repo` runs on, when it runs on the | |
| 30 | * workspace's runners; null for g1t's sandboxes. Never throws: when the | |
| 31 | * actions service cannot say, the work runs in a sandbox as before. | |
| 32 | */ | |
| 33 | export async function selfHostedRoute(actions: ServiceBinding, repo: RepoPath): Promise<string[] | null> { | |
| 34 | try { | |
| 35 | const labels = await call<string[] | null>(actions, "runner_route", { workspace: repo.namespace.toLowerCase(), repo }); | |
| 36 | return Array.isArray(labels) && labels.length > 0 ? labels : null; | |
| 37 | } catch (error) { | |
| 38 | console.log("self-hosted route unknown; using a sandbox", repo.namespace, String(error)); | |
| 39 | return null; | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | type Outcome<T> = { ok: true; value: T } | { ok: false; error: { message: string } }; | |
| 44 | ||
| 45 | /** Hands a sandbox's work to the workspace's runners. Returns the task's id. */ | |
| 46 | export async function enqueueTask( | |
| 47 | actions: ServiceBinding, | |
| 48 | task: { | |
| 49 | sandbox: string; | |
| 50 | repo: RepoPath; | |
| 51 | kind: string; | |
| 52 | title: string; | |
| 53 | labels: string[]; | |
| 54 | env: Record<string, string>; | |
| 55 | timeoutMinutes: number; | |
| 56 | }, | |
| 57 | ): Promise<string> { | |
| 58 | const queued = await call<Outcome<string>>(actions, "enqueue_task", { ...task, workspace: task.repo.namespace.toLowerCase() }); | |
| 59 | if (!queued.ok) throw new Error(queued.error.message); | |
| 60 | return queued.value; | |
| 61 | } | |
| 62 | ||
| 63 | /** Withdraws a sandbox's task: a person stopped it, or its time ran out. Never throws. */ | |
| 64 | export async function cancelTask(actions: ServiceBinding, sandbox: string, reason: string | null): Promise<void> { | |
| 65 | await call(actions, "cancel_task", { sandbox, reason }).catch((error: unknown) => | |
| 66 | console.log("self-hosted task not cancelled", sandbox, String(error)), | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * The environment a self-hosted runner gets: what the sandbox would have | |
| 72 | * been started with, less what only makes sense in g1t's sandbox (the | |
| 73 | * egress certificate and proxy), with the mining watch off, since the | |
| 74 | * machine and its electricity are the workspace's. | |
| 75 | */ | |
| 76 | export function taskEnv(vars: Record<string, string>): Record<string, string> { | |
| 77 | const env: Record<string, string> = {}; | |
| 78 | for (const [name, value] of Object.entries(vars)) { | |
| 79 | if (/^(HTTPS?_PROXY|NO_PROXY|NODE_EXTRA_CA_CERTS|SSL_CERT_FILE|G1T_EGRESS_CA)$/i.test(name)) continue; | |
| 80 | env[name] = value; | |
| 81 | } | |
| 82 | env.G1T_ABUSE = "off"; | |
| 83 | env.G1T_SELF_HOSTED = "1"; | |
| 84 | return env; | |
| 85 | } | |
| 86 | ||
| 87 | /** What the run's session says when it goes to the workspace's runners. */ | |
| 88 | export function handedOverStep(labels: string[]): string { | |
| 89 | return `Handed to a self-hosted runner with labels ${labels.join(", ")}. g1t's network guardrails are not enforced on your own machines.`; | |
| 90 | } | |
| 91 | ||
| 92 | /** Where the work is: the tracked repository, else the meter's `owner/name`. */ | |
| 93 | export function taskRepo(track: { repo: RepoPath } | undefined, meter: { repo: string } | undefined, owner: { repo: string | null } | undefined): RepoPath | null { | |
| 94 | if (track) return track.repo; | |
| 95 | const full = meter?.repo ?? owner?.repo ?? null; | |
| 96 | if (!full) return null; | |
| 97 | const [namespace, name] = full.split("/"); | |
| 98 | return namespace && name ? { namespace, name } : null; | |
| 99 | } |