Skip to main content

runnerd_core/
api.rs

1//! The daemon's JSON API, version 1: the request and response types every client deserializes, and the
2//! principal a transport authenticates. Plain data only: no I/O, no Docker or GitHub types, no secrets but a
3//! [`Pairing`]'s one-time link, which its `Debug` leaves out.
4//!
5//! Routes, all under the authenticated principal:
6//!
7//! | Route | Body |
8//! |---|---|
9//! | `GET /v1/status` | [`Status`] |
10//! | `POST /v1/repositories/{owner}/{name}/pause?`[`PauseQuery`] | [`Paused`]; 409 [`Busy`] while it runs a job |
11//! | `POST /v1/repositories/{owner}/{name}/resume` | none, 204 |
12//! | `POST /v1/repositories/pause?`[`PauseQuery`] | [`Paused`]; 409 [`Busy`] while any runs a job, pausing none |
13//! | `POST /v1/repositories/resume` | none, 204: every repository resumed |
14//! | `POST /v1/profiles/{name}/reseed` | none, 202; 409 while it reseeds; `cache.reseeded` when done |
15//! | `POST /v1/caches/{name}/clean` | none, 204; 409 [`Busy`] while a runner uses it |
16//! | `GET /v1/config` | [`ConfigFile`] |
17//! | `POST /v1/config/check` | TOML in, [`ConfigCheck`] |
18//! | `PUT /v1/config` | TOML in, [`ConfigCheck`]; 422 [`ConfigCheck`] when invalid |
19//! | `GET /v1/doctor` | [`Doctor`] |
20//! | `POST /v1/shutdown` | [`ShutdownRequest`] in, 202 |
21//! | `GET /v1/dashboard` | [`Dashboard`] |
22//! | `POST /v1/dashboard/sign-out` | none, 204 |
23//! | `POST /v1/dashboard/pairings?`[`PairingQuery`] | [`Pairings`]; 409 when the dashboard is off or has no `[web] origins` |
24//! | `GET /v1/jobs?`[`JobQuery`] | [`JobPage`] |
25//! | `GET /v1/jobs/{id}` | [`Job`] |
26//! | `GET /v1/jobs/{id}/samples` | [`SampleSeries`] |
27//! | `GET /v1/jobs/{id}/logs?`[`LogQuery`] | `text/plain`, one line per log line |
28//! | `GET /v1/host/samples?`[`HostSampleQuery`] | [`HostSamples`] |
29//! | `GET /v1/stats?`[`StatsQuery`] | [`Stats`] |
30//! | `GET /v1/activity?`[`ActivityQuery`] | [`Activity`] |
31//! | `GET /v1/sizing?`[`SizingQuery`] | [`Sizing`] |
32//! | `GET /v1/insights?`[`InsightsQuery`] | [`Insights`] |
33//! | `GET /v1/digest` | [`Digest`] |
34//! | `POST /v1/notify/test?`[`NotifyTestQuery`] | [`NotifyTest`]; 404 without `[notify]` targets or with no such one |
35//! | `GET /v1/caches` | [`Caches`] |
36//! | `GET /v1/caches/history?`[`CacheHistoryQuery`] | [`CacheHistory`] |
37//! | `GET /v1/events?`[`EventQuery`] | `text/event-stream` of [`EventEnvelope`] |
38//! | `GET /v1/records` | [`Records`] |
39//! | `DELETE /v1/records/{job}` | [`RemovedRecords`]; 409 while a shell is open in it |
40//! | `DELETE /v1/records?`[`RemoveRecordsQuery`] | [`RemovedRecords`]; records with an open shell stay |
41//! | `POST /v1/records/{job}/shell?`[`ShellQuery`] | `Upgrade:` [`SHELL_PROTOCOL`], 101, then the shell's terminal; 409 when the budget is full |
42//!
43//! Errors carry an [`ErrorResponse`].
44
45use serde::{Deserialize, Serialize};
46
47mod admission;
48mod control;
49mod events;
50mod insights;
51mod jobs;
52mod records;
53mod sizing;
54
55pub use admission::{Admission, AdmissionPolicy, Basis, CpuAdmission, JobPeak, cores, cpus};
56pub use control::{
57    Busy, Check, CheckStatus, ConfigCheck, ConfigFile, Doctor, PauseQuery, Paused, ReplaceQuery,
58    RunningJob,
59};
60pub use events::{Event, EventEnvelope, EventQuery, RunnerFailure};
61pub use insights::{
62    AutoNote, Delivery, Digest, INSIGHT_DAYS, INSIGHT_DAYS_MAX, Insight, InsightKind, Insights,
63    InsightsQuery, LongWait, Notification, NotifyEvent, NotifyTest, NotifyTestQuery, Priority,
64    RepositoryWaits, RunLink, Severity, WaitSpread, WeekWaits,
65};
66pub use jobs::{
67    Activity, ActivityQuery, Bucket, BuildCacheCounts, BuildCacheStats, CacheHistory,
68    CacheHistoryQuery, CacheKind, CacheSize, CacheVolume, Caches, HostMemory, HostSample,
69    HostSampleQuery, HostSamples, Job, JobMetrics, JobPage, JobQuery, JobRunner, JobState,
70    JobSummary, LogQuery, LogSummary, Pressure, Sample, SampleSeries, Started, Stats, StatsQuery,
71    StatsRow, Step, Timeline,
72};
73pub use records::{
74    Record, RecordLimits, RecordNote, RecordRemoval, Records, RemoveRecordsQuery, RemovedRecords,
75    SHELL_PROTOCOL, ShellQuery,
76};
77pub use sizing::{
78    Advice, AutoPlan, JobSizing, PAGE_CACHE_ROOM, Peaks, ProfileSizing, Resize, Sizing,
79    SizingChange, SizingHold, SizingQuery, Spread, Starts, period,
80};
81
82/// Who sent a request, as the transport authenticated it. Handlers get it from one place, so a new transport only
83/// has to produce one.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "kebab-case", tag = "kind")]
86pub enum Principal {
87    /// The user the daemon runs as, on the local Unix socket.
88    LocalOwner {
89        /// The Unix user ID the socket's peer runs as.
90        uid: u32,
91    },
92}
93
94/// `GET /v1/status`: what the daemon serves and runs right now.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct Status {
97    /// The daemon's version, from its `Cargo.toml`.
98    pub version: String,
99    /// Set once the daemon stops polling to shut down.
100    pub shutting_down: bool,
101    /// The memory budget and what runners use of it.
102    pub budget: Budget,
103    /// Every configured profile with its slots.
104    pub profiles: Vec<ProfileStatus>,
105    /// Every configured repository.
106    pub repositories: Vec<RepositoryStatus>,
107    /// Queued jobs the repositories' runners can take and none holds yet, at the last poll, oldest first. No event
108    /// carries the list: `job.queued` announces a job, `job.started` and `job.finished` its leaving the queue.
109    /// A repository whose last listing failed, as while GitHub rate-limits runnerd, or that is no longer polled
110    /// because runnerd is shutting down, shows no jobs here, while the history keeps its jobs queued.
111    #[serde(default)]
112    pub queued: Vec<QueuedJob>,
113}
114
115/// The memory budget runners and the mirror share, in bytes.
116#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Budget {
118    /// The systemd slice runner containers run in.
119    pub slice: String,
120    /// The slice's `MemoryMax`; `None` when it is unlimited.
121    pub memory_max: Option<u64>,
122    /// Memory the running containers reserve: the runners' caps, or under measured admission the larger of each
123    /// runner's predicted peak and its last sampled memory, and the caps of the seed containers, shells and the
124    /// mirror.
125    pub memory_in_use: u64,
126    /// What a new runner must fit in beside them.
127    #[serde(default)]
128    pub admission: AdmissionPolicy,
129    /// Under measured admission, whether the slice is at the high-water mark, so no runner starts.
130    #[serde(default)]
131    pub held: bool,
132    /// The mirror's part of `memory_in_use`: its cap while it is enabled or still running, else 0.
133    #[serde(default)]
134    pub mirror_memory: u64,
135    /// The seed containers' part of `memory_in_use`, one per profile that reseeds, by profile name.
136    #[serde(default)]
137    pub seed_memory: Vec<SeedMemory>,
138    /// The shells' part of `memory_in_use`, one per shell open in a record, by job.
139    #[serde(default)]
140    pub shell_memory: Vec<ShellMemory>,
141    /// What the host's memory leaves runners beside its other programs, from a recent reading of it; absent
142    /// without one, and from a daemon before it read the host.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub host: Option<HostBudget>,
145    /// The CPU budget, under `[host] cpu_overcommit`. Absent without one, and from a daemon before CPU admission.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub cpu: Option<CpuBudget>,
148    /// The CPUs the slice may use, in millicores: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`,
149    /// whether or not a CPU budget admits runners. The runners' CPU limits are quotas that may add up past it.
150    /// Absent when it could not be read, and from a daemon before it said.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub slice_cpus: Option<u64>,
153}
154
155/// What the host's memory leaves the runners (ADR 0055). New runners fit in the lower of `MemoryMax` and
156/// `limit`; running ones are never stopped for it.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158pub struct HostBudget {
159    /// The reading it comes from.
160    pub memory: HostMemory,
161    /// What the slice may hold now: what its runners hold without page cache plus the host's `MemAvailable`, less
162    /// a margin kept free for the host's page cache.
163    pub limit: u64,
164    /// What that leaves new runners beside what the running ones reserve, in bytes.
165    pub spare: u64,
166    /// Whether the host's tasks stall for memory (`full` pressure), so no runner starts until they stop.
167    pub stalled: bool,
168}
169
170impl HostBudget {
171    /// The host's line as clients show it, such as `host: 31G of 64G available, 1.5% stalled for memory; leaves the
172    /// slice 40G` or, while it stalls, `…; stalling, so no runner starts`.
173    pub fn line(&self) -> String {
174        let size = |bytes: u64| crate::config::ByteSize(bytes).to_string();
175        let mut line = format!(
176            "host: {} of {} available",
177            size(self.memory.available),
178            size(self.memory.total)
179        );
180        if let Some(full) = self.memory.full.filter(|full| full.avg10 > 0) {
181            line = format!("{line}, {} stalled for memory", percent(full.avg10));
182        }
183        if self.stalled {
184            format!("{line}; stalling, so no runner starts")
185        } else {
186            format!("{line}; leaves the slice {}", size(self.limit))
187        }
188    }
189}
190
191/// Hundredths of a percent as a percentage: `1.5%`, `12%`.
192pub fn percent(hundredths: u32) -> String {
193    let whole = hundredths / 100;
194    match hundredths % 100 / 10 {
195        0 => format!("{whole}%"),
196        tenth => format!("{whole}.{tenth}%"),
197    }
198}
199
200/// The CPU budget runners share, in millicores.
201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
202pub struct CpuBudget {
203    /// The CPUs the slice may use: its `AllowedCPUs` or the host's, less under a lower `CPUQuota`.
204    pub cpus: u64,
205    /// Those times `cpu_overcommit`: what the runners' reservations must fit.
206    pub budget: u64,
207    /// What the runners reserve: each its predicted average CPU under measured admission, else its `cpus`.
208    pub in_use: u64,
209    /// Whether a queued job waits for CPU that memory and a slot have room for.
210    pub held: bool,
211}
212
213impl CpuBudget {
214    /// The budget's line as clients show it beside memory, such as `CPU 4.5 of 32 CPUs reserved` or, while a job
215    /// waits for it, `CPU 30 of 32 CPUs reserved; a job waits for CPU`.
216    pub fn line(&self) -> String {
217        let mut line = format!(
218            "CPU {} of {} CPUs reserved",
219            cores(self.in_use),
220            cores(self.budget)
221        );
222        if self.held {
223            line.push_str("; a job waits for CPU");
224        }
225        line
226    }
227}
228
229impl Budget {
230    /// How runners are admitted, as clients add it to the budget's line: nothing under caps, else `by predicted
231    /// peaks`, and why nothing starts while the slice is at the high-water mark.
232    pub fn admission_note(&self) -> Option<&'static str> {
233        match (self.admission, self.held) {
234            (AdmissionPolicy::Caps, _) => None,
235            (AdmissionPolicy::Measured, false) => Some("by predicted peaks"),
236            (AdmissionPolicy::Measured, true) => Some(
237                "by predicted peaks; the slice is past 90% of its MemoryMax, so no runner starts",
238            ),
239        }
240    }
241}
242
243/// A shell container's memory cap: its record's profile's limit when the shell opened.
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
245pub struct ShellMemory {
246    /// The job whose record the shell runs.
247    pub job: u64,
248    /// Its cap in bytes.
249    pub memory: u64,
250}
251
252/// A running seed container's memory cap: its profile's limit when the reseed started.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct SeedMemory {
255    /// The profile it reseeds.
256    pub profile: String,
257    /// Its cap in bytes.
258    pub memory: u64,
259}
260
261/// A runner profile and what runs in its slots.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct ProfileStatus {
264    /// The name under `[profiles]` in `runnerd.toml`.
265    pub name: String,
266    /// The image its runners run, such as `runnerd-runner:base`.
267    pub image: String,
268    /// CPUs per slot.
269    pub cpus: u32,
270    /// Bytes per slot.
271    pub memory: u64,
272    /// Set while slot 1's layers become the profile's snapshot.
273    pub reseeding: bool,
274    /// Index 0 is slot 1.
275    pub slots: Vec<SlotStatus>,
276}
277
278/// One slot of a profile: room for one job.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct SlotStatus {
281    /// 1-based.
282    pub slot: usize,
283    /// `None` when the slot is free.
284    pub runner: Option<RunnerStatus>,
285}
286
287/// A runner registered for one job, in its container.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct RunnerStatus {
290    /// `owner/name` the runner is registered on.
291    pub repository: String,
292    /// The runner's name, also its container's.
293    pub name: String,
294    /// The runner's ID on GitHub.
295    pub runner_id: u64,
296    /// The Docker container ID.
297    pub container: String,
298    /// Whether GitHub reports it running a job.
299    pub busy: bool,
300    /// Seconds since the Unix epoch.
301    pub started_at: u64,
302    /// What it takes of the budget: its cap under caps; under measured admission the larger of its predicted peak
303    /// and its last sampled memory. 0 from a daemon before measured admission.
304    #[serde(default)]
305    pub reserved: u64,
306    /// A warm runner: idle, and no queued job of its repository waits for it; it takes the next one GitHub hands
307    /// out. False from a daemon before warm runners.
308    #[serde(default)]
309    pub warm: bool,
310}
311
312/// A configured repository and its queue.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct RepositoryStatus {
315    /// `owner/name` on GitHub.
316    pub name: String,
317    /// The profile its jobs run in.
318    pub profile: String,
319    /// The labels its runners register with.
320    pub labels: Vec<String>,
321    /// Paused repositories get no new runners; their running jobs finish.
322    pub paused: bool,
323    /// Its jobs in `Status::queued`: queued ones its runners match and none of them took yet.
324    pub queued_jobs: usize,
325}
326
327/// A queued job runners of this daemon can take, as GitHub listed it at the last poll.
328#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
329pub struct QueuedJob {
330    /// GitHub's job ID.
331    pub id: u64,
332    /// The repository whose runners can take it, `owner/name`.
333    pub repository: String,
334    /// The workflow run it belongs to.
335    pub run_id: u64,
336    /// 1 for the first attempt of the run, higher for re-runs.
337    pub run_attempt: u32,
338    /// The workflow's name.
339    pub workflow: Option<String>,
340    /// The job's name in the workflow.
341    pub name: String,
342    /// The branch the run is for.
343    pub branch: Option<String>,
344    /// The job's page on GitHub.
345    pub url: Option<String>,
346    /// When GitHub queued it, in seconds since the Unix epoch; 0 if GitHub did not say.
347    pub queued_at: u64,
348    /// Why no runner has it yet, as the last plan saw it. `None` before the daemon has planned with it, while it
349    /// shuts down, and from a daemon before it said.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub waiting: Option<Waiting>,
352}
353
354/// What failed, that holds a repository's runners back for a while.
355#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "kebab-case")]
357pub enum HoldCause {
358    /// Its runners ended without taking a job.
359    #[default]
360    NoJob,
361    /// Its runners ended without a job after waiting for one, twice in a row.
362    IdleExits,
363    /// GitHub did not register its runner.
364    Register,
365    /// Docker did not start its runner's container.
366    Start,
367}
368
369/// Why a queued job has no runner of its own yet. Jobs are served oldest first; each takes the first of these that
370/// holds for it.
371#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "kebab-case", tag = "kind")]
373pub enum Waiting {
374    /// Its repository is paused.
375    Paused,
376    /// Runners of its repository failed, so none starts before `until` (seconds since the Unix epoch).
377    Backoff {
378        /// When runners may start again.
379        until: u64,
380        /// What failed; `NoJob` from a daemon before it said.
381        #[serde(default)]
382        cause: HoldCause,
383    },
384    /// A runner started for it and waits for GitHub to hand it the job: a runner takes any job its labels match,
385    /// so not necessarily this one.
386    RunnerStarting,
387    /// Slot 1 of its profile is becoming the profile's snapshot, so none of the profile's slots starts.
388    Reseeding {
389        /// The profile.
390        profile: String,
391    },
392    /// Its profile's image is being pulled, so none of the profile's slots starts.
393    PullingImage {
394        /// The profile.
395        profile: String,
396    },
397    /// Every slot of its profile holds a runner.
398    NoSlot {
399        /// The profile.
400        profile: String,
401    },
402    /// What its runner would reserve does not fit the memory budget left. Under measured admission, `free` is 0
403    /// while the slice is at the high-water mark (`Budget::held`).
404    Memory {
405        /// What its runner would reserve, in bytes.
406        needs: u64,
407        /// What the budget has left, in bytes.
408        free: u64,
409    },
410    /// What its runner would reserve does not fit what the host's memory has to spare beside its other programs
411    /// (`HostBudget::spare`), though it fits the slice's budget. `free` is 0 while the host's tasks stall for memory
412    /// (`HostBudget::stalled`).
413    HostMemory {
414        /// What its runner would reserve, in bytes.
415        needs: u64,
416        /// What the host has to spare, in bytes.
417        free: u64,
418    },
419    /// What its runner would reserve does not fit the CPU budget left, in millicores.
420    Cpu {
421        /// What its runner would reserve.
422        needs: u64,
423        /// What the CPU budget has left.
424        free: u64,
425    },
426    /// An older job has waited long enough for CPU that no younger one takes CPU before it.
427    BehindCpu {
428        /// The older job's ID.
429        job: u64,
430    },
431    /// An older job is about to have a runner start for it, which takes the slot, memory or CPU this one would
432    /// otherwise start with: what holds it is that start, not what runs now.
433    BehindStart {
434        /// The older job's ID.
435        job: u64,
436    },
437}
438
439/// What a [`Waiting`] reason waits on, which clients draw it by.
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub enum WaitKind {
442    /// Something the owner can act on: a pause, or runners that failed to take a job.
443    Act,
444    /// Something that clears by itself shortly: a runner starting, a reseed, an image pull, an older job's start.
445    Soon,
446    /// Room: a slot, memory or CPU that a running job holds.
447    Full,
448}
449
450impl Waiting {
451    /// What it waits on.
452    pub fn kind(&self) -> WaitKind {
453        match self {
454            Waiting::Paused | Waiting::Backoff { .. } => WaitKind::Act,
455            Waiting::RunnerStarting
456            | Waiting::Reseeding { .. }
457            | Waiting::PullingImage { .. }
458            | Waiting::BehindStart { .. } => WaitKind::Soon,
459            Waiting::NoSlot { .. }
460            | Waiting::Memory { .. }
461            | Waiting::HostMemory { .. }
462            | Waiting::Cpu { .. }
463            | Waiting::BehindCpu { .. } => WaitKind::Full,
464        }
465    }
466
467    /// Why `job` waits, in the owner's words with the numbers that hold it, as every client says it: such as
468    /// `All 2 node slots are busy` or `Needs 9G of memory; 2.5G of the budget is free`. `status` is the one `job`
469    /// is queued in, which names the slot of a starting runner and the older job one waits behind.
470    pub fn reason(&self, job: &QueuedJob, status: &Status, now: u64) -> String {
471        let size = |bytes: u64| crate::config::ByteSize(bytes).to_string();
472        let older_name = |id: u64| {
473            status
474                .queued
475                .iter()
476                .find(|candidate| candidate.id == id)
477                .map_or_else(|| "an older job".to_owned(), |older| older.name.clone())
478        };
479        match self {
480            Waiting::Paused => format!("{} is paused", job.repository),
481            Waiting::Backoff { until, cause } => format!(
482                "{}; retrying in {}",
483                match cause {
484                    HoldCause::NoJob => "Runners failed to take a job",
485                    HoldCause::IdleExits => "Runners ended without a job after waiting for one",
486                    HoldCause::Register => "Runners cannot register with GitHub",
487                    HoldCause::Start => "Runners cannot start",
488                },
489                admission::wait(until.saturating_sub(now))
490            ),
491            Waiting::RunnerStarting => {
492                let slot = status.profiles.iter().find_map(|profile| {
493                    profile
494                        .slots
495                        .iter()
496                        .find(|slot| {
497                            slot.runner.as_ref().is_some_and(|runner| {
498                                runner.repository == job.repository && !runner.busy
499                            })
500                        })
501                        .map(|slot| format!("{}\u{a0}{}", profile.name, slot.slot))
502                });
503                match slot {
504                    Some(slot) => format!("A runner is starting for it in {slot}"),
505                    None => "A runner is starting for it".to_owned(),
506                }
507            }
508            Waiting::Reseeding { profile } => format!("{profile} is rebuilding its snapshot"),
509            Waiting::PullingImage { profile } => format!("Pulling {profile}'s image"),
510            Waiting::NoSlot { profile } => {
511                let slots = status
512                    .profiles
513                    .iter()
514                    .find(|candidate| &candidate.name == profile)
515                    .map_or(0, |profile| profile.slots.len());
516                if slots == 1 {
517                    format!("The {profile} slot is busy")
518                } else {
519                    format!("All {slots} {profile} slots are busy")
520                }
521            }
522            Waiting::Memory { .. } if status.budget.held => {
523                "The slice is past 90% of its MemoryMax, so nothing starts".to_owned()
524            }
525            Waiting::Memory { needs, free } => format!(
526                "Needs {} of memory; {} of the budget is free",
527                size(*needs),
528                size(*free)
529            ),
530            Waiting::HostMemory { .. }
531                if status.budget.host.as_ref().is_some_and(|host| host.stalled) =>
532            {
533                "The host is short of memory: its programs stall waiting for it, so nothing starts"
534                    .to_owned()
535            }
536            Waiting::HostMemory { needs, free } => format!(
537                "The host is short of memory: needs {}; the host has {} to spare",
538                size(*needs),
539                size(*free)
540            ),
541            Waiting::Cpu { needs, free } => {
542                format!(
543                    "Needs {}; {} are free",
544                    cpus(*needs),
545                    admission::free_cores(*free)
546                )
547            }
548            Waiting::BehindCpu { job: older } => {
549                format!("Behind {}, which waits for CPU", older_name(*older))
550            }
551            Waiting::BehindStart { job: older } => {
552                format!(
553                    "Next after {}, whose runner starts first",
554                    older_name(*older)
555                )
556            }
557        }
558    }
559}
560
561/// `GET /v1/dashboard`: the dashboard's `[web]` settings, as the daemon read them when it started.
562#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
563pub struct Dashboard {
564    /// `[web] open`: the one of `[web] origins` that `runnerctl web` opens the dashboard on; the loopback address
565    /// when none.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub open: Option<String>,
568}
569
570/// `POST /v1/dashboard/pairings`: which of `[web] origins` to make a link for.
571#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
572pub struct PairingQuery {
573    /// One of `[web] origins`; each of them when none.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub origin: Option<String>,
576}
577
578/// `POST /v1/dashboard/pairings`: fresh one-time links that sign a device in to the dashboard.
579#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
580pub struct Pairings {
581    /// One per origin asked for.
582    pub links: Vec<Pairing>,
583}
584
585/// A link that signs one device in to the dashboard on `origin`, and nowhere else, within `lifetime`.
586#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
587pub struct Pairing {
588    /// One of `[web] origins`.
589    pub origin: String,
590    /// The link with its one-time code: a credential, which `Debug` leaves out.
591    pub url: String,
592    /// Seconds it works for from when it was made.
593    pub lifetime: u64,
594}
595
596impl std::fmt::Debug for Pairing {
597    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
598        formatter
599            .debug_struct("Pairing")
600            .field("origin", &self.origin)
601            .field("lifetime", &self.lifetime)
602            .finish_non_exhaustive()
603    }
604}
605
606/// `POST /v1/shutdown`.
607#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
608pub struct ShutdownRequest {
609    /// Kill running jobs and delete their runners instead of waiting for them.
610    #[serde(default)]
611    pub force: bool,
612}
613
614/// The body of every error response.
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct ErrorResponse {
617    /// What went wrong, for people to read.
618    pub message: String,
619}