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