1use crate::incident::IncidentReasonCode;
18use crate::worker_facts::{
19 DiskRootFacts, RuntimeFacts, RustFacts, WorkerBinaryFacts, derive_target_triple,
20};
21
22pub const FACT_PREFIX: &str = "RCH_FACT ";
25
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum ToolProbeError {
29 #[error("tool name {name:?} is invalid: {reason}")]
31 InvalidName {
32 name: String,
34 reason: &'static str,
36 },
37 #[error("tool {name:?} declares an empty probe command")]
39 EmptyCommand {
40 name: String,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct NamedToolProbe {
59 name: String,
61 command: Vec<String>,
63}
64
65impl NamedToolProbe {
66 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 #[must_use]
102 pub fn name(&self) -> &str {
103 &self.name
104 }
105
106 #[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 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#[derive(Debug, Clone)]
126pub struct ProbeSpec {
127 pub remote_user: String,
129 pub rch_wkr_path: String,
131 pub cargo_path: Option<String>,
133 pub rustup_path: Option<String>,
135 pub disk_roots: Vec<String>,
137 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 #[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
162fn shq(s: &str) -> String {
164 format!("'{}'", s.replace('\'', "'\\''"))
165}
166
167#[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#[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 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 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 s.push_str(&format!(
219 "cv=$({cargo} --version 2>/dev/null) && printf '%scargo_version=%s\\n' \"$P\" \"$cv\"; "
220 ));
221 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 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 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 s.push_str("gov=$(go version 2>/dev/null) && printf '%sgo_version=%s\\n' \"$P\" \"$gov\"; ");
261 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 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 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 for root in &spec.disk_roots {
301 let q = shq(root);
302 s.push_str(&format!(
303 "rp={q}; \
304 while [ ! -e \"$rp\" ] && [ ! -L \"$rp\" ]; do \
305 parent=$(dirname -- \"$rp\") || break; \
306 [ \"$parent\" != \"$rp\" ] || break; rp=$parent; \
307 done; \
308 if [ -d \"$rp\" ]; then \
309 b=$(df -Pk \"$rp\" 2>/dev/null | awk 'NR==2{{print $2\";\"$4}}'); \
310 i=$(df -Pi \"$rp\" 2>/dev/null | awk 'NR==2{{print $4}}'); \
311 printf '%sdisk=%s;%s;%s\\n' \"$P\" {q} \"$b\" \"$i\"; \
312 fi; "
313 ));
314 }
315 s
316}
317
318#[derive(Debug, Clone, Default, PartialEq, Eq)]
321pub struct ProbedFacts {
322 pub os: Option<String>,
324 pub arch: Option<String>,
325 pub probed_user: Option<String>,
326 pub rch_wkr_path: Option<String>,
327 pub worker: Option<WorkerBinaryFacts>,
328 pub rust: RustFacts,
329 pub runtimes: RuntimeFacts,
330 pub disk_roots: Vec<DiskRootFacts>,
331 pub tools_present: Vec<String>,
333 pub tools_absent: Vec<String>,
335 worker_version: Option<String>,
337 worker_protocol: Option<u32>,
338}
339
340impl ProbedFacts {
341 #[must_use]
345 pub fn target_triple(&self) -> Option<String> {
346 match (&self.os, &self.arch) {
347 (Some(os), Some(arch)) => Some(derive_target_triple(os, arch, None)),
348 _ => None,
349 }
350 }
351
352 #[must_use]
357 pub fn has_tool(&self, name: &str) -> bool {
358 self.tools_present.iter().any(|t| t == name)
359 }
360
361 #[must_use]
364 pub fn declares_tool(&self, name: &str) -> bool {
365 self.has_tool(name) || self.tools_absent.iter().any(|t| t == name)
366 }
367}
368
369#[must_use]
378pub fn normalize_probed_os(raw: &str) -> String {
379 let lower = raw.trim().to_ascii_lowercase();
380 for prefix in ["mingw32_nt", "mingw64_nt", "msys_nt", "cygwin_nt"] {
381 if lower.starts_with(prefix) {
382 return "windows".to_string();
383 }
384 }
385 lower
386}
387
388#[must_use]
391pub fn parse_capability_probe(stdout: &str) -> ProbedFacts {
392 let mut f = ProbedFacts::default();
393 for line in stdout.lines() {
394 let Some(kv) = line.trim().strip_prefix(FACT_PREFIX) else {
395 continue;
396 };
397 let Some((key, value)) = kv.split_once('=') else {
398 continue;
399 };
400 let value = value.trim();
401 match key {
402 "os" => f.os = Some(normalize_probed_os(value)),
403 "arch" => f.arch = Some(value.to_string()),
404 "user" => f.probed_user = Some(value.to_string()),
405 "rch_wkr_path" => f.rch_wkr_path = Some(value.to_string()),
406 "worker_version" => f.worker_version = Some(value.to_string()),
407 "worker_protocol" => f.worker_protocol = value.parse::<u32>().ok(),
408 "cargo_version" => f.rust.rustc_version = Some(value.to_string()),
409 "toolchain" => f.rust.toolchains.push(value.to_string()),
410 "target" => f.rust.targets.push(value.to_string()),
411 "component" => {
417 if let Some((toolchain, component)) = value.split_once(':') {
418 let toolchain = toolchain.trim();
419 let component = component.trim();
420 if !toolchain.is_empty() && !component.is_empty() {
421 f.rust.components.push(format!("{toolchain}:{component}"));
422 }
423 }
424 }
425 "tool" => {
426 if !value.is_empty() && !f.tools_present.iter().any(|t| t == value) {
427 f.tools_present.push(value.to_string());
428 }
429 }
430 "tool_absent" => {
431 if !value.is_empty() && !f.tools_absent.iter().any(|t| t == value) {
432 f.tools_absent.push(value.to_string());
433 }
434 }
435 "bun_version" => f.runtimes.bun_version = Some(value.to_string()),
436 "node_version" => f.runtimes.node_version = Some(value.to_string()),
437 "npm_version" => f.runtimes.npm_version = Some(value.to_string()),
438 "nix_version" => f.runtimes.nix_version = Some(value.to_string()),
439 "go_version" => f.runtimes.go_version = Some(value.to_string()),
440 "zig_version" => f.runtimes.zig_version = Some(value.to_string()),
441 "cargo_zigbuild_version" => f.runtimes.cargo_zigbuild_version = Some(value.to_string()),
442 "disk" => {
443 let parts: Vec<&str> = value.split(';').collect();
445 if parts.len() == 4 {
446 let kb = |s: &str| s.parse::<u64>().unwrap_or(0).saturating_mul(1024);
447 f.disk_roots.push(DiskRootFacts {
448 path: parts[0].to_string(),
449 total_bytes: kb(parts[1]),
450 available_bytes: kb(parts[2]),
451 available_inodes: parts[3].parse::<u64>().unwrap_or(0),
452 });
453 }
454 }
455 _ => {}
456 }
457 }
458 if let Some(version) = f.worker_version.clone() {
461 f.worker = Some(WorkerBinaryFacts {
462 rch_wkr_path: f.rch_wkr_path.clone().unwrap_or_default(),
463 version,
464 protocol_version: f.worker_protocol.unwrap_or(0),
465 });
466 }
467 f
468}
469
470#[derive(Debug, Clone, Default)]
472pub struct CapabilityRequirement {
473 pub needs_targets: Vec<String>,
475 pub min_protocol: u32,
477 pub needs_cargo: bool,
479 pub needs_os: Option<String>,
482 pub needs_arch: Option<String>,
484 pub needs_bun: bool,
486 pub needs_node: bool,
488 pub needs_go: bool,
490 pub needs_zig: bool,
493 pub needs_nix: bool,
495 pub needs_toolchains: Vec<String>,
499 pub needs_components: Vec<String>,
511 pub needs_tools: Vec<String>,
521}
522
523impl CapabilityRequirement {
524 #[must_use]
526 pub fn rust(min_protocol: u32) -> Self {
527 Self {
528 min_protocol,
529 needs_cargo: true,
530 ..Self::default()
531 }
532 }
533
534 #[must_use]
536 pub fn with_targets<I, S>(mut self, targets: I) -> Self
537 where
538 I: IntoIterator<Item = S>,
539 S: Into<String>,
540 {
541 self.needs_targets = targets.into_iter().map(Into::into).collect();
542 self
543 }
544
545 #[must_use]
547 pub fn with_os(mut self, os: impl Into<String>) -> Self {
548 self.needs_os = Some(os.into());
549 self
550 }
551
552 #[must_use]
554 pub fn with_arch(mut self, arch: impl Into<String>) -> Self {
555 self.needs_arch = Some(arch.into());
556 self
557 }
558
559 #[must_use]
561 pub fn with_tools<I, S>(mut self, tools: I) -> Self
562 where
563 I: IntoIterator<Item = S>,
564 S: Into<String>,
565 {
566 self.needs_tools = tools.into_iter().map(Into::into).collect();
567 self
568 }
569
570 #[must_use]
572 pub fn with_toolchains<I, S>(mut self, toolchains: I) -> Self
573 where
574 I: IntoIterator<Item = S>,
575 S: Into<String>,
576 {
577 self.needs_toolchains = toolchains.into_iter().map(Into::into).collect();
578 self
579 }
580}
581
582#[derive(Debug, Clone, PartialEq, Eq)]
585pub enum CapabilityVerdict {
586 Admissible,
588 Rejected {
590 reason: IncidentReasonCode,
591 detail: String,
592 },
593}
594
595#[must_use]
599pub fn assess_admissibility(facts: &ProbedFacts, req: &CapabilityRequirement) -> CapabilityVerdict {
600 let Some(worker) = &facts.worker else {
603 return CapabilityVerdict::Rejected {
604 reason: IncidentReasonCode::WrongUserPathWorkerBinary,
605 detail: format!(
606 "rch-wkr at {} did not report a version as the configured user",
607 facts.rch_wkr_path.as_deref().unwrap_or("<unknown path>")
608 ),
609 };
610 };
611 if worker.protocol_version < req.min_protocol {
612 return CapabilityVerdict::Rejected {
613 reason: IncidentReasonCode::WrongUserPathWorkerBinary,
614 detail: format!(
615 "worker protocol {} < required {}",
616 worker.protocol_version, req.min_protocol
617 ),
618 };
619 }
620 if let Some(needed_os) = &req.needs_os {
623 let matches = facts
624 .os
625 .as_deref()
626 .is_some_and(|os| os.eq_ignore_ascii_case(needed_os));
627 if !matches {
628 return CapabilityVerdict::Rejected {
629 reason: IncidentReasonCode::OsArchMismatch,
630 detail: format!(
631 "worker OS {} does not satisfy required {}",
632 facts.os.as_deref().unwrap_or("<unknown>"),
633 needed_os
634 ),
635 };
636 }
637 }
638 if let Some(needed_arch) = &req.needs_arch {
639 let matches = facts
640 .arch
641 .as_deref()
642 .is_some_and(|arch| arch.eq_ignore_ascii_case(needed_arch));
643 if !matches {
644 return CapabilityVerdict::Rejected {
645 reason: IncidentReasonCode::OsArchMismatch,
646 detail: format!(
647 "worker arch {} does not satisfy required {}",
648 facts.arch.as_deref().unwrap_or("<unknown>"),
649 needed_arch
650 ),
651 };
652 }
653 }
654 if req.needs_cargo && facts.rust.rustc_version.is_none() {
655 return CapabilityVerdict::Rejected {
656 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
657 detail: "cargo/rust toolchain not found at the configured user/path".to_string(),
658 };
659 }
660 for needed in &req.needs_toolchains {
661 if !facts
666 .rust
667 .toolchains
668 .iter()
669 .any(|t| t == needed || t.starts_with(&format!("{needed}-")))
670 {
671 return CapabilityVerdict::Rejected {
672 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
673 detail: format!("missing rustup toolchain {needed}"),
674 };
675 }
676 }
677 for needed in &req.needs_targets {
678 if !facts.rust.targets.iter().any(|t| t == needed) {
679 return CapabilityVerdict::Rejected {
680 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
681 detail: format!("missing rustup target {needed}"),
682 };
683 }
684 }
685 for needed in &req.needs_components {
691 let Some((toolchain, component)) = needed.split_once(':') else {
692 return CapabilityVerdict::Rejected {
693 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
694 detail: format!(
695 "malformed component requirement {needed}; expected <toolchain>:<component>"
696 ),
697 };
698 };
699 let prefix = format!("{toolchain}-");
700 let present = facts.rust.components.iter().any(|entry| {
701 entry.split_once(':').is_some_and(|(owner, name)| {
702 name == component && (owner == toolchain || owner.starts_with(&prefix))
703 })
704 });
705 if !present {
706 return CapabilityVerdict::Rejected {
707 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
708 detail: format!("missing rustup component {component} for toolchain {toolchain}"),
709 };
710 }
711 }
712 for needed in &req.needs_tools {
718 if !facts.has_tool(needed) {
719 return CapabilityVerdict::Rejected {
720 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
721 detail: if facts.declares_tool(needed) {
722 format!(
723 "required tool `{needed}` is declared for this worker but its probe did not succeed"
724 )
725 } else {
726 format!("required tool `{needed}` has no declared probe on this worker")
727 },
728 };
729 }
730 }
731 if req.needs_bun && facts.runtimes.bun_version.is_none() {
732 return CapabilityVerdict::Rejected {
733 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
734 detail: "bun runtime not found at the configured user/path".to_string(),
735 };
736 }
737 if req.needs_node && facts.runtimes.node_version.is_none() {
738 return CapabilityVerdict::Rejected {
739 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
740 detail: "node runtime not found at the configured user/path".to_string(),
741 };
742 }
743 if req.needs_nix && facts.runtimes.nix_version.is_none() {
744 return CapabilityVerdict::Rejected {
745 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
746 detail: "nix (binary + populated /nix/store) not found on the worker".to_string(),
747 };
748 }
749 if req.needs_go && facts.runtimes.go_version.is_none() {
750 return CapabilityVerdict::Rejected {
751 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
752 detail: "go toolchain not found at the configured user/path".to_string(),
753 };
754 }
755 if req.needs_zig
760 && (facts.runtimes.zig_version.is_none() || facts.runtimes.cargo_zigbuild_version.is_none())
761 {
762 return CapabilityVerdict::Rejected {
763 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
764 detail: "zig + cargo-zigbuild not found on the worker".to_string(),
765 };
766 }
767 CapabilityVerdict::Admissible
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq)]
776pub struct WorkerLiveness {
777 pub telemetry_stale: bool,
779 pub healthy: bool,
781 pub has_free_slot: bool,
783}
784
785impl WorkerLiveness {
786 #[must_use]
788 pub fn ready() -> Self {
789 Self {
790 telemetry_stale: false,
791 healthy: true,
792 has_free_slot: true,
793 }
794 }
795}
796
797#[derive(Debug, Clone, PartialEq, Eq)]
802pub enum EligibilityVerdict {
803 Eligible,
805 MissingCapability {
807 reason: IncidentReasonCode,
808 detail: String,
809 },
810 StaleTelemetry,
812 Unhealthy,
814 Busy,
816}
817
818impl EligibilityVerdict {
819 #[must_use]
821 pub fn reason(&self) -> Option<IncidentReasonCode> {
822 match self {
823 EligibilityVerdict::Eligible => None,
824 EligibilityVerdict::MissingCapability { reason, .. } => Some(*reason),
825 EligibilityVerdict::StaleTelemetry => Some(IncidentReasonCode::TelemetryStale),
826 EligibilityVerdict::Unhealthy => Some(IncidentReasonCode::CircuitOpen),
827 EligibilityVerdict::Busy => Some(IncidentReasonCode::InsufficientSlots),
828 }
829 }
830
831 #[must_use]
833 pub fn is_eligible(&self) -> bool {
834 matches!(self, EligibilityVerdict::Eligible)
835 }
836}
837
838#[must_use]
846pub fn assess_worker_eligibility(
847 facts: &ProbedFacts,
848 req: &CapabilityRequirement,
849 liveness: &WorkerLiveness,
850) -> EligibilityVerdict {
851 if liveness.telemetry_stale {
852 return EligibilityVerdict::StaleTelemetry;
853 }
854 if let CapabilityVerdict::Rejected { reason, detail } = assess_admissibility(facts, req) {
855 return EligibilityVerdict::MissingCapability { reason, detail };
856 }
857 if !liveness.healthy {
858 return EligibilityVerdict::Unhealthy;
859 }
860 if !liveness.has_free_slot {
861 return EligibilityVerdict::Busy;
862 }
863 EligibilityVerdict::Eligible
864}
865
866#[cfg(test)]
867mod tests {
868 use super::*;
869
870 #[test]
871 fn test_normalize_probed_os_collapses_windows_shells() {
872 for raw in [
875 "MINGW64_NT-10.0-26100",
876 "mingw32_nt-10.0-22631",
877 "MSYS_NT-10.0-26100",
878 "CYGWIN_NT-10.0",
879 ] {
880 assert_eq!(normalize_probed_os(raw), "windows", "raw={raw}");
881 }
882 }
883
884 #[test]
885 fn test_normalize_probed_os_passes_through_unix() {
886 assert_eq!(normalize_probed_os("Linux"), "linux");
887 assert_eq!(normalize_probed_os("Darwin"), "darwin");
888 assert_eq!(normalize_probed_os(" linux "), "linux");
889 }
890
891 #[test]
892 fn test_parse_normalizes_windows_os_fact() {
893 let facts =
894 parse_capability_probe("RCH_FACT os=MINGW64_NT-10.0-26100\nRCH_FACT arch=x86_64\n");
895 assert_eq!(facts.os.as_deref(), Some("windows"));
896 }
897
898 fn spec() -> ProbeSpec {
899 let mut s = ProbeSpec::new("rch", "/home/rch/.local/bin/rch-wkr");
900 s.cargo_path = Some("/home/rch/.cargo/bin/cargo".to_string());
901 s.disk_roots = vec!["/data/tmp".to_string()];
902 s
903 }
904
905 fn tool(name: &str, argv: &[&str]) -> NamedToolProbe {
906 NamedToolProbe::new(name, argv.iter().copied()).expect("valid probe")
907 }
908
909 #[test]
910 fn named_tool_probe_refuses_names_that_cannot_survive_the_wire_format() {
911 for bad in [
915 "",
916 "clang=1",
917 "two words",
918 "new\nline",
919 "tab\there",
920 "sh;rm",
921 ] {
922 assert!(
923 NamedToolProbe::new(bad, ["true"]).is_err(),
924 "{bad:?} must be refused"
925 );
926 }
927 for good in [
928 "clang",
929 "ld.lld",
930 "cargo-nextest",
931 "gcc-14",
932 "llvm_cov",
933 "g++",
934 ] {
935 assert!(
936 NamedToolProbe::new(good, ["true"]).is_ok(),
937 "{good:?} must be accepted"
938 );
939 }
940 assert!(matches!(
942 NamedToolProbe::new("clang", Vec::<String>::new()),
943 Err(ToolProbeError::EmptyCommand { .. })
944 ));
945 }
946
947 #[test]
948 fn script_probes_declared_tools_by_exit_status_and_reports_both_outcomes() {
949 let spec = spec().with_tools([
950 tool("clang", &["clang", "--version"]),
951 tool("ld.lld", &["/usr/bin/ld.lld", "--version"]),
952 ]);
953 let script = build_capability_probe_script(&spec);
954 assert!(script.contains("if 'clang' '--version' >/dev/null 2>&1;"));
958 assert!(script.contains("if '/usr/bin/ld.lld' '--version' >/dev/null 2>&1;"));
959 assert!(script.contains("tool=%s"));
960 assert!(script.contains("tool_absent=%s"));
961 assert!(!build_capability_probe_script(&self::spec()).contains("tool="));
963 }
964
965 #[test]
966 fn parsed_tool_facts_separate_verified_from_declared_but_failing() {
967 let facts = parse_capability_probe(
968 "RCH_FACT os=linux\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\nRCH_FACT tool=clang\n",
969 );
970 assert_eq!(facts.tools_present, vec!["clang".to_string()], "deduped");
971 assert_eq!(facts.tools_absent, vec!["ld.lld".to_string()]);
972 assert!(facts.has_tool("clang"));
973 assert!(!facts.has_tool("ld.lld"));
974 assert!(facts.declares_tool("ld.lld"));
976 assert!(!facts.declares_tool("wild"));
977 }
978
979 #[test]
980 fn tool_requirements_gate_admissibility_and_unknown_names_are_inadmissible() {
981 let facts = parse_capability_probe(
982 "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT rch_wkr_path=/w\n\
983 RCH_FACT worker_version=1\nRCH_FACT worker_protocol=9\n\
984 RCH_FACT cargo_version=cargo 1.99\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\n",
985 );
986 let base = CapabilityRequirement::rust(1);
987 assert_eq!(
989 assess_admissibility(&facts, &base),
990 CapabilityVerdict::Admissible
991 );
992 assert_eq!(
994 assess_admissibility(&facts, &base.clone().with_tools(["clang"])),
995 CapabilityVerdict::Admissible
996 );
997 let CapabilityVerdict::Rejected { reason, detail } =
999 assess_admissibility(&facts, &base.clone().with_tools(["ld.lld"]))
1000 else {
1001 panic!("a failing probe must not be admissible");
1002 };
1003 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1004 assert!(detail.contains("did not succeed"), "{detail}");
1005 let CapabilityVerdict::Rejected { detail, .. } =
1009 assess_admissibility(&facts, &base.clone().with_tools(["clanggg"]))
1010 else {
1011 panic!("an undeclared tool must not be admissible");
1012 };
1013 assert!(detail.contains("no declared probe"), "{detail}");
1014 assert!(matches!(
1016 assess_admissibility(&facts, &base.with_tools(["clang", "ld.lld"])),
1017 CapabilityVerdict::Rejected { .. }
1018 ));
1019 }
1020
1021 #[test]
1022 fn script_uses_exact_paths_and_probes_all_component_facts() {
1023 let script = build_capability_probe_script(&spec());
1024 assert!(script.contains("/home/rch/.local/bin/rch-wkr"));
1026 assert!(script.contains("/home/rch/.cargo/bin/cargo"));
1027 assert!(script.contains("--version"));
1029 assert!(script.contains("--protocol-version"));
1030 assert!(script.contains("toolchain list"));
1031 assert!(script.contains("target list --installed"));
1032 assert!(script.contains("component list --installed --toolchain"));
1037 assert!(script.contains("rustc -vV"));
1038 assert!(script.contains("sub(\"-\" h \"$\""));
1039 assert!(
1040 script.contains("for tc in"),
1041 "component probe must loop over every installed toolchain"
1042 );
1043 assert!(script.contains("bun --version"));
1044 assert!(script.contains("df -Pk"));
1045 assert!(script.contains("df -Pi"));
1046 assert!(script.contains("'/data/tmp'"));
1048 }
1049
1050 #[cfg(unix)]
1051 #[test]
1052 fn live_disk_probe_measures_future_roots_without_creating_them() {
1053 let root = tempfile::tempdir().unwrap();
1054 let parent = root.path().join("worker's build volume");
1055 std::fs::create_dir(&parent).unwrap();
1056 let future = parent.join("not-created/cargo-target");
1057 let not_directory = root.path().join("file");
1058 std::fs::write(¬_directory, b"not a build root").unwrap();
1059 let mut spec = ProbeSpec::new("probe", "/not-installed/rch-wkr");
1060 spec.disk_roots = vec![
1061 parent.to_str().unwrap().to_owned(),
1062 future.to_str().unwrap().to_owned(),
1063 not_directory.to_str().unwrap().to_owned(),
1064 ];
1065 let output = std::process::Command::new("sh")
1066 .arg("-c")
1067 .arg(build_capability_probe_script(&spec))
1068 .output()
1069 .unwrap();
1070 assert!(
1071 output.status.success(),
1072 "{}",
1073 String::from_utf8_lossy(&output.stderr)
1074 );
1075 let facts = parse_capability_probe(std::str::from_utf8(&output.stdout).unwrap());
1076 assert_eq!(facts.disk_roots.len(), 2, "{:?}", facts.disk_roots);
1077 assert_eq!(facts.disk_roots[0].path, spec.disk_roots[0]);
1078 assert_eq!(facts.disk_roots[1].path, spec.disk_roots[1]);
1079 assert!(facts.disk_roots[0].total_bytes > 0);
1080 assert_eq!(
1081 facts.disk_roots[0].total_bytes,
1082 facts.disk_roots[1].total_bytes
1083 );
1084 assert!(
1085 !future.exists(),
1086 "a read-only disk probe must not create build directories"
1087 );
1088 assert_eq!(std::fs::read(¬_directory).unwrap(), b"not a build root");
1089 }
1090
1091 fn good_output() -> &'static str {
1092 "RCH_FACT os=linux\n\
1093 RCH_FACT arch=x86_64\n\
1094 RCH_FACT user=rch\n\
1095 RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1096 RCH_FACT worker_version=1.0.41\n\
1097 RCH_FACT worker_protocol=3\n\
1098 RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1099 RCH_FACT toolchain=stable\n\
1100 RCH_FACT toolchain=nightly-2026-05-22\n\
1101 RCH_FACT target=x86_64-unknown-linux-gnu\n\
1102 RCH_FACT target=wasm32-unknown-unknown\n\
1103 RCH_FACT component=stable:clippy\n\
1104 RCH_FACT component=stable:rustfmt\n\
1105 RCH_FACT component=nightly-2026-05-22:rustfmt\n\
1106 RCH_FACT component=malformed-without-separator\n\
1107 RCH_FACT bun_version=1.1.0\n\
1108 RCH_FACT disk=/data/tmp;1048576;524288;900000\n\
1109 incidental noise line that must be ignored\n"
1110 }
1111
1112 #[test]
1113 fn parses_full_probe_output() {
1114 let f = parse_capability_probe(good_output());
1115 assert_eq!(f.os.as_deref(), Some("linux"));
1116 assert_eq!(f.arch.as_deref(), Some("x86_64"));
1117 assert_eq!(f.probed_user.as_deref(), Some("rch"));
1118 let w = f.worker.as_ref().unwrap();
1119 assert_eq!(w.version, "1.0.41");
1120 assert_eq!(w.protocol_version, 3);
1121 assert_eq!(w.rch_wkr_path, "/home/rch/.local/bin/rch-wkr");
1122 assert_eq!(f.rust.toolchains, vec!["stable", "nightly-2026-05-22"]);
1123 assert!(f.rust.targets.iter().any(|t| t == "wasm32-unknown-unknown"));
1124 assert_eq!(
1129 f.rust.components,
1130 vec![
1131 "stable:clippy",
1132 "stable:rustfmt",
1133 "nightly-2026-05-22:rustfmt"
1134 ]
1135 );
1136 assert_eq!(f.runtimes.bun_version.as_deref(), Some("1.1.0"));
1137 assert_eq!(f.disk_roots.len(), 1);
1138 assert_eq!(f.disk_roots[0].path, "/data/tmp");
1139 assert_eq!(f.disk_roots[0].available_bytes, 524288 * 1024);
1140 assert_eq!(f.disk_roots[0].available_inodes, 900000);
1141 assert_eq!(
1142 f.target_triple().as_deref(),
1143 Some("x86_64-unknown-linux-gnu")
1144 );
1145 }
1146
1147 fn req_wasm() -> CapabilityRequirement {
1148 CapabilityRequirement {
1149 needs_targets: vec!["wasm32-unknown-unknown".to_string()],
1150 min_protocol: 3,
1151 needs_cargo: true,
1152 ..CapabilityRequirement::default()
1153 }
1154 }
1155
1156 #[test]
1157 fn admissible_when_all_capabilities_present() {
1158 let f = parse_capability_probe(good_output());
1159 assert_eq!(
1160 assess_admissibility(&f, &req_wasm()),
1161 CapabilityVerdict::Admissible
1162 );
1163 }
1164
1165 #[test]
1166 fn component_requirement_is_toolchain_scoped() {
1167 let f = parse_capability_probe(good_output());
1168 let with_components = |needed: &[&str]| CapabilityRequirement {
1169 needs_components: needed.iter().map(|s| (*s).to_string()).collect(),
1170 ..req_wasm()
1171 };
1172
1173 assert_eq!(
1176 assess_admissibility(&f, &with_components(&["stable:clippy"])),
1177 CapabilityVerdict::Admissible
1178 );
1179 assert_eq!(
1180 assess_admissibility(&f, &with_components(&["nightly:rustfmt"])),
1181 CapabilityVerdict::Admissible
1182 );
1183
1184 match assess_admissibility(&f, &with_components(&["nightly:clippy"])) {
1187 CapabilityVerdict::Rejected { reason, detail } => {
1188 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1189 assert!(
1190 detail.contains("clippy") && detail.contains("nightly"),
1191 "rejection must name the component and toolchain: {detail}"
1192 );
1193 }
1194 other => panic!("expected rejection, got {other:?}"),
1195 }
1196
1197 match assess_admissibility(&f, &with_components(&["clippy"])) {
1200 CapabilityVerdict::Rejected { detail, .. } => {
1201 assert!(detail.contains("malformed"), "got {detail}");
1202 }
1203 other => panic!("expected rejection, got {other:?}"),
1204 }
1205 }
1206
1207 #[test]
1208 fn no_component_requirement_constrains_nothing() {
1209 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT user=rch\n\
1212 RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1213 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1214 RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1215 RCH_FACT target=wasm32-unknown-unknown\n";
1216 let f = parse_capability_probe(out);
1217 assert!(f.rust.components.is_empty());
1218 assert_eq!(
1219 assess_admissibility(&f, &req_wasm()),
1220 CapabilityVerdict::Admissible
1221 );
1222 }
1223
1224 #[test]
1225 fn user_broken_binary_rejected_distinct_from_unreachable() {
1226 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1230 RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n";
1231 let f = parse_capability_probe(out);
1232 assert!(f.worker.is_none());
1233 match assess_admissibility(&f, &req_wasm()) {
1234 CapabilityVerdict::Rejected { reason, .. } => {
1235 assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1236 }
1237 other => panic!("expected rejection, got {other:?}"),
1238 }
1239 }
1240
1241 #[test]
1242 fn missing_rustup_rejected_for_cargo_requirement() {
1243 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1245 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1246 let f = parse_capability_probe(out);
1247 match assess_admissibility(&f, &req_wasm()) {
1248 CapabilityVerdict::Rejected { reason, detail } => {
1249 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1250 assert!(detail.contains("cargo"));
1251 }
1252 other => panic!("expected rejection, got {other:?}"),
1253 }
1254 }
1255
1256 #[test]
1257 fn missing_wasm_target_rejected() {
1258 let out = "RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1260 RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n";
1261 let f = parse_capability_probe(out);
1262 match assess_admissibility(&f, &req_wasm()) {
1263 CapabilityVerdict::Rejected { reason, detail } => {
1264 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1265 assert!(detail.contains("wasm32-unknown-unknown"));
1266 }
1267 other => panic!("expected rejection, got {other:?}"),
1268 }
1269 }
1270
1271 #[test]
1272 fn stale_worker_protocol_rejected() {
1273 let out = "RCH_FACT worker_version=0.9.0\nRCH_FACT worker_protocol=1\n\
1274 RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=wasm32-unknown-unknown\n";
1275 let f = parse_capability_probe(out);
1276 match assess_admissibility(&f, &req_wasm()) {
1277 CapabilityVerdict::Rejected { reason, detail } => {
1278 assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1279 assert!(detail.contains("protocol"));
1280 }
1281 other => panic!("expected rejection, got {other:?}"),
1282 }
1283 }
1284
1285 #[test]
1286 fn empty_output_parses_empty() {
1287 let f = parse_capability_probe("");
1289 assert_eq!(f, ProbedFacts::default());
1290 assert!(f.worker.is_none());
1291 assert!(f.os.is_none());
1292 }
1293
1294 #[test]
1297 fn os_mismatch_rejected_with_osarch_reason() {
1298 let f = parse_capability_probe(good_output());
1300 let req = CapabilityRequirement::rust(3).with_os("darwin");
1301 match assess_admissibility(&f, &req) {
1302 CapabilityVerdict::Rejected { reason, detail } => {
1303 assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1304 assert!(detail.contains("darwin"));
1305 }
1306 other => panic!("expected OS-mismatch rejection, got {other:?}"),
1307 }
1308 assert_eq!(
1310 assess_admissibility(&f, &CapabilityRequirement::rust(3).with_os("linux")),
1311 CapabilityVerdict::Admissible
1312 );
1313 }
1314
1315 #[test]
1316 fn arch_mismatch_rejected_with_osarch_reason() {
1317 let f = parse_capability_probe(good_output());
1318 let req = CapabilityRequirement::rust(3).with_arch("aarch64");
1319 match assess_admissibility(&f, &req) {
1320 CapabilityVerdict::Rejected { reason, .. } => {
1321 assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1322 }
1323 other => panic!("expected arch-mismatch rejection, got {other:?}"),
1324 }
1325 }
1326
1327 #[test]
1328 fn missing_node_runtime_rejected() {
1329 let f = parse_capability_probe(good_output());
1331 let mut req = CapabilityRequirement::rust(3);
1332 req.needs_node = true;
1333 match assess_admissibility(&f, &req) {
1334 CapabilityVerdict::Rejected { reason, detail } => {
1335 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1336 assert!(detail.contains("node"));
1337 }
1338 other => panic!("expected node rejection, got {other:?}"),
1339 }
1340 let mut bun_req = CapabilityRequirement::rust(3);
1342 bun_req.needs_bun = true;
1343 assert_eq!(
1344 assess_admissibility(&f, &bun_req),
1345 CapabilityVerdict::Admissible
1346 );
1347 }
1348
1349 #[test]
1350 fn specific_toolchain_prefix_matched() {
1351 let f = parse_capability_probe(good_output());
1352 let ok = CapabilityRequirement::rust(3).with_toolchains(["nightly-2026-05-22"]);
1354 assert_eq!(assess_admissibility(&f, &ok), CapabilityVerdict::Admissible);
1355 let missing = CapabilityRequirement::rust(3).with_toolchains(["nightly-2025-01-01"]);
1357 match assess_admissibility(&f, &missing) {
1358 CapabilityVerdict::Rejected { reason, detail } => {
1359 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1360 assert!(detail.contains("nightly-2025-01-01"));
1361 }
1362 other => panic!("expected toolchain rejection, got {other:?}"),
1363 }
1364 }
1365
1366 #[test]
1370 fn eligible_when_capable_fresh_healthy_idle() {
1371 let f = parse_capability_probe(good_output());
1372 let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1373 assert_eq!(v, EligibilityVerdict::Eligible);
1374 assert!(v.is_eligible());
1375 assert_eq!(v.reason(), None);
1376 }
1377
1378 #[test]
1379 fn no_worker_with_runtime_is_missing_capability_not_busy() {
1380 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1383 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1384 let f = parse_capability_probe(out);
1385 let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1386 assert_eq!(
1387 v.reason(),
1388 Some(IncidentReasonCode::MissingRuntimeToolchainTarget)
1389 );
1390 assert!(matches!(v, EligibilityVerdict::MissingCapability { .. }));
1391 }
1392
1393 #[test]
1394 fn os_arch_mismatch_surfaces_as_missing_capability() {
1395 let f = parse_capability_probe(good_output());
1396 let req = CapabilityRequirement::rust(3).with_os("darwin");
1397 let v = assess_worker_eligibility(&f, &req, &WorkerLiveness::ready());
1398 assert_eq!(v.reason(), Some(IncidentReasonCode::OsArchMismatch));
1399 }
1400
1401 #[test]
1402 fn unhealthy_and_busy_are_distinct_from_missing_capability() {
1403 let f = parse_capability_probe(good_output());
1404 let unhealthy = WorkerLiveness {
1406 telemetry_stale: false,
1407 healthy: false,
1408 has_free_slot: true,
1409 };
1410 assert_eq!(
1411 assess_worker_eligibility(&f, &req_wasm(), &unhealthy).reason(),
1412 Some(IncidentReasonCode::CircuitOpen)
1413 );
1414 let busy = WorkerLiveness {
1416 telemetry_stale: false,
1417 healthy: true,
1418 has_free_slot: false,
1419 };
1420 assert_eq!(
1421 assess_worker_eligibility(&f, &req_wasm(), &busy).reason(),
1422 Some(IncidentReasonCode::InsufficientSlots)
1423 );
1424 }
1425
1426 #[test]
1427 fn stale_capability_cache_is_distinct_reason() {
1428 let f = parse_capability_probe(good_output());
1429 let stale = WorkerLiveness {
1430 telemetry_stale: true,
1431 healthy: true,
1432 has_free_slot: true,
1433 };
1434 let v = assess_worker_eligibility(&f, &req_wasm(), &stale);
1435 assert_eq!(v, EligibilityVerdict::StaleTelemetry);
1436 assert_eq!(v.reason(), Some(IncidentReasonCode::TelemetryStale));
1437 }
1438
1439 #[test]
1440 fn capability_refresh_changes_eligibility() {
1441 let before = parse_capability_probe(
1443 "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1444 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1445 RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n",
1446 );
1447 let v_before = assess_worker_eligibility(&before, &req_wasm(), &WorkerLiveness::ready());
1448 assert!(matches!(
1449 v_before,
1450 EligibilityVerdict::MissingCapability { .. }
1451 ));
1452
1453 let after = parse_capability_probe(good_output());
1455 let v_after = assess_worker_eligibility(&after, &req_wasm(), &WorkerLiveness::ready());
1456 assert_eq!(v_after, EligibilityVerdict::Eligible);
1457 }
1458
1459 #[test]
1460 fn remote_worker_binary_path_is_absolute_per_user() {
1461 assert_eq!(
1462 remote_worker_binary_path("root", None),
1463 "/root/.local/bin/rch-wkr"
1464 );
1465 assert_eq!(
1466 remote_worker_binary_path("ubuntu", Some("linux")),
1467 "/home/ubuntu/.local/bin/rch-wkr"
1468 );
1469 for user in ["root", "rch", "ubuntu"] {
1472 let path = remote_worker_binary_path(user, None);
1473 assert!(!path.contains('~'), "{path} must not contain a tilde");
1474 assert!(path.starts_with('/'), "{path} must be absolute");
1475 let spec = ProbeSpec::new(user, path);
1476 let script = build_capability_probe_script(&spec);
1477 assert!(script.contains("/.local/bin/rch-wkr'"));
1480 assert!(!script.contains("'~/.local/bin/rch-wkr'"));
1481 }
1482 }
1483
1484 #[test]
1485 fn remote_worker_binary_path_uses_git_bash_home_for_windows() {
1486 assert_eq!(
1487 remote_worker_binary_path("jeffr", Some("windows")),
1488 "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1489 );
1490 assert_eq!(
1491 remote_worker_binary_path("jeffr", Some("WINDOWS")),
1492 "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1493 );
1494 }
1495}