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 let Some(check) = crate::tool_requirement::check_exact_runtime(
721 needed,
722 facts.runtimes.node_version.as_deref(),
723 facts.runtimes.bun_version.as_deref(),
724 ) {
725 if let Err(detail) = check {
726 return CapabilityVerdict::Rejected {
727 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
728 detail,
729 };
730 }
731 continue;
732 }
733 if !facts.has_tool(needed) {
734 return CapabilityVerdict::Rejected {
735 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
736 detail: if facts.declares_tool(needed) {
737 format!(
738 "required tool `{needed}` is declared for this worker but its probe did not succeed"
739 )
740 } else {
741 format!("required tool `{needed}` has no declared probe on this worker")
742 },
743 };
744 }
745 }
746 if req.needs_bun && facts.runtimes.bun_version.is_none() {
747 return CapabilityVerdict::Rejected {
748 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
749 detail: "bun runtime not found at the configured user/path".to_string(),
750 };
751 }
752 if req.needs_node && facts.runtimes.node_version.is_none() {
753 return CapabilityVerdict::Rejected {
754 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
755 detail: "node runtime not found at the configured user/path".to_string(),
756 };
757 }
758 if req.needs_nix && facts.runtimes.nix_version.is_none() {
759 return CapabilityVerdict::Rejected {
760 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
761 detail: "nix (binary + populated /nix/store) not found on the worker".to_string(),
762 };
763 }
764 if req.needs_go && facts.runtimes.go_version.is_none() {
765 return CapabilityVerdict::Rejected {
766 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
767 detail: "go toolchain not found at the configured user/path".to_string(),
768 };
769 }
770 if req.needs_zig
775 && (facts.runtimes.zig_version.is_none() || facts.runtimes.cargo_zigbuild_version.is_none())
776 {
777 return CapabilityVerdict::Rejected {
778 reason: IncidentReasonCode::MissingRuntimeToolchainTarget,
779 detail: "zig + cargo-zigbuild not found on the worker".to_string(),
780 };
781 }
782 CapabilityVerdict::Admissible
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
791pub struct WorkerLiveness {
792 pub telemetry_stale: bool,
794 pub healthy: bool,
796 pub has_free_slot: bool,
798}
799
800impl WorkerLiveness {
801 #[must_use]
803 pub fn ready() -> Self {
804 Self {
805 telemetry_stale: false,
806 healthy: true,
807 has_free_slot: true,
808 }
809 }
810}
811
812#[derive(Debug, Clone, PartialEq, Eq)]
817pub enum EligibilityVerdict {
818 Eligible,
820 MissingCapability {
822 reason: IncidentReasonCode,
823 detail: String,
824 },
825 StaleTelemetry,
827 Unhealthy,
829 Busy,
831}
832
833impl EligibilityVerdict {
834 #[must_use]
836 pub fn reason(&self) -> Option<IncidentReasonCode> {
837 match self {
838 EligibilityVerdict::Eligible => None,
839 EligibilityVerdict::MissingCapability { reason, .. } => Some(*reason),
840 EligibilityVerdict::StaleTelemetry => Some(IncidentReasonCode::TelemetryStale),
841 EligibilityVerdict::Unhealthy => Some(IncidentReasonCode::CircuitOpen),
842 EligibilityVerdict::Busy => Some(IncidentReasonCode::InsufficientSlots),
843 }
844 }
845
846 #[must_use]
848 pub fn is_eligible(&self) -> bool {
849 matches!(self, EligibilityVerdict::Eligible)
850 }
851}
852
853#[must_use]
861pub fn assess_worker_eligibility(
862 facts: &ProbedFacts,
863 req: &CapabilityRequirement,
864 liveness: &WorkerLiveness,
865) -> EligibilityVerdict {
866 if liveness.telemetry_stale {
867 return EligibilityVerdict::StaleTelemetry;
868 }
869 if let CapabilityVerdict::Rejected { reason, detail } = assess_admissibility(facts, req) {
870 return EligibilityVerdict::MissingCapability { reason, detail };
871 }
872 if !liveness.healthy {
873 return EligibilityVerdict::Unhealthy;
874 }
875 if !liveness.has_free_slot {
876 return EligibilityVerdict::Busy;
877 }
878 EligibilityVerdict::Eligible
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884
885 #[test]
886 fn test_normalize_probed_os_collapses_windows_shells() {
887 for raw in [
890 "MINGW64_NT-10.0-26100",
891 "mingw32_nt-10.0-22631",
892 "MSYS_NT-10.0-26100",
893 "CYGWIN_NT-10.0",
894 ] {
895 assert_eq!(normalize_probed_os(raw), "windows", "raw={raw}");
896 }
897 }
898
899 #[test]
900 fn test_normalize_probed_os_passes_through_unix() {
901 assert_eq!(normalize_probed_os("Linux"), "linux");
902 assert_eq!(normalize_probed_os("Darwin"), "darwin");
903 assert_eq!(normalize_probed_os(" linux "), "linux");
904 }
905
906 #[test]
907 fn test_parse_normalizes_windows_os_fact() {
908 let facts =
909 parse_capability_probe("RCH_FACT os=MINGW64_NT-10.0-26100\nRCH_FACT arch=x86_64\n");
910 assert_eq!(facts.os.as_deref(), Some("windows"));
911 }
912
913 fn spec() -> ProbeSpec {
914 let mut s = ProbeSpec::new("rch", "/home/rch/.local/bin/rch-wkr");
915 s.cargo_path = Some("/home/rch/.cargo/bin/cargo".to_string());
916 s.disk_roots = vec!["/data/tmp".to_string()];
917 s
918 }
919
920 fn tool(name: &str, argv: &[&str]) -> NamedToolProbe {
921 NamedToolProbe::new(name, argv.iter().copied()).expect("valid probe")
922 }
923
924 #[test]
925 fn named_tool_probe_refuses_names_that_cannot_survive_the_wire_format() {
926 for bad in [
930 "",
931 "clang=1",
932 "two words",
933 "new\nline",
934 "tab\there",
935 "sh;rm",
936 ] {
937 assert!(
938 NamedToolProbe::new(bad, ["true"]).is_err(),
939 "{bad:?} must be refused"
940 );
941 }
942 for good in [
943 "clang",
944 "ld.lld",
945 "cargo-nextest",
946 "gcc-14",
947 "llvm_cov",
948 "g++",
949 ] {
950 assert!(
951 NamedToolProbe::new(good, ["true"]).is_ok(),
952 "{good:?} must be accepted"
953 );
954 }
955 assert!(matches!(
957 NamedToolProbe::new("clang", Vec::<String>::new()),
958 Err(ToolProbeError::EmptyCommand { .. })
959 ));
960 }
961
962 #[test]
963 fn script_probes_declared_tools_by_exit_status_and_reports_both_outcomes() {
964 let spec = spec().with_tools([
965 tool("clang", &["clang", "--version"]),
966 tool("ld.lld", &["/usr/bin/ld.lld", "--version"]),
967 ]);
968 let script = build_capability_probe_script(&spec);
969 assert!(script.contains("if 'clang' '--version' >/dev/null 2>&1;"));
973 assert!(script.contains("if '/usr/bin/ld.lld' '--version' >/dev/null 2>&1;"));
974 assert!(script.contains("tool=%s"));
975 assert!(script.contains("tool_absent=%s"));
976 assert!(!build_capability_probe_script(&self::spec()).contains("tool="));
978 }
979
980 #[test]
981 fn parsed_tool_facts_separate_verified_from_declared_but_failing() {
982 let facts = parse_capability_probe(
983 "RCH_FACT os=linux\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\nRCH_FACT tool=clang\n",
984 );
985 assert_eq!(facts.tools_present, vec!["clang".to_string()], "deduped");
986 assert_eq!(facts.tools_absent, vec!["ld.lld".to_string()]);
987 assert!(facts.has_tool("clang"));
988 assert!(!facts.has_tool("ld.lld"));
989 assert!(facts.declares_tool("ld.lld"));
991 assert!(!facts.declares_tool("wild"));
992 }
993
994 #[test]
995 fn tool_requirements_gate_admissibility_and_unknown_names_are_inadmissible() {
996 let facts = parse_capability_probe(
997 "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT rch_wkr_path=/w\n\
998 RCH_FACT worker_version=1\nRCH_FACT worker_protocol=9\n\
999 RCH_FACT cargo_version=cargo 1.99\nRCH_FACT tool=clang\nRCH_FACT tool_absent=ld.lld\n",
1000 );
1001 let base = CapabilityRequirement::rust(1);
1002 assert_eq!(
1004 assess_admissibility(&facts, &base),
1005 CapabilityVerdict::Admissible
1006 );
1007 assert_eq!(
1009 assess_admissibility(&facts, &base.clone().with_tools(["clang"])),
1010 CapabilityVerdict::Admissible
1011 );
1012 let CapabilityVerdict::Rejected { reason, detail } =
1014 assess_admissibility(&facts, &base.clone().with_tools(["ld.lld"]))
1015 else {
1016 panic!("a failing probe must not be admissible");
1017 };
1018 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1019 assert!(detail.contains("did not succeed"), "{detail}");
1020 let CapabilityVerdict::Rejected { detail, .. } =
1024 assess_admissibility(&facts, &base.clone().with_tools(["clanggg"]))
1025 else {
1026 panic!("an undeclared tool must not be admissible");
1027 };
1028 assert!(detail.contains("no declared probe"), "{detail}");
1029 assert!(matches!(
1031 assess_admissibility(&facts, &base.with_tools(["clang", "ld.lld"])),
1032 CapabilityVerdict::Rejected { .. }
1033 ));
1034 }
1035
1036 #[test]
1037 fn script_uses_exact_paths_and_probes_all_component_facts() {
1038 let script = build_capability_probe_script(&spec());
1039 assert!(script.contains("/home/rch/.local/bin/rch-wkr"));
1041 assert!(script.contains("/home/rch/.cargo/bin/cargo"));
1042 assert!(script.contains("--version"));
1044 assert!(script.contains("--protocol-version"));
1045 assert!(script.contains("toolchain list"));
1046 assert!(script.contains("target list --installed"));
1047 assert!(script.contains("component list --installed --toolchain"));
1052 assert!(script.contains("rustc -vV"));
1053 assert!(script.contains("sub(\"-\" h \"$\""));
1054 assert!(
1055 script.contains("for tc in"),
1056 "component probe must loop over every installed toolchain"
1057 );
1058 assert!(script.contains("bun --version"));
1059 assert!(script.contains("df -Pk"));
1060 assert!(script.contains("df -Pi"));
1061 assert!(script.contains("'/data/tmp'"));
1063 }
1064
1065 #[cfg(unix)]
1066 #[test]
1067 fn live_disk_probe_measures_future_roots_without_creating_them() {
1068 let root = tempfile::tempdir().unwrap();
1069 let parent = root.path().join("worker's build volume");
1070 std::fs::create_dir(&parent).unwrap();
1071 let future = parent.join("not-created/cargo-target");
1072 let not_directory = root.path().join("file");
1073 std::fs::write(¬_directory, b"not a build root").unwrap();
1074 let mut spec = ProbeSpec::new("probe", "/not-installed/rch-wkr");
1075 spec.disk_roots = vec![
1076 parent.to_str().unwrap().to_owned(),
1077 future.to_str().unwrap().to_owned(),
1078 not_directory.to_str().unwrap().to_owned(),
1079 ];
1080 let output = std::process::Command::new("sh")
1081 .arg("-c")
1082 .arg(build_capability_probe_script(&spec))
1083 .output()
1084 .unwrap();
1085 assert!(
1086 output.status.success(),
1087 "{}",
1088 String::from_utf8_lossy(&output.stderr)
1089 );
1090 let facts = parse_capability_probe(std::str::from_utf8(&output.stdout).unwrap());
1091 assert_eq!(facts.disk_roots.len(), 2, "{:?}", facts.disk_roots);
1092 assert_eq!(facts.disk_roots[0].path, spec.disk_roots[0]);
1093 assert_eq!(facts.disk_roots[1].path, spec.disk_roots[1]);
1094 assert!(facts.disk_roots[0].total_bytes > 0);
1095 assert_eq!(
1096 facts.disk_roots[0].total_bytes,
1097 facts.disk_roots[1].total_bytes
1098 );
1099 assert!(
1100 !future.exists(),
1101 "a read-only disk probe must not create build directories"
1102 );
1103 assert_eq!(std::fs::read(¬_directory).unwrap(), b"not a build root");
1104 }
1105
1106 fn good_output() -> &'static str {
1107 "RCH_FACT os=linux\n\
1108 RCH_FACT arch=x86_64\n\
1109 RCH_FACT user=rch\n\
1110 RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1111 RCH_FACT worker_version=1.0.41\n\
1112 RCH_FACT worker_protocol=3\n\
1113 RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1114 RCH_FACT toolchain=stable\n\
1115 RCH_FACT toolchain=nightly-2026-05-22\n\
1116 RCH_FACT target=x86_64-unknown-linux-gnu\n\
1117 RCH_FACT target=wasm32-unknown-unknown\n\
1118 RCH_FACT component=stable:clippy\n\
1119 RCH_FACT component=stable:rustfmt\n\
1120 RCH_FACT component=nightly-2026-05-22:rustfmt\n\
1121 RCH_FACT component=malformed-without-separator\n\
1122 RCH_FACT bun_version=1.1.0\n\
1123 RCH_FACT disk=/data/tmp;1048576;524288;900000\n\
1124 incidental noise line that must be ignored\n"
1125 }
1126
1127 #[test]
1128 fn parses_full_probe_output() {
1129 let f = parse_capability_probe(good_output());
1130 assert_eq!(f.os.as_deref(), Some("linux"));
1131 assert_eq!(f.arch.as_deref(), Some("x86_64"));
1132 assert_eq!(f.probed_user.as_deref(), Some("rch"));
1133 let w = f.worker.as_ref().unwrap();
1134 assert_eq!(w.version, "1.0.41");
1135 assert_eq!(w.protocol_version, 3);
1136 assert_eq!(w.rch_wkr_path, "/home/rch/.local/bin/rch-wkr");
1137 assert_eq!(f.rust.toolchains, vec!["stable", "nightly-2026-05-22"]);
1138 assert!(f.rust.targets.iter().any(|t| t == "wasm32-unknown-unknown"));
1139 assert_eq!(
1144 f.rust.components,
1145 vec![
1146 "stable:clippy",
1147 "stable:rustfmt",
1148 "nightly-2026-05-22:rustfmt"
1149 ]
1150 );
1151 assert_eq!(f.runtimes.bun_version.as_deref(), Some("1.1.0"));
1152 assert_eq!(f.disk_roots.len(), 1);
1153 assert_eq!(f.disk_roots[0].path, "/data/tmp");
1154 assert_eq!(f.disk_roots[0].available_bytes, 524288 * 1024);
1155 assert_eq!(f.disk_roots[0].available_inodes, 900000);
1156 assert_eq!(
1157 f.target_triple().as_deref(),
1158 Some("x86_64-unknown-linux-gnu")
1159 );
1160 }
1161
1162 fn req_wasm() -> CapabilityRequirement {
1163 CapabilityRequirement {
1164 needs_targets: vec!["wasm32-unknown-unknown".to_string()],
1165 min_protocol: 3,
1166 needs_cargo: true,
1167 ..CapabilityRequirement::default()
1168 }
1169 }
1170
1171 #[test]
1172 fn admissible_when_all_capabilities_present() {
1173 let f = parse_capability_probe(good_output());
1174 assert_eq!(
1175 assess_admissibility(&f, &req_wasm()),
1176 CapabilityVerdict::Admissible
1177 );
1178 }
1179
1180 #[test]
1181 fn component_requirement_is_toolchain_scoped() {
1182 let f = parse_capability_probe(good_output());
1183 let with_components = |needed: &[&str]| CapabilityRequirement {
1184 needs_components: needed.iter().map(|s| (*s).to_string()).collect(),
1185 ..req_wasm()
1186 };
1187
1188 assert_eq!(
1191 assess_admissibility(&f, &with_components(&["stable:clippy"])),
1192 CapabilityVerdict::Admissible
1193 );
1194 assert_eq!(
1195 assess_admissibility(&f, &with_components(&["nightly:rustfmt"])),
1196 CapabilityVerdict::Admissible
1197 );
1198
1199 match assess_admissibility(&f, &with_components(&["nightly:clippy"])) {
1202 CapabilityVerdict::Rejected { reason, detail } => {
1203 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1204 assert!(
1205 detail.contains("clippy") && detail.contains("nightly"),
1206 "rejection must name the component and toolchain: {detail}"
1207 );
1208 }
1209 other => panic!("expected rejection, got {other:?}"),
1210 }
1211
1212 match assess_admissibility(&f, &with_components(&["clippy"])) {
1215 CapabilityVerdict::Rejected { detail, .. } => {
1216 assert!(detail.contains("malformed"), "got {detail}");
1217 }
1218 other => panic!("expected rejection, got {other:?}"),
1219 }
1220 }
1221
1222 #[test]
1223 fn no_component_requirement_constrains_nothing() {
1224 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\nRCH_FACT user=rch\n\
1227 RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n\
1228 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1229 RCH_FACT cargo_version=cargo 1.98.0-nightly\n\
1230 RCH_FACT target=wasm32-unknown-unknown\n";
1231 let f = parse_capability_probe(out);
1232 assert!(f.rust.components.is_empty());
1233 assert_eq!(
1234 assess_admissibility(&f, &req_wasm()),
1235 CapabilityVerdict::Admissible
1236 );
1237 }
1238
1239 #[test]
1240 fn user_broken_binary_rejected_distinct_from_unreachable() {
1241 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1245 RCH_FACT rch_wkr_path=/home/rch/.local/bin/rch-wkr\n";
1246 let f = parse_capability_probe(out);
1247 assert!(f.worker.is_none());
1248 match assess_admissibility(&f, &req_wasm()) {
1249 CapabilityVerdict::Rejected { reason, .. } => {
1250 assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1251 }
1252 other => panic!("expected rejection, got {other:?}"),
1253 }
1254 }
1255
1256 #[test]
1257 fn missing_rustup_rejected_for_cargo_requirement() {
1258 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1260 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\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("cargo"));
1266 }
1267 other => panic!("expected rejection, got {other:?}"),
1268 }
1269 }
1270
1271 #[test]
1272 fn missing_wasm_target_rejected() {
1273 let out = "RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1275 RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n";
1276 let f = parse_capability_probe(out);
1277 match assess_admissibility(&f, &req_wasm()) {
1278 CapabilityVerdict::Rejected { reason, detail } => {
1279 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1280 assert!(detail.contains("wasm32-unknown-unknown"));
1281 }
1282 other => panic!("expected rejection, got {other:?}"),
1283 }
1284 }
1285
1286 #[test]
1287 fn stale_worker_protocol_rejected() {
1288 let out = "RCH_FACT worker_version=0.9.0\nRCH_FACT worker_protocol=1\n\
1289 RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=wasm32-unknown-unknown\n";
1290 let f = parse_capability_probe(out);
1291 match assess_admissibility(&f, &req_wasm()) {
1292 CapabilityVerdict::Rejected { reason, detail } => {
1293 assert_eq!(reason, IncidentReasonCode::WrongUserPathWorkerBinary);
1294 assert!(detail.contains("protocol"));
1295 }
1296 other => panic!("expected rejection, got {other:?}"),
1297 }
1298 }
1299
1300 #[test]
1301 fn empty_output_parses_empty() {
1302 let f = parse_capability_probe("");
1304 assert_eq!(f, ProbedFacts::default());
1305 assert!(f.worker.is_none());
1306 assert!(f.os.is_none());
1307 }
1308
1309 #[test]
1312 fn os_mismatch_rejected_with_osarch_reason() {
1313 let f = parse_capability_probe(good_output());
1315 let req = CapabilityRequirement::rust(3).with_os("darwin");
1316 match assess_admissibility(&f, &req) {
1317 CapabilityVerdict::Rejected { reason, detail } => {
1318 assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1319 assert!(detail.contains("darwin"));
1320 }
1321 other => panic!("expected OS-mismatch rejection, got {other:?}"),
1322 }
1323 assert_eq!(
1325 assess_admissibility(&f, &CapabilityRequirement::rust(3).with_os("linux")),
1326 CapabilityVerdict::Admissible
1327 );
1328 }
1329
1330 #[test]
1331 fn arch_mismatch_rejected_with_osarch_reason() {
1332 let f = parse_capability_probe(good_output());
1333 let req = CapabilityRequirement::rust(3).with_arch("aarch64");
1334 match assess_admissibility(&f, &req) {
1335 CapabilityVerdict::Rejected { reason, .. } => {
1336 assert_eq!(reason, IncidentReasonCode::OsArchMismatch);
1337 }
1338 other => panic!("expected arch-mismatch rejection, got {other:?}"),
1339 }
1340 }
1341
1342 #[test]
1343 fn missing_node_runtime_rejected() {
1344 let f = parse_capability_probe(good_output());
1346 let mut req = CapabilityRequirement::rust(3);
1347 req.needs_node = true;
1348 match assess_admissibility(&f, &req) {
1349 CapabilityVerdict::Rejected { reason, detail } => {
1350 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1351 assert!(detail.contains("node"));
1352 }
1353 other => panic!("expected node rejection, got {other:?}"),
1354 }
1355 let mut bun_req = CapabilityRequirement::rust(3);
1357 bun_req.needs_bun = true;
1358 assert_eq!(
1359 assess_admissibility(&f, &bun_req),
1360 CapabilityVerdict::Admissible
1361 );
1362 }
1363
1364 #[test]
1365 fn specific_toolchain_prefix_matched() {
1366 let f = parse_capability_probe(good_output());
1367 let ok = CapabilityRequirement::rust(3).with_toolchains(["nightly-2026-05-22"]);
1369 assert_eq!(assess_admissibility(&f, &ok), CapabilityVerdict::Admissible);
1370 let missing = CapabilityRequirement::rust(3).with_toolchains(["nightly-2025-01-01"]);
1372 match assess_admissibility(&f, &missing) {
1373 CapabilityVerdict::Rejected { reason, detail } => {
1374 assert_eq!(reason, IncidentReasonCode::MissingRuntimeToolchainTarget);
1375 assert!(detail.contains("nightly-2025-01-01"));
1376 }
1377 other => panic!("expected toolchain rejection, got {other:?}"),
1378 }
1379 }
1380
1381 #[test]
1385 fn eligible_when_capable_fresh_healthy_idle() {
1386 let f = parse_capability_probe(good_output());
1387 let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1388 assert_eq!(v, EligibilityVerdict::Eligible);
1389 assert!(v.is_eligible());
1390 assert_eq!(v.reason(), None);
1391 }
1392
1393 #[test]
1394 fn no_worker_with_runtime_is_missing_capability_not_busy() {
1395 let out = "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1398 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n";
1399 let f = parse_capability_probe(out);
1400 let v = assess_worker_eligibility(&f, &req_wasm(), &WorkerLiveness::ready());
1401 assert_eq!(
1402 v.reason(),
1403 Some(IncidentReasonCode::MissingRuntimeToolchainTarget)
1404 );
1405 assert!(matches!(v, EligibilityVerdict::MissingCapability { .. }));
1406 }
1407
1408 #[test]
1409 fn os_arch_mismatch_surfaces_as_missing_capability() {
1410 let f = parse_capability_probe(good_output());
1411 let req = CapabilityRequirement::rust(3).with_os("darwin");
1412 let v = assess_worker_eligibility(&f, &req, &WorkerLiveness::ready());
1413 assert_eq!(v.reason(), Some(IncidentReasonCode::OsArchMismatch));
1414 }
1415
1416 #[test]
1417 fn unhealthy_and_busy_are_distinct_from_missing_capability() {
1418 let f = parse_capability_probe(good_output());
1419 let unhealthy = WorkerLiveness {
1421 telemetry_stale: false,
1422 healthy: false,
1423 has_free_slot: true,
1424 };
1425 assert_eq!(
1426 assess_worker_eligibility(&f, &req_wasm(), &unhealthy).reason(),
1427 Some(IncidentReasonCode::CircuitOpen)
1428 );
1429 let busy = WorkerLiveness {
1431 telemetry_stale: false,
1432 healthy: true,
1433 has_free_slot: false,
1434 };
1435 assert_eq!(
1436 assess_worker_eligibility(&f, &req_wasm(), &busy).reason(),
1437 Some(IncidentReasonCode::InsufficientSlots)
1438 );
1439 }
1440
1441 #[test]
1442 fn stale_capability_cache_is_distinct_reason() {
1443 let f = parse_capability_probe(good_output());
1444 let stale = WorkerLiveness {
1445 telemetry_stale: true,
1446 healthy: true,
1447 has_free_slot: true,
1448 };
1449 let v = assess_worker_eligibility(&f, &req_wasm(), &stale);
1450 assert_eq!(v, EligibilityVerdict::StaleTelemetry);
1451 assert_eq!(v.reason(), Some(IncidentReasonCode::TelemetryStale));
1452 }
1453
1454 #[test]
1455 fn capability_refresh_changes_eligibility() {
1456 let before = parse_capability_probe(
1458 "RCH_FACT os=linux\nRCH_FACT arch=x86_64\n\
1459 RCH_FACT worker_version=1.0.41\nRCH_FACT worker_protocol=3\n\
1460 RCH_FACT cargo_version=cargo 1.98\nRCH_FACT target=x86_64-unknown-linux-gnu\n",
1461 );
1462 let v_before = assess_worker_eligibility(&before, &req_wasm(), &WorkerLiveness::ready());
1463 assert!(matches!(
1464 v_before,
1465 EligibilityVerdict::MissingCapability { .. }
1466 ));
1467
1468 let after = parse_capability_probe(good_output());
1470 let v_after = assess_worker_eligibility(&after, &req_wasm(), &WorkerLiveness::ready());
1471 assert_eq!(v_after, EligibilityVerdict::Eligible);
1472 }
1473
1474 #[test]
1475 fn remote_worker_binary_path_is_absolute_per_user() {
1476 assert_eq!(
1477 remote_worker_binary_path("root", None),
1478 "/root/.local/bin/rch-wkr"
1479 );
1480 assert_eq!(
1481 remote_worker_binary_path("ubuntu", Some("linux")),
1482 "/home/ubuntu/.local/bin/rch-wkr"
1483 );
1484 for user in ["root", "rch", "ubuntu"] {
1487 let path = remote_worker_binary_path(user, None);
1488 assert!(!path.contains('~'), "{path} must not contain a tilde");
1489 assert!(path.starts_with('/'), "{path} must be absolute");
1490 let spec = ProbeSpec::new(user, path);
1491 let script = build_capability_probe_script(&spec);
1492 assert!(script.contains("/.local/bin/rch-wkr'"));
1495 assert!(!script.contains("'~/.local/bin/rch-wkr'"));
1496 }
1497 }
1498
1499 #[test]
1500 fn remote_worker_binary_path_uses_git_bash_home_for_windows() {
1501 assert_eq!(
1502 remote_worker_binary_path("jeffr", Some("windows")),
1503 "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1504 );
1505 assert_eq!(
1506 remote_worker_binary_path("jeffr", Some("WINDOWS")),
1507 "/c/Users/jeffr/.local/bin/rch-wkr.exe"
1508 );
1509 }
1510}