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