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 {
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#[derive(Debug, Clone, Default, PartialEq, Eq)]
313pub struct ProbedFacts {
314 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 pub tools_present: Vec<String>,
325 pub tools_absent: Vec<String>,
327 worker_version: Option<String>,
329 worker_protocol: Option<u32>,
330}
331
332impl ProbedFacts {
333 #[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 #[must_use]
349 pub fn has_tool(&self, name: &str) -> bool {
350 self.tools_present.iter().any(|t| t == name)
351 }
352
353 #[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#[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#[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" => {
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 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 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#[derive(Debug, Clone, Default)]
464pub struct CapabilityRequirement {
465 pub needs_targets: Vec<String>,
467 pub min_protocol: u32,
469 pub needs_cargo: bool,
471 pub needs_os: Option<String>,
474 pub needs_arch: Option<String>,
476 pub needs_bun: bool,
478 pub needs_node: bool,
480 pub needs_go: bool,
482 pub needs_zig: bool,
485 pub needs_nix: bool,
487 pub needs_toolchains: Vec<String>,
491 pub needs_components: Vec<String>,
503 pub needs_tools: Vec<String>,
513}
514
515impl CapabilityRequirement {
516 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
577pub enum CapabilityVerdict {
578 Admissible,
580 Rejected {
582 reason: IncidentReasonCode,
583 detail: String,
584 },
585}
586
587#[must_use]
591pub fn assess_admissibility(facts: &ProbedFacts, req: &CapabilityRequirement) -> CapabilityVerdict {
592 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
768pub struct WorkerLiveness {
769 pub telemetry_stale: bool,
771 pub healthy: bool,
773 pub has_free_slot: bool,
775}
776
777impl WorkerLiveness {
778 #[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#[derive(Debug, Clone, PartialEq, Eq)]
794pub enum EligibilityVerdict {
795 Eligible,
797 MissingCapability {
799 reason: IncidentReasonCode,
800 detail: String,
801 },
802 StaleTelemetry,
804 Unhealthy,
806 Busy,
808}
809
810impl EligibilityVerdict {
811 #[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 #[must_use]
825 pub fn is_eligible(&self) -> bool {
826 matches!(self, EligibilityVerdict::Eligible)
827 }
828}
829
830#[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 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 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 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 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 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 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 assert_eq!(
981 assess_admissibility(&facts, &base),
982 CapabilityVerdict::Admissible
983 );
984 assert_eq!(
986 assess_admissibility(&facts, &base.clone().with_tools(["clang"])),
987 CapabilityVerdict::Admissible
988 );
989 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 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 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 assert!(script.contains("/home/rch/.local/bin/rch-wkr"));
1018 assert!(script.contains("/home/rch/.cargo/bin/cargo"));
1019 assert!(script.contains("--version"));
1021 assert!(script.contains("--protocol-version"));
1022 assert!(script.contains("toolchain list"));
1023 assert!(script.contains("target list --installed"));
1024 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 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 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 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 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 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 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 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 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 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 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 #[test]
1248 fn os_mismatch_rejected_with_osarch_reason() {
1249 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 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 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 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 let ok = CapabilityRequirement::rust(3).with_toolchains(["nightly-2026-05-22"]);
1305 assert_eq!(assess_admissibility(&f, &ok), CapabilityVerdict::Admissible);
1306 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 #[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 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 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 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 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 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 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 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}