Skip to content

Commit

The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.

syntaqxcommitted Parent871c4a7Browse files
20 files+1003−510/20 viewed
+30−8
1818
1919 | Where | Page | Who manages it |
2020 | --- | --- | --- |
21−| A workspace | **Settings → Runners**, `g1t.sh/<workspace>/-/runners` | Owners only. |
21+| A workspace | **Workspace → Runners** in the dock, `g1t.sh/<workspace>/-/runners` | Owners only. |
2222 | A project | **Settings → Runners**, `g1t.sh/<workspace>/<project>/settings/runners` | People with the Admin [role](/guides/access-and-roles/) on its repository |
2323
2424 A workspace's runners serve the repositories their [group](#groups) allows.
2525 A project's own runners serve only that project.
2626
27+## The Runners page
28+
29+A workspace's **Runners** page shows g1t cloud and your own runners side
30+by side, with what each is running now and this month's time on each.
31+
32+| Part | What it shows |
33+| --- | --- |
34+| **g1t cloud** | How many agent runs and workflow jobs are running in g1t's sandboxes now, this month's sandbox time and what it came to at price, the price per minute from g1t's [price book](/guides/usage-and-billing/), and how many agent runs your plan allows at once. |
35+| **Your runners** | How many are online, busy, idle and offline, this month's self-hosted time (always $0), how many jobs and agent runs are waiting for one of them, and whether they take agent work. |
36+| **On g1t cloud now** | Each running agent run and workflow job, linked to its run, with its kind, project and when it started. Work handed to one of your runners is listed under that runner instead. |
37+| **Machine time** | This month's time on g1t cloud against your runners', from your [usage](/guides/usage-and-billing/). |
38+| **Your runners** list | Each runner's status, labels, OS, architecture, `g1t-runner` version and when it was last heard from, and what it is running, linked to the workflow run or the agent run. |
39+| **Where work runs** | What decides where each kind of work runs, and the [agent setting](#agents-on-your-runners). |
40+
41+A runner is **Idle** when it is online with nothing to do, **Busy** while it
42+runs a job or an agent run, and **Offline** when it has not polled for 90
43+seconds. When a service does not answer, only its part of the page says so.
44+
45+A runner in the desktop app, sessions that persist on a runner between
46+tasks, and an official agent image are coming, and the page labels them
47+so.
48+
2749 ## Add a runner
2850
29−1. Open **Settings → Runners** and click **New runner**. g1t makes a
51+1. Open **Runners** (a project's: **Settings → Runners**) and click **New runner**. g1t makes a
3052 registration token and shows the commands for Linux, macOS, Windows and
3153 Docker with it filled in. The token lasts an hour, can register any
3254 number of runners until then, and is shown once.
159181 them. Every workspace has a **Default** group, for every repository, which
160182 runners join unless their token or `--group` names another.
161183
162−To keep a set of machines for some repositories, open **Settings →
163−Runners**, click **New group**, name it, and choose **Only these**
184+To keep a set of machines for some repositories, open the workspace's
185+**Runners** page, click **New group**, name it, and choose **Only these**
164186 repositories. Then click **New runner** with that group chosen, or register
165187 with `--group`. Deleting a group moves its runners to the default group.
166188
167189 ## Agents on your runners
168190
169−**Settings → Runners → Where work runs** can send g1t's own work to your
191+**Runners → Where work runs** can send g1t's own work to your
170192 runners too: agent runs, checks, reviews, merge checks and the merge queue.
171193 Switch on **Run g1t's work on self-hosted runners** and give the labels
172194 a runner needs to take it (`self-hosted` is always one).
203225 ## Security
204226
205227 - **Pull requests from forks never run on your runners** unless you allow
206− it under **Settings → Runners → Where work runs**. Leave it off on a
228+ it under **Runners → Where work runs**. Leave it off on a
207229 public repository: anyone who can open a pull request could run any code
208230 on the machine, read what it can reach, and leave something behind for
209231 the next job. Such jobs get no secrets either way.
299321
300322 On the machine, `g1t-runner remove` unregisters it and forgets its
301323 credential (run `service uninstall` first if it is a service). Or remove it
302−from **Settings → Runners**; the runner stops on its next poll. A job it was
324+from the **Runners** page; the runner stops on its next poll. A job it was
303325 running fails. Runners offline for 14 days are removed by themselves.
304326
305327 ## API and MCP
319341
320342 | What you see | What to do |
321343 | --- | --- |
322−| The job says *Waiting for a self-hosted runner with labels …* | No online runner has every one of those labels in a group that allows the repository. Compare the labels on **Settings → Runners**, check the runner's group, or start the runner. |
344+| The job says *Waiting for a self-hosted runner with labels …* | No online runner has every one of those labels in a group that allows the repository. Compare the labels on the **Runners** page, check the runner's group, or start the runner. |
323345 | *Pull requests from forks do not run on self-hosted runners here.* | The run is from a fork. Allow it under **Where work runs**, if you trust everyone who can open a pull request. |
324346 | `register` says the token is not valid | It expired after an hour, or was mistyped. Make a new one. |
325347 | `register` says a runner with that name is registered | Choose another `--name`, or add `--replace`. |
+421−0
1+import { ArrowRight, Bot, Boxes, Cloud, Cpu, Laptop, Plus, ServerCog, Workflow } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Link } from "react-router";
4+
5+import { RUN_KIND_LABEL, type Runner, type RunKind } from "@g1t/contracts";
6+
7+import { dollars } from "../lib/billing";
8+import { type CloudWork, machineTime, perMinute } from "../lib/runners";
9+import type { RunnersAction, RunnersPageData } from "../lib/runners.server";
10+import { Groups, NewRunner, RunnerRow, RunnerSettingsForm, STATUS } from "./runners";
11+import { EmptyState, ErrorText, TimeAgo } from "./ui";
12+import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
13+
14+/** The most of what runs on g1t's cloud the card lists before saying how many more. */
15+const CLOUD_SHOWN = 6;
16+
17+/** A small label that something is coming. */
18+function Coming() {
19+ return <span className="shrink-0 rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">Coming</span>;
20+}
21+
22+function Section({ id, title, about, action, children }: { id?: string; title: string; about?: ReactNode; action?: ReactNode; children: ReactNode }) {
23+ return (
24+ <section id={id} className="scroll-mt-6 space-y-3">
25+ <div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-1">
26+ <div className="min-w-0">
27+ <h2 className="text-base font-semibold tracking-tight">{title}</h2>
28+ {about && <div className="mt-0.5 text-sm text-muted">{about}</div>}
29+ </div>
30+ {action}
31+ </div>
32+ {children}
33+ </section>
34+ );
35+}
36+
37+/** One figure in a place's card: a label and its value. */
38+function Figure({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
39+ return (
40+ <div className="flex items-baseline justify-between gap-3 py-2 text-sm">
41+ <dt className="text-muted">
42+ {hint ? (
43+ <Tooltip>
44+ <TooltipTrigger asChild>
45+ <span className="cursor-help underline decoration-line-strong decoration-dotted underline-offset-4">{label}</span>
46+ </TooltipTrigger>
47+ <TooltipContent>{hint}</TooltipContent>
48+ </Tooltip>
49+ ) : (
50+ label
51+ )}
52+ </dt>
53+ <dd className="min-w-0 text-right text-fg tabular-nums">{children}</dd>
54+ </div>
55+ );
56+}
57+
58+function Unknown({ what }: { what: string }) {
59+ return <span className="text-faint">{what} didn&apos;t answer</span>;
60+}
61+
62+function PlaceCard({ icon, title, tag, about, headline, children }: { icon: ReactNode; title: string; tag: ReactNode; about: string; headline: ReactNode; children: ReactNode }) {
63+ return (
64+ <section className="flex min-w-0 flex-col rounded-2xl border border-line bg-surface">
65+ <header className="px-5 pt-4">
66+ <div className="flex items-center gap-2">
67+ <span className="grid size-7 shrink-0 place-items-center rounded-lg bg-raised text-fg-soft">{icon}</span>
68+ <h2 className="text-sm font-semibold">{title}</h2>
69+ <span className="ml-auto">{tag}</span>
70+ </div>
71+ <p className="mt-2 text-sm leading-relaxed text-muted">{about}</p>
72+ </header>
73+ <div className="px-5 pt-4">{headline}</div>
74+ <dl className="mt-2 divide-y divide-line border-t border-line px-5 py-1">{children}</dl>
75+ </section>
76+ );
77+}
78+
79+function kindLabel(work: CloudWork): string {
80+ if (work.kind === "workflow") return "Workflow job";
81+ return RUN_KIND_LABEL[work.detail as RunKind] ?? "Agent run";
82+}
83+
84+function CloudList({ data }: { data: RunnersPageData }) {
85+ const cloud = data.cloud;
86+ if (!cloud) return <p className="px-5 py-4 text-sm text-muted">What runs on g1t&apos;s cloud can&apos;t be read right now.</p>;
87+ if (cloud.running.length === 0) {
88+ return (
89+ <p className="px-5 py-4 text-sm text-muted">
90+ Nothing is running here now. A sandbox starts for each agent run or job and stops when it ends{cloud.queued > 0 ? `; ${cloud.queued} waiting to start` : ""}.
91+ </p>
92+ );
93+ }
94+ const shown = cloud.running.slice(0, CLOUD_SHOWN);
95+ const rest = cloud.running.length - shown.length;
96+ return (
97+ <ul className="divide-y divide-line">
98+ {shown.map((work) => (
99+ <li key={`${work.kind}:${work.id}`} className="flex items-center gap-3 px-5 py-2.5">
100+ <span className="grid size-6 shrink-0 place-items-center rounded-md bg-info/12 text-info" aria-hidden>
101+ {work.kind === "agent" ? <Bot size={13} /> : <Workflow size={13} />}
102+ </span>
103+ <div className="min-w-0 grow">
104+ <Link to={work.href} className="block truncate text-sm text-fg hover:underline">
105+ {work.title}
106+ </Link>
107+ <p className="truncate text-xs text-muted">
108+ {kindLabel(work)} · {work.repo}
109+ </p>
110+ </div>
111+ {work.startedAt && (
112+ <span className="shrink-0 text-xs text-faint">
113+ <TimeAgo at={work.startedAt} />
114+ </span>
115+ )}
116+ </li>
117+ ))}
118+ {(rest > 0 || cloud.more || cloud.queued > 0) && (
119+ <li className="px-5 py-2.5 text-xs text-muted">
120+ {[rest > 0 || cloud.more ? `${rest > 0 ? rest : "More"}${cloud.more ? "+" : ""} more running` : null, cloud.queued > 0 ? `${cloud.queued} waiting to start` : null]
121+ .filter(Boolean)
122+ .join(" · ")}
123+ </li>
124+ )}
125+ </ul>
126+ );
127+}
128+
129+function StatusCounts({ runners }: { runners: Runner[] }) {
130+ const counts = { busy: 0, online: 0, offline: 0 } as Record<Runner["status"], number>;
131+ for (const runner of runners) counts[runner.status] += 1;
132+ return (
133+ <ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
134+ {(["busy", "online", "offline"] as const).map((status) => (
135+ <li key={status} className="flex items-center gap-1.5">
136+ <span className={`size-2 rounded-full ${STATUS[status].dot}`} aria-hidden />
137+ <span className="text-fg tabular-nums">{counts[status]}</span> {STATUS[status].label.toLowerCase()}
138+ </li>
139+ ))}
140+ </ul>
141+ );
142+}
143+
144+/** The workspace's Runners page: g1t's cloud and its own runners, side by side. */
145+export function RunnersPage({ data, action, slug }: { data: RunnersPageData; action: RunnersAction | undefined; slug: string }) {
146+ const own = data.runners;
147+ const online = own.filter((runner) => runner.status !== "offline").length;
148+ const cost = data.cost;
149+ const running = data.cloud?.running.length ?? null;
150+ const usage = `/${slug}/-/usage`;
151+ return (
152+ <div className="space-y-10">
153+ <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
154+ <div className="min-w-0">
155+ <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">Runners</h1>
156+ <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
157+ The machines your agents and workflow jobs run on. g1t&apos;s cloud needs nothing set up; your own runners are any machine with{" "}
158+ <code className="font-mono text-fg-soft">g1t-runner</code> on it, and time on them is free.
159+ </p>
160+ </div>
161+ <a
162+ href="#add"
163+ className="inline-flex h-9 shrink-0 items-center gap-1.5 self-start rounded-lg bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover"
164+ >
165+ <Plus size={15} />
166+ Add a runner
167+ </a>
168+ </header>
169+
170+ {(action?.notice || action?.error || data.error) && (
171+ <div>
172+ {action?.notice && <p className="text-sm text-success">{action.notice}</p>}
173+ <ErrorText>{action?.error ?? data.error ?? null}</ErrorText>
174+ </div>
175+ )}
176+
177+ <div className="grid items-start gap-5 lg:grid-cols-2">
178+ <PlaceCard
179+ icon={<Cloud size={15} />}
180+ title="g1t cloud"
181+ tag={<span className="rounded-full bg-raised px-2 py-0.5 text-xs text-fg-soft">Managed by g1t</span>}
182+ about="A fresh sandbox for every agent run and workflow job, stopped when it ends. Billed by the second at the price below."
183+ headline={
184+ <div className="flex items-baseline gap-2">
185+ <span className="text-3xl font-semibold tracking-tight tabular-nums">{running ?? "–"}</span>
186+ <span className="text-sm text-muted">running now</span>
187+ </div>
188+ }
189+ >
190+ <Figure label="This month" hint="Agent sandbox time and sandbox time on your usage, at price, from the first of the month.">
191+ {cost ? (
192+ <>
193+ {machineTime(cost.cloudSeconds)} · {dollars(cost.cloudMicros)}
194+ </>
195+ ) : (
196+ <Unknown what="Billing" />
197+ )}
198+ </Figure>
199+ {data.prices ? (
200+ <Figure label="Price" hint={`From g1t's price book: ${data.prices.map((price) => price.title).join("; ")}.`}>
201+ <span className="flex flex-wrap justify-end gap-x-1">
202+ {data.prices.map((price, at) => (
203+ <span key={price.meter} className="whitespace-nowrap">
204+ {at > 0 && "+ "}
205+ {perMinute(price.perMinuteMicros)}/{price.unit}
206+ </span>
207+ ))}
208+ </span>
209+ </Figure>
210+ ) : (
211+ <Figure label="Price">
212+ <Unknown what="Billing" />
213+ </Figure>
214+ )}
215+ {data.agentCap != null && (
216+ <Figure label="Agent runs at once" hint="Your plan's cap on agent runs at the same time.">
217+ up to {data.agentCap}
218+ </Figure>
219+ )}
220+ </PlaceCard>
221+
222+ <PlaceCard
223+ icon={<ServerCog size={15} />}
224+ title="Your runners"
225+ tag={<span className="rounded-full bg-success/12 px-2 py-0.5 text-xs font-medium text-success">Free</span>}
226+ about="Your own machines: Linux, macOS or Windows, anywhere they can reach g1t. They only connect out, and take the work whose labels they have."
227+ headline={
228+ <div className="space-y-2">
229+ <div className="flex items-baseline gap-2">
230+ <span className="text-3xl font-semibold tracking-tight tabular-nums">{own.length === 0 ? 0 : online}</span>
231+ <span className="text-sm text-muted">{own.length === 0 ? "runners yet" : `of ${own.length} online`}</span>
232+ </div>
233+ {own.length > 0 && <StatusCounts runners={own} />}
234+ </div>
235+ }
236+ >
237+ <Figure label="This month" hint="Self-hosted runner time on your usage, from the first of the month. It is never charged.">
238+ {cost ? (
239+ <>
240+ {machineTime(cost.ownSeconds)} · {dollars(0)}
241+ </>
242+ ) : (
243+ <Unknown what="Billing" />
244+ )}
245+ </Figure>
246+ <Figure label="Waiting for one of them">
247+ {data.waiting ? (
248+ data.waiting.jobs + data.waiting.tasks === 0 ? (
249+ "Nothing"
250+ ) : (
251+ [
252+ data.waiting.jobs > 0 ? `${data.waiting.jobs} ${data.waiting.jobs === 1 ? "job" : "jobs"}` : null,
253+ data.waiting.tasks > 0 ? `${data.waiting.tasks} agent ${data.waiting.tasks === 1 ? "run" : "runs"}` : null,
254+ ]
255+ .filter(Boolean)
256+ .join(", ")
257+ )
258+ ) : (
259+ <Unknown what="Actions" />
260+ )}
261+ </Figure>
262+ <Figure label="Takes agent work">
263+ {data.settings ? (data.settings.agentsOnSelfHosted ? `Yes, labelled ${data.settings.agentLabels.join(", ")}` : "No") : "–"}
264+ </Figure>
265+ </PlaceCard>
266+ </div>
267+
268+ <div className="grid items-start gap-5 lg:grid-cols-2">
269+ <Section title="On g1t cloud now" about="Agent runs and workflow jobs in g1t's sandboxes.">
270+ <div className="rounded-2xl border border-line bg-surface">
271+ <CloudList data={data} />
272+ </div>
273+ </Section>
274+ <Section
275+ title="Machine time"
276+ about={cost ? `${cost.from} to ${cost.until}, as your usage shows it.` : "From your usage."}
277+ action={
278+ <Link to={usage} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
279+ Usage
280+ <ArrowRight size={12} />
281+ </Link>
282+ }
283+ >
284+ <TimeSplit data={data} />
285+ </Section>
286+ </div>
287+
288+ <Section title="Your runners" about={own.length === 0 ? "None yet." : `${online} of ${own.length} online. A runner that hasn't been heard from for 90 seconds is offline.`}>
289+ {own.length === 0 ? (
290+ <EmptyState title="No runners of your own yet">
291+ <a href="#add" className="underline underline-offset-2">
292+ Add one
293+ </a>{" "}
294+ to run jobs and agents on your hardware. It connects out to g1t; nothing needs to reach the machine.
295+ </EmptyState>
296+ ) : (
297+ <ul className="divide-y divide-line rounded-2xl border border-line bg-surface">
298+ {own.map((runner) => (
299+ <RunnerRow key={runner.id} runner={runner} manage />
300+ ))}
301+ </ul>
302+ )}
303+ </Section>
304+
305+ <Section title="Where work runs" about="For every project in the workspace, unless a project says otherwise in its own settings.">
306+ <div className="grid gap-3 md:grid-cols-3">
307+ <WhereCard icon={<Workflow size={15} />} title="Workflow jobs">
308+ Each job&apos;s <code className="font-mono text-fg-soft">runs-on</code> decides. <code className="font-mono text-fg-soft">ubuntu-latest</code>,{" "}
309+ <code className="font-mono text-fg-soft">g1t-4core</code> and other Linux labels run on g1t cloud;{" "}
310+ <code className="font-mono text-fg-soft">self-hosted</code> and your own labels run on your runners.
311+ </WhereCard>
312+ <WhereCard icon={<Bot size={15} />} title="Agents">
313+ {data.settings?.agentsOnSelfHosted
314+ ? `Agent runs, checks, reviews and the merge queue run on your runners labelled ${data.settings.agentLabels.join(", ")}.`
315+ : "Agent runs, checks, reviews and the merge queue run on g1t cloud. Switch them to your runners below."}
316+ </WhereCard>
317+ <WhereCard icon={<Laptop size={15} />} title="Your desktop" coming>
318+ The desktop app will include a runner: for a logged-in browser, a simulator or a tool only your network reaches. It asks before each task.
319+ </WhereCard>
320+ </div>
321+ <RunnerSettingsForm
322+ key={data.settings ? `${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"}
323+ data={data}
324+ manage
325+ scope="workspace"
326+ />
327+ </Section>
328+
329+ <Section
330+ id="add"
331+ title="Add a runner"
332+ about="Download g1t-runner, register it with a token made here, and start it. Linux, macOS and Windows, x64 and arm64, or Docker."
333+ >
334+ <NewRunner token={action?.token ?? null} groups={data.groups} repoScoped={false} />
335+ </Section>
336+
337+ <Section title="Groups" about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another.">
338+ <Groups groups={data.groups} repositories={data.repositories} manage action={action} />
339+ </Section>
340+
341+ <Section title="Coming to runners">
342+ <ul className="divide-y divide-line rounded-2xl border border-line bg-surface">
343+ <ComingRow icon={<Cpu size={15} />} title="Sessions that persist">
344+ An agent keeps its runner&apos;s disk between tasks, its checkouts, caches and notes, and picks up where it left off.
345+ </ComingRow>
346+ <ComingRow icon={<Boxes size={15} />} title="An official agent image">
347+ One image with git, Node and the agent&apos;s tools, so agent work runs on any runner with Docker without an image of your own.
348+ </ComingRow>
349+ </ul>
350+ </Section>
351+ </div>
352+ );
353+}
354+
355+function WhereCard({ icon, title, coming, children }: { icon: ReactNode; title: string; coming?: boolean; children: ReactNode }) {
356+ return (
357+ <div className={`rounded-2xl border p-4 ${coming ? "border-dashed border-line" : "border-line bg-surface"}`}>
358+ <h3 className="flex items-center gap-2 text-sm font-medium">
359+ <span className="text-muted">{icon}</span>
360+ {title}
361+ {coming && <span className="ml-auto"><Coming /></span>}
362+ </h3>
363+ <p className="mt-2 text-sm leading-relaxed text-muted">{children}</p>
364+ </div>
365+ );
366+}
367+
368+function ComingRow({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) {
369+ return (
370+ <li className="flex items-start gap-3 px-4 py-3">
371+ <span className="mt-0.5 text-muted">{icon}</span>
372+ <div className="min-w-0 grow">
373+ <p className="flex items-center gap-2 text-sm font-medium">
374+ {title}
375+ <Coming />
376+ </p>
377+ <p className="mt-0.5 text-sm text-muted">{children}</p>
378+ </div>
379+ </li>
380+ );
381+}
382+
383+/** This month's machine time, g1t cloud's against the workspace's own, as one bar. */
384+function TimeSplit({ data }: { data: RunnersPageData }) {
385+ const cost = data.cost;
386+ if (!cost) {
387+ return (
388+ <div className="rounded-2xl border border-line bg-surface px-5 py-4 text-sm text-muted">Billing didn&apos;t answer, so this month&apos;s time can&apos;t be shown right now.</div>
389+ );
390+ }
391+ const total = cost.cloudSeconds + cost.ownSeconds;
392+ return (
393+ <div className="rounded-2xl border border-line bg-surface px-5 py-4">
394+ {total === 0 ? (
395+ <p className="text-sm text-muted">No machine time yet this month.</p>
396+ ) : (
397+ <div className="flex h-2.5 gap-[2px] overflow-hidden rounded-full" role="img" aria-label="This month's machine time by where it ran">
398+ {cost.cloudSeconds > 0 && <span className="h-full min-w-[3px] bg-info" style={{ flexGrow: cost.cloudSeconds }} />}
399+ {cost.ownSeconds > 0 && <span className="h-full min-w-[3px] bg-success" style={{ flexGrow: cost.ownSeconds }} />}
400+ </div>
401+ )}
402+ <ul className="mt-3 space-y-2 text-sm">
403+ <li className="flex items-center gap-2">
404+ <span className="size-2 shrink-0 rounded-[2px] bg-info" aria-hidden />
405+ <span className="min-w-0 grow truncate text-fg-soft">g1t cloud</span>
406+ <span className="text-muted tabular-nums">{machineTime(cost.cloudSeconds)}</span>
407+ <span className="w-20 text-right text-fg tabular-nums">{dollars(cost.cloudMicros)}</span>
408+ </li>
409+ <li className="flex items-center gap-2">
410+ <span className="size-2 shrink-0 rounded-[2px] bg-success" aria-hidden />
411+ <span className="min-w-0 grow truncate text-fg-soft">Your runners</span>
412+ <span className="text-muted tabular-nums">{machineTime(cost.ownSeconds)}</span>
413+ <span className="w-20 text-right text-fg tabular-nums">{dollars(0)}</span>
414+ </li>
415+ </ul>
416+ <p className="mt-4 text-[0.6875rem] leading-relaxed text-faint">
417+ {cost.free ? "Usage at cost: g1t charges nothing for it now." : "At price, before your plan's included usage or credit paid for any of it."} Models are separate.
418+ </p>
419+ </div>
420+ );
421+}
+24−16
44
55 import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
66
7+import { agentRunHref, workflowRunHref } from "../lib/runners";
78 import type { RunnersAction, RunnersData } from "../lib/runners.server";
89 import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
910 import { SelectField } from "./ui/select";
1011 import { Switch } from "./ui/switch";
1112 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
1213
13−const STATUS: Record<Runner["status"], { dot: string; label: string }> = {
14− online: { dot: "bg-success", label: "Idle" },
15− busy: { dot: "bg-info", label: "Busy" },
16− offline: { dot: "bg-faint", label: "Offline" },
14+/** Online and waiting for work reads as Idle; online and working, Busy. */
15+export const STATUS: Record<Runner["status"], { dot: string; text: string; label: string }> = {
16+ online: { dot: "bg-success", text: "text-success", label: "Idle" },
17+ busy: { dot: "bg-info", text: "text-info", label: "Busy" },
18+ offline: { dot: "bg-faint", text: "text-muted", label: "Offline" },
1719 };
1820
19−const OS_NAMES: Record<Runner["os"], string> = { linux: "Linux", macos: "macOS", windows: "Windows" };
21+/** Where a runner's current work is: its workflow run, or the agent run it is. */
22+function workHref(work: NonNullable<Runner["work"]>): string | null {
23+ if (!work.runId || !work.repo) return null;
24+ return work.kind === "workflow" ? workflowRunHref(work.repo, work.runId) : agentRunHref(work.repo, work.runId);
25+}
26+
27+export const OS_NAMES: Record<Runner["os"], string> = { linux: "Linux", macos: "macOS", windows: "Windows" };
2028
2129 function Section({ icon, title, about, children }: { icon: React.ReactNode; title: string; about: React.ReactNode; children: React.ReactNode }) {
2230 return (
3341 );
3442 }
3543
36−function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) {
44+export function RunnerRow({ runner, manage }: { runner: Runner; manage: boolean }) {
3745 const status = STATUS[runner.status];
46+ const href = runner.work ? workHref(runner.work) : null;
3847 return (
3948 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
4049 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden />
4150 <div className="min-w-0 grow basis-60">
4251 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
4352 <span className="font-medium">{runner.name}</span>
44− <span className="text-xs text-muted">{status.label}</span>
53+ <span className={`text-xs font-medium ${status.text}`}>{status.label}</span>
4554 {runner.ephemeral && <Pill>ephemeral</Pill>}
4655 {runner.group && <span className="text-xs text-faint">in {runner.group}</span>}
4756 </div>
5564 {runner.work && (
5665 <p className="mt-1.5 truncate text-xs text-muted">
5766 {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">
67+ {href ? (
68+ <Link to={href} className="text-fg hover:underline">
6069 {runner.work.name}
6170 </Link>
6271 ) : (
6675 </p>
6776 )}
6877 </div>
69− <div className="shrink-0 text-right text-xs text-faint">
78+ <div className="ml-6 grow text-xs text-faint sm:ml-0 sm:shrink-0 sm:grow-0 sm:text-right">
7079 <div>
7180 {OS_NAMES[runner.os]} {runner.arch}
7281 {runner.version && <> · {runner.version}</>}
143152 }
144153 }
145154
146−function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) {
155+export function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) {
147156 const [platform, setPlatform] = useState<Platform>("linux");
148157 const [arch, setArch] = useState<"x64" | "arm64">("x64");
149158 return (
237246 );
238247 }
239248
240−function Groups({
249+export function Groups({
241250 groups,
242251 repositories,
243252 manage,
333342 );
334343 }
335344
336−function Settings({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) {
345+export function RunnerSettingsForm({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) {
337346 const settings = data.settings;
338347 const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false);
339348 const [forks, setForks] = useState(settings?.forkPullRequests ?? false);
407416 manage: boolean;
408417 }) {
409418 const online = data.runners.filter((runner) => runner.status !== "offline").length;
410− const base = (repo: string) => `/${repo}`;
411419 return (
412420 <div className="max-w-5xl space-y-8">
413421 <div className="min-h-6">
435443 ) : (
436444 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
437445 {data.runners.map((runner) => (
438− <RunnerRow key={runner.id} runner={runner} manage={manage && (scope === "workspace" || runner.repo !== null)} base={base} />
446+ <RunnerRow key={runner.id} runner={runner} manage={manage && (scope === "workspace" || runner.repo !== null)} />
439447 ))}
440448 </ul>
441449 )}
464472 about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."}
465473 >
466474 {/* Keyed to what is saved, so following the workspace again shows its settings, not the switches as they were. */}
467− <Settings
475+ <RunnerSettingsForm
468476 key={data.settings ? `${data.settings.inherited}:${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"}
469477 data={data}
470478 manage={manage}
+10−8
459459
460460 /**
461461 * A workspace's settings pages, which the sidebar drills into: how it is
462− * set up and connected (guardrails, secrets, runners, integrations,
462+ * set up and connected (guardrails, secrets, integrations,
463463 * webhooks), what it pays, its repositories, tokens and record. The main
464464 * list keeps the places work happens and who belongs; every member can
465465 * still open these.
11361136 }
11371137
11381138 /** Workspace pages that sit in its Settings list, drilled into from the Workspace sidebar. */
1139−const WORKSPACE_SETTINGS = ["settings", "repositories", "tokens", "personal-access-tokens", "secrets", "actions", "runners", "webhooks", "emoji"];
1139+const WORKSPACE_SETTINGS = ["settings", "repositories", "tokens", "personal-access-tokens", "secrets", "actions", "webhooks", "emoji"];
11401140
11411141 /**
11421142 * The Workspace mode's sidebar: the workspace itself, for every member.
1143− * Its overview, money, connections, policies and record, then its
1143+ * Its overview, money, machines, connections, policies and record, then its
11441144 * settings as a list of their own. Owner-only pages stay owner-only.
11451145 */
11461146 export function WorkspaceSidebar({ slug, owner }: { slug: string; owner: boolean }) {
11661166 Billing and plans
11671167 </SidebarLink>
11681168 </SidebarGroup>
1169+ {owner && (
1170+ <SidebarGroup title="Compute" className="mt-3">
1171+ <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />} current={at("runners")}>
1172+ Runners
1173+ </SidebarLink>
1174+ </SidebarGroup>
1175+ )}
11691176 <SidebarGroup title="Connections" className="mt-3">
11701177 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />} current={at("integrations")}>
11711178 Integrations
12301237 <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}>
12311238 Actions
12321239 </SidebarLink>
1233− {owner && (
1234− <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}>
1235− Runners
1236− </SidebarLink>
1237− )}
12381240 </SidebarGroup>
12391241 </nav>
12401242 );
+1−1
114114 "limit_requests links log logs managed_pulls memories_by_id memory_context my_repo_invitations " +
115115 "outside_collaborators overview path_by_id prices profile profile_workspaces public_namespaces read_session " +
116116 "readable ready_issues references registration repo_access resolve resolve_branch resolve_path resolve_slug " +
117− "routes run run_context run_cost runner_groups runner_settings runners runs scorecards search search_memories " +
117+ "routes run run_context run_cost runner_activity runner_groups runner_settings runners runs scorecards search search_memories usage_report " +
118118 "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " +
119119 "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " +
120120 "stars about public_links branch_drift tags last_commits languages contributors license releases release " +
+61−0
77 type RunnerSettings,
88 type RunnersOwner,
99 type User,
10+ RUNNER_ACTIVITY_LIMIT,
1011 runnersClient,
1112 } from "@g1t/contracts";
1213
1314 import { instrumented } from "./perf.server";
15+import { type CloudNow, type CloudPrice, type OwnWaiting, type RunnerCost, cloudNow, cloudPrices, ownWaiting, runnerCost } from "./runners";
16+import { agents, billing } from "./services.server";
1417
1518 /** Self-hosted runners, kept by the actions service. */
1619 export const runners = runnersClient(instrumented("actions", env.ACTIONS));
4043 };
4144 }
4245
46+/** The workspace's Runners page: its runners, and g1t's cloud beside them. */
47+export type RunnersPageData = RunnersData & {
48+ /** What runs on g1t's cloud now; null when neither the work nor the actions service answered. */
49+ cloud: CloudNow | null;
50+ /** What waits for the workspace's own runners; null when the actions service did not answer. */
51+ waiting: OwnWaiting | null;
52+ /** This month's machine time; null when billing did not answer. */
53+ cost: RunnerCost | null;
54+ /** A minute on g1t's cloud, from the price book; null when it has none. */
55+ prices: CloudPrice[] | null;
56+ /** Agent runs at once on g1t's cloud the plan allows; null for no cap, or unknown. */
57+ agentCap: number | null;
58+};
59+
60+/** Active agent runs read: the work service's most in one list. */
61+const RUN_PAGE = 200;
62+
63+const warn = (what: string) => (error: unknown) => {
64+ console.warn(`runners: ${what} failed`, error);
65+ return null;
66+};
67+
68+/**
69+ * Every read the workspace's Runners page makes, at once. The runners,
70+ * groups and settings come as on a project's page; what runs on g1t's
71+ * cloud, what waits, and this month's time each come from their own
72+ * service, and one that does not answer leaves only its part saying so.
73+ */
74+export async function loadRunnersPage(workspace: string, actor: User, repositories: string[] = [], now = new Date()): Promise<RunnersPageData> {
75+ const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
76+ const until = now.toISOString().slice(0, 10);
77+ const [base, activity, runs, report, book, entitlements] = await Promise.all([
78+ loadRunners({ workspace }, actor, repositories),
79+ runners
80+ .activity(actor, workspace)
81+ .then((result) => (result.ok ? result.value : null))
82+ .catch(warn("runner activity")),
83+ agents
84+ .listRuns(actor, { workspace, active: true, limit: RUN_PAGE })
85+ .then((result) => (result.ok ? result.value : null))
86+ .catch(warn("agent runs")),
87+ billing
88+ .usageReport(workspace, actor, { from, until })
89+ .then((result) => (result.ok ? result.value : null))
90+ .catch(warn("usage report")),
91+ billing.prices().catch(warn("prices")),
92+ billing.entitlements(workspace).catch(warn("entitlements")),
93+ ]);
94+ return {
95+ ...base,
96+ cloud: cloudNow(runs, activity, RUN_PAGE, RUNNER_ACTIVITY_LIMIT),
97+ waiting: ownWaiting(activity),
98+ cost: runnerCost(report),
99+ prices: cloudPrices(book?.prices ?? null),
100+ agentCap: entitlements && entitlements.maxConcurrentAgents > 0 ? entitlements.maxConcurrentAgents : null,
101+ };
102+}
103+
43104 export type RunnersAction = {
44105 error?: string;
45106 notice?: string;
+105−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AgentRun, Price, RunnerActivity, UsageReport } from "@g1t/contracts";
5+
6+import { cloudNow, cloudPrices, machineTime, ownWaiting, perMinute, runnerCost } from "./runners.ts";
7+
8+function run(id: string, status: AgentRun["status"], over: Partial<AgentRun> = {}): AgentRun {
9+ return {
10+ id,
11+ repo: { namespace: "acme", name: "web" },
12+ number: 12,
13+ title: "Fix the flaky checkout test",
14+ kind: "implement",
15+ agent: "g1t",
16+ model: null,
17+ status,
18+ step: null,
19+ steps: [],
20+ stepCount: 0,
21+ startedBy: null,
22+ error: null,
23+ costUsd: null,
24+ turns: null,
25+ createdAt: "2026-10-10T10:00:00Z",
26+ startedAt: "2026-10-10T10:01:00Z",
27+ finishedAt: null,
28+ updatedAt: "2026-10-10T10:02:00Z",
29+ ...over,
30+ };
31+}
32+
33+const activity: RunnerActivity = {
34+ cloud_jobs: [{ id: "job_1", name: "test (ubuntu)", run_id: "wr_1", repo: "acme/web", started_at: "2026-10-10T10:05:00Z" }],
35+ cloud_jobs_queued: 2,
36+ handed_over: [
37+ { id: "rtk_1", run_id: "run_mine", kind: "implement", title: "On my runner", repo: "acme/web", status: "in_progress", runner_name: "mac", created_at: "2026-10-10T09:00:00Z", started_at: null },
38+ { id: "rtk_2", run_id: null, kind: "checks", title: "Waiting", repo: "acme/web", status: "queued", runner_name: null, created_at: "2026-10-10T09:00:00Z", started_at: null },
39+ ],
40+ self_hosted_jobs_queued: 3,
41+};
42+
43+test("g1t cloud is the running agent runs not handed to the workspace's runners, and the jobs in g1t's sandboxes", () => {
44+ const now = cloudNow([run("run_a", "running"), run("run_mine", "running"), run("run_q", "queued")], activity, 200, 100)!;
45+ assert.deepEqual(
46+ now.running.map((work) => [work.kind, work.id, work.href]),
47+ [
48+ ["workflow", "job_1", "/acme/web/actions/runs/wr_1"],
49+ ["agent", "run_a", "/acme/web/agents/runs/run_a"],
50+ ],
51+ );
52+ // One queued agent run and two queued jobs.
53+ assert.equal(now.queued, 3);
54+ assert.equal(now.more, false);
55+});
56+
57+test("a service that does not answer leaves only its part out", () => {
58+ assert.equal(cloudNow(null, null, 200, 100), null);
59+ assert.equal(cloudNow([run("run_a", "running")], null, 200, 100)!.running.length, 1);
60+ assert.equal(ownWaiting(null), null);
61+ assert.deepEqual(ownWaiting(activity), { tasks: 1, jobs: 3 });
62+ assert.equal(runnerCost(null), null);
63+});
64+
65+test("a full list says there may be more", () => {
66+ assert.equal(cloudNow([run("run_a", "running")], activity, 1, 100)!.more, true);
67+});
68+
69+test("this month's time adds agent sandbox and sandbox time for g1t cloud, and self-hosted time for the workspace's own", () => {
70+ const meter = (key: string, quantity: number, micros: number) => ({ key, label: key, product: "x", unit: "seconds", quantity, micros, daily: [], byProject: [] });
71+ const report = {
72+ from: "2026-10-01",
73+ until: "2026-10-10",
74+ free: false,
75+ products: [
76+ { key: "agent", label: "Agent", micros: 0, meters: [meter("agent_sandbox", 600, 1_000_000), meter("agent_models", 9, 5_000_000)] },
77+ { key: "sandboxes", label: "Sandboxes", micros: 0, meters: [meter("sandbox", 120, 200_000), meter("self_hosted", 3600, 0)] },
78+ ],
79+ } as unknown as UsageReport;
80+ assert.deepEqual(runnerCost(report), { from: "2026-10-01", until: "2026-10-10", cloudSeconds: 720, cloudMicros: 1_200_000, ownSeconds: 3600, free: false });
81+});
82+
83+test("prices are the sandbox's parts when both are priced, else the one price, per minute", () => {
84+ const price = (meter: string, unit: string, priceMicros: number) => ({ meter, title: meter, unit, priceMicros }) as Price;
85+ assert.deepEqual(
86+ cloudPrices([price("sandbox_second", "second", 25), price("sandbox_base_second", "second", 14.5), price("sandbox_cpu_second", "vCPU-second", 24)])!.map((p) => [p.meter, p.unit, p.perMinuteMicros]),
87+ [
88+ ["sandbox_base_second", "minute", 870],
89+ ["sandbox_cpu_second", "vCPU-minute", 1440],
90+ ],
91+ );
92+ assert.deepEqual(cloudPrices([price("sandbox_second", "second", 25)])!.map((p) => p.perMinuteMicros), [1500]);
93+ assert.equal(cloudPrices([]), null);
94+ assert.equal(cloudPrices(null), null);
95+});
96+
97+test("machine time and prices read as people say them", () => {
98+ assert.equal(machineTime(0), "0 min");
99+ assert.equal(machineTime(45), "45s");
100+ assert.equal(machineTime(720), "12 min");
101+ assert.equal(machineTime(12_240), "3.4 h");
102+ assert.equal(perMinute(1500), "$0.0015");
103+ assert.equal(perMinute(870), "$0.00087");
104+ assert.equal(perMinute(0), "$0");
105+});
+168−0
1+/**
2+ * The Runners page's shaping: what runs on g1t's cloud now, what waits for
3+ * the workspace's own runners, and what each cost this month, from the
4+ * actions, work and billing services' own answers. Pure, so it can be
5+ * tested; the reads are in ./runners.server.ts.
6+ *
7+ * - **On g1t's cloud now** is the workspace's agent runs that are running
8+ * (from the work service) and not handed to one of its own runners
9+ * (`runner_activity`'s `handed_over`), and its workflow jobs running in
10+ * g1t's sandboxes (`cloud_jobs`).
11+ * - **This month** is billing's usage report from the first of the month:
12+ * agent sandbox time and sandbox time are g1t's cloud, at price;
13+ * self-hosted runner time is the workspace's own, at $0.
14+ */
15+import type { AgentRun, MeterLine, Price, RunnerActivity, UsageReport } from "@g1t/contracts";
16+
17+/** One thing running in one of g1t's sandboxes. */
18+export type CloudWork = {
19+ kind: "agent" | "workflow";
20+ id: string;
21+ /** What it is: a pull request's title, a job's name. */
22+ title: string;
23+ /** The run's kind for an agent (`implement`, `checks`…); the job's workflow run for a job. */
24+ detail: string;
25+ /** `owner/name`. */
26+ repo: string;
27+ href: string;
28+ startedAt: string | null;
29+};
30+
31+export type CloudNow = {
32+ running: CloudWork[];
33+ /** Agent runs and workflow jobs waiting to start in g1t's sandboxes. */
34+ queued: number;
35+ /** A list reached its limit: there may be more than shown. */
36+ more: boolean;
37+};
38+
39+/** Waiting for one of the workspace's own runners. */
40+export type OwnWaiting = { tasks: number; jobs: number };
41+
42+/** This month's machine time, from billing's usage report. */
43+export type RunnerCost = {
44+ from: string;
45+ until: string;
46+ /** g1t's cloud: agent sandbox time and sandbox time. */
47+ cloudSeconds: number;
48+ /** At price, before what paid for it. */
49+ cloudMicros: number;
50+ /** The workspace's own runners: self-hosted runner time, always $0. */
51+ ownSeconds: number;
52+ /** g1t charges nothing for now: the figures are usage at cost. */
53+ free: boolean;
54+};
55+
56+/** A price for time on g1t's cloud, per minute, from the price book. */
57+export type CloudPrice = { meter: string; title: string; unit: string; perMinuteMicros: number };
58+
59+/** Billing's meters for machine time (`METERS` in services/billing/src/report.rs). */
60+export const CLOUD_METERS = ["agent_sandbox", "sandbox"] as const;
61+export const OWN_METER = "self_hosted";
62+
63+/** The price book's meters for a sandbox's time: its parts when both are priced, else the one price. */
64+const PRICE_PARTS = ["sandbox_base_second", "sandbox_cpu_second"] as const;
65+const PRICE_WHOLE = "sandbox_second";
66+
67+/** The repository page of an agent run. */
68+export function agentRunHref(repo: string, runId: string): string {
69+ return `/${repo}/agents/runs/${runId}`;
70+}
71+
72+/** The page of a workflow run. */
73+export function workflowRunHref(repo: string, runId: string): string {
74+ return `/${repo}/actions/runs/${runId}`;
75+}
76+
77+const repoName = (run: Pick<AgentRun, "repo">) => `${run.repo.namespace}/${run.repo.name}`;
78+
79+/**
80+ * What runs on g1t's cloud now. `runs` are the workspace's active agent
81+ * runs (null when the work service did not answer); `activity` is the
82+ * actions service's (null when it did not). Null when neither answered.
83+ */
84+export function cloudNow(runs: AgentRun[] | null, activity: RunnerActivity | null, runLimit: number, jobLimit: number): CloudNow | null {
85+ if (!runs && !activity) return null;
86+ const handed = new Set((activity?.handed_over ?? []).map((task) => task.run_id).filter((id): id is string => !!id));
87+ const ours = (runs ?? []).filter((run) => !handed.has(run.id));
88+ const running: CloudWork[] = [
89+ ...ours
90+ .filter((run) => run.status === "running")
91+ .map((run) => ({
92+ kind: "agent" as const,
93+ id: run.id,
94+ title: run.title ?? (run.number != null ? `#${run.number}` : repoName(run)),
95+ detail: run.kind,
96+ repo: repoName(run),
97+ href: agentRunHref(repoName(run), run.id),
98+ startedAt: run.startedAt ?? run.createdAt,
99+ })),
100+ ...(activity?.cloud_jobs ?? []).map((job) => ({
101+ kind: "workflow" as const,
102+ id: job.id,
103+ title: job.name,
104+ detail: job.run_id,
105+ repo: job.repo,
106+ href: workflowRunHref(job.repo, job.run_id),
107+ startedAt: job.started_at,
108+ })),
109+ ].sort((a, b) => (b.startedAt ?? "").localeCompare(a.startedAt ?? ""));
110+ const queued = ours.filter((run) => run.status === "queued").length + (activity?.cloud_jobs_queued ?? 0);
111+ const more = (runs?.length ?? 0) >= runLimit || (activity?.cloud_jobs.length ?? 0) >= jobLimit;
112+ return { running, queued, more };
113+}
114+
115+/** What waits for one of the workspace's own runners: agent work not yet taken, and workflow jobs. */
116+export function ownWaiting(activity: RunnerActivity | null): OwnWaiting | null {
117+ if (!activity) return null;
118+ return { tasks: activity.handed_over.filter((task) => task.status === "queued").length, jobs: activity.self_hosted_jobs_queued };
119+}
120+
121+/** This month's machine time from billing's usage report; null when billing did not answer. */
122+export function runnerCost(report: UsageReport | null): RunnerCost | null {
123+ if (!report) return null;
124+ const meters: MeterLine[] = report.products.flatMap((product) => product.meters);
125+ const sum = (keys: readonly string[], pick: (meter: MeterLine) => number) =>
126+ meters.filter((meter) => keys.includes(meter.key)).reduce((total, meter) => total + pick(meter), 0);
127+ return {
128+ from: report.from,
129+ until: report.until,
130+ cloudSeconds: sum(CLOUD_METERS, (meter) => meter.quantity),
131+ cloudMicros: sum(CLOUD_METERS, (meter) => meter.micros + (meter.pendingMicros ?? 0)),
132+ ownSeconds: sum([OWN_METER], (meter) => meter.quantity),
133+ free: report.free,
134+ };
135+}
136+
137+/** The price of a minute on g1t's cloud, as the price book has it; null when it has none. */
138+export function cloudPrices(prices: Price[] | null): CloudPrice[] | null {
139+ if (!prices) return null;
140+ const perMinute = (price: Price): CloudPrice => ({
141+ meter: price.meter,
142+ title: price.title,
143+ unit: price.unit.replace(/second$/, "minute"),
144+ perMinuteMicros: price.priceMicros * 60,
145+ });
146+ const parts = PRICE_PARTS.map((meter) => prices.find((price) => price.meter === meter));
147+ if (parts.every(Boolean)) return parts.map((price) => perMinute(price!));
148+ const whole = prices.find((price) => price.meter === PRICE_WHOLE);
149+ return whole ? [perMinute(whole)] : null;
150+}
151+
152+/** Seconds as people read machine time: "45s", "12 min", "3.4 h". */
153+export function machineTime(seconds: number): string {
154+ if (seconds <= 0) return "0 min";
155+ if (seconds < 60) return `${Math.round(seconds)}s`;
156+ const minutes = seconds / 60;
157+ if (minutes < 100) return `${Math.round(minutes)} min`;
158+ const hours = minutes / 60;
159+ return `${hours < 10 ? hours.toFixed(1) : Math.round(hours).toLocaleString("en-US")} h`;
160+}
161+
162+/** A price per minute, to a tenth of a cent or finer: "$0.0012". */
163+export function perMinute(micros: number): string {
164+ if (micros <= 0) return "$0";
165+ const dollars = micros / 1_000_000;
166+ const digits = dollars >= 0.01 ? 3 : Math.min(6, Math.max(4, 2 - Math.floor(Math.log10(dollars))));
167+ return `$${dollars.toFixed(digits).replace(/0+$/, "").replace(/\.$/, "")}`;
168+}
+4−2
2525 return (WORKSPACE_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspacePageKey) : null;
2626 }
2727
28−/** The workspace's pages that its Settings row drills into. */
28+/**
29+ * The workspace's pages that its Settings row drills into. Runners is not
30+ * one: it is a page of Workspace mode's own, at `-/runners`.
31+ */
2932 export const SETTINGS_PAGES = [
3033 "settings",
3134 "repositories",
3437 "guardrails",
3538 "secrets",
3639 "actions",
37− "runners",
3840 "integrations",
3941 "webhooks",
4042 "emoji",
+0−4
111111 title: "Secrets and variables",
112112 about: "Shared with every repository, or the ones you link: read by workflows, deployments, or both. A repository's own row of the same key wins.",
113113 },
114− runners: {
115− title: "Self-hosted runners",
116− about: "Your own machines, which run workflow jobs, and if you choose agents' work, for free. They connect out to g1t; nothing reaches in.",
117− },
118114 security: {
119115 title: "Security",
120116 about: "Open alerts in every project: secrets found in pushes and history, and vulnerable dependencies. Each project's Security page has the details and the security update g1t opened for each.",
+12−6
11 import type { Route } from "./+types/runners";
22 import { page } from "../../lib/meta";
3−import { RunnersPanel } from "../../components/runners";
4−import { actOnRunners, loadRunners } from "../../lib/runners.server";
3+import { RunnersPage } from "../../components/runners-page";
4+import { actOnRunners, loadRunnersPage } from "../../lib/runners.server";
55 import { repos } from "../../lib/services.server";
66 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
77
99 return page(args, { title: `Runners · ${params.owner} · g1t` });
1010 }
1111
12+/**
13+ * Runners, in Workspace mode: the machines the workspace's agents and
14+ * workflow jobs run on, g1t's cloud and its own side by side, with what
15+ * each is running, this month's time and how to add one. The workspace's
16+ * machines and their tokens are its owners'.
17+ */
1218 export async function loader({ params, context, request }: Route.LoaderArgs) {
13− // The workspace's own machines and their tokens: owners only.
1419 if (roleIn(getViewer(context), params.owner) !== "owner") throw new Response(null, { status: 404 });
1520 const user = requireUser(context, request);
1621 const workspace = params.owner.toLowerCase();
17− const names = (await repos.list(user, { namespace: workspace })).map((repo) => repo.name);
18− return loadRunners({ workspace }, user, names);
22+ // One round: the repositories (for choosing a group's) beside everything else.
23+ const [list, data] = await Promise.all([repos.list(user, { namespace: workspace }), loadRunnersPage(workspace, user)]);
24+ return { slug: workspace, ...data, repositories: list.map((repo) => repo.name) };
1925 }
2026
2127 export async function action({ request, params, context }: Route.ActionArgs) {
2632 }
2733
2834 export default function WorkspaceRunners({ loaderData, actionData }: Route.ComponentProps) {
29− return <RunnersPanel data={loaderData} action={actionData} scope="workspace" manage />;
35+ return <RunnersPage data={loaderData} action={actionData} slug={loaderData.slug} />;
3036 }
+64−1
7474 pub name: String,
7575 /// `owner/name`.
7676 pub repo: Option<String>,
77− /// The workflow run the job is in, for a link.
77+ /// For a link: the workflow run the job is in, or for `agent` work
78+ /// the agent run it is (`/<owner>/<name>/agents/runs/<id>`), when
79+ /// the sandbox that handed it over said which.
7880 pub run_id: Option<String>,
7981 pub started_at: Option<String>,
8082 }
261263 pub viewer: crate::Viewer,
262264 }
263265
266+/// `runner_activity`: what runs for a workspace now, on g1t's cloud and
267+/// handed to its own runners, for the Runners page. Owners, as the
268+/// workspace's runners are. Snake case both ways. Returns
269+/// `Outcome<RunnerActivity>`.
270+#[derive(Debug, Serialize, Deserialize)]
271+pub struct RunnerActivityArgs {
272+ pub actor: User,
273+ pub workspace: String,
274+}
275+
276+/// A workflow job running in one of g1t's sandboxes.
277+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
278+pub struct CloudJob {
279+ pub id: String,
280+ pub name: String,
281+ /// The workflow run it is in.
282+ pub run_id: String,
283+ /// `owner/name`.
284+ pub repo: String,
285+ pub started_at: Option<String>,
286+}
287+
288+/// Agent work handed to one of the workspace's own runners, waiting for
289+/// one or taken.
290+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
291+pub struct HandedOverTask {
292+ pub id: String,
293+ /// The agent run it is, when the sandbox said; older tasks have none.
294+ pub run_id: Option<String>,
295+ /// `agent`, `checks`, `review`, `queue`…
296+ pub kind: String,
297+ pub title: String,
298+ /// `owner/name`.
299+ pub repo: String,
300+ /// `queued` or `in_progress`.
301+ pub status: String,
302+ pub runner_name: Option<String>,
303+ pub created_at: String,
304+ pub started_at: Option<String>,
305+}
306+
307+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
308+pub struct RunnerActivity {
309+ /// Workflow jobs in g1t's sandboxes now, newest first, at most
310+ /// `ACTIVITY_LIMIT`.
311+ pub cloud_jobs: Vec<CloudJob>,
312+ /// Workflow jobs queued for g1t's sandboxes.
313+ pub cloud_jobs_queued: u32,
314+ /// Agent work on, or waiting for, the workspace's own runners.
315+ pub handed_over: Vec<HandedOverTask>,
316+ /// Workflow jobs waiting for one of the workspace's own runners.
317+ pub self_hosted_jobs_queued: u32,
318+}
319+
320+/// The most jobs and tasks `runner_activity` lists of each.
321+pub const ACTIVITY_LIMIT: u32 = 100;
322+
264323 /// A job that has waited ten minutes or more for a self-hosted runner,
265324 /// with none that matches online.
266325 #[derive(Clone, Debug, Serialize, Deserialize)]
411470 pub labels: Vec<String>,
412471 pub env: Map<String, Value>,
413472 pub timeout_minutes: u32,
473+ /// The agent run the work belongs to, when the sandbox opened one: what
474+ /// people's pages link a runner's agent work to.
475+ #[serde(default, alias = "run_id")]
476+ pub run_id: Option<String>,
414477 }
415478
416479 /// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a
+1−0
834834 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
835835 return {
836836 stuck: (viewer) => call("stuck_jobs", { viewer }),
837+ activity: (actor, workspace) => call("runner_activity", { actor, workspace }),
837838 list: (actor, owner) => call("runners", { actor, ...owner }),
838839 createToken: (actor, owner, group) => call("create_registration_token", { actor, ...owner, group }),
839840 remove: (actor, owner, id) => call("remove_runner", { actor, ...owner, id }),
+32−0
1616 id: string;
1717 name: string;
1818 repo: string | null;
19+ /** The workflow run the job is in; for `agent` work, the agent run it is, when known. */
1920 runId: string | null;
2021 startedAt: string | null;
2122 };
7677 inherit?: boolean;
7778 };
7879
80+/** A workflow job running in one of g1t's sandboxes. Snake case, as `runner_activity` answers. */
81+export type CloudJob = { id: string; name: string; run_id: string; repo: string; started_at: string | null };
82+
83+/** Agent work handed to one of the workspace's own runners, waiting for one or taken. */
84+export type HandedOverTask = {
85+ id: string;
86+ /** The agent run it is; null for work handed over before runs were noted. */
87+ run_id: string | null;
88+ kind: string;
89+ title: string;
90+ repo: string;
91+ status: "queued" | "in_progress";
92+ runner_name: string | null;
93+ created_at: string;
94+ started_at: string | null;
95+};
96+
97+/** What runs for a workspace now, in g1t's sandboxes and on its own runners (`runner_activity`). */
98+export type RunnerActivity = {
99+ /** At most `RUNNER_ACTIVITY_LIMIT`, newest first. */
100+ cloud_jobs: CloudJob[];
101+ cloud_jobs_queued: number;
102+ handed_over: HandedOverTask[];
103+ self_hosted_jobs_queued: number;
104+};
105+
106+/** The most jobs and tasks `runner_activity` lists of each: `ACTIVITY_LIMIT` in the actions service. */
107+export const RUNNER_ACTIVITY_LIMIT = 100;
108+
79109 /** A job that has waited ten minutes or more with no matching runner online. */
80110 export type StuckJob = { id: string; name: string; runId: string; repo: string; labels: string; queuedAt: string };
81111
82112 export interface RunnersApi {
83113 /** The viewer's workspaces' jobs stuck waiting for a self-hosted runner, for Mission control. */
84114 stuck(viewer: User): Promise<StuckJob[]>;
115+ /** What runs for the workspace now, in g1t's sandboxes and handed to its own runners. Owners. */
116+ activity(actor: User, workspace: string): Promise<Result<RunnerActivity>>;
85117 list(actor: User, owner: RunnersOwner): Promise<Result<Runner[]>>;
86118 createToken(actor: User, owner: RunnersOwner, group?: string): Promise<Result<RegistrationToken>>;
87119 remove(actor: User, owner: RunnersOwner, id: string): Promise<Result<boolean>>;
+8−0
1+-- The agent run a task handed to a self-hosted runner belongs to, so the
2+-- Runners page can link a runner's agent work to it and leave it out of
3+-- what runs on g1t's cloud. Null for tasks handed over before this.
4+ALTER TABLE runner_tasks ADD COLUMN run_id TEXT;
5+
6+-- What runs in g1t's sandboxes for a workspace now, for the Runners page:
7+-- jobs without labels, by the lowercased workspace and status.
8+CREATE INDEX IF NOT EXISTS jobs_cloud_lower ON jobs (lower(namespace), status) WHERE labels IS NULL;
+1−0
295295 // Agent work, from the runner service.
296296 "runner_route" => reply(&service.runner_route(args(body)?).await?),
297297 "stuck_jobs" => reply(&service.stuck_jobs(args(body)?).await?),
298+ "runner_activity" => reply(&service.runner_activity(args(body)?).await?),
298299 "enqueue_task" => reply(&service.enqueue_task(args(body)?).await?),
299300 "cancel_task" => reply(&service.cancel_task(args(body)?).await?),
300301 _ => Response::error("Unknown method", 404),
+53−4
1515 use g1t_contracts::billing::{ComputeKind, RecordSandboxArgs};
1616 use g1t_contracts::repos::{Repo, RepoPath};
1717 use g1t_contracts::runners::{
18− self as model, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
18+ self as model, ACTIVITY_LIMIT, CloudJob, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs,
1919 ListRunnersArgs, Poll, PollArgs, RegisterArgs, Registered, RegistrationToken, RemoveRunnerArgs, RemoveSelfArgs, RouteArgs, Runner,
2020 RunnerAuth, RunnerGroup, RunnerGroupsArgs, RunnerSettings, RunnerSettingsArgs, RunnerWork, RunnersOwner, SetRunnerGroupArgs,
2121 SetRunnerSettingsArgs, Wanted,
136136 pub started_at: Option<String>,
137137 #[serde(default)]
138138 pub seen_at: Option<String>,
139+ /// The agent run it belongs to (migration 0012); none for older tasks.
140+ #[serde(default)]
141+ pub run_id: Option<String>,
139142 }
140143
141144 /// Who may do what with a set of runners: the workspace, and the
313316 id: t.id,
314317 name: t.title,
315318 repo: Some(t.repo),
316− run_id: None,
319+ run_id: t.run_id,
317320 started_at: t.started_at,
318321 }));
319322 }
13271330 let inserted = self
13281331 .db
13291332 .prepare(
1330− "INSERT OR IGNORE INTO runner_tasks (id, sandbox, workspace, repo_id, repo, kind, title, labels, env, timeout_minutes, status, created_at)
1331− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?) RETURNING id",
1333+ "INSERT OR IGNORE INTO runner_tasks (id, sandbox, workspace, repo_id, repo, kind, title, labels, env, timeout_minutes, status, created_at, run_id)
1334+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?) RETURNING id",
13321335 )
13331336 .bind(&[
13341337 id.as_str().into(),
13421345 sealed.into(),
13431346 a.timeout_minutes.max(1).into(),
13441347 at.into(),
1348+ optional(a.run_id.as_deref().filter(|id| !id.is_empty())),
13451349 ])?
13461350 .first::<Value>(None)
13471351 .await?;
14041408 Ok(())
14051409 }
14061410
1411+ /// `runner_activity`: what runs for the workspace now, in g1t's
1412+ /// sandboxes and handed to its own runners. Its owners only, as its
1413+ /// runners are.
1414+ pub async fn runner_activity(&self, a: RunnerActivityArgs) -> Result<Outcome<RunnerActivity>> {
1415+ let owner = RunnersOwner { repo: None, workspace: Some(a.workspace.clone()) };
1416+ let place = check!(self.runner_place(&a.actor, &owner, false).await?);
1417+ let workspace = place.workspace;
1418+ let cloud_jobs = self
1419+ .db
1420+ .prepare(
1421+ "SELECT jobs.id, jobs.name, jobs.run_id, runs.repo, jobs.started_at FROM jobs JOIN runs ON runs.id = jobs.run_id
1422+ WHERE lower(jobs.namespace) = ? AND jobs.status = 'in_progress' AND jobs.labels IS NULL
1423+ ORDER BY jobs.started_at DESC LIMIT ?",
1424+ )
1425+ .bind(&[workspace.as_str().into(), ACTIVITY_LIMIT.into()])?
1426+ .all()
1427+ .await?
1428+ .results::<CloudJob>()?;
1429+ let cloud_jobs_queued = self.queued_jobs(&workspace, false).await?;
1430+ let self_hosted_jobs_queued = self.queued_jobs(&workspace, true).await?;
1431+ let handed_over = self
1432+ .db
1433+ .prepare(
1434+ "SELECT id, run_id, kind, title, repo, status, runner_name, created_at, started_at FROM runner_tasks
1435+ WHERE status IN ('queued', 'in_progress') AND workspace = ?
1436+ ORDER BY created_at DESC LIMIT ?",
1437+ )
1438+ .bind(&[workspace.as_str().into(), ACTIVITY_LIMIT.into()])?
1439+ .all()
1440+ .await?
1441+ .results::<HandedOverTask>()?;
1442+ Ok(Outcome::Ok(RunnerActivity { cloud_jobs, cloud_jobs_queued, handed_over, self_hosted_jobs_queued }))
1443+ }
1444+
1445+ /// The workspace's workflow jobs queued for its own runners
1446+ /// (`labelled`), or for g1t's sandboxes.
1447+ async fn queued_jobs(&self, workspace: &str, labelled: bool) -> Result<u32> {
1448+ let sql = if labelled {
1449+ "SELECT COUNT(*) AS n FROM jobs WHERE lower(namespace) = ? AND status = 'queued' AND labels IS NOT NULL"
1450+ } else {
1451+ "SELECT COUNT(*) AS n FROM jobs WHERE lower(namespace) = ? AND status = 'queued' AND labels IS NULL"
1452+ };
1453+ Ok(self.db.prepare(sql).bind(&[workspace.into()])?.first::<Count>(None).await?.map_or(0, |c| c.n))
1454+ }
1455+
14071456 /// `stuck_jobs`: jobs of the viewer's workspaces that have waited ten
14081457 /// minutes or more for a self-hosted runner while none that could take
14091458 /// them is online, for Mission control's Needs you.
+1−0
471471 labels: selfHosted,
472472 env: taskEnv({ ...vars, ...harness }),
473473 timeoutMinutes: minutes,
474+ runId: tracked?.runId ?? null,
474475 });
475476 await this.ctx.storage.put("remote", true);
476477 if (tracked) await reportRun(this.env.WORK, tracked, { steps: [handedOverStep(selfHosted)] });
+3−0
5555 labels: ["self-hosted"],
5656 env: { MODE: "checks" },
5757 timeoutMinutes: 45,
58+ runId: "run_9",
5859 });
5960 assert.equal(id, "rtk_1");
6061 assert.equal((calls[0]!.body as { workspace: string }).workspace, "acme");
62+ // The agent run it is goes as run_id, for the Runners page's links.
63+ assert.equal((calls[0]!.body as { run_id: string }).run_id, "run_9");
6164 await cancelTask(service, "do_1", "Stopped.");
6265 assert.deepEqual(calls[1], { method: "cancel_task", body: { sandbox: "do_1", reason: "Stopped." } });
6366 await assert.rejects(
+4−1
5353 labels: string[];
5454 env: Record<string, string>;
5555 timeoutMinutes: number;
56+ /** The agent run the work is, when the sandbox opened one: the Runners page links to it. */
57+ runId?: string | null;
5658 },
5759 ): Promise<string> {
58− const queued = await call<Outcome<string>>(actions, "enqueue_task", { ...task, workspace: task.repo.namespace.toLowerCase() });
60+ const { runId, ...rest } = task;
61+ const queued = await call<Outcome<string>>(actions, "enqueue_task", { ...rest, run_id: runId ?? null, workspace: task.repo.namespace.toLowerCase() });
5962 if (!queued.ok) throw new Error(queued.error.message);
6063 return queued.value;
6164 }