Skip to main content

runner_manager_domain/
model.rs

1// owner: b1-domain-core
2
3//! Value types shared by every other module in this crate.
4//!
5//! These are the types named in
6//! `.taskflow/2026-08-21-local-runner-manager/04-subsystem-contracts.md`,
7//! "Persistent local data". Two rules shape every type here:
8//!
9//! 1. **No I/O.** Nothing in this crate opens a socket, a file, or a database.
10//!    `rusqlite` is declared in `crates/domain/Cargo.toml` for `b2`, which owns
11//!    `store.rs`; no module owned by `b1` refers to it.
12//! 2. **An illegal configuration should be unrepresentable, not merely
13//!    rejected.** Where the contract document writes a plain integer that has a
14//!    documented floor or ceiling — `host_capacity`, `refresh_interval_secs` —
15//!    this module gives it a type that cannot hold the illegal value, so a
16//!    caller who forgets to validate still cannot build one.
17//!
18//! D4 removed `scale_set_id`, `scale_set_name`, and `protocol_flag` from the
19//! model. They are not here under another name, and they must not come back:
20//! the routing token is now [`crate::policy::RoutingLabels`].
21
22use std::cmp::Ordering;
23use std::fmt;
24use std::hash::{Hash, Hasher};
25use std::num::{NonZeroU16, NonZeroUsize};
26use std::str::FromStr;
27
28use serde::{Deserialize, Serialize};
29use uuid::Uuid;
30
31use crate::path::LocalAbsolutePath;
32
33/// Every timestamp in the domain is UTC.
34///
35/// The domain never reads this from the operating system. Decisions that depend
36/// on elapsed time take a [`Clock`], which the tests replace with
37/// `runner_manager_testkit::clock::FakeClock`.
38pub type Timestamp = chrono::DateTime<chrono::Utc>;
39
40/// A span of time, re-exported so callers need not name `chrono` directly.
41pub type Elapsed = chrono::TimeDelta;
42
43// ---------------------------------------------------------------------------
44// Errors
45// ---------------------------------------------------------------------------
46
47/// A value that cannot be constructed because it would violate a documented
48/// constraint.
49#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
50pub enum ValidationError {
51    #[error("{what} must not be empty")]
52    Empty { what: &'static str },
53
54    #[error("{what} must be at most {max} characters, got {actual}")]
55    TooLong {
56        what: &'static str,
57        max: usize,
58        actual: usize,
59    },
60
61    #[error("{what} contains a character that is not allowed here: {found:?}")]
62    IllegalCharacter { what: &'static str, found: char },
63
64    #[error("{what} must not start or end with {edge:?}")]
65    IllegalEdge { what: &'static str, edge: char },
66
67    #[error(
68        "a repository target must be written as OWNER/REPO; got {got:?} with {slashes} separator(s)"
69    )]
70    MalformedOwnerRepo { got: String, slashes: usize },
71
72    #[error("{what} must be at least {min}, got {actual}")]
73    BelowFloor {
74        what: &'static str,
75        min: u16,
76        actual: u16,
77    },
78
79    #[error("a non-empty collection of {what} was required, but none were supplied")]
80    NonEmptyRequired { what: &'static str },
81
82    #[error("{what} is not a recognised value: {got:?}")]
83    Unrecognised { what: &'static str, got: String },
84}
85
86// ---------------------------------------------------------------------------
87// Clock
88// ---------------------------------------------------------------------------
89
90/// The domain's only source of "now".
91///
92/// `b1`'s Definition of Done requires that recovery decisions be testable with
93/// no real time dependency, so every function in this crate that compares
94/// timestamps takes one of these rather than calling the system clock.
95pub trait Clock: fmt::Debug + Send + Sync {
96    fn now(&self) -> Timestamp;
97}
98
99/// The production adapter.
100///
101/// It is an adapter and nothing more: **no decision function in this crate
102/// constructs one**. Every such function takes `&dyn Clock`, which is what makes
103/// `FakeClock` a complete substitute in tests. It lives here rather than in
104/// `crates/platform` because the port lives here and `platform` depends on
105/// `domain`, not the other way round.
106#[derive(Debug, Clone, Copy, Default)]
107pub struct SystemClock;
108
109impl Clock for SystemClock {
110    fn now(&self) -> Timestamp {
111        chrono::Utc::now()
112    }
113}
114
115impl<T: Clock + ?Sized> Clock for &T {
116    fn now(&self) -> Timestamp {
117        (**self).now()
118    }
119}
120
121impl<T: Clock + ?Sized> Clock for std::sync::Arc<T> {
122    fn now(&self) -> Timestamp {
123        (**self).now()
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Identifiers
129// ---------------------------------------------------------------------------
130
131macro_rules! uuid_newtype {
132    ($name:ident, $doc:literal) => {
133        #[doc = $doc]
134        #[derive(
135            Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
136        )]
137        #[serde(transparent)]
138        pub struct $name(Uuid);
139
140        impl $name {
141            /// A fresh random identifier.
142            #[must_use]
143            pub fn new_random() -> Self {
144                Self(Uuid::new_v4())
145            }
146
147            /// A deterministic identifier, for fixtures and tests.
148            #[must_use]
149            pub const fn from_u128(value: u128) -> Self {
150                Self(Uuid::from_u128(value))
151            }
152
153            #[must_use]
154            pub const fn from_uuid(value: Uuid) -> Self {
155                Self(value)
156            }
157
158            #[must_use]
159            pub const fn as_uuid(&self) -> &Uuid {
160                &self.0
161            }
162        }
163
164        impl fmt::Display for $name {
165            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166                fmt::Display::fmt(&self.0, f)
167            }
168        }
169    };
170}
171
172uuid_newtype!(HostId, "Identifies one physical machine running one agent.");
173uuid_newtype!(PolicyId, "Identifies one `ScalePolicy`.");
174uuid_newtype!(AttemptId, "Identifies one `RunnerAttempt`.");
175
176// ---------------------------------------------------------------------------
177// Host operating system and architecture
178// ---------------------------------------------------------------------------
179
180/// The host operating systems this product supports.
181///
182/// The supported-version matrix — Windows 10/11 and Server 2016/2019/2022,
183/// macOS 11.0+, and the nine listed Linux distributions — is `d1`'s to validate
184/// (`01-current-architecture.md`, "Authoritative external constraints"). The
185/// domain only needs the three families, because that is what a routing label
186/// encodes.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum Os {
190    Windows,
191    MacOs,
192    Linux,
193}
194
195impl Os {
196    pub const ALL: [Os; 3] = [Os::Windows, Os::MacOs, Os::Linux];
197
198    /// The token this OS contributes to a derived routing label.
199    ///
200    /// These are GitHub's own runner-package OS tokens (`win`, `osx`, `linux`),
201    /// which is what `e2` will select a download by, so a label and a package
202    /// name never disagree about what "this host" is. `02-target-architecture.md`
203    /// gives `rm-home-win-x64` as the worked example, which fixes `win`.
204    #[must_use]
205    pub const fn label_token(self) -> &'static str {
206        match self {
207            Os::Windows => "win",
208            Os::MacOs => "osx",
209            Os::Linux => "linux",
210        }
211    }
212
213    /// The token this OS contributes to its shared pool label
214    /// (`rm-pool-<token>`, coordinator design D10). Spelled out, unlike
215    /// [`Self::label_token`], because workflow authors write it by hand.
216    #[must_use]
217    pub const fn pool_token(self) -> &'static str {
218        match self {
219            Os::Windows => "windows",
220            Os::MacOs => "macos",
221            Os::Linux => "linux",
222        }
223    }
224
225    /// Container actions and service containers require Linux
226    /// (`01-current-architecture.md`, edge case 2). `f2` surfaces this on a
227    /// macOS or Windows policy.
228    #[must_use]
229    pub const fn supports_container_actions(self) -> bool {
230        matches!(self, Os::Linux)
231    }
232}
233
234impl fmt::Display for Os {
235    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236        f.write_str(self.label_token())
237    }
238}
239
240impl FromStr for Os {
241    type Err = ValidationError;
242
243    fn from_str(s: &str) -> Result<Self, Self::Err> {
244        match s.trim().to_ascii_lowercase().as_str() {
245            "win" | "windows" => Ok(Os::Windows),
246            "osx" | "macos" | "mac" | "darwin" => Ok(Os::MacOs),
247            "linux" => Ok(Os::Linux),
248            other => Err(ValidationError::Unrecognised {
249                what: "host operating system",
250                got: other.to_string(),
251            }),
252        }
253    }
254}
255
256/// The host architectures this product supports.
257///
258/// ARM64 is public preview on all three operating systems and ARM32 is Linux
259/// only (`01-current-architecture.md`). Enforcing that pairing is `d1`'s job;
260/// this enum only has to be able to name the values.
261#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum Arch {
264    X64,
265    Arm64,
266    Arm32,
267}
268
269impl Arch {
270    pub const ALL: [Arch; 3] = [Arch::X64, Arch::Arm64, Arch::Arm32];
271
272    /// The token this architecture contributes to a derived routing label.
273    /// GitHub's runner-package architecture tokens; `x64` is fixed by the
274    /// `rm-home-win-x64` example in `02-target-architecture.md`.
275    #[must_use]
276    pub const fn label_token(self) -> &'static str {
277        match self {
278            Arch::X64 => "x64",
279            Arch::Arm64 => "arm64",
280            Arch::Arm32 => "arm",
281        }
282    }
283
284    /// ARM64 is public preview, which `f2` must warn about rather than reject.
285    #[must_use]
286    pub const fn is_public_preview(self) -> bool {
287        matches!(self, Arch::Arm64)
288    }
289}
290
291impl fmt::Display for Arch {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        f.write_str(self.label_token())
294    }
295}
296
297impl FromStr for Arch {
298    type Err = ValidationError;
299
300    fn from_str(s: &str) -> Result<Self, Self::Err> {
301        match s.trim().to_ascii_lowercase().as_str() {
302            "x64" | "x86_64" | "amd64" => Ok(Arch::X64),
303            "arm64" | "aarch64" => Ok(Arch::Arm64),
304            "arm" | "arm32" | "armv7" => Ok(Arch::Arm32),
305            other => Err(ValidationError::Unrecognised {
306                what: "host architecture",
307                got: other.to_string(),
308            }),
309        }
310    }
311}
312
313// ---------------------------------------------------------------------------
314// Service start mode and cache policy
315// ---------------------------------------------------------------------------
316
317/// When the installed service starts (`05-infrastructure.md`, "Service
318/// behavior"). D13 makes `Boot` the default; `Login` is available for operators
319/// who prefer a user-scoped secret store.
320#[derive(
321    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
322)]
323#[serde(rename_all = "snake_case")]
324pub enum StartMode {
325    #[default]
326    Boot,
327    Login,
328}
329
330impl fmt::Display for StartMode {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        f.write_str(match self {
333            StartMode::Boot => "boot",
334            StartMode::Login => "login",
335        })
336    }
337}
338
339impl FromStr for StartMode {
340    type Err = ValidationError;
341
342    fn from_str(s: &str) -> Result<Self, Self::Err> {
343        match s.trim().to_ascii_lowercase().as_str() {
344            "boot" => Ok(StartMode::Boot),
345            "login" => Ok(StartMode::Login),
346            other => Err(ValidationError::Unrecognised {
347                what: "service start mode",
348                got: other.to_string(),
349            }),
350        }
351    }
352}
353
354/// What survives an attempt's cleanup.
355///
356/// `04-subsystem-contracts.md`, precedence rule 6: "Runtime cache retention is
357/// optional; job workspace retention is **always disabled** in v1."
358///
359/// The first half is the choice this enum offers. The second half is enforced by
360/// having no representation at all: [`CachePolicy::retains_job_workspace`] is a
361/// `const fn` returning `false`, and there is no variant, field, or constructor
362/// that could make it return anything else. A future version that wants
363/// workspace retention has to add one deliberately, which is the point — a
364/// retained workspace is the two-job contamination path that `e3` and
365/// `07-security.md` test against.
366#[derive(
367    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
368)]
369#[serde(rename_all = "snake_case")]
370pub enum CachePolicy {
371    /// Keep the verified runner package cache between attempts (the default;
372    /// re-downloading it on every cold start would be the main avoidable cost).
373    #[default]
374    RetainRunnerPackage,
375    /// Discard the runner package cache after each attempt.
376    DiscardRunnerPackage,
377}
378
379impl CachePolicy {
380    #[must_use]
381    pub const fn retains_runner_package(self) -> bool {
382        matches!(self, CachePolicy::RetainRunnerPackage)
383    }
384
385    /// Always `false`, by construction rather than by policy.
386    ///
387    /// D4 is the deliberate addition the type comment anticipated, and it was
388    /// made **on another type**: job-workspace retention is
389    /// [`crate::workspace::WorkspacePolicy`], repository-scoped, opt-in, and
390    /// carrying its own root. Nothing was added here, so a caller reading a
391    /// `CachePolicy` still cannot conclude anything about the job workspace —
392    /// which is the point of keeping them apart
393    /// (`02-target-architecture.md`, "Repository policy").
394    #[must_use]
395    pub const fn retains_job_workspace(self) -> bool {
396        false
397    }
398}
399
400// ---------------------------------------------------------------------------
401// Refresh interval
402// ---------------------------------------------------------------------------
403
404/// The agent's per-target refresh interval.
405///
406/// `04-subsystem-contracts.md`, "Refresh and backpressure": "a bounded interval,
407/// default 60 seconds with a hard floor of 30 seconds per target". The floor is
408/// a rate-budget constraint, not a preference — below it, one target's demand,
409/// inventory, and workflow-count polling alone consumes roughly a tenth of the
410/// hourly ceiling. Making it a type means a caller cannot write `5` into
411/// `Host.refresh_interval_secs` at all.
412#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
413#[serde(try_from = "u16", into = "u16")]
414pub struct RefreshInterval(u16);
415
416impl RefreshInterval {
417    pub const MIN_SECS: u16 = 30;
418    pub const DEFAULT_SECS: u16 = 60;
419
420    /// # Errors
421    /// [`ValidationError::BelowFloor`] if `secs` is under the documented
422    /// 30-second floor.
423    pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
424        if secs < Self::MIN_SECS {
425            return Err(ValidationError::BelowFloor {
426                what: "refresh interval (seconds)",
427                min: Self::MIN_SECS,
428                actual: secs,
429            });
430        }
431        Ok(Self(secs))
432    }
433
434    #[must_use]
435    pub const fn as_secs(self) -> u16 {
436        self.0
437    }
438}
439
440impl Default for RefreshInterval {
441    fn default() -> Self {
442        Self(Self::DEFAULT_SECS)
443    }
444}
445
446impl TryFrom<u16> for RefreshInterval {
447    type Error = ValidationError;
448
449    fn try_from(value: u16) -> Result<Self, Self::Error> {
450        Self::from_secs(value)
451    }
452}
453
454impl From<RefreshInterval> for u16 {
455    fn from(value: RefreshInterval) -> Self {
456        value.0
457    }
458}
459
460impl fmt::Display for RefreshInterval {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        write!(f, "{}s", self.0)
463    }
464}
465
466/// How often a target with **nothing happening** is polled.
467///
468/// [`RefreshInterval`] is the *active* interval and keeps its 30-second floor,
469/// because an active target's listings change and every changed listing is a
470/// full, charged response. An idle target's listings do not change, and its
471/// polls are conditional requests that GitHub answers `304 Not Modified`,
472/// which "does not count against your primary rate limit"
473/// (<https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#use-conditional-requests-if-appropriate>).
474/// So the idle interval may be much shorter, and that is where it pays: work
475/// usually arrives after idle, and every second of idle interval is a second a
476/// queued job waits before a runner is started for it.
477///
478/// The floor is not the primary limit any more but the secondary one: every
479/// request, a `304` included, is assumed to cost one of the 900 points a
480/// minute GitHub allows per user. Five seconds keeps one host polling ten
481/// idle repositories (two listings each) at 240 requests a minute, under a
482/// third of that.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
484#[serde(try_from = "u16", into = "u16")]
485pub struct IdlePollInterval(u16);
486
487impl IdlePollInterval {
488    pub const MIN_SECS: u16 = 5;
489    pub const DEFAULT_SECS: u16 = 10;
490
491    /// # Errors
492    /// [`ValidationError::BelowFloor`] under [`Self::MIN_SECS`].
493    pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
494        if secs < Self::MIN_SECS {
495            return Err(ValidationError::BelowFloor {
496                what: "idle poll interval (seconds)",
497                min: Self::MIN_SECS,
498                actual: secs,
499            });
500        }
501        Ok(Self(secs))
502    }
503
504    #[must_use]
505    pub const fn as_secs(self) -> u16 {
506        self.0
507    }
508
509    #[must_use]
510    pub const fn as_duration(self) -> std::time::Duration {
511        std::time::Duration::from_secs(self.0 as u64)
512    }
513}
514
515impl Default for IdlePollInterval {
516    fn default() -> Self {
517        Self(Self::DEFAULT_SECS)
518    }
519}
520
521impl TryFrom<u16> for IdlePollInterval {
522    type Error = ValidationError;
523
524    fn try_from(value: u16) -> Result<Self, Self::Error> {
525        Self::from_secs(value)
526    }
527}
528
529impl From<IdlePollInterval> for u16 {
530    fn from(value: IdlePollInterval) -> Self {
531        value.0
532    }
533}
534
535impl fmt::Display for IdlePollInterval {
536    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537        write!(f, "{}s", self.0)
538    }
539}
540
541/// How often a target is polled while a coordinator's reconciliation sweep
542/// covers it (`host set-poll-interval --coordinated`).
543///
544/// The coordinator then polls the target's queue itself and wakes this host
545/// for any queued job, so this host's own poll is a safety net. The floor is
546/// a minute: a covered target polled more often than an uncovered active
547/// one would defeat the point.
548#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
549#[serde(try_from = "u16", into = "u16")]
550pub struct CoordinatedPollInterval(u16);
551
552impl CoordinatedPollInterval {
553    pub const MIN_SECS: u16 = 60;
554    pub const DEFAULT_SECS: u16 = 300;
555
556    /// # Errors
557    /// [`ValidationError::BelowFloor`] under [`Self::MIN_SECS`].
558    pub fn from_secs(secs: u16) -> Result<Self, ValidationError> {
559        if secs < Self::MIN_SECS {
560            return Err(ValidationError::BelowFloor {
561                what: "coordinated poll interval (seconds)",
562                min: Self::MIN_SECS,
563                actual: secs,
564            });
565        }
566        Ok(Self(secs))
567    }
568
569    #[must_use]
570    pub const fn as_secs(self) -> u16 {
571        self.0
572    }
573
574    #[must_use]
575    pub const fn as_duration(self) -> std::time::Duration {
576        std::time::Duration::from_secs(self.0 as u64)
577    }
578}
579
580impl Default for CoordinatedPollInterval {
581    fn default() -> Self {
582        Self(Self::DEFAULT_SECS)
583    }
584}
585
586impl TryFrom<u16> for CoordinatedPollInterval {
587    type Error = ValidationError;
588
589    fn try_from(value: u16) -> Result<Self, Self::Error> {
590        Self::from_secs(value)
591    }
592}
593
594impl From<CoordinatedPollInterval> for u16 {
595    fn from(value: CoordinatedPollInterval) -> Self {
596        value.0
597    }
598}
599
600impl fmt::Display for CoordinatedPollInterval {
601    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
602        write!(f, "{}s", self.0)
603    }
604}
605
606// ---------------------------------------------------------------------------
607// Labels
608// ---------------------------------------------------------------------------
609
610/// One GitHub runner label, normalised.
611///
612/// **Normalisation is lower-casing, and it is not cosmetic.** The `v1` spike
613/// registered a runner with `["rm-d18-spike","Windows","X64","self-hosted-rm"]`
614/// and GitHub returned `windows` and `x64`
615/// (`docs/spikes/d18-org-jit-verification.md`, Point 3, finding 3). A label read
616/// back from the API therefore never matches a mixed-case label held locally
617/// unless both sides are folded first. Folding on construction makes every
618/// comparison in this crate — `Eq`, `Ord`, `Hash`, set membership — case
619/// insensitive for free, so no call site can forget.
620///
621/// **The fold is ASCII, matching [`HostLabel`] and the target [`Name`] types.**
622/// The only case folding this crate has evidence for is GitHub's, and the only
623/// evidence is the spike above, which is entirely ASCII. Unicode
624/// `to_lowercase()` would also make folding length-changing — `İ` (U+0130)
625/// lowercases to two chars — which is how a 256-character label could exceed
626/// [`Self::MAX_LEN`] *after* construction. ASCII folding is length-preserving,
627/// so that class of bug cannot occur, and the length check below is applied to
628/// the folded value regardless so the stored string is what was measured.
629///
630/// **What the character rules are for.** They are round-trippability rules, not
631/// injection defences, and reading them as the latter is how a rule that
632/// defends nothing gets added. A `Label` is never interpolated into a shell: it
633/// travels as one element of the runner's comma-separated `--labels` argument
634/// and as a JSON string. So exactly two characters break the round trip — the
635/// comma, which is the separator itself, and control characters, which break
636/// both the argument and the JSON framing. Nothing else does, and a quote rule
637/// in particular is neither necessary (no shell is involved) nor sufficient
638/// (`<`, `>`, `$`, `` ` ``, `;`, `|`, `&` and whitespace would all still pass).
639///
640/// An explicit allow-list was considered and rejected: real GitHub labels
641/// include `c#`, `.net`, `x86_64` and similar, so any allow-list narrow enough
642/// to be worth having would reject legitimate `runs-on` values and turn a
643/// cosmetic concern into a demand-matching failure in
644/// [`crate::policy::RunsOn::required_labels`].
645#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
646#[serde(try_from = "String", into = "String")]
647pub struct Label(String);
648
649impl Label {
650    /// GitHub's documented maximum label length.
651    pub const MAX_LEN: usize = 256;
652
653    /// # Errors
654    /// Empty, over-long, comma-bearing, or control-character-bearing input. A
655    /// comma is rejected because it separates labels in the runner's own
656    /// configuration, so a label containing one is not round-trippable; control
657    /// characters break the same argument and the JSON encoding around it. See
658    /// the type documentation for why the list stops there.
659    pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
660        let trimmed = raw.as_ref().trim();
661        if trimmed.is_empty() {
662            return Err(ValidationError::Empty { what: "a label" });
663        }
664        if let Some(bad) = trimmed.chars().find(|c| *c == ',' || c.is_control()) {
665            return Err(ValidationError::IllegalCharacter {
666                what: "a label",
667                found: bad,
668            });
669        }
670        // Folded first, then measured: the length that matters is the length of
671        // the value this type will actually hold and hand to GitHub.
672        let folded = trimmed.to_ascii_lowercase();
673        if folded.chars().count() > Self::MAX_LEN {
674            return Err(ValidationError::TooLong {
675                what: "a label",
676                max: Self::MAX_LEN,
677                actual: folded.chars().count(),
678            });
679        }
680        Ok(Self(folded))
681    }
682
683    #[must_use]
684    pub fn as_str(&self) -> &str {
685        &self.0
686    }
687}
688
689impl fmt::Display for Label {
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        f.write_str(&self.0)
692    }
693}
694
695impl TryFrom<String> for Label {
696    type Error = ValidationError;
697
698    fn try_from(value: String) -> Result<Self, Self::Error> {
699        Self::new(value)
700    }
701}
702
703impl TryFrom<&str> for Label {
704    type Error = ValidationError;
705
706    fn try_from(value: &str) -> Result<Self, Self::Error> {
707        Self::new(value)
708    }
709}
710
711impl From<Label> for String {
712    fn from(value: Label) -> Self {
713        value.0
714    }
715}
716
717impl FromStr for Label {
718    type Err = ValidationError;
719
720    fn from_str(s: &str) -> Result<Self, Self::Err> {
721        Self::new(s)
722    }
723}
724
725/// The operator's `--host-label` value: the human-chosen half of a routing
726/// label.
727///
728/// Narrower than a [`Label`] on purpose. This value is concatenated into
729/// `rm-<host>-<os>-<arch>`, so a host label containing a space or an upper-case
730/// letter would produce a routing label an operator could not retype into
731/// `runs-on` from memory.
732#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
733#[serde(try_from = "String", into = "String")]
734pub struct HostLabel(String);
735
736impl HostLabel {
737    pub const MAX_LEN: usize = 64;
738
739    /// # Errors
740    /// Empty, over-long, or containing anything but ASCII alphanumerics,
741    /// `-`, and `_`; or starting or ending with `-`.
742    pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
743        let trimmed = raw.as_ref().trim();
744        if trimmed.is_empty() {
745            return Err(ValidationError::Empty {
746                what: "a host label",
747            });
748        }
749        if trimmed.len() > Self::MAX_LEN {
750            return Err(ValidationError::TooLong {
751                what: "a host label",
752                max: Self::MAX_LEN,
753                actual: trimmed.len(),
754            });
755        }
756        if let Some(bad) = trimmed
757            .chars()
758            .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
759        {
760            return Err(ValidationError::IllegalCharacter {
761                what: "a host label",
762                found: bad,
763            });
764        }
765        if trimmed.starts_with('-') || trimmed.ends_with('-') {
766            return Err(ValidationError::IllegalEdge {
767                what: "a host label",
768                edge: '-',
769            });
770        }
771        Ok(Self(trimmed.to_ascii_lowercase()))
772    }
773
774    #[must_use]
775    pub fn as_str(&self) -> &str {
776        &self.0
777    }
778}
779
780impl fmt::Display for HostLabel {
781    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
782        f.write_str(&self.0)
783    }
784}
785
786impl TryFrom<String> for HostLabel {
787    type Error = ValidationError;
788
789    fn try_from(value: String) -> Result<Self, Self::Error> {
790        Self::new(value)
791    }
792}
793
794impl From<HostLabel> for String {
795    fn from(value: HostLabel) -> Self {
796        value.0
797    }
798}
799
800impl FromStr for HostLabel {
801    type Err = ValidationError;
802
803    fn from_str(s: &str) -> Result<Self, Self::Err> {
804        Self::new(s)
805    }
806}
807
808/// A case-insensitive profile identity. The narrow alphabet also makes it safe
809/// to use as the final segment of a derived GitHub runner label.
810#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
811#[serde(try_from = "String", into = "String")]
812pub struct ProfileName(String);
813
814impl ProfileName {
815    pub const MAX_LEN: usize = 64;
816
817    /// # Errors
818    /// Empty, over-long, or non-ASCII-alphanumeric/`-`/`_` input.
819    pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
820        let trimmed = raw.as_ref().trim();
821        if trimmed.is_empty() {
822            return Err(ValidationError::Empty {
823                what: "a profile name",
824            });
825        }
826        if trimmed.len() > Self::MAX_LEN {
827            return Err(ValidationError::TooLong {
828                what: "a profile name",
829                max: Self::MAX_LEN,
830                actual: trimmed.len(),
831            });
832        }
833        if let Some(found) = trimmed
834            .chars()
835            .find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
836        {
837            return Err(ValidationError::IllegalCharacter {
838                what: "a profile name",
839                found,
840            });
841        }
842        if trimmed.starts_with('-') || trimmed.ends_with('-') {
843            return Err(ValidationError::IllegalEdge {
844                what: "a profile name",
845                edge: '-',
846            });
847        }
848        Ok(Self(trimmed.to_ascii_lowercase()))
849    }
850
851    #[must_use]
852    pub fn as_str(&self) -> &str {
853        &self.0
854    }
855
856    #[must_use]
857    pub fn default_profile() -> Self {
858        Self("default".to_string())
859    }
860
861    #[must_use]
862    pub fn is_default(&self) -> bool {
863        self.0 == "default"
864    }
865}
866
867impl fmt::Display for ProfileName {
868    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
869        f.write_str(&self.0)
870    }
871}
872
873impl TryFrom<String> for ProfileName {
874    type Error = ValidationError;
875    fn try_from(value: String) -> Result<Self, Self::Error> {
876        Self::new(value)
877    }
878}
879
880impl From<ProfileName> for String {
881    fn from(value: ProfileName) -> Self {
882        value.0
883    }
884}
885
886#[cfg(test)]
887mod profile_name_tests {
888    use super::*;
889
890    #[test]
891    fn names_are_case_insensitive_and_reject_selector_unsafe_input() {
892        assert_eq!(
893            ProfileName::new("Py-Isolated").unwrap(),
894            ProfileName::new("py-isolated").unwrap()
895        );
896        assert_eq!(ProfileName::default_profile().as_str(), "default");
897        assert!(matches!(
898            ProfileName::new(" "),
899            Err(ValidationError::Empty { .. })
900        ));
901        assert!(matches!(
902            ProfileName::new("py bad"),
903            Err(ValidationError::IllegalCharacter { .. })
904        ));
905        assert!(matches!(
906            ProfileName::new("-bad"),
907            Err(ValidationError::IllegalEdge { .. })
908        ));
909        assert!(matches!(
910            ProfileName::new("x".repeat(ProfileName::MAX_LEN + 1)),
911            Err(ValidationError::TooLong { .. })
912        ));
913    }
914}
915
916// ---------------------------------------------------------------------------
917// NonEmpty
918// ---------------------------------------------------------------------------
919
920/// A collection that cannot be empty.
921///
922/// `04-subsystem-contracts.md` types the routing token as
923/// `Option<NonEmpty<Label>>`, and the `v1` spike shows why the inner
924/// non-emptiness is load-bearing rather than tidy: `generate-jitconfig` with
925/// `labels: []` returns `422 Invalid property /labels: 1 item required`
926/// (`docs/spikes/d18-org-jit-verification.md`, Point 3). An empty label set is
927/// not a degenerate case to handle at the gateway; it is a value the domain
928/// should not be able to hand out.
929///
930/// [`crate::policy::RoutingLabels`] gives a *stronger* guarantee than this type
931/// and is what a policy actually stores; this type is the shape the contract
932/// document names, and [`crate::policy::RoutingLabels::to_non_empty`] produces
933/// it.
934#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
935pub struct NonEmpty<T> {
936    items: Vec<T>,
937}
938
939impl<T> NonEmpty<T> {
940    #[must_use]
941    pub fn of(first: T) -> Self {
942        Self { items: vec![first] }
943    }
944
945    /// # Errors
946    /// [`ValidationError::NonEmptyRequired`] when `items` is empty.
947    pub fn try_from_vec(items: Vec<T>, what: &'static str) -> Result<Self, ValidationError> {
948        if items.is_empty() {
949            return Err(ValidationError::NonEmptyRequired { what });
950        }
951        Ok(Self { items })
952    }
953
954    #[must_use]
955    pub fn first(&self) -> &T {
956        // Safe: no constructor produces an empty `items`.
957        &self.items[0]
958    }
959
960    /// The number of elements. Never zero, which is why this returns
961    /// [`NonZeroUsize`] rather than `usize` and is not called `len`.
962    #[must_use]
963    pub fn count(&self) -> NonZeroUsize {
964        NonZeroUsize::new(self.items.len()).expect("NonEmpty is never empty")
965    }
966
967    pub fn iter(&self) -> std::slice::Iter<'_, T> {
968        self.items.iter()
969    }
970
971    #[must_use]
972    pub fn as_slice(&self) -> &[T] {
973        &self.items
974    }
975
976    pub fn push(&mut self, item: T) {
977        self.items.push(item);
978    }
979
980    #[must_use]
981    pub fn into_vec(self) -> Vec<T> {
982        self.items
983    }
984
985    #[must_use]
986    pub fn contains(&self, needle: &T) -> bool
987    where
988        T: PartialEq,
989    {
990        self.items.contains(needle)
991    }
992}
993
994impl<'a, T> IntoIterator for &'a NonEmpty<T> {
995    type Item = &'a T;
996    type IntoIter = std::slice::Iter<'a, T>;
997
998    fn into_iter(self) -> Self::IntoIter {
999        self.items.iter()
1000    }
1001}
1002
1003impl<T> IntoIterator for NonEmpty<T> {
1004    type Item = T;
1005    type IntoIter = std::vec::IntoIter<T>;
1006
1007    fn into_iter(self) -> Self::IntoIter {
1008        self.items.into_iter()
1009    }
1010}
1011
1012// ---------------------------------------------------------------------------
1013// Targets
1014// ---------------------------------------------------------------------------
1015
1016/// A GitHub name compared the way GitHub compares it: without regard to case.
1017///
1018/// The original spelling is preserved for display — an operator who typed
1019/// `IvanMurzak/GitHub-Runner-Scaler-UI` should see it back — while `Eq`, `Ord`,
1020/// and `Hash` fold case, so `f2`'s duplicate-policy check cannot be defeated by
1021/// re-adding the same repository in different capitalisation.
1022#[derive(Debug, Clone)]
1023struct Name(String);
1024
1025impl Name {
1026    fn as_str(&self) -> &str {
1027        &self.0
1028    }
1029}
1030
1031impl PartialEq for Name {
1032    fn eq(&self, other: &Self) -> bool {
1033        self.0.eq_ignore_ascii_case(&other.0)
1034    }
1035}
1036
1037impl Eq for Name {}
1038
1039impl Ord for Name {
1040    fn cmp(&self, other: &Self) -> Ordering {
1041        self.0
1042            .bytes()
1043            .map(|b| b.to_ascii_lowercase())
1044            .cmp(other.0.bytes().map(|b| b.to_ascii_lowercase()))
1045    }
1046}
1047
1048impl PartialOrd for Name {
1049    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1050        Some(self.cmp(other))
1051    }
1052}
1053
1054impl Hash for Name {
1055    fn hash<H: Hasher>(&self, state: &mut H) {
1056        for byte in self.0.bytes() {
1057            state.write_u8(byte.to_ascii_lowercase());
1058        }
1059        state.write_u8(0xff);
1060    }
1061}
1062
1063impl fmt::Display for Name {
1064    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1065        f.write_str(&self.0)
1066    }
1067}
1068
1069fn validate_login(raw: &str, what: &'static str) -> Result<Name, ValidationError> {
1070    let trimmed = raw.trim();
1071    if trimmed.is_empty() {
1072        return Err(ValidationError::Empty { what });
1073    }
1074    if trimmed.len() > 39 {
1075        return Err(ValidationError::TooLong {
1076            what,
1077            max: 39,
1078            actual: trimmed.len(),
1079        });
1080    }
1081    if let Some(bad) = trimmed
1082        .chars()
1083        .find(|c| !(c.is_ascii_alphanumeric() || *c == '-'))
1084    {
1085        return Err(ValidationError::IllegalCharacter { what, found: bad });
1086    }
1087    if trimmed.starts_with('-') || trimmed.ends_with('-') {
1088        return Err(ValidationError::IllegalEdge { what, edge: '-' });
1089    }
1090    Ok(Name(trimmed.to_string()))
1091}
1092
1093fn validate_repo_name(raw: &str) -> Result<Name, ValidationError> {
1094    const WHAT: &str = "a repository name";
1095    let trimmed = raw.trim();
1096    if trimmed.is_empty() {
1097        return Err(ValidationError::Empty { what: WHAT });
1098    }
1099    if trimmed.len() > 100 {
1100        return Err(ValidationError::TooLong {
1101            what: WHAT,
1102            max: 100,
1103            actual: trimmed.len(),
1104        });
1105    }
1106    if trimmed == "." || trimmed == ".." {
1107        return Err(ValidationError::Unrecognised {
1108            what: WHAT,
1109            got: trimmed.to_string(),
1110        });
1111    }
1112    if let Some(bad) = trimmed
1113        .chars()
1114        .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')))
1115    {
1116        return Err(ValidationError::IllegalCharacter {
1117            what: WHAT,
1118            found: bad,
1119        });
1120    }
1121    Ok(Name(trimmed.to_string()))
1122}
1123
1124/// One repository, as `OWNER/REPO`.
1125#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1126#[serde(try_from = "String", into = "String")]
1127pub struct OwnerRepo {
1128    owner: Name,
1129    repo: Name,
1130}
1131
1132impl OwnerRepo {
1133    /// # Errors
1134    /// Either half failing GitHub's naming rules.
1135    pub fn new(owner: impl AsRef<str>, repo: impl AsRef<str>) -> Result<Self, ValidationError> {
1136        Ok(Self {
1137            owner: validate_login(owner.as_ref(), "a repository owner")?,
1138            repo: validate_repo_name(repo.as_ref())?,
1139        })
1140    }
1141
1142    /// # Errors
1143    /// Input that is not exactly one `owner/repo` pair.
1144    pub fn parse(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1145        let raw = raw.as_ref().trim();
1146        let slashes = raw.matches('/').count();
1147        if slashes != 1 {
1148            return Err(ValidationError::MalformedOwnerRepo {
1149                got: raw.to_string(),
1150                slashes,
1151            });
1152        }
1153        let (owner, repo) = raw.split_once('/').expect("exactly one separator");
1154        Self::new(owner, repo)
1155    }
1156
1157    #[must_use]
1158    pub fn owner(&self) -> &str {
1159        self.owner.as_str()
1160    }
1161
1162    #[must_use]
1163    pub fn repo(&self) -> &str {
1164        self.repo.as_str()
1165    }
1166}
1167
1168impl fmt::Display for OwnerRepo {
1169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1170        write!(f, "{}/{}", self.owner, self.repo)
1171    }
1172}
1173
1174impl TryFrom<String> for OwnerRepo {
1175    type Error = ValidationError;
1176
1177    fn try_from(value: String) -> Result<Self, Self::Error> {
1178        Self::parse(value)
1179    }
1180}
1181
1182impl From<OwnerRepo> for String {
1183    fn from(value: OwnerRepo) -> Self {
1184        value.to_string()
1185    }
1186}
1187
1188impl FromStr for OwnerRepo {
1189    type Err = ValidationError;
1190
1191    fn from_str(s: &str) -> Result<Self, Self::Err> {
1192        Self::parse(s)
1193    }
1194}
1195
1196/// One organization login.
1197#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1198#[serde(try_from = "String", into = "String")]
1199pub struct Org(Name);
1200
1201impl Org {
1202    /// # Errors
1203    /// Input failing GitHub's login rules.
1204    pub fn new(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1205        Ok(Self(validate_login(raw.as_ref(), "an organization login")?))
1206    }
1207
1208    #[must_use]
1209    pub fn as_str(&self) -> &str {
1210        self.0.as_str()
1211    }
1212}
1213
1214impl fmt::Display for Org {
1215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1216        fmt::Display::fmt(&self.0, f)
1217    }
1218}
1219
1220impl TryFrom<String> for Org {
1221    type Error = ValidationError;
1222
1223    fn try_from(value: String) -> Result<Self, Self::Error> {
1224        Self::new(value)
1225    }
1226}
1227
1228impl From<Org> for String {
1229    fn from(value: Org) -> Self {
1230        value.to_string()
1231    }
1232}
1233
1234impl FromStr for Org {
1235    type Err = ValidationError;
1236
1237    fn from_str(s: &str) -> Result<Self, Self::Err> {
1238        Self::new(s)
1239    }
1240}
1241
1242/// Which scope a target has. D18's *whole* difference lives in this enum.
1243#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1244#[serde(rename_all = "snake_case")]
1245pub enum TargetScope {
1246    Repository,
1247    Organization,
1248}
1249
1250/// What one policy scales for (D18).
1251///
1252/// `04-subsystem-contracts.md`: "The two differ only in which GitHub endpoints
1253/// and which App permission the gateway uses; ownership, capacity, and lifecycle
1254/// rules are identical."
1255///
1256/// That sentence is a design constraint on *this crate*, and it is why nothing
1257/// below branches on the variant: there is no `is_repository()` shortcut, no
1258/// per-variant capacity rule, and no endpoint string. Endpoints belong to `c3`
1259/// and `c4`; the only thing the domain exposes is [`ScaleTarget::scope`], so a
1260/// gateway can select one. `policy::tests::repository_and_organization_targets_
1261/// are_equivalent` runs one body over both variants to keep it that way.
1262#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1263#[serde(tag = "scope", content = "value", rename_all = "snake_case")]
1264pub enum ScaleTarget {
1265    Repository(OwnerRepo),
1266    Organization(Org),
1267}
1268
1269impl ScaleTarget {
1270    /// # Errors
1271    /// A malformed `OWNER/REPO`.
1272    pub fn repository(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1273        Ok(Self::Repository(OwnerRepo::parse(raw)?))
1274    }
1275
1276    /// # Errors
1277    /// A malformed organization login.
1278    pub fn organization(raw: impl AsRef<str>) -> Result<Self, ValidationError> {
1279        Ok(Self::Organization(Org::new(raw)?))
1280    }
1281
1282    #[must_use]
1283    pub const fn scope(&self) -> TargetScope {
1284        match self {
1285            ScaleTarget::Repository(_) => TargetScope::Repository,
1286            ScaleTarget::Organization(_) => TargetScope::Organization,
1287        }
1288    }
1289
1290    /// The target as an operator would type it: `owner/repo` or `org`.
1291    #[must_use]
1292    pub fn slug(&self) -> String {
1293        match self {
1294            ScaleTarget::Repository(r) => r.to_string(),
1295            ScaleTarget::Organization(o) => o.to_string(),
1296        }
1297    }
1298}
1299
1300impl fmt::Display for ScaleTarget {
1301    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1302        f.write_str(&self.slug())
1303    }
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Host
1308// ---------------------------------------------------------------------------
1309
1310/// One machine running one agent.
1311///
1312/// `host_capacity` is the ceiling on concurrent runner attempts across **every**
1313/// policy on this machine (D9). It is a [`NonZeroU16`] because a host that
1314/// declares zero capacity is not a configured host, it is a disabled one, and
1315/// the two should not be spelled the same way. [`crate::capacity::HostAllocator`]
1316/// is the only thing that spends it.
1317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1318pub struct Host {
1319    pub id: HostId,
1320    pub display_name: String,
1321    pub os: Os,
1322    pub architecture: Arch,
1323    pub host_capacity: NonZeroU16,
1324    pub service_start_mode: StartMode,
1325    pub refresh_interval: RefreshInterval,
1326    /// Where disposable runner attempts are created, when the operator has said
1327    /// (D2).
1328    ///
1329    /// `None` means "use the platform default", which is resolved at runtime by
1330    /// `b1` and shown as `platform-default` rather than baked into the row.
1331    /// `02-target-architecture.md`: "Storing only the override allows a future
1332    /// platform-default correction without rewriting every database" — a stored
1333    /// `C:\rman` would silently become the operator's explicit choice the day
1334    /// the default moves.
1335    ///
1336    /// This is **runner** placement and not application data: config, SQLite,
1337    /// logs, diagnostics and the verified package cache stay under `AppPaths`,
1338    /// and `--data-dir` continues to move those and only those (invariant 1).
1339    pub runner_root_override: Option<LocalAbsolutePath>,
1340    pub created_at: Timestamp,
1341}
1342
1343impl Host {
1344    /// # Errors
1345    /// An empty display name.
1346    pub fn new(
1347        id: HostId,
1348        display_name: impl AsRef<str>,
1349        os: Os,
1350        architecture: Arch,
1351        host_capacity: NonZeroU16,
1352        created_at: Timestamp,
1353    ) -> Result<Self, ValidationError> {
1354        let display_name = display_name.as_ref().trim();
1355        if display_name.is_empty() {
1356            return Err(ValidationError::Empty {
1357                what: "a host display name",
1358            });
1359        }
1360        Ok(Self {
1361            id,
1362            display_name: display_name.to_string(),
1363            os,
1364            architecture,
1365            host_capacity,
1366            service_start_mode: StartMode::default(),
1367            refresh_interval: RefreshInterval::default(),
1368            // D3: a newly registered host places attempts under the platform
1369            // default. Nothing but an explicit `host set-runtime-root` fills
1370            // this in.
1371            runner_root_override: None,
1372            created_at,
1373        })
1374    }
1375
1376    #[must_use]
1377    pub fn host_capacity(&self) -> u16 {
1378        self.host_capacity.get()
1379    }
1380
1381    /// Whether the runner root came from the operator rather than the platform.
1382    ///
1383    /// D11 requires CLI and TUI to show "the effective path \[and] configured
1384    /// source"; the effective path needs `b1`'s platform default, but which of
1385    /// the two sources produced it is decidable here, with no I/O.
1386    #[must_use]
1387    pub const fn has_configured_runner_root(&self) -> bool {
1388        self.runner_root_override.is_some()
1389    }
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use super::*;
1395
1396    fn ts(secs: i64) -> Timestamp {
1397        chrono::DateTime::from_timestamp(secs, 0).expect("valid timestamp")
1398    }
1399
1400    // -- labels -------------------------------------------------------------
1401
1402    #[test]
1403    fn a_label_is_folded_to_lower_case_because_github_stores_it_that_way() {
1404        // `docs/spikes/d18-org-jit-verification.md`, Point 3, finding 3:
1405        // "`Windows` and `X64` came back as `windows` and `x64`."
1406        assert_eq!(Label::new("Windows").unwrap().as_str(), "windows");
1407        assert_eq!(Label::new("X64").unwrap().as_str(), "x64");
1408        assert_eq!(
1409            Label::new("  RM-Home-Win-X64 ").unwrap().as_str(),
1410            "rm-home-win-x64"
1411        );
1412        assert_eq!(
1413            Label::new("Windows").unwrap(),
1414            Label::new("windows").unwrap()
1415        );
1416    }
1417
1418    #[test]
1419    fn label_case_folding_reaches_eq_ord_and_hash_together() {
1420        use std::collections::BTreeSet;
1421        let mut set = BTreeSet::new();
1422        set.insert(Label::new("Windows").unwrap());
1423        set.insert(Label::new("windows").unwrap());
1424        set.insert(Label::new("WINDOWS").unwrap());
1425        assert_eq!(
1426            set.len(),
1427            1,
1428            "three spellings of one GitHub label must collapse to one member, \
1429             or a routing-label set can silently hold duplicates"
1430        );
1431    }
1432
1433    #[test]
1434    fn an_unusable_label_cannot_be_constructed() {
1435        assert!(matches!(Label::new(""), Err(ValidationError::Empty { .. })));
1436        assert!(matches!(
1437            Label::new("   "),
1438            Err(ValidationError::Empty { .. })
1439        ));
1440        assert!(
1441            matches!(
1442                Label::new("a,b"),
1443                Err(ValidationError::IllegalCharacter { found: ',', .. })
1444            ),
1445            "a comma separates labels in the runner's own configuration, so a \
1446             label containing one does not round-trip"
1447        );
1448        assert!(matches!(
1449            Label::new("a\nb"),
1450            Err(ValidationError::IllegalCharacter { .. })
1451        ));
1452        assert!(matches!(
1453            Label::new("x".repeat(Label::MAX_LEN + 1)),
1454            Err(ValidationError::TooLong { .. })
1455        ));
1456        assert!(Label::new("x".repeat(Label::MAX_LEN)).is_ok());
1457    }
1458
1459    #[test]
1460    fn the_label_character_rules_are_about_round_tripping_not_injection() {
1461        // A quote is not rejected. It has no authority behind it: a Label is
1462        // never interpolated into a shell, so quoting cannot break anything, and
1463        // rejecting quotes while accepting every one of the characters below
1464        // would be a rule that defends nothing while looking like it defends
1465        // something.
1466        assert_eq!(Label::new(r#"say"hi"#).unwrap().as_str(), r#"say"hi"#);
1467        assert_eq!(Label::new("it's").unwrap().as_str(), "it's");
1468
1469        // The characters an injection rule would have to cover, all accepted --
1470        // this is the "neither necessary nor sufficient" half, pinned so that a
1471        // future quote-style rule has to confront it.
1472        for raw in ["a<b", "a>b", "a$b", "a`b", "a;b", "a|b", "a&b", "a b"] {
1473            assert!(
1474                Label::new(raw).is_ok(),
1475                "{raw:?} must construct: the rule set is round-trippability, not \
1476                 shell safety"
1477            );
1478        }
1479
1480        // Real GitHub labels an allow-list would have had to enumerate.
1481        for raw in ["c#", ".net", "x86_64", "ubuntu-22.04"] {
1482            assert!(Label::new(raw).is_ok(), "{raw:?} is a real GitHub label");
1483        }
1484    }
1485
1486    #[test]
1487    fn label_folding_is_ascii_and_the_length_is_measured_after_folding() {
1488        // Consistent with HostLabel and with the target Name type, both of which
1489        // fold with to_ascii_lowercase.
1490        assert_eq!(
1491            Label::new("RM-Home-Win-X64").unwrap().as_str(),
1492            "rm-home-win-x64"
1493        );
1494
1495        // U+0130 lowercases to two chars under Unicode folding. Under ASCII
1496        // folding it is left alone, so a MAX_LEN input stays MAX_LEN and cannot
1497        // exceed the ceiling after construction -- which is what the old
1498        // fold-after-measure order allowed.
1499        let mut raw = "x".repeat(Label::MAX_LEN - 1);
1500        raw.push('\u{0130}');
1501        let label = Label::new(&raw).expect("exactly MAX_LEN characters");
1502        assert_eq!(
1503            label.as_str().chars().count(),
1504            Label::MAX_LEN,
1505            "a constructed Label must never be longer than MAX_LEN"
1506        );
1507    }
1508
1509    #[test]
1510    fn a_host_label_is_narrower_than_a_label() {
1511        assert_eq!(HostLabel::new("Home-Win").unwrap().as_str(), "home-win");
1512        assert!(matches!(
1513            HostLabel::new("home win"),
1514            Err(ValidationError::IllegalCharacter { found: ' ', .. })
1515        ));
1516        assert!(matches!(
1517            HostLabel::new("-home"),
1518            Err(ValidationError::IllegalEdge { edge: '-', .. })
1519        ));
1520        assert!(matches!(
1521            HostLabel::new("home-"),
1522            Err(ValidationError::IllegalEdge { edge: '-', .. })
1523        ));
1524        assert!(matches!(
1525            HostLabel::new(""),
1526            Err(ValidationError::Empty { .. })
1527        ));
1528        assert!(HostLabel::new("home_win2").is_ok());
1529    }
1530
1531    // -- NonEmpty -----------------------------------------------------------
1532
1533    #[test]
1534    fn non_empty_rejects_an_empty_vec_and_reports_count_as_non_zero() {
1535        assert!(matches!(
1536            NonEmpty::<Label>::try_from_vec(Vec::new(), "labels"),
1537            Err(ValidationError::NonEmptyRequired { what: "labels" })
1538        ));
1539
1540        let one = NonEmpty::of(Label::new("a").unwrap());
1541        assert_eq!(one.count().get(), 1);
1542        assert_eq!(one.first().as_str(), "a");
1543
1544        let mut two = one;
1545        two.push(Label::new("b").unwrap());
1546        assert_eq!(two.count().get(), 2);
1547        assert!(two.contains(&Label::new("B").unwrap()));
1548    }
1549
1550    // -- refresh interval ---------------------------------------------------
1551
1552    #[test]
1553    fn the_idle_interval_defaults_to_ten_seconds_with_a_five_second_floor() {
1554        assert_eq!(IdlePollInterval::default().as_secs(), 10);
1555        assert_eq!(
1556            IdlePollInterval::from_secs(5).unwrap().as_duration(),
1557            std::time::Duration::from_secs(5)
1558        );
1559        assert!(matches!(
1560            IdlePollInterval::from_secs(4),
1561            Err(ValidationError::BelowFloor {
1562                min: 5,
1563                actual: 4,
1564                ..
1565            })
1566        ));
1567        assert!(serde_json::from_str::<IdlePollInterval>("4").is_err());
1568    }
1569
1570    #[test]
1571    fn the_refresh_interval_floor_is_unrepresentable_rather_than_validated() {
1572        // `04-subsystem-contracts.md`: "default 60 seconds with a hard floor of
1573        // 30 seconds per target".
1574        assert_eq!(RefreshInterval::default().as_secs(), 60);
1575        assert_eq!(RefreshInterval::from_secs(30).unwrap().as_secs(), 30);
1576        assert!(matches!(
1577            RefreshInterval::from_secs(29),
1578            Err(ValidationError::BelowFloor {
1579                min: 30,
1580                actual: 29,
1581                ..
1582            })
1583        ));
1584        assert!(matches!(
1585            RefreshInterval::from_secs(0),
1586            Err(ValidationError::BelowFloor { .. })
1587        ));
1588        // Deserialisation goes through the same gate, so `b2` cannot load a
1589        // hand-edited row that polls every second.
1590        assert!(serde_json::from_str::<RefreshInterval>("29").is_err());
1591        assert_eq!(
1592            serde_json::from_str::<RefreshInterval>("45")
1593                .unwrap()
1594                .as_secs(),
1595            45
1596        );
1597    }
1598
1599    // -- cache policy -------------------------------------------------------
1600
1601    #[test]
1602    fn job_workspace_retention_has_no_representation_in_v1() {
1603        // `04-subsystem-contracts.md`, precedence rule 6.
1604        for policy in [
1605            CachePolicy::RetainRunnerPackage,
1606            CachePolicy::DiscardRunnerPackage,
1607        ] {
1608            assert!(
1609                !policy.retains_job_workspace(),
1610                "no CachePolicy value may retain a job workspace in v1; a \
1611                 retained workspace is the two-job contamination path"
1612            );
1613        }
1614        assert!(CachePolicy::default().retains_runner_package());
1615    }
1616
1617    // -- targets ------------------------------------------------------------
1618
1619    #[test]
1620    fn owner_repo_parsing_accepts_one_separator_and_nothing_else() {
1621        let ok = OwnerRepo::parse("IvanMurzak/GitHub-Runner-Scaler-UI").unwrap();
1622        assert_eq!(ok.owner(), "IvanMurzak");
1623        assert_eq!(ok.repo(), "GitHub-Runner-Scaler-UI");
1624        assert_eq!(ok.to_string(), "IvanMurzak/GitHub-Runner-Scaler-UI");
1625
1626        for bad in ["owner", "owner/repo/extra", "/repo", "owner/"] {
1627            assert!(
1628                OwnerRepo::parse(bad).is_err(),
1629                "{bad:?} must not parse as a repository target"
1630            );
1631        }
1632    }
1633
1634    #[test]
1635    fn github_names_compare_without_regard_to_case_but_display_as_typed() {
1636        use std::collections::HashSet;
1637
1638        let typed = OwnerRepo::parse("IvanMurzak/Repo").unwrap();
1639        let other = OwnerRepo::parse("ivanmurzak/repo").unwrap();
1640        assert_eq!(
1641            typed, other,
1642            "GitHub resolves these to one repository, so `f2`'s duplicate check \
1643             must too"
1644        );
1645        assert_eq!(
1646            typed.to_string(),
1647            "IvanMurzak/Repo",
1648            "the operator's spelling survives for display"
1649        );
1650
1651        let mut seen = HashSet::new();
1652        seen.insert(typed.clone());
1653        assert!(
1654            !seen.insert(other),
1655            "Hash must agree with Eq, or a HashSet-based duplicate check leaks"
1656        );
1657
1658        assert_eq!(
1659            Org::new("Tap-Top-Fun").unwrap(),
1660            Org::new("tap-top-fun").unwrap()
1661        );
1662    }
1663
1664    #[test]
1665    fn a_scale_target_exposes_its_scope_and_nothing_endpoint_shaped() {
1666        let repo = ScaleTarget::repository("o/r").unwrap();
1667        let org = ScaleTarget::organization("o").unwrap();
1668        assert_eq!(repo.scope(), TargetScope::Repository);
1669        assert_eq!(org.scope(), TargetScope::Organization);
1670        assert_eq!(repo.slug(), "o/r");
1671        assert_eq!(org.slug(), "o");
1672    }
1673
1674    #[test]
1675    fn a_scale_target_round_trips_through_serde_at_both_scopes() {
1676        for target in [
1677            ScaleTarget::repository("owner/repo").unwrap(),
1678            ScaleTarget::organization("org").unwrap(),
1679        ] {
1680            let json = serde_json::to_string(&target).unwrap();
1681            let back: ScaleTarget = serde_json::from_str(&json).unwrap();
1682            assert_eq!(target, back, "{json} did not round-trip");
1683        }
1684    }
1685
1686    // -- os / arch ----------------------------------------------------------
1687
1688    #[test]
1689    fn os_and_arch_tokens_are_the_ones_the_worked_example_fixes() {
1690        // `02-target-architecture.md`: "encodes the product, host identity, and
1691        // host OS — for example `rm-home-win-x64`".
1692        assert_eq!(Os::Windows.label_token(), "win");
1693        assert_eq!(Arch::X64.label_token(), "x64");
1694        assert_eq!(Os::MacOs.label_token(), "osx");
1695        assert_eq!(Os::Linux.label_token(), "linux");
1696        assert_eq!(Arch::Arm64.label_token(), "arm64");
1697        assert_eq!(Arch::Arm32.label_token(), "arm");
1698
1699        for os in Os::ALL {
1700            assert_eq!(os.label_token().parse::<Os>().unwrap(), os);
1701        }
1702        for arch in Arch::ALL {
1703            assert_eq!(arch.label_token().parse::<Arch>().unwrap(), arch);
1704        }
1705        assert!("plan9".parse::<Os>().is_err());
1706        assert!("riscv".parse::<Arch>().is_err());
1707    }
1708
1709    #[test]
1710    fn only_linux_supports_container_actions_and_only_arm64_is_preview() {
1711        // `01-current-architecture.md`, edge case 2 and the architecture row.
1712        assert!(Os::Linux.supports_container_actions());
1713        assert!(!Os::Windows.supports_container_actions());
1714        assert!(!Os::MacOs.supports_container_actions());
1715
1716        assert!(Arch::Arm64.is_public_preview());
1717        assert!(!Arch::X64.is_public_preview());
1718    }
1719
1720    // -- host ---------------------------------------------------------------
1721
1722    #[test]
1723    fn a_host_cannot_be_built_with_zero_capacity_or_a_blank_name() {
1724        assert!(
1725            NonZeroU16::new(0).is_none(),
1726            "zero host capacity is unrepresentable"
1727        );
1728        assert!(matches!(
1729            Host::new(
1730                HostId::from_u128(1),
1731                "  ",
1732                Os::Windows,
1733                Arch::X64,
1734                NonZeroU16::new(2).unwrap(),
1735                ts(0),
1736            ),
1737            Err(ValidationError::Empty { .. })
1738        ));
1739
1740        let host = Host::new(
1741            HostId::from_u128(1),
1742            " home-pc ",
1743            Os::Windows,
1744            Arch::X64,
1745            NonZeroU16::new(2).unwrap(),
1746            ts(0),
1747        )
1748        .unwrap();
1749        assert_eq!(host.display_name, "home-pc");
1750        assert_eq!(host.host_capacity(), 2);
1751        assert_eq!(host.service_start_mode, StartMode::Boot);
1752        assert_eq!(host.refresh_interval.as_secs(), 60);
1753    }
1754
1755    // -- runner root --------------------------------------------------------
1756
1757    fn a_host() -> Host {
1758        Host::new(
1759            HostId::from_u128(1),
1760            "home-pc",
1761            Os::Windows,
1762            Arch::X64,
1763            NonZeroU16::new(2).unwrap(),
1764            ts(0),
1765        )
1766        .expect("a valid host")
1767    }
1768
1769    #[test]
1770    fn a_new_host_uses_the_platform_default_runner_root() {
1771        // D3: nothing but an explicit `host set-runtime-root` configures one, so
1772        // a host registered by this build behaves exactly as it did before the
1773        // setting existed.
1774        let host = a_host();
1775        assert_eq!(host.runner_root_override, None);
1776        assert!(!host.has_configured_runner_root());
1777    }
1778
1779    #[test]
1780    fn a_configured_runner_root_round_trips_through_serde() {
1781        let root = LocalAbsolutePath::new(if cfg!(windows) {
1782            "C:\\rman"
1783        } else {
1784            "/srv/rman"
1785        })
1786        .expect("a valid native root");
1787        let mut host = a_host();
1788        host.runner_root_override = Some(root.clone());
1789        assert!(host.has_configured_runner_root());
1790
1791        let encoded = serde_json::to_string(&host).expect("serialisable");
1792        let decoded: Host = serde_json::from_str(&encoded).expect("deserialisable");
1793        assert_eq!(decoded, host);
1794        assert_eq!(decoded.runner_root_override, Some(root));
1795        // Placement is not authentication: the host record carries no credential
1796        // before this change and must carry none after it.
1797        for needle in ["token", "secret", "password"] {
1798            assert!(
1799                !encoded.to_ascii_lowercase().contains(needle),
1800                "the host record leaked {needle:?}: {encoded}"
1801            );
1802        }
1803    }
1804
1805    #[test]
1806    fn a_host_row_carrying_an_illegal_runner_root_fails_closed() {
1807        // The stored shape is re-validated by `LocalAbsolutePath`'s own
1808        // deserializer, so a hand-edited network share never becomes a runner
1809        // root (D10).
1810        let host = a_host();
1811        let encoded = serde_json::to_string(&host).expect("serialisable");
1812        let corrupted = encoded.replace(
1813            "\"runner_root_override\":null",
1814            "\"runner_root_override\":\"rman\"",
1815        );
1816        assert_ne!(corrupted, encoded, "the fixture must actually be corrupted");
1817        assert!(serde_json::from_str::<Host>(&corrupted).is_err());
1818    }
1819
1820    #[test]
1821    fn a_fake_clock_is_a_complete_substitute_for_the_system_clock() {
1822        // Proves the port is object safe and that nothing here needs the real
1823        // one; `crate::attempt` is where it actually matters.
1824        #[derive(Debug)]
1825        struct Fixed(Timestamp);
1826        impl Clock for Fixed {
1827            fn now(&self) -> Timestamp {
1828                self.0
1829            }
1830        }
1831        let clock: &dyn Clock = &Fixed(ts(1_700_000_000));
1832        assert_eq!(clock.now(), ts(1_700_000_000));
1833    }
1834}