Skip to content
476 linesCodeBlameRaw

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 incidents1import { Bot, Boxes, Cpu, Plus, ServerCog, ShieldAlert, Trash2 } from "lucide-react";
Merge branch 'worktree-agent-a985c01f43538af01'2import { useEffect, useState } from "react";
3import { Form, Link } from "react-router";
Fast pages, required checks on the branch, self-hosted runners, honest incidents4
5import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
6
7import type { RunnersAction, RunnersData } from "../lib/runners.server";
Merge branch 'worktree-agent-a985c01f43538af01'8import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
Chat controls, public profiles, shadcn selects, and no Docs tab in a project9import { SelectField } from "./ui/select";
Fast pages, required checks on the branch, self-hosted runners, honest incidents10import { Switch } from "./ui/switch";
11import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
12
13const STATUS: Record<Runner["status"], { dot: string; label: string }> = {
Merge branch 'worktree-agent-ad7c6d88d93adc817'14 online: { dot: "bg-success", label: "Idle" },
Fast pages, required checks on the branch, self-hosted runners, honest incidents15 busy: { dot: "bg-info", label: "Busy" },
16 offline: { dot: "bg-faint", label: "Offline" },
17};
18
19const OS_NAMES: Record<Runner["os"], string> = { linux: "Linux", macos: "macOS", windows: "Windows" };
20
21function Section({ icon, title, about, children }: { icon: React.ReactNode; title: string; about: React.ReactNode; children: React.ReactNode }) {
22 return (
23 <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]">
24 <div>
25 <h2 className="flex items-center gap-2 font-medium">
26 {icon}
27 {title}
28 </h2>
29 <div className="mt-1 text-sm text-muted">{about}</div>
30 </div>
31 <div className="min-w-0">{children}</div>
32 </section>
33 );
34}
35
36function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) {
37 const status = STATUS[runner.status];
38 return (
39 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
40 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden />
41 <div className="min-w-0 grow basis-60">
42 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
43 <span className="font-medium">{runner.name}</span>
44 <span className="text-xs text-muted">{status.label}</span>
45 {runner.ephemeral && <Pill>ephemeral</Pill>}
46 {runner.group && <span className="text-xs text-faint">in {runner.group}</span>}
47 </div>
48 <div className="mt-1.5 flex flex-wrap gap-1">
49 {runner.labels.map((label) => (
50 <span key={label} className="rounded-md bg-raised px-1.5 py-0.5 font-mono text-[0.6875rem] text-fg-soft">
51 {label}
52 </span>
53 ))}
54 </div>
55 {runner.work && (
56 <p className="mt-1.5 truncate text-xs text-muted">
57 {runner.work.kind === "workflow" ? "Running " : "Agent work: "}
58 {runner.work.runId && runner.work.repo ? (
59 <Link to={`${base(runner.work.repo)}/actions/runs/${runner.work.runId}`} className="text-fg hover:underline">
60 {runner.work.name}
61 </Link>
62 ) : (
63 <span className="text-fg">{runner.work.name}</span>
64 )}
65 {runner.work.repo && <> in {runner.work.repo}</>}
66 </p>
67 )}
68 </div>
69 <div className="shrink-0 text-right text-xs text-faint">
70 <div>
71 {OS_NAMES[runner.os]} {runner.arch}
72 {runner.version && <> · {runner.version}</>}
73 </div>
74 <div className="mt-0.5">{runner.lastSeenAt ? <>Seen <TimeAgo at={runner.lastSeenAt} /></> : "Never seen"}</div>
75 </div>
76 {manage && (
77 <Form
78 method="post"
79 className="shrink-0"
80 onSubmit={(event) => {
81 if (!confirm(`Remove ${runner.name}? Its credential stops working at once${runner.work ? ", and the job it is running fails" : ""}.`)) event.preventDefault();
82 }}
83 >
84 <input type="hidden" name="intent" value="remove" />
85 <input type="hidden" name="id" value={runner.id} />
86 <input type="hidden" name="name" value={runner.name} />
Merge branch 'worktree-agent-a985c01f43538af01'87 <SubmitButton
88 icon
89 match={{ intent: "remove", id: runner.id }}
Fast pages, required checks on the branch, self-hosted runners, honest incidents90 aria-label={`Remove ${runner.name}`}
Merge branch 'worktree-agent-a985c01f43538af01'91 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50"
Fast pages, required checks on the branch, self-hosted runners, honest incidents92 >
93 <Trash2 size={15} />
Merge branch 'worktree-agent-a985c01f43538af01'94 </SubmitButton>
Fast pages, required checks on the branch, self-hosted runners, honest incidents95 </Form>
96 )}
97 </li>
98 );
99}
100
101type Platform = "linux" | "macos" | "windows" | "docker";
102
103/** The copy-paste steps for one platform. */
104export function installSteps(platform: Platform, token: RegistrationToken | null, arch: "x64" | "arm64" = "x64"): { title: string; lines: string[] }[] {
105 const url = token?.url ?? "https://g1t.sh";
106 const value = token?.token ?? "<registration token>";
107 const register = (exe: string) => `${exe} register --url ${url} --token ${value}`;
108 switch (platform) {
109 case "linux": {
110 const file = arch === "arm64" ? RUNNER_FILES["linux-arm64"] : RUNNER_FILES["linux-x64"];
111 return [
112 { title: "Download", lines: [`curl -fsSLo g1t-runner ${RUNNER_DOWNLOADS}/latest/${file} && chmod +x g1t-runner`] },
113 { title: "Register", lines: [register("./g1t-runner")] },
114 { title: "Run it, or install it as a service", lines: ["./g1t-runner run", "sudo ./g1t-runner service install"] },
115 ];
116 }
117 case "macos": {
118 const file = arch === "x64" ? RUNNER_FILES["macos-x64"] : RUNNER_FILES["macos-arm64"];
119 return [
120 { title: "Download", lines: [`curl -fsSLo g1t-runner ${RUNNER_DOWNLOADS}/latest/${file} && chmod +x g1t-runner`] },
121 { title: "Register", lines: [register("./g1t-runner")] },
122 { title: "Run it, or install it as a launch agent", lines: ["./g1t-runner run", "./g1t-runner service install"] },
123 ];
124 }
125 case "windows":
126 return [
127 {
128 title: "Download (PowerShell)",
129 lines: [`Invoke-WebRequest ${RUNNER_DOWNLOADS}/latest/${RUNNER_FILES["windows-x64"]} -OutFile g1t-runner.exe`],
130 },
131 { title: "Register", lines: [register(".\\g1t-runner.exe")] },
132 { title: "Run it, or install it as a service (as Administrator)", lines: [".\\g1t-runner.exe run", ".\\g1t-runner.exe service install"] },
133 ];
134 case "docker":
135 return [
136 {
137 title: "Register and run, with jobs in sibling containers",
138 lines: [
139 `docker run -d --name g1t-runner --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v g1t-runner:/data -e G1T_RUNNER_DIR=/data ${RUNNER_IMAGE} register-and-run --url ${url} --token ${value}`,
140 ],
141 },
142 ];
143 }
144}
145
146function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) {
147 const [platform, setPlatform] = useState<Platform>("linux");
148 const [arch, setArch] = useState<"x64" | "arm64">("x64");
149 return (
150 <div className="space-y-4 rounded-xl border border-line bg-surface p-4">
151 {!token ? (
152 <Form method="post" className="flex flex-wrap items-end gap-3">
153 <input type="hidden" name="intent" value="token" />
154 {!repoScoped && groups.length > 1 && (
155 <Field label="Group">
Chat controls, public profiles, shadcn selects, and no Docs tab in a project156 <SelectField
157 name="group"
158 defaultValue={groups.find((g) => g.default)?.name ?? groups[0]?.name}
159 className="w-auto min-w-40"
160 options={groups.map((group) => ({ value: group.name, label: group.name }))}
161 />
Fast pages, required checks on the branch, self-hosted runners, honest incidents162 </Field>
163 )}
Merge branch 'worktree-agent-a985c01f43538af01'164 <SubmitButton match={{ intent: "token" }} pending="Making a token…">
Fast pages, required checks on the branch, self-hosted runners, honest incidents165 <Plus size={15} />
166 New runner
Merge branch 'worktree-agent-a985c01f43538af01'167 </SubmitButton>
Fast pages, required checks on the branch, self-hosted runners, honest incidents168 <p className="basis-full text-xs text-faint">Makes a registration token that lasts an hour and registers any number of runners. It is shown once.</p>
169 </Form>
170 ) : (
171 <p className="text-sm">
172 Registration token made. It expires <TimeAgoFuture at={token.expiresAt} />
173 {token.group ? <>; its runners join {token.group}</> : null}. Copy it now: it is not shown again.
174 </p>
175 )}
176 <Tabs value={platform} onValueChange={(value) => setPlatform(value as Platform)}>
177 <div className="flex flex-wrap items-center gap-3">
178 <TabsList aria-label="Where the runner runs">
179 <TabsTrigger value="linux">Linux</TabsTrigger>
180 <TabsTrigger value="macos">macOS</TabsTrigger>
181 <TabsTrigger value="windows">Windows</TabsTrigger>
182 <TabsTrigger value="docker">Docker</TabsTrigger>
183 </TabsList>
184 {(platform === "linux" || platform === "macos") && (
185 <TabsList aria-label="Architecture">
186 <button
187 type="button"
188 onClick={() => setArch("x64")}
189 className={`rounded-md px-2.5 py-1 text-xs font-medium ${arch === "x64" ? "bg-raised text-fg" : "text-muted hover:text-fg"}`}
190 >
191 {platform === "macos" ? "Intel" : "x64"}
192 </button>
193 <button
194 type="button"
195 onClick={() => setArch("arm64")}
196 className={`rounded-md px-2.5 py-1 text-xs font-medium ${arch === "arm64" ? "bg-raised text-fg" : "text-muted hover:text-fg"}`}
197 >
198 {platform === "macos" ? "Apple silicon" : "arm64"}
199 </button>
200 </TabsList>
201 )}
202 </div>
203 </Tabs>
204 <ol className="space-y-3">
205 {installSteps(platform, token, platform === "macos" && arch === "x64" ? "x64" : arch).map((step, at) => (
206 <li key={step.title}>
207 <p className="mb-1.5 text-xs text-muted">
208 {at + 1}. {step.title}
209 </p>
210 <div className="space-y-1.5">
211 {step.lines.map((line) => (
212 <CopyLine key={line} text={line} prompt />
213 ))}
214 </div>
215 </li>
216 ))}
217 </ol>
218 <p className="text-xs text-faint">
219 Jobs run in Docker containers by default; add <code className="font-mono">--no-docker</code> to run them on the machine itself. Add{" "}
220 <code className="font-mono">--labels gpu,cuda</code> for jobs to ask for, or <code className="font-mono">--ephemeral</code> for a runner that takes
221 one job and removes itself.{" "}
222 <a href="https://docs.g1t.sh/guides/self-hosted-runners/" className="underline underline-offset-2">
223 Self-hosted runners
224 </a>
225 </p>
226 </div>
227 );
228}
229
230/** "in 59m", for a time still to come. */
231function TimeAgoFuture({ at }: { at: string }) {
232 const minutes = Math.max(0, Math.round((new Date(at).getTime() - Date.now()) / 60_000));
233 return (
234 <time dateTime={at} suppressHydrationWarning>
235 {minutes >= 60 ? "in an hour" : `in ${minutes}m`}
236 </time>
237 );
238}
239
Merge branch 'worktree-agent-a985c01f43538af01'240function Groups({
241 groups,
242 repositories,
243 manage,
244 action,
245}: {
246 groups: RunnerGroup[];
247 repositories: string[];
248 manage: boolean;
249 action: RunnersAction | undefined;
250}) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents251 const [editing, setEditing] = useState<string | null>(null);
Merge branch 'worktree-agent-a985c01f43538af01'252 // A group saved closes its form: the list above now shows it as saved.
253 useEffect(() => {
254 if (action?.notice) setEditing(null);
255 }, [action]);
Fast pages, required checks on the branch, self-hosted runners, honest incidents256 return (
257 <div className="space-y-3">
258 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
259 {groups.map((group) => (
260 <li key={group.id} className="px-4 py-3">
261 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
262 <span className="font-medium">{group.name}</span>
263 {group.default && <Pill>default</Pill>}
264 <span className="text-xs text-muted">
265 {group.runners} {group.runners === 1 ? "runner" : "runners"} ·{" "}
266 {group.repositories.length === 0 ? "every repository" : group.repositories.join(", ")}
267 </span>
268 {manage && (
269 <span className="ml-auto flex items-center gap-2">
270 <Button type="button" variant="quiet" onClick={() => setEditing(editing === group.id ? null : group.id)}>
271 {editing === group.id ? "Close" : "Edit"}
272 </Button>
273 {!group.default && (
274 <Form method="post" onSubmit={(event) => { if (!confirm(`Delete ${group.name}? Its runners join the default group.`)) event.preventDefault(); }}>
275 <input type="hidden" name="intent" value="delete-group" />
276 <input type="hidden" name="id" value={group.id} />
Merge branch 'worktree-agent-a985c01f43538af01'277 <SubmitButton variant="quiet" match={{ intent: "delete-group", id: group.id }} pending="Deleting…">
Fast pages, required checks on the branch, self-hosted runners, honest incidents278 Delete
Merge branch 'worktree-agent-a985c01f43538af01'279 </SubmitButton>
Fast pages, required checks on the branch, self-hosted runners, honest incidents280 </Form>
281 )}
282 </span>
283 )}
284 </div>
285 {editing === group.id && <GroupForm group={group} repositories={repositories} />}
286 </li>
287 ))}
288 </ul>
289 {manage && (editing === "new" ? <GroupForm group={null} repositories={repositories} /> : (
290 <Button type="button" variant="quiet" onClick={() => setEditing("new")}>
291 <Plus size={15} />
292 New group
293 </Button>
294 ))}
295 </div>
296 );
297}
298
299function GroupForm({ group, repositories }: { group: RunnerGroup | null; repositories: string[] }) {
300 const [reach, setReach] = useState(group && group.repositories.length > 0 ? "some" : "all");
301 return (
302 <Form method="post" className="mt-3 space-y-3 rounded-lg border border-line bg-bg p-3">
303 <input type="hidden" name="intent" value="group" />
304 {group && <input type="hidden" name="id" value={group.id} />}
305 <Field label="Name">
306 <Input name="name" defaultValue={group?.name ?? ""} placeholder="GPU machines" maxLength={64} required={!group} />
307 </Field>
308 <fieldset className="space-y-2 text-sm">
309 <legend className="mb-1 text-xs text-muted">Repositories that may use its runners</legend>
310 <label className="flex items-center gap-2">
311 <input type="radio" name="reach" value="all" checked={reach === "all"} onChange={() => setReach("all")} />
312 Every repository in the workspace
313 </label>
314 <label className="flex items-center gap-2">
315 <input type="radio" name="reach" value="some" checked={reach === "some"} onChange={() => setReach("some")} />
316 Only these
317 </label>
318 {reach === "some" && (
319 <div className="grid gap-1 pl-6 sm:grid-cols-2">
320 {repositories.map((name) => (
321 <label key={name} className="flex items-center gap-2 font-mono text-xs">
322 <input type="checkbox" name="repository" value={name} defaultChecked={group?.repositories.includes(name)} />
323 {name}
324 </label>
325 ))}
326 </div>
327 )}
328 </fieldset>
Merge branch 'worktree-agent-a985c01f43538af01'329 <SubmitButton match={{ intent: "group", id: group?.id }} pending="Saving…">
Fast pages, required checks on the branch, self-hosted runners, honest incidents330 Save group
Merge branch 'worktree-agent-a985c01f43538af01'331 </SubmitButton>
Fast pages, required checks on the branch, self-hosted runners, honest incidents332 </Form>
333 );
334}
335
336function Settings({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) {
337 const settings = data.settings;
338 const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false);
339 const [forks, setForks] = useState(settings?.forkPullRequests ?? false);
340 if (!settings) return null;
Merge branch 'worktree-agent-a985c01f43538af01'341 // The intent comes from the button pressed: a hidden one before it would
342 // win the form's first "intent", and Follow the workspace would only save.
Fast pages, required checks on the branch, self-hosted runners, honest incidents343 return (
344 <Form method="post" className="space-y-4">
345 {scope === "project" && settings.inherited && (
346 <p className="text-sm text-muted">These are the workspace&apos;s settings. Saving here gives this project its own.</p>
347 )}
348 <label className="flex items-start justify-between gap-4 rounded-xl border border-line bg-surface p-4">
349 <span className="min-w-0">
350 <span className="flex items-center gap-2 text-sm font-medium">
351 <Bot size={15} className="text-muted" />
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent352 Run g1t's work on self-hosted runners
Fast pages, required checks on the branch, self-hosted runners, honest incidents353 </span>
354 <span className="mt-1 block text-sm text-muted">
355 Agent runs, checks, reviews and the merge queue run on your runners with these labels instead of g1t&apos;s sandboxes. The machine time is
356 free; the model is paid as before, through g1t&apos;s model proxy, or by your own provider if you connected one. Agent work needs Docker, or a
357 Linux runner.
358 </span>
359 <span className="mt-3 block max-w-sm">
360 <Input name="agentLabels" defaultValue={settings.agentLabels.join(", ")} disabled={!manage} aria-label="Labels for agent work" />
361 </span>
362 </span>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit363 <Switch name="agents" checked={agents} onCheckedChange={setAgents} disabled={!manage} aria-label="Run g1t's work on self-hosted runners" />
Fast pages, required checks on the branch, self-hosted runners, honest incidents364 </label>
365 <label className={`flex items-start justify-between gap-4 rounded-xl border p-4 ${forks ? "border-danger/50 bg-danger/5" : "border-line bg-surface"}`}>
366 <span className="min-w-0">
367 <span className="flex items-center gap-2 text-sm font-medium">
368 <ShieldAlert size={15} className={forks ? "text-danger" : "text-muted"} />
369 Let pull requests from forks use self-hosted runners
370 </span>
371 <span className="mt-1 block text-sm text-muted">
372 Off by default, and best left off. Anyone who can open a pull request could then run any code on your machines, read what they can
373 reach, and leave something behind for the next job. Their jobs never get secrets either way.
374 </span>
375 </span>
376 <Switch name="forks" checked={forks} onCheckedChange={setForks} disabled={!manage} />
377 </label>
378 <p className="text-xs text-faint">
379 g1t&apos;s network guardrails are not enforced on your own machines: a job or an agent there reaches whatever the machine can.
380 </p>
381 {manage && (
382 <div className="flex flex-wrap gap-3">
Merge branch 'worktree-agent-a985c01f43538af01'383 <SubmitButton name="intent" value="settings" pending="Saving…">
Fast pages, required checks on the branch, self-hosted runners, honest incidents384 Save
Merge branch 'worktree-agent-a985c01f43538af01'385 </SubmitButton>
Fast pages, required checks on the branch, self-hosted runners, honest incidents386 {scope === "project" && !settings.inherited && (
Merge branch 'worktree-agent-a985c01f43538af01'387 <SubmitButton name="intent" value="inherit" variant="quiet" pending="Following…">
Fast pages, required checks on the branch, self-hosted runners, honest incidents388 Follow the workspace
Merge branch 'worktree-agent-a985c01f43538af01'389 </SubmitButton>
Fast pages, required checks on the branch, self-hosted runners, honest incidents390 )}
391 </div>
392 )}
393 </Form>
394 );
395}
396
397/** Settings, Runners: a workspace's, or a project's own. */
398export function RunnersPanel({
399 data,
400 action,
401 scope,
402 manage,
403}: {
404 data: RunnersData;
405 action: RunnersAction | undefined;
406 scope: "workspace" | "project";
407 manage: boolean;
408}) {
409 const online = data.runners.filter((runner) => runner.status !== "offline").length;
410 const base = (repo: string) => `/${repo}`;
411 return (
412 <div className="max-w-5xl space-y-8">
413 <div className="min-h-6">
Merge branch 'worktree-agent-ad7c6d88d93adc817'414 {action?.notice && <p className="text-sm text-success">{action.notice}</p>}
Fast pages, required checks on the branch, self-hosted runners, honest incidents415 <ErrorText>{action?.error ?? data.error ?? null}</ErrorText>
416 </div>
417 <Section
418 icon={<ServerCog size={15} className="text-muted" />}
419 title="Runners"
420 about={
421 <>
422 <p>
423 Your own machines, for jobs with <code className="font-mono text-fg">runs-on: self-hosted</code>. Their time is free on every plan.
424 </p>
425 <p className="mt-2">
426 {data.runners.length === 0 ? "None yet." : `${online} of ${data.runners.length} online.`}
427 </p>
428 </>
429 }
430 >
431 {data.runners.length === 0 ? (
432 <EmptyState title="No self-hosted runners">
433 Add one below. It connects out to g1t; nothing needs to reach the machine.
434 </EmptyState>
435 ) : (
436 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
437 {data.runners.map((runner) => (
438 <RunnerRow key={runner.id} runner={runner} manage={manage && (scope === "workspace" || runner.repo !== null)} base={base} />
439 ))}
440 </ul>
441 )}
442 </Section>
443 {manage && (
444 <Section
445 icon={<Plus size={15} className="text-muted" />}
446 title="New runner"
447 about="Download g1t-runner, register it with a token made here, and start it. Linux, macOS and Windows, x64 and arm64."
448 >
449 <NewRunner token={action?.token ?? null} groups={data.groups} repoScoped={scope === "project"} />
450 </Section>
451 )}
452 {scope === "workspace" && (
453 <Section
454 icon={<Boxes size={15} className="text-muted" />}
455 title="Groups"
456 about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another."
457 >
Merge branch 'worktree-agent-a985c01f43538af01'458 <Groups groups={data.groups} repositories={data.repositories} manage={manage} action={action} />
Fast pages, required checks on the branch, self-hosted runners, honest incidents459 </Section>
460 )}
461 <Section
462 icon={<Cpu size={15} className="text-muted" />}
463 title="Where work runs"
464 about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."}
465 >
Merge branch 'worktree-agent-a985c01f43538af01'466 {/* Keyed to what is saved, so following the workspace again shows its settings, not the switches as they were. */}
467 <Settings
468 key={data.settings ? `${data.settings.inherited}:${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"}
469 data={data}
470 manage={manage}
471 scope={scope}
472 />
Fast pages, required checks on the branch, self-hosted runners, honest incidents473 </Section>
474 </div>
475 );
476}

This file's history is long; its oldest lines are credited to the oldest commit read.