Skip to content
839 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.

Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01//! 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.
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.080 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>,
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.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>,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0114}
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
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.237// --- The desktop app's runners ----------------------------------------------
238
239/// The desktop app keeps a runner on a person's computer while it is open
240/// (apps/desktop/src-tauri/src/runner). What it says of that runner, as it
241/// says it in its tray and settings (its diagnosis.rs, the one catalogue
242/// of those words), is kept here per computer, so the workspace's Runners
243/// page lists every desktop with the same state, why, and the fix. A
244/// desktop not heard from for `DESKTOP_OPEN_WITHIN_MS` has g1t closed, and
245/// shows `closed`, which the app sent too.
246pub const DESKTOP_OPEN_WITHIN_MS: u64 = 10 * 60 * 1000;
247/// The most desktops a workspace keeps reports for.
248pub const MAX_DESKTOPS: u32 = 200;
249
250/// A diagnosis, as the app made it.
251#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
252pub struct DesktopDiagnosis {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.253 /// `idle`, `busy`, `no_docker`, `no_wsl`, `sandbox_failed`, `not_allowed`, …
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.254 pub code: String,
255 /// `ok`, `working`, `off`, `warning` or `problem`.
256 pub tone: String,
257 pub title: String,
258 #[serde(default)]
259 pub cause: Option<String>,
260 #[serde(default)]
261 pub fix: Option<String>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.262 /// A sandboxed desktop's kinds of job, each with how it is: Linux jobs
263 /// can run while Windows jobs cannot. Empty for the other backends.
264 #[serde(default, skip_serializing_if = "Vec::is_empty")]
265 pub capabilities: Vec<DesktopCapability>,
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.266}
267
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.268/// One kind of job a sandboxed desktop runs, as its app says it.
269#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
270pub struct DesktopCapability {
271 /// `linux`, `windows` or `macos`.
272 pub id: String,
273 /// `Linux jobs`.
274 pub label: String,
275 pub tone: String,
276 /// `Waiting for jobs`, `Not available`.
277 pub title: String,
278 #[serde(default)]
279 pub cause: Option<String>,
280 #[serde(default)]
281 pub fix: Option<String>,
282}
283
284/// The most kinds of job a desktop reports.
285pub const MAX_DESKTOP_CAPABILITIES: usize = 4;
286
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.287/// One person's computer, as its desktop app last said.
288#[derive(Clone, Debug, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct DesktopRunner {
291 pub workspace: String,
292 /// The computer's name, which its runner has too.
293 pub machine: String,
294 /// Whose computer: their username.
295 pub person: String,
296 /// `sandboxed`, `docker` or `host`.
297 pub backend: String,
298 pub version: Option<String>,
299 pub diagnosis: DesktopDiagnosis,
300 /// What to show while g1t is closed on it.
301 pub closed: DesktopDiagnosis,
302 /// Heard from within `DESKTOP_OPEN_WITHIN_MS`: g1t is open there.
303 pub open: bool,
304 pub reported_at: String,
305}
306
307/// `report_desktop`: the person's own desktop app, through the site, for a
308/// workspace they own. Returns `Outcome<bool>`.
309#[derive(Debug, Serialize, Deserialize)]
310pub struct ReportDesktopArgs {
311 pub actor: User,
312 pub workspace: String,
313 pub machine: String,
314 pub backend: String,
315 #[serde(default)]
316 pub version: Option<String>,
317 pub diagnosis: DesktopDiagnosis,
318 pub closed: DesktopDiagnosis,
319}
320
321/// `desktop_runners`: owners of the workspace. Returns `Outcome<Vec<DesktopRunner>>`.
322#[derive(Debug, Serialize, Deserialize)]
323pub struct DesktopRunnersArgs {
324 pub actor: User,
325 pub workspace: String,
326}
327
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0328/// `runner_settings`: members of the workspace, or admins of the
329/// repository. Returns `Outcome<RunnerSettings>`.
330#[derive(Debug, Serialize, Deserialize)]
331pub struct RunnerSettingsArgs {
332 pub actor: User,
333 #[serde(flatten)]
334 pub owner: RunnersOwner,
335}
336
337/// `set_runner_settings`: owners, or admins of the repository. Left out is
338/// unchanged; `inherit` drops a repository's own settings. Returns
339/// `Outcome<RunnerSettings>`.
340#[derive(Debug, Serialize, Deserialize)]
341pub struct SetRunnerSettingsArgs {
342 pub actor: User,
343 #[serde(flatten)]
344 pub owner: RunnersOwner,
345 #[serde(default)]
346 pub agents_on_self_hosted: Option<bool>,
347 #[serde(default)]
348 pub agent_labels: Option<Vec<String>>,
349 #[serde(default)]
350 pub fork_pull_requests: Option<bool>,
351 #[serde(default)]
352 pub inherit: bool,
353}
354
355/// `stuck_jobs`: Mission control's Needs you. Returns `Vec<StuckJob>`.
356#[derive(Debug, Serialize, Deserialize)]
357pub struct StuckJobsArgs {
358 pub viewer: crate::Viewer,
359}
360
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.361/// `runner_activity`: what runs for a workspace now, on g1t's cloud and
362/// handed to its own runners, for the Runners page. Owners, as the
363/// workspace's runners are. Snake case both ways. Returns
364/// `Outcome<RunnerActivity>`.
365#[derive(Debug, Serialize, Deserialize)]
366pub struct RunnerActivityArgs {
367 pub actor: User,
368 pub workspace: String,
369}
370
371/// A workflow job running in one of g1t's sandboxes.
372#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
373pub struct CloudJob {
374 pub id: String,
375 pub name: String,
376 /// The workflow run it is in.
377 pub run_id: String,
378 /// `owner/name`.
379 pub repo: String,
380 pub started_at: Option<String>,
381}
382
383/// Agent work handed to one of the workspace's own runners, waiting for
384/// one or taken.
385#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
386pub struct HandedOverTask {
387 pub id: String,
388 /// The agent run it is, when the sandbox said; older tasks have none.
389 pub run_id: Option<String>,
390 /// `agent`, `checks`, `review`, `queue`…
391 pub kind: String,
392 pub title: String,
393 /// `owner/name`.
394 pub repo: String,
395 /// `queued` or `in_progress`.
396 pub status: String,
397 pub runner_name: Option<String>,
398 pub created_at: String,
399 pub started_at: Option<String>,
400}
401
402#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
403pub struct RunnerActivity {
404 /// Workflow jobs in g1t's sandboxes now, newest first, at most
405 /// `ACTIVITY_LIMIT`.
406 pub cloud_jobs: Vec<CloudJob>,
407 /// Workflow jobs queued for g1t's sandboxes.
408 pub cloud_jobs_queued: u32,
409 /// Agent work on, or waiting for, the workspace's own runners.
410 pub handed_over: Vec<HandedOverTask>,
411 /// Workflow jobs waiting for one of the workspace's own runners.
412 pub self_hosted_jobs_queued: u32,
413}
414
415/// The most jobs and tasks `runner_activity` lists of each.
416pub const ACTIVITY_LIMIT: u32 = 100;
417
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0418/// A job that has waited ten minutes or more for a self-hosted runner,
419/// with none that matches online.
420#[derive(Clone, Debug, Serialize, Deserialize)]
421#[serde(rename_all = "camelCase")]
422pub struct StuckJob {
423 pub id: String,
424 pub name: String,
425 pub run_id: String,
426 /// `owner/name`.
427 pub repo: String,
428 /// What it asks for, for people: `self-hosted, linux, gpu`.
429 pub labels: String,
430 pub queued_at: String,
431}
432
433// --- The runner's side (snake_case on the wire, as the API passes it) ------
434
435/// `runner_register`: a machine registering with a registration token.
436/// Returns `Outcome<Registered>`.
437#[derive(Clone, Debug, Default, Serialize, Deserialize)]
438pub struct RegisterArgs {
439 pub token: String,
440 pub name: String,
441 #[serde(default)]
442 pub labels: Vec<String>,
443 pub os: String,
444 pub arch: String,
445 #[serde(default)]
446 pub version: String,
447 #[serde(default)]
448 pub ephemeral: bool,
449 /// A group by name, for a workspace's token; the token's group, else
450 /// the default, if left out.
451 #[serde(default)]
452 pub group: Option<String>,
453 /// Replace a runner of the same name instead of refusing.
454 #[serde(default)]
455 pub replace: bool,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9456 /// How the runner keeps work from its machine: `nested-container` (a
457 /// container per job, inside the runner's own sandboxed container),
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.458 /// `vm` (a throwaway VM per job, native to the machine's OS),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9459 /// `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.460 /// its machine) or `host` (directly on the machine). A `host` runner is
461 /// given no public repository's work ([`isolation_allows`]); older
462 /// runners leave it out.
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9463 #[serde(default)]
464 pub isolation: Option<String>,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0465}
466
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9467/// The isolation levels a runner may say it has (`RegisterArgs::isolation`).
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.468pub const ISOLATIONS: &[&str] = &["nested-container", "vm", "container", "host"];
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9469
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0470#[derive(Clone, Debug, Serialize, Deserialize)]
471pub struct Registered {
472 pub runner: Runner,
473 /// Shown once; the runner keeps it in its configuration.
474 pub credential: String,
475}
476
477/// Every call a registered runner makes carries its id and credential.
478#[derive(Clone, Debug, Default, Serialize, Deserialize)]
479pub struct RunnerAuth {
480 pub runner: String,
481 pub credential: String,
482}
483
484/// `runner_poll`: waits up to `wait_ms` for work. Doubles as the runner's
485/// heartbeat. Returns `Outcome<Poll>`.
486#[derive(Clone, Debug, Default, Serialize, Deserialize)]
487pub struct PollArgs {
488 #[serde(flatten)]
489 pub auth: RunnerAuth,
490 #[serde(default)]
491 pub version: String,
492 /// What it is running now; it is given nothing more while busy.
493 #[serde(default)]
494 pub running: Vec<String>,
495 #[serde(default)]
496 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.497 /// How the runner keeps work from its machine now, as at registration
498 /// (`RegisterArgs::isolation`); kept up to date from each poll. Older
499 /// runners leave it out.
500 #[serde(default)]
501 pub isolation: Option<String>,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0502}
503
504/// Work for a runner.
505#[derive(Clone, Debug, Serialize, Deserialize)]
506pub struct Assignment {
507 /// `workflow` or `agent`.
508 pub kind: String,
509 pub id: String,
510 pub name: String,
511 pub repo: String,
512 pub timeout_minutes: u32,
513 /// The image to run it in: the job's `container:`, when it names one.
514 pub image: Option<String>,
515 /// A workflow job's own token, for `ACTIONS_JOB`/`ACTIONS_TOKEN`.
516 pub token: Option<String>,
517 /// An agent task's environment, as g1t's sandbox would have been
518 /// started with: `MODE`, the run's short-lived credentials, the model
519 /// proxy's address.
520 pub env: Option<Map<String, Value>>,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9521 /// The repository's visibility, `public` or `private`, when g1t knows
522 /// it: a runner that runs work directly on its machine refuses a
523 /// public repository's. Older g1t leaves it out.
524 #[serde(default)]
525 pub visibility: Option<String>,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0526}
527
528#[derive(Clone, Debug, Default, Serialize, Deserialize)]
529pub struct Poll {
530 pub assignment: Option<Assignment>,
531 /// Work it holds that it should stop: cancelled, timed out, removed.
532 #[serde(default)]
533 pub cancel: Vec<String>,
534 /// A new credential: it replaces the old, which stops working once this
535 /// one is used.
536 pub credential: Option<String>,
537 /// The runner was removed: stop and forget the credential.
538 #[serde(default)]
539 pub removed: bool,
540}
541
542/// `runner_finished`: a runner telling what became of work it was given.
543/// For a workflow job the harness has normally reported already; this
544/// covers one that crashed or was killed. Returns `Outcome<bool>` (whether
545/// it changed anything).
546#[derive(Clone, Debug, Default, Serialize, Deserialize)]
547pub struct FinishedArgs {
548 #[serde(flatten)]
549 pub auth: RunnerAuth,
550 pub id: String,
551 pub exit_code: i32,
552 #[serde(default)]
553 pub reason: Option<String>,
554}
555
556/// `runner_remove_self`: `g1t-runner remove`. Returns `Outcome<bool>`.
557#[derive(Clone, Debug, Default, Serialize, Deserialize)]
558pub struct RemoveSelfArgs {
559 #[serde(flatten)]
560 pub auth: RunnerAuth,
561}
562
563// --- Between services (camelCase, as every service speaks) ----------------
564
565/// `runner_route`: whether a kind of g1t's own work in a repository runs on
566/// self-hosted runners, and on which labels. Asked by the runner service
567/// before it starts a sandbox. Returns `Option<Vec<String>>`.
568#[derive(Clone, Debug, Serialize, Deserialize)]
569#[serde(rename_all = "camelCase")]
570pub struct RouteArgs {
571 pub workspace: String,
572 pub repo: RepoPath,
573}
574
575/// `enqueue_task`: agent work for a self-hosted runner, from the runner
576/// service's sandbox (`sandbox` is its Durable Object's id), which is told
577/// with `task_ended` how it went. Returns `Outcome<String>` (the task's id).
578#[derive(Clone, Debug, Serialize, Deserialize)]
579#[serde(rename_all = "camelCase")]
580pub struct EnqueueTaskArgs {
581 pub sandbox: String,
582 pub workspace: String,
583 pub repo: RepoPath,
584 /// The run kind: `agent`, `checks`, `review`, `queue`…
585 pub kind: String,
586 pub title: String,
587 pub labels: Vec<String>,
588 pub env: Map<String, Value>,
589 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.590 /// The agent run the work belongs to, when the sandbox opened one: what
591 /// people's pages link a runner's agent work to.
592 #[serde(default, alias = "run_id")]
593 pub run_id: Option<String>,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0594}
595
596/// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a
597/// time cap). Returns `Outcome<bool>`.
598#[derive(Clone, Debug, Serialize, Deserialize)]
599#[serde(rename_all = "camelCase")]
600pub struct CancelTaskArgs {
601 pub sandbox: String,
602 #[serde(default)]
603 pub reason: Option<String>,
604}
605
606// --- Labels ----------------------------------------------------------------
607
608/// A label as it is kept: trimmed and lowercase. `None` when it is not
609/// one: empty, too long, or with characters other than letters, digits,
610/// `-`, `_`, `.`, `:` and `/`.
611pub fn clean_label(label: &str) -> Option<String> {
612 let label = label.trim().to_ascii_lowercase();
613 let ok = !label.is_empty()
614 && label.len() <= MAX_LABEL_LEN
615 && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/'));
616 ok.then_some(label)
617}
618
619/// A runner's labels: what it was given, cleaned, with `self-hosted`, its
620/// OS and its architecture, each once, in that order first.
621pub fn runner_labels(given: &[String], os: &str, arch: &str) -> Result<Vec<String>, String> {
622 let mut labels: Vec<String> = Vec::new();
623 for label in [SELF_HOSTED, os, arch].iter().map(|s| s.to_string()).chain(given.iter().cloned()) {
624 let Some(clean) = clean_label(&label) else {
625 return Err(format!("`{label}` is not a label: use letters, digits, `-`, `_`, `.`, `:` and `/`, up to {MAX_LABEL_LEN} characters."));
626 };
627 if !labels.contains(&clean) {
628 labels.push(clean);
629 }
630 }
631 if labels.len() > MAX_LABELS {
632 return Err(format!("A runner has at most {MAX_LABELS} labels."));
633 }
634 Ok(labels)
635}
636
637/// The OS a runner reports, as its label: `linux`, `macos` or `windows`.
638pub fn os_label(os: &str) -> Option<&'static str> {
639 match os.trim().to_ascii_lowercase().as_str() {
640 "linux" => Some("linux"),
641 "macos" | "darwin" | "osx" | "mac" => Some("macos"),
642 "windows" | "win" => Some("windows"),
643 _ => None,
644 }
645}
646
647/// The architecture a runner reports, as its label: `x64` or `arm64`.
648pub fn arch_label(arch: &str) -> Option<&'static str> {
649 match arch.trim().to_ascii_lowercase().as_str() {
650 "x64" | "x86_64" | "amd64" => Some("x64"),
651 "arm64" | "aarch64" => Some("arm64"),
652 _ => None,
653 }
654}
655
656/// What a job's `runs-on` asks for, read for self-hosted runners: its
657/// labels, cleaned, and the group it names. `self_hosted` when it names
658/// `self-hosted` or a group, which only self-hosted runners can satisfy.
659#[derive(Clone, Debug, Default, PartialEq, Eq)]
660pub struct Wanted {
661 pub labels: Vec<String>,
662 pub group: Option<String>,
663 pub self_hosted: bool,
664}
665
666impl Wanted {
667 pub fn of(labels: &[String], group: Option<&str>) -> Wanted {
668 let mut out: Vec<String> = Vec::new();
669 for label in labels {
670 let clean = label.trim().to_ascii_lowercase();
671 if !clean.is_empty() && !out.contains(&clean) {
672 out.push(clean);
673 }
674 }
675 let group = group.map(|g| g.trim().to_owned()).filter(|g| !g.is_empty());
676 let self_hosted = group.is_some() || out.iter().any(|l| l == SELF_HOSTED);
677 Wanted { labels: out, group, self_hosted }
678 }
679
680 /// The job's labels as stored on it: its labels, then `group:<name>`.
681 pub fn stored(&self) -> Vec<String> {
682 let mut out = self.labels.clone();
683 if let Some(group) = &self.group {
684 out.push(format!("group:{}", group.to_ascii_lowercase()));
685 }
686 out
687 }
688
689 /// Back from what [`Wanted::stored`] kept.
690 pub fn from_stored(stored: &[String]) -> Wanted {
691 let group = stored.iter().find_map(|l| l.strip_prefix("group:").map(str::to_owned));
692 let labels: Vec<String> = stored.iter().filter(|l| !l.starts_with("group:")).cloned().collect();
693 Wanted::of(&labels, group.as_deref())
694 }
695
696 /// Whether a runner with `labels`, in the group called `group`, can
697 /// take it: every label it asks for, and its group if it names one.
698 pub fn matches(&self, labels: &[String], group: Option<&str>) -> bool {
699 let has = |wanted: &String| labels.iter().any(|l| l.eq_ignore_ascii_case(wanted));
700 let group_ok = match (&self.group, group) {
701 (None, _) => true,
702 (Some(wanted), Some(group)) => wanted.eq_ignore_ascii_case(group),
703 (Some(_), None) => false,
704 };
705 group_ok && self.labels.iter().all(has)
706 }
707
708 /// For people: `self-hosted, linux, gpu` (in group `build`).
709 pub fn describe(&self) -> String {
710 let labels = self.labels.join(", ");
711 match &self.group {
712 Some(group) if labels.is_empty() => format!("in group {group}"),
713 Some(group) => format!("{labels} in group {group}"),
714 None => labels,
715 }
716 }
717}
718
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.719/// What a public repository's job says while the only runners that could
720/// take it run jobs directly on their machines (`host` isolation), which
721/// are never given a public repository's work.
722pub const WAITING_FOR_SANDBOX: &str =
723 "Waiting for a sandboxed runner: this repository is public, and host-mode runners don't run public repositories' jobs.";
724
725/// Whether a runner of `isolation` may be given a repository's work, by
726/// its visibility (`public` or `private`; unknown lets it through, and the
727/// runner decides). A host-mode runner takes no public repository's work.
728pub fn isolation_allows(isolation: Option<&str>, visibility: Option<&str>) -> bool {
729 !(isolation == Some("host") && visibility == Some("public"))
730}
731
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0732/// What a job waiting for a runner says.
733pub fn waiting_reason(wanted: &Wanted) -> String {
734 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 fn strings(items: &[&str]) -> Vec<String> {
742 items.iter().map(|s| s.to_string()).collect()
743 }
744
745 #[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.746 fn a_host_mode_runner_takes_no_public_repositorys_work() {
747 assert!(!isolation_allows(Some("host"), Some("public")));
748 assert!(isolation_allows(Some("host"), Some("private")));
749 assert!(isolation_allows(Some("host"), None));
750 assert!(isolation_allows(Some("container"), Some("public")));
751 assert!(isolation_allows(None, Some("public")), "an older runner decides itself");
752 let poll: PollArgs = serde_json::from_value(serde_json::json!({ "runner": "rnr_1", "credential": "c" })).unwrap();
753 assert_eq!(poll.isolation, None, "an older runner's poll");
754 }
755
756 #[test]
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0757 fn a_runner_always_has_self_hosted_its_os_and_its_arch() {
758 let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap();
759 assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"]));
760 assert!(runner_labels(&strings(&["has space"]), "linux", "x64").is_err());
761 assert!(runner_labels(&strings(&[""]), "linux", "x64").is_err());
762 }
763
764 #[test]
765 fn os_and_arch_are_read_as_people_write_them() {
766 assert_eq!(os_label("Darwin"), Some("macos"));
767 assert_eq!(os_label("Windows"), Some("windows"));
768 assert_eq!(os_label("plan9"), None);
769 assert_eq!(arch_label("x86_64"), Some("x64"));
770 assert_eq!(arch_label("aarch64"), Some("arm64"));
771 assert_eq!(arch_label("mips"), None);
772 }
773
774 #[test]
775 fn a_job_names_self_hosted_or_a_group_to_need_one() {
776 assert!(Wanted::of(&strings(&["Self-Hosted", "linux"]), None).self_hosted);
777 assert!(Wanted::of(&strings(&[]), Some("build")).self_hosted);
778 assert!(!Wanted::of(&strings(&["ubuntu-latest"]), None).self_hosted);
779 }
780
781 #[test]
782 fn every_label_the_job_names_must_be_the_runners() {
783 let runner = strings(&["self-hosted", "linux", "x64", "gpu"]);
784 assert!(Wanted::of(&strings(&["self-hosted"]), None).matches(&runner, Some("default")));
785 assert!(Wanted::of(&strings(&["self-hosted", "LINUX", "gpu"]), None).matches(&runner, None));
786 assert!(!Wanted::of(&strings(&["self-hosted", "linux", "arm64"]), None).matches(&runner, None));
787 assert!(!Wanted::of(&strings(&["self-hosted", "windows"]), None).matches(&runner, None));
788 }
789
790 #[test]
791 fn a_group_is_matched_by_name() {
792 let runner = strings(&["self-hosted", "linux", "x64"]);
793 let wanted = Wanted::of(&strings(&["linux"]), Some("Build"));
794 assert!(wanted.matches(&runner, Some("build")));
795 assert!(!wanted.matches(&runner, Some("default")));
796 assert!(!wanted.matches(&runner, None));
797 assert_eq!(Wanted::from_stored(&wanted.stored()), wanted.clone().tap_lower());
798 }
799
800 impl Wanted {
801 fn tap_lower(mut self) -> Wanted {
802 self.group = self.group.map(|g| g.to_ascii_lowercase());
803 self
804 }
805 }
806
807 #[test]
808 fn waiting_says_for_what() {
809 let wanted = Wanted::of(&strings(&["self-hosted", "linux", "gpu"]), None);
810 assert_eq!(waiting_reason(&wanted), "Waiting for a self-hosted runner with labels self-hosted, linux, gpu.");
811 }
812
813 #[test]
814 fn the_runners_wire_is_snake_case() {
815 let poll: PollArgs = serde_json::from_value(serde_json::json!({
816 "runner": "rnr_1", "credential": "g1tr_x", "version": "0.1.0", "running": ["job_1"], "wait_ms": 1000
817 }))
818 .unwrap();
819 assert_eq!(poll.auth.runner, "rnr_1");
820 assert_eq!(poll.wait_ms, 1000);
821 let assignment = serde_json::to_value(Assignment {
822 kind: "workflow".into(),
823 id: "job_1".into(),
824 name: "build".into(),
825 repo: "acme/web".into(),
826 timeout_minutes: 60,
827 image: None,
828 token: Some("t".into()),
829 env: None,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9830 visibility: Some("private".into()),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0831 })
832 .unwrap();
833 assert_eq!(assignment["timeout_minutes"], 60);
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9834 assert_eq!(assignment["visibility"], "private");
835 // An older runner's registration, without an isolation level.
836 let register: RegisterArgs = serde_json::from_value(serde_json::json!({ "token": "g1trt_x", "name": "a", "os": "linux", "arch": "x64" })).unwrap();
837 assert_eq!(register.isolation, None);
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0838 }
839}

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