Skip to content
748 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1//! Self-hosted runners: a workspace's or a repository's own machines, which
2//! run its workflow jobs (and, when it says so, its agents' work) instead
3//! of g1t's sandboxes. Time on them costs nothing.
4//!
5//! The actions service keeps them, beside the jobs they run. A machine runs
6//! `g1t-runner` (`crates/runner`), which registers once with a short-lived
7//! registration token an admin made, gets a credential of its own, and then
8//! only ever calls out: it polls `api.g1t.sh` for work, runs what it is
9//! given with the same harness g1t's sandboxes use, and reports back. No
10//! port is opened on the machine.
11//!
12//! - A **registration token** (`g1trt_…`) is made by an owner of the
13//! workspace (or an admin of the repository) and lasts an hour. It can
14//! register any number of runners until then, and nothing else.
15//! - A **runner credential** (`g1tr_…`) belongs to one runner. It is stored
16//! only as a hash, rotates every day (the poll that rotates it hands the
17//! new one over), and is revoked when the runner is removed. It can poll
18//! for work, report on what that runner was given, and remove the runner;
19//! nothing else. Any other endpoint refuses it.
20//! - A job is given to a runner when every label in its `runs-on` is one
21//! of the runner's labels (ignoring case), and the runner's group lets
22//! the job's repository use it. A job whose `runs-on` names
23//! `self-hosted` (or a `group`) only ever runs on self-hosted runners; it
24//! waits, saying for which labels, until one picks it up, for a day at
25//! most.
26//!
27//! The management methods take an `actor` and an owner (`repo` or
28//! `workspace`), as secrets do. The runner's own methods take its id and
29//! credential. Mirrors `packages/contracts/src/runners.ts`.
30
31use serde::{Deserialize, Serialize};
32use serde_json::{Map, Value};
33
34use crate::User;
35use crate::repos::RepoPath;
36
37/// Every runner has these labels, whatever else it was given.
38pub const SELF_HOSTED: &str = "self-hosted";
39/// What a registration token starts with.
40pub const REGISTRATION_PREFIX: &str = "g1trt_";
41/// What a runner's credential starts with.
42pub const CREDENTIAL_PREFIX: &str = "g1tr_";
43/// How long a registration token lasts, in seconds.
44pub const REGISTRATION_TTL_SECONDS: u64 = 60 * 60;
45/// How long a job may wait for a self-hosted runner before it fails.
46pub const MAX_WAIT_HOURS: u64 = 24;
47/// A runner that has not polled for this long is offline.
48pub const ONLINE_WITHIN_MS: u64 = 90 * 1000;
49/// How long one poll waits for work before answering with none.
50pub const MAX_POLL_WAIT_MS: u64 = 20 * 1000;
51/// How often a runner's credential is replaced.
52pub const ROTATE_AFTER_MS: u64 = 24 * 60 * 60 * 1000;
53/// The most labels a runner has, and the longest one.
54pub const MAX_LABELS: usize = 30;
55pub const MAX_LABEL_LEN: usize = 64;
56/// The most runners a workspace has.
57pub const MAX_RUNNERS: u32 = 500;
58/// The most runners a workspace registers in one minute.
59pub const MAX_REGISTRATIONS_PER_MINUTE: u32 = 30;
60/// The most registration tokens a workspace makes in one hour.
61pub const MAX_TOKENS_PER_HOUR: u32 = 60;
62
63// --- What people see -------------------------------------------------------
64
65/// What a runner is doing now.
66#[derive(Clone, Debug, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct RunnerWork {
69 /// `workflow` (a workflow job) or `agent` (an agent's run, checks, a
70 /// review, the merge queue).
71 pub kind: String,
72 pub id: String,
73 /// The job's name, or what the agent is doing.
74 pub name: String,
75 /// `owner/name`.
76 pub repo: Option<String>,
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.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.
Fast pages, required checks on the branch, self-hosted runners, honest incidents80 pub run_id: Option<String>,
81 pub started_at: Option<String>,
82}
83
84#[derive(Clone, Debug, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct Runner {
87 pub id: String,
88 pub name: String,
89 pub workspace: String,
90 /// `owner/name` for a repository's own runner; null for the workspace's.
91 pub repo: Option<String>,
92 /// Its group's name; null for a repository's runner.
93 pub group: Option<String>,
94 /// Every label, `self-hosted`, its OS and architecture among them.
95 pub labels: Vec<String>,
96 /// `linux`, `macos` or `windows`.
97 pub os: String,
98 /// `x64` or `arm64`.
99 pub arch: String,
100 /// The `g1t-runner` version it last reported.
101 pub version: String,
102 /// Runs one job, then removes itself.
103 pub ephemeral: bool,
104 /// `online`, `busy` or `offline`.
105 pub status: String,
106 pub work: Option<RunnerWork>,
107 pub last_seen_at: Option<String>,
108 pub created_at: String,
109 pub created_by: Option<String>,
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.110 /// How it keeps work from its machine, as it last said
111 /// ([`ISOLATIONS`]); null for a runner from before runners said.
112 #[serde(default)]
113 pub isolation: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents114}
115
116/// Which of a workspace's repositories may use its runners.
117#[derive(Clone, Debug, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct RunnerGroup {
120 pub id: String,
121 pub name: String,
122 /// The group runners join when none is named: every repository.
123 pub default: bool,
124 /// Repository names (without the workspace) that may use it; empty is
125 /// every repository in the workspace.
126 pub repositories: Vec<String>,
127 pub runners: u32,
128 pub updated_at: String,
129}
130
131/// A registration token, shown once.
132#[derive(Clone, Debug, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct RegistrationToken {
135 pub token: String,
136 pub expires_at: String,
137 pub workspace: String,
138 pub repo: Option<String>,
139 pub group: Option<String>,
140 /// What to pass as `--url`.
141 pub url: String,
142}
143
144/// Where a workspace's (or a repository's) agents work, and whether pull
145/// requests from forks may use its runners.
146#[derive(Clone, Debug, Default, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct RunnerSettings {
149 /// Agent runs, checks, reviews and the merge queue run on self-hosted
150 /// runners with `agent_labels`, instead of g1t's sandboxes.
151 pub agents_on_self_hosted: bool,
152 /// The labels a runner needs to take agent work; `self-hosted` is
153 /// always one.
154 pub agent_labels: Vec<String>,
155 /// Jobs of pull requests from forks may run on self-hosted runners.
156 /// Off by default: anyone who can open a pull request could run code
157 /// on the machine.
158 pub fork_pull_requests: bool,
159 /// For a repository: these are its workspace's, not its own.
160 pub inherited: bool,
161}
162
163// --- Management ------------------------------------------------------------
164
165/// A workspace's runners (`workspace`), or a repository's own (`repo`).
166#[derive(Clone, Debug, Default, Serialize, Deserialize)]
167pub struct RunnersOwner {
168 #[serde(default)]
169 pub repo: Option<RepoPath>,
170 #[serde(default)]
171 pub workspace: Option<String>,
172}
173
174/// `runners`: a workspace's runners (members), or a repository's own
175/// (its admins). Returns `Outcome<Vec<Runner>>`.
176#[derive(Debug, Serialize, Deserialize)]
177pub struct ListRunnersArgs {
178 pub actor: User,
179 #[serde(flatten)]
180 pub owner: RunnersOwner,
181}
182
183/// `create_registration_token`: owners of the workspace, or admins of the
184/// repository. Returns `Outcome<RegistrationToken>`.
185#[derive(Debug, Serialize, Deserialize)]
186pub struct CreateRegistrationTokenArgs {
187 pub actor: User,
188 #[serde(flatten)]
189 pub owner: RunnersOwner,
190 /// The group its runners join, by name or id; the default group if
191 /// left out. Not for a repository's runners.
192 #[serde(default)]
193 pub group: Option<String>,
194}
195
196/// `remove_runner`: as `create_registration_token`. A job it is running is
197/// cancelled. Returns `Outcome<bool>`.
198#[derive(Debug, Serialize, Deserialize)]
199pub struct RemoveRunnerArgs {
200 pub actor: User,
201 #[serde(flatten)]
202 pub owner: RunnersOwner,
203 pub id: String,
204}
205
206/// `runner_groups`: members. Returns `Outcome<Vec<RunnerGroup>>`.
207#[derive(Debug, Serialize, Deserialize)]
208pub struct RunnerGroupsArgs {
209 pub actor: User,
210 pub workspace: String,
211}
212
213/// `set_runner_group`: create one (no `id`) or change one. Owners.
214/// Returns `Outcome<RunnerGroup>`.
215#[derive(Debug, Serialize, Deserialize)]
216pub struct SetRunnerGroupArgs {
217 pub actor: User,
218 pub workspace: String,
219 #[serde(default)]
220 pub id: Option<String>,
221 #[serde(default)]
222 pub name: Option<String>,
223 /// Repository names; empty for every repository.
224 #[serde(default)]
225 pub repositories: Option<Vec<String>>,
226}
227
228/// `delete_runner_group`: owners. Its runners move to the default group,
229/// which cannot be deleted. Returns `Outcome<bool>`.
230#[derive(Debug, Serialize, Deserialize)]
231pub struct DeleteRunnerGroupArgs {
232 pub actor: User,
233 pub workspace: String,
234 pub id: String,
235}
236
237/// `runner_settings`: members of the workspace, or admins of the
238/// repository. Returns `Outcome<RunnerSettings>`.
239#[derive(Debug, Serialize, Deserialize)]
240pub struct RunnerSettingsArgs {
241 pub actor: User,
242 #[serde(flatten)]
243 pub owner: RunnersOwner,
244}
245
246/// `set_runner_settings`: owners, or admins of the repository. Left out is
247/// unchanged; `inherit` drops a repository's own settings. Returns
248/// `Outcome<RunnerSettings>`.
249#[derive(Debug, Serialize, Deserialize)]
250pub struct SetRunnerSettingsArgs {
251 pub actor: User,
252 #[serde(flatten)]
253 pub owner: RunnersOwner,
254 #[serde(default)]
255 pub agents_on_self_hosted: Option<bool>,
256 #[serde(default)]
257 pub agent_labels: Option<Vec<String>>,
258 #[serde(default)]
259 pub fork_pull_requests: Option<bool>,
260 #[serde(default)]
261 pub inherit: bool,
262}
263
264/// `stuck_jobs`: Mission control's Needs you. Returns `Vec<StuckJob>`.
265#[derive(Debug, Serialize, Deserialize)]
266pub struct StuckJobsArgs {
267 pub viewer: crate::Viewer,
268}
269
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.270/// `runner_activity`: what runs for a workspace now, on g1t's cloud and
271/// handed to its own runners, for the Runners page. Owners, as the
272/// workspace's runners are. Snake case both ways. Returns
273/// `Outcome<RunnerActivity>`.
274#[derive(Debug, Serialize, Deserialize)]
275pub struct RunnerActivityArgs {
276 pub actor: User,
277 pub workspace: String,
278}
279
280/// A workflow job running in one of g1t's sandboxes.
281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
282pub struct CloudJob {
283 pub id: String,
284 pub name: String,
285 /// The workflow run it is in.
286 pub run_id: String,
287 /// `owner/name`.
288 pub repo: String,
289 pub started_at: Option<String>,
290}
291
292/// Agent work handed to one of the workspace's own runners, waiting for
293/// one or taken.
294#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
295pub struct HandedOverTask {
296 pub id: String,
297 /// The agent run it is, when the sandbox said; older tasks have none.
298 pub run_id: Option<String>,
299 /// `agent`, `checks`, `review`, `queue`…
300 pub kind: String,
301 pub title: String,
302 /// `owner/name`.
303 pub repo: String,
304 /// `queued` or `in_progress`.
305 pub status: String,
306 pub runner_name: Option<String>,
307 pub created_at: String,
308 pub started_at: Option<String>,
309}
310
311#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
312pub struct RunnerActivity {
313 /// Workflow jobs in g1t's sandboxes now, newest first, at most
314 /// `ACTIVITY_LIMIT`.
315 pub cloud_jobs: Vec<CloudJob>,
316 /// Workflow jobs queued for g1t's sandboxes.
317 pub cloud_jobs_queued: u32,
318 /// Agent work on, or waiting for, the workspace's own runners.
319 pub handed_over: Vec<HandedOverTask>,
320 /// Workflow jobs waiting for one of the workspace's own runners.
321 pub self_hosted_jobs_queued: u32,
322}
323
324/// The most jobs and tasks `runner_activity` lists of each.
325pub const ACTIVITY_LIMIT: u32 = 100;
326
Fast pages, required checks on the branch, self-hosted runners, honest incidents327/// A job that has waited ten minutes or more for a self-hosted runner,
328/// with none that matches online.
329#[derive(Clone, Debug, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct StuckJob {
332 pub id: String,
333 pub name: String,
334 pub run_id: String,
335 /// `owner/name`.
336 pub repo: String,
337 /// What it asks for, for people: `self-hosted, linux, gpu`.
338 pub labels: String,
339 pub queued_at: String,
340}
341
342// --- The runner's side (snake_case on the wire, as the API passes it) ------
343
344/// `runner_register`: a machine registering with a registration token.
345/// Returns `Outcome<Registered>`.
346#[derive(Clone, Debug, Default, Serialize, Deserialize)]
347pub struct RegisterArgs {
348 pub token: String,
349 pub name: String,
350 #[serde(default)]
351 pub labels: Vec<String>,
352 pub os: String,
353 pub arch: String,
354 #[serde(default)]
355 pub version: String,
356 #[serde(default)]
357 pub ephemeral: bool,
358 /// A group by name, for a workspace's token; the token's group, else
359 /// the default, if left out.
360 #[serde(default)]
361 pub group: Option<String>,
362 /// Replace a runner of the same name instead of refusing.
363 #[serde(default)]
364 pub replace: bool,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9365 /// How the runner keeps work from its machine: `nested-container` (a
366 /// container per job, inside the runner's own sandboxed container),
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.367 /// `vm` (a throwaway VM per job, native to the machine's OS),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9368 /// `container` (a container per job, on a Docker Engine it shares with
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.369 /// its machine) or `host` (directly on the machine). A `host` runner is
370 /// given no public repository's work ([`isolation_allows`]); older
371 /// runners leave it out.
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9372 #[serde(default)]
373 pub isolation: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents374}
375
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9376/// The isolation levels a runner may say it has (`RegisterArgs::isolation`).
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.377pub const ISOLATIONS: &[&str] = &["nested-container", "vm", "container", "host"];
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9378
Fast pages, required checks on the branch, self-hosted runners, honest incidents379#[derive(Clone, Debug, Serialize, Deserialize)]
380pub struct Registered {
381 pub runner: Runner,
382 /// Shown once; the runner keeps it in its configuration.
383 pub credential: String,
384}
385
386/// Every call a registered runner makes carries its id and credential.
387#[derive(Clone, Debug, Default, Serialize, Deserialize)]
388pub struct RunnerAuth {
389 pub runner: String,
390 pub credential: String,
391}
392
393/// `runner_poll`: waits up to `wait_ms` for work. Doubles as the runner's
394/// heartbeat. Returns `Outcome<Poll>`.
395#[derive(Clone, Debug, Default, Serialize, Deserialize)]
396pub struct PollArgs {
397 #[serde(flatten)]
398 pub auth: RunnerAuth,
399 #[serde(default)]
400 pub version: String,
401 /// What it is running now; it is given nothing more while busy.
402 #[serde(default)]
403 pub running: Vec<String>,
404 #[serde(default)]
405 pub wait_ms: u64,
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.406 /// How the runner keeps work from its machine now, as at registration
407 /// (`RegisterArgs::isolation`); kept up to date from each poll. Older
408 /// runners leave it out.
409 #[serde(default)]
410 pub isolation: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents411}
412
413/// Work for a runner.
414#[derive(Clone, Debug, Serialize, Deserialize)]
415pub struct Assignment {
416 /// `workflow` or `agent`.
417 pub kind: String,
418 pub id: String,
419 pub name: String,
420 pub repo: String,
421 pub timeout_minutes: u32,
422 /// The image to run it in: the job's `container:`, when it names one.
423 pub image: Option<String>,
424 /// A workflow job's own token, for `ACTIONS_JOB`/`ACTIONS_TOKEN`.
425 pub token: Option<String>,
426 /// An agent task's environment, as g1t's sandbox would have been
427 /// started with: `MODE`, the run's short-lived credentials, the model
428 /// proxy's address.
429 pub env: Option<Map<String, Value>>,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9430 /// The repository's visibility, `public` or `private`, when g1t knows
431 /// it: a runner that runs work directly on its machine refuses a
432 /// public repository's. Older g1t leaves it out.
433 #[serde(default)]
434 pub visibility: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents435}
436
437#[derive(Clone, Debug, Default, Serialize, Deserialize)]
438pub struct Poll {
439 pub assignment: Option<Assignment>,
440 /// Work it holds that it should stop: cancelled, timed out, removed.
441 #[serde(default)]
442 pub cancel: Vec<String>,
443 /// A new credential: it replaces the old, which stops working once this
444 /// one is used.
445 pub credential: Option<String>,
446 /// The runner was removed: stop and forget the credential.
447 #[serde(default)]
448 pub removed: bool,
449}
450
451/// `runner_finished`: a runner telling what became of work it was given.
452/// For a workflow job the harness has normally reported already; this
453/// covers one that crashed or was killed. Returns `Outcome<bool>` (whether
454/// it changed anything).
455#[derive(Clone, Debug, Default, Serialize, Deserialize)]
456pub struct FinishedArgs {
457 #[serde(flatten)]
458 pub auth: RunnerAuth,
459 pub id: String,
460 pub exit_code: i32,
461 #[serde(default)]
462 pub reason: Option<String>,
463}
464
465/// `runner_remove_self`: `g1t-runner remove`. Returns `Outcome<bool>`.
466#[derive(Clone, Debug, Default, Serialize, Deserialize)]
467pub struct RemoveSelfArgs {
468 #[serde(flatten)]
469 pub auth: RunnerAuth,
470}
471
472// --- Between services (camelCase, as every service speaks) ----------------
473
474/// `runner_route`: whether a kind of g1t's own work in a repository runs on
475/// self-hosted runners, and on which labels. Asked by the runner service
476/// before it starts a sandbox. Returns `Option<Vec<String>>`.
477#[derive(Clone, Debug, Serialize, Deserialize)]
478#[serde(rename_all = "camelCase")]
479pub struct RouteArgs {
480 pub workspace: String,
481 pub repo: RepoPath,
482}
483
484/// `enqueue_task`: agent work for a self-hosted runner, from the runner
485/// service's sandbox (`sandbox` is its Durable Object's id), which is told
486/// with `task_ended` how it went. Returns `Outcome<String>` (the task's id).
487#[derive(Clone, Debug, Serialize, Deserialize)]
488#[serde(rename_all = "camelCase")]
489pub struct EnqueueTaskArgs {
490 pub sandbox: String,
491 pub workspace: String,
492 pub repo: RepoPath,
493 /// The run kind: `agent`, `checks`, `review`, `queue`…
494 pub kind: String,
495 pub title: String,
496 pub labels: Vec<String>,
497 pub env: Map<String, Value>,
498 pub timeout_minutes: u32,
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.499 /// The agent run the work belongs to, when the sandbox opened one: what
500 /// people's pages link a runner's agent work to.
501 #[serde(default, alias = "run_id")]
502 pub run_id: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents503}
504
505/// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a
506/// time cap). Returns `Outcome<bool>`.
507#[derive(Clone, Debug, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509pub struct CancelTaskArgs {
510 pub sandbox: String,
511 #[serde(default)]
512 pub reason: Option<String>,
513}
514
515// --- Labels ----------------------------------------------------------------
516
517/// A label as it is kept: trimmed and lowercase. `None` when it is not
518/// one: empty, too long, or with characters other than letters, digits,
519/// `-`, `_`, `.`, `:` and `/`.
520pub fn clean_label(label: &str) -> Option<String> {
521 let label = label.trim().to_ascii_lowercase();
522 let ok = !label.is_empty()
523 && label.len() <= MAX_LABEL_LEN
524 && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/'));
525 ok.then_some(label)
526}
527
528/// A runner's labels: what it was given, cleaned, with `self-hosted`, its
529/// OS and its architecture, each once, in that order first.
530pub fn runner_labels(given: &[String], os: &str, arch: &str) -> Result<Vec<String>, String> {
531 let mut labels: Vec<String> = Vec::new();
532 for label in [SELF_HOSTED, os, arch].iter().map(|s| s.to_string()).chain(given.iter().cloned()) {
533 let Some(clean) = clean_label(&label) else {
534 return Err(format!("`{label}` is not a label: use letters, digits, `-`, `_`, `.`, `:` and `/`, up to {MAX_LABEL_LEN} characters."));
535 };
536 if !labels.contains(&clean) {
537 labels.push(clean);
538 }
539 }
540 if labels.len() > MAX_LABELS {
541 return Err(format!("A runner has at most {MAX_LABELS} labels."));
542 }
543 Ok(labels)
544}
545
546/// The OS a runner reports, as its label: `linux`, `macos` or `windows`.
547pub fn os_label(os: &str) -> Option<&'static str> {
548 match os.trim().to_ascii_lowercase().as_str() {
549 "linux" => Some("linux"),
550 "macos" | "darwin" | "osx" | "mac" => Some("macos"),
551 "windows" | "win" => Some("windows"),
552 _ => None,
553 }
554}
555
556/// The architecture a runner reports, as its label: `x64` or `arm64`.
557pub fn arch_label(arch: &str) -> Option<&'static str> {
558 match arch.trim().to_ascii_lowercase().as_str() {
559 "x64" | "x86_64" | "amd64" => Some("x64"),
560 "arm64" | "aarch64" => Some("arm64"),
561 _ => None,
562 }
563}
564
565/// What a job's `runs-on` asks for, read for self-hosted runners: its
566/// labels, cleaned, and the group it names. `self_hosted` when it names
567/// `self-hosted` or a group, which only self-hosted runners can satisfy.
568#[derive(Clone, Debug, Default, PartialEq, Eq)]
569pub struct Wanted {
570 pub labels: Vec<String>,
571 pub group: Option<String>,
572 pub self_hosted: bool,
573}
574
575impl Wanted {
576 pub fn of(labels: &[String], group: Option<&str>) -> Wanted {
577 let mut out: Vec<String> = Vec::new();
578 for label in labels {
579 let clean = label.trim().to_ascii_lowercase();
580 if !clean.is_empty() && !out.contains(&clean) {
581 out.push(clean);
582 }
583 }
584 let group = group.map(|g| g.trim().to_owned()).filter(|g| !g.is_empty());
585 let self_hosted = group.is_some() || out.iter().any(|l| l == SELF_HOSTED);
586 Wanted { labels: out, group, self_hosted }
587 }
588
589 /// The job's labels as stored on it: its labels, then `group:<name>`.
590 pub fn stored(&self) -> Vec<String> {
591 let mut out = self.labels.clone();
592 if let Some(group) = &self.group {
593 out.push(format!("group:{}", group.to_ascii_lowercase()));
594 }
595 out
596 }
597
598 /// Back from what [`Wanted::stored`] kept.
599 pub fn from_stored(stored: &[String]) -> Wanted {
600 let group = stored.iter().find_map(|l| l.strip_prefix("group:").map(str::to_owned));
601 let labels: Vec<String> = stored.iter().filter(|l| !l.starts_with("group:")).cloned().collect();
602 Wanted::of(&labels, group.as_deref())
603 }
604
605 /// Whether a runner with `labels`, in the group called `group`, can
606 /// take it: every label it asks for, and its group if it names one.
607 pub fn matches(&self, labels: &[String], group: Option<&str>) -> bool {
608 let has = |wanted: &String| labels.iter().any(|l| l.eq_ignore_ascii_case(wanted));
609 let group_ok = match (&self.group, group) {
610 (None, _) => true,
611 (Some(wanted), Some(group)) => wanted.eq_ignore_ascii_case(group),
612 (Some(_), None) => false,
613 };
614 group_ok && self.labels.iter().all(has)
615 }
616
617 /// For people: `self-hosted, linux, gpu` (in group `build`).
618 pub fn describe(&self) -> String {
619 let labels = self.labels.join(", ");
620 match &self.group {
621 Some(group) if labels.is_empty() => format!("in group {group}"),
622 Some(group) => format!("{labels} in group {group}"),
623 None => labels,
624 }
625 }
626}
627
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.628/// What a public repository's job says while the only runners that could
629/// take it run jobs directly on their machines (`host` isolation), which
630/// are never given a public repository's work.
631pub const WAITING_FOR_SANDBOX: &str =
632 "Waiting for a sandboxed runner: this repository is public, and host-mode runners don't run public repositories' jobs.";
633
634/// Whether a runner of `isolation` may be given a repository's work, by
635/// its visibility (`public` or `private`; unknown lets it through, and the
636/// runner decides). A host-mode runner takes no public repository's work.
637pub fn isolation_allows(isolation: Option<&str>, visibility: Option<&str>) -> bool {
638 !(isolation == Some("host") && visibility == Some("public"))
639}
640
Fast pages, required checks on the branch, self-hosted runners, honest incidents641/// What a job waiting for a runner says.
642pub fn waiting_reason(wanted: &Wanted) -> String {
643 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 fn strings(items: &[&str]) -> Vec<String> {
651 items.iter().map(|s| s.to_string()).collect()
652 }
653
654 #[test]
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.655 fn a_host_mode_runner_takes_no_public_repositorys_work() {
656 assert!(!isolation_allows(Some("host"), Some("public")));
657 assert!(isolation_allows(Some("host"), Some("private")));
658 assert!(isolation_allows(Some("host"), None));
659 assert!(isolation_allows(Some("container"), Some("public")));
660 assert!(isolation_allows(None, Some("public")), "an older runner decides itself");
661 let poll: PollArgs = serde_json::from_value(serde_json::json!({ "runner": "rnr_1", "credential": "c" })).unwrap();
662 assert_eq!(poll.isolation, None, "an older runner's poll");
663 }
664
665 #[test]
Fast pages, required checks on the branch, self-hosted runners, honest incidents666 fn a_runner_always_has_self_hosted_its_os_and_its_arch() {
667 let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap();
668 assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"]));
669 assert!(runner_labels(&strings(&["has space"]), "linux", "x64").is_err());
670 assert!(runner_labels(&strings(&[""]), "linux", "x64").is_err());
671 }
672
673 #[test]
674 fn os_and_arch_are_read_as_people_write_them() {
675 assert_eq!(os_label("Darwin"), Some("macos"));
676 assert_eq!(os_label("Windows"), Some("windows"));
677 assert_eq!(os_label("plan9"), None);
678 assert_eq!(arch_label("x86_64"), Some("x64"));
679 assert_eq!(arch_label("aarch64"), Some("arm64"));
680 assert_eq!(arch_label("mips"), None);
681 }
682
683 #[test]
684 fn a_job_names_self_hosted_or_a_group_to_need_one() {
685 assert!(Wanted::of(&strings(&["Self-Hosted", "linux"]), None).self_hosted);
686 assert!(Wanted::of(&strings(&[]), Some("build")).self_hosted);
687 assert!(!Wanted::of(&strings(&["ubuntu-latest"]), None).self_hosted);
688 }
689
690 #[test]
691 fn every_label_the_job_names_must_be_the_runners() {
692 let runner = strings(&["self-hosted", "linux", "x64", "gpu"]);
693 assert!(Wanted::of(&strings(&["self-hosted"]), None).matches(&runner, Some("default")));
694 assert!(Wanted::of(&strings(&["self-hosted", "LINUX", "gpu"]), None).matches(&runner, None));
695 assert!(!Wanted::of(&strings(&["self-hosted", "linux", "arm64"]), None).matches(&runner, None));
696 assert!(!Wanted::of(&strings(&["self-hosted", "windows"]), None).matches(&runner, None));
697 }
698
699 #[test]
700 fn a_group_is_matched_by_name() {
701 let runner = strings(&["self-hosted", "linux", "x64"]);
702 let wanted = Wanted::of(&strings(&["linux"]), Some("Build"));
703 assert!(wanted.matches(&runner, Some("build")));
704 assert!(!wanted.matches(&runner, Some("default")));
705 assert!(!wanted.matches(&runner, None));
706 assert_eq!(Wanted::from_stored(&wanted.stored()), wanted.clone().tap_lower());
707 }
708
709 impl Wanted {
710 fn tap_lower(mut self) -> Wanted {
711 self.group = self.group.map(|g| g.to_ascii_lowercase());
712 self
713 }
714 }
715
716 #[test]
717 fn waiting_says_for_what() {
718 let wanted = Wanted::of(&strings(&["self-hosted", "linux", "gpu"]), None);
719 assert_eq!(waiting_reason(&wanted), "Waiting for a self-hosted runner with labels self-hosted, linux, gpu.");
720 }
721
722 #[test]
723 fn the_runners_wire_is_snake_case() {
724 let poll: PollArgs = serde_json::from_value(serde_json::json!({
725 "runner": "rnr_1", "credential": "g1tr_x", "version": "0.1.0", "running": ["job_1"], "wait_ms": 1000
726 }))
727 .unwrap();
728 assert_eq!(poll.auth.runner, "rnr_1");
729 assert_eq!(poll.wait_ms, 1000);
730 let assignment = serde_json::to_value(Assignment {
731 kind: "workflow".into(),
732 id: "job_1".into(),
733 name: "build".into(),
734 repo: "acme/web".into(),
735 timeout_minutes: 60,
736 image: None,
737 token: Some("t".into()),
738 env: None,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9739 visibility: Some("private".into()),
Fast pages, required checks on the branch, self-hosted runners, honest incidents740 })
741 .unwrap();
742 assert_eq!(assignment["timeout_minutes"], 60);
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9743 assert_eq!(assignment["visibility"], "private");
744 // An older runner's registration, without an isolation level.
745 let register: RegisterArgs = serde_json::from_value(serde_json::json!({ "token": "g1trt_x", "name": "a", "os": "linux", "arch": "x64" })).unwrap();
746 assert_eq!(register.isolation, None);
Fast pages, required checks on the branch, self-hosted runners, honest incidents747 }
748}

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