Skip to main content

rch_common/
capability_probe.rs

1//! Exact user/path capability probes for runtimes, toolchains, and targets
2//! (bd-session-history-remediation-ocv9i.12.2).
3//!
4//! A capability probe runs **as the configured remote user, using the exact
5//! executable paths RCH will actually invoke** (not whatever a login shell's
6//! PATH happens to resolve), so the facts reflect what an offloaded build will
7//! really see. The probe is a single shell script that prints `RCH_FACT k=v`
8//! lines; [`parse_capability_probe`] turns that output into [`ProbedFacts`],
9//! and [`assess_admissibility`] decides whether the worker is admissible for a
10//! given [`CapabilityRequirement`].
11//!
12//! Crucially this distinguishes **SSH reachability** (did the script run at
13//! all?) from **capability admissibility** (it ran, but lacks a needed target /
14//! the binary at the exact path is broken / the protocol is stale) — the two
15//! failure classes operators kept conflating.
16
17use crate::incident::IncidentReasonCode;
18use crate::worker_facts::{
19    DiskRootFacts, RuntimeFacts, RustFacts, WorkerBinaryFacts, derive_target_triple,
20};
21
22/// Sentinel prefix every probe fact line carries, so probe output is easy to
23/// separate from incidental stdout.
24pub const FACT_PREFIX: &str = "RCH_FACT ";
25
26/// Why an operator-declared tool probe was refused before it ever ran.
27#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum ToolProbeError {
29    /// The name cannot survive the fact wire format or is empty.
30    #[error("tool name {name:?} is invalid: {reason}")]
31    InvalidName {
32        /// The rejected name.
33        name: String,
34        /// What is wrong with it.
35        reason: &'static str,
36    },
37    /// A probe with no argv can never prove anything.
38    #[error("tool {name:?} declares an empty probe command")]
39    EmptyCommand {
40        /// The tool whose command was empty.
41        name: String,
42    },
43}
44
45/// An operator-declared named tool probe: a FIXED argv, written in worker
46/// configuration, run as the configured remote user.
47///
48/// The argv is never caller-supplied. RCH deliberately exposes no general
49/// remote-shell probe API: a build that could ask a worker to run an arbitrary
50/// command "to check for a tool" would be a remote execution primitive wearing
51/// a capability-probe hat. Declaring the argv in operator config keeps the set
52/// of commands a worker will run for a probe finite and reviewable.
53///
54/// Verification is by EXIT STATUS: zero means present, anything else — missing
55/// binary, error exit, signal — means absent. Output is discarded so a chatty
56/// tool cannot corrupt the fact stream.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct NamedToolProbe {
59    /// The name selection and `--require-tool` refer to.
60    name: String,
61    /// The exact argv to run.
62    command: Vec<String>,
63}
64
65impl NamedToolProbe {
66    /// Declare a probe, rejecting names that cannot round-trip the fact wire
67    /// format (`RCH_FACT tool=<name>`): a name containing `=`, whitespace or a
68    /// control character would be parsed back as a different name, which is a
69    /// silent capability lie rather than a loud config error.
70    ///
71    /// # Errors
72    /// [`ToolProbeError`] when the name is unusable or the command is empty.
73    pub fn new(
74        name: impl Into<String>,
75        command: impl IntoIterator<Item = impl Into<String>>,
76    ) -> Result<Self, ToolProbeError> {
77        let name = name.into();
78        let command: Vec<String> = command.into_iter().map(Into::into).collect();
79        let invalid = |reason| ToolProbeError::InvalidName {
80            name: name.clone(),
81            reason,
82        };
83        if name.is_empty() {
84            return Err(invalid("it is empty"));
85        }
86        if !name
87            .bytes()
88            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
89        {
90            return Err(invalid(
91                "only ASCII letters, digits and `-` `_` `.` `+` are allowed",
92            ));
93        }
94        if command.is_empty() {
95            return Err(ToolProbeError::EmptyCommand { name });
96        }
97        Ok(Self { name, command })
98    }
99
100    /// The declared name.
101    #[must_use]
102    pub fn name(&self) -> &str {
103        &self.name
104    }
105
106    /// The declared argv.
107    #[must_use]
108    pub fn command(&self) -> &[String] {
109        &self.command
110    }
111}
112
113impl TryFrom<&crate::types::WorkerToolProbe> for NamedToolProbe {
114    type Error = ToolProbeError;
115
116    /// Validate a `workers.toml` entry. This is the single point where a
117    /// malformed declaration is refused, so a bad name fails config load
118    /// loudly rather than becoming a worker that quietly advertises nothing.
119    fn try_from(entry: &crate::types::WorkerToolProbe) -> Result<Self, Self::Error> {
120        Self::new(entry.name.clone(), entry.command.iter().cloned())
121    }
122}
123
124/// Exact paths/identity the probe must use (never PATH-resolved).
125#[derive(Debug, Clone)]
126pub struct ProbeSpec {
127    /// SSH login user the probe runs as.
128    pub remote_user: String,
129    /// Exact path to the `rch-wkr` binary RCH will invoke.
130    pub rch_wkr_path: String,
131    /// Exact path to `cargo`, if known (else the probe tries `command -v`).
132    pub cargo_path: Option<String>,
133    /// Exact path to `rustup`, if known.
134    pub rustup_path: Option<String>,
135    /// Disk roots whose capacity to report (temp root, build roots, cargo home).
136    pub disk_roots: Vec<String>,
137    /// Operator-declared named tool probes for this worker.
138    pub tools: Vec<NamedToolProbe>,
139}
140
141impl ProbeSpec {
142    #[must_use]
143    pub fn new(remote_user: impl Into<String>, rch_wkr_path: impl Into<String>) -> Self {
144        Self {
145            remote_user: remote_user.into(),
146            rch_wkr_path: rch_wkr_path.into(),
147            cargo_path: None,
148            rustup_path: None,
149            disk_roots: Vec::new(),
150            tools: Vec::new(),
151        }
152    }
153
154    /// Declare the operator-configured tool probes (builder style).
155    #[must_use]
156    pub fn with_tools(mut self, tools: impl IntoIterator<Item = NamedToolProbe>) -> Self {
157        self.tools = tools.into_iter().collect();
158        self
159    }
160}
161
162/// Shell-quote a value for safe single-argument embedding.
163fn shq(s: &str) -> String {
164    format!("'{}'", s.replace('\'', "'\\''"))
165}
166
167/// The canonical ABSOLUTE path to the deployed `rch-wkr` binary for a remote
168/// user and host OS. Unix workers use `/root` or `/home/<user>`; Windows workers
169/// run under Git Bash and use `/c/Users/<user>` plus the native `.exe` suffix.
170/// The probe script shell-quotes this path, so it MUST be absolute: a literal
171/// `~/.local/bin/...` would be single-quoted and never expand, making `[ -x ... ]`
172/// fail and every worker look like it has a missing binary. Single source of
173/// truth for both the daemon bypass-recovery prober and the
174/// `rch self-test --smoke` capability scenario.
175#[must_use]
176pub fn remote_worker_binary_path(user: &str, declared_os: Option<&str>) -> String {
177    if declared_os.is_some_and(|os| os.eq_ignore_ascii_case("windows")) {
178        return format!("/c/Users/{user}/.local/bin/rch-wkr.exe");
179    }
180
181    let home = if user == "root" {
182        "/root".to_string()
183    } else {
184        format!("/home/{user}")
185    };
186    format!("{home}/.local/bin/rch-wkr")
187}
188
189/// Build the capability-probe shell script. It is intentionally fail-soft:
190/// every probe that errors simply omits its fact line, so a missing rustup or a
191/// broken binary shows up as *absent facts* (capability), never as a script
192/// crash (reachability).
193#[must_use]
194pub fn build_capability_probe_script(spec: &ProbeSpec) -> String {
195    let wkr = shq(&spec.rch_wkr_path);
196    let cargo = spec
197        .cargo_path
198        .as_deref()
199        .map_or_else(|| "cargo".to_string(), shq);
200    let rustup = spec
201        .rustup_path
202        .as_deref()
203        .map_or_else(|| "rustup".to_string(), shq);
204
205    let mut s = String::new();
206    s.push_str("set -u; P='RCH_FACT '; ");
207    // Host facts.
208    s.push_str("printf '%sos=%s\\n' \"$P\" \"$(uname -s | tr 'A-Z' 'a-z')\"; ");
209    s.push_str("printf '%sarch=%s\\n' \"$P\" \"$(uname -m)\"; ");
210    s.push_str("printf '%suser=%s\\n' \"$P\" \"$(id -un 2>/dev/null)\"; ");
211    // Worker binary at the EXACT path (version + protocol). Absent => broken.
212    s.push_str(&format!("printf '%srch_wkr_path=%s\\n' \"$P\" {wkr}; "));
213    s.push_str(&format!(
214        "if [ -x {wkr} ]; then v=$({wkr} --version 2>/dev/null) && printf '%sworker_version=%s\\n' \"$P\" \"$v\"; \
215         pr=$({wkr} --protocol-version 2>/dev/null) && printf '%sworker_protocol=%s\\n' \"$P\" \"$pr\"; fi; "
216    ));
217    // Cargo / rust.
218    s.push_str(&format!(
219        "cv=$({cargo} --version 2>/dev/null) && printf '%scargo_version=%s\\n' \"$P\" \"$cv\"; "
220    ));
221    // Toolchains + installed targets via rustup (each on its own fact line).
222    s.push_str(&format!(
223        "{rustup} toolchain list 2>/dev/null | awk -v p=\"$P\" '{{print p\"toolchain=\"$1}}'; "
224    ));
225    s.push_str(&format!(
226        "{rustup} target list --installed 2>/dev/null | awk -v p=\"$P\" '{{print p\"target=\"$1}}'; "
227    ));
228    // Installed components, reported PER TOOLCHAIN as `component=<toolchain>:<name>`.
229    //
230    // Per-toolchain is the whole point, not extra precision: components are
231    // installed against a specific toolchain, and consuming projects pin their
232    // own via `rust-toolchain.toml`. A worker can hold `clippy` for `stable` and
233    // still be unable to run `cargo clippy` for a pinned nightly, which is
234    // exactly the state that let 4 of 12 workers report healthy while silently
235    // voiding a mandated lint gate (bd-vc61a). Probing only the default
236    // toolchain would have reported OK for every one of them.
237    //
238    // `rustup toolchain list` prints the active toolchain with a ` (default)` /
239    // ` (override)` suffix, so `$1` is taken as the name. Each inner listing is
240    // best-effort: a toolchain that cannot be queried simply contributes no
241    // component facts, which reads downstream as "not known to have it" rather
242    // than as "known to lack it".
243    s.push_str(&format!(
244        "for tc in $({rustup} toolchain list 2>/dev/null | awk '{{print $1}}'); do \
245           rh=$({rustup} run \"$tc\" rustc -vV 2>/dev/null | awk '$1 == \"host:\" {{print $2; exit}}'); \
246           {rustup} component list --installed --toolchain \"$tc\" 2>/dev/null \
247             | awk -v p=\"$P\" -v t=\"$tc\" -v h=\"$rh\" \
248                 '{{n=$1; if (h != \"\") sub(\"-\" h \"$\", \"\", n); print p\"component=\"t\":\"n}}'; \
249         done; "
250    ));
251    // JS runtimes (PATH-resolved is acceptable for these advisory facts).
252    s.push_str("bv=$(bun --version 2>/dev/null) && printf '%sbun_version=%s\\n' \"$P\" \"$bv\"; ");
253    s.push_str(
254        "nv=$(node --version 2>/dev/null) && printf '%snode_version=%s\\n' \"$P\" \"$nv\"; ",
255    );
256    s.push_str(
257        "npmv=$(npm --version 2>/dev/null) && printf '%snpm_version=%s\\n' \"$P\" \"$npmv\"; ",
258    );
259    // Go toolchain (PATH-resolved; advisory fact gating go build/test/vet routing).
260    s.push_str("gov=$(go version 2>/dev/null) && printf '%sgo_version=%s\\n' \"$P\" \"$gov\"; ");
261    // Zig + cargo-zigbuild (PATH-resolved; gate `cargo zigbuild` cross-compile
262    // routing). BOTH are required: cargo-zigbuild drives the build, zig is the C
263    // compiler/cross-linker it shells out to. Reported as separate facts.
264    s.push_str("zv=$(zig version 2>/dev/null) && printf '%szig_version=%s\\n' \"$P\" \"$zv\"; ");
265    s.push_str(
266        "czv=$(cargo-zigbuild --version 2>/dev/null) && printf '%scargo_zigbuild_version=%s\\n' \"$P\" \"$czv\"; ",
267    );
268    // Nix: only report a version when a populated `/nix/store` also exists, since
269    // a `nix` binary without a store cannot build derivations (gates nix routing).
270    s.push_str(
271        "if [ -d /nix/store ] && [ -n \"$(ls -A /nix/store 2>/dev/null | head -n1)\" ]; then \
272           nixv=$(nix --version 2>/dev/null) && printf '%snix_version=%s\\n' \"$P\" \"$nixv\"; \
273         fi; ",
274    );
275    // Operator-declared named tools. Each is the exact argv from worker config,
276    // run as this (already configured) user with output discarded — verification
277    // is the EXIT STATUS, and a chatty tool must not be able to inject fact
278    // lines. Both outcomes are reported: `tool=` for present, `tool_absent=` for
279    // a declared probe that failed. The difference matters operationally — "the
280    // fleet never declared clang" and "this worker declares clang but the probe
281    // fails" have different fixes, and a facts stream that only carried
282    // successes could not tell them apart.
283    for tool in &spec.tools {
284        let argv = tool
285            .command()
286            .iter()
287            .map(|part| shq(part))
288            .collect::<Vec<_>>()
289            .join(" ");
290        let name = shq(tool.name());
291        s.push_str(&format!(
292            "if {argv} >/dev/null 2>&1; then printf '%stool=%s\\n' \"$P\" {name}; \
293             else printf '%stool_absent=%s\\n' \"$P\" {name}; fi; "
294        ));
295    }
296    // Disk roots: path;total_kb;avail_kb;avail_inodes (df -Pk and df -Pi).
297    for root in &spec.disk_roots {
298        let q = shq(root);
299        s.push_str(&format!(
300            "if [ -d {q} ]; then \
301               b=$(df -Pk {q} 2>/dev/null | awk 'NR==2{{print $2\";\"$4}}'); \
302               i=$(df -Pi {q} 2>/dev/null | awk 'NR==2{{print $4}}'); \
303               printf '%sdisk=%s;%s;%s\\n' \"$P\" {q} \"$b\" \"$i\"; \
304             fi; "
305        ));
306    }
307    s
308}
309
310/// Structured facts parsed from probe output. Sub-facts are `None`/empty when
311/// the corresponding probe produced no line (i.e. the capability is absent).
312#[derive(Debug, Clone, Default, PartialEq, Eq)]
313pub struct ProbedFacts {
314    /// Probed host OS, normalized by [`normalize_probed_os`].
315    pub os: Option<String>,
316    pub arch: Option<String>,
317    pub probed_user: Option<String>,
318    pub rch_wkr_path: Option<String>,
319    pub worker: Option<WorkerBinaryFacts>,
320    pub rust: RustFacts,
321    pub runtimes: RuntimeFacts,
322    pub disk_roots: Vec<DiskRootFacts>,
323    /// Operator-declared tools whose probe exited zero on this worker.
324    pub tools_present: Vec<String>,
325    /// Operator-declared tools whose probe ran and did NOT exit zero.
326    pub tools_absent: Vec<String>,
327    /// Raw worker_version line (kept even if protocol was missing).
328    worker_version: Option<String>,
329    worker_protocol: Option<u32>,
330}
331
332impl ProbedFacts {
333    /// The worker's derived target triple from probed os/arch (libc unknown here
334    /// — defaults to gnu on linux; collectors that know musl override on the
335    /// assembled [`crate::worker_facts::HostFacts`]).
336    #[must_use]
337    pub fn target_triple(&self) -> Option<String> {
338        match (&self.os, &self.arch) {
339            (Some(os), Some(arch)) => Some(derive_target_triple(os, arch, None)),
340            _ => None,
341        }
342    }
343
344    /// Whether a named tool was VERIFIED present on this worker. Absence of
345    /// evidence reads as absence: a tool nobody declared a probe for is not
346    /// present, which is what keeps an unknown `--require-tool` name
347    /// inadmissible everywhere instead of silently unconstrained.
348    #[must_use]
349    pub fn has_tool(&self, name: &str) -> bool {
350        self.tools_present.iter().any(|t| t == name)
351    }
352
353    /// Whether this worker DECLARED a probe for the tool (either outcome).
354    /// Distinguishes "the fleet never declared it" from "declared, probe fails".
355    #[must_use]
356    pub fn declares_tool(&self, name: &str) -> bool {
357        self.has_tool(name) || self.tools_absent.iter().any(|t| t == name)
358    }
359}
360
361/// Collapse the many shapes `uname -s` takes on Windows into plain `windows`.
362///
363/// The probe is a POSIX script, so on a Windows worker it runs under MSYS2,
364/// Git-Bash or Cygwin, each of which reports a versioned kernel name —
365/// `mingw64_nt-10.0-26100`, `msys_nt-10.0-26100`, `cygwin_nt-10.0`. Left raw,
366/// none of them compare equal to the `windows` an operator writes in config or
367/// a `*-pc-windows-msvc` triple implies. Everything else passes through
368/// untouched.
369#[must_use]
370pub fn normalize_probed_os(raw: &str) -> String {
371    let lower = raw.trim().to_ascii_lowercase();
372    for prefix in ["mingw32_nt", "mingw64_nt", "msys_nt", "cygwin_nt"] {
373        if lower.starts_with(prefix) {
374            return "windows".to_string();
375        }
376    }
377    lower
378}
379
380/// Parse `RCH_FACT k=v` probe output into [`ProbedFacts`]. Lines without the
381/// prefix are ignored, so incidental stdout never corrupts the parse.
382#[must_use]
383pub fn parse_capability_probe(stdout: &str) -> ProbedFacts {
384    let mut f = ProbedFacts::default();
385    for line in stdout.lines() {
386        let Some(kv) = line.trim().strip_prefix(FACT_PREFIX) else {
387            continue;
388        };
389        let Some((key, value)) = kv.split_once('=') else {
390            continue;
391        };
392        let value = value.trim();
393        match key {
394            "os" => f.os = Some(normalize_probed_os(value)),
395            "arch" => f.arch = Some(value.to_string()),
396            "user" => f.probed_user = Some(value.to_string()),
397            "rch_wkr_path" => f.rch_wkr_path = Some(value.to_string()),
398            "worker_version" => f.worker_version = Some(value.to_string()),
399            "worker_protocol" => f.worker_protocol = value.parse::<u32>().ok(),
400            "cargo_version" => f.rust.rustc_version = Some(value.to_string()),
401            "toolchain" => f.rust.toolchains.push(value.to_string()),
402            "target" => f.rust.targets.push(value.to_string()),
403            // `component=<toolchain>:<name>`. A value without a separator is
404            // dropped rather than stored under an empty toolchain: an entry that
405            // cannot say WHICH toolchain owns the component would answer
406            // `has_component` for every toolchain, which is the fail-open
407            // direction this fact exists to close.
408            "component" => {
409                if let Some((toolchain, component)) = value.split_once(':') {
410                    let toolchain = toolchain.trim();
411                    let component = component.trim();
412                    if !toolchain.is_empty() && !component.is_empty() {
413                        f.rust.components.push(format!("{toolchain}:{component}"));
414                    }
415                }
416            }
417            "tool" => {
418                if !value.is_empty() && !f.tools_present.iter().any(|t| t == value) {
419                    f.tools_present.push(value.to_string());
420                }
421            }
422            "tool_absent" => {
423                if !value.is_empty() && !f.tools_absent.iter().any(|t| t == value) {
424                    f.tools_absent.push(value.to_string());
425                }
426            }
427            "bun_version" => f.runtimes.bun_version = Some(value.to_string()),
428            "node_version" => f.runtimes.node_version = Some(value.to_string()),
429            "npm_version" => f.runtimes.npm_version = Some(value.to_string()),
430            "nix_version" => f.runtimes.nix_version = Some(value.to_string()),
431            "go_version" => f.runtimes.go_version = Some(value.to_string()),
432            "zig_version" => f.runtimes.zig_version = Some(value.to_string()),
433            "cargo_zigbuild_version" => f.runtimes.cargo_zigbuild_version = Some(value.to_string()),
434            "disk" => {
435                // path;total_kb;avail_kb;avail_inodes
436                let parts: Vec<&str> = value.split(';').collect();
437                if parts.len() == 4 {
438                    let kb = |s: &str| s.parse::<u64>().unwrap_or(0).saturating_mul(1024);
439                    f.disk_roots.push(DiskRootFacts {
440                        path: parts[0].to_string(),
441                        total_bytes: kb(parts[1]),
442                        available_bytes: kb(parts[2]),
443                        available_inodes: parts[3].parse::<u64>().unwrap_or(0),
444                    });
445                }
446            }
447            _ => {}
448        }
449    }
450    // Assemble the worker binary facts only if a version was reported (i.e. the
451    // binary at the exact path actually ran).
452    if let Some(version) = f.worker_version.clone() {
453        f.worker = Some(WorkerBinaryFacts {
454            rch_wkr_path: f.rch_wkr_path.clone().unwrap_or_default(),
455            version,
456            protocol_version: f.worker_protocol.unwrap_or(0),
457        });
458    }
459    f
460}
461
462/// What a command needs from a worker before it is admissible.
463#[derive(Debug, Clone, Default)]
464pub struct CapabilityRequirement {
465    /// rustup targets the build needs (e.g. `wasm32-unknown-unknown`).
466    pub needs_targets: Vec<String>,
467    /// Minimum acceptable worker wire protocol.
468    pub min_protocol: u32,
469    /// Whether a working cargo is required.
470    pub needs_cargo: bool,
471    /// Required host OS for the produced artifact (e.g. `linux`, `darwin`).
472    /// Matched case-insensitively against the worker's probed `os`.
473    pub needs_os: Option<String>,
474    /// Required host arch (e.g. `x86_64`, `aarch64`), matched case-insensitively.
475    pub needs_arch: Option<String>,
476    /// Whether a working `bun` runtime is required.
477    pub needs_bun: bool,
478    /// Whether a working `node` runtime is required.
479    pub needs_node: bool,
480    /// Whether a working Go toolchain is required.
481    pub needs_go: bool,
482    /// Whether `zig` + `cargo-zigbuild` are required (a `cargo zigbuild`
483    /// cross-compile). Enforced ON TOP OF `needs_cargo` and `needs_targets`.
484    pub needs_zig: bool,
485    /// Whether a working `nix` (binary + populated `/nix/store`) is required.
486    pub needs_nix: bool,
487    /// Specific rustup toolchains the build needs (prefix-matched against the
488    /// worker's installed toolchains, e.g. `nightly-2025-11-01` matches
489    /// `nightly-2025-11-01-x86_64-unknown-linux-gnu`).
490    pub needs_toolchains: Vec<String>,
491    /// Rustup components the build needs, each as `"<toolchain>:<component>"`
492    /// (e.g. `"nightly:clippy"`). The toolchain half is prefix-matched exactly
493    /// like [`Self::needs_toolchains`]; the component half must match exactly.
494    ///
495    /// Components are toolchain-scoped on purpose: a worker holding `clippy`
496    /// for `stable` cannot lint a project that pins a nightly, and without this
497    /// the scheduler routed lint work to such a worker and surfaced
498    /// `error: 'cargo-clippy' is not installed for the toolchain '...'` as a
499    /// plain exit 1 — indistinguishable from a real lint failure (bd-vc61a).
500    ///
501    /// Empty by default, so this constrains nothing until a caller opts in.
502    pub needs_components: Vec<String>,
503    /// Operator-declared named tools the work needs verified on the worker
504    /// (`rch exec --job --require-tool NAME`, or a project's
505    /// `jobs.required_tools`).
506    ///
507    /// Empty by default: no requirement means no gate. A name no worker
508    /// declares a probe for is inadmissible EVERYWHERE rather than ignored —
509    /// a typo that silently disabled the gate would be worse than a refusal,
510    /// because the caller asked for the gate precisely because the job cannot
511    /// run without the tool.
512    pub needs_tools: Vec<String>,
513}
514
515impl CapabilityRequirement {
516    /// A bare Rust build requirement (cargo, given wire protocol).
517    #[must_use]
518    pub fn rust(min_protocol: u32) -> Self {
519        Self {
520            min_protocol,
521            needs_cargo: true,
522            ..Self::default()
523        }
524    }
525
526    /// Require these rustup targets (builder style).
527    #[must_use]
528    pub fn with_targets<I, S>(mut self, targets: I) -> Self
529    where
530        I: IntoIterator<Item = S>,
531        S: Into<String>,
532    {
533        self.needs_targets = targets.into_iter().map(Into::into).collect();
534        self
535    }
536
537    /// Require a host OS (e.g. `linux` / `darwin`).
538    #[must_use]
539    pub fn with_os(mut self, os: impl Into<String>) -> Self {
540        self.needs_os = Some(os.into());
541        self
542    }
543
544    /// Require a host arch (e.g. `x86_64` / `aarch64`).
545    #[must_use]
546    pub fn with_arch(mut self, arch: impl Into<String>) -> Self {
547        self.needs_arch = Some(arch.into());
548        self
549    }
550
551    /// Require verified named tools (builder style).
552    #[must_use]
553    pub fn with_tools<I, S>(mut self, tools: I) -> Self
554    where
555        I: IntoIterator<Item = S>,
556        S: Into<String>,
557    {
558        self.needs_tools = tools.into_iter().map(Into::into).collect();
559        self
560    }
561
562    /// Require specific rustup toolchains (builder style).
563    #[must_use]
564    pub fn with_toolchains<I, S>(mut self, toolchains: I) -> Self
565    where
566        I: IntoIterator<Item = S>,
567        S: Into<String>,
568    {
569        self.needs_toolchains = toolchains.into_iter().map(Into::into).collect();
570        self
571    }
572}
573
574/// Outcome of an admissibility assessment. Reachability is the caller's concern
575/// (did the SSH probe run?); this answers "it ran — is it usable?".
576#[derive(Debug, Clone, PartialEq, Eq)]
577pub enum CapabilityVerdict {
578    /// Worker is admissible for the requirement.
579    Admissible,
580    /// Worker ran the probe but is not admissible, with the incident reason.
581    Rejected {
582        reason: IncidentReasonCode,
583        detail: String,
584    },
585}
586
587/// Assess admissibility of probed facts against a requirement. Assumes the
588/// probe was reachable (non-empty facts); a fully empty parse should be treated
589/// as unreachable by the caller, not passed here.
590#[must_use]
591pub fn assess_admissibility(facts: &ProbedFacts, req: &CapabilityRequirement) -> CapabilityVerdict {
592    // The exact-path worker binary must have produced a version (root-good /
593    // user-broken: the binary at the configured path is unusable for this user).
594    let Some(worker) = &facts.worker else {
595        return CapabilityVerdict::Rejected {
596            reason: IncidentReasonCode::WrongUserPathWorkerBinary,
597            detail: format!(
598                "rch-wkr at {} did not report a version as the configured user",
599                facts.rch_wkr_path.as_deref().unwrap_or("<unknown path>")
600            ),
601        };
602    };
603    if worker.protocol_version < req.min_protocol {
604        return CapabilityVerdict::Rejected {
605            reason: IncidentReasonCode::WrongUserPathWorkerBinary,
606            detail: format!(
607                "worker protocol {} < required {}",
608                worker.protocol_version, req.min_protocol
609            ),
610        };
611    }
612    // Host OS/arch must match the required output triple — a worker that ran the
613    // probe fine can still produce the wrong-platform artifact.
614    if let Some(needed_os) = &req.needs_os {
615        let matches = facts
616            .os
617            .as_deref()
618            .is_some_and(|os| os.eq_ignore_ascii_case(needed_os));
619        if !matches {
620            return CapabilityVerdict::Rejected {
621                reason: IncidentReasonCode::OsArchMismatch,
622                detail: format!(
623                    "worker OS {} does not satisfy required {}",
624                    facts.os.as_deref().unwrap_or("<unknown>"),
625                    needed_os
626                ),
627            };
628        }
629    }
630    if let Some(needed_arch) = &req.needs_arch {
631        let matches = facts
632            .arch
633            .as_deref()
634            .is_some_and(|arch| arch.eq_ignore_ascii_case(needed_arch));
635        if !matches {
636            return CapabilityVerdict::Rejected {
637                reason: IncidentReasonCode::OsArchMismatch,
638                detail: format!(
639                    "worker arch {} does not satisfy required {}",
640                    facts.arch.as_deref().unwrap_or("<unknown>"),
641                    needed_arch
642                ),
643            };
644        }
645    }
646    if req.needs_cargo && facts.rust.rustc_version.is_none() {
647        return CapabilityVerdict::Rejected {
648            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
649            detail: "cargo/rust toolchain not found at the configured user/path".to_string(),
650        };
651    }
652    for needed in &req.needs_toolchains {
653        // Match the exact name OR a `-`-suffixed host-triple form, so
654        // `nightly-2025-11-01` satisfies
655        // `nightly-2025-11-01-x86_64-unknown-linux-gnu` but NOT
656        // `nightly-2025-11-010` (bd-review-toolchain-prefix).
657        if !facts
658            .rust
659            .toolchains
660            .iter()
661            .any(|t| t == needed || t.starts_with(&format!("{needed}-")))
662        {
663            return CapabilityVerdict::Rejected {
664                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
665                detail: format!("missing rustup toolchain {needed}"),
666            };
667        }
668    }
669    for needed in &req.needs_targets {
670        if !facts.rust.targets.iter().any(|t| t == needed) {
671            return CapabilityVerdict::Rejected {
672                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
673                detail: format!("missing rustup target {needed}"),
674            };
675        }
676    }
677    // Toolchain-scoped components. Rejecting here turns "this worker cannot run
678    // the command" into a routing decision, so the caller sees a capability
679    // rejection (and, if every candidate is rejected, the same local fallback a
680    // missing target produces) instead of a 2-second exit 1 that reads like a
681    // lint failure (bd-vc61a).
682    for needed in &req.needs_components {
683        let Some((toolchain, component)) = needed.split_once(':') else {
684            return CapabilityVerdict::Rejected {
685                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
686                detail: format!(
687                    "malformed component requirement {needed}; expected <toolchain>:<component>"
688                ),
689            };
690        };
691        let prefix = format!("{toolchain}-");
692        let present = facts.rust.components.iter().any(|entry| {
693            entry.split_once(':').is_some_and(|(owner, name)| {
694                name == component && (owner == toolchain || owner.starts_with(&prefix))
695            })
696        });
697        if !present {
698            return CapabilityVerdict::Rejected {
699                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
700                detail: format!("missing rustup component {component} for toolchain {toolchain}"),
701            };
702        }
703    }
704    // Operator-declared named tools. Rejecting here — before any slot is
705    // reserved — is the point: a job that needs a tool this worker lacks would
706    // otherwise hold a reservation only to fail on the worker, and the failure
707    // would be indistinguishable from the job's own nonzero exit (job mode
708    // surfaces remote status verbatim).
709    for needed in &req.needs_tools {
710        if !facts.has_tool(needed) {
711            return CapabilityVerdict::Rejected {
712                reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
713                detail: if facts.declares_tool(needed) {
714                    format!(
715                        "required tool `{needed}` is declared for this worker but its probe did not succeed"
716                    )
717                } else {
718                    format!("required tool `{needed}` has no declared probe on this worker")
719                },
720            };
721        }
722    }
723    if req.needs_bun && facts.runtimes.bun_version.is_none() {
724        return CapabilityVerdict::Rejected {
725            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
726            detail: "bun runtime not found at the configured user/path".to_string(),
727        };
728    }
729    if req.needs_node && facts.runtimes.node_version.is_none() {
730        return CapabilityVerdict::Rejected {
731            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
732            detail: "node runtime not found at the configured user/path".to_string(),
733        };
734    }
735    if req.needs_nix && facts.runtimes.nix_version.is_none() {
736        return CapabilityVerdict::Rejected {
737            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
738            detail: "nix (binary + populated /nix/store) not found on the worker".to_string(),
739        };
740    }
741    if req.needs_go && facts.runtimes.go_version.is_none() {
742        return CapabilityVerdict::Rejected {
743            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
744            detail: "go toolchain not found at the configured user/path".to_string(),
745        };
746    }
747    // Zig cross-build needs BOTH zig and cargo-zigbuild (the requested rustup
748    // target std is enforced by the needs_targets loop above). Missing either →
749    // this worker can't run it; if every candidate is rejected the build falls
750    // back to local, exactly like a missing rustup target.
751    if req.needs_zig
752        && (facts.runtimes.zig_version.is_none() || facts.runtimes.cargo_zigbuild_version.is_none())
753    {
754        return CapabilityVerdict::Rejected {
755            reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
756            detail: "zig + cargo-zigbuild not found on the worker".to_string(),
757        };
758    }
759    CapabilityVerdict::Admissible
760}
761
762/// A worker's live admission state, separate from its (structural) capability.
763/// Capability says *can this worker ever run the command*; liveness says *is it
764/// usable right now*. Keeping them distinct is what lets selection report a
765/// missing-capability reason instead of conflating it with an unhealthy or busy
766/// worker.
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
768pub struct WorkerLiveness {
769    /// The worker's capability facts are too stale to trust.
770    pub telemetry_stale: bool,
771    /// The worker is healthy (circuit closed, reachable).
772    pub healthy: bool,
773    /// The worker has at least one free build slot.
774    pub has_free_slot: bool,
775}
776
777impl WorkerLiveness {
778    /// A fresh, healthy, idle worker.
779    #[must_use]
780    pub fn ready() -> Self {
781        Self {
782            telemetry_stale: false,
783            healthy: true,
784            has_free_slot: true,
785        }
786    }
787}
788
789/// The distinct eligibility outcomes selection must tell apart, each with its
790/// own incident reason — so an agent learns *why* a worker was not chosen
791/// (missing capability vs unhealthy vs busy vs stale facts) rather than a single
792/// opaque "no admissible workers".
793#[derive(Debug, Clone, PartialEq, Eq)]
794pub enum EligibilityVerdict {
795    /// Capable, fresh, healthy, and has a free slot.
796    Eligible,
797    /// The worker structurally cannot run this command.
798    MissingCapability {
799        reason: IncidentReasonCode,
800        detail: String,
801    },
802    /// Capability is unknowable because the facts are stale.
803    StaleTelemetry,
804    /// Capable but unhealthy (circuit open / unreachable).
805    Unhealthy,
806    /// Capable and healthy but at capacity right now.
807    Busy,
808}
809
810impl EligibilityVerdict {
811    /// The incident reason for this verdict (`None` when eligible).
812    #[must_use]
813    pub fn reason(&self) -> Option<IncidentReasonCode> {
814        match self {
815            EligibilityVerdict::Eligible => None,
816            EligibilityVerdict::MissingCapability { reason, .. } => Some(*reason),
817            EligibilityVerdict::StaleTelemetry => Some(IncidentReasonCode::TelemetryStale),
818            EligibilityVerdict::Unhealthy => Some(IncidentReasonCode::CircuitOpen),
819            EligibilityVerdict::Busy => Some(IncidentReasonCode::InsufficientSlots),
820        }
821    }
822
823    /// Whether the worker is eligible to run the command.
824    #[must_use]
825    pub fn is_eligible(&self) -> bool {
826        matches!(self, EligibilityVerdict::Eligible)
827    }
828}
829
830/// Assess a worker's full eligibility for a command, distinguishing missing
831/// capability from an unhealthy or busy worker. Pure and total.
832///
833/// Precedence: stale facts (can't trust capability) first; then a structural
834/// capability rejection (no point waiting for a slot it can never use); then
835/// health; then capacity. Capacity/health are transient, so they only matter
836/// once the worker is known capable on fresh facts.
837#[must_use]
838pub fn assess_worker_eligibility(
839    facts: &ProbedFacts,
840    req: &CapabilityRequirement,
841    liveness: &WorkerLiveness,
842) -> EligibilityVerdict {
843    if liveness.telemetry_stale {
844        return EligibilityVerdict::StaleTelemetry;
845    }
846    if let CapabilityVerdict::Rejected { reason, detail } = assess_admissibility(facts, req) {
847        return EligibilityVerdict::MissingCapability { reason, detail };
848    }
849    if !liveness.healthy {
850        return EligibilityVerdict::Unhealthy;
851    }
852    if !liveness.has_free_slot {
853        return EligibilityVerdict::Busy;
854    }
855    EligibilityVerdict::Eligible
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    #[test]
863    fn test_normalize_probed_os_collapses_windows_shells() {
864        // The probe is a POSIX script, so on Windows it runs under MSYS2 /
865        // Git-Bash / Cygwin and `uname -s` names the emulation layer, not the OS.
866        for raw in [
867            "MINGW64_NT-10.0-26100",
868            "mingw32_nt-10.0-22631",
869            "MSYS_NT-10.0-26100",
870            "CYGWIN_NT-10.0",
871        ] {
872            assert_eq!(normalize_probed_os(raw), "windows", "raw={raw}");
873        }
874    }
875
876    #[test]
877    fn test_normalize_probed_os_passes_through_unix() {
878        assert_eq!(normalize_probed_os("Linux"), "linux");
879        assert_eq!(normalize_probed_os("Darwin"), "darwin");
880        assert_eq!(normalize_probed_os(" linux "), "linux");
881    }
882
883    #[test]
884    fn test_parse_normalizes_windows_os_fact() {
885        let facts =
886            parse_capability_probe("RCH_FACT os=MINGW64_NT-10.0-26100\nRCH_FACT arch=x86_64\n");
887        assert_eq!(facts.os.as_deref(), Some("windows"));
888    }
889
890    fn spec() -> ProbeSpec {
891        let mut s = ProbeSpec::new("rch", "/home/rch/.local/bin/rch-wkr");
892        s.cargo_path = Some("/home/rch/.cargo/bin/cargo".to_string());
893        s.disk_roots = vec!["/data/tmp".to_string()];
894        s
895    }
896
897    fn tool(name: &str, argv: &[&str]) -> NamedToolProbe {
898        NamedToolProbe::new(name, argv.iter().copied()).expect("valid probe")
899    }
900
901    #[test]
902    fn named_tool_probe_refuses_names_that_cannot_survive_the_wire_format() {
903        // A fact line is `RCH_FACT tool=<name>`, parsed on the FIRST `=`. A name
904        // carrying `=`, whitespace or a control character would parse back as a
905        // different name — a silent capability lie. Refuse at declaration.
906        for bad in [
907            "",
908            "clang=1",
909            "two words",
910            "new\nline",
911            "tab\there",
912            "sh;rm",
913        ] {
914            assert!(
915                NamedToolProbe::new(bad, ["true"]).is_err(),
916                "{bad:?} must be refused"
917            );
918        }
919        for good in [
920            "clang",
921            "ld.lld",
922            "cargo-nextest",
923            "gcc-14",
924            "llvm_cov",
925            "g++",
926        ] {
927            assert!(
928                NamedToolProbe::new(good, ["true"]).is_ok(),
929                "{good:?} must be accepted"
930            );
931        }
932        // A probe with no argv can never prove anything.
933        assert!(matches!(
934            NamedToolProbe::new("clang", Vec::<String>::new()),
935            Err(ToolProbeError::EmptyCommand { .. })
936        ));
937    }
938
939    #[test]
940    fn script_probes_declared_tools_by_exit_status_and_reports_both_outcomes() {
941        let spec = spec().with_tools([
942            tool("clang", &["clang", "--version"]),
943            tool("ld.lld", &["/usr/bin/ld.lld", "--version"]),
944        ]);
945        let script = build_capability_probe_script(&spec);
946        // The operator's exact argv, shell-quoted, with output discarded: the
947        // verification is the exit status, and a chatty tool must not be able to
948        // inject its own RCH_FACT lines into the stream.
949        assert!(script.contains("if 'clang' '--version' >/dev/null 2>&1;"));
950        assert!(script.contains("if '/usr/bin/ld.lld' '--version' >/dev/null 2>&1;"));
951        assert!(script.contains("tool=%s"));
952        assert!(script.contains("tool_absent=%s"));
953        // No tools declared => no tool probes at all (no requirement, no gate).
954        assert!(!build_capability_probe_script(&self::spec()).contains("tool="));
955    }
956
957    #[test]
958    fn parsed_tool_facts_separate_verified_from_declared_but_failing() {
959        let facts = parse_capability_probe(
960            "RCH_FACT os=linux\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\nRCH_FACT tool=clang\n",
961        );
962        assert_eq!(facts.tools_present, vec!["clang".to_string()], "deduped");
963        assert_eq!(facts.tools_absent, vec!["ld.lld".to_string()]);
964        assert!(facts.has_tool("clang"));
965        assert!(!facts.has_tool("ld.lld"));
966        // Declared-but-failing is NOT the same as never declared.
967        assert!(facts.declares_tool("ld.lld"));
968        assert!(!facts.declares_tool("wild"));
969    }
970
971    #[test]
972    fn tool_requirements_gate_admissibility_and_unknown_names_are_inadmissible() {
973        let facts = parse_capability_probe(
974            "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT rch_wkr_path=/w\n\
975             RCH_FACT worker_version=1\nRCH_FACT worker_protocol=9\n\
976             RCH_FACT cargo_version=cargo 1.99\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\n",
977        );
978        let base = CapabilityRequirement::rust(1);
979        // No requirement => no gate.
980        assert_eq!(
981            assess_admissibility(&facts, &base),
982            CapabilityVerdict::Admissible
983        );
984        // Verified tool => admissible.
985        assert_eq!(
986            assess_admissibility(&facts, &base.clone().with_tools(["clang"])),
987            CapabilityVerdict::Admissible
988        );
989        // Declared but failing probe => rejected, and the detail says so.
990        let CapabilityVerdict::Rejected { reason, detail } =
991            assess_admissibility(&facts, &base.clone().with_tools(["ld.lld"]))
992        else {
993            panic!("a failing probe must not be admissible");
994        };
995        assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
996        assert!(detail.contains("did not succeed"), "{detail}");
997        // A name NOBODY declared (typo, or a tool the fleet never probed) is
998        // inadmissible rather than silently unconstrained — the caller asked
999        // for the gate because the job cannot run without the tool.
1000        let CapabilityVerdict::Rejected { detail, .. } =
1001            assess_admissibility(&facts, &base.clone().with_tools(["clanggg"]))
1002        else {
1003            panic!("an undeclared tool must not be admissible");
1004        };
1005        assert!(detail.contains("no declared probe"), "{detail}");
1006        // Every required tool must hold, not just one.
1007        assert!(matches!(
1008            assess_admissibility(&facts, &base.with_tools(["clang", "ld.lld"])),
1009            CapabilityVerdict::Rejected { .. }
1010        ));
1011    }
1012
1013    #[test]
1014    fn script_uses_exact_paths_and_probes_all_component_facts() {
1015        let script = build_capability_probe_script(&spec());
1016        // Exact rch-wkr path is embedded (not PATH-resolved).
1017        assert!(script.contains("/home/rch/.local/bin/rch-wkr"));
1018        assert!(script.contains("/home/rch/.cargo/bin/cargo"));
1019        // Probes every required capability dimension.
1020        assert!(script.contains("--version"));
1021        assert!(script.contains("--protocol-version"));
1022        assert!(script.contains("toolchain list"));
1023        assert!(script.contains("target list --installed"));
1024        // Components must be enumerated PER toolchain, not for the default one:
1025        // a worker holding clippy for `stable` still cannot lint a pinned
1026        // nightly, and probing only the default would report it healthy
1027        // (bd-vc61a).
1028        assert!(script.contains("component list --installed --toolchain"));
1029        assert!(script.contains("rustc -vV"));
1030        assert!(script.contains("sub(\"-\" h \"$\""));
1031        assert!(
1032            script.contains("for tc in"),
1033            "component probe must loop over every installed toolchain"
1034        );
1035        assert!(script.contains("bun --version"));
1036        assert!(script.contains("df -Pk"));
1037        assert!(script.contains("df -Pi"));
1038        // Quoted disk root.
1039        assert!(script.contains("'/data/tmp'"));
1040    }
1041
1042    fn good_output() -> &'static str {
1043        "RCH_FACT os=linux\n\
1044         RCH_FACT arch=x86_64\n\
1045         RCH_FACT user=rch\n\
1046         RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1047         RCH_FACT worker_version=1.0.41\n\
1048         RCH_FACT worker_protocol=3\n\
1049         RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1050         RCH_FACT toolchain=stable\n\
1051         RCH_FACT toolchain=nightly-2026-05-22\n\
1052         RCH_FACT target=x86_64-unknown-linux-gnu\n\
1053         RCH_FACT target=wasm32-unknown-unknown\n\
1054         RCH_FACT component=stable:clippy\n\
1055         RCH_FACT component=stable:rustfmt\n\
1056         RCH_FACT component=nightly-2026-05-22:rustfmt\n\
1057         RCH_FACT component=malformed-without-separator\n\
1058         RCH_FACT bun_version=1.1.0\n\
1059         RCH_FACT disk=/data/tmp;1048576;524288;900000\n\
1060         incidental noise line that must be ignored\n"
1061    }
1062
1063    #[test]
1064    fn parses_full_probe_output() {
1065        let f = parse_capability_probe(good_output());
1066        assert_eq!(f.os.as_deref(), Some("linux"));
1067        assert_eq!(f.arch.as_deref(), Some("x86_64"));
1068        assert_eq!(f.probed_user.as_deref(), Some("rch"));
1069        let w = f.worker.as_ref().unwrap();
1070        assert_eq!(w.version, "1.0.41");
1071        assert_eq!(w.protocol_version, 3);
1072        assert_eq!(w.rch_wkr_path, "/home/rch/.local/bin/rch-wkr");
1073        assert_eq!(f.rust.toolchains, vec!["stable", "nightly-2026-05-22"]);
1074        assert!(f.rust.targets.iter().any(|t| t == "wasm32-unknown-unknown"));
1075        // Components stay toolchain-qualified, and the malformed line carrying
1076        // no `<toolchain>:<name>` separator is dropped rather than stored under
1077        // an empty owner -- an unqualified entry would answer `has_component`
1078        // for EVERY toolchain, which is the fail-open direction.
1079        assert_eq!(
1080            f.rust.components,
1081            vec![
1082                "stable:clippy",
1083                "stable:rustfmt",
1084                "nightly-2026-05-22:rustfmt"
1085            ]
1086        );
1087        assert_eq!(f.runtimes.bun_version.as_deref(), Some("1.1.0"));
1088        assert_eq!(f.disk_roots.len(), 1);
1089        assert_eq!(f.disk_roots[0].path, "/data/tmp");
1090        assert_eq!(f.disk_roots[0].available_bytes, 524288 * 1024);
1091        assert_eq!(f.disk_roots[0].available_inodes, 900000);
1092        assert_eq!(
1093            f.target_triple().as_deref(),
1094            Some("x86_64-unknown-linux-gnu")
1095        );
1096    }
1097
1098    fn req_wasm() -> CapabilityRequirement {
1099        CapabilityRequirement {
1100            needs_targets: vec!["wasm32-unknown-unknown".to_string()],
1101            min_protocol: 3,
1102            needs_cargo: true,
1103            ..CapabilityRequirement::default()
1104        }
1105    }
1106
1107    #[test]
1108    fn admissible_when_all_capabilities_present() {
1109        let f = parse_capability_probe(good_output());
1110        assert_eq!(
1111            assess_admissibility(&f, &req_wasm()),
1112            CapabilityVerdict::Admissible
1113        );
1114    }
1115
1116    #[test]
1117    fn component_requirement_is_toolchain_scoped() {
1118        let f = parse_capability_probe(good_output());
1119        let with_components = |needed: &[&str]| CapabilityRequirement {
1120            needs_components: needed.iter().map(|s| (*s).to_string()).collect(),
1121            ..req_wasm()
1122        };
1123
1124        // Present for the toolchain that actually owns it, including the
1125        // `-`-suffixed pinned form.
1126        assert_eq!(
1127            assess_admissibility(&f, &with_components(&["stable:clippy"])),
1128            CapabilityVerdict::Admissible
1129        );
1130        assert_eq!(
1131            assess_admissibility(&f, &with_components(&["nightly:rustfmt"])),
1132            CapabilityVerdict::Admissible
1133        );
1134
1135        // The regression this exists to catch: the worker HAS clippy, but for
1136        // `stable` only. A pinned-nightly lint must not be routed here.
1137        match assess_admissibility(&f, &with_components(&["nightly:clippy"])) {
1138            CapabilityVerdict::Rejected { reason, detail } => {
1139                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1140                assert!(
1141                    detail.contains("clippy") && detail.contains("nightly"),
1142                    "rejection must name the component and toolchain: {detail}"
1143                );
1144            }
1145            other => panic!("expected rejection, got {other:?}"),
1146        }
1147
1148        // A requirement that cannot say which toolchain it means is rejected
1149        // rather than matched loosely.
1150        match assess_admissibility(&f, &with_components(&["clippy"])) {
1151            CapabilityVerdict::Rejected { detail, .. } => {
1152                assert!(detail.contains("malformed"), "got {detail}");
1153            }
1154            other => panic!("expected rejection, got {other:?}"),
1155        }
1156    }
1157
1158    #[test]
1159    fn no_component_requirement_constrains_nothing() {
1160        // Default is empty, so every existing caller keeps its current routing.
1161        // A worker reporting no components at all stays admissible.
1162        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT user=rch\n\
1163                   RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1164                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1165                   RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1166                   RCH_FACT target=wasm32-unknown-unknown\n";
1167        let f = parse_capability_probe(out);
1168        assert!(f.rust.components.is_empty());
1169        assert_eq!(
1170            assess_admissibility(&f, &req_wasm()),
1171            CapabilityVerdict::Admissible
1172        );
1173    }
1174
1175    #[test]
1176    fn user_broken_binary_rejected_distinct_from_unreachable() {
1177        // The probe RAN (host facts present) but the exact-path binary produced
1178        // no version — root-good / user-broken. This is capability rejection,
1179        // not unreachability.
1180        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1181                   RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n";
1182        let f = parse_capability_probe(out);
1183        assert!(f.worker.is_none());
1184        match assess_admissibility(&f, &req_wasm()) {
1185            CapabilityVerdict::Rejected { reason, .. } => {
1186                assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1187            }
1188            other => panic!("expected rejection, got {other:?}"),
1189        }
1190    }
1191
1192    #[test]
1193    fn missing_rustup_rejected_for_cargo_requirement() {
1194        // Worker fine, but no cargo/toolchains were probed.
1195        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1196                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1197        let f = parse_capability_probe(out);
1198        match assess_admissibility(&f, &req_wasm()) {
1199            CapabilityVerdict::Rejected { reason, detail } => {
1200                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1201                assert!(detail.contains("cargo"));
1202            }
1203            other => panic!("expected rejection, got {other:?}"),
1204        }
1205    }
1206
1207    #[test]
1208    fn missing_wasm_target_rejected() {
1209        // Has cargo + protocol, but lacks the wasm target.
1210        let out = "RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1211                   RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n";
1212        let f = parse_capability_probe(out);
1213        match assess_admissibility(&f, &req_wasm()) {
1214            CapabilityVerdict::Rejected { reason, detail } => {
1215                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1216                assert!(detail.contains("wasm32-unknown-unknown"));
1217            }
1218            other => panic!("expected rejection, got {other:?}"),
1219        }
1220    }
1221
1222    #[test]
1223    fn stale_worker_protocol_rejected() {
1224        let out = "RCH_FACT worker_version=0.9.0\nRCH_FACT worker_protocol=1\n\
1225                   RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=wasm32-unknown-unknown\n";
1226        let f = parse_capability_probe(out);
1227        match assess_admissibility(&f, &req_wasm()) {
1228            CapabilityVerdict::Rejected { reason, detail } => {
1229                assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1230                assert!(detail.contains("protocol"));
1231            }
1232            other => panic!("expected rejection, got {other:?}"),
1233        }
1234    }
1235
1236    #[test]
1237    fn empty_output_parses_empty() {
1238        // A fully empty parse is the caller's signal of unreachability.
1239        let f = parse_capability_probe("");
1240        assert_eq!(f, ProbedFacts::default());
1241        assert!(f.worker.is_none());
1242        assert!(f.os.is_none());
1243    }
1244
1245    // --- 12.3: OS/arch, runtime, toolchain capability dimensions -----------
1246
1247    #[test]
1248    fn os_mismatch_rejected_with_osarch_reason() {
1249        // A linux worker cannot produce a darwin artifact.
1250        let f = parse_capability_probe(good_output());
1251        let req = CapabilityRequirement::rust(3).with_os("darwin");
1252        match assess_admissibility(&f, &req) {
1253            CapabilityVerdict::Rejected { reason, detail } => {
1254                assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1255                assert!(detail.contains("darwin"));
1256            }
1257            other => panic!("expected OS-mismatch rejection, got {other:?}"),
1258        }
1259        // The matching OS is admissible.
1260        assert_eq!(
1261            assess_admissibility(&f, &CapabilityRequirement::rust(3).with_os("linux")),
1262            CapabilityVerdict::Admissible
1263        );
1264    }
1265
1266    #[test]
1267    fn arch_mismatch_rejected_with_osarch_reason() {
1268        let f = parse_capability_probe(good_output());
1269        let req = CapabilityRequirement::rust(3).with_arch("aarch64");
1270        match assess_admissibility(&f, &req) {
1271            CapabilityVerdict::Rejected { reason, .. } => {
1272                assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1273            }
1274            other => panic!("expected arch-mismatch rejection, got {other:?}"),
1275        }
1276    }
1277
1278    #[test]
1279    fn missing_node_runtime_rejected() {
1280        // good_output has bun but no node.
1281        let f = parse_capability_probe(good_output());
1282        let mut req = CapabilityRequirement::rust(3);
1283        req.needs_node = true;
1284        match assess_admissibility(&f, &req) {
1285            CapabilityVerdict::Rejected { reason, detail } => {
1286                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1287                assert!(detail.contains("node"));
1288            }
1289            other => panic!("expected node rejection, got {other:?}"),
1290        }
1291        // bun IS present, so a bun requirement is admissible.
1292        let mut bun_req = CapabilityRequirement::rust(3);
1293        bun_req.needs_bun = true;
1294        assert_eq!(
1295            assess_admissibility(&f, &bun_req),
1296            CapabilityVerdict::Admissible
1297        );
1298    }
1299
1300    #[test]
1301    fn specific_toolchain_prefix_matched() {
1302        let f = parse_capability_probe(good_output());
1303        // Installed nightly-2026-05-22 satisfies the bare prefix.
1304        let ok = CapabilityRequirement::rust(3).with_toolchains(["nightly-2026-05-22"]);
1305        assert_eq!(assess_admissibility(&f, &ok), CapabilityVerdict::Admissible);
1306        // A different pinned nightly is missing.
1307        let missing = CapabilityRequirement::rust(3).with_toolchains(["nightly-2025-01-01"]);
1308        match assess_admissibility(&f, &missing) {
1309            CapabilityVerdict::Rejected { reason, detail } => {
1310                assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1311                assert!(detail.contains("nightly-2025-01-01"));
1312            }
1313            other => panic!("expected toolchain rejection, got {other:?}"),
1314        }
1315    }
1316
1317    // --- 12.3: eligibility distinguishes missing-capability / unhealthy /
1318    //           busy / stale ------------------------------------------------
1319
1320    #[test]
1321    fn eligible_when_capable_fresh_healthy_idle() {
1322        let f = parse_capability_probe(good_output());
1323        let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1324        assert_eq!(v, EligibilityVerdict::Eligible);
1325        assert!(v.is_eligible());
1326        assert_eq!(v.reason(), None);
1327    }
1328
1329    #[test]
1330    fn no_worker_with_runtime_is_missing_capability_not_busy() {
1331        // A worker without cargo, even if idle+healthy, is missing-capability —
1332        // distinct from a busy or unhealthy worker.
1333        let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1334                   RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1335        let f = parse_capability_probe(out);
1336        let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1337        assert_eq!(
1338            v.reason(),
1339            Some(IncidentReasonCode::MissingRuntimeToolchainTarget)
1340        );
1341        assert!(matches!(v, EligibilityVerdict::MissingCapability { .. }));
1342    }
1343
1344    #[test]
1345    fn os_arch_mismatch_surfaces_as_missing_capability() {
1346        let f = parse_capability_probe(good_output());
1347        let req = CapabilityRequirement::rust(3).with_os("darwin");
1348        let v = assess_worker_eligibility(&f, &req, &WorkerLiveness::ready());
1349        assert_eq!(v.reason(), Some(IncidentReasonCode::OsArchMismatch));
1350    }
1351
1352    #[test]
1353    fn unhealthy_and_busy_are_distinct_from_missing_capability() {
1354        let f = parse_capability_probe(good_output());
1355        // Capable but circuit-open => Unhealthy.
1356        let unhealthy = WorkerLiveness {
1357            telemetry_stale: false,
1358            healthy: false,
1359            has_free_slot: true,
1360        };
1361        assert_eq!(
1362            assess_worker_eligibility(&f, &req_wasm(), &unhealthy).reason(),
1363            Some(IncidentReasonCode::CircuitOpen)
1364        );
1365        // Capable + healthy but no slot => Busy.
1366        let busy = WorkerLiveness {
1367            telemetry_stale: false,
1368            healthy: true,
1369            has_free_slot: false,
1370        };
1371        assert_eq!(
1372            assess_worker_eligibility(&f, &req_wasm(), &busy).reason(),
1373            Some(IncidentReasonCode::InsufficientSlots)
1374        );
1375    }
1376
1377    #[test]
1378    fn stale_capability_cache_is_distinct_reason() {
1379        let f = parse_capability_probe(good_output());
1380        let stale = WorkerLiveness {
1381            telemetry_stale: true,
1382            healthy: true,
1383            has_free_slot: true,
1384        };
1385        let v = assess_worker_eligibility(&f, &req_wasm(), &stale);
1386        assert_eq!(v, EligibilityVerdict::StaleTelemetry);
1387        assert_eq!(v.reason(), Some(IncidentReasonCode::TelemetryStale));
1388    }
1389
1390    #[test]
1391    fn capability_refresh_changes_eligibility() {
1392        // Before refresh: worker lacks the wasm target => missing capability.
1393        let before = parse_capability_probe(
1394            "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1395             RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1396             RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n",
1397        );
1398        let v_before = assess_worker_eligibility(&before, &req_wasm(), &WorkerLiveness::ready());
1399        assert!(matches!(
1400            v_before,
1401            EligibilityVerdict::MissingCapability { .. }
1402        ));
1403
1404        // After refresh: the wasm target was installed => now eligible.
1405        let after = parse_capability_probe(good_output());
1406        let v_after = assess_worker_eligibility(&after, &req_wasm(), &WorkerLiveness::ready());
1407        assert_eq!(v_after, EligibilityVerdict::Eligible);
1408    }
1409
1410    #[test]
1411    fn remote_worker_binary_path_is_absolute_per_user() {
1412        assert_eq!(
1413            remote_worker_binary_path("root", None),
1414            "/root/.local/bin/rch-wkr"
1415        );
1416        assert_eq!(
1417            remote_worker_binary_path("ubuntu", Some("linux")),
1418            "/home/ubuntu/.local/bin/rch-wkr"
1419        );
1420        // The path must be absolute: the probe script single-quotes it, so a
1421        // literal `~` would never expand and `[ -x ... ]` would always fail.
1422        for user in ["root", "rch", "ubuntu"] {
1423            let path = remote_worker_binary_path(user, None);
1424            assert!(!path.contains('~'), "{path} must not contain a tilde");
1425            assert!(path.starts_with('/'), "{path} must be absolute");
1426            let spec = ProbeSpec::new(user, path);
1427            let script = build_capability_probe_script(&spec);
1428            // The shell-quoted binary path embedded in the script is absolute,
1429            // so `[ -x ... ]` resolves on the real worker.
1430            assert!(script.contains("/.local/bin/rch-wkr'"));
1431            assert!(!script.contains("'~/.local/bin/rch-wkr'"));
1432        }
1433    }
1434
1435    #[test]
1436    fn remote_worker_binary_path_uses_git_bash_home_for_windows() {
1437        assert_eq!(
1438            remote_worker_binary_path("jeffr", Some("windows")),
1439            "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1440        );
1441        assert_eq!(
1442            remote_worker_binary_path("jeffr", Some("WINDOWS")),
1443            "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1444        );
1445    }
1446}