flagon-io/g1t

public

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/services/runner/src/self-hosted.ts

99 lines4,404 bytesCodeBlame
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
15import type { RepoPath, ServiceBinding } from "@g1t/contracts";
16
17/** The actions service's JSON protocol. */
18async 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 */
33export 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
43type 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. */
46export 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. */
64export 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 */
76export 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. */
88export 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`. */
93export 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}