Skip to content
713 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>,
110}
111
112/// Which of a workspace's repositories may use its runners.
113#[derive(Clone, Debug, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct RunnerGroup {
116 pub id: String,
117 pub name: String,
118 /// The group runners join when none is named: every repository.
119 pub default: bool,
120 /// Repository names (without the workspace) that may use it; empty is
121 /// every repository in the workspace.
122 pub repositories: Vec<String>,
123 pub runners: u32,
124 pub updated_at: String,
125}
126
127/// A registration token, shown once.
128#[derive(Clone, Debug, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase")]
130pub struct RegistrationToken {
131 pub token: String,
132 pub expires_at: String,
133 pub workspace: String,
134 pub repo: Option<String>,
135 pub group: Option<String>,
136 /// What to pass as `--url`.
137 pub url: String,
138}
139
140/// Where a workspace's (or a repository's) agents work, and whether pull
141/// requests from forks may use its runners.
142#[derive(Clone, Debug, Default, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct RunnerSettings {
145 /// Agent runs, checks, reviews and the merge queue run on self-hosted
146 /// runners with `agent_labels`, instead of g1t's sandboxes.
147 pub agents_on_self_hosted: bool,
148 /// The labels a runner needs to take agent work; `self-hosted` is
149 /// always one.
150 pub agent_labels: Vec<String>,
151 /// Jobs of pull requests from forks may run on self-hosted runners.
152 /// Off by default: anyone who can open a pull request could run code
153 /// on the machine.
154 pub fork_pull_requests: bool,
155 /// For a repository: these are its workspace's, not its own.
156 pub inherited: bool,
157}
158
159// --- Management ------------------------------------------------------------
160
161/// A workspace's runners (`workspace`), or a repository's own (`repo`).
162#[derive(Clone, Debug, Default, Serialize, Deserialize)]
163pub struct RunnersOwner {
164 #[serde(default)]
165 pub repo: Option<RepoPath>,
166 #[serde(default)]
167 pub workspace: Option<String>,
168}
169
170/// `runners`: a workspace's runners (members), or a repository's own
171/// (its admins). Returns `Outcome<Vec<Runner>>`.
172#[derive(Debug, Serialize, Deserialize)]
173pub struct ListRunnersArgs {
174 pub actor: User,
175 #[serde(flatten)]
176 pub owner: RunnersOwner,
177}
178
179/// `create_registration_token`: owners of the workspace, or admins of the
180/// repository. Returns `Outcome<RegistrationToken>`.
181#[derive(Debug, Serialize, Deserialize)]
182pub struct CreateRegistrationTokenArgs {
183 pub actor: User,
184 #[serde(flatten)]
185 pub owner: RunnersOwner,
186 /// The group its runners join, by name or id; the default group if
187 /// left out. Not for a repository's runners.
188 #[serde(default)]
189 pub group: Option<String>,
190}
191
192/// `remove_runner`: as `create_registration_token`. A job it is running is
193/// cancelled. Returns `Outcome<bool>`.
194#[derive(Debug, Serialize, Deserialize)]
195pub struct RemoveRunnerArgs {
196 pub actor: User,
197 #[serde(flatten)]
198 pub owner: RunnersOwner,
199 pub id: String,
200}
201
202/// `runner_groups`: members. Returns `Outcome<Vec<RunnerGroup>>`.
203#[derive(Debug, Serialize, Deserialize)]
204pub struct RunnerGroupsArgs {
205 pub actor: User,
206 pub workspace: String,
207}
208
209/// `set_runner_group`: create one (no `id`) or change one. Owners.
210/// Returns `Outcome<RunnerGroup>`.
211#[derive(Debug, Serialize, Deserialize)]
212pub struct SetRunnerGroupArgs {
213 pub actor: User,
214 pub workspace: String,
215 #[serde(default)]
216 pub id: Option<String>,
217 #[serde(default)]
218 pub name: Option<String>,
219 /// Repository names; empty for every repository.
220 #[serde(default)]
221 pub repositories: Option<Vec<String>>,
222}
223
224/// `delete_runner_group`: owners. Its runners move to the default group,
225/// which cannot be deleted. Returns `Outcome<bool>`.
226#[derive(Debug, Serialize, Deserialize)]
227pub struct DeleteRunnerGroupArgs {
228 pub actor: User,
229 pub workspace: String,
230 pub id: String,
231}
232
233/// `runner_settings`: members of the workspace, or admins of the
234/// repository. Returns `Outcome<RunnerSettings>`.
235#[derive(Debug, Serialize, Deserialize)]
236pub struct RunnerSettingsArgs {
237 pub actor: User,
238 #[serde(flatten)]
239 pub owner: RunnersOwner,
240}
241
242/// `set_runner_settings`: owners, or admins of the repository. Left out is
243/// unchanged; `inherit` drops a repository's own settings. Returns
244/// `Outcome<RunnerSettings>`.
245#[derive(Debug, Serialize, Deserialize)]
246pub struct SetRunnerSettingsArgs {
247 pub actor: User,
248 #[serde(flatten)]
249 pub owner: RunnersOwner,
250 #[serde(default)]
251 pub agents_on_self_hosted: Option<bool>,
252 #[serde(default)]
253 pub agent_labels: Option<Vec<String>>,
254 #[serde(default)]
255 pub fork_pull_requests: Option<bool>,
256 #[serde(default)]
257 pub inherit: bool,
258}
259
260/// `stuck_jobs`: Mission control's Needs you. Returns `Vec<StuckJob>`.
261#[derive(Debug, Serialize, Deserialize)]
262pub struct StuckJobsArgs {
263 pub viewer: crate::Viewer,
264}
265
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.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)]
271pub 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)]
278pub 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)]
291pub 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)]
308pub 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.
321pub const ACTIVITY_LIMIT: u32 = 100;
322
Fast pages, required checks on the branch, self-hosted runners, honest incidents323/// A job that has waited ten minutes or more for a self-hosted runner,
324/// with none that matches online.
325#[derive(Clone, Debug, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct StuckJob {
328 pub id: String,
329 pub name: String,
330 pub run_id: String,
331 /// `owner/name`.
332 pub repo: String,
333 /// What it asks for, for people: `self-hosted, linux, gpu`.
334 pub labels: String,
335 pub queued_at: String,
336}
337
338// --- The runner's side (snake_case on the wire, as the API passes it) ------
339
340/// `runner_register`: a machine registering with a registration token.
341/// Returns `Outcome<Registered>`.
342#[derive(Clone, Debug, Default, Serialize, Deserialize)]
343pub struct RegisterArgs {
344 pub token: String,
345 pub name: String,
346 #[serde(default)]
347 pub labels: Vec<String>,
348 pub os: String,
349 pub arch: String,
350 #[serde(default)]
351 pub version: String,
352 #[serde(default)]
353 pub ephemeral: bool,
354 /// A group by name, for a workspace's token; the token's group, else
355 /// the default, if left out.
356 #[serde(default)]
357 pub group: Option<String>,
358 /// Replace a runner of the same name instead of refusing.
359 #[serde(default)]
360 pub replace: bool,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.361 /// How the runner keeps work from its machine: `nested-container` (a
362 /// container per job, inside the runner's own sandboxed container),
363 /// `container` (a container per job, on a Docker Engine it shares with
364 /// its machine) or `host` (directly on the machine). Kept, and shown
365 /// nowhere yet; older runners leave it out.
366 #[serde(default)]
367 pub isolation: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents368}
369
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.370/// The isolation levels a runner may say it has (`RegisterArgs::isolation`).
371pub const ISOLATIONS: &[&str] = &["nested-container", "container", "host"];
372
Fast pages, required checks on the branch, self-hosted runners, honest incidents373#[derive(Clone, Debug, Serialize, Deserialize)]
374pub struct Registered {
375 pub runner: Runner,
376 /// Shown once; the runner keeps it in its configuration.
377 pub credential: String,
378}
379
380/// Every call a registered runner makes carries its id and credential.
381#[derive(Clone, Debug, Default, Serialize, Deserialize)]
382pub struct RunnerAuth {
383 pub runner: String,
384 pub credential: String,
385}
386
387/// `runner_poll`: waits up to `wait_ms` for work. Doubles as the runner's
388/// heartbeat. Returns `Outcome<Poll>`.
389#[derive(Clone, Debug, Default, Serialize, Deserialize)]
390pub struct PollArgs {
391 #[serde(flatten)]
392 pub auth: RunnerAuth,
393 #[serde(default)]
394 pub version: String,
395 /// What it is running now; it is given nothing more while busy.
396 #[serde(default)]
397 pub running: Vec<String>,
398 #[serde(default)]
399 pub wait_ms: u64,
400}
401
402/// Work for a runner.
403#[derive(Clone, Debug, Serialize, Deserialize)]
404pub struct Assignment {
405 /// `workflow` or `agent`.
406 pub kind: String,
407 pub id: String,
408 pub name: String,
409 pub repo: String,
410 pub timeout_minutes: u32,
411 /// The image to run it in: the job's `container:`, when it names one.
412 pub image: Option<String>,
413 /// A workflow job's own token, for `ACTIONS_JOB`/`ACTIONS_TOKEN`.
414 pub token: Option<String>,
415 /// An agent task's environment, as g1t's sandbox would have been
416 /// started with: `MODE`, the run's short-lived credentials, the model
417 /// proxy's address.
418 pub env: Option<Map<String, Value>>,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.419 /// The repository's visibility, `public` or `private`, when g1t knows
420 /// it: a runner that runs work directly on its machine refuses a
421 /// public repository's. Older g1t leaves it out.
422 #[serde(default)]
423 pub visibility: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents424}
425
426#[derive(Clone, Debug, Default, Serialize, Deserialize)]
427pub struct Poll {
428 pub assignment: Option<Assignment>,
429 /// Work it holds that it should stop: cancelled, timed out, removed.
430 #[serde(default)]
431 pub cancel: Vec<String>,
432 /// A new credential: it replaces the old, which stops working once this
433 /// one is used.
434 pub credential: Option<String>,
435 /// The runner was removed: stop and forget the credential.
436 #[serde(default)]
437 pub removed: bool,
438}
439
440/// `runner_finished`: a runner telling what became of work it was given.
441/// For a workflow job the harness has normally reported already; this
442/// covers one that crashed or was killed. Returns `Outcome<bool>` (whether
443/// it changed anything).
444#[derive(Clone, Debug, Default, Serialize, Deserialize)]
445pub struct FinishedArgs {
446 #[serde(flatten)]
447 pub auth: RunnerAuth,
448 pub id: String,
449 pub exit_code: i32,
450 #[serde(default)]
451 pub reason: Option<String>,
452}
453
454/// `runner_remove_self`: `g1t-runner remove`. Returns `Outcome<bool>`.
455#[derive(Clone, Debug, Default, Serialize, Deserialize)]
456pub struct RemoveSelfArgs {
457 #[serde(flatten)]
458 pub auth: RunnerAuth,
459}
460
461// --- Between services (camelCase, as every service speaks) ----------------
462
463/// `runner_route`: whether a kind of g1t's own work in a repository runs on
464/// self-hosted runners, and on which labels. Asked by the runner service
465/// before it starts a sandbox. Returns `Option<Vec<String>>`.
466#[derive(Clone, Debug, Serialize, Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct RouteArgs {
469 pub workspace: String,
470 pub repo: RepoPath,
471}
472
473/// `enqueue_task`: agent work for a self-hosted runner, from the runner
474/// service's sandbox (`sandbox` is its Durable Object's id), which is told
475/// with `task_ended` how it went. Returns `Outcome<String>` (the task's id).
476#[derive(Clone, Debug, Serialize, Deserialize)]
477#[serde(rename_all = "camelCase")]
478pub struct EnqueueTaskArgs {
479 pub sandbox: String,
480 pub workspace: String,
481 pub repo: RepoPath,
482 /// The run kind: `agent`, `checks`, `review`, `queue`…
483 pub kind: String,
484 pub title: String,
485 pub labels: Vec<String>,
486 pub env: Map<String, Value>,
487 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.488 /// The agent run the work belongs to, when the sandbox opened one: what
489 /// people's pages link a runner's agent work to.
490 #[serde(default, alias = "run_id")]
491 pub run_id: Option<String>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents492}
493
494/// `cancel_task`: the sandbox's work stopped from g1t's side (a person, a
495/// time cap). Returns `Outcome<bool>`.
496#[derive(Clone, Debug, Serialize, Deserialize)]
497#[serde(rename_all = "camelCase")]
498pub struct CancelTaskArgs {
499 pub sandbox: String,
500 #[serde(default)]
501 pub reason: Option<String>,
502}
503
504// --- Labels ----------------------------------------------------------------
505
506/// A label as it is kept: trimmed and lowercase. `None` when it is not
507/// one: empty, too long, or with characters other than letters, digits,
508/// `-`, `_`, `.`, `:` and `/`.
509pub fn clean_label(label: &str) -> Option<String> {
510 let label = label.trim().to_ascii_lowercase();
511 let ok = !label.is_empty()
512 && label.len() <= MAX_LABEL_LEN
513 && label.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/'));
514 ok.then_some(label)
515}
516
517/// A runner's labels: what it was given, cleaned, with `self-hosted`, its
518/// OS and its architecture, each once, in that order first.
519pub fn runner_labels(given: &[String], os: &str, arch: &str) -> Result<Vec<String>, String> {
520 let mut labels: Vec<String> = Vec::new();
521 for label in [SELF_HOSTED, os, arch].iter().map(|s| s.to_string()).chain(given.iter().cloned()) {
522 let Some(clean) = clean_label(&label) else {
523 return Err(format!("`{label}` is not a label: use letters, digits, `-`, `_`, `.`, `:` and `/`, up to {MAX_LABEL_LEN} characters."));
524 };
525 if !labels.contains(&clean) {
526 labels.push(clean);
527 }
528 }
529 if labels.len() > MAX_LABELS {
530 return Err(format!("A runner has at most {MAX_LABELS} labels."));
531 }
532 Ok(labels)
533}
534
535/// The OS a runner reports, as its label: `linux`, `macos` or `windows`.
536pub fn os_label(os: &str) -> Option<&'static str> {
537 match os.trim().to_ascii_lowercase().as_str() {
538 "linux" => Some("linux"),
539 "macos" | "darwin" | "osx" | "mac" => Some("macos"),
540 "windows" | "win" => Some("windows"),
541 _ => None,
542 }
543}
544
545/// The architecture a runner reports, as its label: `x64` or `arm64`.
546pub fn arch_label(arch: &str) -> Option<&'static str> {
547 match arch.trim().to_ascii_lowercase().as_str() {
548 "x64" | "x86_64" | "amd64" => Some("x64"),
549 "arm64" | "aarch64" => Some("arm64"),
550 _ => None,
551 }
552}
553
554/// What a job's `runs-on` asks for, read for self-hosted runners: its
555/// labels, cleaned, and the group it names. `self_hosted` when it names
556/// `self-hosted` or a group, which only self-hosted runners can satisfy.
557#[derive(Clone, Debug, Default, PartialEq, Eq)]
558pub struct Wanted {
559 pub labels: Vec<String>,
560 pub group: Option<String>,
561 pub self_hosted: bool,
562}
563
564impl Wanted {
565 pub fn of(labels: &[String], group: Option<&str>) -> Wanted {
566 let mut out: Vec<String> = Vec::new();
567 for label in labels {
568 let clean = label.trim().to_ascii_lowercase();
569 if !clean.is_empty() && !out.contains(&clean) {
570 out.push(clean);
571 }
572 }
573 let group = group.map(|g| g.trim().to_owned()).filter(|g| !g.is_empty());
574 let self_hosted = group.is_some() || out.iter().any(|l| l == SELF_HOSTED);
575 Wanted { labels: out, group, self_hosted }
576 }
577
578 /// The job's labels as stored on it: its labels, then `group:<name>`.
579 pub fn stored(&self) -> Vec<String> {
580 let mut out = self.labels.clone();
581 if let Some(group) = &self.group {
582 out.push(format!("group:{}", group.to_ascii_lowercase()));
583 }
584 out
585 }
586
587 /// Back from what [`Wanted::stored`] kept.
588 pub fn from_stored(stored: &[String]) -> Wanted {
589 let group = stored.iter().find_map(|l| l.strip_prefix("group:").map(str::to_owned));
590 let labels: Vec<String> = stored.iter().filter(|l| !l.starts_with("group:")).cloned().collect();
591 Wanted::of(&labels, group.as_deref())
592 }
593
594 /// Whether a runner with `labels`, in the group called `group`, can
595 /// take it: every label it asks for, and its group if it names one.
596 pub fn matches(&self, labels: &[String], group: Option<&str>) -> bool {
597 let has = |wanted: &String| labels.iter().any(|l| l.eq_ignore_ascii_case(wanted));
598 let group_ok = match (&self.group, group) {
599 (None, _) => true,
600 (Some(wanted), Some(group)) => wanted.eq_ignore_ascii_case(group),
601 (Some(_), None) => false,
602 };
603 group_ok && self.labels.iter().all(has)
604 }
605
606 /// For people: `self-hosted, linux, gpu` (in group `build`).
607 pub fn describe(&self) -> String {
608 let labels = self.labels.join(", ");
609 match &self.group {
610 Some(group) if labels.is_empty() => format!("in group {group}"),
611 Some(group) => format!("{labels} in group {group}"),
612 None => labels,
613 }
614 }
615}
616
617/// What a job waiting for a runner says.
618pub fn waiting_reason(wanted: &Wanted) -> String {
619 format!("Waiting for a self-hosted runner with labels {}.", wanted.describe())
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 fn strings(items: &[&str]) -> Vec<String> {
627 items.iter().map(|s| s.to_string()).collect()
628 }
629
630 #[test]
631 fn a_runner_always_has_self_hosted_its_os_and_its_arch() {
632 let labels = runner_labels(&strings(&["GPU", "gpu", " cuda-12 "]), "linux", "x64").unwrap();
633 assert_eq!(labels, strings(&["self-hosted", "linux", "x64", "gpu", "cuda-12"]));
634 assert!(runner_labels(&strings(&["has space"]), "linux", "x64").is_err());
635 assert!(runner_labels(&strings(&[""]), "linux", "x64").is_err());
636 }
637
638 #[test]
639 fn os_and_arch_are_read_as_people_write_them() {
640 assert_eq!(os_label("Darwin"), Some("macos"));
641 assert_eq!(os_label("Windows"), Some("windows"));
642 assert_eq!(os_label("plan9"), None);
643 assert_eq!(arch_label("x86_64"), Some("x64"));
644 assert_eq!(arch_label("aarch64"), Some("arm64"));
645 assert_eq!(arch_label("mips"), None);
646 }
647
648 #[test]
649 fn a_job_names_self_hosted_or_a_group_to_need_one() {
650 assert!(Wanted::of(&strings(&["Self-Hosted", "linux"]), None).self_hosted);
651 assert!(Wanted::of(&strings(&[]), Some("build")).self_hosted);
652 assert!(!Wanted::of(&strings(&["ubuntu-latest"]), None).self_hosted);
653 }
654
655 #[test]
656 fn every_label_the_job_names_must_be_the_runners() {
657 let runner = strings(&["self-hosted", "linux", "x64", "gpu"]);
658 assert!(Wanted::of(&strings(&["self-hosted"]), None).matches(&runner, Some("default")));
659 assert!(Wanted::of(&strings(&["self-hosted", "LINUX", "gpu"]), None).matches(&runner, None));
660 assert!(!Wanted::of(&strings(&["self-hosted", "linux", "arm64"]), None).matches(&runner, None));
661 assert!(!Wanted::of(&strings(&["self-hosted", "windows"]), None).matches(&runner, None));
662 }
663
664 #[test]
665 fn a_group_is_matched_by_name() {
666 let runner = strings(&["self-hosted", "linux", "x64"]);
667 let wanted = Wanted::of(&strings(&["linux"]), Some("Build"));
668 assert!(wanted.matches(&runner, Some("build")));
669 assert!(!wanted.matches(&runner, Some("default")));
670 assert!(!wanted.matches(&runner, None));
671 assert_eq!(Wanted::from_stored(&wanted.stored()), wanted.clone().tap_lower());
672 }
673
674 impl Wanted {
675 fn tap_lower(mut self) -> Wanted {
676 self.group = self.group.map(|g| g.to_ascii_lowercase());
677 self
678 }
679 }
680
681 #[test]
682 fn waiting_says_for_what() {
683 let wanted = Wanted::of(&strings(&["self-hosted", "linux", "gpu"]), None);
684 assert_eq!(waiting_reason(&wanted), "Waiting for a self-hosted runner with labels self-hosted, linux, gpu.");
685 }
686
687 #[test]
688 fn the_runners_wire_is_snake_case() {
689 let poll: PollArgs = serde_json::from_value(serde_json::json!({
690 "runner": "rnr_1", "credential": "g1tr_x", "version": "0.1.0", "running": ["job_1"], "wait_ms": 1000
691 }))
692 .unwrap();
693 assert_eq!(poll.auth.runner, "rnr_1");
694 assert_eq!(poll.wait_ms, 1000);
695 let assignment = serde_json::to_value(Assignment {
696 kind: "workflow".into(),
697 id: "job_1".into(),
698 name: "build".into(),
699 repo: "acme/web".into(),
700 timeout_minutes: 60,
701 image: None,
702 token: Some("t".into()),
703 env: None,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.704 visibility: Some("private".into()),
Fast pages, required checks on the branch, self-hosted runners, honest incidents705 })
706 .unwrap();
707 assert_eq!(assignment["timeout_minutes"], 60);
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.708 assert_eq!(assignment["visibility"], "private");
709 // An older runner's registration, without an isolation level.
710 let register: RegisterArgs = serde_json::from_value(serde_json::json!({ "token": "g1trt_x", "name": "a", "os": "linux", "arch": "x64" })).unwrap();
711 assert_eq!(register.isolation, None);
Fast pages, required checks on the branch, self-hosted runners, honest incidents712 }
713}

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