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.
20 files+1003−510/20 viewed
| 18 | 18 | ||
| 19 | 19 | | Where | Page | Who manages it | | |
| 20 | 20 | | --- | --- | --- | | |
| 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. | | |
| 22 | 22 | | A project | **Settings → Runners**, `g1t.sh/<workspace>/<project>/settings/runners` | People with the Admin [role](/guides/access-and-roles/) on its repository | | |
| 23 | 23 | ||
| 24 | 24 | A workspace's runners serve the repositories their [group](#groups) allows. | |
| 25 | 25 | A project's own runners serve only that project. | |
| 26 | 26 | ||
| 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 | + | ||
| 27 | 49 | ## Add a runner | |
| 28 | 50 | ||
| 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 | |
| 30 | 52 | registration token and shows the commands for Linux, macOS, Windows and | |
| 31 | 53 | Docker with it filled in. The token lasts an hour, can register any | |
| 32 | 54 | number of runners until then, and is shown once. | |
| ⋯ | |||
| 159 | 181 | them. Every workspace has a **Default** group, for every repository, which | |
| 160 | 182 | runners join unless their token or `--group` names another. | |
| 161 | 183 | ||
| 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** | |
| 164 | 186 | repositories. Then click **New runner** with that group chosen, or register | |
| 165 | 187 | with `--group`. Deleting a group moves its runners to the default group. | |
| 166 | 188 | ||
| 167 | 189 | ## Agents on your runners | |
| 168 | 190 | ||
| 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 | |
| 170 | 192 | runners too: agent runs, checks, reviews, merge checks and the merge queue. | |
| 171 | 193 | Switch on **Run g1t's work on self-hosted runners** and give the labels | |
| 172 | 194 | a runner needs to take it (`self-hosted` is always one). | |
| ⋯ | |||
| 203 | 225 | ## Security | |
| 204 | 226 | ||
| 205 | 227 | - **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 | |
| 207 | 229 | public repository: anyone who can open a pull request could run any code | |
| 208 | 230 | on the machine, read what it can reach, and leave something behind for | |
| 209 | 231 | the next job. Such jobs get no secrets either way. | |
| ⋯ | |||
| 299 | 321 | ||
| 300 | 322 | On the machine, `g1t-runner remove` unregisters it and forgets its | |
| 301 | 323 | 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 | |
| 303 | 325 | running fails. Runners offline for 14 days are removed by themselves. | |
| 304 | 326 | ||
| 305 | 327 | ## API and MCP | |
| ⋯ | |||
| 319 | 341 | ||
| 320 | 342 | | What you see | What to do | | |
| 321 | 343 | | --- | --- | | |
| 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. | | |
| 323 | 345 | | *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. | | |
| 324 | 346 | | `register` says the token is not valid | It expired after an hour, or was mistyped. Make a new one. | | |
| 325 | 347 | | `register` says a runner with that name is registered | Choose another `--name`, or add `--replace`. | | |
| 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'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's cloud can'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'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'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'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'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't answer, so this month's time can'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 | + | } |
| 4 | 4 | ||
| 5 | 5 | import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { agentRunHref, workflowRunHref } from "../lib/runners"; | |
| 7 | 8 | import type { RunnersAction, RunnersData } from "../lib/runners.server"; | |
| 8 | 9 | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui"; | |
| 9 | 10 | import { SelectField } from "./ui/select"; | |
| 10 | 11 | import { Switch } from "./ui/switch"; | |
| 11 | 12 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 12 | 13 | ||
| 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" }, | |
| 17 | 19 | }; | |
| 18 | 20 | ||
| 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" }; | |
| 20 | 28 | ||
| 21 | 29 | function Section({ icon, title, about, children }: { icon: React.ReactNode; title: string; about: React.ReactNode; children: React.ReactNode }) { | |
| 22 | 30 | return ( | |
| ⋯ | |||
| 33 | 41 | ); | |
| 34 | 42 | } | |
| 35 | 43 | ||
| 36 | − | function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) { | |
| 44 | + | export function RunnerRow({ runner, manage }: { runner: Runner; manage: boolean }) { | |
| 37 | 45 | const status = STATUS[runner.status]; | |
| 46 | + | const href = runner.work ? workHref(runner.work) : null; | |
| 38 | 47 | return ( | |
| 39 | 48 | <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3"> | |
| 40 | 49 | <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden /> | |
| 41 | 50 | <div className="min-w-0 grow basis-60"> | |
| 42 | 51 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 43 | 52 | <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> | |
| 45 | 54 | {runner.ephemeral && <Pill>ephemeral</Pill>} | |
| 46 | 55 | {runner.group && <span className="text-xs text-faint">in {runner.group}</span>} | |
| 47 | 56 | </div> | |
| ⋯ | |||
| 55 | 64 | {runner.work && ( | |
| 56 | 65 | <p className="mt-1.5 truncate text-xs text-muted"> | |
| 57 | 66 | {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"> | |
| 60 | 69 | {runner.work.name} | |
| 61 | 70 | </Link> | |
| 62 | 71 | ) : ( | |
| ⋯ | |||
| 66 | 75 | </p> | |
| 67 | 76 | )} | |
| 68 | 77 | </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"> | |
| 70 | 79 | <div> | |
| 71 | 80 | {OS_NAMES[runner.os]} {runner.arch} | |
| 72 | 81 | {runner.version && <> · {runner.version}</>} | |
| ⋯ | |||
| 143 | 152 | } | |
| 144 | 153 | } | |
| 145 | 154 | ||
| 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 }) { | |
| 147 | 156 | const [platform, setPlatform] = useState<Platform>("linux"); | |
| 148 | 157 | const [arch, setArch] = useState<"x64" | "arm64">("x64"); | |
| 149 | 158 | return ( | |
| ⋯ | |||
| 237 | 246 | ); | |
| 238 | 247 | } | |
| 239 | 248 | ||
| 240 | − | function Groups({ | |
| 249 | + | export function Groups({ | |
| 241 | 250 | groups, | |
| 242 | 251 | repositories, | |
| 243 | 252 | manage, | |
| ⋯ | |||
| 333 | 342 | ); | |
| 334 | 343 | } | |
| 335 | 344 | ||
| 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" }) { | |
| 337 | 346 | const settings = data.settings; | |
| 338 | 347 | const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false); | |
| 339 | 348 | const [forks, setForks] = useState(settings?.forkPullRequests ?? false); | |
| ⋯ | |||
| 407 | 416 | manage: boolean; | |
| 408 | 417 | }) { | |
| 409 | 418 | const online = data.runners.filter((runner) => runner.status !== "offline").length; | |
| 410 | − | const base = (repo: string) => `/${repo}`; | |
| 411 | 419 | return ( | |
| 412 | 420 | <div className="max-w-5xl space-y-8"> | |
| 413 | 421 | <div className="min-h-6"> | |
| ⋯ | |||
| 435 | 443 | ) : ( | |
| 436 | 444 | <ul className="divide-y divide-line rounded-xl border border-line bg-surface"> | |
| 437 | 445 | {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)} /> | |
| 439 | 447 | ))} | |
| 440 | 448 | </ul> | |
| 441 | 449 | )} | |
| ⋯ | |||
| 464 | 472 | about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."} | |
| 465 | 473 | > | |
| 466 | 474 | {/* Keyed to what is saved, so following the workspace again shows its settings, not the switches as they were. */} | |
| 467 | − | <Settings | |
| 475 | + | <RunnerSettingsForm | |
| 468 | 476 | key={data.settings ? `${data.settings.inherited}:${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"} | |
| 469 | 477 | data={data} | |
| 470 | 478 | manage={manage} | |
| 459 | 459 | ||
| 460 | 460 | /** | |
| 461 | 461 | * 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, | |
| 463 | 463 | * webhooks), what it pays, its repositories, tokens and record. The main | |
| 464 | 464 | * list keeps the places work happens and who belongs; every member can | |
| 465 | 465 | * still open these. | |
| ⋯ | |||
| 1136 | 1136 | } | |
| 1137 | 1137 | ||
| 1138 | 1138 | /** 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"]; | |
| 1140 | 1140 | ||
| 1141 | 1141 | /** | |
| 1142 | 1142 | * 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 | |
| 1144 | 1144 | * settings as a list of their own. Owner-only pages stay owner-only. | |
| 1145 | 1145 | */ | |
| 1146 | 1146 | export function WorkspaceSidebar({ slug, owner }: { slug: string; owner: boolean }) { | |
| ⋯ | |||
| 1166 | 1166 | Billing and plans | |
| 1167 | 1167 | </SidebarLink> | |
| 1168 | 1168 | </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 | + | )} | |
| 1169 | 1176 | <SidebarGroup title="Connections" className="mt-3"> | |
| 1170 | 1177 | <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />} current={at("integrations")}> | |
| 1171 | 1178 | Integrations | |
| ⋯ | |||
| 1230 | 1237 | <SidebarLink to={`/${slug}/-/actions`} icon={<PlayCircle size={15} />}> | |
| 1231 | 1238 | Actions | |
| 1232 | 1239 | </SidebarLink> | |
| 1233 | − | {owner && ( | |
| 1234 | − | <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}> | |
| 1235 | − | Runners | |
| 1236 | − | </SidebarLink> | |
| 1237 | − | )} | |
| 1238 | 1240 | </SidebarGroup> | |
| 1239 | 1241 | </nav> | |
| 1240 | 1242 | ); | |
| 114 | 114 | "limit_requests links log logs managed_pulls memories_by_id memory_context my_repo_invitations " + | |
| 115 | 115 | "outside_collaborators overview path_by_id prices profile profile_workspaces public_namespaces read_session " + | |
| 116 | 116 | "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 " + | |
| 118 | 118 | "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " + | |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + |
| 7 | 7 | type RunnerSettings, | |
| 8 | 8 | type RunnersOwner, | |
| 9 | 9 | type User, | |
| 10 | + | RUNNER_ACTIVITY_LIMIT, | |
| 10 | 11 | runnersClient, | |
| 11 | 12 | } from "@g1t/contracts"; | |
| 12 | 13 | ||
| 13 | 14 | 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"; | |
| 14 | 17 | ||
| 15 | 18 | /** Self-hosted runners, kept by the actions service. */ | |
| 16 | 19 | export const runners = runnersClient(instrumented("actions", env.ACTIONS)); | |
| ⋯ | |||
| 40 | 43 | }; | |
| 41 | 44 | } | |
| 42 | 45 | ||
| 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 | + | ||
| 43 | 104 | export type RunnersAction = { | |
| 44 | 105 | error?: string; | |
| 45 | 106 | notice?: string; | |
| 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 | + | }); |
| 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 | + | } |
| 25 | 25 | return (WORKSPACE_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspacePageKey) : null; | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 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 | + | */ | |
| 29 | 32 | export const SETTINGS_PAGES = [ | |
| 30 | 33 | "settings", | |
| 31 | 34 | "repositories", | |
| ⋯ | |||
| 34 | 37 | "guardrails", | |
| 35 | 38 | "secrets", | |
| 36 | 39 | "actions", | |
| 37 | − | "runners", | |
| 38 | 40 | "integrations", | |
| 39 | 41 | "webhooks", | |
| 40 | 42 | "emoji", | |
| 111 | 111 | title: "Secrets and variables", | |
| 112 | 112 | 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.", | |
| 113 | 113 | }, | |
| 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 | − | }, | |
| 118 | 114 | security: { | |
| 119 | 115 | title: "Security", | |
| 120 | 116 | 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.", |
| 1 | 1 | import type { Route } from "./+types/runners"; | |
| 2 | 2 | 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"; | |
| 5 | 5 | import { repos } from "../../lib/services.server"; | |
| 6 | 6 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 7 | 7 | ||
| ⋯ | |||
| 9 | 9 | return page(args, { title: `Runners · ${params.owner} · g1t` }); | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 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 | + | */ | |
| 12 | 18 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 13 | − | // The workspace's own machines and their tokens: owners only. | |
| 14 | 19 | if (roleIn(getViewer(context), params.owner) !== "owner") throw new Response(null, { status: 404 }); | |
| 15 | 20 | const user = requireUser(context, request); | |
| 16 | 21 | 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) }; | |
| 19 | 25 | } | |
| 20 | 26 | ||
| 21 | 27 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| ⋯ | |||
| 26 | 32 | } | |
| 27 | 33 | ||
| 28 | 34 | 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} />; | |
| 30 | 36 | } | |
| 74 | 74 | pub name: String, | |
| 75 | 75 | /// `owner/name`. | |
| 76 | 76 | 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. | |
| 78 | 80 | pub run_id: Option<String>, | |
| 79 | 81 | pub started_at: Option<String>, | |
| 80 | 82 | } | |
| ⋯ | |||
| 261 | 263 | pub viewer: crate::Viewer, | |
| 262 | 264 | } | |
| 263 | 265 | ||
| 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 | + | ||
| 264 | 323 | /// A job that has waited ten minutes or more for a self-hosted runner, | |
| 265 | 324 | /// with none that matches online. | |
| 266 | 325 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| ⋯ | |||
| 411 | 470 | pub labels: Vec<String>, | |
| 412 | 471 | pub env: Map<String, Value>, | |
| 413 | 472 | 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>, | |
| 414 | 477 | } | |
| 415 | 478 | ||
| 416 | 479 | /// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a | |
| 834 | 834 | const call = <T>(method: string, args: object) => rpc<T>(service, method, args); | |
| 835 | 835 | return { | |
| 836 | 836 | stuck: (viewer) => call("stuck_jobs", { viewer }), | |
| 837 | + | activity: (actor, workspace) => call("runner_activity", { actor, workspace }), | |
| 837 | 838 | list: (actor, owner) => call("runners", { actor, ...owner }), | |
| 838 | 839 | createToken: (actor, owner, group) => call("create_registration_token", { actor, ...owner, group }), | |
| 839 | 840 | remove: (actor, owner, id) => call("remove_runner", { actor, ...owner, id }), |
| 16 | 16 | id: string; | |
| 17 | 17 | name: string; | |
| 18 | 18 | repo: string | null; | |
| 19 | + | /** The workflow run the job is in; for `agent` work, the agent run it is, when known. */ | |
| 19 | 20 | runId: string | null; | |
| 20 | 21 | startedAt: string | null; | |
| 21 | 22 | }; | |
| ⋯ | |||
| 76 | 77 | inherit?: boolean; | |
| 77 | 78 | }; | |
| 78 | 79 | ||
| 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 | + | ||
| 79 | 109 | /** A job that has waited ten minutes or more with no matching runner online. */ | |
| 80 | 110 | export type StuckJob = { id: string; name: string; runId: string; repo: string; labels: string; queuedAt: string }; | |
| 81 | 111 | ||
| 82 | 112 | export interface RunnersApi { | |
| 83 | 113 | /** The viewer's workspaces' jobs stuck waiting for a self-hosted runner, for Mission control. */ | |
| 84 | 114 | 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>>; | |
| 85 | 117 | list(actor: User, owner: RunnersOwner): Promise<Result<Runner[]>>; | |
| 86 | 118 | createToken(actor: User, owner: RunnersOwner, group?: string): Promise<Result<RegistrationToken>>; | |
| 87 | 119 | remove(actor: User, owner: RunnersOwner, id: string): Promise<Result<boolean>>; | |
| 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; |
| 295 | 295 | // Agent work, from the runner service. | |
| 296 | 296 | "runner_route" => reply(&service.runner_route(args(body)?).await?), | |
| 297 | 297 | "stuck_jobs" => reply(&service.stuck_jobs(args(body)?).await?), | |
| 298 | + | "runner_activity" => reply(&service.runner_activity(args(body)?).await?), | |
| 298 | 299 | "enqueue_task" => reply(&service.enqueue_task(args(body)?).await?), | |
| 299 | 300 | "cancel_task" => reply(&service.cancel_task(args(body)?).await?), | |
| 300 | 301 | _ => Response::error("Unknown method", 404), |
| 15 | 15 | use g1t_contracts::billing::{ComputeKind, RecordSandboxArgs}; | |
| 16 | 16 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 17 | 17 | 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, | |
| 19 | 19 | ListRunnersArgs, Poll, PollArgs, RegisterArgs, Registered, RegistrationToken, RemoveRunnerArgs, RemoveSelfArgs, RouteArgs, Runner, | |
| 20 | 20 | RunnerAuth, RunnerGroup, RunnerGroupsArgs, RunnerSettings, RunnerSettingsArgs, RunnerWork, RunnersOwner, SetRunnerGroupArgs, | |
| 21 | 21 | SetRunnerSettingsArgs, Wanted, | |
| ⋯ | |||
| 136 | 136 | pub started_at: Option<String>, | |
| 137 | 137 | #[serde(default)] | |
| 138 | 138 | 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>, | |
| 139 | 142 | } | |
| 140 | 143 | ||
| 141 | 144 | /// Who may do what with a set of runners: the workspace, and the | |
| ⋯ | |||
| 313 | 316 | id: t.id, | |
| 314 | 317 | name: t.title, | |
| 315 | 318 | repo: Some(t.repo), | |
| 316 | − | run_id: None, | |
| 319 | + | run_id: t.run_id, | |
| 317 | 320 | started_at: t.started_at, | |
| 318 | 321 | })); | |
| 319 | 322 | } | |
| ⋯ | |||
| 1327 | 1330 | let inserted = self | |
| 1328 | 1331 | .db | |
| 1329 | 1332 | .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", | |
| 1332 | 1335 | ) | |
| 1333 | 1336 | .bind(&[ | |
| 1334 | 1337 | id.as_str().into(), | |
| ⋯ | |||
| 1342 | 1345 | sealed.into(), | |
| 1343 | 1346 | a.timeout_minutes.max(1).into(), | |
| 1344 | 1347 | at.into(), | |
| 1348 | + | optional(a.run_id.as_deref().filter(|id| !id.is_empty())), | |
| 1345 | 1349 | ])? | |
| 1346 | 1350 | .first::<Value>(None) | |
| 1347 | 1351 | .await?; | |
| ⋯ | |||
| 1404 | 1408 | Ok(()) | |
| 1405 | 1409 | } | |
| 1406 | 1410 | ||
| 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 | + | ||
| 1407 | 1456 | /// `stuck_jobs`: jobs of the viewer's workspaces that have waited ten | |
| 1408 | 1457 | /// minutes or more for a self-hosted runner while none that could take | |
| 1409 | 1458 | /// them is online, for Mission control's Needs you. | |
| 471 | 471 | labels: selfHosted, | |
| 472 | 472 | env: taskEnv({ ...vars, ...harness }), | |
| 473 | 473 | timeoutMinutes: minutes, | |
| 474 | + | runId: tracked?.runId ?? null, | |
| 474 | 475 | }); | |
| 475 | 476 | await this.ctx.storage.put("remote", true); | |
| 476 | 477 | if (tracked) await reportRun(this.env.WORK, tracked, { steps: [handedOverStep(selfHosted)] }); |
| 55 | 55 | labels: ["self-hosted"], | |
| 56 | 56 | env: { MODE: "checks" }, | |
| 57 | 57 | timeoutMinutes: 45, | |
| 58 | + | runId: "run_9", | |
| 58 | 59 | }); | |
| 59 | 60 | assert.equal(id, "rtk_1"); | |
| 60 | 61 | 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"); | |
| 61 | 64 | await cancelTask(service, "do_1", "Stopped."); | |
| 62 | 65 | assert.deepEqual(calls[1], { method: "cancel_task", body: { sandbox: "do_1", reason: "Stopped." } }); | |
| 63 | 66 | await assert.rejects( |
| 53 | 53 | labels: string[]; | |
| 54 | 54 | env: Record<string, string>; | |
| 55 | 55 | timeoutMinutes: number; | |
| 56 | + | /** The agent run the work is, when the sandbox opened one: the Runners page links to it. */ | |
| 57 | + | runId?: string | null; | |
| 56 | 58 | }, | |
| 57 | 59 | ): 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() }); | |
| 59 | 62 | if (!queued.ok) throw new Error(queued.error.message); | |
| 60 | 63 | return queued.value; | |
| 61 | 64 | } |