1use crate::{CompilationKind, toolchain::ToolchainInfo};
4use rand::RngExt;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
11pub struct WorkerId(pub String);
12
13impl WorkerId {
14 pub fn new(id: impl Into<String>) -> Self {
15 Self(id.into())
16 }
17
18 pub fn as_str(&self) -> &str {
19 &self.0
20 }
21}
22
23impl std::fmt::Display for WorkerId {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 write!(f, "{}", self.0)
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum WorkerStatus {
48 #[default]
50 Healthy,
51 Degraded,
53 Unreachable,
55 Draining,
57 Drained,
59 Disabled,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[serde(rename_all = "snake_case")]
66pub enum CircuitState {
67 #[default]
69 Closed,
70 Open,
72 HalfOpen,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
78#[serde(rename_all = "lowercase")]
79pub enum RequiredRuntime {
80 #[default]
82 None,
83 Rust,
85 Bun,
87 Node,
89 Nix,
91 Go,
93 Zig,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
102#[serde(rename_all = "lowercase")]
103pub enum CommandPriority {
104 Low,
105 #[default]
106 Normal,
107 High,
108}
109
110impl std::fmt::Display for CommandPriority {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 let value = match self {
113 Self::Low => "low",
114 Self::Normal => "normal",
115 Self::High => "high",
116 };
117 write!(f, "{}", value)
118 }
119}
120
121impl std::str::FromStr for CommandPriority {
122 type Err = ();
123
124 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 match s.trim().to_lowercase().as_str() {
126 "low" => Ok(Self::Low),
127 "normal" => Ok(Self::Normal),
128 "high" => Ok(Self::High),
129 _ => Err(()),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SelectionRequest {
137 pub project: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub command: Option<String>,
142 #[serde(default)]
144 pub command_priority: CommandPriority,
145 pub estimated_cores: u32,
147 #[serde(default)]
149 pub preferred_workers: Vec<WorkerId>,
150 #[serde(default)]
152 pub toolchain: Option<ToolchainInfo>,
153 #[serde(default)]
155 pub required_runtime: RequiredRuntime,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub classification_duration_us: Option<u64>,
160 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
166 pub job_mode: bool,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub required_tools: Vec<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub hook_pid: Option<u32>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188#[serde(rename_all = "snake_case")]
189pub enum SelectionReason {
190 Success,
192 NoWorkersConfigured,
194 AllWorkersUnreachable,
196 AllCircuitsOpen,
198 AllWorkersBusy,
200 NoWorkersPassedHealth,
202 AllWorkersFailedPreflight,
204 AllWorkersFailedConvergence,
206 NoAdmissibleWorkers(String),
208 NoMatchingWorkers,
210 NoWorkersWithRuntime(String),
212 SelectionError(String),
214 AffinityPinned,
216 AffinityFallback,
218}
219
220pub const SELECTION_RESPONSE_PROTOCOL_VERSION: u64 = 1;
226
227impl std::fmt::Display for SelectionReason {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 match self {
230 Self::Success => write!(f, "worker assigned successfully"),
231 Self::NoWorkersConfigured => write!(f, "no workers configured"),
232 Self::AllWorkersUnreachable => write!(f, "all workers unreachable"),
233 Self::AllCircuitsOpen => write!(f, "all worker circuits open"),
234 Self::AllWorkersBusy => write!(f, "all workers at capacity"),
235 Self::NoWorkersPassedHealth => write!(f, "no workers passed health thresholds"),
236 Self::AllWorkersFailedPreflight => write!(f, "all workers failed preflight checks"),
237 Self::AllWorkersFailedConvergence => {
238 write!(f, "all workers failed repo convergence checks")
239 }
240 Self::NoAdmissibleWorkers(summary) => {
241 write!(f, "no admissible workers: {}", summary)
242 }
243 Self::NoMatchingWorkers => write!(f, "no matching workers found"),
244 Self::NoWorkersWithRuntime(rt) => write!(f, "no workers with {} installed", rt),
245 Self::SelectionError(e) => write!(f, "selection error: {}", e),
246 Self::AffinityPinned => write!(f, "worker assigned via affinity pinning"),
247 Self::AffinityFallback => write!(f, "worker assigned via last-success fallback"),
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
258#[serde(rename_all = "snake_case")]
259pub enum SelectionStrategy {
260 Priority,
263 Fastest,
266 #[default]
269 Balanced,
270 CacheAffinity,
273 FairFastest,
276}
277
278impl std::fmt::Display for SelectionStrategy {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 let name = match self {
281 Self::Priority => "priority",
282 Self::Fastest => "fastest",
283 Self::Balanced => "balanced",
284 Self::CacheAffinity => "cache_affinity",
285 Self::FairFastest => "fair_fastest",
286 };
287 write!(f, "{}", name)
288 }
289}
290
291impl std::str::FromStr for SelectionStrategy {
292 type Err = String;
293
294 fn from_str(s: &str) -> Result<Self, Self::Err> {
295 match s.trim().to_lowercase().as_str() {
296 "priority" => Ok(Self::Priority),
297 "fastest" => Ok(Self::Fastest),
298 "balanced" => Ok(Self::Balanced),
299 "cache_affinity" | "cache-affinity" | "cacheaffinity" => Ok(Self::CacheAffinity),
300 "fair_fastest" | "fair-fastest" | "fairfastest" => Ok(Self::FairFastest),
301 _ => Err(format!(
302 "unknown selection strategy '{}', expected one of: priority, fastest, balanced, cache_affinity, fair_fastest",
303 s
304 )),
305 }
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SelectionConfig {
312 #[serde(default)]
314 pub strategy: SelectionStrategy,
315 #[serde(default = "default_min_success_rate")]
317 pub min_success_rate: f64,
318 #[serde(default)]
320 pub weights: SelectionWeightConfig,
321 #[serde(default)]
323 pub fairness: FairnessConfig,
324 #[serde(default)]
326 pub affinity: AffinityConfig,
327
328 #[serde(default = "default_max_load_per_core")]
332 pub max_load_per_core: Option<f64>,
333 #[serde(default = "default_min_free_gb")]
336 pub min_free_gb: Option<f64>,
337 #[serde(
340 default = "default_disk_gb_per_slot",
341 deserialize_with = "deserialize_disk_gb_per_slot"
342 )]
343 pub disk_gb_per_slot: f64,
344}
345
346impl Default for SelectionConfig {
347 fn default() -> Self {
348 Self {
349 strategy: SelectionStrategy::default(),
350 min_success_rate: default_min_success_rate(),
351 weights: SelectionWeightConfig::default(),
352 fairness: FairnessConfig::default(),
353 affinity: AffinityConfig::default(),
354 max_load_per_core: default_max_load_per_core(),
355 min_free_gb: default_min_free_gb(),
356 disk_gb_per_slot: default_disk_gb_per_slot(),
357 }
358 }
359}
360
361fn default_min_success_rate() -> f64 {
362 0.8
363}
364
365fn default_max_load_per_core() -> Option<f64> {
366 Some(2.0) }
368
369fn default_min_free_gb() -> Option<f64> {
370 Some(10.0) }
372
373fn default_disk_gb_per_slot() -> f64 {
374 10.0
375}
376
377fn deserialize_disk_gb_per_slot<'de, D>(deserializer: D) -> Result<f64, D::Error>
378where
379 D: serde::Deserializer<'de>,
380{
381 let budget = f64::deserialize(deserializer)?;
382 if budget.is_finite() && budget > 0.0 {
383 Ok(budget)
384 } else {
385 Err(serde::de::Error::custom(
386 "disk_gb_per_slot must be finite and greater than zero",
387 ))
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct SelectionWeightConfig {
394 #[serde(default = "default_weight_speedscore")]
396 pub speedscore: f64,
397 #[serde(default = "default_weight_slots")]
399 pub slots: f64,
400 #[serde(default = "default_weight_health")]
402 pub health: f64,
403 #[serde(default = "default_weight_cache")]
405 pub cache: f64,
406 #[serde(default = "default_weight_network")]
408 pub network: f64,
409 #[serde(default = "default_weight_priority")]
411 pub priority: f64,
412 #[serde(
414 default = "default_weight_disk",
415 deserialize_with = "deserialize_weight_disk"
416 )]
417 pub disk: f64,
418 #[serde(default = "default_half_open_penalty")]
420 pub half_open_penalty: f64,
421}
422
423impl Default for SelectionWeightConfig {
424 fn default() -> Self {
425 Self {
426 speedscore: default_weight_speedscore(),
427 slots: default_weight_slots(),
428 health: default_weight_health(),
429 cache: default_weight_cache(),
430 network: default_weight_network(),
431 priority: default_weight_priority(),
432 disk: default_weight_disk(),
433 half_open_penalty: default_half_open_penalty(),
434 }
435 }
436}
437
438fn default_weight_speedscore() -> f64 {
439 0.5
440}
441fn default_weight_slots() -> f64 {
442 0.4
443}
444fn default_weight_health() -> f64 {
445 0.3
446}
447fn default_weight_cache() -> f64 {
448 0.2
449}
450fn default_weight_network() -> f64 {
451 0.1
452}
453fn default_weight_priority() -> f64 {
454 0.5
461}
462fn default_half_open_penalty() -> f64 {
463 0.5
464}
465
466fn default_weight_disk() -> f64 {
467 0.2
468}
469
470fn deserialize_weight_disk<'de, D>(deserializer: D) -> Result<f64, D::Error>
471where
472 D: serde::Deserializer<'de>,
473{
474 let weight = f64::deserialize(deserializer)?;
475 if weight.is_finite() && (0.0..=1.0).contains(&weight) {
476 Ok(weight)
477 } else {
478 Err(serde::de::Error::custom(
479 "disk weight must be finite and between zero and one",
480 ))
481 }
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct FairnessConfig {
487 #[serde(default = "default_fairness_lookback_secs")]
489 pub lookback_secs: u64,
490 #[serde(default = "default_max_consecutive_selections")]
492 pub max_consecutive_selections: u32,
493}
494
495impl Default for FairnessConfig {
496 fn default() -> Self {
497 Self {
498 lookback_secs: default_fairness_lookback_secs(),
499 max_consecutive_selections: default_max_consecutive_selections(),
500 }
501 }
502}
503
504fn default_fairness_lookback_secs() -> u64 {
505 300 }
507
508fn default_max_consecutive_selections() -> u32 {
509 3
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct AffinityConfig {
515 #[serde(default = "default_affinity_enabled")]
517 pub enabled: bool,
518 #[serde(default = "default_affinity_pin_minutes")]
521 pub pin_minutes: u64,
522 #[serde(default = "default_last_success_fallback")]
526 pub enable_last_success_fallback: bool,
527 #[serde(default = "default_fallback_min_success_rate")]
530 pub fallback_min_success_rate: f64,
531}
532
533impl Default for AffinityConfig {
534 fn default() -> Self {
535 Self {
536 enabled: default_affinity_enabled(),
537 pin_minutes: default_affinity_pin_minutes(),
538 enable_last_success_fallback: default_last_success_fallback(),
539 fallback_min_success_rate: default_fallback_min_success_rate(),
540 }
541 }
542}
543
544fn default_affinity_enabled() -> bool {
545 true
546}
547
548fn default_affinity_pin_minutes() -> u64 {
549 60 }
551
552fn default_last_success_fallback() -> bool {
553 true
554}
555
556fn default_fallback_min_success_rate() -> f64 {
557 0.5 }
559
560#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
562pub struct SelectedWorker {
563 pub id: WorkerId,
565 pub host: String,
567 pub user: String,
569 pub identity_file: String,
571 pub slots_available: u32,
573 pub speed_score: f64,
575 #[serde(default)]
581 pub declared_os: Option<String>,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct SelectionResponse {
587 pub worker: Option<SelectedWorker>,
589 pub reason: SelectionReason,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub build_id: Option<u64>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub diagnostics: Option<SelectionDiagnostics>,
597}
598
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
601#[serde(rename_all = "snake_case")]
602pub enum WorkerSelectionDiagnosticDecision {
603 Allow,
605 FallbackCandidate,
607 Deny,
609}
610
611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
613pub struct WorkerSelectionDiagnostic {
614 pub worker_id: WorkerId,
616 pub status: String,
618 pub circuit_state: String,
620 pub pressure_state: String,
622 pub pressure_reason_code: String,
624 #[serde(skip_serializing_if = "Option::is_none")]
626 pub success_rate: Option<f64>,
627 pub min_success_rate: f64,
629 pub fallback_min_success_rate: f64,
631 pub required_runtime: RequiredRuntime,
633 pub runtime_available: bool,
635 pub available_slots: u32,
637 pub total_slots: u32,
639 pub estimated_cores: u32,
641 pub active_project_excluded: bool,
643 pub final_decision: WorkerSelectionDiagnosticDecision,
645 pub final_reason: String,
647 pub reason_codes: Vec<String>,
649}
650
651#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
653pub struct SelectionDiagnostics {
654 pub required_runtime: RequiredRuntime,
656 pub estimated_cores: u32,
658 pub min_success_rate: f64,
660 pub fallback_min_success_rate: f64,
662 pub active_project_exclusion_count: usize,
664 pub workers: Vec<WorkerSelectionDiagnostic>,
666}
667
668#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct ReleaseRequest {
671 pub worker_id: WorkerId,
673 pub slots: u32,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
677 pub build_id: Option<u64>,
678 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub local_wrapper_id: Option<String>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub exit_code: Option<i32>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub duration_ms: Option<u64>,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub bytes_transferred: Option<u64>,
692 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub timing: Option<CommandTimingBreakdown>,
695 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
699 pub worker_fault: bool,
700}
701
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
704#[serde(rename_all = "snake_case")]
705pub enum BuildHeartbeatPhase {
706 SyncUp,
708 Execute,
710 SyncDown,
712 Finalize,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
718pub struct BuildHeartbeatRequest {
719 pub build_id: u64,
721 pub worker_id: WorkerId,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
725 pub hook_pid: Option<u32>,
726 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub local_wrapper_id: Option<String>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub remote_pgid_file: Option<String>,
732 pub phase: BuildHeartbeatPhase,
734 #[serde(default, skip_serializing_if = "Option::is_none")]
736 pub detail: Option<String>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub progress_counter: Option<u64>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub progress_percent: Option<f64>,
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct WorkerConfig {
751 pub id: WorkerId,
753 pub host: String,
755 pub user: String,
757 pub identity_file: String,
759 pub total_slots: u32,
761 #[serde(default = "default_priority")]
763 pub priority: u32,
764 #[serde(default)]
770 pub tags: Vec<String>,
771 #[serde(default, skip_serializing_if = "Vec::is_empty")]
780 pub tools: Vec<WorkerToolProbe>,
781}
782
783#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
789pub struct WorkerToolProbe {
790 pub name: String,
792 pub command: Vec<String>,
794}
795
796fn default_priority() -> u32 {
797 100
798}
799
800pub const OS_TAG_PREFIX: &str = "os:";
802
803#[must_use]
814pub fn declared_os(tags: &[String]) -> Option<String> {
815 tags.iter()
820 .filter_map(|tag| tag.strip_prefix(OS_TAG_PREFIX))
821 .map(|os| os.trim().to_ascii_lowercase())
822 .find(|os| !os.is_empty())
823}
824
825#[must_use]
827pub fn os_tag(os: &str) -> String {
828 format!("{OS_TAG_PREFIX}{}", os.trim().to_ascii_lowercase())
829}
830
831impl Default for WorkerConfig {
832 fn default() -> Self {
833 Self {
834 id: WorkerId::new("default-worker"),
835 host: "localhost".to_string(),
836 user: "user".to_string(),
837 identity_file: "~/.ssh/id_rsa".to_string(),
838 total_slots: 4,
839 priority: default_priority(),
840 tags: Vec::new(),
841 tools: Vec::new(),
842 }
843 }
844}
845
846#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
852pub struct WorkerCapabilities {
853 #[serde(default, skip_serializing_if = "Option::is_none")]
855 pub rustc_version: Option<String>,
856 #[serde(default, skip_serializing_if = "Vec::is_empty")]
859 pub rustup_toolchains: Vec<String>,
860 #[serde(default, skip_serializing_if = "Vec::is_empty")]
864 pub rustup_components: Vec<String>,
865 #[serde(default, skip_serializing_if = "Vec::is_empty")]
871 pub probe_warnings: Vec<String>,
872 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub bun_version: Option<String>,
875 #[serde(default, skip_serializing_if = "Option::is_none")]
877 pub node_version: Option<String>,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
880 pub npm_version: Option<String>,
881 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub nix_version: Option<String>,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub go_version: Option<String>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub zig_version: Option<String>,
895 #[serde(default, skip_serializing_if = "Option::is_none")]
899 pub cargo_zigbuild_version: Option<String>,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub cpu_microarch_level: Option<u8>,
908 #[serde(default, skip_serializing_if = "Vec::is_empty")]
915 pub tools_present: Vec<String>,
916 #[serde(default, skip_serializing_if = "Vec::is_empty")]
922 pub tools_absent: Vec<String>,
923
924 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub num_cpus: Option<u32>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
930 pub load_avg_1: Option<f64>,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub load_avg_5: Option<f64>,
934 #[serde(default, skip_serializing_if = "Option::is_none")]
936 pub load_avg_15: Option<f64>,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub disk_free_gb: Option<f64>,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub disk_total_gb: Option<f64>,
945 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub build_disk_free_gb: Option<f64>,
951 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub build_disk_total_gb: Option<f64>,
954 #[serde(default, skip_serializing_if = "Option::is_none")]
959 pub projects_root_ok: Option<bool>,
960 #[serde(default, skip_serializing_if = "Option::is_none")]
962 pub projects_root_issue: Option<String>,
963 #[serde(default, skip_serializing_if = "Option::is_none")]
965 pub projects_root_checked_at_unix_ms: Option<i64>,
966}
967
968impl WorkerCapabilities {
969 pub fn new() -> Self {
971 Self::default()
972 }
973
974 pub fn mock_with_rust() -> Self {
978 Self {
979 rustc_version: Some("1.85.0-nightly (mock)".to_string()),
980 projects_root_ok: Some(true),
981 ..Self::default()
982 }
983 }
984
985 pub fn is_pre_v3_x86(&self) -> bool {
990 matches!(self.cpu_microarch_level, Some(1 | 2))
991 }
992
993 pub fn has_bun(&self) -> bool {
995 self.bun_version.is_some()
996 }
997
998 pub fn has_node(&self) -> bool {
1000 self.node_version.is_some()
1001 }
1002
1003 pub fn has_rust(&self) -> bool {
1005 self.rustc_version.is_some()
1006 }
1007
1008 pub fn has_rustup_component(&self, toolchain: &str, component: &str) -> bool {
1015 let owner_prefix = format!("{toolchain}-");
1016 self.rustup_components.iter().any(|fact| {
1017 fact.split_once(':').is_some_and(|(owner, name)| {
1018 name == component && (owner == toolchain || owner.starts_with(&owner_prefix))
1019 })
1020 })
1021 }
1022
1023 pub fn has_nix(&self) -> bool {
1025 self.nix_version.is_some()
1026 }
1027
1028 pub fn has_go(&self) -> bool {
1030 self.go_version.is_some()
1031 }
1032
1033 pub fn has_zig(&self) -> bool {
1043 self.zig_version.is_some() && self.cargo_zigbuild_version.is_some()
1044 }
1045
1046 pub fn load_per_core(&self) -> Option<f64> {
1049 match (self.load_avg_1, self.num_cpus) {
1050 (Some(load), Some(cpus)) if cpus > 0 => Some(load / cpus as f64),
1051 _ => None,
1052 }
1053 }
1054
1055 pub fn is_high_load(&self, max_load_per_core: f64) -> Option<bool> {
1058 self.load_per_core().map(|lpc| lpc > max_load_per_core)
1059 }
1060
1061 pub fn build_disk_gb(&self) -> (Option<f64>, Option<f64>) {
1069 if self.build_disk_free_gb.is_some() {
1070 (self.build_disk_free_gb, self.build_disk_total_gb)
1071 } else {
1072 (self.disk_free_gb, self.disk_total_gb)
1073 }
1074 }
1075
1076 pub fn is_low_disk(&self, min_free_gb: f64) -> Option<bool> {
1079 self.build_disk_gb().0.map(|free| free < min_free_gb)
1080 }
1081
1082 pub fn is_topology_healthy(&self) -> Option<bool> {
1086 self.projects_root_ok
1087 }
1088}
1089
1090#[must_use]
1099pub fn x86_64_microarch_level_from_flags(flags: &str) -> u8 {
1100 let set: std::collections::HashSet<&str> = flags.split_whitespace().collect();
1101 let has_all = |required: &[&str]| required.iter().all(|flag| set.contains(flag));
1102 const V2: &[&str] = &["cx16", "lahf_lm", "popcnt", "sse4_1", "sse4_2", "ssse3"];
1104 const V3: &[&str] = &[
1106 "abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe", "xsave",
1107 ];
1108 const V4: &[&str] = &["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"];
1110 if !has_all(V2) {
1111 return 1;
1112 }
1113 if !has_all(V3) {
1114 return 2;
1115 }
1116 if !has_all(V4) {
1117 return 3;
1118 }
1119 4
1120}
1121
1122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1135pub struct PathTopologyConfig {
1136 #[serde(default)]
1139 pub canonical_root: Option<String>,
1140
1141 #[serde(default)]
1144 pub alias_root: Option<String>,
1145}
1146
1147impl PathTopologyConfig {
1148 pub fn to_policy(&self) -> crate::path_topology::PathTopologyPolicy {
1154 let canonical = self
1155 .canonical_root
1156 .as_deref()
1157 .filter(|s| !s.is_empty())
1158 .map(|s| shellexpand::tilde(s).into_owned())
1159 .unwrap_or_else(|| crate::path_topology::DEFAULT_CANONICAL_PROJECT_ROOT.to_string());
1160 let alias = self
1161 .alias_root
1162 .as_deref()
1163 .filter(|s| !s.is_empty())
1164 .map(|s| shellexpand::tilde(s).into_owned())
1165 .unwrap_or_else(|| crate::path_topology::DEFAULT_ALIAS_PROJECT_ROOT.to_string());
1166 crate::path_topology::PathTopologyPolicy::new(
1167 PathBuf::from(canonical),
1168 PathBuf::from(alias),
1169 )
1170 }
1171}
1172
1173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1175pub struct RchConfig {
1176 #[serde(default)]
1177 pub general: GeneralConfig,
1178 #[serde(default)]
1179 pub compilation: CompilationConfig,
1180 #[serde(default)]
1181 pub transfer: TransferConfig,
1182 #[serde(default)]
1183 pub environment: EnvironmentConfig,
1184 #[serde(default)]
1185 pub circuit: CircuitBreakerConfig,
1186 #[serde(default)]
1187 pub output: OutputConfig,
1188 #[serde(default)]
1189 pub self_healing: SelfHealingConfig,
1190 #[serde(default)]
1191 pub self_test: SelfTestConfig,
1192 #[serde(default)]
1194 pub selection: SelectionConfig,
1195 #[serde(default)]
1197 pub execution: ExecutionConfig,
1198 #[serde(default)]
1200 pub alerts: AlertsConfig,
1201 #[serde(default)]
1203 pub fleet: FleetConfig,
1204 #[serde(default)]
1206 pub path_topology: PathTopologyConfig,
1207 #[serde(default)]
1209 pub doctor: DoctorConfig,
1210 #[serde(default)]
1215 pub remediation: crate::remediation_config::RemediationConfig,
1216 #[serde(default)]
1219 pub layer0: Layer0Config,
1220 #[serde(default)]
1223 pub api: ApiConfig,
1224 #[serde(default)]
1226 pub dashboard: DashboardConfig,
1227 #[serde(default)]
1229 pub jobs: JobsConfig,
1230}
1231
1232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1234pub struct JobsConfig {
1235 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1245 pub required_tools: Vec<String>,
1246}
1247
1248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1265#[serde(default)]
1266pub struct ApiConfig {
1267 pub bind: String,
1271 pub token: Option<String>,
1273 pub token_file: Option<String>,
1275 pub no_token: bool,
1279 pub allow_any_addr: bool,
1281}
1282
1283#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1285#[serde(default)]
1286pub struct DashboardConfig {
1287 pub url: Option<String>,
1289}
1290
1291#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1297pub struct DoctorConfig {
1298 #[serde(default)]
1300 pub webhooks: DoctorWebhooksConfig,
1301}
1302
1303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1309pub struct DoctorWebhooksConfig {
1310 #[serde(default)]
1312 pub endpoints: Vec<DoctorWebhookEndpoint>,
1313 #[serde(default = "default_webhook_max_inflight")]
1318 pub max_inflight: usize,
1319}
1320
1321impl Default for DoctorWebhooksConfig {
1325 fn default() -> Self {
1326 Self {
1327 endpoints: Vec::new(),
1328 max_inflight: default_webhook_max_inflight(),
1329 }
1330 }
1331}
1332
1333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1341pub struct DoctorWebhookEndpoint {
1342 pub name: String,
1344 pub url: String,
1346 #[serde(default)]
1348 pub format: DoctorWebhookFormat,
1349 #[serde(default)]
1354 pub on_transitions: Vec<String>,
1355 #[serde(default = "default_webhook_retry_max")]
1357 pub retry_max: u32,
1358 #[serde(default = "default_webhook_retry_backoff_ms")]
1360 pub retry_backoff_ms: u64,
1361 #[serde(default = "default_webhook_timeout_ms")]
1363 pub timeout_ms: u64,
1364 #[serde(default = "default_true")]
1366 pub enabled: bool,
1367 #[serde(default)]
1370 pub routing_key_env: Option<String>,
1371 #[serde(default)]
1374 pub bearer_token_env: Option<String>,
1375 #[serde(default)]
1379 pub signing_secret_env: Option<String>,
1380}
1381
1382#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1384#[serde(rename_all = "snake_case")]
1385pub enum DoctorWebhookFormat {
1386 #[default]
1388 GenericJson,
1389 Slack,
1391 Pagerduty,
1393}
1394
1395fn default_webhook_max_inflight() -> usize {
1396 16
1397}
1398
1399fn default_webhook_retry_max() -> u32 {
1400 3
1401}
1402
1403fn default_webhook_retry_backoff_ms() -> u64 {
1404 200
1405}
1406
1407fn default_webhook_timeout_ms() -> u64 {
1408 5000
1409}
1410
1411#[derive(Debug, Clone, Serialize, Deserialize)]
1413pub struct AlertsConfig {
1414 #[serde(default = "default_true")]
1416 pub enabled: bool,
1417 #[serde(default = "default_alert_suppress_duplicates_secs")]
1419 pub suppress_duplicates_secs: u64,
1420 #[serde(default = "default_alert_cleared_retention_secs")]
1425 pub cleared_retention_secs: u64,
1426 #[serde(default)]
1428 pub webhook: Option<WebhookConfig>,
1429}
1430
1431#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1433pub struct WebhookConfig {
1434 pub url: Option<String>,
1436 #[serde(default)]
1438 pub secret: Option<String>,
1439 #[serde(default = "default_webhook_timeout_secs")]
1441 pub timeout_secs: u64,
1442 #[serde(default = "default_webhook_retry_count")]
1444 pub retry_count: u32,
1445 #[serde(default)]
1448 pub events: Vec<String>,
1449}
1450
1451fn default_webhook_timeout_secs() -> u64 {
1452 5
1453}
1454
1455fn default_webhook_retry_count() -> u32 {
1456 3
1457}
1458
1459fn default_alert_suppress_duplicates_secs() -> u64 {
1460 300
1461}
1462
1463fn default_alert_cleared_retention_secs() -> u64 {
1464 300
1465}
1466
1467impl Default for AlertsConfig {
1468 fn default() -> Self {
1469 Self {
1470 enabled: true,
1471 suppress_duplicates_secs: default_alert_suppress_duplicates_secs(),
1472 cleared_retention_secs: default_alert_cleared_retention_secs(),
1473 webhook: None,
1474 }
1475 }
1476}
1477
1478#[derive(Debug, Clone, Serialize, Deserialize)]
1483pub struct FleetConfig {
1484 #[serde(default = "default_fleet_ssh_connect_timeout_secs")]
1486 pub ssh_connect_timeout_secs: u64,
1487 #[serde(default = "default_fleet_ssh_command_timeout_secs")]
1489 pub ssh_command_timeout_secs: u64,
1490 #[serde(default = "default_fleet_min_disk_space_mb")]
1492 pub min_disk_space_mb: u64,
1493 #[serde(default = "default_fleet_max_load_average")]
1495 pub max_load_average: f64,
1496 #[serde(default = "default_fleet_max_concurrent_workers")]
1498 pub max_concurrent_workers: usize,
1499 #[serde(default = "default_fleet_retry_count")]
1501 pub retry_count: u32,
1502 #[serde(default = "default_fleet_retry_delay_ms")]
1504 pub retry_delay_ms: u64,
1505}
1506
1507fn default_fleet_ssh_connect_timeout_secs() -> u64 {
1508 10
1509}
1510
1511fn default_fleet_ssh_command_timeout_secs() -> u64 {
1512 30
1513}
1514
1515fn default_fleet_min_disk_space_mb() -> u64 {
1516 500
1517}
1518
1519fn default_fleet_max_load_average() -> f64 {
1520 10.0
1521}
1522
1523fn default_fleet_max_concurrent_workers() -> usize {
1524 10
1525}
1526
1527fn default_fleet_retry_count() -> u32 {
1528 2
1529}
1530
1531fn default_fleet_retry_delay_ms() -> u64 {
1532 1000
1533}
1534
1535impl Default for FleetConfig {
1536 fn default() -> Self {
1537 Self {
1538 ssh_connect_timeout_secs: default_fleet_ssh_connect_timeout_secs(),
1539 ssh_command_timeout_secs: default_fleet_ssh_command_timeout_secs(),
1540 min_disk_space_mb: default_fleet_min_disk_space_mb(),
1541 max_load_average: default_fleet_max_load_average(),
1542 max_concurrent_workers: default_fleet_max_concurrent_workers(),
1543 retry_count: default_fleet_retry_count(),
1544 retry_delay_ms: default_fleet_retry_delay_ms(),
1545 }
1546 }
1547}
1548
1549impl FleetConfig {
1550 pub fn ssh_connect_timeout(&self) -> std::time::Duration {
1552 std::time::Duration::from_secs(self.ssh_connect_timeout_secs)
1553 }
1554
1555 pub fn ssh_command_timeout(&self) -> std::time::Duration {
1557 std::time::Duration::from_secs(self.ssh_command_timeout_secs)
1558 }
1559
1560 pub fn retry_delay(&self) -> std::time::Duration {
1562 std::time::Duration::from_millis(self.retry_delay_ms)
1563 }
1564}
1565
1566#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1569#[serde(rename_all = "lowercase")]
1570pub enum BoxRole {
1571 Dispatcher,
1576 Worker,
1579 #[default]
1581 Hybrid,
1582}
1583
1584impl BoxRole {
1585 #[must_use]
1587 pub fn as_str(self) -> &'static str {
1588 match self {
1589 BoxRole::Dispatcher => "dispatcher",
1590 BoxRole::Worker => "worker",
1591 BoxRole::Hybrid => "hybrid",
1592 }
1593 }
1594}
1595
1596#[derive(Debug, Clone, Serialize, Deserialize)]
1597pub struct GeneralConfig {
1598 #[serde(default = "default_true")]
1600 pub enabled: bool,
1601 #[serde(default)]
1604 pub role: BoxRole,
1605 #[serde(default)]
1609 pub force_local: bool,
1610 #[serde(default)]
1615 pub force_remote: bool,
1616 #[serde(default = "default_log_level")]
1618 pub log_level: String,
1619 #[serde(default = "default_socket_path")]
1621 pub socket_path: String,
1622}
1623
1624#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1626pub struct EnvironmentConfig {
1627 #[serde(default)]
1629 pub allowlist: Vec<String>,
1630}
1631
1632impl Default for GeneralConfig {
1633 fn default() -> Self {
1634 Self {
1635 enabled: true,
1636 role: BoxRole::default(),
1637 force_local: false,
1638 force_remote: false,
1639 log_level: "info".to_string(),
1640 socket_path: default_socket_path(),
1641 }
1642 }
1643}
1644
1645#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1647#[serde(rename_all = "lowercase")]
1648pub enum OutputVisibility {
1649 #[default]
1650 None,
1651 Summary,
1652 Verbose,
1653}
1654
1655impl std::fmt::Display for OutputVisibility {
1656 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1657 let value = match self {
1658 OutputVisibility::None => "none",
1659 OutputVisibility::Summary => "summary",
1660 OutputVisibility::Verbose => "verbose",
1661 };
1662 write!(f, "{}", value)
1663 }
1664}
1665
1666impl std::str::FromStr for OutputVisibility {
1667 type Err = ();
1668
1669 fn from_str(s: &str) -> Result<Self, Self::Err> {
1670 match s.trim().to_lowercase().as_str() {
1671 "none" | "silent" | "quiet" => Ok(Self::None),
1672 "summary" | "short" => Ok(Self::Summary),
1673 "verbose" | "debug" => Ok(Self::Verbose),
1674 _ => Err(()),
1675 }
1676 }
1677}
1678
1679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1684#[serde(rename_all = "lowercase")]
1685pub enum ColorMode {
1686 #[default]
1689 Always,
1690 Auto,
1692 Never,
1694}
1695
1696impl std::fmt::Display for ColorMode {
1697 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1698 let value = match self {
1699 ColorMode::Always => "always",
1700 ColorMode::Auto => "auto",
1701 ColorMode::Never => "never",
1702 };
1703 write!(f, "{}", value)
1704 }
1705}
1706
1707impl std::str::FromStr for ColorMode {
1708 type Err = ();
1709
1710 fn from_str(s: &str) -> Result<Self, Self::Err> {
1711 match s.trim().to_lowercase().as_str() {
1712 "always" | "force" | "yes" | "true" => Ok(Self::Always),
1713 "auto" | "detect" => Ok(Self::Auto),
1714 "never" | "none" | "no" | "false" => Ok(Self::Never),
1715 _ => Err(()),
1716 }
1717 }
1718}
1719
1720fn default_color_mode() -> ColorMode {
1721 ColorMode::default()
1722}
1723
1724#[derive(Debug, Clone, Serialize, Deserialize)]
1726pub struct OutputConfig {
1727 #[serde(default = "default_output_visibility")]
1729 pub visibility: OutputVisibility,
1730 #[serde(default)]
1732 pub first_run_complete: bool,
1733 #[serde(default = "default_color_mode")]
1736 pub color_mode: ColorMode,
1737}
1738
1739impl Default for OutputConfig {
1740 fn default() -> Self {
1741 Self {
1742 visibility: OutputVisibility::None,
1743 first_run_complete: false,
1744 color_mode: ColorMode::default(),
1745 }
1746 }
1747}
1748
1749fn default_autostart_cooldown_secs() -> u64 {
1754 30
1755}
1756
1757fn default_autostart_timeout_secs() -> u64 {
1758 3
1759}
1760
1761#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1767#[serde(rename_all = "lowercase")]
1768pub enum SelfHealingLogLevel {
1769 Debug,
1770 #[default]
1771 Info,
1772 Warn,
1773 Error,
1774}
1775
1776impl SelfHealingLogLevel {
1777 pub fn from_env_str(s: &str) -> Option<Self> {
1779 match s.trim().to_ascii_lowercase().as_str() {
1780 "debug" => Some(Self::Debug),
1781 "info" => Some(Self::Info),
1782 "warn" | "warning" => Some(Self::Warn),
1783 "error" => Some(Self::Error),
1784 _ => None,
1785 }
1786 }
1787}
1788
1789#[derive(Debug, Clone, Serialize, Deserialize)]
1791pub struct SelfHealingConfig {
1792 #[serde(default = "default_true")]
1794 pub hook_starts_daemon: bool,
1795 #[serde(default = "default_true")]
1797 pub daemon_installs_hooks: bool,
1798 #[serde(default = "default_autostart_cooldown_secs")]
1800 pub auto_start_cooldown_secs: u64,
1801 #[serde(
1804 default = "default_autostart_timeout_secs",
1805 alias = "daemon_start_timeout"
1806 )]
1807 pub auto_start_timeout_secs: u64,
1808 #[serde(default)]
1811 pub self_healing_log_level: SelfHealingLogLevel,
1812}
1813
1814impl Default for SelfHealingConfig {
1815 fn default() -> Self {
1816 Self {
1817 hook_starts_daemon: default_true(),
1818 daemon_installs_hooks: default_true(),
1819 auto_start_cooldown_secs: default_autostart_cooldown_secs(),
1820 auto_start_timeout_secs: default_autostart_timeout_secs(),
1821 self_healing_log_level: SelfHealingLogLevel::default(),
1822 }
1823 }
1824}
1825
1826impl SelfHealingConfig {
1827 pub fn with_env_overrides(mut self) -> Self {
1836 if let Ok(val) = std::env::var("RCH_NO_SELF_HEALING")
1838 && (val == "1" || val.eq_ignore_ascii_case("true"))
1839 {
1840 self.hook_starts_daemon = false;
1841 self.daemon_installs_hooks = false;
1842 return self;
1843 }
1844
1845 if let Ok(val) = std::env::var("RCH_HOOK_STARTS_DAEMON") {
1847 self.hook_starts_daemon = val != "0" && !val.eq_ignore_ascii_case("false");
1848 }
1849 if let Ok(val) = std::env::var("RCH_DAEMON_INSTALLS_HOOKS") {
1850 self.daemon_installs_hooks = val != "0" && !val.eq_ignore_ascii_case("false");
1851 }
1852
1853 if let Ok(val) = std::env::var("RCH_AUTO_START_TIMEOUT_SECS")
1855 && let Ok(secs) = val.parse()
1856 {
1857 self.auto_start_timeout_secs = secs;
1858 }
1859 if let Ok(val) = std::env::var("RCH_AUTO_START_COOLDOWN_SECS")
1860 && let Ok(secs) = val.parse()
1861 {
1862 self.auto_start_cooldown_secs = secs;
1863 }
1864 if let Ok(val) = std::env::var("RCH_SELF_HEALING_LOG_LEVEL") {
1866 if let Some(level) = SelfHealingLogLevel::from_env_str(&val) {
1867 self.self_healing_log_level = level;
1868 } else {
1869 tracing::warn!(
1870 target: "rch::self_healing",
1871 invalid = %val,
1872 "RCH_SELF_HEALING_LOG_LEVEL has invalid value; using default=info"
1873 );
1874 }
1875 }
1876
1877 self
1878 }
1879}
1880
1881#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1887#[serde(rename_all = "snake_case")]
1888pub enum SelfTestFailureAction {
1889 #[default]
1891 Alert,
1892 DisableWorker,
1894 AlertAndDisable,
1896}
1897
1898#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1900#[serde(untagged)]
1901pub enum SelfTestWorkers {
1902 All(String),
1904 List(Vec<WorkerId>),
1906}
1907
1908impl Default for SelfTestWorkers {
1909 fn default() -> Self {
1910 Self::All("all".to_string())
1911 }
1912}
1913
1914impl SelfTestWorkers {
1915 pub fn resolve(&self) -> Option<Vec<WorkerId>> {
1917 match self {
1918 SelfTestWorkers::All(value) => {
1919 if value.eq_ignore_ascii_case("all") {
1920 None
1921 } else {
1922 Some(vec![WorkerId::new(value.clone())])
1923 }
1924 }
1925 SelfTestWorkers::List(list) => Some(list.clone()),
1926 }
1927 }
1928}
1929
1930fn default_self_test_retry_count() -> u32 {
1931 3
1932}
1933
1934fn default_self_test_retry_delay() -> String {
1935 "5m".to_string()
1936}
1937
1938fn default_self_test_enabled() -> bool {
1939 false
1940}
1941
1942#[derive(Debug, Clone, Serialize, Deserialize)]
1944pub struct SelfTestConfig {
1945 #[serde(default = "default_self_test_enabled")]
1947 pub enabled: bool,
1948 #[serde(default)]
1950 pub schedule: Option<String>,
1951 #[serde(default)]
1953 pub interval: Option<String>,
1954 #[serde(default)]
1956 pub workers: SelfTestWorkers,
1957 #[serde(default)]
1959 pub on_failure: SelfTestFailureAction,
1960 #[serde(default = "default_self_test_retry_count")]
1962 pub retry_count: u32,
1963 #[serde(default = "default_self_test_retry_delay")]
1965 pub retry_delay: String,
1966}
1967
1968impl Default for SelfTestConfig {
1969 fn default() -> Self {
1970 Self {
1971 enabled: default_self_test_enabled(),
1972 schedule: None,
1973 interval: None,
1974 workers: SelfTestWorkers::default(),
1975 on_failure: SelfTestFailureAction::default(),
1976 retry_count: default_self_test_retry_count(),
1977 retry_delay: default_self_test_retry_delay(),
1978 }
1979 }
1980}
1981
1982#[derive(Debug, Clone, Serialize, Deserialize)]
1984pub struct CircuitBreakerConfig {
1985 #[serde(default = "default_circuit_failure_threshold")]
1987 pub failure_threshold: u32,
1988 #[serde(default = "default_circuit_success_threshold")]
1990 pub success_threshold: u32,
1991 #[serde(default = "default_circuit_error_rate_threshold")]
1993 pub error_rate_threshold: f64,
1994 #[serde(default = "default_circuit_window_secs")]
1996 pub window_secs: u64,
1997 #[serde(default = "default_circuit_open_cooldown_secs")]
1999 pub open_cooldown_secs: u64,
2000 #[serde(default = "default_circuit_half_open_max_probes")]
2002 pub half_open_max_probes: u32,
2003 #[serde(default = "default_circuit_command_failure_threshold")]
2014 pub command_failure_threshold: u32,
2015 #[serde(default = "default_circuit_open_cooldown_max_secs")]
2023 pub open_cooldown_max_secs: u64,
2024}
2025
2026impl Default for CircuitBreakerConfig {
2027 fn default() -> Self {
2028 Self {
2029 failure_threshold: default_circuit_failure_threshold(),
2030 success_threshold: default_circuit_success_threshold(),
2031 error_rate_threshold: default_circuit_error_rate_threshold(),
2032 window_secs: default_circuit_window_secs(),
2033 open_cooldown_secs: default_circuit_open_cooldown_secs(),
2034 half_open_max_probes: default_circuit_half_open_max_probes(),
2035 command_failure_threshold: default_circuit_command_failure_threshold(),
2036 open_cooldown_max_secs: default_circuit_open_cooldown_max_secs(),
2037 }
2038 }
2039}
2040
2041impl CircuitBreakerConfig {
2042 pub fn cooldown_ms_for_streak(&self, reopen_streak: u32) -> u64 {
2046 let base_ms = self.open_cooldown_secs.saturating_mul(1000);
2047 let cap_ms = self
2048 .open_cooldown_max_secs
2049 .saturating_mul(1000)
2050 .max(base_ms);
2051 let exponent = reopen_streak.saturating_sub(1).min(16);
2053 base_ms.saturating_mul(1u64 << exponent).min(cap_ms)
2054 }
2055}
2056
2057#[derive(Debug, Clone, Default)]
2062pub struct CircuitStats {
2063 state: CircuitState,
2065 consecutive_failures: u32,
2067 consecutive_successes: u32,
2069 window_successes: u32,
2071 window_failures: u32,
2073 opened_at: Option<u64>,
2075 last_state_change: u64,
2077 active_probes: u32,
2079 consecutive_command_failures: u32,
2084 reopen_streak: u32,
2087 recent_results: Vec<bool>,
2090}
2091
2092const CIRCUIT_HISTORY_SIZE: usize = 10;
2094
2095impl CircuitStats {
2096 pub fn new() -> Self {
2098 Self {
2099 state: CircuitState::Closed,
2100 consecutive_failures: 0,
2101 consecutive_successes: 0,
2102 window_successes: 0,
2103 window_failures: 0,
2104 opened_at: None,
2105 last_state_change: Self::now_millis(),
2106 active_probes: 0,
2107 consecutive_command_failures: 0,
2108 reopen_streak: 0,
2109 recent_results: Vec::with_capacity(CIRCUIT_HISTORY_SIZE),
2110 }
2111 }
2112
2113 pub fn consecutive_command_failures(&self) -> u32 {
2116 self.consecutive_command_failures
2117 }
2118
2119 pub fn reopen_streak(&self) -> u32 {
2122 self.reopen_streak
2123 }
2124
2125 fn effective_cooldown_ms(&self, config: &CircuitBreakerConfig) -> u64 {
2127 config.cooldown_ms_for_streak(self.reopen_streak.max(1))
2128 }
2129
2130 pub fn state(&self) -> CircuitState {
2132 self.state
2133 }
2134
2135 pub fn opened_at(&self) -> Option<u64> {
2137 self.opened_at
2138 }
2139
2140 pub fn last_state_change(&self) -> u64 {
2142 self.last_state_change
2143 }
2144
2145 pub fn consecutive_failures(&self) -> u32 {
2147 self.consecutive_failures
2148 }
2149
2150 pub fn error_rate(&self) -> f64 {
2160 if self.recent_results.is_empty() {
2161 return 0.0;
2162 }
2163 let failures = self.recent_results.iter().filter(|&&ok| !ok).count();
2164 failures as f64 / self.recent_results.len() as f64
2165 }
2166
2167 pub fn record_success(&mut self) {
2172 self.window_successes += 1;
2173 self.consecutive_failures = 0;
2174 self.push_result(true);
2175
2176 if self.state == CircuitState::HalfOpen {
2177 self.consecutive_successes += 1;
2178 if self.active_probes > 0 {
2179 self.active_probes -= 1;
2180 }
2181 }
2182 }
2183
2184 pub fn record_failure(&mut self) {
2189 self.window_failures += 1;
2190 self.consecutive_failures += 1;
2191 self.push_result(false);
2192
2193 if self.state == CircuitState::HalfOpen {
2194 self.consecutive_successes = 0;
2195 if self.active_probes > 0 {
2196 self.active_probes -= 1;
2197 }
2198 }
2199 }
2200
2201 pub fn should_open(&self, config: &CircuitBreakerConfig) -> bool {
2207 if self.state != CircuitState::Closed {
2208 return false;
2209 }
2210
2211 if self.consecutive_failures >= config.failure_threshold {
2213 return true;
2214 }
2215
2216 if self.recent_results.len() >= 5 && self.error_rate() >= config.error_rate_threshold {
2220 return true;
2221 }
2222
2223 false
2224 }
2225
2226 pub fn should_half_open(&self, config: &CircuitBreakerConfig) -> bool {
2230 if self.state != CircuitState::Open {
2231 return false;
2232 }
2233
2234 let now = Self::now_millis();
2235 let cooldown_ms = self.effective_cooldown_ms(config);
2236
2237 if let Some(opened_at) = self.opened_at {
2238 now.saturating_sub(opened_at) >= cooldown_ms
2239 } else {
2240 false
2241 }
2242 }
2243
2244 pub fn should_close(&self, config: &CircuitBreakerConfig) -> bool {
2253 if self.state != CircuitState::HalfOpen {
2254 return false;
2255 }
2256
2257 self.consecutive_successes >= config.success_threshold
2258 && self.consecutive_command_failures == 0
2259 }
2260
2261 pub fn can_probe(&self, config: &CircuitBreakerConfig) -> bool {
2265 if self.state != CircuitState::HalfOpen {
2266 return false;
2267 }
2268
2269 self.active_probes < config.half_open_max_probes
2270 }
2271
2272 pub fn start_probe(&mut self, config: &CircuitBreakerConfig) -> bool {
2276 if !self.can_probe(config) {
2277 return false;
2278 }
2279 self.active_probes += 1;
2280 true
2281 }
2282
2283 pub fn open(&mut self) {
2285 if self.state != CircuitState::Open {
2286 self.state = CircuitState::Open;
2287 self.opened_at = Some(Self::now_millis());
2288 self.last_state_change = Self::now_millis();
2289 self.consecutive_successes = 0;
2290 self.active_probes = 0;
2291 self.reopen_streak = self.reopen_streak.saturating_add(1);
2294 }
2295 }
2296
2297 pub fn half_open(&mut self) {
2299 if self.state != CircuitState::HalfOpen {
2300 self.state = CircuitState::HalfOpen;
2301 self.last_state_change = Self::now_millis();
2302 self.consecutive_successes = 0;
2303 self.active_probes = 0;
2304 }
2305 }
2306
2307 pub fn close(&mut self) {
2309 if self.state != CircuitState::Closed {
2310 self.state = CircuitState::Closed;
2311 self.last_state_change = Self::now_millis();
2312 self.opened_at = None;
2313 self.consecutive_failures = 0;
2314 self.consecutive_successes = 0;
2315 self.active_probes = 0;
2316 self.consecutive_command_failures = 0;
2317 self.reopen_streak = 0;
2318 self.window_successes = 0;
2324 self.window_failures = 0;
2325 self.recent_results.clear();
2326 }
2327 }
2328
2329 pub fn reset_window(&mut self) {
2335 self.window_successes = 0;
2336 self.window_failures = 0;
2337 self.recent_results.clear();
2338 }
2339
2340 pub fn recent_results(&self) -> &[bool] {
2345 &self.recent_results
2346 }
2347
2348 pub fn recovery_remaining_secs(&self, config: &CircuitBreakerConfig) -> Option<u64> {
2352 if self.state != CircuitState::Open {
2353 return None;
2354 }
2355
2356 let now = Self::now_millis();
2357 let cooldown_ms = self.effective_cooldown_ms(config);
2358
2359 if let Some(opened_at) = self.opened_at {
2360 let elapsed_ms = now.saturating_sub(opened_at);
2361 if elapsed_ms < cooldown_ms {
2362 return Some((cooldown_ms - elapsed_ms) / 1000);
2363 }
2364 }
2365 None
2366 }
2367
2368 fn push_result(&mut self, success: bool) {
2370 if self.recent_results.len() >= CIRCUIT_HISTORY_SIZE {
2371 self.recent_results.remove(0);
2372 }
2373 self.recent_results.push(success);
2374 }
2375
2376 fn now_millis() -> u64 {
2377 std::time::SystemTime::now()
2378 .duration_since(std::time::UNIX_EPOCH)
2379 .map(|d| d.as_millis() as u64)
2380 .unwrap_or(0)
2381 }
2382
2383 pub fn apply_health_outcome(
2409 &mut self,
2410 healthy: bool,
2411 transient: bool,
2412 config: &CircuitBreakerConfig,
2413 ) -> CircuitState {
2414 if healthy {
2415 if self.state == CircuitState::Open && self.should_half_open(config) {
2419 self.half_open();
2420 }
2421 self.record_success();
2422 if self.should_close(config) {
2423 self.close();
2424 }
2425 } else {
2426 let was_half_open = self.state == CircuitState::HalfOpen;
2427 self.record_failure();
2428 if self.should_open(config) {
2429 self.open();
2430 } else if was_half_open && !transient {
2431 self.open();
2435 }
2436 }
2437
2438 if self.state == CircuitState::Open && self.should_half_open(config) {
2441 self.half_open();
2442 }
2443
2444 self.state
2445 }
2446
2447 pub fn apply_command_outcome(
2471 &mut self,
2472 success: bool,
2473 config: &CircuitBreakerConfig,
2474 ) -> CircuitState {
2475 if success {
2476 self.consecutive_command_failures = 0;
2477 return self.apply_health_outcome(true, false, config);
2478 }
2479
2480 self.consecutive_command_failures = self.consecutive_command_failures.saturating_add(1);
2481 let was_half_open = self.state == CircuitState::HalfOpen;
2482 self.record_failure();
2483 let threshold = config.command_failure_threshold.max(1);
2484 if was_half_open
2485 || self.should_open(config)
2486 || (self.state == CircuitState::Closed
2487 && self.consecutive_command_failures >= threshold)
2488 {
2489 self.open();
2490 }
2491
2492 if self.state == CircuitState::Open && self.should_half_open(config) {
2493 self.half_open();
2494 }
2495
2496 self.state
2497 }
2498}
2499
2500#[derive(Debug, Clone, Serialize, Deserialize)]
2501pub struct CompilationConfig {
2502 #[serde(default = "default_confidence")]
2504 pub confidence_threshold: f64,
2505 #[serde(default = "default_min_local_time")]
2507 pub min_local_time_ms: u64,
2508 #[serde(default = "default_speedup_threshold")]
2512 pub remote_speedup_threshold: f64,
2513 #[serde(default = "default_build_slots")]
2515 pub build_slots: u32,
2516 #[serde(default = "default_test_slots")]
2519 pub test_slots: u32,
2520 #[serde(default = "default_check_slots")]
2523 pub check_slots: u32,
2524 #[serde(default = "default_build_timeout")]
2527 pub build_timeout_sec: u64,
2528 #[serde(default = "default_test_timeout")]
2531 pub test_timeout_sec: u64,
2532 #[serde(default = "default_bun_timeout")]
2536 pub bun_timeout_sec: u64,
2537 #[serde(default = "default_external_timeout_enabled")]
2541 pub external_timeout_enabled: bool,
2542 #[serde(default = "default_allow_local_fallback")]
2555 pub allow_local_fallback: bool,
2556 #[serde(default)]
2567 pub remote_build_jobs: RemoteBuildJobs,
2568}
2569
2570#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2582pub enum RemoteBuildJobs {
2583 #[default]
2585 Auto,
2586 Off,
2588 Fixed(u32),
2590}
2591
2592impl RemoteBuildJobs {
2593 pub fn parse(value: &str) -> Result<Self, String> {
2595 let trimmed = value.trim();
2596 match trimmed.to_ascii_lowercase().as_str() {
2597 "auto" => Ok(Self::Auto),
2598 "off" | "none" | "disabled" => Ok(Self::Off),
2599 _ => match trimmed.parse::<u32>() {
2600 Ok(0) => Err(
2601 "remote_build_jobs must be >= 1 (cargo rejects `-j 0`); use `off` to disable"
2602 .to_string(),
2603 ),
2604 Ok(n) => Ok(Self::Fixed(n)),
2605 Err(_) => Err(format!(
2606 "'{trimmed}' is not a valid remote_build_jobs value (expected `auto`, `off`, or a positive integer)"
2607 )),
2608 },
2609 }
2610 }
2611}
2612
2613impl std::fmt::Display for RemoteBuildJobs {
2614 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2615 match self {
2616 Self::Auto => f.write_str("auto"),
2617 Self::Off => f.write_str("off"),
2618 Self::Fixed(n) => write!(f, "{n}"),
2619 }
2620 }
2621}
2622
2623impl std::str::FromStr for RemoteBuildJobs {
2624 type Err = String;
2625
2626 fn from_str(value: &str) -> Result<Self, Self::Err> {
2627 Self::parse(value)
2628 }
2629}
2630
2631impl Serialize for RemoteBuildJobs {
2632 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2633 match self {
2634 Self::Auto => serializer.serialize_str("auto"),
2635 Self::Off => serializer.serialize_str("off"),
2636 Self::Fixed(n) => serializer.serialize_u32(*n),
2637 }
2638 }
2639}
2640
2641impl<'de> Deserialize<'de> for RemoteBuildJobs {
2642 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2643 #[derive(Deserialize)]
2644 #[serde(untagged)]
2645 enum Raw {
2646 Count(u64),
2647 Spelling(String),
2648 }
2649 match Raw::deserialize(deserializer)? {
2650 Raw::Count(n) => {
2651 let n = u32::try_from(n).map_err(|_| {
2652 serde::de::Error::custom(format!("remote_build_jobs {n} is out of range"))
2653 })?;
2654 Self::parse(&n.to_string()).map_err(serde::de::Error::custom)
2655 }
2656 Raw::Spelling(s) => Self::parse(&s).map_err(serde::de::Error::custom),
2657 }
2658 }
2659}
2660
2661impl Default for CompilationConfig {
2662 fn default() -> Self {
2663 Self {
2664 confidence_threshold: 0.85,
2665 min_local_time_ms: 2000,
2666 remote_speedup_threshold: default_speedup_threshold(),
2667 build_slots: default_build_slots(),
2668 test_slots: default_test_slots(),
2669 check_slots: default_check_slots(),
2670 build_timeout_sec: default_build_timeout(),
2671 test_timeout_sec: default_test_timeout(),
2672 bun_timeout_sec: default_bun_timeout(),
2673 external_timeout_enabled: default_external_timeout_enabled(),
2674 allow_local_fallback: default_allow_local_fallback(),
2675 remote_build_jobs: RemoteBuildJobs::default(),
2676 }
2677 }
2678}
2679
2680fn default_build_slots() -> u32 {
2681 4
2682}
2683
2684fn default_test_slots() -> u32 {
2685 8
2686}
2687
2688fn default_check_slots() -> u32 {
2689 2
2690}
2691
2692fn default_build_timeout() -> u64 {
2695 300
2696}
2697
2698fn default_test_timeout() -> u64 {
2701 1800
2702}
2703
2704fn default_bun_timeout() -> u64 {
2708 600
2709}
2710
2711fn default_external_timeout_enabled() -> bool {
2713 true
2714}
2715
2716fn default_allow_local_fallback() -> bool {
2720 true
2721}
2722
2723impl CompilationConfig {
2724 pub fn timeout_for_kind(&self, kind: Option<CompilationKind>) -> std::time::Duration {
2730 let secs = match kind {
2731 Some(CompilationKind::BunTest) | Some(CompilationKind::BunTypecheck) => {
2733 self.bun_timeout_sec
2734 }
2735 Some(CompilationKind::CargoTest) | Some(CompilationKind::CargoNextest) => {
2737 self.test_timeout_sec
2738 }
2739 Some(CompilationKind::NixBuild) => self.test_timeout_sec,
2743 Some(CompilationKind::GoTest) => self.test_timeout_sec,
2746 _ => self.build_timeout_sec,
2748 };
2749 std::time::Duration::from_secs(secs)
2750 }
2751
2752 pub fn external_timeout_enabled(&self) -> bool {
2754 self.external_timeout_enabled
2755 }
2756}
2757
2758pub const LAYER0_PACK_VERSION: &str = "1";
2764
2765#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2774#[serde(default)]
2775pub struct Layer0Config {
2776 pub enabled: bool,
2778 pub release_lto_thin: bool,
2780 pub release_codegen_units_1: bool,
2783}
2784
2785impl Layer0Config {
2786 #[must_use]
2792 pub fn active_env(&self) -> Vec<(&'static str, &'static str)> {
2793 if !self.enabled {
2794 return Vec::new();
2795 }
2796 let mut env = Vec::new();
2797 if self.release_lto_thin {
2798 env.push(("CARGO_PROFILE_RELEASE_LTO", "thin"));
2799 }
2800 if self.release_codegen_units_1 {
2801 env.push(("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"));
2802 }
2803 env
2804 }
2805}
2806
2807#[derive(Debug, Clone, Serialize, Deserialize)]
2808pub struct TransferConfig {
2809 #[serde(default = "default_compression")]
2811 pub compression_level: u32,
2812 #[serde(default = "default_excludes")]
2814 pub exclude_patterns: Vec<String>,
2815 #[serde(default, skip_serializing_if = "Option::is_none")]
2819 pub ssh_server_alive_interval_secs: Option<u64>,
2820 #[serde(default, skip_serializing_if = "Option::is_none")]
2825 pub ssh_control_persist_secs: Option<u64>,
2826 #[serde(default = "default_remote_base")]
2837 pub remote_base: String,
2838 #[serde(default, skip_serializing_if = "Option::is_none")]
2844 pub sync_timeout_ms: Option<u64>,
2845 #[serde(default = "default_source_sync_silence_timeout_secs")]
2857 pub source_sync_silence_timeout_secs: u64,
2858 #[serde(default)]
2860 pub retry: RetryConfig,
2861 #[serde(default)]
2867 pub verify_artifacts: bool,
2868 #[serde(default = "default_verify_max_size")]
2873 pub verify_max_size_bytes: u64,
2874
2875 #[serde(default, skip_serializing_if = "Option::is_none")]
2884 pub max_transfer_mb: Option<u64>,
2885
2886 #[serde(default, skip_serializing_if = "Option::is_none")]
2892 pub max_transfer_time_ms: Option<u64>,
2893
2894 #[serde(default, skip_serializing_if = "Option::is_none")]
2899 pub bwlimit_kbps: Option<u64>,
2900
2901 #[serde(default, skip_serializing_if = "Option::is_none")]
2910 pub rsync_bin: Option<String>,
2911
2912 #[serde(default, skip_serializing_if = "Option::is_none")]
2917 pub estimated_bandwidth_bps: Option<u64>,
2918
2919 #[serde(default)]
2932 pub adaptive_compression: bool,
2933
2934 #[serde(default = "default_min_compression")]
2939 pub min_compression_level: u32,
2940
2941 #[serde(default = "default_max_compression")]
2946 pub max_compression_level: u32,
2947}
2948
2949impl Default for TransferConfig {
2950 fn default() -> Self {
2951 Self {
2952 compression_level: 3,
2953 exclude_patterns: default_excludes(),
2954 ssh_server_alive_interval_secs: None,
2955 ssh_control_persist_secs: None,
2956 remote_base: default_remote_base(),
2957 sync_timeout_ms: None,
2958 source_sync_silence_timeout_secs: default_source_sync_silence_timeout_secs(),
2959 retry: RetryConfig::default(),
2960 verify_artifacts: false,
2961 verify_max_size_bytes: default_verify_max_size(),
2962 max_transfer_mb: None,
2964 max_transfer_time_ms: None,
2965 bwlimit_kbps: None,
2966 rsync_bin: None,
2967 estimated_bandwidth_bps: None,
2968 adaptive_compression: false,
2970 min_compression_level: default_min_compression(),
2971 max_compression_level: default_max_compression(),
2972 }
2973 }
2974}
2975
2976impl TransferConfig {
2977 pub const MIN_SYNC_TIMEOUT_MS: u64 = 1_000;
2979 pub const MAX_SYNC_TIMEOUT_MS: u64 = 3_600_000;
2981 pub const DEFAULT_SYNC_TIMEOUT_FLOOR_MS: u64 = 30_000;
2983 const DEFAULT_SYNC_BYTES_PER_SECOND: u64 = 1024 * 1024;
2984
2985 #[must_use]
2987 pub fn valid_sync_timeout_ms(value: u64) -> bool {
2988 (Self::MIN_SYNC_TIMEOUT_MS..=Self::MAX_SYNC_TIMEOUT_MS).contains(&value)
2989 }
2990
2991 #[must_use]
2998 pub fn sync_timeout_for_payload(&self, payload_bytes: u64) -> std::time::Duration {
2999 let timeout_ms = self
3000 .sync_timeout_ms
3001 .filter(|value| Self::valid_sync_timeout_ms(*value))
3002 .unwrap_or_else(|| {
3003 let transfer_seconds = payload_bytes
3004 .saturating_add(Self::DEFAULT_SYNC_BYTES_PER_SECOND - 1)
3005 / Self::DEFAULT_SYNC_BYTES_PER_SECOND;
3006 Self::DEFAULT_SYNC_TIMEOUT_FLOOR_MS
3007 .saturating_add(transfer_seconds.saturating_mul(1_000))
3008 .min(Self::MAX_SYNC_TIMEOUT_MS)
3009 });
3010 std::time::Duration::from_millis(timeout_ms)
3011 }
3012
3013 pub fn select_compression_level(&self, estimated_bytes: Option<u64>) -> u32 {
3025 if !self.adaptive_compression {
3026 return self.compression_level;
3027 }
3028
3029 let Some(bytes) = estimated_bytes else {
3030 return self.compression_level;
3032 };
3033
3034 const SMALL_THRESHOLD: u64 = 10_000_000; const LARGE_THRESHOLD: u64 = 200_000_000; let level = if bytes < SMALL_THRESHOLD {
3039 1 } else if bytes < LARGE_THRESHOLD {
3041 3 } else {
3043 7 };
3045
3046 level.clamp(self.min_compression_level, self.max_compression_level)
3048 }
3049}
3050
3051#[rustfmt::skip]
3065pub const BUILTIN_EXECUTION_BASES: &[&str] = &[
3066 "cargo",
3068 "rustc",
3069 "nextest",
3071 "gcc",
3073 "g++",
3074 "clang",
3075 "clang++",
3076 "cc",
3077 "c++",
3078 "make",
3080 "cmake",
3081 "ninja",
3082 "meson",
3083 "bun",
3085 "nix",
3087 "go",
3089 "tsc",
3091];
3092
3093fn default_execution_allowlist() -> Vec<String> {
3094 BUILTIN_EXECUTION_BASES
3095 .iter()
3096 .map(|base| (*base).to_string())
3097 .collect()
3098}
3099
3100#[derive(Debug, Clone, Serialize, Deserialize)]
3105pub struct ExecutionConfig {
3106 #[serde(default = "default_execution_allowlist")]
3115 pub allowlist: Vec<String>,
3116}
3117
3118impl Default for ExecutionConfig {
3119 fn default() -> Self {
3120 Self {
3121 allowlist: default_execution_allowlist(),
3122 }
3123 }
3124}
3125
3126impl ExecutionConfig {
3127 pub fn is_allowed(&self, command_base: &str) -> bool {
3129 self.allowlist
3130 .iter()
3131 .any(|allowed| allowed.eq_ignore_ascii_case(command_base))
3132 }
3133
3134 pub fn missing_builtin_bases(&self) -> Vec<&'static str> {
3140 BUILTIN_EXECUTION_BASES
3141 .iter()
3142 .copied()
3143 .filter(|base| !self.is_allowed(base))
3144 .collect()
3145 }
3146}
3147
3148fn default_retry_max_attempts() -> u32 {
3153 3
3154}
3155
3156fn default_retry_base_delay_ms() -> u64 {
3157 100
3158}
3159
3160fn default_retry_max_delay_ms() -> u64 {
3161 5000
3162}
3163
3164fn default_retry_jitter_factor() -> f64 {
3165 0.1
3166}
3167
3168fn default_retry_total_timeout_ms() -> u64 {
3169 30000
3170}
3171
3172#[derive(Debug, Clone, Serialize, Deserialize)]
3177pub struct RetryConfig {
3178 #[serde(default = "default_retry_max_attempts")]
3180 pub max_attempts: u32,
3181
3182 #[serde(default = "default_retry_base_delay_ms")]
3184 pub base_delay_ms: u64,
3185
3186 #[serde(default = "default_retry_max_delay_ms")]
3188 pub max_delay_ms: u64,
3189
3190 #[serde(default = "default_retry_jitter_factor")]
3193 pub jitter_factor: f64,
3194
3195 #[serde(default = "default_retry_total_timeout_ms")]
3198 pub total_timeout_ms: u64,
3199}
3200
3201impl Default for RetryConfig {
3202 fn default() -> Self {
3203 Self {
3204 max_attempts: default_retry_max_attempts(),
3205 base_delay_ms: default_retry_base_delay_ms(),
3206 max_delay_ms: default_retry_max_delay_ms(),
3207 jitter_factor: default_retry_jitter_factor(),
3208 total_timeout_ms: default_retry_total_timeout_ms(),
3209 }
3210 }
3211}
3212
3213impl RetryConfig {
3214 pub fn no_retry() -> Self {
3216 Self {
3217 max_attempts: 1,
3218 ..Default::default()
3219 }
3220 }
3221
3222 pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
3226 if attempt == 0 {
3227 return std::time::Duration::ZERO;
3228 }
3229
3230 let base = self.base_delay_ms as f64;
3232 let delay = base * (2.0_f64.powi(attempt as i32 - 1));
3233 let capped = delay.min(self.max_delay_ms as f64);
3234
3235 let jittered = if self.jitter_factor > 0.0 {
3237 let jitter_range = capped * self.jitter_factor;
3238 let jitter = (rand::rng().random::<f64>() * 2.0 - 1.0) * jitter_range;
3239 (capped + jitter).max(0.0)
3240 } else {
3241 capped
3242 };
3243
3244 std::time::Duration::from_millis(jittered as u64)
3245 }
3246
3247 pub fn should_retry(&self, attempt: u32, elapsed: std::time::Duration) -> bool {
3249 if attempt >= self.max_attempts {
3250 return false;
3251 }
3252 elapsed.as_millis() < self.total_timeout_ms as u128
3253 }
3254}
3255
3256fn default_source_sync_silence_timeout_secs() -> u64 {
3261 120
3262}
3263
3264pub fn default_remote_base() -> String {
3273 "/data/tmp/rch".to_string()
3274}
3275
3276pub fn validate_remote_base(path: &str) -> Result<String, String> {
3279 let expanded = shellexpand::tilde(path).into_owned();
3281
3282 if !expanded.starts_with('/') {
3284 return Err(format!(
3285 "remote_base must be an absolute path, got: {}",
3286 path
3287 ));
3288 }
3289
3290 if expanded.contains("..") {
3292 return Err(format!(
3293 "remote_base must not contain path traversal (..): {}",
3294 path
3295 ));
3296 }
3297
3298 let normalized = expanded.trim_end_matches('/').to_string();
3300
3301 if normalized.is_empty() || normalized == "/" {
3303 return Err("remote_base cannot be the root directory (safety restriction)".to_string());
3304 }
3305
3306 let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
3310 if components.len() < 2 {
3311 return Err(format!(
3312 "remote_base must be at least 2 levels deep (e.g. /tmp/rch), got: {}",
3313 normalized
3314 ));
3315 }
3316
3317 Ok(normalized)
3318}
3319
3320fn default_circuit_failure_threshold() -> u32 {
3321 3
3322}
3323
3324fn default_circuit_success_threshold() -> u32 {
3325 2
3326}
3327
3328fn default_circuit_error_rate_threshold() -> f64 {
3329 0.5
3330}
3331
3332fn default_circuit_window_secs() -> u64 {
3333 60
3334}
3335
3336fn default_circuit_open_cooldown_secs() -> u64 {
3337 30
3338}
3339
3340fn default_circuit_half_open_max_probes() -> u32 {
3341 1
3342}
3343
3344fn default_circuit_command_failure_threshold() -> u32 {
3345 2
3346}
3347
3348fn default_circuit_open_cooldown_max_secs() -> u64 {
3349 480
3351}
3352
3353fn default_true() -> bool {
3354 true
3355}
3356
3357fn default_log_level() -> String {
3358 "info".to_string()
3359}
3360
3361pub fn default_socket_path() -> String {
3362 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR")
3364 && !runtime_dir.trim().is_empty()
3365 {
3366 let path = PathBuf::from(runtime_dir).join("rch.sock");
3367 return path.to_string_lossy().to_string();
3368 }
3369
3370 if let Some(cache_dir) = dirs::cache_dir() {
3372 let rch_cache = cache_dir.join("rch");
3373 let _ = std::fs::create_dir_all(&rch_cache);
3374 return rch_cache.join("rch.sock").to_string_lossy().to_string();
3375 }
3376
3377 "/tmp/rch.sock".to_string()
3379}
3380
3381#[allow(dead_code)]
3382fn default_output_visibility() -> OutputVisibility {
3383 OutputVisibility::None
3384}
3385
3386fn default_confidence() -> f64 {
3387 0.85
3388}
3389
3390fn default_min_local_time() -> u64 {
3391 2000
3392}
3393
3394fn default_speedup_threshold() -> f64 {
3398 1.2
3399}
3400
3401fn default_compression() -> u32 {
3402 3
3403}
3404
3405fn default_verify_max_size() -> u64 {
3407 100 * 1024 * 1024 }
3409
3410fn default_min_compression() -> u32 {
3412 1
3413}
3414
3415fn default_max_compression() -> u32 {
3417 9
3418}
3419
3420fn default_excludes() -> Vec<String> {
3421 vec![
3422 "target/".to_string(),
3424 "*.rlib".to_string(),
3425 "*.rmeta".to_string(),
3426 ".git/".to_string(),
3429 "node_modules/".to_string(),
3431 ".bun/".to_string(),
3433 ".npm/".to_string(),
3435 ".pnpm-store/".to_string(),
3436 "dist/".to_string(),
3438 "build/".to_string(),
3439 ".next/".to_string(),
3441 ".nuxt/".to_string(),
3442 ".turbo/".to_string(),
3443 ".parcel-cache/".to_string(),
3444 ".beads/".to_string(),
3446 ".doctor/".to_string(),
3448 "coverage/".to_string(),
3450 ".nyc_output/".to_string(),
3451 ".cargo/credentials".to_string(),
3453 ".cargo/credentials.toml".to_string(),
3454 ".env".to_string(),
3455 ".env.*".to_string(),
3456 "*.pem".to_string(),
3457 "*.key".to_string(),
3458 "credentials.json".to_string(),
3459 "secrets.json".to_string(),
3460 "secrets.yaml".to_string(),
3461 "secrets.yml".to_string(),
3462 "secrets.toml".to_string(),
3463 "secrets.env".to_string(),
3464 "secrets.txt".to_string(),
3465 "secrets.conf".to_string(),
3466 "secrets.cfg".to_string(),
3467 "secrets.properties".to_string(),
3468 "secrets.xml".to_string(),
3469 "id_rsa".to_string(),
3483 "id_ed25519".to_string(),
3484 "id_ecdsa".to_string(),
3485 "id_dsa".to_string(),
3486 "*.p12".to_string(),
3488 "*.pfx".to_string(),
3489 "*.keystore".to_string(),
3490 "*.jks".to_string(),
3491 ".netrc".to_string(),
3494 "_netrc".to_string(),
3495 ".aws/credentials".to_string(),
3496 ".docker/config.json".to_string(),
3497 ".npmrc".to_string(),
3501 ".pypirc".to_string(),
3502 "secring.*".to_string(),
3504 ]
3505}
3506
3507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3513#[serde(rename_all = "snake_case")]
3514pub enum BuildLocation {
3515 #[default]
3517 Local,
3518 Remote,
3520}
3521
3522#[derive(Debug, Clone, Serialize, Deserialize)]
3524pub struct BuildCancellationWorkerHealth {
3525 pub status: String,
3527 pub speed_score: f64,
3529 pub used_slots: u32,
3531 pub available_slots: u32,
3533 pub pressure_state: String,
3535 pub pressure_reason_code: String,
3537}
3538
3539#[derive(Debug, Clone, Serialize, Deserialize)]
3541pub struct BuildCancellationMetadata {
3542 pub operation_id: String,
3544 pub origin: String,
3546 pub reason_code: String,
3548 #[serde(default)]
3550 pub decision_path: Vec<String>,
3551 pub escalation_stage: String,
3553 pub escalation_count: u32,
3555 pub remote_kill_attempted: bool,
3557 pub cleanup_ok: bool,
3559 pub history_cancelled: bool,
3561 pub final_state: String,
3563 #[serde(default, skip_serializing_if = "Option::is_none")]
3565 pub worker_health: Option<BuildCancellationWorkerHealth>,
3566}
3567
3568#[derive(Debug, Clone, Serialize, Deserialize)]
3570pub struct BuildRecord {
3571 pub id: u64,
3573 pub started_at: String,
3575 pub completed_at: String,
3577 pub project_id: String,
3579 pub worker_id: Option<String>,
3581 pub command: String,
3583 pub exit_code: i32,
3585 pub duration_ms: u64,
3587 pub location: BuildLocation,
3589 pub bytes_transferred: Option<u64>,
3591 #[serde(default)]
3593 pub timing: Option<CommandTimingBreakdown>,
3594 #[serde(default, skip_serializing_if = "Option::is_none")]
3596 pub cancellation: Option<BuildCancellationMetadata>,
3597}
3598
3599#[derive(Debug, Clone, Serialize, Deserialize)]
3601pub struct BuildRecordInput {
3602 pub started_at: String,
3604 pub completed_at: String,
3606 pub project_id: String,
3608 pub worker_id: Option<String>,
3610 pub command: String,
3612 pub exit_code: i32,
3614 pub duration_ms: u64,
3616 pub location: BuildLocation,
3618 pub bytes_transferred: Option<u64>,
3620 #[serde(default)]
3622 pub timing: Option<CommandTimingBreakdown>,
3623 #[serde(default, skip_serializing_if = "Option::is_none")]
3625 pub cancellation: Option<BuildCancellationMetadata>,
3626}
3627
3628impl BuildRecordInput {
3629 pub fn into_record(self, id: u64) -> BuildRecord {
3631 BuildRecord {
3632 id,
3633 started_at: self.started_at,
3634 completed_at: self.completed_at,
3635 project_id: self.project_id,
3636 worker_id: self.worker_id,
3637 command: self.command,
3638 exit_code: self.exit_code,
3639 duration_ms: self.duration_ms,
3640 location: self.location,
3641 bytes_transferred: self.bytes_transferred,
3642 timing: self.timing,
3643 cancellation: self.cancellation,
3644 }
3645 }
3646}
3647
3648#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3653pub struct BuildStats {
3654 pub total_builds: usize,
3656 pub success_count: usize,
3658 pub failure_count: usize,
3660 pub remote_count: usize,
3662 pub local_count: usize,
3664 pub avg_duration_ms: u64,
3666}
3667
3668#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3675pub struct SavedTimeStats {
3676 pub total_remote_duration_ms: u64,
3678 pub estimated_local_duration_ms: u64,
3681 pub time_saved_ms: u64,
3684 pub builds_counted: usize,
3686 pub avg_speedup: f64,
3690 pub today_saved_ms: u64,
3692 pub week_saved_ms: u64,
3694 #[serde(default)]
3697 pub estimate_basis: String,
3698 #[serde(default)]
3700 pub local_baseline_builds: usize,
3701}
3702
3703use std::collections::VecDeque;
3708use std::time::{Duration, Instant};
3709
3710#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3712pub struct CompilationTimingBreakdown {
3713 #[serde(with = "duration_millis")]
3715 pub rsync_up: Duration,
3716 #[serde(with = "duration_millis")]
3718 pub remote_build: Duration,
3719 #[serde(with = "duration_millis")]
3721 pub rsync_down: Duration,
3722 #[serde(with = "duration_millis")]
3724 pub total: Duration,
3725}
3726
3727#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3731pub struct CommandTimingBreakdown {
3732 #[serde(with = "option_duration_millis")]
3734 pub classify: Option<Duration>,
3735 #[serde(with = "option_duration_millis")]
3737 pub select: Option<Duration>,
3738 #[serde(with = "option_duration_millis")]
3740 pub sync_up: Option<Duration>,
3741 #[serde(with = "option_duration_millis")]
3743 pub exec: Option<Duration>,
3744 #[serde(with = "option_duration_millis")]
3746 pub sync_down: Option<Duration>,
3747 #[serde(with = "option_duration_millis")]
3749 pub cleanup: Option<Duration>,
3750 #[serde(with = "option_duration_millis")]
3752 pub total: Option<Duration>,
3753}
3754
3755mod duration_millis {
3756 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3757 use std::time::Duration;
3758
3759 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
3760 where
3761 S: Serializer,
3762 {
3763 duration.as_millis().serialize(serializer)
3764 }
3765
3766 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
3767 where
3768 D: Deserializer<'de>,
3769 {
3770 let millis = u64::deserialize(deserializer)?;
3771 Ok(Duration::from_millis(millis))
3772 }
3773}
3774
3775#[derive(Debug, Clone, Serialize, Deserialize)]
3777pub struct CompilationMetrics {
3778 pub project_id: String,
3780 pub worker_id: String,
3782 pub timestamp: chrono::DateTime<chrono::Utc>,
3784 pub timing: CompilationTimingBreakdown,
3786 #[serde(with = "option_duration_millis")]
3788 pub local_build_time: Option<Duration>,
3789 pub speedup: Option<f64>,
3791 pub files_synced: u64,
3793 pub bytes_transferred: u64,
3795 pub exit_code: i32,
3797 pub success: bool,
3799}
3800
3801mod option_duration_millis {
3802 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3803 use std::time::Duration;
3804
3805 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
3806 where
3807 S: Serializer,
3808 {
3809 match duration {
3810 Some(d) => Some(d.as_millis() as u64).serialize(serializer),
3811 None => Option::<u64>::None.serialize(serializer),
3812 }
3813 }
3814
3815 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
3816 where
3817 D: Deserializer<'de>,
3818 {
3819 let opt: Option<u64> = Option::deserialize(deserializer)?;
3820 Ok(opt.map(Duration::from_millis))
3821 }
3822}
3823
3824impl CompilationMetrics {
3825 pub fn calculate_speedup(&mut self) {
3827 if let Some(local) = self.local_build_time
3828 && self.timing.total.as_millis() > 0
3829 {
3830 self.speedup = Some(local.as_secs_f64() / self.timing.total.as_secs_f64());
3831 }
3832 }
3833
3834 pub fn is_beneficial(&self) -> bool {
3836 self.speedup.map(|s| s > 1.0).unwrap_or(false)
3837 }
3838}
3839
3840impl Default for CompilationMetrics {
3841 fn default() -> Self {
3842 Self {
3843 project_id: String::new(),
3844 worker_id: String::new(),
3845 timestamp: chrono::Utc::now(),
3846 timing: CompilationTimingBreakdown::default(),
3847 local_build_time: None,
3848 speedup: None,
3849 files_synced: 0,
3850 bytes_transferred: 0,
3851 exit_code: 0,
3852 success: true,
3853 }
3854 }
3855}
3856
3857#[derive(Debug)]
3873pub struct CompilationTimer {
3874 project_id: String,
3875 worker_id: String,
3876 start: Instant,
3877 phase_start: Instant,
3878 rsync_up: Option<Duration>,
3879 remote_build: Option<Duration>,
3880 rsync_down: Option<Duration>,
3881}
3882
3883impl CompilationTimer {
3884 pub fn new(project_id: &str, worker_id: &str) -> Self {
3886 let now = Instant::now();
3887 Self {
3888 project_id: project_id.to_string(),
3889 worker_id: worker_id.to_string(),
3890 start: now,
3891 phase_start: now,
3892 rsync_up: None,
3893 remote_build: None,
3894 rsync_down: None,
3895 }
3896 }
3897
3898 pub fn end_rsync_up(&mut self) {
3900 self.rsync_up = Some(self.phase_start.elapsed());
3901 self.phase_start = Instant::now();
3902 tracing::info!(
3903 rsync_up_ms = %self.rsync_up.unwrap().as_millis(),
3904 "TIMING: rsync_up completed"
3905 );
3906 }
3907
3908 pub fn end_remote_build(&mut self) {
3910 self.remote_build = Some(self.phase_start.elapsed());
3911 self.phase_start = Instant::now();
3912 tracing::info!(
3913 remote_build_ms = %self.remote_build.unwrap().as_millis(),
3914 "TIMING: remote_build completed"
3915 );
3916 }
3917
3918 pub fn end_rsync_down(&mut self) {
3920 self.rsync_down = Some(self.phase_start.elapsed());
3921 tracing::info!(
3922 rsync_down_ms = %self.rsync_down.unwrap().as_millis(),
3923 "TIMING: rsync_down completed"
3924 );
3925 }
3926
3927 pub fn finish(self, exit_code: i32, files: u64, bytes: u64) -> CompilationMetrics {
3929 let total = self.start.elapsed();
3930 tracing::info!(
3931 total_ms = %total.as_millis(),
3932 exit_code = %exit_code,
3933 "TIMING: compilation completed"
3934 );
3935
3936 CompilationMetrics {
3937 project_id: self.project_id,
3938 worker_id: self.worker_id,
3939 timestamp: chrono::Utc::now(),
3940 timing: CompilationTimingBreakdown {
3941 rsync_up: self.rsync_up.unwrap_or_default(),
3942 remote_build: self.remote_build.unwrap_or_default(),
3943 rsync_down: self.rsync_down.unwrap_or_default(),
3944 total,
3945 },
3946 local_build_time: None,
3947 speedup: None,
3948 files_synced: files,
3949 bytes_transferred: bytes,
3950 exit_code,
3951 success: exit_code == 0,
3952 }
3953 }
3954}
3955
3956#[derive(Debug)]
3958pub struct MetricsAggregator {
3959 history: VecDeque<CompilationMetrics>,
3960 max_history: usize,
3961}
3962
3963impl MetricsAggregator {
3964 pub fn new(max_history: usize) -> Self {
3966 Self {
3967 history: VecDeque::with_capacity(max_history),
3968 max_history,
3969 }
3970 }
3971
3972 pub fn record(&mut self, metrics: CompilationMetrics) {
3974 if self.history.len() >= self.max_history {
3975 self.history.pop_front();
3976 }
3977 self.history.push_back(metrics);
3978 }
3979
3980 pub fn average_speedup(&self) -> Option<f64> {
3982 let speedups: Vec<f64> = self.history.iter().filter_map(|m| m.speedup).collect();
3983
3984 if speedups.is_empty() {
3985 None
3986 } else {
3987 Some(speedups.iter().sum::<f64>() / speedups.len() as f64)
3988 }
3989 }
3990
3991 pub fn p50_total_time(&self) -> Option<Duration> {
3993 self.percentile_total_time(0.50)
3994 }
3995
3996 pub fn p95_total_time(&self) -> Option<Duration> {
3998 self.percentile_total_time(0.95)
3999 }
4000
4001 pub fn p99_total_time(&self) -> Option<Duration> {
4003 self.percentile_total_time(0.99)
4004 }
4005
4006 fn percentile_total_time(&self, percentile: f64) -> Option<Duration> {
4011 let mut times: Vec<_> = self.history.iter().map(|m| m.timing.total).collect();
4012
4013 if times.is_empty() {
4014 return None;
4015 }
4016
4017 times.sort();
4018 let idx = (((times.len() - 1) as f64 * percentile).round() as usize).min(times.len() - 1);
4022 Some(times[idx])
4023 }
4024
4025 pub fn success_rate(&self) -> f64 {
4027 if self.history.is_empty() {
4028 return 100.0;
4029 }
4030 let successes = self.history.iter().filter(|m| m.success).count();
4031 (successes as f64 / self.history.len() as f64) * 100.0
4032 }
4033
4034 pub fn count(&self) -> usize {
4036 self.history.len()
4037 }
4038
4039 pub fn metrics(&self) -> &VecDeque<CompilationMetrics> {
4041 &self.history
4042 }
4043
4044 pub fn clear(&mut self) {
4046 self.history.clear();
4047 }
4048}
4049
4050impl Default for MetricsAggregator {
4051 fn default() -> Self {
4052 Self::new(1000)
4053 }
4054}
4055
4056#[cfg(test)]
4057mod tests {
4058 use super::*;
4059 use crate::test_guard;
4060
4061 #[test]
4062 fn test_declared_os_reads_reserved_tag() {
4063 assert_eq!(
4064 declared_os(&["rust".to_string(), "os:windows".to_string()]),
4065 Some("windows".to_string())
4066 );
4067 assert_eq!(
4068 declared_os(&["os:WINDOWS".to_string()]),
4069 Some("windows".to_string())
4070 );
4071 }
4072
4073 #[test]
4074 fn test_declared_os_absent_for_ordinary_tags() {
4075 assert_eq!(declared_os(&[]), None);
4078 assert_eq!(
4079 declared_os(&["rust".to_string(), "bun".to_string(), "go".to_string()]),
4080 None
4081 );
4082 assert_eq!(declared_os(&["os:".to_string()]), None);
4084 assert_eq!(declared_os(&["os: ".to_string()]), None);
4085 }
4086
4087 #[test]
4088 fn test_os_tag_roundtrips_through_declared_os() {
4089 for os in ["windows", "Darwin", " linux "] {
4090 let tag = os_tag(os);
4091 assert_eq!(
4092 declared_os(std::slice::from_ref(&tag)).as_deref(),
4093 Some(os.trim().to_ascii_lowercase().as_str())
4094 );
4095 }
4096 }
4097
4098 #[test]
4103 fn test_no_inline_secret_fields() {
4104 let endpoint = DoctorWebhookEndpoint {
4105 name: "ops".to_string(),
4106 url: "https://example.com/hook".to_string(),
4107 format: DoctorWebhookFormat::Pagerduty,
4108 on_transitions: vec!["any_to_failing".to_string()],
4109 retry_max: 3,
4110 retry_backoff_ms: 200,
4111 timeout_ms: 5000,
4112 enabled: true,
4113 routing_key_env: Some("RCH_PAGERDUTY_ROUTING_KEY".to_string()),
4114 bearer_token_env: Some("RCH_WEBHOOK_BEARER".to_string()),
4115 signing_secret_env: Some("RCH_WEBHOOK_HMAC".to_string()),
4116 };
4117 let value = serde_json::to_value(&endpoint).unwrap();
4120 let obj = value.as_object().expect("endpoint serializes to an object");
4121 for forbidden in ["secret", "routing_key", "bearer_token", "password", "token"] {
4122 assert!(
4123 !obj.contains_key(forbidden),
4124 "endpoint config must not expose an inline `{forbidden}` field"
4125 );
4126 }
4127 assert!(obj.contains_key("routing_key_env"));
4128 assert!(obj.contains_key("bearer_token_env"));
4129 assert!(obj.contains_key("signing_secret_env"));
4130 }
4131
4132 #[test]
4133 fn test_webhook_format_toml_spelling() {
4134 #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
4139 struct FormatWrap {
4140 format: DoctorWebhookFormat,
4141 }
4142 let rendered = toml::to_string(&FormatWrap {
4143 format: DoctorWebhookFormat::GenericJson,
4144 })
4145 .unwrap();
4146 assert!(
4147 rendered.contains("format = \"generic_json\""),
4148 "unexpected TOML: {rendered}"
4149 );
4150 let slack: FormatWrap = toml::from_str("format = \"slack\"").unwrap();
4151 assert_eq!(slack.format, DoctorWebhookFormat::Slack);
4152 let pd: FormatWrap = toml::from_str("format = \"pagerduty\"").unwrap();
4153 assert_eq!(pd.format, DoctorWebhookFormat::Pagerduty);
4154 }
4155
4156 #[test]
4157 fn test_doctor_webhooks_defaults() {
4158 let cfg = DoctorWebhooksConfig::default();
4159 assert!(cfg.endpoints.is_empty());
4160 assert_eq!(cfg.max_inflight, 16);
4161 let rch: RchConfig = toml::from_str("").unwrap();
4164 assert!(rch.doctor.webhooks.endpoints.is_empty());
4165 }
4166
4167 #[test]
4168 fn test_doctor_webhook_endpoint_parses_from_toml() {
4169 let toml_src = r#"
4170[doctor.webhooks]
4171max_inflight = 8
4172
4173[[doctor.webhooks.endpoints]]
4174name = "ops-slack"
4175url = "https://hooks.slack.com/services/T0/B0/x"
4176format = "slack"
4177on_transitions = ["healthy_to_degraded", "any_to_failing"]
4178retry_max = 2
4179"#;
4180 let cfg: RchConfig = toml::from_str(toml_src).unwrap();
4181 assert_eq!(cfg.doctor.webhooks.max_inflight, 8);
4182 assert_eq!(cfg.doctor.webhooks.endpoints.len(), 1);
4183 let ep = &cfg.doctor.webhooks.endpoints[0];
4184 assert_eq!(ep.name, "ops-slack");
4185 assert_eq!(ep.format, DoctorWebhookFormat::Slack);
4186 assert_eq!(ep.on_transitions.len(), 2);
4187 assert_eq!(ep.retry_max, 2);
4188 assert_eq!(ep.retry_backoff_ms, 200);
4190 assert_eq!(ep.timeout_ms, 5000);
4191 assert!(ep.enabled);
4192 }
4193
4194 fn assert_golden_json(
4195 actual: serde_json::Value,
4196 expected: &serde_json::Value,
4197 fixture_path: &str,
4198 ) {
4199 if &actual == expected {
4200 return;
4201 }
4202
4203 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
4204 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
4205 assert_eq!(
4206 &actual,
4207 expected,
4208 "golden mismatch in {fixture_path}\n\
4209 expected_blake3={}\n\
4210 actual_blake3={}\n\
4211 bless path: review the semantic diff, update the fixture expected_* field, \
4212 and record both hashes in the Beads closeout.\n\
4213 expected:\n{expected_json}\n\
4214 actual:\n{actual_json}",
4215 blake3::hash(expected_json.as_bytes()),
4216 blake3::hash(actual_json.as_bytes())
4217 );
4218 }
4219
4220 #[test]
4221 fn test_circuit_state_default() {
4222 let _guard = test_guard!();
4223 assert_eq!(CircuitState::default(), CircuitState::Closed);
4224 }
4225
4226 #[test]
4227 fn test_circuit_config_defaults() {
4228 let _guard = test_guard!();
4229 let config = CircuitBreakerConfig::default();
4230 assert_eq!(config.failure_threshold, 3);
4231 assert_eq!(config.success_threshold, 2);
4232 assert_eq!(config.error_rate_threshold, 0.5);
4233 assert_eq!(config.window_secs, 60);
4234 assert_eq!(config.open_cooldown_secs, 30);
4235 assert_eq!(config.half_open_max_probes, 1);
4236 }
4237
4238 #[test]
4239 fn test_rch_config_has_circuit_defaults() {
4240 let _guard = test_guard!();
4241 let config = RchConfig::default();
4242 assert_eq!(config.circuit.failure_threshold, 3);
4243 }
4244
4245 #[test]
4246 fn test_circuit_config_serde_roundtrip() {
4247 let _guard = test_guard!();
4248 let config = CircuitBreakerConfig {
4249 failure_threshold: 5,
4250 success_threshold: 3,
4251 error_rate_threshold: 0.75,
4252 window_secs: 120,
4253 open_cooldown_secs: 45,
4254 half_open_max_probes: 2,
4255 command_failure_threshold: 4,
4256 open_cooldown_max_secs: 900,
4257 };
4258 let json = serde_json::to_string(&config).unwrap();
4259 let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
4260 assert_eq!(parsed.command_failure_threshold, 4);
4261 assert_eq!(parsed.open_cooldown_max_secs, 900);
4262 assert_eq!(parsed.failure_threshold, 5);
4263 assert_eq!(parsed.success_threshold, 3);
4264 assert_eq!(parsed.error_rate_threshold, 0.75);
4265 assert_eq!(parsed.window_secs, 120);
4266 assert_eq!(parsed.open_cooldown_secs, 45);
4267 assert_eq!(parsed.half_open_max_probes, 2);
4268 }
4269
4270 #[test]
4275 fn test_worker_capabilities_default() {
4276 let _guard = test_guard!();
4277 let caps = WorkerCapabilities::default();
4278 assert!(caps.rustc_version.is_none());
4279 assert!(caps.rustup_toolchains.is_empty());
4280 assert!(caps.rustup_components.is_empty());
4281 assert!(caps.bun_version.is_none());
4282 assert!(caps.node_version.is_none());
4283 assert!(caps.npm_version.is_none());
4284 assert!(caps.projects_root_ok.is_none());
4285 assert!(caps.projects_root_issue.is_none());
4286 assert!(caps.projects_root_checked_at_unix_ms.is_none());
4287 }
4288
4289 #[test]
4290 fn test_worker_capabilities_new() {
4291 let _guard = test_guard!();
4292 let caps = WorkerCapabilities::new();
4293 assert!(caps.rustc_version.is_none());
4294 assert!(!caps.has_rust());
4295 assert!(!caps.has_bun());
4296 assert!(!caps.has_node());
4297 assert!(caps.is_topology_healthy().is_none());
4298 }
4299
4300 #[test]
4301 fn test_worker_capabilities_has_rust() {
4302 let _guard = test_guard!();
4303 let mut caps = WorkerCapabilities::new();
4304 assert!(!caps.has_rust());
4305
4306 caps.rustc_version = Some("rustc 1.76.0 (07dca489a 2024-02-04)".to_string());
4307 assert!(caps.has_rust());
4308 }
4309
4310 #[test]
4311 fn test_worker_capabilities_components_are_toolchain_scoped_and_exact() {
4312 let _guard = test_guard!();
4313 let caps = WorkerCapabilities {
4314 rustup_components: vec![
4315 "stable-x86_64-unknown-linux-gnu:clippy".to_string(),
4316 "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
4317 "beta-x86_64-unknown-linux-gnu:clippy-preview".to_string(),
4318 ],
4319 ..Default::default()
4320 };
4321
4322 assert!(caps.has_rustup_component("stable", "clippy"));
4323 assert!(caps.has_rustup_component("nightly-2026-07-05", "rustfmt"));
4324 assert!(!caps.has_rustup_component("nightly-2026-07-05", "clippy"));
4325 assert!(!caps.has_rustup_component("beta", "clippy"));
4326 assert!(!caps.has_rustup_component("nightly-2026-07-0", "rustfmt"));
4327 }
4328
4329 #[test]
4330 fn test_worker_capabilities_has_bun() {
4331 let _guard = test_guard!();
4332 let mut caps = WorkerCapabilities::new();
4333 assert!(!caps.has_bun());
4334
4335 caps.bun_version = Some("1.0.25".to_string());
4336 assert!(caps.has_bun());
4337 }
4338
4339 #[test]
4340 fn test_worker_capabilities_has_nix() {
4341 let _guard = test_guard!();
4342 let mut caps = WorkerCapabilities::new();
4343 assert!(!caps.has_nix());
4344
4345 caps.nix_version = Some("nix (Nix) 2.24.9".to_string());
4346 assert!(caps.has_nix());
4347 }
4348
4349 #[test]
4350 fn test_worker_capabilities_has_zig_requires_both_halves() {
4351 let _guard = test_guard!();
4352 let mut caps = WorkerCapabilities::new();
4353 assert!(!caps.has_zig());
4354
4355 caps.zig_version = Some("0.14.1".to_string());
4358 assert!(!caps.has_zig());
4359
4360 caps.zig_version = None;
4362 caps.cargo_zigbuild_version = Some("cargo-zigbuild 0.23.0".to_string());
4363 assert!(!caps.has_zig());
4364
4365 caps.zig_version = Some("0.14.1".to_string());
4366 assert!(caps.has_zig());
4367 }
4368
4369 #[test]
4370 fn test_nix_build_uses_generous_timeout() {
4371 let _guard = test_guard!();
4372 let cfg = CompilationConfig::default();
4373 assert_eq!(
4375 cfg.timeout_for_kind(Some(CompilationKind::NixBuild)),
4376 std::time::Duration::from_secs(cfg.test_timeout_sec)
4377 );
4378 }
4379
4380 #[test]
4381 fn test_job_uses_build_timeout_bucket() {
4382 let _guard = test_guard!();
4383 let cfg = CompilationConfig::default();
4386 assert_eq!(
4387 cfg.timeout_for_kind(Some(CompilationKind::Job)),
4388 std::time::Duration::from_secs(cfg.build_timeout_sec)
4389 );
4390 }
4391
4392 #[test]
4393 fn test_layer0_disabled_by_default_and_env_gated() {
4394 let _guard = test_guard!();
4395 let cfg = Layer0Config::default();
4396 assert!(!cfg.enabled);
4397 let knobs_on = Layer0Config {
4399 enabled: false,
4400 release_lto_thin: true,
4401 release_codegen_units_1: true,
4402 };
4403 assert!(cfg.active_env().is_empty());
4404 assert!(knobs_on.active_env().is_empty());
4405 }
4406
4407 #[test]
4408 fn test_layer0_knobs_emit_stable_env_pairs() {
4409 let _guard = test_guard!();
4410 let lto_only = Layer0Config {
4411 enabled: true,
4412 release_lto_thin: true,
4413 release_codegen_units_1: false,
4414 };
4415 assert_eq!(
4416 lto_only.active_env(),
4417 vec![("CARGO_PROFILE_RELEASE_LTO", "thin")]
4418 );
4419 let both = Layer0Config {
4420 enabled: true,
4421 release_lto_thin: true,
4422 release_codegen_units_1: true,
4423 };
4424 assert_eq!(
4425 both.active_env(),
4426 vec![
4427 ("CARGO_PROFILE_RELEASE_LTO", "thin"),
4428 ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
4429 ],
4430 "emission order must stay stable for deterministic baselines"
4431 );
4432 }
4433
4434 #[test]
4435 fn test_layer0_pack_version_is_recorded() {
4436 let _guard = test_guard!();
4437 assert!(!LAYER0_PACK_VERSION.is_empty());
4439 }
4440 #[test]
4441 fn test_default_allowlist_includes_nix() {
4442 let _guard = test_guard!();
4443 let cfg = ExecutionConfig::default();
4444 assert!(cfg.is_allowed("nix"));
4445 }
4446
4447 #[test]
4448 fn test_worker_capabilities_has_node() {
4449 let _guard = test_guard!();
4450 let mut caps = WorkerCapabilities::new();
4451 assert!(!caps.has_node());
4452
4453 caps.node_version = Some("v20.11.0".to_string());
4454 assert!(caps.has_node());
4455 }
4456
4457 #[test]
4458 fn test_worker_capabilities_multiple_runtimes() {
4459 let _guard = test_guard!();
4460 let caps = WorkerCapabilities {
4461 rustc_version: Some("rustc 1.76.0".to_string()),
4462 bun_version: Some("1.0.25".to_string()),
4463 node_version: Some("v20.11.0".to_string()),
4464 npm_version: Some("10.2.4".to_string()),
4465 ..Default::default()
4466 };
4467
4468 assert!(caps.has_rust());
4469 assert!(caps.has_bun());
4470 assert!(caps.has_node());
4471 }
4472
4473 #[test]
4474 fn test_worker_capabilities_serialization_empty() {
4475 let _guard = test_guard!();
4476 let caps = WorkerCapabilities::new();
4477 let json = serde_json::to_string(&caps).unwrap();
4478 assert_eq!(json, "{}");
4480
4481 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4483 assert!(!parsed.has_rust());
4484 assert!(!parsed.has_bun());
4485 assert!(!parsed.has_node());
4486 }
4487
4488 #[test]
4489 fn test_worker_capabilities_serialization_with_versions() {
4490 let _guard = test_guard!();
4491 let caps = WorkerCapabilities {
4492 rustc_version: Some("rustc 1.76.0-nightly".to_string()),
4493 bun_version: Some("1.0.25".to_string()),
4494 node_version: None,
4495 npm_version: None,
4496 ..Default::default()
4497 };
4498
4499 let json = serde_json::to_string(&caps).unwrap();
4500 assert!(json.contains("rustc_version"));
4501 assert!(json.contains("rustc 1.76.0-nightly"));
4502 assert!(json.contains("bun_version"));
4503 assert!(json.contains("1.0.25"));
4504 assert!(!json.contains("node_version"));
4506 assert!(!json.contains("npm_version"));
4507
4508 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4509 assert!(parsed.has_rust());
4510 assert!(parsed.has_bun());
4511 assert!(!parsed.has_node());
4512 }
4513
4514 #[test]
4515 fn test_worker_capabilities_deserialization_partial() {
4516 let _guard = test_guard!();
4517 let json = r#"{"bun_version": "1.0.0"}"#;
4519 let caps: WorkerCapabilities = serde_json::from_str(json).unwrap();
4520
4521 assert!(!caps.has_rust());
4522 assert!(caps.has_bun());
4523 assert!(!caps.has_node());
4524 assert_eq!(caps.bun_version, Some("1.0.0".to_string()));
4525 }
4526
4527 #[test]
4528 fn test_worker_capabilities_clone() {
4529 let _guard = test_guard!();
4530 let caps = WorkerCapabilities {
4531 rustc_version: Some("1.76.0".to_string()),
4532 bun_version: None,
4533 node_version: Some("v20".to_string()),
4534 npm_version: None,
4535 projects_root_ok: Some(false),
4536 projects_root_issue: Some("alias_missing".to_string()),
4537 projects_root_checked_at_unix_ms: Some(123),
4538 ..Default::default()
4539 };
4540
4541 let cloned = caps.clone();
4542 assert_eq!(cloned.rustc_version, caps.rustc_version);
4543 assert_eq!(cloned.bun_version, caps.bun_version);
4544 assert_eq!(cloned.node_version, caps.node_version);
4545 assert_eq!(cloned.npm_version, caps.npm_version);
4546 assert_eq!(cloned.projects_root_ok, caps.projects_root_ok);
4547 assert_eq!(cloned.projects_root_issue, caps.projects_root_issue);
4548 assert_eq!(
4549 cloned.projects_root_checked_at_unix_ms,
4550 caps.projects_root_checked_at_unix_ms
4551 );
4552 }
4553
4554 #[test]
4555 fn test_worker_capabilities_topology_health_helper() {
4556 let _guard = test_guard!();
4557 let mut caps = WorkerCapabilities::new();
4558 assert_eq!(caps.is_topology_healthy(), None);
4559
4560 caps.projects_root_ok = Some(true);
4561 assert_eq!(caps.is_topology_healthy(), Some(true));
4562
4563 caps.projects_root_ok = Some(false);
4564 assert_eq!(caps.is_topology_healthy(), Some(false));
4565 }
4566
4567 #[test]
4568 fn test_selection_reason_serialization() {
4569 let _guard = test_guard!();
4570 assert_eq!(
4572 serde_json::to_string(&SelectionReason::Success).unwrap(),
4573 "\"success\""
4574 );
4575 assert_eq!(
4576 serde_json::to_string(&SelectionReason::NoWorkersConfigured).unwrap(),
4577 "\"no_workers_configured\""
4578 );
4579 assert_eq!(
4580 serde_json::to_string(&SelectionReason::AllWorkersUnreachable).unwrap(),
4581 "\"all_workers_unreachable\""
4582 );
4583 assert_eq!(
4584 serde_json::to_string(&SelectionReason::AllCircuitsOpen).unwrap(),
4585 "\"all_circuits_open\""
4586 );
4587 assert_eq!(
4588 serde_json::to_string(&SelectionReason::AllWorkersBusy).unwrap(),
4589 "\"all_workers_busy\""
4590 );
4591 assert_eq!(
4592 serde_json::to_string(&SelectionReason::NoWorkersPassedHealth).unwrap(),
4593 "\"no_workers_passed_health\""
4594 );
4595 assert_eq!(
4596 serde_json::to_string(&SelectionReason::AllWorkersFailedPreflight).unwrap(),
4597 "\"all_workers_failed_preflight\""
4598 );
4599 assert_eq!(
4600 serde_json::to_string(&SelectionReason::AllWorkersFailedConvergence).unwrap(),
4601 "\"all_workers_failed_convergence\""
4602 );
4603 assert_eq!(
4604 serde_json::to_string(&SelectionReason::NoAdmissibleWorkers(
4605 "critical_pressure=1,insufficient_slots=1".to_string()
4606 ))
4607 .unwrap(),
4608 "{\"no_admissible_workers\":\"critical_pressure=1,insufficient_slots=1\"}"
4609 );
4610 assert_eq!(
4611 serde_json::to_string(&SelectionReason::NoMatchingWorkers).unwrap(),
4612 "\"no_matching_workers\""
4613 );
4614 }
4615
4616 #[test]
4617 fn test_selection_reason_with_error() {
4618 let _guard = test_guard!();
4619 let reason = SelectionReason::SelectionError("test error".to_string());
4620 let json = serde_json::to_string(&reason).unwrap();
4621 assert!(json.contains("selection_error"));
4622 assert!(json.contains("test error"));
4623 }
4624
4625 #[test]
4626 fn test_selection_reason_deserialization() {
4627 let _guard = test_guard!();
4628 assert_eq!(
4629 serde_json::from_str::<SelectionReason>("\"success\"").unwrap(),
4630 SelectionReason::Success
4631 );
4632 assert_eq!(
4633 serde_json::from_str::<SelectionReason>("\"all_workers_busy\"").unwrap(),
4634 SelectionReason::AllWorkersBusy
4635 );
4636 assert_eq!(
4637 serde_json::from_str::<SelectionReason>("\"no_workers_passed_health\"").unwrap(),
4638 SelectionReason::NoWorkersPassedHealth
4639 );
4640 assert_eq!(
4641 serde_json::from_str::<SelectionReason>("\"all_workers_failed_preflight\"").unwrap(),
4642 SelectionReason::AllWorkersFailedPreflight
4643 );
4644 assert_eq!(
4645 serde_json::from_str::<SelectionReason>("\"all_workers_failed_convergence\"").unwrap(),
4646 SelectionReason::AllWorkersFailedConvergence
4647 );
4648 assert_eq!(
4649 serde_json::from_str::<SelectionReason>(
4650 "{\"no_admissible_workers\":\"critical_pressure=1\"}"
4651 )
4652 .unwrap(),
4653 SelectionReason::NoAdmissibleWorkers("critical_pressure=1".to_string())
4654 );
4655 }
4656
4657 #[test]
4658 fn test_selection_reason_display() {
4659 let _guard = test_guard!();
4660 assert_eq!(
4661 SelectionReason::Success.to_string(),
4662 "worker assigned successfully"
4663 );
4664 assert_eq!(
4665 SelectionReason::NoWorkersConfigured.to_string(),
4666 "no workers configured"
4667 );
4668 assert_eq!(
4669 SelectionReason::AllWorkersUnreachable.to_string(),
4670 "all workers unreachable"
4671 );
4672 assert_eq!(
4673 SelectionReason::AllWorkersBusy.to_string(),
4674 "all workers at capacity"
4675 );
4676 assert_eq!(
4677 SelectionReason::NoWorkersPassedHealth.to_string(),
4678 "no workers passed health thresholds"
4679 );
4680 assert_eq!(
4681 SelectionReason::AllWorkersFailedPreflight.to_string(),
4682 "all workers failed preflight checks"
4683 );
4684 assert_eq!(
4685 SelectionReason::AllWorkersFailedConvergence.to_string(),
4686 "all workers failed repo convergence checks"
4687 );
4688 assert_eq!(
4689 SelectionReason::NoAdmissibleWorkers(
4690 "critical_pressure=1,insufficient_slots=1".to_string()
4691 )
4692 .to_string(),
4693 "no admissible workers: critical_pressure=1,insufficient_slots=1"
4694 );
4695 assert_eq!(
4696 SelectionReason::SelectionError("oops".to_string()).to_string(),
4697 "selection error: oops"
4698 );
4699 }
4700
4701 #[test]
4702 fn test_selection_response_with_worker() {
4703 let _guard = test_guard!();
4704 let response = SelectionResponse {
4705 worker: Some(SelectedWorker {
4706 id: WorkerId::new("test"),
4707 host: "localhost".to_string(),
4708 user: "user".to_string(),
4709 identity_file: "~/.ssh/id_rsa".to_string(),
4710 slots_available: 8,
4711 speed_score: 75.0,
4712 declared_os: None,
4713 }),
4714 reason: SelectionReason::Success,
4715 build_id: None,
4716 diagnostics: None,
4717 };
4718
4719 let json = serde_json::to_string(&response).unwrap();
4720 assert!(json.contains("\"reason\":\"success\""));
4721 assert!(json.contains("\"id\":\"test\""));
4722 }
4723
4724 #[test]
4725 fn test_selection_response_without_worker() {
4726 let _guard = test_guard!();
4727 let response = SelectionResponse {
4728 worker: None,
4729 reason: SelectionReason::AllWorkersBusy,
4730 build_id: None,
4731 diagnostics: None,
4732 };
4733
4734 let json = serde_json::to_string(&response).unwrap();
4735 assert!(json.contains("\"worker\":null"));
4736 assert!(json.contains("\"reason\":\"all_workers_busy\""));
4737 }
4738
4739 #[test]
4740 fn golden_selection_response_with_no_workers_passed_health_diagnostics() {
4741 let _guard = test_guard!();
4742 const FIXTURE_PATH: &str =
4743 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json";
4744 let fixture: serde_json::Value = serde_json::from_str(include_str!(
4745 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json"
4746 ))
4747 .expect("selection diagnostics golden fixture parses");
4748 assert_eq!(
4749 fixture["schema_version"],
4750 "rch.golden.selection_diagnostics.v1"
4751 );
4752
4753 let response = SelectionResponse {
4754 worker: None,
4755 reason: SelectionReason::NoWorkersPassedHealth,
4756 build_id: None,
4757 diagnostics: Some(SelectionDiagnostics {
4758 required_runtime: RequiredRuntime::Rust,
4759 estimated_cores: 2,
4760 min_success_rate: 0.8,
4761 fallback_min_success_rate: 0.5,
4762 active_project_exclusion_count: 0,
4763 workers: vec![WorkerSelectionDiagnostic {
4764 worker_id: WorkerId::new("fixture-worker"),
4765 status: "healthy".to_string(),
4766 circuit_state: "closed".to_string(),
4767 pressure_state: "healthy".to_string(),
4768 pressure_reason_code: "pressure.ok".to_string(),
4769 success_rate: Some(0.2),
4770 min_success_rate: 0.8,
4771 fallback_min_success_rate: 0.5,
4772 required_runtime: RequiredRuntime::Rust,
4773 runtime_available: true,
4774 available_slots: 8,
4775 total_slots: 8,
4776 estimated_cores: 2,
4777 active_project_excluded: false,
4778 final_decision: WorkerSelectionDiagnosticDecision::Deny,
4779 final_reason: "success_rate 0.20 < fallback 0.50".to_string(),
4780 reason_codes: vec![
4781 "health.below_min_success_rate".to_string(),
4782 "health.below_fallback_min_success_rate".to_string(),
4783 ],
4784 }],
4785 }),
4786 };
4787
4788 assert_golden_json(
4789 serde_json::to_value(&response).expect("response serializes"),
4790 &fixture["expected_response"],
4791 FIXTURE_PATH,
4792 );
4793 }
4794
4795 #[test]
4796 fn test_selection_response_roundtrip() {
4797 let _guard = test_guard!();
4798 let original = SelectionResponse {
4799 worker: Some(SelectedWorker {
4800 id: WorkerId::new("worker-1"),
4801 host: "192.168.1.100".to_string(),
4802 user: "ubuntu".to_string(),
4803 identity_file: "/path/to/key".to_string(),
4804 slots_available: 16,
4805 speed_score: 90.5,
4806 declared_os: None,
4807 }),
4808 reason: SelectionReason::Success,
4809 build_id: None,
4810 diagnostics: None,
4811 };
4812
4813 let json = serde_json::to_string(&original).unwrap();
4814 let parsed: SelectionResponse = serde_json::from_str(&json).unwrap();
4815
4816 assert!(parsed.worker.is_some());
4817 let worker = parsed.worker.unwrap();
4818 assert_eq!(worker.id.as_str(), "worker-1");
4819 assert_eq!(worker.host, "192.168.1.100");
4820 assert_eq!(worker.slots_available, 16);
4821 assert_eq!(parsed.reason, SelectionReason::Success);
4822 }
4823
4824 #[test]
4825 fn test_build_heartbeat_phase_roundtrip() {
4826 let _guard = test_guard!();
4827 let json = serde_json::to_string(&BuildHeartbeatPhase::SyncUp).unwrap();
4828 assert_eq!(json, "\"sync_up\"");
4829 let parsed: BuildHeartbeatPhase = serde_json::from_str(&json).unwrap();
4830 assert_eq!(parsed, BuildHeartbeatPhase::SyncUp);
4831 }
4832
4833 #[test]
4834 fn test_build_heartbeat_request_roundtrip() {
4835 let _guard = test_guard!();
4836 let request = BuildHeartbeatRequest {
4837 build_id: 42,
4838 worker_id: WorkerId::new("worker-a"),
4839 hook_pid: Some(12345),
4840 local_wrapper_id: Some("wrapper-abc123".to_string()),
4841 remote_pgid_file: Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string()),
4842 phase: BuildHeartbeatPhase::Execute,
4843 detail: Some("Compiling crates".to_string()),
4844 progress_counter: Some(7),
4845 progress_percent: Some(42.5),
4846 };
4847
4848 let json = serde_json::to_string(&request).unwrap();
4849 let parsed: BuildHeartbeatRequest = serde_json::from_str(&json).unwrap();
4850 assert_eq!(parsed.build_id, 42);
4851 assert_eq!(parsed.worker_id.as_str(), "worker-a");
4852 assert_eq!(
4853 parsed.remote_pgid_file,
4854 Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string())
4855 );
4856 assert_eq!(parsed.phase, BuildHeartbeatPhase::Execute);
4857 assert_eq!(parsed.progress_counter, Some(7));
4858 assert_eq!(parsed.progress_percent, Some(42.5));
4859 }
4860
4861 #[test]
4864 fn test_circuit_stats_new() {
4865 let _guard = test_guard!();
4866 let stats = CircuitStats::new();
4867 assert_eq!(stats.state(), CircuitState::Closed);
4868 assert_eq!(stats.consecutive_failures(), 0);
4869 assert_eq!(stats.error_rate(), 0.0);
4870 assert!(stats.opened_at().is_none());
4871 }
4872
4873 #[test]
4874 fn test_circuit_stats_record_success() {
4875 let _guard = test_guard!();
4876 let mut stats = CircuitStats::new();
4877 stats.record_success();
4878 stats.record_success();
4879 assert_eq!(stats.error_rate(), 0.0);
4880 assert_eq!(stats.consecutive_failures(), 0);
4881 }
4882
4883 #[test]
4884 fn test_circuit_stats_record_failure() {
4885 let _guard = test_guard!();
4886 let mut stats = CircuitStats::new();
4887 stats.record_failure();
4888 stats.record_failure();
4889 assert_eq!(stats.consecutive_failures(), 2);
4890 assert_eq!(stats.error_rate(), 1.0); }
4892
4893 #[test]
4894 fn test_circuit_stats_error_rate() {
4895 let _guard = test_guard!();
4896 let mut stats = CircuitStats::new();
4897 stats.record_success();
4898 stats.record_success();
4899 stats.record_failure();
4900 assert!((stats.error_rate() - 0.333).abs() < 0.01);
4902 }
4903
4904 #[test]
4905 fn test_circuit_stats_should_open_consecutive_failures() {
4906 let _guard = test_guard!();
4907 let mut stats = CircuitStats::new();
4908 let config = CircuitBreakerConfig::default(); stats.record_failure();
4911 assert!(!stats.should_open(&config));
4912
4913 stats.record_failure();
4914 assert!(!stats.should_open(&config));
4915
4916 stats.record_failure();
4917 assert!(stats.should_open(&config)); }
4919
4920 #[test]
4921 fn test_circuit_stats_should_open_error_rate() {
4922 let _guard = test_guard!();
4923 let mut stats = CircuitStats::new();
4924 let config = CircuitBreakerConfig {
4925 error_rate_threshold: 0.5,
4926 ..Default::default()
4927 };
4928
4929 stats.record_success();
4931 stats.record_success();
4932 stats.record_failure();
4933 stats.record_failure();
4934 assert!(!stats.should_open(&config)); stats.record_failure(); assert!(stats.should_open(&config));
4938 }
4939
4940 #[test]
4941 fn test_circuit_close_clears_recent_results_no_flap() {
4942 let _guard = test_guard!();
4943 let mut stats = CircuitStats::new();
4947 let config = CircuitBreakerConfig {
4948 error_rate_threshold: 0.5,
4949 ..Default::default()
4950 };
4951 for _ in 0..6 {
4952 stats.record_failure();
4953 }
4954 assert!(stats.should_open(&config));
4955 stats.open();
4956 stats.half_open();
4958 stats.record_success();
4959 stats.record_success();
4960 stats.close();
4961 assert_eq!(stats.error_rate(), 0.0);
4963 assert!(!stats.should_open(&config));
4964 }
4965
4966 #[test]
4967 fn test_apply_health_outcome_opens_on_failure_threshold() {
4968 let _guard = test_guard!();
4969 let config = CircuitBreakerConfig {
4971 failure_threshold: 3,
4972 open_cooldown_secs: 300,
4973 ..Default::default()
4974 };
4975 let mut stats = CircuitStats::new();
4976 assert_eq!(
4977 stats.apply_health_outcome(false, false, &config),
4978 CircuitState::Closed
4979 );
4980 assert_eq!(
4981 stats.apply_health_outcome(false, false, &config),
4982 CircuitState::Closed
4983 );
4984 assert_eq!(
4985 stats.apply_health_outcome(false, false, &config),
4986 CircuitState::Open
4987 );
4988 }
4989
4990 #[test]
4991 fn test_apply_health_outcome_half_open_two_probe_recovery() {
4992 let _guard = test_guard!();
4993 let config = CircuitBreakerConfig {
4999 failure_threshold: 3,
5000 success_threshold: 2,
5001 open_cooldown_secs: 0,
5002 ..Default::default()
5003 };
5004 let mut stats = CircuitStats::new();
5005 for _ in 0..3 {
5006 stats.apply_health_outcome(false, false, &config);
5007 }
5008 assert_eq!(stats.state(), CircuitState::HalfOpen);
5010
5011 let after_first = stats.apply_health_outcome(true, false, &config);
5013 assert_eq!(after_first, CircuitState::HalfOpen);
5014
5015 let after_second = stats.apply_health_outcome(true, false, &config);
5017 assert_eq!(after_second, CircuitState::Closed);
5018 }
5019
5020 #[test]
5021 fn test_apply_health_outcome_transient_half_open_does_not_reopen() {
5022 let _guard = test_guard!();
5023 let config = CircuitBreakerConfig {
5026 failure_threshold: 3,
5027 success_threshold: 2,
5028 open_cooldown_secs: 0,
5029 ..Default::default()
5030 };
5031 let mut stats = CircuitStats::new();
5032 for _ in 0..3 {
5033 stats.apply_health_outcome(false, false, &config);
5034 }
5035 assert_eq!(stats.state(), CircuitState::HalfOpen);
5037
5038 assert_eq!(
5040 stats.apply_health_outcome(true, false, &config),
5041 CircuitState::HalfOpen
5042 );
5043
5044 let after_transient = stats.apply_health_outcome(false, true, &config);
5050 assert_eq!(after_transient, CircuitState::HalfOpen);
5051
5052 assert_eq!(
5054 stats.apply_health_outcome(true, false, &config),
5055 CircuitState::HalfOpen
5056 );
5057 assert_eq!(
5058 stats.apply_health_outcome(true, false, &config),
5059 CircuitState::Closed
5060 );
5061 }
5062
5063 #[test]
5064 fn test_apply_health_outcome_real_vs_transient_half_open_failure() {
5065 let _guard = test_guard!();
5066 let config = CircuitBreakerConfig {
5074 failure_threshold: 3,
5075 success_threshold: 2,
5076 open_cooldown_secs: 300,
5077 ..Default::default()
5078 };
5079
5080 let mut real = CircuitStats::new();
5082 real.open();
5083 real.half_open();
5084 assert_eq!(real.state(), CircuitState::HalfOpen);
5085 let after_real = real.apply_health_outcome(false, false, &config);
5086 assert_eq!(
5087 after_real,
5088 CircuitState::Open,
5089 "a real half-open failure must reopen the circuit"
5090 );
5091
5092 let mut transient = CircuitStats::new();
5094 transient.open();
5095 transient.half_open();
5096 assert_eq!(transient.state(), CircuitState::HalfOpen);
5097 let after_transient = transient.apply_health_outcome(false, true, &config);
5098 assert_eq!(
5099 after_transient,
5100 CircuitState::HalfOpen,
5101 "a transient half-open failure must NOT reopen the circuit"
5102 );
5103 }
5104
5105 #[test]
5110 fn test_command_failure_blocks_half_open_close_on_liveness_successes() {
5111 let _guard = test_guard!();
5112 let config = CircuitBreakerConfig {
5113 failure_threshold: 3,
5114 success_threshold: 2,
5115 open_cooldown_secs: 0,
5116 command_failure_threshold: 2,
5117 ..Default::default()
5118 };
5119 let mut stats = CircuitStats::new();
5120 stats.open();
5121 stats.half_open();
5122
5123 assert_eq!(
5125 stats.apply_command_outcome(false, &config),
5126 CircuitState::HalfOpen,
5127 "cooldown 0: reopen then immediately half-open again"
5128 );
5129 assert_eq!(stats.consecutive_command_failures(), 1);
5130
5131 for _ in 0..10 {
5133 assert_eq!(
5134 stats.apply_health_outcome(true, false, &config),
5135 CircuitState::HalfOpen,
5136 "liveness successes must not close the circuit while a command failure is outstanding"
5137 );
5138 }
5139
5140 assert_eq!(
5142 stats.apply_command_outcome(true, &config),
5143 CircuitState::Closed
5144 );
5145 assert_eq!(stats.consecutive_command_failures(), 0);
5146 assert_eq!(stats.reopen_streak(), 0);
5147 }
5148
5149 #[test]
5152 fn test_command_failures_open_closed_circuit_despite_liveness_successes() {
5153 let _guard = test_guard!();
5154 let config = CircuitBreakerConfig {
5155 failure_threshold: 3,
5156 command_failure_threshold: 2,
5157 open_cooldown_secs: 300,
5158 ..Default::default()
5159 };
5160 let mut stats = CircuitStats::new();
5161
5162 assert_eq!(
5163 stats.apply_command_outcome(false, &config),
5164 CircuitState::Closed
5165 );
5166 stats.apply_health_outcome(true, false, &config);
5168 stats.apply_health_outcome(true, false, &config);
5169 assert_eq!(stats.consecutive_failures(), 0);
5170 assert_eq!(
5172 stats.apply_command_outcome(false, &config),
5173 CircuitState::Open
5174 );
5175 assert_eq!(stats.reopen_streak(), 1);
5176 }
5177
5178 #[test]
5182 fn test_command_failure_in_half_open_always_reopens() {
5183 let _guard = test_guard!();
5184 let config = CircuitBreakerConfig {
5185 open_cooldown_secs: 300,
5186 ..Default::default()
5187 };
5188 let mut stats = CircuitStats::new();
5189 stats.open();
5190 stats.half_open();
5191 assert_eq!(
5192 stats.apply_command_outcome(false, &config),
5193 CircuitState::Open
5194 );
5195 assert_eq!(stats.reopen_streak(), 2);
5196 }
5197
5198 #[test]
5202 fn test_reopen_streak_backs_off_cooldown_until_cap() {
5203 let _guard = test_guard!();
5204 let config = CircuitBreakerConfig {
5205 open_cooldown_secs: 30,
5206 open_cooldown_max_secs: 100,
5207 ..Default::default()
5208 };
5209 assert_eq!(config.cooldown_ms_for_streak(0), 30_000);
5210 assert_eq!(config.cooldown_ms_for_streak(1), 30_000);
5211 assert_eq!(config.cooldown_ms_for_streak(2), 60_000);
5212 assert_eq!(config.cooldown_ms_for_streak(3), 100_000, "capped");
5213 assert_eq!(config.cooldown_ms_for_streak(40), 100_000, "no overflow");
5214
5215 let inverted = CircuitBreakerConfig {
5217 open_cooldown_secs: 30,
5218 open_cooldown_max_secs: 5,
5219 ..Default::default()
5220 };
5221 assert_eq!(inverted.cooldown_ms_for_streak(3), 30_000);
5222
5223 let mut stats = CircuitStats::new();
5226 stats.open();
5227 stats.half_open();
5228 stats.open();
5229 assert_eq!(stats.reopen_streak(), 2);
5230 let remaining = stats.recovery_remaining_secs(&config).unwrap();
5231 assert!(
5232 remaining > 30 && remaining <= 60,
5233 "second open should wait ~60s, got {remaining}"
5234 );
5235
5236 stats.close();
5238 assert_eq!(stats.reopen_streak(), 0);
5239 stats.open();
5240 assert!(stats.recovery_remaining_secs(&config).unwrap() <= 30);
5241 }
5242
5243 #[test]
5244 fn test_circuit_stats_success_resets_consecutive_failures() {
5245 let _guard = test_guard!();
5246 let mut stats = CircuitStats::new();
5247 stats.record_failure();
5248 stats.record_failure();
5249 assert_eq!(stats.consecutive_failures(), 2);
5250
5251 stats.record_success();
5252 assert_eq!(stats.consecutive_failures(), 0);
5253 }
5254
5255 #[test]
5256 fn test_circuit_stats_open_transition() {
5257 let _guard = test_guard!();
5258 let mut stats = CircuitStats::new();
5259 stats.open();
5260
5261 assert_eq!(stats.state(), CircuitState::Open);
5262 assert!(stats.opened_at().is_some());
5263 }
5264
5265 #[test]
5266 fn test_circuit_stats_half_open_transition() {
5267 let _guard = test_guard!();
5268 let mut stats = CircuitStats::new();
5269 stats.open();
5270 stats.half_open();
5271
5272 assert_eq!(stats.state(), CircuitState::HalfOpen);
5273 }
5274
5275 #[test]
5276 fn test_circuit_stats_close_transition() {
5277 let _guard = test_guard!();
5278 let mut stats = CircuitStats::new();
5279 stats.open();
5280 stats.half_open();
5281 stats.close();
5282
5283 assert_eq!(stats.state(), CircuitState::Closed);
5284 assert!(stats.opened_at().is_none());
5285 assert_eq!(stats.consecutive_failures(), 0);
5286 }
5287
5288 #[test]
5289 fn test_circuit_stats_should_close() {
5290 let _guard = test_guard!();
5291 let mut stats = CircuitStats::new();
5292 let config = CircuitBreakerConfig {
5293 success_threshold: 2,
5294 ..Default::default()
5295 };
5296
5297 stats.open();
5298 stats.half_open();
5299
5300 assert!(!stats.should_close(&config)); stats.record_success();
5303 assert!(!stats.should_close(&config)); stats.record_success();
5306 assert!(stats.should_close(&config)); }
5308
5309 #[test]
5310 fn test_circuit_stats_can_probe() {
5311 let _guard = test_guard!();
5312 let mut stats = CircuitStats::new();
5313 let config = CircuitBreakerConfig {
5314 half_open_max_probes: 1,
5315 ..Default::default()
5316 };
5317
5318 assert!(!stats.can_probe(&config));
5320
5321 stats.open();
5322 assert!(!stats.can_probe(&config));
5324
5325 stats.half_open();
5326 assert!(stats.can_probe(&config));
5328
5329 assert!(stats.start_probe(&config));
5331 assert!(!stats.can_probe(&config));
5333 assert!(!stats.start_probe(&config));
5334 }
5335
5336 #[test]
5337 fn test_circuit_stats_probe_completion() {
5338 let _guard = test_guard!();
5339 let mut stats = CircuitStats::new();
5340 let config = CircuitBreakerConfig {
5341 half_open_max_probes: 1,
5342 ..Default::default()
5343 };
5344
5345 stats.open();
5346 stats.half_open();
5347 stats.start_probe(&config);
5348
5349 stats.record_success();
5351
5352 assert!(stats.can_probe(&config));
5354 }
5355
5356 #[test]
5357 fn test_circuit_stats_failure_in_half_open() {
5358 let _guard = test_guard!();
5359 let mut stats = CircuitStats::new();
5360 let config = CircuitBreakerConfig {
5361 success_threshold: 2,
5362 ..Default::default()
5363 };
5364
5365 stats.open();
5366 stats.half_open();
5367
5368 stats.record_success();
5369 assert_eq!(stats.consecutive_failures(), 0);
5370
5371 stats.record_failure();
5373 assert!(!stats.should_close(&config));
5374
5375 stats.record_success();
5377 stats.record_success();
5378 assert!(stats.should_close(&config));
5379 }
5380
5381 #[test]
5382 fn test_circuit_stats_reset_window() {
5383 let _guard = test_guard!();
5384 let mut stats = CircuitStats::new();
5385 stats.record_success();
5386 stats.record_failure();
5387
5388 assert!(stats.error_rate() > 0.0);
5389
5390 stats.reset_window();
5391 assert_eq!(stats.error_rate(), 0.0);
5392 }
5393
5394 #[test]
5395 fn test_circuit_state_transitions_are_deterministic() {
5396 let _guard = test_guard!();
5397 let config = CircuitBreakerConfig::default();
5398 let mut stats = CircuitStats::new();
5399
5400 assert_eq!(stats.state(), CircuitState::Closed);
5402
5403 for _ in 0..3 {
5405 stats.record_failure();
5406 }
5407 assert!(stats.should_open(&config));
5408 stats.open();
5409 assert_eq!(stats.state(), CircuitState::Open);
5410
5411 stats.half_open();
5413 assert_eq!(stats.state(), CircuitState::HalfOpen);
5414
5415 for _ in 0..2 {
5417 stats.record_success();
5418 }
5419 assert!(stats.should_close(&config));
5420 stats.close();
5421 assert_eq!(stats.state(), CircuitState::Closed);
5422 }
5423
5424 fn make_test_metrics(
5429 speedup: Option<f64>,
5430 total_secs: u64,
5431 success: bool,
5432 ) -> CompilationMetrics {
5433 CompilationMetrics {
5434 project_id: "test-project".to_string(),
5435 worker_id: "worker-1".to_string(),
5436 timestamp: chrono::Utc::now(),
5437 timing: CompilationTimingBreakdown {
5438 rsync_up: Duration::from_millis(100),
5439 remote_build: Duration::from_millis(800),
5440 rsync_down: Duration::from_millis(100),
5441 total: Duration::from_secs(total_secs),
5442 },
5443 local_build_time: speedup.map(|s| Duration::from_secs_f64(total_secs as f64 * s)),
5444 speedup,
5445 files_synced: 100,
5446 bytes_transferred: 1_000_000,
5447 exit_code: if success { 0 } else { 1 },
5448 success,
5449 }
5450 }
5451
5452 #[test]
5453 fn test_compilation_timing_breakdown_default() {
5454 let _guard = test_guard!();
5455 let timing = CompilationTimingBreakdown::default();
5456 assert_eq!(timing.rsync_up, Duration::ZERO);
5457 assert_eq!(timing.remote_build, Duration::ZERO);
5458 assert_eq!(timing.rsync_down, Duration::ZERO);
5459 assert_eq!(timing.total, Duration::ZERO);
5460 }
5461
5462 #[test]
5463 fn test_compilation_timing_breakdown_serialization() {
5464 let _guard = test_guard!();
5465 let timing = CompilationTimingBreakdown {
5466 rsync_up: Duration::from_millis(100),
5467 remote_build: Duration::from_millis(2000),
5468 rsync_down: Duration::from_millis(50),
5469 total: Duration::from_millis(2150),
5470 };
5471
5472 let json = serde_json::to_string(&timing).unwrap();
5473 assert!(json.contains("100")); assert!(json.contains("2000")); assert!(json.contains("50")); assert!(json.contains("2150")); let parsed: CompilationTimingBreakdown = serde_json::from_str(&json).unwrap();
5479 assert_eq!(parsed.rsync_up, timing.rsync_up);
5480 assert_eq!(parsed.remote_build, timing.remote_build);
5481 assert_eq!(parsed.rsync_down, timing.rsync_down);
5482 assert_eq!(parsed.total, timing.total);
5483 }
5484
5485 #[test]
5486 fn test_command_timing_breakdown_serialization() {
5487 let _guard = test_guard!();
5488 let timing = CommandTimingBreakdown {
5489 classify: Some(Duration::from_millis(2)),
5490 select: None,
5491 sync_up: Some(Duration::from_millis(120)),
5492 exec: Some(Duration::from_millis(900)),
5493 sync_down: None,
5494 cleanup: Some(Duration::from_millis(5)),
5495 total: Some(Duration::from_millis(1027)),
5496 };
5497
5498 let json = serde_json::to_string(&timing).unwrap();
5499 assert!(json.contains("\"classify\":2"));
5500 assert!(json.contains("\"select\":null"));
5501 assert!(json.contains("\"sync_up\":120"));
5502 assert!(json.contains("\"exec\":900"));
5503 assert!(json.contains("\"sync_down\":null"));
5504 assert!(json.contains("\"cleanup\":5"));
5505 assert!(json.contains("\"total\":1027"));
5506
5507 let parsed: CommandTimingBreakdown = serde_json::from_str(&json).unwrap();
5508 assert_eq!(parsed.classify, timing.classify);
5509 assert_eq!(parsed.select, timing.select);
5510 assert_eq!(parsed.sync_up, timing.sync_up);
5511 assert_eq!(parsed.exec, timing.exec);
5512 assert_eq!(parsed.sync_down, timing.sync_down);
5513 assert_eq!(parsed.cleanup, timing.cleanup);
5514 assert_eq!(parsed.total, timing.total);
5515 }
5516
5517 #[test]
5518 fn test_compilation_metrics_calculate_speedup() {
5519 let _guard = test_guard!();
5520 let mut metrics = CompilationMetrics {
5521 timing: CompilationTimingBreakdown {
5522 total: Duration::from_secs(10),
5523 ..Default::default()
5524 },
5525 local_build_time: Some(Duration::from_secs(30)),
5526 ..Default::default()
5527 };
5528
5529 metrics.calculate_speedup();
5530
5531 assert!(metrics.speedup.is_some());
5532 let speedup = metrics.speedup.unwrap();
5533 assert!(
5534 (speedup - 3.0).abs() < 0.01,
5535 "Expected 3.0x speedup, got {}",
5536 speedup
5537 );
5538 }
5539
5540 #[test]
5541 fn test_compilation_metrics_is_beneficial() {
5542 let _guard = test_guard!();
5543 let mut metrics = CompilationMetrics::default();
5544
5545 assert!(!metrics.is_beneficial());
5547
5548 metrics.speedup = Some(1.5);
5550 assert!(metrics.is_beneficial());
5551
5552 metrics.speedup = Some(0.8);
5554 assert!(!metrics.is_beneficial());
5555
5556 metrics.speedup = Some(1.0);
5558 assert!(!metrics.is_beneficial());
5559 }
5560
5561 #[test]
5562 fn test_compilation_metrics_serialization() {
5563 let _guard = test_guard!();
5564 let metrics = make_test_metrics(Some(2.5), 10, true);
5565 let json = serde_json::to_string(&metrics).unwrap();
5566
5567 assert!(json.contains("test-project"));
5568 assert!(json.contains("worker-1"));
5569 assert!(json.contains("2.5")); let parsed: CompilationMetrics = serde_json::from_str(&json).unwrap();
5572 assert_eq!(parsed.project_id, "test-project");
5573 assert_eq!(parsed.worker_id, "worker-1");
5574 assert!(parsed.success);
5575 }
5576
5577 #[test]
5578 fn test_compilation_timer_new() {
5579 let _guard = test_guard!();
5580 let timer = CompilationTimer::new("my-project", "my-worker");
5581 assert_eq!(timer.project_id, "my-project");
5582 assert_eq!(timer.worker_id, "my-worker");
5583 assert!(timer.rsync_up.is_none());
5584 assert!(timer.remote_build.is_none());
5585 assert!(timer.rsync_down.is_none());
5586 }
5587
5588 #[test]
5589 fn test_compilation_timer_phases() {
5590 let _guard = test_guard!();
5591 let mut timer = CompilationTimer::new("test", "worker");
5592
5593 std::thread::sleep(Duration::from_millis(5));
5595 timer.end_rsync_up();
5596 assert!(timer.rsync_up.is_some());
5597 assert!(timer.rsync_up.unwrap() >= Duration::from_millis(5));
5598
5599 std::thread::sleep(Duration::from_millis(10));
5601 timer.end_remote_build();
5602 assert!(timer.remote_build.is_some());
5603 assert!(timer.remote_build.unwrap() >= Duration::from_millis(10));
5604
5605 std::thread::sleep(Duration::from_millis(5));
5607 timer.end_rsync_down();
5608 assert!(timer.rsync_down.is_some());
5609 assert!(timer.rsync_down.unwrap() >= Duration::from_millis(5));
5610
5611 let metrics = timer.finish(0, 50, 500_000);
5613 assert_eq!(metrics.project_id, "test");
5614 assert_eq!(metrics.worker_id, "worker");
5615 assert!(metrics.success);
5616 assert!(metrics.timing.total >= Duration::from_millis(20));
5617 assert_eq!(metrics.files_synced, 50);
5618 assert_eq!(metrics.bytes_transferred, 500_000);
5619 }
5620
5621 #[test]
5622 fn test_metrics_aggregator_new() {
5623 let _guard = test_guard!();
5624 let agg = MetricsAggregator::new(100);
5625 assert_eq!(agg.count(), 0);
5626 assert!(agg.average_speedup().is_none());
5627 }
5628
5629 #[test]
5630 fn test_metrics_aggregator_record() {
5631 let _guard = test_guard!();
5632 let mut agg = MetricsAggregator::new(3);
5633
5634 agg.record(make_test_metrics(Some(2.0), 10, true));
5636 assert_eq!(agg.count(), 1);
5637
5638 agg.record(make_test_metrics(Some(3.0), 20, true));
5640 agg.record(make_test_metrics(Some(4.0), 30, true));
5641 assert_eq!(agg.count(), 3);
5642
5643 agg.record(make_test_metrics(Some(5.0), 40, true));
5645 assert_eq!(agg.count(), 3);
5646 }
5647
5648 #[test]
5649 fn test_metrics_aggregator_average_speedup() {
5650 let _guard = test_guard!();
5651 let mut agg = MetricsAggregator::new(100);
5652
5653 assert!(agg.average_speedup().is_none());
5655
5656 for i in 1..=5 {
5658 agg.record(make_test_metrics(Some(i as f64), 10, true));
5659 }
5660
5661 let avg = agg.average_speedup().unwrap();
5662 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5663 }
5664
5665 #[test]
5666 fn test_metrics_aggregator_average_speedup_with_none() {
5667 let _guard = test_guard!();
5668 let mut agg = MetricsAggregator::new(100);
5669
5670 agg.record(make_test_metrics(Some(2.0), 10, true));
5672 agg.record(make_test_metrics(None, 10, true)); agg.record(make_test_metrics(Some(4.0), 10, true));
5674
5675 let avg = agg.average_speedup().unwrap();
5677 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5678 }
5679
5680 #[test]
5681 fn test_metrics_aggregator_percentiles() {
5682 let _guard = test_guard!();
5683 let mut agg = MetricsAggregator::new(100);
5684
5685 for i in 1..=10 {
5687 agg.record(make_test_metrics(Some(1.0), i, true));
5688 }
5689
5690 let p50 = agg.p50_total_time().unwrap();
5692 assert!(p50 >= Duration::from_secs(5) && p50 <= Duration::from_secs(6));
5693
5694 let p95 = agg.p95_total_time().unwrap();
5696 assert!(p95 >= Duration::from_secs(9) && p95 <= Duration::from_secs(10));
5697
5698 let p99 = agg.p99_total_time().unwrap();
5700 assert!(p99 >= Duration::from_secs(9));
5701 }
5702
5703 #[test]
5704 fn test_metrics_aggregator_success_rate() {
5705 let _guard = test_guard!();
5706 let mut agg = MetricsAggregator::new(100);
5707
5708 assert_eq!(agg.success_rate(), 100.0);
5710
5711 agg.record(make_test_metrics(Some(1.0), 10, true));
5713 agg.record(make_test_metrics(Some(1.0), 10, true));
5714 assert_eq!(agg.success_rate(), 100.0);
5715
5716 agg.record(make_test_metrics(Some(1.0), 10, false));
5718 let rate = agg.success_rate();
5719 assert!((rate - 66.67).abs() < 1.0, "Expected ~66.67%, got {}", rate);
5720 }
5721
5722 #[test]
5723 fn test_metrics_aggregator_clear() {
5724 let _guard = test_guard!();
5725 let mut agg = MetricsAggregator::new(100);
5726 agg.record(make_test_metrics(Some(1.0), 10, true));
5727 agg.record(make_test_metrics(Some(2.0), 20, true));
5728
5729 assert_eq!(agg.count(), 2);
5730
5731 agg.clear();
5732 assert_eq!(agg.count(), 0);
5733 assert!(agg.average_speedup().is_none());
5734 }
5735
5736 #[test]
5739 fn test_remote_build_jobs_parse_display_and_serde() {
5740 let _guard = test_guard!();
5741 assert_eq!(RemoteBuildJobs::default(), RemoteBuildJobs::Auto);
5742 for (spelling, expected) in [
5743 ("auto", RemoteBuildJobs::Auto),
5744 (" AUTO ", RemoteBuildJobs::Auto),
5745 ("off", RemoteBuildJobs::Off),
5746 ("none", RemoteBuildJobs::Off),
5747 ("4", RemoteBuildJobs::Fixed(4)),
5748 ] {
5749 assert_eq!(
5750 RemoteBuildJobs::parse(spelling).unwrap(),
5751 expected,
5752 "{spelling}"
5753 );
5754 }
5755 assert!(RemoteBuildJobs::parse("0").is_err(), "cargo rejects -j 0");
5756 assert!(RemoteBuildJobs::parse("many").is_err());
5757 assert_eq!(RemoteBuildJobs::Fixed(4).to_string(), "4");
5758 assert_eq!(RemoteBuildJobs::Off.to_string(), "off");
5759
5760 #[derive(Deserialize)]
5763 struct Wrap {
5764 compilation: CompilationConfig,
5765 }
5766 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = \"off\"\n").unwrap();
5767 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Off);
5768 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = 6\n").unwrap();
5769 assert_eq!(
5770 parsed.compilation.remote_build_jobs,
5771 RemoteBuildJobs::Fixed(6)
5772 );
5773 let parsed: Wrap = toml::from_str("[compilation]\nbuild_slots = 4\n").unwrap();
5774 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Auto);
5775 assert!(toml::from_str::<Wrap>("[compilation]\nremote_build_jobs = 0\n").is_err());
5776
5777 let json = serde_json::to_string(&RemoteBuildJobs::Fixed(3)).unwrap();
5779 assert_eq!(json, "3");
5780 assert_eq!(
5781 serde_json::to_string(&RemoteBuildJobs::Auto).unwrap(),
5782 "\"auto\""
5783 );
5784 let back: RemoteBuildJobs = serde_json::from_str("\"auto\"").unwrap();
5785 assert_eq!(back, RemoteBuildJobs::Auto);
5786 }
5787
5788 #[test]
5789 fn test_compilation_config_default_timeouts() {
5790 let _guard = test_guard!();
5791 let config = CompilationConfig::default();
5792 assert_eq!(config.build_timeout_sec, 300);
5794 assert_eq!(config.test_timeout_sec, 1800);
5796 }
5797
5798 #[test]
5799 fn test_compilation_config_timeout_for_test_kinds() {
5800 let _guard = test_guard!();
5801 let config = CompilationConfig::default();
5802
5803 assert_eq!(
5805 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5806 std::time::Duration::from_secs(1800)
5807 );
5808 assert_eq!(
5809 config.timeout_for_kind(Some(crate::CompilationKind::CargoNextest)),
5810 std::time::Duration::from_secs(1800)
5811 );
5812 assert_eq!(
5813 config.timeout_for_kind(Some(crate::CompilationKind::BunTest)),
5814 std::time::Duration::from_secs(config.bun_timeout_sec)
5815 );
5816 }
5817
5818 #[test]
5819 fn test_compilation_config_timeout_for_build_kinds() {
5820 let _guard = test_guard!();
5821 let config = CompilationConfig::default();
5822
5823 assert_eq!(
5825 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5826 std::time::Duration::from_secs(300)
5827 );
5828 assert_eq!(
5829 config.timeout_for_kind(Some(crate::CompilationKind::CargoCheck)),
5830 std::time::Duration::from_secs(300)
5831 );
5832 assert_eq!(
5833 config.timeout_for_kind(Some(crate::CompilationKind::CargoClippy)),
5834 std::time::Duration::from_secs(300)
5835 );
5836 assert_eq!(
5837 config.timeout_for_kind(None),
5838 std::time::Duration::from_secs(300)
5839 );
5840 }
5841
5842 #[test]
5843 fn test_compilation_config_custom_timeouts() {
5844 let _guard = test_guard!();
5845 let config = CompilationConfig {
5846 build_timeout_sec: 600, test_timeout_sec: 3600, ..Default::default()
5849 };
5850
5851 assert_eq!(
5852 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5853 std::time::Duration::from_secs(600)
5854 );
5855 assert_eq!(
5856 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5857 std::time::Duration::from_secs(3600)
5858 );
5859 }
5860
5861 #[test]
5862 fn test_compilation_config_speedup_threshold_default() {
5863 let _guard = test_guard!();
5864 let config = CompilationConfig::default();
5865 assert!((config.remote_speedup_threshold - 1.2).abs() < 0.001);
5867 }
5868
5869 #[test]
5870 fn test_compilation_config_speedup_threshold_custom() {
5871 let _guard = test_guard!();
5872 let config = CompilationConfig {
5873 remote_speedup_threshold: 1.5, ..Default::default()
5875 };
5876 assert!((config.remote_speedup_threshold - 1.5).abs() < 0.001);
5877 }
5878
5879 #[test]
5880 fn test_compilation_config_speedup_threshold_no_minimum() {
5881 let _guard = test_guard!();
5882 let config = CompilationConfig {
5884 remote_speedup_threshold: 1.0,
5885 ..Default::default()
5886 };
5887 assert!((config.remote_speedup_threshold - 1.0).abs() < 0.001);
5888 }
5889
5890 #[test]
5891 fn test_compilation_config_min_local_time_ms_default() {
5892 let _guard = test_guard!();
5893 let config = CompilationConfig::default();
5894 assert_eq!(config.min_local_time_ms, 2000);
5896 }
5897
5898 #[test]
5899 fn test_compilation_config_serde_roundtrip_speedup_threshold() {
5900 let _guard = test_guard!();
5901 let config = CompilationConfig {
5902 remote_speedup_threshold: 2.5,
5903 min_local_time_ms: 5000,
5904 ..Default::default()
5905 };
5906 let json = serde_json::to_string(&config).unwrap();
5907 let parsed: CompilationConfig = serde_json::from_str(&json).unwrap();
5908 assert!((parsed.remote_speedup_threshold - 2.5).abs() < 0.001);
5909 assert_eq!(parsed.min_local_time_ms, 5000);
5910 }
5911
5912 #[test]
5917 fn test_validate_remote_base_absolute_path() {
5918 let _guard = test_guard!();
5919 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
5920 assert_eq!(
5921 validate_remote_base("/var/rch-builds").unwrap(),
5922 "/var/rch-builds"
5923 );
5924 assert_eq!(
5925 validate_remote_base("/home/builder/.rch").unwrap(),
5926 "/home/builder/.rch"
5927 );
5928 }
5929
5930 #[test]
5931 fn test_validate_remote_base_tilde_expansion() {
5932 let _guard = test_guard!();
5933 let result = validate_remote_base("~/rch");
5935 assert!(result.is_ok());
5936 let path = result.unwrap();
5937 assert!(
5938 path.starts_with('/'),
5939 "Path should be absolute after expansion: {}",
5940 path
5941 );
5942 assert!(!path.contains('~'), "Tilde should be expanded: {}", path);
5943 }
5944
5945 #[test]
5946 fn test_validate_remote_base_rejects_relative_path() {
5947 let _guard = test_guard!();
5948 let result = validate_remote_base("tmp/rch");
5949 assert!(result.is_err());
5950 assert!(result.unwrap_err().contains("absolute path"));
5951 }
5952
5953 #[test]
5954 fn test_validate_remote_base_rejects_path_traversal() {
5955 let _guard = test_guard!();
5956 let result = validate_remote_base("/tmp/../etc/rch");
5957 assert!(result.is_err());
5958 assert!(result.unwrap_err().contains("path traversal"));
5959
5960 let result = validate_remote_base("/tmp/rch/../other");
5961 assert!(result.is_err());
5962 assert!(result.unwrap_err().contains("path traversal"));
5963 }
5964
5965 #[test]
5966 fn test_validate_remote_base_normalizes_trailing_slash() {
5967 let _guard = test_guard!();
5968 assert_eq!(validate_remote_base("/tmp/rch/").unwrap(), "/tmp/rch");
5969 assert_eq!(validate_remote_base("/tmp/rch///").unwrap(), "/tmp/rch");
5970 }
5971
5972 #[test]
5973 fn test_validate_remote_base_root_path() {
5974 let _guard = test_guard!();
5975 let result = validate_remote_base("/");
5977 assert!(result.is_err());
5978 assert!(result.unwrap_err().contains("root directory"));
5979 }
5980
5981 #[test]
5982 fn test_validate_remote_base_rejects_top_level() {
5983 let _guard = test_guard!();
5984 let result = validate_remote_base("/tmp");
5986 assert!(result.is_err());
5987 assert!(result.unwrap_err().contains("at least 2 levels deep"));
5988
5989 let result = validate_remote_base("/home");
5990 assert!(result.is_err());
5991
5992 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
5994 }
5995
5996 #[test]
5997 fn test_default_remote_base() {
5998 let _guard = test_guard!();
5999 assert_eq!(default_remote_base(), "/data/tmp/rch");
6000 }
6001
6002 #[test]
6003 fn test_transfer_config_default_has_remote_base() {
6004 let _guard = test_guard!();
6005 let config = TransferConfig::default();
6006 assert_eq!(config.remote_base, "/data/tmp/rch");
6007 }
6008
6009 #[test]
6010 fn test_source_sync_timeout_validation_boundaries() {
6011 let _guard = test_guard!();
6012 assert!(!TransferConfig::valid_sync_timeout_ms(0));
6013 assert!(!TransferConfig::valid_sync_timeout_ms(999));
6014 assert!(TransferConfig::valid_sync_timeout_ms(1_000));
6015 assert!(TransferConfig::valid_sync_timeout_ms(3_600_000));
6016 assert!(!TransferConfig::valid_sync_timeout_ms(3_600_001));
6017 }
6018
6019 #[test]
6020 fn test_source_sync_timeout_is_explicit_or_payload_aware() {
6021 let _guard = test_guard!();
6022 let default = TransferConfig::default();
6023 assert_eq!(
6024 default.sync_timeout_for_payload(0),
6025 std::time::Duration::from_millis(30_000)
6026 );
6027 assert_eq!(
6028 default.sync_timeout_for_payload(10 * 1024 * 1024),
6029 std::time::Duration::from_millis(40_000)
6030 );
6031 assert_eq!(
6032 default.sync_timeout_for_payload(u64::MAX),
6033 std::time::Duration::from_millis(3_600_000)
6034 );
6035
6036 let explicit = TransferConfig {
6037 sync_timeout_ms: Some(120_000),
6038 ..TransferConfig::default()
6039 };
6040 assert_eq!(
6041 explicit.sync_timeout_for_payload(u64::MAX),
6042 std::time::Duration::from_millis(120_000)
6043 );
6044 }
6045
6046 #[test]
6051 fn test_retry_config_default() {
6052 let _guard = test_guard!();
6053 let config = RetryConfig::default();
6054 assert_eq!(config.max_attempts, 3);
6055 assert_eq!(config.base_delay_ms, 100);
6056 assert_eq!(config.max_delay_ms, 5000);
6057 assert_eq!(config.jitter_factor, 0.1);
6058 assert_eq!(config.total_timeout_ms, 30000);
6059 }
6060
6061 #[test]
6062 fn test_retry_config_no_retry() {
6063 let _guard = test_guard!();
6064 let config = RetryConfig::no_retry();
6065 assert_eq!(config.max_attempts, 1);
6066 }
6067
6068 #[test]
6069 fn test_retry_config_delay_for_attempt_zero() {
6070 let _guard = test_guard!();
6071 let config = RetryConfig::default();
6072 assert_eq!(config.delay_for_attempt(0), std::time::Duration::ZERO);
6073 }
6074
6075 #[test]
6076 fn test_retry_config_delay_for_attempt_exponential() {
6077 let _guard = test_guard!();
6078 let config = RetryConfig {
6079 base_delay_ms: 100,
6080 max_delay_ms: 10000,
6081 jitter_factor: 0.0, ..Default::default()
6083 };
6084
6085 assert_eq!(
6087 config.delay_for_attempt(1),
6088 std::time::Duration::from_millis(100)
6089 );
6090 assert_eq!(
6092 config.delay_for_attempt(2),
6093 std::time::Duration::from_millis(200)
6094 );
6095 assert_eq!(
6097 config.delay_for_attempt(3),
6098 std::time::Duration::from_millis(400)
6099 );
6100 }
6101
6102 #[test]
6103 fn test_retry_config_delay_capped_at_max() {
6104 let _guard = test_guard!();
6105 let config = RetryConfig {
6106 base_delay_ms: 1000,
6107 max_delay_ms: 2000,
6108 jitter_factor: 0.0,
6109 ..Default::default()
6110 };
6111
6112 assert_eq!(
6114 config.delay_for_attempt(2),
6115 std::time::Duration::from_millis(2000)
6116 );
6117 assert_eq!(
6119 config.delay_for_attempt(3),
6120 std::time::Duration::from_millis(2000)
6121 );
6122 }
6123
6124 #[test]
6125 fn test_retry_config_random_jitter_stays_within_bounds() {
6126 let _guard = test_guard!();
6127 let config = RetryConfig {
6128 base_delay_ms: 100,
6129 max_delay_ms: 200,
6130 jitter_factor: 0.25,
6131 ..Default::default()
6132 };
6133
6134 for (attempt, minimum_ms, maximum_ms) in [(1, 75, 125), (3, 150, 250)] {
6137 for _ in 0..128 {
6138 let delay = config.delay_for_attempt(attempt);
6139 assert!(delay >= std::time::Duration::from_millis(minimum_ms));
6140 assert!(delay <= std::time::Duration::from_millis(maximum_ms));
6141 }
6142 }
6143 }
6144
6145 #[test]
6146 fn test_retry_config_should_retry() {
6147 let _guard = test_guard!();
6148 let config = RetryConfig {
6149 max_attempts: 3,
6150 total_timeout_ms: 1000,
6151 ..Default::default()
6152 };
6153
6154 assert!(config.should_retry(0, std::time::Duration::from_millis(0)));
6156 assert!(config.should_retry(1, std::time::Duration::from_millis(500)));
6157 assert!(config.should_retry(2, std::time::Duration::from_millis(900)));
6158
6159 assert!(!config.should_retry(3, std::time::Duration::from_millis(0)));
6161
6162 assert!(!config.should_retry(1, std::time::Duration::from_millis(1001)));
6164 }
6165
6166 #[test]
6167 fn test_retry_config_serde_roundtrip() {
6168 let _guard = test_guard!();
6169 let config = RetryConfig {
6170 max_attempts: 5,
6171 base_delay_ms: 200,
6172 max_delay_ms: 8000,
6173 jitter_factor: 0.2,
6174 total_timeout_ms: 60000,
6175 };
6176 let json = serde_json::to_string(&config).unwrap();
6177 let parsed: RetryConfig = serde_json::from_str(&json).unwrap();
6178 assert_eq!(parsed.max_attempts, 5);
6179 assert_eq!(parsed.base_delay_ms, 200);
6180 assert_eq!(parsed.max_delay_ms, 8000);
6181 assert_eq!(parsed.jitter_factor, 0.2);
6182 assert_eq!(parsed.total_timeout_ms, 60000);
6183 }
6184
6185 #[test]
6186 fn test_transfer_config_includes_retry() {
6187 let _guard = test_guard!();
6188 let config = TransferConfig::default();
6189 assert_eq!(config.retry.max_attempts, 3);
6190 }
6191
6192 #[test]
6197 fn test_execution_config_default_allowlist() {
6198 let _guard = test_guard!();
6199 let config = ExecutionConfig::default();
6200 assert!(config.is_allowed("cargo"));
6202 assert!(config.is_allowed("rustc"));
6203 assert!(config.is_allowed("gcc"));
6204 assert!(config.is_allowed("g++"));
6205 assert!(config.is_allowed("clang"));
6206 assert!(config.is_allowed("clang++"));
6207 assert!(config.is_allowed("make"));
6208 assert!(config.is_allowed("cmake"));
6209 assert!(config.is_allowed("ninja"));
6210 assert!(config.is_allowed("meson"));
6211 assert!(config.is_allowed("bun"));
6212 assert!(config.is_allowed("nextest"));
6213 assert!(config.is_allowed("cc"));
6214 assert!(config.is_allowed("c++"));
6215 }
6216
6217 #[test]
6218 fn test_execution_config_is_allowed_case_insensitive() {
6219 let _guard = test_guard!();
6220 let config = ExecutionConfig::default();
6221 assert!(config.is_allowed("CARGO"));
6223 assert!(config.is_allowed("Cargo"));
6224 assert!(config.is_allowed("GCC"));
6225 assert!(config.is_allowed("Gcc"));
6226 }
6227
6228 #[test]
6229 fn test_execution_config_is_not_allowed() {
6230 let _guard = test_guard!();
6231 let config = ExecutionConfig::default();
6232 assert!(!config.is_allowed("python"));
6234 assert!(!config.is_allowed("npm"));
6235 assert!(!config.is_allowed(""));
6236 assert!(config.is_allowed("go"));
6241 assert!(config.is_allowed("tsc"));
6242 }
6243
6244 #[test]
6245 fn test_execution_config_empty_allowlist() {
6246 let _guard = test_guard!();
6247 let config = ExecutionConfig { allowlist: vec![] };
6248 assert!(!config.is_allowed("cargo"));
6250 assert!(!config.is_allowed("gcc"));
6251 }
6252
6253 #[test]
6254 fn test_execution_config_custom_allowlist() {
6255 let _guard = test_guard!();
6256 let config = ExecutionConfig {
6257 allowlist: vec!["cargo".to_string(), "custom_tool".to_string()],
6258 };
6259 assert!(config.is_allowed("cargo"));
6260 assert!(config.is_allowed("custom_tool"));
6261 assert!(!config.is_allowed("gcc"));
6262 }
6263
6264 #[test]
6265 fn test_execution_config_serde() {
6266 let _guard = test_guard!();
6267 let config = ExecutionConfig {
6268 allowlist: vec!["cargo".to_string(), "rustc".to_string()],
6269 };
6270 let json = serde_json::to_string(&config).unwrap();
6271 let parsed: ExecutionConfig = serde_json::from_str(&json).unwrap();
6272 assert_eq!(parsed.allowlist, config.allowlist);
6273 }
6274
6275 #[test]
6276 fn test_rch_config_includes_execution() {
6277 let _guard = test_guard!();
6278 let config = RchConfig::default();
6279 assert!(config.execution.is_allowed("cargo"));
6281 }
6282
6283 #[test]
6288 fn test_adaptive_compression_disabled_by_default() {
6289 let _guard = test_guard!();
6290 let config = TransferConfig::default();
6291 assert!(!config.adaptive_compression);
6292 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6294 assert_eq!(config.select_compression_level(Some(500_000_000)), 3);
6295 }
6296
6297 #[test]
6298 fn test_adaptive_compression_small_payload() {
6299 let _guard = test_guard!();
6300 let config = TransferConfig {
6301 adaptive_compression: true,
6302 ..Default::default()
6303 };
6304 assert_eq!(config.select_compression_level(Some(0)), 1);
6306 assert_eq!(config.select_compression_level(Some(1_000_000)), 1); assert_eq!(config.select_compression_level(Some(9_999_999)), 1); }
6309
6310 #[test]
6311 fn test_adaptive_compression_medium_payload() {
6312 let _guard = test_guard!();
6313 let config = TransferConfig {
6314 adaptive_compression: true,
6315 ..Default::default()
6316 };
6317 assert_eq!(config.select_compression_level(Some(10_000_000)), 3); assert_eq!(config.select_compression_level(Some(100_000_000)), 3); assert_eq!(config.select_compression_level(Some(199_999_999)), 3); }
6322
6323 #[test]
6324 fn test_adaptive_compression_large_payload() {
6325 let _guard = test_guard!();
6326 let config = TransferConfig {
6327 adaptive_compression: true,
6328 ..Default::default()
6329 };
6330 assert_eq!(config.select_compression_level(Some(200_000_000)), 7); assert_eq!(config.select_compression_level(Some(500_000_000)), 7); assert_eq!(config.select_compression_level(Some(1_000_000_000)), 7); }
6335
6336 #[test]
6337 fn test_adaptive_compression_no_estimate() {
6338 let _guard = test_guard!();
6339 let config = TransferConfig {
6340 adaptive_compression: true,
6341 compression_level: 5,
6342 ..Default::default()
6343 };
6344 assert_eq!(config.select_compression_level(None), 5);
6346 }
6347
6348 #[test]
6349 fn test_adaptive_compression_respects_min_level() {
6350 let _guard = test_guard!();
6351 let config = TransferConfig {
6352 adaptive_compression: true,
6353 min_compression_level: 3,
6354 ..Default::default()
6355 };
6356 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6358 }
6359
6360 #[test]
6361 fn test_adaptive_compression_respects_max_level() {
6362 let _guard = test_guard!();
6363 let config = TransferConfig {
6364 adaptive_compression: true,
6365 max_compression_level: 5,
6366 ..Default::default()
6367 };
6368 assert_eq!(config.select_compression_level(Some(500_000_000)), 5);
6370 }
6371
6372 #[test]
6373 fn test_adaptive_compression_serde_roundtrip() {
6374 let _guard = test_guard!();
6375 let config = TransferConfig {
6376 adaptive_compression: true,
6377 min_compression_level: 2,
6378 max_compression_level: 8,
6379 ..Default::default()
6380 };
6381 let json = serde_json::to_string(&config).unwrap();
6382 let parsed: TransferConfig = serde_json::from_str(&json).unwrap();
6383 assert!(parsed.adaptive_compression);
6384 assert_eq!(parsed.min_compression_level, 2);
6385 assert_eq!(parsed.max_compression_level, 8);
6386 }
6387
6388 #[test]
6393 fn test_self_healing_config_defaults() {
6394 let _guard = test_guard!();
6395 let config = SelfHealingConfig::default();
6397
6398 assert!(
6400 config.hook_starts_daemon,
6401 "hook_starts_daemon should be true by default"
6402 );
6403 assert!(
6404 config.daemon_installs_hooks,
6405 "daemon_installs_hooks should be true by default"
6406 );
6407
6408 assert_eq!(
6410 config.auto_start_cooldown_secs, 30,
6411 "auto_start_cooldown_secs should default to 30"
6412 );
6413 assert_eq!(
6414 config.auto_start_timeout_secs, 3,
6415 "auto_start_timeout_secs should default to 3"
6416 );
6417 }
6419
6420 #[test]
6421 fn test_self_healing_config_serde_full() {
6422 let _guard = test_guard!();
6423 let config = SelfHealingConfig {
6425 hook_starts_daemon: false,
6426 daemon_installs_hooks: false,
6427 auto_start_cooldown_secs: 60,
6428 auto_start_timeout_secs: 10,
6429 self_healing_log_level: SelfHealingLogLevel::Debug,
6430 };
6431
6432 let json = serde_json::to_string(&config).unwrap();
6433 assert!(json.contains("\"hook_starts_daemon\":false"));
6434 assert!(json.contains("\"daemon_installs_hooks\":false"));
6435 assert!(json.contains("\"auto_start_cooldown_secs\":60"));
6436 assert!(json.contains("\"auto_start_timeout_secs\":10"));
6437 assert!(json.contains("\"self_healing_log_level\":\"debug\""));
6438
6439 let parsed: SelfHealingConfig = serde_json::from_str(&json).unwrap();
6440 assert!(!parsed.hook_starts_daemon);
6441 assert!(!parsed.daemon_installs_hooks);
6442 assert_eq!(parsed.auto_start_cooldown_secs, 60);
6443 assert_eq!(parsed.auto_start_timeout_secs, 10);
6444 assert_eq!(parsed.self_healing_log_level, SelfHealingLogLevel::Debug);
6445 }
6447
6448 #[test]
6451 fn test_self_healing_log_level_default_is_info() {
6452 let level = SelfHealingLogLevel::default();
6454 assert_eq!(level, SelfHealingLogLevel::Info);
6455 let config = SelfHealingConfig::default();
6457 assert_eq!(
6458 config.self_healing_log_level,
6459 SelfHealingLogLevel::Info,
6460 "config default should be Info"
6461 );
6462 }
6464
6465 #[test]
6466 fn test_self_healing_log_level_serializes_lowercase() {
6467 let json = serde_json::to_string(&SelfHealingLogLevel::Warn).unwrap();
6469 assert_eq!(json, "\"warn\"");
6470 let parsed: SelfHealingLogLevel = serde_json::from_str("\"error\"").unwrap();
6471 assert_eq!(parsed, SelfHealingLogLevel::Error);
6472 }
6474
6475 #[test]
6476 fn test_self_healing_log_level_from_env_str_accepts_known() {
6477 assert_eq!(
6479 SelfHealingLogLevel::from_env_str("debug"),
6480 Some(SelfHealingLogLevel::Debug)
6481 );
6482 assert_eq!(
6483 SelfHealingLogLevel::from_env_str("DEBUG"),
6484 Some(SelfHealingLogLevel::Debug)
6485 );
6486 assert_eq!(
6487 SelfHealingLogLevel::from_env_str("info"),
6488 Some(SelfHealingLogLevel::Info)
6489 );
6490 assert_eq!(
6491 SelfHealingLogLevel::from_env_str("warn"),
6492 Some(SelfHealingLogLevel::Warn)
6493 );
6494 assert_eq!(
6495 SelfHealingLogLevel::from_env_str("warning"),
6496 Some(SelfHealingLogLevel::Warn),
6497 "warning should map to Warn"
6498 );
6499 assert_eq!(
6500 SelfHealingLogLevel::from_env_str("error"),
6501 Some(SelfHealingLogLevel::Error)
6502 );
6503 assert_eq!(
6505 SelfHealingLogLevel::from_env_str(" debug "),
6506 Some(SelfHealingLogLevel::Debug)
6507 );
6508 }
6510
6511 #[test]
6512 fn test_self_healing_log_level_from_env_str_rejects_unknown() {
6513 assert!(SelfHealingLogLevel::from_env_str("banana").is_none());
6515 assert!(SelfHealingLogLevel::from_env_str("").is_none());
6516 assert!(SelfHealingLogLevel::from_env_str("trace").is_none());
6517 }
6519
6520 #[test]
6521 fn test_self_healing_config_serde_partial_uses_defaults() {
6522 let _guard = test_guard!();
6523 let json = r#"{"hook_starts_daemon": false}"#;
6525 let config: SelfHealingConfig = serde_json::from_str(json).unwrap();
6526
6527 assert!(
6528 !config.hook_starts_daemon,
6529 "Explicit false should be parsed"
6530 );
6531 assert!(
6532 config.daemon_installs_hooks,
6533 "Missing field should use default (true)"
6534 );
6535 assert_eq!(
6536 config.auto_start_cooldown_secs, 30,
6537 "Missing field should use default (30)"
6538 );
6539 assert_eq!(
6540 config.auto_start_timeout_secs, 3,
6541 "Missing field should use default (3)"
6542 );
6543 }
6545
6546 #[test]
6547 fn test_self_healing_config_toml_with_alias() {
6548 let _guard = test_guard!();
6549 let toml_str = r#"
6551 hook_starts_daemon = true
6552 daemon_installs_hooks = false
6553 auto_start_cooldown_secs = 45
6554 daemon_start_timeout = 7
6555 "#;
6556
6557 let config: SelfHealingConfig = toml::from_str(toml_str).unwrap();
6558 assert!(config.hook_starts_daemon);
6559 assert!(!config.daemon_installs_hooks);
6560 assert_eq!(config.auto_start_cooldown_secs, 45);
6561 assert_eq!(
6562 config.auto_start_timeout_secs, 7,
6563 "daemon_start_timeout alias should set auto_start_timeout_secs"
6564 );
6565 }
6567
6568 #[allow(unsafe_code)]
6569 mod self_healing_env_override_tests {
6570 use super::*;
6571 use crate::config::env_test_lock;
6572
6573 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
6574 env_test_lock()
6575 }
6576
6577 fn set_env(key: &str, value: &str) {
6578 unsafe { std::env::set_var(key, value) };
6580 }
6581
6582 fn remove_env(key: &str) {
6583 unsafe { std::env::remove_var(key) };
6585 }
6586
6587 struct EnvVarGuard {
6588 key: &'static str,
6589 old: Option<String>,
6590 }
6591
6592 impl EnvVarGuard {
6593 fn set(key: &'static str, value: &str) -> Self {
6594 let old = std::env::var(key).ok();
6595 set_env(key, value);
6596 Self { key, old }
6597 }
6598 }
6599
6600 impl Drop for EnvVarGuard {
6601 fn drop(&mut self) {
6602 if let Some(old) = &self.old {
6603 set_env(self.key, old);
6604 } else {
6605 remove_env(self.key);
6606 }
6607 }
6608 }
6609
6610 #[test]
6611 fn test_self_healing_config_with_env_overrides_master_disable() {
6612 let _guard = test_guard!();
6613 let _guard = env_guard();
6614
6615 let _no_self_healing = EnvVarGuard::set("RCH_NO_SELF_HEALING", "1");
6616 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "1");
6617 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "1");
6618 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "99");
6619 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "99");
6620
6621 let config = SelfHealingConfig::default().with_env_overrides();
6622 assert!(
6623 !config.hook_starts_daemon,
6624 "RCH_NO_SELF_HEALING should disable hook auto-start"
6625 );
6626 assert!(
6627 !config.daemon_installs_hooks,
6628 "RCH_NO_SELF_HEALING should disable daemon hook installation"
6629 );
6630
6631 assert_eq!(config.auto_start_cooldown_secs, 30);
6633 assert_eq!(config.auto_start_timeout_secs, 3);
6634 }
6635
6636 #[test]
6637 fn test_self_healing_config_with_env_overrides_toggles_and_numbers() {
6638 let _guard = test_guard!();
6639 let _guard = env_guard();
6640
6641 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "0");
6642 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "false");
6643 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "45");
6644 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "7");
6645
6646 let config = SelfHealingConfig::default().with_env_overrides();
6647 assert!(!config.hook_starts_daemon);
6648 assert!(!config.daemon_installs_hooks);
6649 assert_eq!(config.auto_start_cooldown_secs, 45);
6650 assert_eq!(config.auto_start_timeout_secs, 7);
6651 }
6652
6653 #[test]
6654 fn test_self_healing_config_with_env_overrides_invalid_numbers_ignored() {
6655 let _guard = test_guard!();
6656 let _guard = env_guard();
6657
6658 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "not-a-number");
6659 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "nope");
6660
6661 let config = SelfHealingConfig::default().with_env_overrides();
6662 assert_eq!(config.auto_start_cooldown_secs, 30);
6663 assert_eq!(config.auto_start_timeout_secs, 3);
6664 }
6665 }
6666
6667 #[test]
6672 fn test_load_per_core_calculation() {
6673 let _guard = test_guard!();
6674 let mut caps = WorkerCapabilities::new();
6675 assert!(caps.load_per_core().is_none());
6677
6678 caps.load_avg_1 = Some(4.0);
6680 assert!(caps.load_per_core().is_none());
6681
6682 caps.num_cpus = Some(4);
6684 assert_eq!(caps.load_per_core(), Some(1.0)); caps.load_avg_1 = Some(16.0);
6688 assert_eq!(caps.load_per_core(), Some(4.0)); }
6690
6691 #[test]
6692 fn test_is_high_load() {
6693 let _guard = test_guard!();
6694 let mut caps = WorkerCapabilities::new();
6695 caps.load_avg_1 = Some(8.0);
6696 caps.num_cpus = Some(4);
6697 assert_eq!(caps.is_high_load(3.0), Some(false));
6701 assert_eq!(caps.is_high_load(2.0), Some(false)); assert_eq!(caps.is_high_load(1.5), Some(true));
6705
6706 caps.load_avg_1 = None;
6708 assert!(caps.is_high_load(1.0).is_none());
6709 }
6710
6711 #[test]
6712 fn test_is_low_disk() {
6713 let _guard = test_guard!();
6714 let mut caps = WorkerCapabilities::new();
6715 caps.disk_free_gb = Some(15.0);
6716
6717 assert_eq!(caps.is_low_disk(10.0), Some(false));
6719 assert_eq!(caps.is_low_disk(15.0), Some(false)); assert_eq!(caps.is_low_disk(20.0), Some(true));
6723
6724 caps.disk_free_gb = None;
6726 assert!(caps.is_low_disk(10.0).is_none());
6727 }
6728
6729 #[test]
6730 fn low_disk_judges_the_build_disk_not_a_small_tmpfs() {
6731 let _guard = test_guard!();
6732 let mut caps = WorkerCapabilities::new();
6735 caps.disk_free_gb = Some(8.0);
6736 caps.disk_total_gb = Some(15.5);
6737 caps.build_disk_free_gb = Some(1700.0);
6738 caps.build_disk_total_gb = Some(1900.0);
6739 assert_eq!(caps.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6740 assert_eq!(caps.is_low_disk(10.0), Some(false));
6741
6742 caps.build_disk_free_gb = Some(4.0);
6744 assert_eq!(caps.is_low_disk(10.0), Some(true));
6745
6746 caps.build_disk_free_gb = None;
6748 caps.build_disk_total_gb = None;
6749 assert_eq!(caps.build_disk_gb(), (Some(8.0), Some(15.5)));
6750 assert_eq!(caps.is_low_disk(10.0), Some(true));
6751 }
6752
6753 #[test]
6754 fn build_disk_sample_is_optional_on_the_wire() {
6755 let _guard = test_guard!();
6756 let old: WorkerCapabilities =
6757 serde_json::from_str(r#"{"disk_free_gb":13.9,"disk_total_gb":15.5}"#).unwrap();
6758 assert_eq!(old.build_disk_free_gb, None);
6759 assert_eq!(old.build_disk_gb(), (Some(13.9), Some(15.5)));
6760
6761 let new: WorkerCapabilities = serde_json::from_str(
6762 r#"{"disk_free_gb":13.9,"disk_total_gb":15.5,"build_disk_free_gb":1700.0,"build_disk_total_gb":1900.0}"#,
6763 )
6764 .unwrap();
6765 assert_eq!(new.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6766 let json = serde_json::to_string(&new).unwrap();
6767 assert!(json.contains("\"build_disk_free_gb\":1700.0"), "{json}");
6768 }
6769
6770 #[test]
6771 fn test_selection_config_preflight_defaults() {
6772 let _guard = test_guard!();
6773 let config = SelectionConfig::default();
6774 assert_eq!(config.max_load_per_core, Some(2.0));
6776 assert_eq!(config.min_free_gb, Some(10.0));
6777 assert_eq!(config.disk_gb_per_slot, 10.0);
6778 }
6779
6780 #[test]
6781 fn test_selection_config_preflight_serde() {
6782 let _guard = test_guard!();
6783 let config = SelectionConfig {
6784 max_load_per_core: Some(3.5),
6785 min_free_gb: Some(25.0),
6786 disk_gb_per_slot: 0.83,
6787 ..Default::default()
6788 };
6789 let json = serde_json::to_string(&config).unwrap();
6790 let parsed: SelectionConfig = serde_json::from_str(&json).unwrap();
6791 assert_eq!(parsed.max_load_per_core, Some(3.5));
6792 assert_eq!(parsed.min_free_gb, Some(25.0));
6793 assert_eq!(parsed.disk_gb_per_slot, 0.83);
6794 }
6795
6796 #[test]
6797 fn test_selection_disk_slot_budget_validation() {
6798 let _guard = test_guard!();
6799 for value in ["0.0", "-1.0", "nan", "inf", "-inf"] {
6800 let result = toml::from_str::<SelectionConfig>(&format!("disk_gb_per_slot = {value}"));
6801 assert!(result.is_err(), "invalid disk slot budget {value}");
6802 }
6803 let default: SelectionConfig = toml::from_str("").unwrap();
6804 assert_eq!(default.disk_gb_per_slot, 10.0);
6805 }
6806
6807 #[test]
6808 fn test_selection_disk_weight_validation_and_roundtrip() {
6809 for value in ["-0.1", "1.1", "nan", "inf", "-inf"] {
6810 assert!(toml::from_str::<SelectionWeightConfig>(&format!("disk = {value}")).is_err());
6811 }
6812 for value in [0.0, 0.2, 1.0] {
6813 let weights: SelectionWeightConfig =
6814 toml::from_str(&format!("disk = {value}")).unwrap();
6815 assert_eq!(weights.disk, value);
6816 let roundtrip: SelectionWeightConfig =
6817 serde_json::from_str(&serde_json::to_string(&weights).unwrap()).unwrap();
6818 assert_eq!(roundtrip.disk, value);
6819 }
6820 let defaults: SelectionWeightConfig = toml::from_str("").unwrap();
6821 assert_eq!(defaults.disk, 0.2);
6822 }
6823
6824 #[test]
6825 fn test_worker_capabilities_health_metrics_serde() {
6826 let _guard = test_guard!();
6827 let caps = WorkerCapabilities {
6828 num_cpus: Some(8),
6829 load_avg_1: Some(1.5),
6830 load_avg_5: Some(2.0),
6831 load_avg_15: Some(1.8),
6832 disk_free_gb: Some(50.5),
6833 disk_total_gb: Some(100.0),
6834 ..Default::default()
6835 };
6836 let json = serde_json::to_string(&caps).unwrap();
6837 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
6838 assert_eq!(parsed.num_cpus, Some(8));
6839 assert_eq!(parsed.load_avg_1, Some(1.5));
6840 assert_eq!(parsed.disk_free_gb, Some(50.5));
6841 }
6842
6843 #[test]
6844 fn microarch_level_classifies_ivy_bridge_as_v2() {
6845 let ivy = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov \
6848 pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm \
6849 constant_tsc pni pclmulqdq ssse3 cx16 sse4_1 sse4_2 x2apic \
6850 popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm";
6851 assert_eq!(x86_64_microarch_level_from_flags(ivy), 2);
6852 }
6853
6854 #[test]
6855 fn microarch_level_classifies_v3_and_v4() {
6856 let v3 = "cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 \
6857 f16c fma movbe xsave";
6858 assert_eq!(x86_64_microarch_level_from_flags(v3), 3);
6859
6860 let v4 = format!("{v3} avx512f avx512bw avx512cd avx512dq avx512vl");
6861 assert_eq!(x86_64_microarch_level_from_flags(&v4), 4);
6862 }
6863
6864 #[test]
6865 fn microarch_level_v1_for_ancient_or_empty_flags() {
6866 assert_eq!(x86_64_microarch_level_from_flags(""), 1);
6867 assert_eq!(x86_64_microarch_level_from_flags("fpu mmx sse sse2"), 1);
6868 }
6869
6870 #[test]
6871 fn pre_v3_predicate_only_fires_on_affirmative_evidence() {
6872 let mut caps = WorkerCapabilities::new();
6873 assert!(!caps.is_pre_v3_x86(), "unknown must not deprioritize");
6874 caps.cpu_microarch_level = Some(2);
6875 assert!(caps.is_pre_v3_x86());
6876 caps.cpu_microarch_level = Some(3);
6877 assert!(!caps.is_pre_v3_x86());
6878 }
6879}