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