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, skip_serializing_if = "disk_headroom_disabled")]
151 pub disk_headroom_gib: u32,
152 #[serde(default)]
154 pub preferred_workers: Vec<WorkerId>,
155 #[serde(default)]
157 pub toolchain: Option<ToolchainInfo>,
158 #[serde(default)]
160 pub required_runtime: RequiredRuntime,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub classification_duration_us: Option<u64>,
165 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
171 pub job_mode: bool,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub required_tools: Vec<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub hook_pid: Option<u32>,
186}
187
188fn disk_headroom_disabled(gib: &u32) -> bool {
189 *gib == 0
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197#[serde(rename_all = "snake_case")]
198pub enum SelectionReason {
199 Success,
201 NoWorkersConfigured,
203 AllWorkersUnreachable,
205 AllCircuitsOpen,
207 AllWorkersBusy,
209 NoWorkersPassedHealth,
211 AllWorkersFailedPreflight,
213 AllWorkersFailedConvergence,
215 NoAdmissibleWorkers(String),
217 NoMatchingWorkers,
219 NoWorkersWithRuntime(String),
221 SelectionError(String),
223 AffinityPinned,
225 AffinityFallback,
227}
228
229pub const SELECTION_RESPONSE_PROTOCOL_VERSION: u64 = 1;
235
236impl std::fmt::Display for SelectionReason {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 match self {
239 Self::Success => write!(f, "worker assigned successfully"),
240 Self::NoWorkersConfigured => write!(f, "no workers configured"),
241 Self::AllWorkersUnreachable => write!(f, "all workers unreachable"),
242 Self::AllCircuitsOpen => write!(f, "all worker circuits open"),
243 Self::AllWorkersBusy => write!(f, "all workers at capacity"),
244 Self::NoWorkersPassedHealth => write!(f, "no workers passed health thresholds"),
245 Self::AllWorkersFailedPreflight => write!(f, "all workers failed preflight checks"),
246 Self::AllWorkersFailedConvergence => {
247 write!(f, "all workers failed repo convergence checks")
248 }
249 Self::NoAdmissibleWorkers(summary) => {
250 write!(f, "no admissible workers: {}", summary)
251 }
252 Self::NoMatchingWorkers => write!(f, "no matching workers found"),
253 Self::NoWorkersWithRuntime(rt) => write!(f, "no workers with {} installed", rt),
254 Self::SelectionError(e) => write!(f, "selection error: {}", e),
255 Self::AffinityPinned => write!(f, "worker assigned via affinity pinning"),
256 Self::AffinityFallback => write!(f, "worker assigned via last-success fallback"),
257 }
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
267#[serde(rename_all = "snake_case")]
268pub enum SelectionStrategy {
269 Priority,
272 Fastest,
275 #[default]
278 Balanced,
279 CacheAffinity,
282 FairFastest,
285}
286
287impl std::fmt::Display for SelectionStrategy {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 let name = match self {
290 Self::Priority => "priority",
291 Self::Fastest => "fastest",
292 Self::Balanced => "balanced",
293 Self::CacheAffinity => "cache_affinity",
294 Self::FairFastest => "fair_fastest",
295 };
296 write!(f, "{}", name)
297 }
298}
299
300impl std::str::FromStr for SelectionStrategy {
301 type Err = String;
302
303 fn from_str(s: &str) -> Result<Self, Self::Err> {
304 match s.trim().to_lowercase().as_str() {
305 "priority" => Ok(Self::Priority),
306 "fastest" => Ok(Self::Fastest),
307 "balanced" => Ok(Self::Balanced),
308 "cache_affinity" | "cache-affinity" | "cacheaffinity" => Ok(Self::CacheAffinity),
309 "fair_fastest" | "fair-fastest" | "fairfastest" => Ok(Self::FairFastest),
310 _ => Err(format!(
311 "unknown selection strategy '{}', expected one of: priority, fastest, balanced, cache_affinity, fair_fastest",
312 s
313 )),
314 }
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct SelectionConfig {
321 #[serde(default)]
323 pub strategy: SelectionStrategy,
324 #[serde(default = "default_min_success_rate")]
326 pub min_success_rate: f64,
327 #[serde(default)]
329 pub weights: SelectionWeightConfig,
330 #[serde(default)]
332 pub fairness: FairnessConfig,
333 #[serde(default)]
335 pub affinity: AffinityConfig,
336
337 #[serde(default = "default_max_load_per_core")]
341 pub max_load_per_core: Option<f64>,
342 #[serde(default = "default_min_free_gb")]
345 pub min_free_gb: Option<f64>,
346 #[serde(
349 default = "default_disk_gb_per_slot",
350 deserialize_with = "deserialize_disk_gb_per_slot"
351 )]
352 pub disk_gb_per_slot: f64,
353 #[serde(default = "default_disk_critical_free_ratio")]
356 pub disk_critical_free_ratio: f64,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub disk_critical_ratio_cap_gb: Option<f64>,
362}
363
364fn default_disk_critical_free_ratio() -> f64 {
365 0.05
366}
367
368impl Default for SelectionConfig {
369 fn default() -> Self {
370 Self {
371 strategy: SelectionStrategy::default(),
372 min_success_rate: default_min_success_rate(),
373 weights: SelectionWeightConfig::default(),
374 fairness: FairnessConfig::default(),
375 affinity: AffinityConfig::default(),
376 max_load_per_core: default_max_load_per_core(),
377 min_free_gb: default_min_free_gb(),
378 disk_gb_per_slot: default_disk_gb_per_slot(),
379 disk_critical_free_ratio: default_disk_critical_free_ratio(),
380 disk_critical_ratio_cap_gb: None,
381 }
382 }
383}
384
385fn default_min_success_rate() -> f64 {
386 0.8
387}
388
389fn default_max_load_per_core() -> Option<f64> {
390 Some(2.0) }
392
393fn default_min_free_gb() -> Option<f64> {
394 Some(10.0) }
396
397fn default_disk_gb_per_slot() -> f64 {
398 10.0
399}
400
401fn deserialize_disk_gb_per_slot<'de, D>(deserializer: D) -> Result<f64, D::Error>
402where
403 D: serde::Deserializer<'de>,
404{
405 let budget = f64::deserialize(deserializer)?;
406 if budget.is_finite() && budget > 0.0 {
407 Ok(budget)
408 } else {
409 Err(serde::de::Error::custom(
410 "disk_gb_per_slot must be finite and greater than zero",
411 ))
412 }
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct SelectionWeightConfig {
418 #[serde(default = "default_weight_speedscore")]
420 pub speedscore: f64,
421 #[serde(default = "default_weight_slots")]
423 pub slots: f64,
424 #[serde(default = "default_weight_health")]
426 pub health: f64,
427 #[serde(default = "default_weight_cache")]
429 pub cache: f64,
430 #[serde(default = "default_weight_network")]
432 pub network: f64,
433 #[serde(default = "default_weight_priority")]
435 pub priority: f64,
436 #[serde(
438 default = "default_weight_disk",
439 deserialize_with = "deserialize_weight_disk"
440 )]
441 pub disk: f64,
442 #[serde(default = "default_half_open_penalty")]
444 pub half_open_penalty: f64,
445}
446
447impl Default for SelectionWeightConfig {
448 fn default() -> Self {
449 Self {
450 speedscore: default_weight_speedscore(),
451 slots: default_weight_slots(),
452 health: default_weight_health(),
453 cache: default_weight_cache(),
454 network: default_weight_network(),
455 priority: default_weight_priority(),
456 disk: default_weight_disk(),
457 half_open_penalty: default_half_open_penalty(),
458 }
459 }
460}
461
462fn default_weight_speedscore() -> f64 {
463 0.5
464}
465fn default_weight_slots() -> f64 {
466 0.4
467}
468fn default_weight_health() -> f64 {
469 0.3
470}
471fn default_weight_cache() -> f64 {
472 0.2
473}
474fn default_weight_network() -> f64 {
475 0.1
476}
477fn default_weight_priority() -> f64 {
478 0.5
485}
486fn default_half_open_penalty() -> f64 {
487 0.5
488}
489
490fn default_weight_disk() -> f64 {
491 0.2
492}
493
494fn deserialize_weight_disk<'de, D>(deserializer: D) -> Result<f64, D::Error>
495where
496 D: serde::Deserializer<'de>,
497{
498 let weight = f64::deserialize(deserializer)?;
499 if weight.is_finite() && (0.0..=1.0).contains(&weight) {
500 Ok(weight)
501 } else {
502 Err(serde::de::Error::custom(
503 "disk weight must be finite and between zero and one",
504 ))
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct FairnessConfig {
511 #[serde(default = "default_fairness_lookback_secs")]
513 pub lookback_secs: u64,
514 #[serde(default = "default_max_consecutive_selections")]
516 pub max_consecutive_selections: u32,
517}
518
519impl Default for FairnessConfig {
520 fn default() -> Self {
521 Self {
522 lookback_secs: default_fairness_lookback_secs(),
523 max_consecutive_selections: default_max_consecutive_selections(),
524 }
525 }
526}
527
528fn default_fairness_lookback_secs() -> u64 {
529 300 }
531
532fn default_max_consecutive_selections() -> u32 {
533 3
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct AffinityConfig {
539 #[serde(default = "default_affinity_enabled")]
541 pub enabled: bool,
542 #[serde(default = "default_affinity_pin_minutes")]
545 pub pin_minutes: u64,
546 #[serde(default = "default_last_success_fallback")]
550 pub enable_last_success_fallback: bool,
551 #[serde(default = "default_fallback_min_success_rate")]
554 pub fallback_min_success_rate: f64,
555}
556
557impl Default for AffinityConfig {
558 fn default() -> Self {
559 Self {
560 enabled: default_affinity_enabled(),
561 pin_minutes: default_affinity_pin_minutes(),
562 enable_last_success_fallback: default_last_success_fallback(),
563 fallback_min_success_rate: default_fallback_min_success_rate(),
564 }
565 }
566}
567
568fn default_affinity_enabled() -> bool {
569 true
570}
571
572fn default_affinity_pin_minutes() -> u64 {
573 60 }
575
576fn default_last_success_fallback() -> bool {
577 true
578}
579
580fn default_fallback_min_success_rate() -> f64 {
581 0.5 }
583
584#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
586pub struct SelectedWorker {
587 pub id: WorkerId,
589 pub host: String,
591 pub user: String,
593 pub identity_file: String,
595 pub slots_available: u32,
597 pub speed_score: f64,
599 #[serde(default)]
605 pub declared_os: Option<String>,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct SelectionResponse {
611 pub worker: Option<SelectedWorker>,
613 pub reason: SelectionReason,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub build_id: Option<u64>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub diagnostics: Option<SelectionDiagnostics>,
621}
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
625#[serde(rename_all = "snake_case")]
626pub enum WorkerSelectionDiagnosticDecision {
627 Allow,
629 FallbackCandidate,
631 Deny,
633}
634
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
637pub struct WorkerSelectionDiagnostic {
638 pub worker_id: WorkerId,
640 pub status: String,
642 pub circuit_state: String,
644 pub pressure_state: String,
646 pub pressure_reason_code: String,
648 #[serde(skip_serializing_if = "Option::is_none")]
650 pub success_rate: Option<f64>,
651 pub min_success_rate: f64,
653 pub fallback_min_success_rate: f64,
655 pub required_runtime: RequiredRuntime,
657 pub runtime_available: bool,
659 pub available_slots: u32,
661 pub total_slots: u32,
663 pub estimated_cores: u32,
665 pub active_project_excluded: bool,
667 pub final_decision: WorkerSelectionDiagnosticDecision,
669 pub final_reason: String,
671 pub reason_codes: Vec<String>,
673}
674
675#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
677pub struct SelectionDiagnostics {
678 pub required_runtime: RequiredRuntime,
680 pub estimated_cores: u32,
682 pub min_success_rate: f64,
684 pub fallback_min_success_rate: f64,
686 pub active_project_exclusion_count: usize,
688 pub workers: Vec<WorkerSelectionDiagnostic>,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct ReleaseRequest {
695 pub worker_id: WorkerId,
697 pub slots: u32,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub build_id: Option<u64>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub local_wrapper_id: Option<String>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub exit_code: Option<i32>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub duration_ms: Option<u64>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub bytes_transferred: Option<u64>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub timing: Option<CommandTimingBreakdown>,
719 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
723 pub worker_fault: bool,
724 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
728 pub worker_disk_full: bool,
729 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub worker_disk_roots: Vec<String>,
733 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
737 pub worker_live_checkout_refused: bool,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
742#[serde(rename_all = "snake_case")]
743pub enum BuildHeartbeatPhase {
744 SyncUp,
746 Execute,
748 SyncDown,
750 Finalize,
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
756pub struct BuildHeartbeatRequest {
757 pub build_id: u64,
759 pub worker_id: WorkerId,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub hook_pid: Option<u32>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub local_wrapper_id: Option<String>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub remote_pgid_file: Option<String>,
770 pub phase: BuildHeartbeatPhase,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub detail: Option<String>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub progress_counter: Option<u64>,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub progress_percent: Option<f64>,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct WorkerConfig {
789 pub id: WorkerId,
791 pub host: String,
793 pub user: String,
795 pub identity_file: String,
797 pub total_slots: u32,
799 #[serde(default = "default_priority")]
801 pub priority: u32,
802 #[serde(default)]
808 pub tags: Vec<String>,
809 #[serde(default, skip_serializing_if = "Vec::is_empty")]
818 pub tools: Vec<WorkerToolProbe>,
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827pub struct WorkerToolProbe {
828 pub name: String,
830 pub command: Vec<String>,
832}
833
834fn default_priority() -> u32 {
835 100
836}
837
838pub const OS_TAG_PREFIX: &str = "os:";
840
841#[must_use]
852pub fn declared_os(tags: &[String]) -> Option<String> {
853 tags.iter()
858 .filter_map(|tag| tag.strip_prefix(OS_TAG_PREFIX))
859 .map(|os| os.trim().to_ascii_lowercase())
860 .find(|os| !os.is_empty())
861}
862
863#[must_use]
865pub fn os_tag(os: &str) -> String {
866 format!("{OS_TAG_PREFIX}{}", os.trim().to_ascii_lowercase())
867}
868
869impl Default for WorkerConfig {
870 fn default() -> Self {
871 Self {
872 id: WorkerId::new("default-worker"),
873 host: "localhost".to_string(),
874 user: "user".to_string(),
875 identity_file: "~/.ssh/id_rsa".to_string(),
876 total_slots: 4,
877 priority: default_priority(),
878 tags: Vec::new(),
879 tools: Vec::new(),
880 }
881 }
882}
883
884#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
890pub struct WorkerCapabilities {
891 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub rustc_version: Option<String>,
894 #[serde(default, skip_serializing_if = "Vec::is_empty")]
897 pub rustup_toolchains: Vec<String>,
898 #[serde(default, skip_serializing_if = "Vec::is_empty")]
902 pub rustup_components: Vec<String>,
903 #[serde(default, skip_serializing_if = "Vec::is_empty")]
909 pub probe_warnings: Vec<String>,
910 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub bun_version: Option<String>,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub node_version: Option<String>,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub npm_version: Option<String>,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub nix_version: Option<String>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub go_version: Option<String>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub zig_version: Option<String>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub cargo_zigbuild_version: Option<String>,
938 #[serde(default, skip_serializing_if = "Option::is_none")]
945 pub cpu_microarch_level: Option<u8>,
946 #[serde(default, skip_serializing_if = "Vec::is_empty")]
953 pub tools_present: Vec<String>,
954 #[serde(default, skip_serializing_if = "Vec::is_empty")]
960 pub tools_absent: Vec<String>,
961
962 #[serde(default, skip_serializing_if = "Option::is_none")]
965 pub num_cpus: Option<u32>,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub load_avg_1: Option<f64>,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub load_avg_5: Option<f64>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub load_avg_15: Option<f64>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub disk_free_gb: Option<f64>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub disk_total_gb: Option<f64>,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub build_disk_free_gb: Option<f64>,
989 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub build_disk_total_gb: Option<f64>,
992 #[serde(default, skip_serializing_if = "Option::is_none")]
997 pub projects_root_ok: Option<bool>,
998 #[serde(default, skip_serializing_if = "Option::is_none")]
1000 pub projects_root_issue: Option<String>,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub projects_root_checked_at_unix_ms: Option<i64>,
1004}
1005
1006impl WorkerCapabilities {
1007 pub fn new() -> Self {
1009 Self::default()
1010 }
1011
1012 pub fn mock_with_rust() -> Self {
1016 Self {
1017 rustc_version: Some("1.85.0-nightly (mock)".to_string()),
1018 projects_root_ok: Some(true),
1019 ..Self::default()
1020 }
1021 }
1022
1023 pub fn is_pre_v3_x86(&self) -> bool {
1028 matches!(self.cpu_microarch_level, Some(1 | 2))
1029 }
1030
1031 pub fn has_bun(&self) -> bool {
1033 self.bun_version.is_some()
1034 }
1035
1036 pub fn has_node(&self) -> bool {
1038 self.node_version.is_some()
1039 }
1040
1041 pub fn has_rust(&self) -> bool {
1043 self.rustc_version.is_some()
1044 }
1045
1046 pub fn has_rustup_component(&self, toolchain: &str, component: &str) -> bool {
1053 let owner_prefix = format!("{toolchain}-");
1054 self.rustup_components.iter().any(|fact| {
1055 fact.split_once(':').is_some_and(|(owner, name)| {
1056 name == component && (owner == toolchain || owner.starts_with(&owner_prefix))
1057 })
1058 })
1059 }
1060
1061 pub fn has_nix(&self) -> bool {
1063 self.nix_version.is_some()
1064 }
1065
1066 pub fn has_go(&self) -> bool {
1068 self.go_version.is_some()
1069 }
1070
1071 pub fn has_zig(&self) -> bool {
1081 self.zig_version.is_some() && self.cargo_zigbuild_version.is_some()
1082 }
1083
1084 pub fn load_per_core(&self) -> Option<f64> {
1087 match (self.load_avg_1, self.num_cpus) {
1088 (Some(load), Some(cpus)) if cpus > 0 => Some(load / cpus as f64),
1089 _ => None,
1090 }
1091 }
1092
1093 pub fn is_high_load(&self, max_load_per_core: f64) -> Option<bool> {
1096 self.load_per_core().map(|lpc| lpc > max_load_per_core)
1097 }
1098
1099 pub fn build_disk_gb(&self) -> (Option<f64>, Option<f64>) {
1107 if self.build_disk_free_gb.is_some() {
1108 (self.build_disk_free_gb, self.build_disk_total_gb)
1109 } else {
1110 (self.disk_free_gb, self.disk_total_gb)
1111 }
1112 }
1113
1114 pub fn is_low_disk(&self, min_free_gb: f64) -> Option<bool> {
1117 self.build_disk_gb().0.map(|free| free < min_free_gb)
1118 }
1119
1120 pub fn is_topology_healthy(&self) -> Option<bool> {
1124 self.projects_root_ok
1125 }
1126}
1127
1128#[must_use]
1137pub fn x86_64_microarch_level_from_flags(flags: &str) -> u8 {
1138 let set: std::collections::HashSet<&str> = flags.split_whitespace().collect();
1139 let has_all = |required: &[&str]| required.iter().all(|flag| set.contains(flag));
1140 const V2: &[&str] = &["cx16", "lahf_lm", "popcnt", "sse4_1", "sse4_2", "ssse3"];
1142 const V3: &[&str] = &[
1144 "abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe", "xsave",
1145 ];
1146 const V4: &[&str] = &["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"];
1148 if !has_all(V2) {
1149 return 1;
1150 }
1151 if !has_all(V3) {
1152 return 2;
1153 }
1154 if !has_all(V4) {
1155 return 3;
1156 }
1157 4
1158}
1159
1160#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1173pub struct PathTopologyConfig {
1174 #[serde(default)]
1177 pub canonical_root: Option<String>,
1178
1179 #[serde(default)]
1182 pub alias_root: Option<String>,
1183}
1184
1185impl PathTopologyConfig {
1186 pub fn to_policy(&self) -> crate::path_topology::PathTopologyPolicy {
1192 let canonical = self
1193 .canonical_root
1194 .as_deref()
1195 .filter(|s| !s.is_empty())
1196 .map(|s| shellexpand::tilde(s).into_owned())
1197 .unwrap_or_else(|| crate::path_topology::DEFAULT_CANONICAL_PROJECT_ROOT.to_string());
1198 let alias = self
1199 .alias_root
1200 .as_deref()
1201 .filter(|s| !s.is_empty())
1202 .map(|s| shellexpand::tilde(s).into_owned())
1203 .unwrap_or_else(|| crate::path_topology::DEFAULT_ALIAS_PROJECT_ROOT.to_string());
1204 crate::path_topology::PathTopologyPolicy::new(
1205 PathBuf::from(canonical),
1206 PathBuf::from(alias),
1207 )
1208 }
1209}
1210
1211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1213pub struct RchConfig {
1214 #[serde(default)]
1215 pub general: GeneralConfig,
1216 #[serde(default)]
1217 pub compilation: CompilationConfig,
1218 #[serde(default)]
1219 pub transfer: TransferConfig,
1220 #[serde(default)]
1221 pub environment: EnvironmentConfig,
1222 #[serde(default)]
1223 pub circuit: CircuitBreakerConfig,
1224 #[serde(default)]
1225 pub output: OutputConfig,
1226 #[serde(default)]
1227 pub self_healing: SelfHealingConfig,
1228 #[serde(default)]
1229 pub self_test: SelfTestConfig,
1230 #[serde(default)]
1232 pub selection: SelectionConfig,
1233 #[serde(default)]
1235 pub execution: ExecutionConfig,
1236 #[serde(default)]
1238 pub alerts: AlertsConfig,
1239 #[serde(default)]
1241 pub fleet: FleetConfig,
1242 #[serde(default)]
1244 pub path_topology: PathTopologyConfig,
1245 #[serde(default)]
1247 pub doctor: DoctorConfig,
1248 #[serde(default)]
1253 pub remediation: crate::remediation_config::RemediationConfig,
1254 #[serde(default)]
1257 pub layer0: Layer0Config,
1258 #[serde(default)]
1261 pub api: ApiConfig,
1262 #[serde(default)]
1264 pub dashboard: DashboardConfig,
1265 #[serde(default)]
1267 pub jobs: JobsConfig,
1268}
1269
1270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1272pub struct JobsConfig {
1273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1283 pub required_tools: Vec<String>,
1284}
1285
1286#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1303#[serde(default)]
1304pub struct ApiConfig {
1305 pub bind: String,
1309 pub token: Option<String>,
1311 pub token_file: Option<String>,
1313 pub no_token: bool,
1317 pub allow_any_addr: bool,
1319}
1320
1321#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1323#[serde(default)]
1324pub struct DashboardConfig {
1325 pub url: Option<String>,
1327}
1328
1329#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1335pub struct DoctorConfig {
1336 #[serde(default)]
1338 pub webhooks: DoctorWebhooksConfig,
1339}
1340
1341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1347pub struct DoctorWebhooksConfig {
1348 #[serde(default)]
1350 pub endpoints: Vec<DoctorWebhookEndpoint>,
1351 #[serde(default = "default_webhook_max_inflight")]
1356 pub max_inflight: usize,
1357}
1358
1359impl Default for DoctorWebhooksConfig {
1363 fn default() -> Self {
1364 Self {
1365 endpoints: Vec::new(),
1366 max_inflight: default_webhook_max_inflight(),
1367 }
1368 }
1369}
1370
1371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1379pub struct DoctorWebhookEndpoint {
1380 pub name: String,
1382 pub url: String,
1384 #[serde(default)]
1386 pub format: DoctorWebhookFormat,
1387 #[serde(default)]
1392 pub on_transitions: Vec<String>,
1393 #[serde(default = "default_webhook_retry_max")]
1395 pub retry_max: u32,
1396 #[serde(default = "default_webhook_retry_backoff_ms")]
1398 pub retry_backoff_ms: u64,
1399 #[serde(default = "default_webhook_timeout_ms")]
1401 pub timeout_ms: u64,
1402 #[serde(default = "default_true")]
1404 pub enabled: bool,
1405 #[serde(default)]
1408 pub routing_key_env: Option<String>,
1409 #[serde(default)]
1412 pub bearer_token_env: Option<String>,
1413 #[serde(default)]
1417 pub signing_secret_env: Option<String>,
1418}
1419
1420#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1422#[serde(rename_all = "snake_case")]
1423pub enum DoctorWebhookFormat {
1424 #[default]
1426 GenericJson,
1427 Slack,
1429 Pagerduty,
1431}
1432
1433fn default_webhook_max_inflight() -> usize {
1434 16
1435}
1436
1437fn default_webhook_retry_max() -> u32 {
1438 3
1439}
1440
1441fn default_webhook_retry_backoff_ms() -> u64 {
1442 200
1443}
1444
1445fn default_webhook_timeout_ms() -> u64 {
1446 5000
1447}
1448
1449#[derive(Debug, Clone, Serialize, Deserialize)]
1451pub struct AlertsConfig {
1452 #[serde(default = "default_true")]
1454 pub enabled: bool,
1455 #[serde(default = "default_alert_suppress_duplicates_secs")]
1457 pub suppress_duplicates_secs: u64,
1458 #[serde(default = "default_alert_cleared_retention_secs")]
1463 pub cleared_retention_secs: u64,
1464 #[serde(default)]
1466 pub webhook: Option<WebhookConfig>,
1467}
1468
1469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1471pub struct WebhookConfig {
1472 pub url: Option<String>,
1474 #[serde(default)]
1476 pub secret: Option<String>,
1477 #[serde(default = "default_webhook_timeout_secs")]
1479 pub timeout_secs: u64,
1480 #[serde(default = "default_webhook_retry_count")]
1482 pub retry_count: u32,
1483 #[serde(default)]
1486 pub events: Vec<String>,
1487}
1488
1489fn default_webhook_timeout_secs() -> u64 {
1490 5
1491}
1492
1493fn default_webhook_retry_count() -> u32 {
1494 3
1495}
1496
1497fn default_alert_suppress_duplicates_secs() -> u64 {
1498 300
1499}
1500
1501fn default_alert_cleared_retention_secs() -> u64 {
1502 300
1503}
1504
1505impl Default for AlertsConfig {
1506 fn default() -> Self {
1507 Self {
1508 enabled: true,
1509 suppress_duplicates_secs: default_alert_suppress_duplicates_secs(),
1510 cleared_retention_secs: default_alert_cleared_retention_secs(),
1511 webhook: None,
1512 }
1513 }
1514}
1515
1516#[derive(Debug, Clone, Serialize, Deserialize)]
1521pub struct FleetConfig {
1522 #[serde(default = "default_fleet_ssh_connect_timeout_secs")]
1524 pub ssh_connect_timeout_secs: u64,
1525 #[serde(default = "default_fleet_ssh_command_timeout_secs")]
1527 pub ssh_command_timeout_secs: u64,
1528 #[serde(default = "default_fleet_min_disk_space_mb")]
1530 pub min_disk_space_mb: u64,
1531 #[serde(default = "default_fleet_max_load_average")]
1533 pub max_load_average: f64,
1534 #[serde(default = "default_fleet_max_concurrent_workers")]
1536 pub max_concurrent_workers: usize,
1537 #[serde(default = "default_fleet_retry_count")]
1539 pub retry_count: u32,
1540 #[serde(default = "default_fleet_retry_delay_ms")]
1542 pub retry_delay_ms: u64,
1543}
1544
1545fn default_fleet_ssh_connect_timeout_secs() -> u64 {
1546 10
1547}
1548
1549fn default_fleet_ssh_command_timeout_secs() -> u64 {
1550 30
1551}
1552
1553fn default_fleet_min_disk_space_mb() -> u64 {
1554 500
1555}
1556
1557fn default_fleet_max_load_average() -> f64 {
1558 10.0
1559}
1560
1561fn default_fleet_max_concurrent_workers() -> usize {
1562 10
1563}
1564
1565fn default_fleet_retry_count() -> u32 {
1566 2
1567}
1568
1569fn default_fleet_retry_delay_ms() -> u64 {
1570 1000
1571}
1572
1573impl Default for FleetConfig {
1574 fn default() -> Self {
1575 Self {
1576 ssh_connect_timeout_secs: default_fleet_ssh_connect_timeout_secs(),
1577 ssh_command_timeout_secs: default_fleet_ssh_command_timeout_secs(),
1578 min_disk_space_mb: default_fleet_min_disk_space_mb(),
1579 max_load_average: default_fleet_max_load_average(),
1580 max_concurrent_workers: default_fleet_max_concurrent_workers(),
1581 retry_count: default_fleet_retry_count(),
1582 retry_delay_ms: default_fleet_retry_delay_ms(),
1583 }
1584 }
1585}
1586
1587impl FleetConfig {
1588 pub fn ssh_connect_timeout(&self) -> std::time::Duration {
1590 std::time::Duration::from_secs(self.ssh_connect_timeout_secs)
1591 }
1592
1593 pub fn ssh_command_timeout(&self) -> std::time::Duration {
1595 std::time::Duration::from_secs(self.ssh_command_timeout_secs)
1596 }
1597
1598 pub fn retry_delay(&self) -> std::time::Duration {
1600 std::time::Duration::from_millis(self.retry_delay_ms)
1601 }
1602}
1603
1604#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1607#[serde(rename_all = "lowercase")]
1608pub enum BoxRole {
1609 Dispatcher,
1614 Worker,
1617 #[default]
1619 Hybrid,
1620}
1621
1622impl BoxRole {
1623 #[must_use]
1625 pub fn as_str(self) -> &'static str {
1626 match self {
1627 BoxRole::Dispatcher => "dispatcher",
1628 BoxRole::Worker => "worker",
1629 BoxRole::Hybrid => "hybrid",
1630 }
1631 }
1632}
1633
1634#[derive(Debug, Clone, Serialize, Deserialize)]
1635pub struct GeneralConfig {
1636 #[serde(default = "default_true")]
1638 pub enabled: bool,
1639 #[serde(default)]
1642 pub role: BoxRole,
1643 #[serde(default)]
1647 pub force_local: bool,
1648 #[serde(default)]
1653 pub force_remote: bool,
1654 #[serde(default = "default_log_level")]
1656 pub log_level: String,
1657 #[serde(default = "default_socket_path")]
1659 pub socket_path: String,
1660}
1661
1662#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1664pub struct EnvironmentConfig {
1665 #[serde(default)]
1667 pub allowlist: Vec<String>,
1668}
1669
1670impl Default for GeneralConfig {
1671 fn default() -> Self {
1672 Self {
1673 enabled: true,
1674 role: BoxRole::default(),
1675 force_local: false,
1676 force_remote: false,
1677 log_level: "info".to_string(),
1678 socket_path: default_socket_path(),
1679 }
1680 }
1681}
1682
1683#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1685#[serde(rename_all = "lowercase")]
1686pub enum OutputVisibility {
1687 #[default]
1688 None,
1689 Summary,
1690 Verbose,
1691}
1692
1693impl std::fmt::Display for OutputVisibility {
1694 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1695 let value = match self {
1696 OutputVisibility::None => "none",
1697 OutputVisibility::Summary => "summary",
1698 OutputVisibility::Verbose => "verbose",
1699 };
1700 write!(f, "{}", value)
1701 }
1702}
1703
1704impl std::str::FromStr for OutputVisibility {
1705 type Err = ();
1706
1707 fn from_str(s: &str) -> Result<Self, Self::Err> {
1708 match s.trim().to_lowercase().as_str() {
1709 "none" | "silent" | "quiet" => Ok(Self::None),
1710 "summary" | "short" => Ok(Self::Summary),
1711 "verbose" | "debug" => Ok(Self::Verbose),
1712 _ => Err(()),
1713 }
1714 }
1715}
1716
1717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1722#[serde(rename_all = "lowercase")]
1723pub enum ColorMode {
1724 #[default]
1727 Always,
1728 Auto,
1730 Never,
1732}
1733
1734impl std::fmt::Display for ColorMode {
1735 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1736 let value = match self {
1737 ColorMode::Always => "always",
1738 ColorMode::Auto => "auto",
1739 ColorMode::Never => "never",
1740 };
1741 write!(f, "{}", value)
1742 }
1743}
1744
1745impl std::str::FromStr for ColorMode {
1746 type Err = ();
1747
1748 fn from_str(s: &str) -> Result<Self, Self::Err> {
1749 match s.trim().to_lowercase().as_str() {
1750 "always" | "force" | "yes" | "true" => Ok(Self::Always),
1751 "auto" | "detect" => Ok(Self::Auto),
1752 "never" | "none" | "no" | "false" => Ok(Self::Never),
1753 _ => Err(()),
1754 }
1755 }
1756}
1757
1758fn default_color_mode() -> ColorMode {
1759 ColorMode::default()
1760}
1761
1762#[derive(Debug, Clone, Serialize, Deserialize)]
1764pub struct OutputConfig {
1765 #[serde(default = "default_output_visibility")]
1767 pub visibility: OutputVisibility,
1768 #[serde(default)]
1770 pub first_run_complete: bool,
1771 #[serde(default = "default_color_mode")]
1774 pub color_mode: ColorMode,
1775}
1776
1777impl Default for OutputConfig {
1778 fn default() -> Self {
1779 Self {
1780 visibility: OutputVisibility::None,
1781 first_run_complete: false,
1782 color_mode: ColorMode::default(),
1783 }
1784 }
1785}
1786
1787fn default_autostart_cooldown_secs() -> u64 {
1792 30
1793}
1794
1795fn default_autostart_timeout_secs() -> u64 {
1796 3
1797}
1798
1799#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1805#[serde(rename_all = "lowercase")]
1806pub enum SelfHealingLogLevel {
1807 Debug,
1808 #[default]
1809 Info,
1810 Warn,
1811 Error,
1812}
1813
1814impl SelfHealingLogLevel {
1815 pub fn from_env_str(s: &str) -> Option<Self> {
1817 match s.trim().to_ascii_lowercase().as_str() {
1818 "debug" => Some(Self::Debug),
1819 "info" => Some(Self::Info),
1820 "warn" | "warning" => Some(Self::Warn),
1821 "error" => Some(Self::Error),
1822 _ => None,
1823 }
1824 }
1825}
1826
1827#[derive(Debug, Clone, Serialize, Deserialize)]
1829pub struct SelfHealingConfig {
1830 #[serde(default = "default_true")]
1832 pub hook_starts_daemon: bool,
1833 #[serde(default = "default_true")]
1835 pub daemon_installs_hooks: bool,
1836 #[serde(default = "default_autostart_cooldown_secs")]
1838 pub auto_start_cooldown_secs: u64,
1839 #[serde(
1842 default = "default_autostart_timeout_secs",
1843 alias = "daemon_start_timeout"
1844 )]
1845 pub auto_start_timeout_secs: u64,
1846 #[serde(default)]
1849 pub self_healing_log_level: SelfHealingLogLevel,
1850}
1851
1852impl Default for SelfHealingConfig {
1853 fn default() -> Self {
1854 Self {
1855 hook_starts_daemon: default_true(),
1856 daemon_installs_hooks: default_true(),
1857 auto_start_cooldown_secs: default_autostart_cooldown_secs(),
1858 auto_start_timeout_secs: default_autostart_timeout_secs(),
1859 self_healing_log_level: SelfHealingLogLevel::default(),
1860 }
1861 }
1862}
1863
1864impl SelfHealingConfig {
1865 pub fn with_env_overrides(mut self) -> Self {
1874 if let Ok(val) = std::env::var("RCH_NO_SELF_HEALING")
1876 && (val == "1" || val.eq_ignore_ascii_case("true"))
1877 {
1878 self.hook_starts_daemon = false;
1879 self.daemon_installs_hooks = false;
1880 return self;
1881 }
1882
1883 if let Ok(val) = std::env::var("RCH_HOOK_STARTS_DAEMON") {
1885 self.hook_starts_daemon = val != "0" && !val.eq_ignore_ascii_case("false");
1886 }
1887 if let Ok(val) = std::env::var("RCH_DAEMON_INSTALLS_HOOKS") {
1888 self.daemon_installs_hooks = val != "0" && !val.eq_ignore_ascii_case("false");
1889 }
1890
1891 if let Ok(val) = std::env::var("RCH_AUTO_START_TIMEOUT_SECS")
1893 && let Ok(secs) = val.parse()
1894 {
1895 self.auto_start_timeout_secs = secs;
1896 }
1897 if let Ok(val) = std::env::var("RCH_AUTO_START_COOLDOWN_SECS")
1898 && let Ok(secs) = val.parse()
1899 {
1900 self.auto_start_cooldown_secs = secs;
1901 }
1902 if let Ok(val) = std::env::var("RCH_SELF_HEALING_LOG_LEVEL") {
1904 if let Some(level) = SelfHealingLogLevel::from_env_str(&val) {
1905 self.self_healing_log_level = level;
1906 } else {
1907 tracing::warn!(
1908 target: "rch::self_healing",
1909 invalid = %val,
1910 "RCH_SELF_HEALING_LOG_LEVEL has invalid value; using default=info"
1911 );
1912 }
1913 }
1914
1915 self
1916 }
1917}
1918
1919#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1925#[serde(rename_all = "snake_case")]
1926pub enum SelfTestFailureAction {
1927 #[default]
1929 Alert,
1930 DisableWorker,
1932 AlertAndDisable,
1934}
1935
1936#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1938#[serde(untagged)]
1939pub enum SelfTestWorkers {
1940 All(String),
1942 List(Vec<WorkerId>),
1944}
1945
1946impl Default for SelfTestWorkers {
1947 fn default() -> Self {
1948 Self::All("all".to_string())
1949 }
1950}
1951
1952impl SelfTestWorkers {
1953 pub fn resolve(&self) -> Option<Vec<WorkerId>> {
1955 match self {
1956 SelfTestWorkers::All(value) => {
1957 if value.eq_ignore_ascii_case("all") {
1958 None
1959 } else {
1960 Some(vec![WorkerId::new(value.clone())])
1961 }
1962 }
1963 SelfTestWorkers::List(list) => Some(list.clone()),
1964 }
1965 }
1966}
1967
1968fn default_self_test_retry_count() -> u32 {
1969 3
1970}
1971
1972fn default_self_test_retry_delay() -> String {
1973 "5m".to_string()
1974}
1975
1976fn default_self_test_enabled() -> bool {
1977 false
1978}
1979
1980#[derive(Debug, Clone, Serialize, Deserialize)]
1982pub struct SelfTestConfig {
1983 #[serde(default = "default_self_test_enabled")]
1985 pub enabled: bool,
1986 #[serde(default)]
1988 pub schedule: Option<String>,
1989 #[serde(default)]
1991 pub interval: Option<String>,
1992 #[serde(default)]
1994 pub workers: SelfTestWorkers,
1995 #[serde(default)]
1997 pub on_failure: SelfTestFailureAction,
1998 #[serde(default = "default_self_test_retry_count")]
2000 pub retry_count: u32,
2001 #[serde(default = "default_self_test_retry_delay")]
2003 pub retry_delay: String,
2004}
2005
2006impl Default for SelfTestConfig {
2007 fn default() -> Self {
2008 Self {
2009 enabled: default_self_test_enabled(),
2010 schedule: None,
2011 interval: None,
2012 workers: SelfTestWorkers::default(),
2013 on_failure: SelfTestFailureAction::default(),
2014 retry_count: default_self_test_retry_count(),
2015 retry_delay: default_self_test_retry_delay(),
2016 }
2017 }
2018}
2019
2020#[derive(Debug, Clone, Serialize, Deserialize)]
2022pub struct CircuitBreakerConfig {
2023 #[serde(default = "default_circuit_failure_threshold")]
2025 pub failure_threshold: u32,
2026 #[serde(default = "default_circuit_success_threshold")]
2028 pub success_threshold: u32,
2029 #[serde(default = "default_circuit_error_rate_threshold")]
2031 pub error_rate_threshold: f64,
2032 #[serde(default = "default_circuit_window_secs")]
2034 pub window_secs: u64,
2035 #[serde(default = "default_circuit_open_cooldown_secs")]
2037 pub open_cooldown_secs: u64,
2038 #[serde(default = "default_circuit_half_open_max_probes")]
2040 pub half_open_max_probes: u32,
2041 #[serde(default = "default_circuit_command_failure_threshold")]
2052 pub command_failure_threshold: u32,
2053 #[serde(default = "default_circuit_open_cooldown_max_secs")]
2061 pub open_cooldown_max_secs: u64,
2062}
2063
2064impl Default for CircuitBreakerConfig {
2065 fn default() -> Self {
2066 Self {
2067 failure_threshold: default_circuit_failure_threshold(),
2068 success_threshold: default_circuit_success_threshold(),
2069 error_rate_threshold: default_circuit_error_rate_threshold(),
2070 window_secs: default_circuit_window_secs(),
2071 open_cooldown_secs: default_circuit_open_cooldown_secs(),
2072 half_open_max_probes: default_circuit_half_open_max_probes(),
2073 command_failure_threshold: default_circuit_command_failure_threshold(),
2074 open_cooldown_max_secs: default_circuit_open_cooldown_max_secs(),
2075 }
2076 }
2077}
2078
2079impl CircuitBreakerConfig {
2080 pub fn cooldown_ms_for_streak(&self, reopen_streak: u32) -> u64 {
2084 let base_ms = self.open_cooldown_secs.saturating_mul(1000);
2085 let cap_ms = self
2086 .open_cooldown_max_secs
2087 .saturating_mul(1000)
2088 .max(base_ms);
2089 let exponent = reopen_streak.saturating_sub(1).min(16);
2091 base_ms.saturating_mul(1u64 << exponent).min(cap_ms)
2092 }
2093}
2094
2095#[derive(Debug, Clone, Default)]
2100pub struct CircuitStats {
2101 state: CircuitState,
2103 consecutive_failures: u32,
2105 consecutive_successes: u32,
2107 window_successes: u32,
2109 window_failures: u32,
2111 opened_at: Option<u64>,
2113 last_state_change: u64,
2115 active_probes: u32,
2117 consecutive_command_failures: u32,
2122 reopen_streak: u32,
2125 recent_results: Vec<bool>,
2128}
2129
2130const CIRCUIT_HISTORY_SIZE: usize = 10;
2132
2133impl CircuitStats {
2134 pub fn new() -> Self {
2136 Self {
2137 state: CircuitState::Closed,
2138 consecutive_failures: 0,
2139 consecutive_successes: 0,
2140 window_successes: 0,
2141 window_failures: 0,
2142 opened_at: None,
2143 last_state_change: Self::now_millis(),
2144 active_probes: 0,
2145 consecutive_command_failures: 0,
2146 reopen_streak: 0,
2147 recent_results: Vec::with_capacity(CIRCUIT_HISTORY_SIZE),
2148 }
2149 }
2150
2151 pub fn consecutive_command_failures(&self) -> u32 {
2154 self.consecutive_command_failures
2155 }
2156
2157 pub fn reopen_streak(&self) -> u32 {
2160 self.reopen_streak
2161 }
2162
2163 fn effective_cooldown_ms(&self, config: &CircuitBreakerConfig) -> u64 {
2165 config.cooldown_ms_for_streak(self.reopen_streak.max(1))
2166 }
2167
2168 pub fn state(&self) -> CircuitState {
2170 self.state
2171 }
2172
2173 pub fn opened_at(&self) -> Option<u64> {
2175 self.opened_at
2176 }
2177
2178 pub fn last_state_change(&self) -> u64 {
2180 self.last_state_change
2181 }
2182
2183 pub fn consecutive_failures(&self) -> u32 {
2185 self.consecutive_failures
2186 }
2187
2188 pub fn error_rate(&self) -> f64 {
2198 if self.recent_results.is_empty() {
2199 return 0.0;
2200 }
2201 let failures = self.recent_results.iter().filter(|&&ok| !ok).count();
2202 failures as f64 / self.recent_results.len() as f64
2203 }
2204
2205 pub fn record_success(&mut self) {
2210 self.window_successes += 1;
2211 self.consecutive_failures = 0;
2212 self.push_result(true);
2213
2214 if self.state == CircuitState::HalfOpen {
2215 self.consecutive_successes += 1;
2216 if self.active_probes > 0 {
2217 self.active_probes -= 1;
2218 }
2219 }
2220 }
2221
2222 pub fn record_failure(&mut self) {
2227 self.window_failures += 1;
2228 self.consecutive_failures += 1;
2229 self.push_result(false);
2230
2231 if self.state == CircuitState::HalfOpen {
2232 self.consecutive_successes = 0;
2233 if self.active_probes > 0 {
2234 self.active_probes -= 1;
2235 }
2236 }
2237 }
2238
2239 pub fn should_open(&self, config: &CircuitBreakerConfig) -> bool {
2245 if self.state != CircuitState::Closed {
2246 return false;
2247 }
2248
2249 if self.consecutive_failures >= config.failure_threshold {
2251 return true;
2252 }
2253
2254 if self.recent_results.len() >= 5 && self.error_rate() >= config.error_rate_threshold {
2258 return true;
2259 }
2260
2261 false
2262 }
2263
2264 pub fn should_half_open(&self, config: &CircuitBreakerConfig) -> bool {
2268 if self.state != CircuitState::Open {
2269 return false;
2270 }
2271
2272 let now = Self::now_millis();
2273 let cooldown_ms = self.effective_cooldown_ms(config);
2274
2275 if let Some(opened_at) = self.opened_at {
2276 now.saturating_sub(opened_at) >= cooldown_ms
2277 } else {
2278 false
2279 }
2280 }
2281
2282 pub fn should_close(&self, config: &CircuitBreakerConfig) -> bool {
2291 if self.state != CircuitState::HalfOpen {
2292 return false;
2293 }
2294
2295 self.consecutive_successes >= config.success_threshold
2296 && self.consecutive_command_failures == 0
2297 }
2298
2299 pub fn can_probe(&self, config: &CircuitBreakerConfig) -> bool {
2303 if self.state != CircuitState::HalfOpen {
2304 return false;
2305 }
2306
2307 self.active_probes < config.half_open_max_probes
2308 }
2309
2310 pub fn start_probe(&mut self, config: &CircuitBreakerConfig) -> bool {
2314 if !self.can_probe(config) {
2315 return false;
2316 }
2317 self.active_probes += 1;
2318 true
2319 }
2320
2321 pub fn open(&mut self) {
2323 if self.state != CircuitState::Open {
2324 self.state = CircuitState::Open;
2325 self.opened_at = Some(Self::now_millis());
2326 self.last_state_change = Self::now_millis();
2327 self.consecutive_successes = 0;
2328 self.active_probes = 0;
2329 self.reopen_streak = self.reopen_streak.saturating_add(1);
2332 }
2333 }
2334
2335 pub fn half_open(&mut self) {
2337 if self.state != CircuitState::HalfOpen {
2338 self.state = CircuitState::HalfOpen;
2339 self.last_state_change = Self::now_millis();
2340 self.consecutive_successes = 0;
2341 self.active_probes = 0;
2342 }
2343 }
2344
2345 pub fn close(&mut self) {
2347 if self.state != CircuitState::Closed {
2348 self.state = CircuitState::Closed;
2349 self.last_state_change = Self::now_millis();
2350 self.opened_at = None;
2351 self.consecutive_failures = 0;
2352 self.consecutive_successes = 0;
2353 self.active_probes = 0;
2354 self.consecutive_command_failures = 0;
2355 self.reopen_streak = 0;
2356 self.window_successes = 0;
2362 self.window_failures = 0;
2363 self.recent_results.clear();
2364 }
2365 }
2366
2367 pub fn reset_window(&mut self) {
2373 self.window_successes = 0;
2374 self.window_failures = 0;
2375 self.recent_results.clear();
2376 }
2377
2378 pub fn recent_results(&self) -> &[bool] {
2383 &self.recent_results
2384 }
2385
2386 pub fn recovery_remaining_secs(&self, config: &CircuitBreakerConfig) -> Option<u64> {
2390 if self.state != CircuitState::Open {
2391 return None;
2392 }
2393
2394 let now = Self::now_millis();
2395 let cooldown_ms = self.effective_cooldown_ms(config);
2396
2397 if let Some(opened_at) = self.opened_at {
2398 let elapsed_ms = now.saturating_sub(opened_at);
2399 if elapsed_ms < cooldown_ms {
2400 return Some((cooldown_ms - elapsed_ms) / 1000);
2401 }
2402 }
2403 None
2404 }
2405
2406 fn push_result(&mut self, success: bool) {
2408 if self.recent_results.len() >= CIRCUIT_HISTORY_SIZE {
2409 self.recent_results.remove(0);
2410 }
2411 self.recent_results.push(success);
2412 }
2413
2414 fn now_millis() -> u64 {
2415 std::time::SystemTime::now()
2416 .duration_since(std::time::UNIX_EPOCH)
2417 .map(|d| d.as_millis() as u64)
2418 .unwrap_or(0)
2419 }
2420
2421 pub fn apply_health_outcome(
2447 &mut self,
2448 healthy: bool,
2449 transient: bool,
2450 config: &CircuitBreakerConfig,
2451 ) -> CircuitState {
2452 if healthy {
2453 if self.state == CircuitState::Open && self.should_half_open(config) {
2457 self.half_open();
2458 }
2459 self.record_success();
2460 if self.should_close(config) {
2461 self.close();
2462 }
2463 } else {
2464 let was_half_open = self.state == CircuitState::HalfOpen;
2465 self.record_failure();
2466 if self.should_open(config) {
2467 self.open();
2468 } else if was_half_open && !transient {
2469 self.open();
2473 }
2474 }
2475
2476 if self.state == CircuitState::Open && self.should_half_open(config) {
2479 self.half_open();
2480 }
2481
2482 self.state
2483 }
2484
2485 pub fn apply_command_outcome(
2509 &mut self,
2510 success: bool,
2511 config: &CircuitBreakerConfig,
2512 ) -> CircuitState {
2513 if success {
2514 self.consecutive_command_failures = 0;
2515 return self.apply_health_outcome(true, false, config);
2516 }
2517
2518 self.consecutive_command_failures = self.consecutive_command_failures.saturating_add(1);
2519 let was_half_open = self.state == CircuitState::HalfOpen;
2520 self.record_failure();
2521 let threshold = config.command_failure_threshold.max(1);
2522 if was_half_open
2523 || self.should_open(config)
2524 || (self.state == CircuitState::Closed
2525 && self.consecutive_command_failures >= threshold)
2526 {
2527 self.open();
2528 }
2529
2530 if self.state == CircuitState::Open && self.should_half_open(config) {
2531 self.half_open();
2532 }
2533
2534 self.state
2535 }
2536}
2537
2538#[derive(Debug, Clone, Serialize, Deserialize)]
2539pub struct CompilationConfig {
2540 #[serde(default = "default_confidence")]
2542 pub confidence_threshold: f64,
2543 #[serde(default = "default_min_local_time")]
2546 pub min_local_time_ms: u64,
2547 #[serde(default = "default_speedup_threshold")]
2551 pub remote_speedup_threshold: f64,
2552 #[serde(default)]
2558 pub local_slots: u32,
2559 #[serde(default = "default_build_slots")]
2561 pub build_slots: u32,
2562 #[serde(default = "default_test_slots")]
2565 pub test_slots: u32,
2566 #[serde(default = "default_check_slots")]
2569 pub check_slots: u32,
2570 #[serde(default)]
2576 pub disk_headroom_gib: u32,
2577 #[serde(default = "default_build_timeout")]
2580 pub build_timeout_sec: u64,
2581 #[serde(default = "default_test_timeout")]
2584 pub test_timeout_sec: u64,
2585 #[serde(default = "default_bun_timeout")]
2589 pub bun_timeout_sec: u64,
2590 #[serde(default = "default_external_timeout_enabled")]
2594 pub external_timeout_enabled: bool,
2595 #[serde(default = "default_allow_local_fallback")]
2608 pub allow_local_fallback: bool,
2609 #[serde(default)]
2620 pub remote_build_jobs: RemoteBuildJobs,
2621}
2622
2623#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2635pub enum RemoteBuildJobs {
2636 #[default]
2638 Auto,
2639 Off,
2641 Fixed(u32),
2643}
2644
2645impl RemoteBuildJobs {
2646 pub fn parse(value: &str) -> Result<Self, String> {
2648 let trimmed = value.trim();
2649 match trimmed.to_ascii_lowercase().as_str() {
2650 "auto" => Ok(Self::Auto),
2651 "off" | "none" | "disabled" => Ok(Self::Off),
2652 _ => match trimmed.parse::<u32>() {
2653 Ok(0) => Err(
2654 "remote_build_jobs must be >= 1 (cargo rejects `-j 0`); use `off` to disable"
2655 .to_string(),
2656 ),
2657 Ok(n) => Ok(Self::Fixed(n)),
2658 Err(_) => Err(format!(
2659 "'{trimmed}' is not a valid remote_build_jobs value (expected `auto`, `off`, or a positive integer)"
2660 )),
2661 },
2662 }
2663 }
2664}
2665
2666impl std::fmt::Display for RemoteBuildJobs {
2667 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2668 match self {
2669 Self::Auto => f.write_str("auto"),
2670 Self::Off => f.write_str("off"),
2671 Self::Fixed(n) => write!(f, "{n}"),
2672 }
2673 }
2674}
2675
2676impl std::str::FromStr for RemoteBuildJobs {
2677 type Err = String;
2678
2679 fn from_str(value: &str) -> Result<Self, Self::Err> {
2680 Self::parse(value)
2681 }
2682}
2683
2684impl Serialize for RemoteBuildJobs {
2685 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2686 match self {
2687 Self::Auto => serializer.serialize_str("auto"),
2688 Self::Off => serializer.serialize_str("off"),
2689 Self::Fixed(n) => serializer.serialize_u32(*n),
2690 }
2691 }
2692}
2693
2694impl<'de> Deserialize<'de> for RemoteBuildJobs {
2695 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2696 #[derive(Deserialize)]
2697 #[serde(untagged)]
2698 enum Raw {
2699 Count(u64),
2700 Spelling(String),
2701 }
2702 match Raw::deserialize(deserializer)? {
2703 Raw::Count(n) => {
2704 let n = u32::try_from(n).map_err(|_| {
2705 serde::de::Error::custom(format!("remote_build_jobs {n} is out of range"))
2706 })?;
2707 Self::parse(&n.to_string()).map_err(serde::de::Error::custom)
2708 }
2709 Raw::Spelling(s) => Self::parse(&s).map_err(serde::de::Error::custom),
2710 }
2711 }
2712}
2713
2714impl Default for CompilationConfig {
2715 fn default() -> Self {
2716 Self {
2717 confidence_threshold: 0.85,
2718 min_local_time_ms: 2000,
2719 remote_speedup_threshold: default_speedup_threshold(),
2720 local_slots: 0,
2721 build_slots: default_build_slots(),
2722 test_slots: default_test_slots(),
2723 check_slots: default_check_slots(),
2724 disk_headroom_gib: 0,
2725 build_timeout_sec: default_build_timeout(),
2726 test_timeout_sec: default_test_timeout(),
2727 bun_timeout_sec: default_bun_timeout(),
2728 external_timeout_enabled: default_external_timeout_enabled(),
2729 allow_local_fallback: default_allow_local_fallback(),
2730 remote_build_jobs: RemoteBuildJobs::default(),
2731 }
2732 }
2733}
2734
2735fn default_build_slots() -> u32 {
2736 4
2737}
2738
2739fn default_test_slots() -> u32 {
2740 8
2741}
2742
2743fn default_check_slots() -> u32 {
2744 2
2745}
2746
2747fn default_build_timeout() -> u64 {
2750 300
2751}
2752
2753fn default_test_timeout() -> u64 {
2756 1800
2757}
2758
2759fn default_bun_timeout() -> u64 {
2763 600
2764}
2765
2766fn default_external_timeout_enabled() -> bool {
2768 true
2769}
2770
2771fn default_allow_local_fallback() -> bool {
2775 true
2776}
2777
2778impl CompilationConfig {
2779 pub fn timeout_for_kind(&self, kind: Option<CompilationKind>) -> std::time::Duration {
2785 let secs = match kind {
2786 Some(CompilationKind::BunTest) | Some(CompilationKind::BunTypecheck) => {
2788 self.bun_timeout_sec
2789 }
2790 Some(CompilationKind::CargoTest) | Some(CompilationKind::CargoNextest) => {
2792 self.test_timeout_sec
2793 }
2794 Some(CompilationKind::NixBuild) => self.test_timeout_sec,
2798 Some(CompilationKind::GoTest) => self.test_timeout_sec,
2801 _ => self.build_timeout_sec,
2803 };
2804 std::time::Duration::from_secs(secs)
2805 }
2806
2807 pub fn external_timeout_enabled(&self) -> bool {
2809 self.external_timeout_enabled
2810 }
2811}
2812
2813pub const LAYER0_PACK_VERSION: &str = "1";
2819
2820#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2829#[serde(default)]
2830pub struct Layer0Config {
2831 pub enabled: bool,
2833 pub release_lto_thin: bool,
2835 pub release_codegen_units_1: bool,
2838}
2839
2840impl Layer0Config {
2841 #[must_use]
2847 pub fn active_env(&self) -> Vec<(&'static str, &'static str)> {
2848 if !self.enabled {
2849 return Vec::new();
2850 }
2851 let mut env = Vec::new();
2852 if self.release_lto_thin {
2853 env.push(("CARGO_PROFILE_RELEASE_LTO", "thin"));
2854 }
2855 if self.release_codegen_units_1 {
2856 env.push(("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"));
2857 }
2858 env
2859 }
2860}
2861
2862#[derive(Debug, Clone, Serialize, Deserialize)]
2863pub struct TransferConfig {
2864 #[serde(default, skip_serializing_if = "Option::is_none")]
2867 pub retrieval_staging_dir: Option<PathBuf>,
2868 #[serde(default)]
2871 pub retrieval_min_free_bytes: u64,
2872 #[serde(default = "default_compression")]
2874 pub compression_level: u32,
2875 #[serde(default = "default_excludes")]
2877 pub exclude_patterns: Vec<String>,
2878 #[serde(default, skip_serializing_if = "Option::is_none")]
2882 pub ssh_server_alive_interval_secs: Option<u64>,
2883 #[serde(default, skip_serializing_if = "Option::is_none")]
2888 pub ssh_control_persist_secs: Option<u64>,
2889 #[serde(default = "default_remote_base")]
2900 pub remote_base: String,
2901 #[serde(default, skip_serializing_if = "Option::is_none")]
2907 pub sync_timeout_ms: Option<u64>,
2908 #[serde(default = "default_source_sync_silence_timeout_secs")]
2920 pub source_sync_silence_timeout_secs: u64,
2921 #[serde(default)]
2923 pub retry: RetryConfig,
2924 #[serde(default)]
2930 pub verify_artifacts: bool,
2931 #[serde(default = "default_verify_max_size")]
2936 pub verify_max_size_bytes: u64,
2937
2938 #[serde(default, skip_serializing_if = "Option::is_none")]
2947 pub max_transfer_mb: Option<u64>,
2948
2949 #[serde(default, skip_serializing_if = "Option::is_none")]
2955 pub max_transfer_time_ms: Option<u64>,
2956
2957 #[serde(default, skip_serializing_if = "Option::is_none")]
2962 pub bwlimit_kbps: Option<u64>,
2963
2964 #[serde(default, skip_serializing_if = "Option::is_none")]
2973 pub rsync_bin: Option<String>,
2974
2975 #[serde(default, skip_serializing_if = "Option::is_none")]
2980 pub estimated_bandwidth_bps: Option<u64>,
2981
2982 #[serde(default)]
2995 pub adaptive_compression: bool,
2996
2997 #[serde(default = "default_min_compression")]
3002 pub min_compression_level: u32,
3003
3004 #[serde(default = "default_max_compression")]
3009 pub max_compression_level: u32,
3010}
3011
3012impl Default for TransferConfig {
3013 fn default() -> Self {
3014 Self {
3015 retrieval_staging_dir: None,
3016 retrieval_min_free_bytes: 0,
3017 compression_level: 3,
3018 exclude_patterns: default_excludes(),
3019 ssh_server_alive_interval_secs: None,
3020 ssh_control_persist_secs: None,
3021 remote_base: default_remote_base(),
3022 sync_timeout_ms: None,
3023 source_sync_silence_timeout_secs: default_source_sync_silence_timeout_secs(),
3024 retry: RetryConfig::default(),
3025 verify_artifacts: false,
3026 verify_max_size_bytes: default_verify_max_size(),
3027 max_transfer_mb: None,
3029 max_transfer_time_ms: None,
3030 bwlimit_kbps: None,
3031 rsync_bin: None,
3032 estimated_bandwidth_bps: None,
3033 adaptive_compression: false,
3035 min_compression_level: default_min_compression(),
3036 max_compression_level: default_max_compression(),
3037 }
3038 }
3039}
3040
3041impl TransferConfig {
3042 pub const MIN_SYNC_TIMEOUT_MS: u64 = 1_000;
3044 pub const MAX_SYNC_TIMEOUT_MS: u64 = 3_600_000;
3046 pub const DEFAULT_SYNC_TIMEOUT_FLOOR_MS: u64 = 30_000;
3048 const DEFAULT_SYNC_BYTES_PER_SECOND: u64 = 1024 * 1024;
3049
3050 #[must_use]
3052 pub fn valid_sync_timeout_ms(value: u64) -> bool {
3053 (Self::MIN_SYNC_TIMEOUT_MS..=Self::MAX_SYNC_TIMEOUT_MS).contains(&value)
3054 }
3055
3056 #[must_use]
3063 pub fn sync_timeout_for_payload(&self, payload_bytes: u64) -> std::time::Duration {
3064 let timeout_ms = self
3065 .sync_timeout_ms
3066 .filter(|value| Self::valid_sync_timeout_ms(*value))
3067 .unwrap_or_else(|| {
3068 let transfer_seconds = payload_bytes
3069 .saturating_add(Self::DEFAULT_SYNC_BYTES_PER_SECOND - 1)
3070 / Self::DEFAULT_SYNC_BYTES_PER_SECOND;
3071 Self::DEFAULT_SYNC_TIMEOUT_FLOOR_MS
3072 .saturating_add(transfer_seconds.saturating_mul(1_000))
3073 .min(Self::MAX_SYNC_TIMEOUT_MS)
3074 });
3075 std::time::Duration::from_millis(timeout_ms)
3076 }
3077
3078 pub fn select_compression_level(&self, estimated_bytes: Option<u64>) -> u32 {
3090 if !self.adaptive_compression {
3091 return self.compression_level;
3092 }
3093
3094 let Some(bytes) = estimated_bytes else {
3095 return self.compression_level;
3097 };
3098
3099 const SMALL_THRESHOLD: u64 = 10_000_000; const LARGE_THRESHOLD: u64 = 200_000_000; let level = if bytes < SMALL_THRESHOLD {
3104 1 } else if bytes < LARGE_THRESHOLD {
3106 3 } else {
3108 7 };
3110
3111 level.clamp(self.min_compression_level, self.max_compression_level)
3113 }
3114}
3115
3116#[rustfmt::skip]
3130pub const BUILTIN_EXECUTION_BASES: &[&str] = &[
3131 "cargo",
3133 "rustc",
3134 "nextest",
3136 "gcc",
3138 "g++",
3139 "clang",
3140 "clang++",
3141 "cc",
3142 "c++",
3143 "make",
3145 "cmake",
3146 "ninja",
3147 "meson",
3148 "bun",
3150 "nix",
3152 "go",
3154 "tsc",
3156];
3157
3158fn default_execution_allowlist() -> Vec<String> {
3159 BUILTIN_EXECUTION_BASES
3160 .iter()
3161 .map(|base| (*base).to_string())
3162 .collect()
3163}
3164
3165#[derive(Debug, Clone, Serialize, Deserialize)]
3170pub struct ExecutionConfig {
3171 #[serde(default = "default_execution_allowlist")]
3180 pub allowlist: Vec<String>,
3181}
3182
3183impl Default for ExecutionConfig {
3184 fn default() -> Self {
3185 Self {
3186 allowlist: default_execution_allowlist(),
3187 }
3188 }
3189}
3190
3191impl ExecutionConfig {
3192 pub fn is_allowed(&self, command_base: &str) -> bool {
3194 self.allowlist
3195 .iter()
3196 .any(|allowed| allowed.eq_ignore_ascii_case(command_base))
3197 }
3198
3199 pub fn missing_builtin_bases(&self) -> Vec<&'static str> {
3205 BUILTIN_EXECUTION_BASES
3206 .iter()
3207 .copied()
3208 .filter(|base| !self.is_allowed(base))
3209 .collect()
3210 }
3211}
3212
3213fn default_retry_max_attempts() -> u32 {
3218 3
3219}
3220
3221fn default_retry_base_delay_ms() -> u64 {
3222 100
3223}
3224
3225fn default_retry_max_delay_ms() -> u64 {
3226 5000
3227}
3228
3229fn default_retry_jitter_factor() -> f64 {
3230 0.1
3231}
3232
3233fn default_retry_total_timeout_ms() -> u64 {
3234 30000
3235}
3236
3237#[derive(Debug, Clone, Serialize, Deserialize)]
3242pub struct RetryConfig {
3243 #[serde(default = "default_retry_max_attempts")]
3245 pub max_attempts: u32,
3246
3247 #[serde(default = "default_retry_base_delay_ms")]
3249 pub base_delay_ms: u64,
3250
3251 #[serde(default = "default_retry_max_delay_ms")]
3253 pub max_delay_ms: u64,
3254
3255 #[serde(default = "default_retry_jitter_factor")]
3258 pub jitter_factor: f64,
3259
3260 #[serde(default = "default_retry_total_timeout_ms")]
3263 pub total_timeout_ms: u64,
3264}
3265
3266impl Default for RetryConfig {
3267 fn default() -> Self {
3268 Self {
3269 max_attempts: default_retry_max_attempts(),
3270 base_delay_ms: default_retry_base_delay_ms(),
3271 max_delay_ms: default_retry_max_delay_ms(),
3272 jitter_factor: default_retry_jitter_factor(),
3273 total_timeout_ms: default_retry_total_timeout_ms(),
3274 }
3275 }
3276}
3277
3278impl RetryConfig {
3279 pub fn no_retry() -> Self {
3281 Self {
3282 max_attempts: 1,
3283 ..Default::default()
3284 }
3285 }
3286
3287 pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
3291 if attempt == 0 {
3292 return std::time::Duration::ZERO;
3293 }
3294
3295 let base = self.base_delay_ms as f64;
3297 let delay = base * (2.0_f64.powi(attempt as i32 - 1));
3298 let capped = delay.min(self.max_delay_ms as f64);
3299
3300 let jittered = if self.jitter_factor > 0.0 {
3302 let jitter_range = capped * self.jitter_factor;
3303 let jitter = (rand::rng().random::<f64>() * 2.0 - 1.0) * jitter_range;
3304 (capped + jitter).max(0.0)
3305 } else {
3306 capped
3307 };
3308
3309 std::time::Duration::from_millis(jittered as u64)
3310 }
3311
3312 pub fn should_retry(&self, attempt: u32, elapsed: std::time::Duration) -> bool {
3314 if attempt >= self.max_attempts {
3315 return false;
3316 }
3317 elapsed.as_millis() < self.total_timeout_ms as u128
3318 }
3319}
3320
3321fn default_source_sync_silence_timeout_secs() -> u64 {
3326 120
3327}
3328
3329pub fn default_remote_base() -> String {
3338 "/data/tmp/rch".to_string()
3339}
3340
3341pub fn validate_remote_base(path: &str) -> Result<String, String> {
3344 let expanded = shellexpand::tilde(path).into_owned();
3346
3347 if !expanded.starts_with('/') {
3349 return Err(format!(
3350 "remote_base must be an absolute path, got: {}",
3351 path
3352 ));
3353 }
3354
3355 if expanded.contains("..") {
3357 return Err(format!(
3358 "remote_base must not contain path traversal (..): {}",
3359 path
3360 ));
3361 }
3362
3363 let normalized = expanded.trim_end_matches('/').to_string();
3365
3366 if normalized.is_empty() || normalized == "/" {
3368 return Err("remote_base cannot be the root directory (safety restriction)".to_string());
3369 }
3370
3371 let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
3375 if components.len() < 2 {
3376 return Err(format!(
3377 "remote_base must be at least 2 levels deep (e.g. /tmp/rch), got: {}",
3378 normalized
3379 ));
3380 }
3381
3382 Ok(normalized)
3383}
3384
3385fn default_circuit_failure_threshold() -> u32 {
3386 3
3387}
3388
3389fn default_circuit_success_threshold() -> u32 {
3390 2
3391}
3392
3393fn default_circuit_error_rate_threshold() -> f64 {
3394 0.5
3395}
3396
3397fn default_circuit_window_secs() -> u64 {
3398 60
3399}
3400
3401fn default_circuit_open_cooldown_secs() -> u64 {
3402 30
3403}
3404
3405fn default_circuit_half_open_max_probes() -> u32 {
3406 1
3407}
3408
3409fn default_circuit_command_failure_threshold() -> u32 {
3410 2
3411}
3412
3413fn default_circuit_open_cooldown_max_secs() -> u64 {
3414 480
3416}
3417
3418fn default_true() -> bool {
3419 true
3420}
3421
3422fn default_log_level() -> String {
3423 "info".to_string()
3424}
3425
3426pub fn default_socket_path() -> String {
3427 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR")
3429 && !runtime_dir.trim().is_empty()
3430 {
3431 let path = PathBuf::from(runtime_dir).join("rch.sock");
3432 return path.to_string_lossy().to_string();
3433 }
3434
3435 if let Some(cache_dir) = dirs::cache_dir() {
3437 let rch_cache = cache_dir.join("rch");
3438 let _ = std::fs::create_dir_all(&rch_cache);
3439 return rch_cache.join("rch.sock").to_string_lossy().to_string();
3440 }
3441
3442 "/tmp/rch.sock".to_string()
3444}
3445
3446#[allow(dead_code)]
3447fn default_output_visibility() -> OutputVisibility {
3448 OutputVisibility::None
3449}
3450
3451fn default_confidence() -> f64 {
3452 0.85
3453}
3454
3455fn default_min_local_time() -> u64 {
3456 2000
3457}
3458
3459fn default_speedup_threshold() -> f64 {
3463 1.2
3464}
3465
3466fn default_compression() -> u32 {
3467 3
3468}
3469
3470fn default_verify_max_size() -> u64 {
3472 100 * 1024 * 1024 }
3474
3475fn default_min_compression() -> u32 {
3477 1
3478}
3479
3480fn default_max_compression() -> u32 {
3482 9
3483}
3484
3485fn default_excludes() -> Vec<String> {
3486 vec![
3487 "target/".to_string(),
3489 "*.rlib".to_string(),
3490 "*.rmeta".to_string(),
3491 ".git/".to_string(),
3494 "node_modules/".to_string(),
3496 ".bun/".to_string(),
3498 ".npm/".to_string(),
3500 ".pnpm-store/".to_string(),
3501 "dist/".to_string(),
3503 "build/".to_string(),
3504 ".next/".to_string(),
3506 ".nuxt/".to_string(),
3507 ".turbo/".to_string(),
3508 ".parcel-cache/".to_string(),
3509 ".beads/".to_string(),
3511 ".doctor/".to_string(),
3513 "coverage/".to_string(),
3515 ".nyc_output/".to_string(),
3516 ".cargo/credentials".to_string(),
3518 ".cargo/credentials.toml".to_string(),
3519 ".env".to_string(),
3520 ".env.*".to_string(),
3521 "*.pem".to_string(),
3522 "*.key".to_string(),
3523 "credentials.json".to_string(),
3524 "secrets.json".to_string(),
3525 "secrets.yaml".to_string(),
3526 "secrets.yml".to_string(),
3527 "secrets.toml".to_string(),
3528 "secrets.env".to_string(),
3529 "secrets.txt".to_string(),
3530 "secrets.conf".to_string(),
3531 "secrets.cfg".to_string(),
3532 "secrets.properties".to_string(),
3533 "secrets.xml".to_string(),
3534 "id_rsa".to_string(),
3548 "id_ed25519".to_string(),
3549 "id_ecdsa".to_string(),
3550 "id_dsa".to_string(),
3551 "*.p12".to_string(),
3553 "*.pfx".to_string(),
3554 "*.keystore".to_string(),
3555 "*.jks".to_string(),
3556 ".netrc".to_string(),
3559 "_netrc".to_string(),
3560 ".aws/credentials".to_string(),
3561 ".docker/config.json".to_string(),
3562 ".npmrc".to_string(),
3566 ".pypirc".to_string(),
3567 "secring.*".to_string(),
3569 ]
3570}
3571
3572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3578#[serde(rename_all = "snake_case")]
3579pub enum BuildLocation {
3580 #[default]
3582 Local,
3583 Remote,
3585}
3586
3587#[derive(Debug, Clone, Serialize, Deserialize)]
3589pub struct BuildCancellationWorkerHealth {
3590 pub status: String,
3592 pub speed_score: f64,
3594 pub used_slots: u32,
3596 pub available_slots: u32,
3598 pub pressure_state: String,
3600 pub pressure_reason_code: String,
3602}
3603
3604#[derive(Debug, Clone, Serialize, Deserialize)]
3606pub struct BuildCancellationMetadata {
3607 pub operation_id: String,
3609 pub origin: String,
3611 pub reason_code: String,
3613 #[serde(default)]
3615 pub decision_path: Vec<String>,
3616 pub escalation_stage: String,
3618 pub escalation_count: u32,
3620 pub remote_kill_attempted: bool,
3622 pub cleanup_ok: bool,
3624 pub history_cancelled: bool,
3626 pub final_state: String,
3628 #[serde(default, skip_serializing_if = "Option::is_none")]
3630 pub worker_health: Option<BuildCancellationWorkerHealth>,
3631}
3632
3633#[derive(Debug, Clone, Serialize, Deserialize)]
3635pub struct BuildRecord {
3636 pub id: u64,
3638 pub started_at: String,
3640 pub completed_at: String,
3642 pub project_id: String,
3644 pub worker_id: Option<String>,
3646 pub command: String,
3648 pub exit_code: i32,
3650 pub duration_ms: u64,
3652 pub location: BuildLocation,
3654 pub bytes_transferred: Option<u64>,
3656 #[serde(default)]
3658 pub timing: Option<CommandTimingBreakdown>,
3659 #[serde(default, skip_serializing_if = "Option::is_none")]
3661 pub cancellation: Option<BuildCancellationMetadata>,
3662}
3663
3664#[derive(Debug, Clone, Serialize, Deserialize)]
3666pub struct BuildRecordInput {
3667 pub started_at: String,
3669 pub completed_at: String,
3671 pub project_id: String,
3673 pub worker_id: Option<String>,
3675 pub command: String,
3677 pub exit_code: i32,
3679 pub duration_ms: u64,
3681 pub location: BuildLocation,
3683 pub bytes_transferred: Option<u64>,
3685 #[serde(default)]
3687 pub timing: Option<CommandTimingBreakdown>,
3688 #[serde(default, skip_serializing_if = "Option::is_none")]
3690 pub cancellation: Option<BuildCancellationMetadata>,
3691}
3692
3693impl BuildRecordInput {
3694 pub fn into_record(self, id: u64) -> BuildRecord {
3696 BuildRecord {
3697 id,
3698 started_at: self.started_at,
3699 completed_at: self.completed_at,
3700 project_id: self.project_id,
3701 worker_id: self.worker_id,
3702 command: self.command,
3703 exit_code: self.exit_code,
3704 duration_ms: self.duration_ms,
3705 location: self.location,
3706 bytes_transferred: self.bytes_transferred,
3707 timing: self.timing,
3708 cancellation: self.cancellation,
3709 }
3710 }
3711}
3712
3713#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3718pub struct BuildStats {
3719 pub total_builds: usize,
3721 pub success_count: usize,
3723 pub failure_count: usize,
3725 pub remote_count: usize,
3727 pub local_count: usize,
3729 pub avg_duration_ms: u64,
3731}
3732
3733#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3740pub struct SavedTimeStats {
3741 pub total_remote_duration_ms: u64,
3743 pub estimated_local_duration_ms: u64,
3746 pub time_saved_ms: u64,
3749 pub builds_counted: usize,
3751 pub avg_speedup: f64,
3755 pub today_saved_ms: u64,
3757 pub week_saved_ms: u64,
3759 #[serde(default)]
3762 pub estimate_basis: String,
3763 #[serde(default)]
3765 pub local_baseline_builds: usize,
3766}
3767
3768use std::collections::VecDeque;
3773use std::time::{Duration, Instant};
3774
3775#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3777pub struct CompilationTimingBreakdown {
3778 #[serde(with = "duration_millis")]
3780 pub rsync_up: Duration,
3781 #[serde(with = "duration_millis")]
3783 pub remote_build: Duration,
3784 #[serde(with = "duration_millis")]
3786 pub rsync_down: Duration,
3787 #[serde(with = "duration_millis")]
3789 pub total: Duration,
3790}
3791
3792#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3796pub struct CommandTimingBreakdown {
3797 #[serde(with = "option_duration_millis")]
3799 pub classify: Option<Duration>,
3800 #[serde(with = "option_duration_millis")]
3802 pub select: Option<Duration>,
3803 #[serde(with = "option_duration_millis")]
3805 pub sync_up: Option<Duration>,
3806 #[serde(with = "option_duration_millis")]
3808 pub exec: Option<Duration>,
3809 #[serde(with = "option_duration_millis")]
3811 pub sync_down: Option<Duration>,
3812 #[serde(with = "option_duration_millis")]
3814 pub cleanup: Option<Duration>,
3815 #[serde(with = "option_duration_millis")]
3817 pub total: Option<Duration>,
3818}
3819
3820mod duration_millis {
3821 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3822 use std::time::Duration;
3823
3824 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
3825 where
3826 S: Serializer,
3827 {
3828 duration.as_millis().serialize(serializer)
3829 }
3830
3831 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
3832 where
3833 D: Deserializer<'de>,
3834 {
3835 let millis = u64::deserialize(deserializer)?;
3836 Ok(Duration::from_millis(millis))
3837 }
3838}
3839
3840#[derive(Debug, Clone, Serialize, Deserialize)]
3842pub struct CompilationMetrics {
3843 pub project_id: String,
3845 pub worker_id: String,
3847 pub timestamp: chrono::DateTime<chrono::Utc>,
3849 pub timing: CompilationTimingBreakdown,
3851 #[serde(with = "option_duration_millis")]
3853 pub local_build_time: Option<Duration>,
3854 pub speedup: Option<f64>,
3856 pub files_synced: u64,
3858 pub bytes_transferred: u64,
3860 pub exit_code: i32,
3862 pub success: bool,
3864}
3865
3866mod option_duration_millis {
3867 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3868 use std::time::Duration;
3869
3870 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
3871 where
3872 S: Serializer,
3873 {
3874 match duration {
3875 Some(d) => Some(d.as_millis() as u64).serialize(serializer),
3876 None => Option::<u64>::None.serialize(serializer),
3877 }
3878 }
3879
3880 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
3881 where
3882 D: Deserializer<'de>,
3883 {
3884 let opt: Option<u64> = Option::deserialize(deserializer)?;
3885 Ok(opt.map(Duration::from_millis))
3886 }
3887}
3888
3889impl CompilationMetrics {
3890 pub fn calculate_speedup(&mut self) {
3892 if let Some(local) = self.local_build_time
3893 && self.timing.total.as_millis() > 0
3894 {
3895 self.speedup = Some(local.as_secs_f64() / self.timing.total.as_secs_f64());
3896 }
3897 }
3898
3899 pub fn is_beneficial(&self) -> bool {
3901 self.speedup.map(|s| s > 1.0).unwrap_or(false)
3902 }
3903}
3904
3905impl Default for CompilationMetrics {
3906 fn default() -> Self {
3907 Self {
3908 project_id: String::new(),
3909 worker_id: String::new(),
3910 timestamp: chrono::Utc::now(),
3911 timing: CompilationTimingBreakdown::default(),
3912 local_build_time: None,
3913 speedup: None,
3914 files_synced: 0,
3915 bytes_transferred: 0,
3916 exit_code: 0,
3917 success: true,
3918 }
3919 }
3920}
3921
3922#[derive(Debug)]
3938pub struct CompilationTimer {
3939 project_id: String,
3940 worker_id: String,
3941 start: Instant,
3942 phase_start: Instant,
3943 rsync_up: Option<Duration>,
3944 remote_build: Option<Duration>,
3945 rsync_down: Option<Duration>,
3946}
3947
3948impl CompilationTimer {
3949 pub fn new(project_id: &str, worker_id: &str) -> Self {
3951 let now = Instant::now();
3952 Self {
3953 project_id: project_id.to_string(),
3954 worker_id: worker_id.to_string(),
3955 start: now,
3956 phase_start: now,
3957 rsync_up: None,
3958 remote_build: None,
3959 rsync_down: None,
3960 }
3961 }
3962
3963 pub fn end_rsync_up(&mut self) {
3965 self.rsync_up = Some(self.phase_start.elapsed());
3966 self.phase_start = Instant::now();
3967 tracing::info!(
3968 rsync_up_ms = %self.rsync_up.unwrap().as_millis(),
3969 "TIMING: rsync_up completed"
3970 );
3971 }
3972
3973 pub fn end_remote_build(&mut self) {
3975 self.remote_build = Some(self.phase_start.elapsed());
3976 self.phase_start = Instant::now();
3977 tracing::info!(
3978 remote_build_ms = %self.remote_build.unwrap().as_millis(),
3979 "TIMING: remote_build completed"
3980 );
3981 }
3982
3983 pub fn end_rsync_down(&mut self) {
3985 self.rsync_down = Some(self.phase_start.elapsed());
3986 tracing::info!(
3987 rsync_down_ms = %self.rsync_down.unwrap().as_millis(),
3988 "TIMING: rsync_down completed"
3989 );
3990 }
3991
3992 pub fn finish(self, exit_code: i32, files: u64, bytes: u64) -> CompilationMetrics {
3994 let total = self.start.elapsed();
3995 tracing::info!(
3996 total_ms = %total.as_millis(),
3997 exit_code = %exit_code,
3998 "TIMING: compilation completed"
3999 );
4000
4001 CompilationMetrics {
4002 project_id: self.project_id,
4003 worker_id: self.worker_id,
4004 timestamp: chrono::Utc::now(),
4005 timing: CompilationTimingBreakdown {
4006 rsync_up: self.rsync_up.unwrap_or_default(),
4007 remote_build: self.remote_build.unwrap_or_default(),
4008 rsync_down: self.rsync_down.unwrap_or_default(),
4009 total,
4010 },
4011 local_build_time: None,
4012 speedup: None,
4013 files_synced: files,
4014 bytes_transferred: bytes,
4015 exit_code,
4016 success: exit_code == 0,
4017 }
4018 }
4019}
4020
4021#[derive(Debug)]
4023pub struct MetricsAggregator {
4024 history: VecDeque<CompilationMetrics>,
4025 max_history: usize,
4026}
4027
4028impl MetricsAggregator {
4029 pub fn new(max_history: usize) -> Self {
4031 Self {
4032 history: VecDeque::with_capacity(max_history),
4033 max_history,
4034 }
4035 }
4036
4037 pub fn record(&mut self, metrics: CompilationMetrics) {
4039 if self.history.len() >= self.max_history {
4040 self.history.pop_front();
4041 }
4042 self.history.push_back(metrics);
4043 }
4044
4045 pub fn average_speedup(&self) -> Option<f64> {
4047 let speedups: Vec<f64> = self.history.iter().filter_map(|m| m.speedup).collect();
4048
4049 if speedups.is_empty() {
4050 None
4051 } else {
4052 Some(speedups.iter().sum::<f64>() / speedups.len() as f64)
4053 }
4054 }
4055
4056 pub fn p50_total_time(&self) -> Option<Duration> {
4058 self.percentile_total_time(0.50)
4059 }
4060
4061 pub fn p95_total_time(&self) -> Option<Duration> {
4063 self.percentile_total_time(0.95)
4064 }
4065
4066 pub fn p99_total_time(&self) -> Option<Duration> {
4068 self.percentile_total_time(0.99)
4069 }
4070
4071 fn percentile_total_time(&self, percentile: f64) -> Option<Duration> {
4076 let mut times: Vec<_> = self.history.iter().map(|m| m.timing.total).collect();
4077
4078 if times.is_empty() {
4079 return None;
4080 }
4081
4082 times.sort();
4083 let idx = (((times.len() - 1) as f64 * percentile).round() as usize).min(times.len() - 1);
4087 Some(times[idx])
4088 }
4089
4090 pub fn success_rate(&self) -> f64 {
4092 if self.history.is_empty() {
4093 return 100.0;
4094 }
4095 let successes = self.history.iter().filter(|m| m.success).count();
4096 (successes as f64 / self.history.len() as f64) * 100.0
4097 }
4098
4099 pub fn count(&self) -> usize {
4101 self.history.len()
4102 }
4103
4104 pub fn metrics(&self) -> &VecDeque<CompilationMetrics> {
4106 &self.history
4107 }
4108
4109 pub fn clear(&mut self) {
4111 self.history.clear();
4112 }
4113}
4114
4115impl Default for MetricsAggregator {
4116 fn default() -> Self {
4117 Self::new(1000)
4118 }
4119}
4120
4121#[cfg(test)]
4122mod tests {
4123 use super::*;
4124 use crate::test_guard;
4125
4126 #[test]
4127 fn test_declared_os_reads_reserved_tag() {
4128 assert_eq!(
4129 declared_os(&["rust".to_string(), "os:windows".to_string()]),
4130 Some("windows".to_string())
4131 );
4132 assert_eq!(
4133 declared_os(&["os:WINDOWS".to_string()]),
4134 Some("windows".to_string())
4135 );
4136 }
4137
4138 #[test]
4139 fn test_declared_os_absent_for_ordinary_tags() {
4140 assert_eq!(declared_os(&[]), None);
4143 assert_eq!(
4144 declared_os(&["rust".to_string(), "bun".to_string(), "go".to_string()]),
4145 None
4146 );
4147 assert_eq!(declared_os(&["os:".to_string()]), None);
4149 assert_eq!(declared_os(&["os: ".to_string()]), None);
4150 }
4151
4152 #[test]
4153 fn test_os_tag_roundtrips_through_declared_os() {
4154 for os in ["windows", "Darwin", " linux "] {
4155 let tag = os_tag(os);
4156 assert_eq!(
4157 declared_os(std::slice::from_ref(&tag)).as_deref(),
4158 Some(os.trim().to_ascii_lowercase().as_str())
4159 );
4160 }
4161 }
4162
4163 #[test]
4168 fn test_no_inline_secret_fields() {
4169 let endpoint = DoctorWebhookEndpoint {
4170 name: "ops".to_string(),
4171 url: "https://example.com/hook".to_string(),
4172 format: DoctorWebhookFormat::Pagerduty,
4173 on_transitions: vec!["any_to_failing".to_string()],
4174 retry_max: 3,
4175 retry_backoff_ms: 200,
4176 timeout_ms: 5000,
4177 enabled: true,
4178 routing_key_env: Some("RCH_PAGERDUTY_ROUTING_KEY".to_string()),
4179 bearer_token_env: Some("RCH_WEBHOOK_BEARER".to_string()),
4180 signing_secret_env: Some("RCH_WEBHOOK_HMAC".to_string()),
4181 };
4182 let value = serde_json::to_value(&endpoint).unwrap();
4185 let obj = value.as_object().expect("endpoint serializes to an object");
4186 for forbidden in ["secret", "routing_key", "bearer_token", "password", "token"] {
4187 assert!(
4188 !obj.contains_key(forbidden),
4189 "endpoint config must not expose an inline `{forbidden}` field"
4190 );
4191 }
4192 assert!(obj.contains_key("routing_key_env"));
4193 assert!(obj.contains_key("bearer_token_env"));
4194 assert!(obj.contains_key("signing_secret_env"));
4195 }
4196
4197 #[test]
4198 fn test_webhook_format_toml_spelling() {
4199 #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
4204 struct FormatWrap {
4205 format: DoctorWebhookFormat,
4206 }
4207 let rendered = toml::to_string(&FormatWrap {
4208 format: DoctorWebhookFormat::GenericJson,
4209 })
4210 .unwrap();
4211 assert!(
4212 rendered.contains("format = \"generic_json\""),
4213 "unexpected TOML: {rendered}"
4214 );
4215 let slack: FormatWrap = toml::from_str("format = \"slack\"").unwrap();
4216 assert_eq!(slack.format, DoctorWebhookFormat::Slack);
4217 let pd: FormatWrap = toml::from_str("format = \"pagerduty\"").unwrap();
4218 assert_eq!(pd.format, DoctorWebhookFormat::Pagerduty);
4219 }
4220
4221 #[test]
4222 fn test_doctor_webhooks_defaults() {
4223 let cfg = DoctorWebhooksConfig::default();
4224 assert!(cfg.endpoints.is_empty());
4225 assert_eq!(cfg.max_inflight, 16);
4226 let rch: RchConfig = toml::from_str("").unwrap();
4229 assert!(rch.doctor.webhooks.endpoints.is_empty());
4230 }
4231
4232 #[test]
4233 fn test_doctor_webhook_endpoint_parses_from_toml() {
4234 let toml_src = r#"
4235[doctor.webhooks]
4236max_inflight = 8
4237
4238[[doctor.webhooks.endpoints]]
4239name = "ops-slack"
4240url = "https://hooks.slack.com/services/T0/B0/x"
4241format = "slack"
4242on_transitions = ["healthy_to_degraded", "any_to_failing"]
4243retry_max = 2
4244"#;
4245 let cfg: RchConfig = toml::from_str(toml_src).unwrap();
4246 assert_eq!(cfg.doctor.webhooks.max_inflight, 8);
4247 assert_eq!(cfg.doctor.webhooks.endpoints.len(), 1);
4248 let ep = &cfg.doctor.webhooks.endpoints[0];
4249 assert_eq!(ep.name, "ops-slack");
4250 assert_eq!(ep.format, DoctorWebhookFormat::Slack);
4251 assert_eq!(ep.on_transitions.len(), 2);
4252 assert_eq!(ep.retry_max, 2);
4253 assert_eq!(ep.retry_backoff_ms, 200);
4255 assert_eq!(ep.timeout_ms, 5000);
4256 assert!(ep.enabled);
4257 }
4258
4259 fn assert_golden_json(
4260 actual: serde_json::Value,
4261 expected: &serde_json::Value,
4262 fixture_path: &str,
4263 ) {
4264 if &actual == expected {
4265 return;
4266 }
4267
4268 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
4269 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
4270 assert_eq!(
4271 &actual,
4272 expected,
4273 "golden mismatch in {fixture_path}\n\
4274 expected_blake3={}\n\
4275 actual_blake3={}\n\
4276 bless path: review the semantic diff, update the fixture expected_* field, \
4277 and record both hashes in the Beads closeout.\n\
4278 expected:\n{expected_json}\n\
4279 actual:\n{actual_json}",
4280 blake3::hash(expected_json.as_bytes()),
4281 blake3::hash(actual_json.as_bytes())
4282 );
4283 }
4284
4285 #[test]
4286 fn test_circuit_state_default() {
4287 let _guard = test_guard!();
4288 assert_eq!(CircuitState::default(), CircuitState::Closed);
4289 }
4290
4291 #[test]
4292 fn test_circuit_config_defaults() {
4293 let _guard = test_guard!();
4294 let config = CircuitBreakerConfig::default();
4295 assert_eq!(config.failure_threshold, 3);
4296 assert_eq!(config.success_threshold, 2);
4297 assert_eq!(config.error_rate_threshold, 0.5);
4298 assert_eq!(config.window_secs, 60);
4299 assert_eq!(config.open_cooldown_secs, 30);
4300 assert_eq!(config.half_open_max_probes, 1);
4301 }
4302
4303 #[test]
4304 fn test_rch_config_has_circuit_defaults() {
4305 let _guard = test_guard!();
4306 let config = RchConfig::default();
4307 assert_eq!(config.circuit.failure_threshold, 3);
4308 }
4309
4310 #[test]
4311 fn test_circuit_config_serde_roundtrip() {
4312 let _guard = test_guard!();
4313 let config = CircuitBreakerConfig {
4314 failure_threshold: 5,
4315 success_threshold: 3,
4316 error_rate_threshold: 0.75,
4317 window_secs: 120,
4318 open_cooldown_secs: 45,
4319 half_open_max_probes: 2,
4320 command_failure_threshold: 4,
4321 open_cooldown_max_secs: 900,
4322 };
4323 let json = serde_json::to_string(&config).unwrap();
4324 let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
4325 assert_eq!(parsed.command_failure_threshold, 4);
4326 assert_eq!(parsed.open_cooldown_max_secs, 900);
4327 assert_eq!(parsed.failure_threshold, 5);
4328 assert_eq!(parsed.success_threshold, 3);
4329 assert_eq!(parsed.error_rate_threshold, 0.75);
4330 assert_eq!(parsed.window_secs, 120);
4331 assert_eq!(parsed.open_cooldown_secs, 45);
4332 assert_eq!(parsed.half_open_max_probes, 2);
4333 }
4334
4335 #[test]
4340 fn test_worker_capabilities_default() {
4341 let _guard = test_guard!();
4342 let caps = WorkerCapabilities::default();
4343 assert!(caps.rustc_version.is_none());
4344 assert!(caps.rustup_toolchains.is_empty());
4345 assert!(caps.rustup_components.is_empty());
4346 assert!(caps.bun_version.is_none());
4347 assert!(caps.node_version.is_none());
4348 assert!(caps.npm_version.is_none());
4349 assert!(caps.projects_root_ok.is_none());
4350 assert!(caps.projects_root_issue.is_none());
4351 assert!(caps.projects_root_checked_at_unix_ms.is_none());
4352 }
4353
4354 #[test]
4355 fn test_worker_capabilities_new() {
4356 let _guard = test_guard!();
4357 let caps = WorkerCapabilities::new();
4358 assert!(caps.rustc_version.is_none());
4359 assert!(!caps.has_rust());
4360 assert!(!caps.has_bun());
4361 assert!(!caps.has_node());
4362 assert!(caps.is_topology_healthy().is_none());
4363 }
4364
4365 #[test]
4366 fn test_worker_capabilities_has_rust() {
4367 let _guard = test_guard!();
4368 let mut caps = WorkerCapabilities::new();
4369 assert!(!caps.has_rust());
4370
4371 caps.rustc_version = Some("rustc 1.76.0 (07dca489a 2024-02-04)".to_string());
4372 assert!(caps.has_rust());
4373 }
4374
4375 #[test]
4376 fn test_worker_capabilities_components_are_toolchain_scoped_and_exact() {
4377 let _guard = test_guard!();
4378 let caps = WorkerCapabilities {
4379 rustup_components: vec![
4380 "stable-x86_64-unknown-linux-gnu:clippy".to_string(),
4381 "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
4382 "beta-x86_64-unknown-linux-gnu:clippy-preview".to_string(),
4383 ],
4384 ..Default::default()
4385 };
4386
4387 assert!(caps.has_rustup_component("stable", "clippy"));
4388 assert!(caps.has_rustup_component("nightly-2026-07-05", "rustfmt"));
4389 assert!(!caps.has_rustup_component("nightly-2026-07-05", "clippy"));
4390 assert!(!caps.has_rustup_component("beta", "clippy"));
4391 assert!(!caps.has_rustup_component("nightly-2026-07-0", "rustfmt"));
4392 }
4393
4394 #[test]
4395 fn test_worker_capabilities_has_bun() {
4396 let _guard = test_guard!();
4397 let mut caps = WorkerCapabilities::new();
4398 assert!(!caps.has_bun());
4399
4400 caps.bun_version = Some("1.0.25".to_string());
4401 assert!(caps.has_bun());
4402 }
4403
4404 #[test]
4405 fn test_worker_capabilities_has_nix() {
4406 let _guard = test_guard!();
4407 let mut caps = WorkerCapabilities::new();
4408 assert!(!caps.has_nix());
4409
4410 caps.nix_version = Some("nix (Nix) 2.24.9".to_string());
4411 assert!(caps.has_nix());
4412 }
4413
4414 #[test]
4415 fn test_worker_capabilities_has_zig_requires_both_halves() {
4416 let _guard = test_guard!();
4417 let mut caps = WorkerCapabilities::new();
4418 assert!(!caps.has_zig());
4419
4420 caps.zig_version = Some("0.14.1".to_string());
4423 assert!(!caps.has_zig());
4424
4425 caps.zig_version = None;
4427 caps.cargo_zigbuild_version = Some("cargo-zigbuild 0.23.0".to_string());
4428 assert!(!caps.has_zig());
4429
4430 caps.zig_version = Some("0.14.1".to_string());
4431 assert!(caps.has_zig());
4432 }
4433
4434 #[test]
4435 fn test_nix_build_uses_generous_timeout() {
4436 let _guard = test_guard!();
4437 let cfg = CompilationConfig::default();
4438 assert_eq!(
4440 cfg.timeout_for_kind(Some(CompilationKind::NixBuild)),
4441 std::time::Duration::from_secs(cfg.test_timeout_sec)
4442 );
4443 }
4444
4445 #[test]
4446 fn test_job_uses_build_timeout_bucket() {
4447 let _guard = test_guard!();
4448 let cfg = CompilationConfig::default();
4451 assert_eq!(
4452 cfg.timeout_for_kind(Some(CompilationKind::Job)),
4453 std::time::Duration::from_secs(cfg.build_timeout_sec)
4454 );
4455 }
4456
4457 #[test]
4458 fn test_layer0_disabled_by_default_and_env_gated() {
4459 let _guard = test_guard!();
4460 let cfg = Layer0Config::default();
4461 assert!(!cfg.enabled);
4462 let knobs_on = Layer0Config {
4464 enabled: false,
4465 release_lto_thin: true,
4466 release_codegen_units_1: true,
4467 };
4468 assert!(cfg.active_env().is_empty());
4469 assert!(knobs_on.active_env().is_empty());
4470 }
4471
4472 #[test]
4473 fn test_layer0_knobs_emit_stable_env_pairs() {
4474 let _guard = test_guard!();
4475 let lto_only = Layer0Config {
4476 enabled: true,
4477 release_lto_thin: true,
4478 release_codegen_units_1: false,
4479 };
4480 assert_eq!(
4481 lto_only.active_env(),
4482 vec![("CARGO_PROFILE_RELEASE_LTO", "thin")]
4483 );
4484 let both = Layer0Config {
4485 enabled: true,
4486 release_lto_thin: true,
4487 release_codegen_units_1: true,
4488 };
4489 assert_eq!(
4490 both.active_env(),
4491 vec![
4492 ("CARGO_PROFILE_RELEASE_LTO", "thin"),
4493 ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
4494 ],
4495 "emission order must stay stable for deterministic baselines"
4496 );
4497 }
4498
4499 #[test]
4500 fn test_layer0_pack_version_is_recorded() {
4501 let _guard = test_guard!();
4502 assert!(!LAYER0_PACK_VERSION.is_empty());
4504 }
4505 #[test]
4506 fn test_default_allowlist_includes_nix() {
4507 let _guard = test_guard!();
4508 let cfg = ExecutionConfig::default();
4509 assert!(cfg.is_allowed("nix"));
4510 }
4511
4512 #[test]
4513 fn test_worker_capabilities_has_node() {
4514 let _guard = test_guard!();
4515 let mut caps = WorkerCapabilities::new();
4516 assert!(!caps.has_node());
4517
4518 caps.node_version = Some("v20.11.0".to_string());
4519 assert!(caps.has_node());
4520 }
4521
4522 #[test]
4523 fn test_worker_capabilities_multiple_runtimes() {
4524 let _guard = test_guard!();
4525 let caps = WorkerCapabilities {
4526 rustc_version: Some("rustc 1.76.0".to_string()),
4527 bun_version: Some("1.0.25".to_string()),
4528 node_version: Some("v20.11.0".to_string()),
4529 npm_version: Some("10.2.4".to_string()),
4530 ..Default::default()
4531 };
4532
4533 assert!(caps.has_rust());
4534 assert!(caps.has_bun());
4535 assert!(caps.has_node());
4536 }
4537
4538 #[test]
4539 fn test_worker_capabilities_serialization_empty() {
4540 let _guard = test_guard!();
4541 let caps = WorkerCapabilities::new();
4542 let json = serde_json::to_string(&caps).unwrap();
4543 assert_eq!(json, "{}");
4545
4546 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4548 assert!(!parsed.has_rust());
4549 assert!(!parsed.has_bun());
4550 assert!(!parsed.has_node());
4551 }
4552
4553 #[test]
4554 fn test_worker_capabilities_serialization_with_versions() {
4555 let _guard = test_guard!();
4556 let caps = WorkerCapabilities {
4557 rustc_version: Some("rustc 1.76.0-nightly".to_string()),
4558 bun_version: Some("1.0.25".to_string()),
4559 node_version: None,
4560 npm_version: None,
4561 ..Default::default()
4562 };
4563
4564 let json = serde_json::to_string(&caps).unwrap();
4565 assert!(json.contains("rustc_version"));
4566 assert!(json.contains("rustc 1.76.0-nightly"));
4567 assert!(json.contains("bun_version"));
4568 assert!(json.contains("1.0.25"));
4569 assert!(!json.contains("node_version"));
4571 assert!(!json.contains("npm_version"));
4572
4573 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4574 assert!(parsed.has_rust());
4575 assert!(parsed.has_bun());
4576 assert!(!parsed.has_node());
4577 }
4578
4579 #[test]
4580 fn test_worker_capabilities_deserialization_partial() {
4581 let _guard = test_guard!();
4582 let json = r#"{"bun_version": "1.0.0"}"#;
4584 let caps: WorkerCapabilities = serde_json::from_str(json).unwrap();
4585
4586 assert!(!caps.has_rust());
4587 assert!(caps.has_bun());
4588 assert!(!caps.has_node());
4589 assert_eq!(caps.bun_version, Some("1.0.0".to_string()));
4590 }
4591
4592 #[test]
4593 fn test_worker_capabilities_clone() {
4594 let _guard = test_guard!();
4595 let caps = WorkerCapabilities {
4596 rustc_version: Some("1.76.0".to_string()),
4597 bun_version: None,
4598 node_version: Some("v20".to_string()),
4599 npm_version: None,
4600 projects_root_ok: Some(false),
4601 projects_root_issue: Some("alias_missing".to_string()),
4602 projects_root_checked_at_unix_ms: Some(123),
4603 ..Default::default()
4604 };
4605
4606 let cloned = caps.clone();
4607 assert_eq!(cloned.rustc_version, caps.rustc_version);
4608 assert_eq!(cloned.bun_version, caps.bun_version);
4609 assert_eq!(cloned.node_version, caps.node_version);
4610 assert_eq!(cloned.npm_version, caps.npm_version);
4611 assert_eq!(cloned.projects_root_ok, caps.projects_root_ok);
4612 assert_eq!(cloned.projects_root_issue, caps.projects_root_issue);
4613 assert_eq!(
4614 cloned.projects_root_checked_at_unix_ms,
4615 caps.projects_root_checked_at_unix_ms
4616 );
4617 }
4618
4619 #[test]
4620 fn test_worker_capabilities_topology_health_helper() {
4621 let _guard = test_guard!();
4622 let mut caps = WorkerCapabilities::new();
4623 assert_eq!(caps.is_topology_healthy(), None);
4624
4625 caps.projects_root_ok = Some(true);
4626 assert_eq!(caps.is_topology_healthy(), Some(true));
4627
4628 caps.projects_root_ok = Some(false);
4629 assert_eq!(caps.is_topology_healthy(), Some(false));
4630 }
4631
4632 #[test]
4633 fn test_selection_reason_serialization() {
4634 let _guard = test_guard!();
4635 assert_eq!(
4637 serde_json::to_string(&SelectionReason::Success).unwrap(),
4638 "\"success\""
4639 );
4640 assert_eq!(
4641 serde_json::to_string(&SelectionReason::NoWorkersConfigured).unwrap(),
4642 "\"no_workers_configured\""
4643 );
4644 assert_eq!(
4645 serde_json::to_string(&SelectionReason::AllWorkersUnreachable).unwrap(),
4646 "\"all_workers_unreachable\""
4647 );
4648 assert_eq!(
4649 serde_json::to_string(&SelectionReason::AllCircuitsOpen).unwrap(),
4650 "\"all_circuits_open\""
4651 );
4652 assert_eq!(
4653 serde_json::to_string(&SelectionReason::AllWorkersBusy).unwrap(),
4654 "\"all_workers_busy\""
4655 );
4656 assert_eq!(
4657 serde_json::to_string(&SelectionReason::NoWorkersPassedHealth).unwrap(),
4658 "\"no_workers_passed_health\""
4659 );
4660 assert_eq!(
4661 serde_json::to_string(&SelectionReason::AllWorkersFailedPreflight).unwrap(),
4662 "\"all_workers_failed_preflight\""
4663 );
4664 assert_eq!(
4665 serde_json::to_string(&SelectionReason::AllWorkersFailedConvergence).unwrap(),
4666 "\"all_workers_failed_convergence\""
4667 );
4668 assert_eq!(
4669 serde_json::to_string(&SelectionReason::NoAdmissibleWorkers(
4670 "critical_pressure=1,insufficient_slots=1".to_string()
4671 ))
4672 .unwrap(),
4673 "{\"no_admissible_workers\":\"critical_pressure=1,insufficient_slots=1\"}"
4674 );
4675 assert_eq!(
4676 serde_json::to_string(&SelectionReason::NoMatchingWorkers).unwrap(),
4677 "\"no_matching_workers\""
4678 );
4679 }
4680
4681 #[test]
4682 fn test_selection_reason_with_error() {
4683 let _guard = test_guard!();
4684 let reason = SelectionReason::SelectionError("test error".to_string());
4685 let json = serde_json::to_string(&reason).unwrap();
4686 assert!(json.contains("selection_error"));
4687 assert!(json.contains("test error"));
4688 }
4689
4690 #[test]
4691 fn test_selection_reason_deserialization() {
4692 let _guard = test_guard!();
4693 assert_eq!(
4694 serde_json::from_str::<SelectionReason>("\"success\"").unwrap(),
4695 SelectionReason::Success
4696 );
4697 assert_eq!(
4698 serde_json::from_str::<SelectionReason>("\"all_workers_busy\"").unwrap(),
4699 SelectionReason::AllWorkersBusy
4700 );
4701 assert_eq!(
4702 serde_json::from_str::<SelectionReason>("\"no_workers_passed_health\"").unwrap(),
4703 SelectionReason::NoWorkersPassedHealth
4704 );
4705 assert_eq!(
4706 serde_json::from_str::<SelectionReason>("\"all_workers_failed_preflight\"").unwrap(),
4707 SelectionReason::AllWorkersFailedPreflight
4708 );
4709 assert_eq!(
4710 serde_json::from_str::<SelectionReason>("\"all_workers_failed_convergence\"").unwrap(),
4711 SelectionReason::AllWorkersFailedConvergence
4712 );
4713 assert_eq!(
4714 serde_json::from_str::<SelectionReason>(
4715 "{\"no_admissible_workers\":\"critical_pressure=1\"}"
4716 )
4717 .unwrap(),
4718 SelectionReason::NoAdmissibleWorkers("critical_pressure=1".to_string())
4719 );
4720 }
4721
4722 #[test]
4723 fn test_selection_reason_display() {
4724 let _guard = test_guard!();
4725 assert_eq!(
4726 SelectionReason::Success.to_string(),
4727 "worker assigned successfully"
4728 );
4729 assert_eq!(
4730 SelectionReason::NoWorkersConfigured.to_string(),
4731 "no workers configured"
4732 );
4733 assert_eq!(
4734 SelectionReason::AllWorkersUnreachable.to_string(),
4735 "all workers unreachable"
4736 );
4737 assert_eq!(
4738 SelectionReason::AllWorkersBusy.to_string(),
4739 "all workers at capacity"
4740 );
4741 assert_eq!(
4742 SelectionReason::NoWorkersPassedHealth.to_string(),
4743 "no workers passed health thresholds"
4744 );
4745 assert_eq!(
4746 SelectionReason::AllWorkersFailedPreflight.to_string(),
4747 "all workers failed preflight checks"
4748 );
4749 assert_eq!(
4750 SelectionReason::AllWorkersFailedConvergence.to_string(),
4751 "all workers failed repo convergence checks"
4752 );
4753 assert_eq!(
4754 SelectionReason::NoAdmissibleWorkers(
4755 "critical_pressure=1,insufficient_slots=1".to_string()
4756 )
4757 .to_string(),
4758 "no admissible workers: critical_pressure=1,insufficient_slots=1"
4759 );
4760 assert_eq!(
4761 SelectionReason::SelectionError("oops".to_string()).to_string(),
4762 "selection error: oops"
4763 );
4764 }
4765
4766 #[test]
4767 fn test_selection_response_with_worker() {
4768 let _guard = test_guard!();
4769 let response = SelectionResponse {
4770 worker: Some(SelectedWorker {
4771 id: WorkerId::new("test"),
4772 host: "localhost".to_string(),
4773 user: "user".to_string(),
4774 identity_file: "~/.ssh/id_rsa".to_string(),
4775 slots_available: 8,
4776 speed_score: 75.0,
4777 declared_os: None,
4778 }),
4779 reason: SelectionReason::Success,
4780 build_id: None,
4781 diagnostics: None,
4782 };
4783
4784 let json = serde_json::to_string(&response).unwrap();
4785 assert!(json.contains("\"reason\":\"success\""));
4786 assert!(json.contains("\"id\":\"test\""));
4787 }
4788
4789 #[test]
4790 fn test_selection_response_without_worker() {
4791 let _guard = test_guard!();
4792 let response = SelectionResponse {
4793 worker: None,
4794 reason: SelectionReason::AllWorkersBusy,
4795 build_id: None,
4796 diagnostics: None,
4797 };
4798
4799 let json = serde_json::to_string(&response).unwrap();
4800 assert!(json.contains("\"worker\":null"));
4801 assert!(json.contains("\"reason\":\"all_workers_busy\""));
4802 }
4803
4804 #[test]
4805 fn golden_selection_response_with_no_workers_passed_health_diagnostics() {
4806 let _guard = test_guard!();
4807 const FIXTURE_PATH: &str =
4808 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json";
4809 let fixture: serde_json::Value = serde_json::from_str(include_str!(
4810 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json"
4811 ))
4812 .expect("selection diagnostics golden fixture parses");
4813 assert_eq!(
4814 fixture["schema_version"],
4815 "rch.golden.selection_diagnostics.v1"
4816 );
4817
4818 let response = SelectionResponse {
4819 worker: None,
4820 reason: SelectionReason::NoWorkersPassedHealth,
4821 build_id: None,
4822 diagnostics: Some(SelectionDiagnostics {
4823 required_runtime: RequiredRuntime::Rust,
4824 estimated_cores: 2,
4825 min_success_rate: 0.8,
4826 fallback_min_success_rate: 0.5,
4827 active_project_exclusion_count: 0,
4828 workers: vec![WorkerSelectionDiagnostic {
4829 worker_id: WorkerId::new("fixture-worker"),
4830 status: "healthy".to_string(),
4831 circuit_state: "closed".to_string(),
4832 pressure_state: "healthy".to_string(),
4833 pressure_reason_code: "pressure.ok".to_string(),
4834 success_rate: Some(0.2),
4835 min_success_rate: 0.8,
4836 fallback_min_success_rate: 0.5,
4837 required_runtime: RequiredRuntime::Rust,
4838 runtime_available: true,
4839 available_slots: 8,
4840 total_slots: 8,
4841 estimated_cores: 2,
4842 active_project_excluded: false,
4843 final_decision: WorkerSelectionDiagnosticDecision::Deny,
4844 final_reason: "success_rate 0.20 < fallback 0.50".to_string(),
4845 reason_codes: vec![
4846 "health.below_min_success_rate".to_string(),
4847 "health.below_fallback_min_success_rate".to_string(),
4848 ],
4849 }],
4850 }),
4851 };
4852
4853 assert_golden_json(
4854 serde_json::to_value(&response).expect("response serializes"),
4855 &fixture["expected_response"],
4856 FIXTURE_PATH,
4857 );
4858 }
4859
4860 #[test]
4861 fn test_selection_response_roundtrip() {
4862 let _guard = test_guard!();
4863 let original = SelectionResponse {
4864 worker: Some(SelectedWorker {
4865 id: WorkerId::new("worker-1"),
4866 host: "192.168.1.100".to_string(),
4867 user: "ubuntu".to_string(),
4868 identity_file: "/path/to/key".to_string(),
4869 slots_available: 16,
4870 speed_score: 90.5,
4871 declared_os: None,
4872 }),
4873 reason: SelectionReason::Success,
4874 build_id: None,
4875 diagnostics: None,
4876 };
4877
4878 let json = serde_json::to_string(&original).unwrap();
4879 let parsed: SelectionResponse = serde_json::from_str(&json).unwrap();
4880
4881 assert!(parsed.worker.is_some());
4882 let worker = parsed.worker.unwrap();
4883 assert_eq!(worker.id.as_str(), "worker-1");
4884 assert_eq!(worker.host, "192.168.1.100");
4885 assert_eq!(worker.slots_available, 16);
4886 assert_eq!(parsed.reason, SelectionReason::Success);
4887 }
4888
4889 #[test]
4890 fn test_build_heartbeat_phase_roundtrip() {
4891 let _guard = test_guard!();
4892 let json = serde_json::to_string(&BuildHeartbeatPhase::SyncUp).unwrap();
4893 assert_eq!(json, "\"sync_up\"");
4894 let parsed: BuildHeartbeatPhase = serde_json::from_str(&json).unwrap();
4895 assert_eq!(parsed, BuildHeartbeatPhase::SyncUp);
4896 }
4897
4898 #[test]
4899 fn test_build_heartbeat_request_roundtrip() {
4900 let _guard = test_guard!();
4901 let request = BuildHeartbeatRequest {
4902 build_id: 42,
4903 worker_id: WorkerId::new("worker-a"),
4904 hook_pid: Some(12345),
4905 local_wrapper_id: Some("wrapper-abc123".to_string()),
4906 remote_pgid_file: Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string()),
4907 phase: BuildHeartbeatPhase::Execute,
4908 detail: Some("Compiling crates".to_string()),
4909 progress_counter: Some(7),
4910 progress_percent: Some(42.5),
4911 };
4912
4913 let json = serde_json::to_string(&request).unwrap();
4914 let parsed: BuildHeartbeatRequest = serde_json::from_str(&json).unwrap();
4915 assert_eq!(parsed.build_id, 42);
4916 assert_eq!(parsed.worker_id.as_str(), "worker-a");
4917 assert_eq!(
4918 parsed.remote_pgid_file,
4919 Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string())
4920 );
4921 assert_eq!(parsed.phase, BuildHeartbeatPhase::Execute);
4922 assert_eq!(parsed.progress_counter, Some(7));
4923 assert_eq!(parsed.progress_percent, Some(42.5));
4924 }
4925
4926 #[test]
4929 fn test_circuit_stats_new() {
4930 let _guard = test_guard!();
4931 let stats = CircuitStats::new();
4932 assert_eq!(stats.state(), CircuitState::Closed);
4933 assert_eq!(stats.consecutive_failures(), 0);
4934 assert_eq!(stats.error_rate(), 0.0);
4935 assert!(stats.opened_at().is_none());
4936 }
4937
4938 #[test]
4939 fn test_circuit_stats_record_success() {
4940 let _guard = test_guard!();
4941 let mut stats = CircuitStats::new();
4942 stats.record_success();
4943 stats.record_success();
4944 assert_eq!(stats.error_rate(), 0.0);
4945 assert_eq!(stats.consecutive_failures(), 0);
4946 }
4947
4948 #[test]
4949 fn test_circuit_stats_record_failure() {
4950 let _guard = test_guard!();
4951 let mut stats = CircuitStats::new();
4952 stats.record_failure();
4953 stats.record_failure();
4954 assert_eq!(stats.consecutive_failures(), 2);
4955 assert_eq!(stats.error_rate(), 1.0); }
4957
4958 #[test]
4959 fn test_circuit_stats_error_rate() {
4960 let _guard = test_guard!();
4961 let mut stats = CircuitStats::new();
4962 stats.record_success();
4963 stats.record_success();
4964 stats.record_failure();
4965 assert!((stats.error_rate() - 0.333).abs() < 0.01);
4967 }
4968
4969 #[test]
4970 fn test_circuit_stats_should_open_consecutive_failures() {
4971 let _guard = test_guard!();
4972 let mut stats = CircuitStats::new();
4973 let config = CircuitBreakerConfig::default(); stats.record_failure();
4976 assert!(!stats.should_open(&config));
4977
4978 stats.record_failure();
4979 assert!(!stats.should_open(&config));
4980
4981 stats.record_failure();
4982 assert!(stats.should_open(&config)); }
4984
4985 #[test]
4986 fn test_circuit_stats_should_open_error_rate() {
4987 let _guard = test_guard!();
4988 let mut stats = CircuitStats::new();
4989 let config = CircuitBreakerConfig {
4990 error_rate_threshold: 0.5,
4991 ..Default::default()
4992 };
4993
4994 stats.record_success();
4996 stats.record_success();
4997 stats.record_failure();
4998 stats.record_failure();
4999 assert!(!stats.should_open(&config)); stats.record_failure(); assert!(stats.should_open(&config));
5003 }
5004
5005 #[test]
5006 fn test_circuit_close_clears_recent_results_no_flap() {
5007 let _guard = test_guard!();
5008 let mut stats = CircuitStats::new();
5012 let config = CircuitBreakerConfig {
5013 error_rate_threshold: 0.5,
5014 ..Default::default()
5015 };
5016 for _ in 0..6 {
5017 stats.record_failure();
5018 }
5019 assert!(stats.should_open(&config));
5020 stats.open();
5021 stats.half_open();
5023 stats.record_success();
5024 stats.record_success();
5025 stats.close();
5026 assert_eq!(stats.error_rate(), 0.0);
5028 assert!(!stats.should_open(&config));
5029 }
5030
5031 #[test]
5032 fn test_apply_health_outcome_opens_on_failure_threshold() {
5033 let _guard = test_guard!();
5034 let config = CircuitBreakerConfig {
5036 failure_threshold: 3,
5037 open_cooldown_secs: 300,
5038 ..Default::default()
5039 };
5040 let mut stats = CircuitStats::new();
5041 assert_eq!(
5042 stats.apply_health_outcome(false, false, &config),
5043 CircuitState::Closed
5044 );
5045 assert_eq!(
5046 stats.apply_health_outcome(false, false, &config),
5047 CircuitState::Closed
5048 );
5049 assert_eq!(
5050 stats.apply_health_outcome(false, false, &config),
5051 CircuitState::Open
5052 );
5053 }
5054
5055 #[test]
5056 fn test_apply_health_outcome_half_open_two_probe_recovery() {
5057 let _guard = test_guard!();
5058 let config = CircuitBreakerConfig {
5064 failure_threshold: 3,
5065 success_threshold: 2,
5066 open_cooldown_secs: 0,
5067 ..Default::default()
5068 };
5069 let mut stats = CircuitStats::new();
5070 for _ in 0..3 {
5071 stats.apply_health_outcome(false, false, &config);
5072 }
5073 assert_eq!(stats.state(), CircuitState::HalfOpen);
5075
5076 let after_first = stats.apply_health_outcome(true, false, &config);
5078 assert_eq!(after_first, CircuitState::HalfOpen);
5079
5080 let after_second = stats.apply_health_outcome(true, false, &config);
5082 assert_eq!(after_second, CircuitState::Closed);
5083 }
5084
5085 #[test]
5086 fn test_apply_health_outcome_transient_half_open_does_not_reopen() {
5087 let _guard = test_guard!();
5088 let config = CircuitBreakerConfig {
5091 failure_threshold: 3,
5092 success_threshold: 2,
5093 open_cooldown_secs: 0,
5094 ..Default::default()
5095 };
5096 let mut stats = CircuitStats::new();
5097 for _ in 0..3 {
5098 stats.apply_health_outcome(false, false, &config);
5099 }
5100 assert_eq!(stats.state(), CircuitState::HalfOpen);
5102
5103 assert_eq!(
5105 stats.apply_health_outcome(true, false, &config),
5106 CircuitState::HalfOpen
5107 );
5108
5109 let after_transient = stats.apply_health_outcome(false, true, &config);
5115 assert_eq!(after_transient, CircuitState::HalfOpen);
5116
5117 assert_eq!(
5119 stats.apply_health_outcome(true, false, &config),
5120 CircuitState::HalfOpen
5121 );
5122 assert_eq!(
5123 stats.apply_health_outcome(true, false, &config),
5124 CircuitState::Closed
5125 );
5126 }
5127
5128 #[test]
5129 fn test_apply_health_outcome_real_vs_transient_half_open_failure() {
5130 let _guard = test_guard!();
5131 let config = CircuitBreakerConfig {
5139 failure_threshold: 3,
5140 success_threshold: 2,
5141 open_cooldown_secs: 300,
5142 ..Default::default()
5143 };
5144
5145 let mut real = CircuitStats::new();
5147 real.open();
5148 real.half_open();
5149 assert_eq!(real.state(), CircuitState::HalfOpen);
5150 let after_real = real.apply_health_outcome(false, false, &config);
5151 assert_eq!(
5152 after_real,
5153 CircuitState::Open,
5154 "a real half-open failure must reopen the circuit"
5155 );
5156
5157 let mut transient = CircuitStats::new();
5159 transient.open();
5160 transient.half_open();
5161 assert_eq!(transient.state(), CircuitState::HalfOpen);
5162 let after_transient = transient.apply_health_outcome(false, true, &config);
5163 assert_eq!(
5164 after_transient,
5165 CircuitState::HalfOpen,
5166 "a transient half-open failure must NOT reopen the circuit"
5167 );
5168 }
5169
5170 #[test]
5175 fn test_command_failure_blocks_half_open_close_on_liveness_successes() {
5176 let _guard = test_guard!();
5177 let config = CircuitBreakerConfig {
5178 failure_threshold: 3,
5179 success_threshold: 2,
5180 open_cooldown_secs: 0,
5181 command_failure_threshold: 2,
5182 ..Default::default()
5183 };
5184 let mut stats = CircuitStats::new();
5185 stats.open();
5186 stats.half_open();
5187
5188 assert_eq!(
5190 stats.apply_command_outcome(false, &config),
5191 CircuitState::HalfOpen,
5192 "cooldown 0: reopen then immediately half-open again"
5193 );
5194 assert_eq!(stats.consecutive_command_failures(), 1);
5195
5196 for _ in 0..10 {
5198 assert_eq!(
5199 stats.apply_health_outcome(true, false, &config),
5200 CircuitState::HalfOpen,
5201 "liveness successes must not close the circuit while a command failure is outstanding"
5202 );
5203 }
5204
5205 assert_eq!(
5207 stats.apply_command_outcome(true, &config),
5208 CircuitState::Closed
5209 );
5210 assert_eq!(stats.consecutive_command_failures(), 0);
5211 assert_eq!(stats.reopen_streak(), 0);
5212 }
5213
5214 #[test]
5217 fn test_command_failures_open_closed_circuit_despite_liveness_successes() {
5218 let _guard = test_guard!();
5219 let config = CircuitBreakerConfig {
5220 failure_threshold: 3,
5221 command_failure_threshold: 2,
5222 open_cooldown_secs: 300,
5223 ..Default::default()
5224 };
5225 let mut stats = CircuitStats::new();
5226
5227 assert_eq!(
5228 stats.apply_command_outcome(false, &config),
5229 CircuitState::Closed
5230 );
5231 stats.apply_health_outcome(true, false, &config);
5233 stats.apply_health_outcome(true, false, &config);
5234 assert_eq!(stats.consecutive_failures(), 0);
5235 assert_eq!(
5237 stats.apply_command_outcome(false, &config),
5238 CircuitState::Open
5239 );
5240 assert_eq!(stats.reopen_streak(), 1);
5241 }
5242
5243 #[test]
5247 fn test_command_failure_in_half_open_always_reopens() {
5248 let _guard = test_guard!();
5249 let config = CircuitBreakerConfig {
5250 open_cooldown_secs: 300,
5251 ..Default::default()
5252 };
5253 let mut stats = CircuitStats::new();
5254 stats.open();
5255 stats.half_open();
5256 assert_eq!(
5257 stats.apply_command_outcome(false, &config),
5258 CircuitState::Open
5259 );
5260 assert_eq!(stats.reopen_streak(), 2);
5261 }
5262
5263 #[test]
5267 fn test_reopen_streak_backs_off_cooldown_until_cap() {
5268 let _guard = test_guard!();
5269 let config = CircuitBreakerConfig {
5270 open_cooldown_secs: 30,
5271 open_cooldown_max_secs: 100,
5272 ..Default::default()
5273 };
5274 assert_eq!(config.cooldown_ms_for_streak(0), 30_000);
5275 assert_eq!(config.cooldown_ms_for_streak(1), 30_000);
5276 assert_eq!(config.cooldown_ms_for_streak(2), 60_000);
5277 assert_eq!(config.cooldown_ms_for_streak(3), 100_000, "capped");
5278 assert_eq!(config.cooldown_ms_for_streak(40), 100_000, "no overflow");
5279
5280 let inverted = CircuitBreakerConfig {
5282 open_cooldown_secs: 30,
5283 open_cooldown_max_secs: 5,
5284 ..Default::default()
5285 };
5286 assert_eq!(inverted.cooldown_ms_for_streak(3), 30_000);
5287
5288 let mut stats = CircuitStats::new();
5291 stats.open();
5292 stats.half_open();
5293 stats.open();
5294 assert_eq!(stats.reopen_streak(), 2);
5295 let remaining = stats.recovery_remaining_secs(&config).unwrap();
5296 assert!(
5297 remaining > 30 && remaining <= 60,
5298 "second open should wait ~60s, got {remaining}"
5299 );
5300
5301 stats.close();
5303 assert_eq!(stats.reopen_streak(), 0);
5304 stats.open();
5305 assert!(stats.recovery_remaining_secs(&config).unwrap() <= 30);
5306 }
5307
5308 #[test]
5309 fn test_circuit_stats_success_resets_consecutive_failures() {
5310 let _guard = test_guard!();
5311 let mut stats = CircuitStats::new();
5312 stats.record_failure();
5313 stats.record_failure();
5314 assert_eq!(stats.consecutive_failures(), 2);
5315
5316 stats.record_success();
5317 assert_eq!(stats.consecutive_failures(), 0);
5318 }
5319
5320 #[test]
5321 fn test_circuit_stats_open_transition() {
5322 let _guard = test_guard!();
5323 let mut stats = CircuitStats::new();
5324 stats.open();
5325
5326 assert_eq!(stats.state(), CircuitState::Open);
5327 assert!(stats.opened_at().is_some());
5328 }
5329
5330 #[test]
5331 fn test_circuit_stats_half_open_transition() {
5332 let _guard = test_guard!();
5333 let mut stats = CircuitStats::new();
5334 stats.open();
5335 stats.half_open();
5336
5337 assert_eq!(stats.state(), CircuitState::HalfOpen);
5338 }
5339
5340 #[test]
5341 fn test_circuit_stats_close_transition() {
5342 let _guard = test_guard!();
5343 let mut stats = CircuitStats::new();
5344 stats.open();
5345 stats.half_open();
5346 stats.close();
5347
5348 assert_eq!(stats.state(), CircuitState::Closed);
5349 assert!(stats.opened_at().is_none());
5350 assert_eq!(stats.consecutive_failures(), 0);
5351 }
5352
5353 #[test]
5354 fn test_circuit_stats_should_close() {
5355 let _guard = test_guard!();
5356 let mut stats = CircuitStats::new();
5357 let config = CircuitBreakerConfig {
5358 success_threshold: 2,
5359 ..Default::default()
5360 };
5361
5362 stats.open();
5363 stats.half_open();
5364
5365 assert!(!stats.should_close(&config)); stats.record_success();
5368 assert!(!stats.should_close(&config)); stats.record_success();
5371 assert!(stats.should_close(&config)); }
5373
5374 #[test]
5375 fn test_circuit_stats_can_probe() {
5376 let _guard = test_guard!();
5377 let mut stats = CircuitStats::new();
5378 let config = CircuitBreakerConfig {
5379 half_open_max_probes: 1,
5380 ..Default::default()
5381 };
5382
5383 assert!(!stats.can_probe(&config));
5385
5386 stats.open();
5387 assert!(!stats.can_probe(&config));
5389
5390 stats.half_open();
5391 assert!(stats.can_probe(&config));
5393
5394 assert!(stats.start_probe(&config));
5396 assert!(!stats.can_probe(&config));
5398 assert!(!stats.start_probe(&config));
5399 }
5400
5401 #[test]
5402 fn test_circuit_stats_probe_completion() {
5403 let _guard = test_guard!();
5404 let mut stats = CircuitStats::new();
5405 let config = CircuitBreakerConfig {
5406 half_open_max_probes: 1,
5407 ..Default::default()
5408 };
5409
5410 stats.open();
5411 stats.half_open();
5412 stats.start_probe(&config);
5413
5414 stats.record_success();
5416
5417 assert!(stats.can_probe(&config));
5419 }
5420
5421 #[test]
5422 fn test_circuit_stats_failure_in_half_open() {
5423 let _guard = test_guard!();
5424 let mut stats = CircuitStats::new();
5425 let config = CircuitBreakerConfig {
5426 success_threshold: 2,
5427 ..Default::default()
5428 };
5429
5430 stats.open();
5431 stats.half_open();
5432
5433 stats.record_success();
5434 assert_eq!(stats.consecutive_failures(), 0);
5435
5436 stats.record_failure();
5438 assert!(!stats.should_close(&config));
5439
5440 stats.record_success();
5442 stats.record_success();
5443 assert!(stats.should_close(&config));
5444 }
5445
5446 #[test]
5447 fn test_circuit_stats_reset_window() {
5448 let _guard = test_guard!();
5449 let mut stats = CircuitStats::new();
5450 stats.record_success();
5451 stats.record_failure();
5452
5453 assert!(stats.error_rate() > 0.0);
5454
5455 stats.reset_window();
5456 assert_eq!(stats.error_rate(), 0.0);
5457 }
5458
5459 #[test]
5460 fn test_circuit_state_transitions_are_deterministic() {
5461 let _guard = test_guard!();
5462 let config = CircuitBreakerConfig::default();
5463 let mut stats = CircuitStats::new();
5464
5465 assert_eq!(stats.state(), CircuitState::Closed);
5467
5468 for _ in 0..3 {
5470 stats.record_failure();
5471 }
5472 assert!(stats.should_open(&config));
5473 stats.open();
5474 assert_eq!(stats.state(), CircuitState::Open);
5475
5476 stats.half_open();
5478 assert_eq!(stats.state(), CircuitState::HalfOpen);
5479
5480 for _ in 0..2 {
5482 stats.record_success();
5483 }
5484 assert!(stats.should_close(&config));
5485 stats.close();
5486 assert_eq!(stats.state(), CircuitState::Closed);
5487 }
5488
5489 fn make_test_metrics(
5494 speedup: Option<f64>,
5495 total_secs: u64,
5496 success: bool,
5497 ) -> CompilationMetrics {
5498 CompilationMetrics {
5499 project_id: "test-project".to_string(),
5500 worker_id: "worker-1".to_string(),
5501 timestamp: chrono::Utc::now(),
5502 timing: CompilationTimingBreakdown {
5503 rsync_up: Duration::from_millis(100),
5504 remote_build: Duration::from_millis(800),
5505 rsync_down: Duration::from_millis(100),
5506 total: Duration::from_secs(total_secs),
5507 },
5508 local_build_time: speedup.map(|s| Duration::from_secs_f64(total_secs as f64 * s)),
5509 speedup,
5510 files_synced: 100,
5511 bytes_transferred: 1_000_000,
5512 exit_code: if success { 0 } else { 1 },
5513 success,
5514 }
5515 }
5516
5517 #[test]
5518 fn test_compilation_timing_breakdown_default() {
5519 let _guard = test_guard!();
5520 let timing = CompilationTimingBreakdown::default();
5521 assert_eq!(timing.rsync_up, Duration::ZERO);
5522 assert_eq!(timing.remote_build, Duration::ZERO);
5523 assert_eq!(timing.rsync_down, Duration::ZERO);
5524 assert_eq!(timing.total, Duration::ZERO);
5525 }
5526
5527 #[test]
5528 fn test_compilation_timing_breakdown_serialization() {
5529 let _guard = test_guard!();
5530 let timing = CompilationTimingBreakdown {
5531 rsync_up: Duration::from_millis(100),
5532 remote_build: Duration::from_millis(2000),
5533 rsync_down: Duration::from_millis(50),
5534 total: Duration::from_millis(2150),
5535 };
5536
5537 let json = serde_json::to_string(&timing).unwrap();
5538 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();
5544 assert_eq!(parsed.rsync_up, timing.rsync_up);
5545 assert_eq!(parsed.remote_build, timing.remote_build);
5546 assert_eq!(parsed.rsync_down, timing.rsync_down);
5547 assert_eq!(parsed.total, timing.total);
5548 }
5549
5550 #[test]
5551 fn test_command_timing_breakdown_serialization() {
5552 let _guard = test_guard!();
5553 let timing = CommandTimingBreakdown {
5554 classify: Some(Duration::from_millis(2)),
5555 select: None,
5556 sync_up: Some(Duration::from_millis(120)),
5557 exec: Some(Duration::from_millis(900)),
5558 sync_down: None,
5559 cleanup: Some(Duration::from_millis(5)),
5560 total: Some(Duration::from_millis(1027)),
5561 };
5562
5563 let json = serde_json::to_string(&timing).unwrap();
5564 assert!(json.contains("\"classify\":2"));
5565 assert!(json.contains("\"select\":null"));
5566 assert!(json.contains("\"sync_up\":120"));
5567 assert!(json.contains("\"exec\":900"));
5568 assert!(json.contains("\"sync_down\":null"));
5569 assert!(json.contains("\"cleanup\":5"));
5570 assert!(json.contains("\"total\":1027"));
5571
5572 let parsed: CommandTimingBreakdown = serde_json::from_str(&json).unwrap();
5573 assert_eq!(parsed.classify, timing.classify);
5574 assert_eq!(parsed.select, timing.select);
5575 assert_eq!(parsed.sync_up, timing.sync_up);
5576 assert_eq!(parsed.exec, timing.exec);
5577 assert_eq!(parsed.sync_down, timing.sync_down);
5578 assert_eq!(parsed.cleanup, timing.cleanup);
5579 assert_eq!(parsed.total, timing.total);
5580 }
5581
5582 #[test]
5583 fn test_compilation_metrics_calculate_speedup() {
5584 let _guard = test_guard!();
5585 let mut metrics = CompilationMetrics {
5586 timing: CompilationTimingBreakdown {
5587 total: Duration::from_secs(10),
5588 ..Default::default()
5589 },
5590 local_build_time: Some(Duration::from_secs(30)),
5591 ..Default::default()
5592 };
5593
5594 metrics.calculate_speedup();
5595
5596 assert!(metrics.speedup.is_some());
5597 let speedup = metrics.speedup.unwrap();
5598 assert!(
5599 (speedup - 3.0).abs() < 0.01,
5600 "Expected 3.0x speedup, got {}",
5601 speedup
5602 );
5603 }
5604
5605 #[test]
5606 fn test_compilation_metrics_is_beneficial() {
5607 let _guard = test_guard!();
5608 let mut metrics = CompilationMetrics::default();
5609
5610 assert!(!metrics.is_beneficial());
5612
5613 metrics.speedup = Some(1.5);
5615 assert!(metrics.is_beneficial());
5616
5617 metrics.speedup = Some(0.8);
5619 assert!(!metrics.is_beneficial());
5620
5621 metrics.speedup = Some(1.0);
5623 assert!(!metrics.is_beneficial());
5624 }
5625
5626 #[test]
5627 fn test_compilation_metrics_serialization() {
5628 let _guard = test_guard!();
5629 let metrics = make_test_metrics(Some(2.5), 10, true);
5630 let json = serde_json::to_string(&metrics).unwrap();
5631
5632 assert!(json.contains("test-project"));
5633 assert!(json.contains("worker-1"));
5634 assert!(json.contains("2.5")); let parsed: CompilationMetrics = serde_json::from_str(&json).unwrap();
5637 assert_eq!(parsed.project_id, "test-project");
5638 assert_eq!(parsed.worker_id, "worker-1");
5639 assert!(parsed.success);
5640 }
5641
5642 #[test]
5643 fn test_compilation_timer_new() {
5644 let _guard = test_guard!();
5645 let timer = CompilationTimer::new("my-project", "my-worker");
5646 assert_eq!(timer.project_id, "my-project");
5647 assert_eq!(timer.worker_id, "my-worker");
5648 assert!(timer.rsync_up.is_none());
5649 assert!(timer.remote_build.is_none());
5650 assert!(timer.rsync_down.is_none());
5651 }
5652
5653 #[test]
5654 fn test_compilation_timer_phases() {
5655 let _guard = test_guard!();
5656 let mut timer = CompilationTimer::new("test", "worker");
5657
5658 std::thread::sleep(Duration::from_millis(5));
5660 timer.end_rsync_up();
5661 assert!(timer.rsync_up.is_some());
5662 assert!(timer.rsync_up.unwrap() >= Duration::from_millis(5));
5663
5664 std::thread::sleep(Duration::from_millis(10));
5666 timer.end_remote_build();
5667 assert!(timer.remote_build.is_some());
5668 assert!(timer.remote_build.unwrap() >= Duration::from_millis(10));
5669
5670 std::thread::sleep(Duration::from_millis(5));
5672 timer.end_rsync_down();
5673 assert!(timer.rsync_down.is_some());
5674 assert!(timer.rsync_down.unwrap() >= Duration::from_millis(5));
5675
5676 let metrics = timer.finish(0, 50, 500_000);
5678 assert_eq!(metrics.project_id, "test");
5679 assert_eq!(metrics.worker_id, "worker");
5680 assert!(metrics.success);
5681 assert!(metrics.timing.total >= Duration::from_millis(20));
5682 assert_eq!(metrics.files_synced, 50);
5683 assert_eq!(metrics.bytes_transferred, 500_000);
5684 }
5685
5686 #[test]
5687 fn test_metrics_aggregator_new() {
5688 let _guard = test_guard!();
5689 let agg = MetricsAggregator::new(100);
5690 assert_eq!(agg.count(), 0);
5691 assert!(agg.average_speedup().is_none());
5692 }
5693
5694 #[test]
5695 fn test_metrics_aggregator_record() {
5696 let _guard = test_guard!();
5697 let mut agg = MetricsAggregator::new(3);
5698
5699 agg.record(make_test_metrics(Some(2.0), 10, true));
5701 assert_eq!(agg.count(), 1);
5702
5703 agg.record(make_test_metrics(Some(3.0), 20, true));
5705 agg.record(make_test_metrics(Some(4.0), 30, true));
5706 assert_eq!(agg.count(), 3);
5707
5708 agg.record(make_test_metrics(Some(5.0), 40, true));
5710 assert_eq!(agg.count(), 3);
5711 }
5712
5713 #[test]
5714 fn test_metrics_aggregator_average_speedup() {
5715 let _guard = test_guard!();
5716 let mut agg = MetricsAggregator::new(100);
5717
5718 assert!(agg.average_speedup().is_none());
5720
5721 for i in 1..=5 {
5723 agg.record(make_test_metrics(Some(i as f64), 10, true));
5724 }
5725
5726 let avg = agg.average_speedup().unwrap();
5727 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5728 }
5729
5730 #[test]
5731 fn test_metrics_aggregator_average_speedup_with_none() {
5732 let _guard = test_guard!();
5733 let mut agg = MetricsAggregator::new(100);
5734
5735 agg.record(make_test_metrics(Some(2.0), 10, true));
5737 agg.record(make_test_metrics(None, 10, true)); agg.record(make_test_metrics(Some(4.0), 10, true));
5739
5740 let avg = agg.average_speedup().unwrap();
5742 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5743 }
5744
5745 #[test]
5746 fn test_metrics_aggregator_percentiles() {
5747 let _guard = test_guard!();
5748 let mut agg = MetricsAggregator::new(100);
5749
5750 for i in 1..=10 {
5752 agg.record(make_test_metrics(Some(1.0), i, true));
5753 }
5754
5755 let p50 = agg.p50_total_time().unwrap();
5757 assert!(p50 >= Duration::from_secs(5) && p50 <= Duration::from_secs(6));
5758
5759 let p95 = agg.p95_total_time().unwrap();
5761 assert!(p95 >= Duration::from_secs(9) && p95 <= Duration::from_secs(10));
5762
5763 let p99 = agg.p99_total_time().unwrap();
5765 assert!(p99 >= Duration::from_secs(9));
5766 }
5767
5768 #[test]
5769 fn test_metrics_aggregator_success_rate() {
5770 let _guard = test_guard!();
5771 let mut agg = MetricsAggregator::new(100);
5772
5773 assert_eq!(agg.success_rate(), 100.0);
5775
5776 agg.record(make_test_metrics(Some(1.0), 10, true));
5778 agg.record(make_test_metrics(Some(1.0), 10, true));
5779 assert_eq!(agg.success_rate(), 100.0);
5780
5781 agg.record(make_test_metrics(Some(1.0), 10, false));
5783 let rate = agg.success_rate();
5784 assert!((rate - 66.67).abs() < 1.0, "Expected ~66.67%, got {}", rate);
5785 }
5786
5787 #[test]
5788 fn test_metrics_aggregator_clear() {
5789 let _guard = test_guard!();
5790 let mut agg = MetricsAggregator::new(100);
5791 agg.record(make_test_metrics(Some(1.0), 10, true));
5792 agg.record(make_test_metrics(Some(2.0), 20, true));
5793
5794 assert_eq!(agg.count(), 2);
5795
5796 agg.clear();
5797 assert_eq!(agg.count(), 0);
5798 assert!(agg.average_speedup().is_none());
5799 }
5800
5801 #[test]
5804 fn test_remote_build_jobs_parse_display_and_serde() {
5805 let _guard = test_guard!();
5806 assert_eq!(RemoteBuildJobs::default(), RemoteBuildJobs::Auto);
5807 for (spelling, expected) in [
5808 ("auto", RemoteBuildJobs::Auto),
5809 (" AUTO ", RemoteBuildJobs::Auto),
5810 ("off", RemoteBuildJobs::Off),
5811 ("none", RemoteBuildJobs::Off),
5812 ("4", RemoteBuildJobs::Fixed(4)),
5813 ] {
5814 assert_eq!(
5815 RemoteBuildJobs::parse(spelling).unwrap(),
5816 expected,
5817 "{spelling}"
5818 );
5819 }
5820 assert!(RemoteBuildJobs::parse("0").is_err(), "cargo rejects -j 0");
5821 assert!(RemoteBuildJobs::parse("many").is_err());
5822 assert_eq!(RemoteBuildJobs::Fixed(4).to_string(), "4");
5823 assert_eq!(RemoteBuildJobs::Off.to_string(), "off");
5824
5825 #[derive(Deserialize)]
5828 struct Wrap {
5829 compilation: CompilationConfig,
5830 }
5831 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = \"off\"\n").unwrap();
5832 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Off);
5833 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = 6\n").unwrap();
5834 assert_eq!(
5835 parsed.compilation.remote_build_jobs,
5836 RemoteBuildJobs::Fixed(6)
5837 );
5838 let parsed: Wrap = toml::from_str("[compilation]\nbuild_slots = 4\n").unwrap();
5839 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Auto);
5840 assert!(toml::from_str::<Wrap>("[compilation]\nremote_build_jobs = 0\n").is_err());
5841
5842 let json = serde_json::to_string(&RemoteBuildJobs::Fixed(3)).unwrap();
5844 assert_eq!(json, "3");
5845 assert_eq!(
5846 serde_json::to_string(&RemoteBuildJobs::Auto).unwrap(),
5847 "\"auto\""
5848 );
5849 let back: RemoteBuildJobs = serde_json::from_str("\"auto\"").unwrap();
5850 assert_eq!(back, RemoteBuildJobs::Auto);
5851 }
5852
5853 #[test]
5854 fn test_compilation_config_default_timeouts() {
5855 let _guard = test_guard!();
5856 let config = CompilationConfig::default();
5857 assert_eq!(config.build_timeout_sec, 300);
5859 assert_eq!(config.test_timeout_sec, 1800);
5861 }
5862
5863 #[test]
5864 fn test_compilation_config_timeout_for_test_kinds() {
5865 let _guard = test_guard!();
5866 let config = CompilationConfig::default();
5867
5868 assert_eq!(
5870 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5871 std::time::Duration::from_secs(1800)
5872 );
5873 assert_eq!(
5874 config.timeout_for_kind(Some(crate::CompilationKind::CargoNextest)),
5875 std::time::Duration::from_secs(1800)
5876 );
5877 assert_eq!(
5878 config.timeout_for_kind(Some(crate::CompilationKind::BunTest)),
5879 std::time::Duration::from_secs(config.bun_timeout_sec)
5880 );
5881 }
5882
5883 #[test]
5884 fn test_compilation_config_timeout_for_build_kinds() {
5885 let _guard = test_guard!();
5886 let config = CompilationConfig::default();
5887
5888 assert_eq!(
5890 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5891 std::time::Duration::from_secs(300)
5892 );
5893 assert_eq!(
5894 config.timeout_for_kind(Some(crate::CompilationKind::CargoCheck)),
5895 std::time::Duration::from_secs(300)
5896 );
5897 assert_eq!(
5898 config.timeout_for_kind(Some(crate::CompilationKind::CargoClippy)),
5899 std::time::Duration::from_secs(300)
5900 );
5901 assert_eq!(
5902 config.timeout_for_kind(None),
5903 std::time::Duration::from_secs(300)
5904 );
5905 }
5906
5907 #[test]
5908 fn test_compilation_config_custom_timeouts() {
5909 let _guard = test_guard!();
5910 let config = CompilationConfig {
5911 build_timeout_sec: 600, test_timeout_sec: 3600, ..Default::default()
5914 };
5915
5916 assert_eq!(
5917 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5918 std::time::Duration::from_secs(600)
5919 );
5920 assert_eq!(
5921 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5922 std::time::Duration::from_secs(3600)
5923 );
5924 }
5925
5926 #[test]
5927 fn test_compilation_config_speedup_threshold_default() {
5928 let _guard = test_guard!();
5929 let config = CompilationConfig::default();
5930 assert!((config.remote_speedup_threshold - 1.2).abs() < 0.001);
5932 }
5933
5934 #[test]
5935 fn test_compilation_config_speedup_threshold_custom() {
5936 let _guard = test_guard!();
5937 let config = CompilationConfig {
5938 remote_speedup_threshold: 1.5, ..Default::default()
5940 };
5941 assert!((config.remote_speedup_threshold - 1.5).abs() < 0.001);
5942 }
5943
5944 #[test]
5945 fn test_compilation_config_speedup_threshold_no_minimum() {
5946 let _guard = test_guard!();
5947 let config = CompilationConfig {
5949 remote_speedup_threshold: 1.0,
5950 ..Default::default()
5951 };
5952 assert!((config.remote_speedup_threshold - 1.0).abs() < 0.001);
5953 }
5954
5955 #[test]
5956 fn test_compilation_config_min_local_time_ms_default() {
5957 let _guard = test_guard!();
5958 let config = CompilationConfig::default();
5959 assert_eq!(config.min_local_time_ms, 2000);
5961 }
5962
5963 #[test]
5964 fn test_compilation_config_serde_roundtrip_speedup_threshold() {
5965 let _guard = test_guard!();
5966 let config = CompilationConfig {
5967 remote_speedup_threshold: 2.5,
5968 min_local_time_ms: 5000,
5969 ..Default::default()
5970 };
5971 let json = serde_json::to_string(&config).unwrap();
5972 let parsed: CompilationConfig = serde_json::from_str(&json).unwrap();
5973 assert!((parsed.remote_speedup_threshold - 2.5).abs() < 0.001);
5974 assert_eq!(parsed.min_local_time_ms, 5000);
5975 }
5976
5977 #[test]
5982 fn test_validate_remote_base_absolute_path() {
5983 let _guard = test_guard!();
5984 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
5985 assert_eq!(
5986 validate_remote_base("/var/rch-builds").unwrap(),
5987 "/var/rch-builds"
5988 );
5989 assert_eq!(
5990 validate_remote_base("/home/builder/.rch").unwrap(),
5991 "/home/builder/.rch"
5992 );
5993 }
5994
5995 #[test]
5996 fn test_validate_remote_base_tilde_expansion() {
5997 let _guard = test_guard!();
5998 let result = validate_remote_base("~/rch");
6000 assert!(result.is_ok());
6001 let path = result.unwrap();
6002 assert!(
6003 path.starts_with('/'),
6004 "Path should be absolute after expansion: {}",
6005 path
6006 );
6007 assert!(!path.contains('~'), "Tilde should be expanded: {}", path);
6008 }
6009
6010 #[test]
6011 fn test_validate_remote_base_rejects_relative_path() {
6012 let _guard = test_guard!();
6013 let result = validate_remote_base("tmp/rch");
6014 assert!(result.is_err());
6015 assert!(result.unwrap_err().contains("absolute path"));
6016 }
6017
6018 #[test]
6019 fn test_validate_remote_base_rejects_path_traversal() {
6020 let _guard = test_guard!();
6021 let result = validate_remote_base("/tmp/../etc/rch");
6022 assert!(result.is_err());
6023 assert!(result.unwrap_err().contains("path traversal"));
6024
6025 let result = validate_remote_base("/tmp/rch/../other");
6026 assert!(result.is_err());
6027 assert!(result.unwrap_err().contains("path traversal"));
6028 }
6029
6030 #[test]
6031 fn test_validate_remote_base_normalizes_trailing_slash() {
6032 let _guard = test_guard!();
6033 assert_eq!(validate_remote_base("/tmp/rch/").unwrap(), "/tmp/rch");
6034 assert_eq!(validate_remote_base("/tmp/rch///").unwrap(), "/tmp/rch");
6035 }
6036
6037 #[test]
6038 fn test_validate_remote_base_root_path() {
6039 let _guard = test_guard!();
6040 let result = validate_remote_base("/");
6042 assert!(result.is_err());
6043 assert!(result.unwrap_err().contains("root directory"));
6044 }
6045
6046 #[test]
6047 fn test_validate_remote_base_rejects_top_level() {
6048 let _guard = test_guard!();
6049 let result = validate_remote_base("/tmp");
6051 assert!(result.is_err());
6052 assert!(result.unwrap_err().contains("at least 2 levels deep"));
6053
6054 let result = validate_remote_base("/home");
6055 assert!(result.is_err());
6056
6057 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
6059 }
6060
6061 #[test]
6062 fn test_default_remote_base() {
6063 let _guard = test_guard!();
6064 assert_eq!(default_remote_base(), "/data/tmp/rch");
6065 }
6066
6067 #[test]
6068 fn test_transfer_config_default_has_remote_base() {
6069 let _guard = test_guard!();
6070 let config = TransferConfig::default();
6071 assert_eq!(config.remote_base, "/data/tmp/rch");
6072 }
6073
6074 #[test]
6075 fn test_source_sync_timeout_validation_boundaries() {
6076 let _guard = test_guard!();
6077 assert!(!TransferConfig::valid_sync_timeout_ms(0));
6078 assert!(!TransferConfig::valid_sync_timeout_ms(999));
6079 assert!(TransferConfig::valid_sync_timeout_ms(1_000));
6080 assert!(TransferConfig::valid_sync_timeout_ms(3_600_000));
6081 assert!(!TransferConfig::valid_sync_timeout_ms(3_600_001));
6082 }
6083
6084 #[test]
6085 fn test_source_sync_timeout_is_explicit_or_payload_aware() {
6086 let _guard = test_guard!();
6087 let default = TransferConfig::default();
6088 assert_eq!(
6089 default.sync_timeout_for_payload(0),
6090 std::time::Duration::from_millis(30_000)
6091 );
6092 assert_eq!(
6093 default.sync_timeout_for_payload(10 * 1024 * 1024),
6094 std::time::Duration::from_millis(40_000)
6095 );
6096 assert_eq!(
6097 default.sync_timeout_for_payload(u64::MAX),
6098 std::time::Duration::from_millis(3_600_000)
6099 );
6100
6101 let explicit = TransferConfig {
6102 sync_timeout_ms: Some(120_000),
6103 ..TransferConfig::default()
6104 };
6105 assert_eq!(
6106 explicit.sync_timeout_for_payload(u64::MAX),
6107 std::time::Duration::from_millis(120_000)
6108 );
6109 }
6110
6111 #[test]
6116 fn test_retry_config_default() {
6117 let _guard = test_guard!();
6118 let config = RetryConfig::default();
6119 assert_eq!(config.max_attempts, 3);
6120 assert_eq!(config.base_delay_ms, 100);
6121 assert_eq!(config.max_delay_ms, 5000);
6122 assert_eq!(config.jitter_factor, 0.1);
6123 assert_eq!(config.total_timeout_ms, 30000);
6124 }
6125
6126 #[test]
6127 fn test_retry_config_no_retry() {
6128 let _guard = test_guard!();
6129 let config = RetryConfig::no_retry();
6130 assert_eq!(config.max_attempts, 1);
6131 }
6132
6133 #[test]
6134 fn test_retry_config_delay_for_attempt_zero() {
6135 let _guard = test_guard!();
6136 let config = RetryConfig::default();
6137 assert_eq!(config.delay_for_attempt(0), std::time::Duration::ZERO);
6138 }
6139
6140 #[test]
6141 fn test_retry_config_delay_for_attempt_exponential() {
6142 let _guard = test_guard!();
6143 let config = RetryConfig {
6144 base_delay_ms: 100,
6145 max_delay_ms: 10000,
6146 jitter_factor: 0.0, ..Default::default()
6148 };
6149
6150 assert_eq!(
6152 config.delay_for_attempt(1),
6153 std::time::Duration::from_millis(100)
6154 );
6155 assert_eq!(
6157 config.delay_for_attempt(2),
6158 std::time::Duration::from_millis(200)
6159 );
6160 assert_eq!(
6162 config.delay_for_attempt(3),
6163 std::time::Duration::from_millis(400)
6164 );
6165 }
6166
6167 #[test]
6168 fn test_retry_config_delay_capped_at_max() {
6169 let _guard = test_guard!();
6170 let config = RetryConfig {
6171 base_delay_ms: 1000,
6172 max_delay_ms: 2000,
6173 jitter_factor: 0.0,
6174 ..Default::default()
6175 };
6176
6177 assert_eq!(
6179 config.delay_for_attempt(2),
6180 std::time::Duration::from_millis(2000)
6181 );
6182 assert_eq!(
6184 config.delay_for_attempt(3),
6185 std::time::Duration::from_millis(2000)
6186 );
6187 }
6188
6189 #[test]
6190 fn test_retry_config_random_jitter_stays_within_bounds() {
6191 let _guard = test_guard!();
6192 let config = RetryConfig {
6193 base_delay_ms: 100,
6194 max_delay_ms: 200,
6195 jitter_factor: 0.25,
6196 ..Default::default()
6197 };
6198
6199 for (attempt, minimum_ms, maximum_ms) in [(1, 75, 125), (3, 150, 250)] {
6202 for _ in 0..128 {
6203 let delay = config.delay_for_attempt(attempt);
6204 assert!(delay >= std::time::Duration::from_millis(minimum_ms));
6205 assert!(delay <= std::time::Duration::from_millis(maximum_ms));
6206 }
6207 }
6208 }
6209
6210 #[test]
6211 fn test_retry_config_should_retry() {
6212 let _guard = test_guard!();
6213 let config = RetryConfig {
6214 max_attempts: 3,
6215 total_timeout_ms: 1000,
6216 ..Default::default()
6217 };
6218
6219 assert!(config.should_retry(0, std::time::Duration::from_millis(0)));
6221 assert!(config.should_retry(1, std::time::Duration::from_millis(500)));
6222 assert!(config.should_retry(2, std::time::Duration::from_millis(900)));
6223
6224 assert!(!config.should_retry(3, std::time::Duration::from_millis(0)));
6226
6227 assert!(!config.should_retry(1, std::time::Duration::from_millis(1001)));
6229 }
6230
6231 #[test]
6232 fn test_retry_config_serde_roundtrip() {
6233 let _guard = test_guard!();
6234 let config = RetryConfig {
6235 max_attempts: 5,
6236 base_delay_ms: 200,
6237 max_delay_ms: 8000,
6238 jitter_factor: 0.2,
6239 total_timeout_ms: 60000,
6240 };
6241 let json = serde_json::to_string(&config).unwrap();
6242 let parsed: RetryConfig = serde_json::from_str(&json).unwrap();
6243 assert_eq!(parsed.max_attempts, 5);
6244 assert_eq!(parsed.base_delay_ms, 200);
6245 assert_eq!(parsed.max_delay_ms, 8000);
6246 assert_eq!(parsed.jitter_factor, 0.2);
6247 assert_eq!(parsed.total_timeout_ms, 60000);
6248 }
6249
6250 #[test]
6251 fn test_transfer_config_includes_retry() {
6252 let _guard = test_guard!();
6253 let config = TransferConfig::default();
6254 assert_eq!(config.retry.max_attempts, 3);
6255 }
6256
6257 #[test]
6262 fn test_execution_config_default_allowlist() {
6263 let _guard = test_guard!();
6264 let config = ExecutionConfig::default();
6265 assert!(config.is_allowed("cargo"));
6267 assert!(config.is_allowed("rustc"));
6268 assert!(config.is_allowed("gcc"));
6269 assert!(config.is_allowed("g++"));
6270 assert!(config.is_allowed("clang"));
6271 assert!(config.is_allowed("clang++"));
6272 assert!(config.is_allowed("make"));
6273 assert!(config.is_allowed("cmake"));
6274 assert!(config.is_allowed("ninja"));
6275 assert!(config.is_allowed("meson"));
6276 assert!(config.is_allowed("bun"));
6277 assert!(config.is_allowed("nextest"));
6278 assert!(config.is_allowed("cc"));
6279 assert!(config.is_allowed("c++"));
6280 }
6281
6282 #[test]
6283 fn test_execution_config_is_allowed_case_insensitive() {
6284 let _guard = test_guard!();
6285 let config = ExecutionConfig::default();
6286 assert!(config.is_allowed("CARGO"));
6288 assert!(config.is_allowed("Cargo"));
6289 assert!(config.is_allowed("GCC"));
6290 assert!(config.is_allowed("Gcc"));
6291 }
6292
6293 #[test]
6294 fn test_execution_config_is_not_allowed() {
6295 let _guard = test_guard!();
6296 let config = ExecutionConfig::default();
6297 assert!(!config.is_allowed("python"));
6299 assert!(!config.is_allowed("npm"));
6300 assert!(!config.is_allowed(""));
6301 assert!(config.is_allowed("go"));
6306 assert!(config.is_allowed("tsc"));
6307 }
6308
6309 #[test]
6310 fn test_execution_config_empty_allowlist() {
6311 let _guard = test_guard!();
6312 let config = ExecutionConfig { allowlist: vec![] };
6313 assert!(!config.is_allowed("cargo"));
6315 assert!(!config.is_allowed("gcc"));
6316 }
6317
6318 #[test]
6319 fn test_execution_config_custom_allowlist() {
6320 let _guard = test_guard!();
6321 let config = ExecutionConfig {
6322 allowlist: vec!["cargo".to_string(), "custom_tool".to_string()],
6323 };
6324 assert!(config.is_allowed("cargo"));
6325 assert!(config.is_allowed("custom_tool"));
6326 assert!(!config.is_allowed("gcc"));
6327 }
6328
6329 #[test]
6330 fn test_execution_config_serde() {
6331 let _guard = test_guard!();
6332 let config = ExecutionConfig {
6333 allowlist: vec!["cargo".to_string(), "rustc".to_string()],
6334 };
6335 let json = serde_json::to_string(&config).unwrap();
6336 let parsed: ExecutionConfig = serde_json::from_str(&json).unwrap();
6337 assert_eq!(parsed.allowlist, config.allowlist);
6338 }
6339
6340 #[test]
6341 fn test_rch_config_includes_execution() {
6342 let _guard = test_guard!();
6343 let config = RchConfig::default();
6344 assert!(config.execution.is_allowed("cargo"));
6346 }
6347
6348 #[test]
6353 fn test_adaptive_compression_disabled_by_default() {
6354 let _guard = test_guard!();
6355 let config = TransferConfig::default();
6356 assert!(!config.adaptive_compression);
6357 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6359 assert_eq!(config.select_compression_level(Some(500_000_000)), 3);
6360 }
6361
6362 #[test]
6363 fn test_adaptive_compression_small_payload() {
6364 let _guard = test_guard!();
6365 let config = TransferConfig {
6366 adaptive_compression: true,
6367 ..Default::default()
6368 };
6369 assert_eq!(config.select_compression_level(Some(0)), 1);
6371 assert_eq!(config.select_compression_level(Some(1_000_000)), 1); assert_eq!(config.select_compression_level(Some(9_999_999)), 1); }
6374
6375 #[test]
6376 fn test_adaptive_compression_medium_payload() {
6377 let _guard = test_guard!();
6378 let config = TransferConfig {
6379 adaptive_compression: true,
6380 ..Default::default()
6381 };
6382 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); }
6387
6388 #[test]
6389 fn test_adaptive_compression_large_payload() {
6390 let _guard = test_guard!();
6391 let config = TransferConfig {
6392 adaptive_compression: true,
6393 ..Default::default()
6394 };
6395 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); }
6400
6401 #[test]
6402 fn test_adaptive_compression_no_estimate() {
6403 let _guard = test_guard!();
6404 let config = TransferConfig {
6405 adaptive_compression: true,
6406 compression_level: 5,
6407 ..Default::default()
6408 };
6409 assert_eq!(config.select_compression_level(None), 5);
6411 }
6412
6413 #[test]
6414 fn test_adaptive_compression_respects_min_level() {
6415 let _guard = test_guard!();
6416 let config = TransferConfig {
6417 adaptive_compression: true,
6418 min_compression_level: 3,
6419 ..Default::default()
6420 };
6421 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6423 }
6424
6425 #[test]
6426 fn test_adaptive_compression_respects_max_level() {
6427 let _guard = test_guard!();
6428 let config = TransferConfig {
6429 adaptive_compression: true,
6430 max_compression_level: 5,
6431 ..Default::default()
6432 };
6433 assert_eq!(config.select_compression_level(Some(500_000_000)), 5);
6435 }
6436
6437 #[test]
6438 fn test_adaptive_compression_serde_roundtrip() {
6439 let _guard = test_guard!();
6440 let config = TransferConfig {
6441 adaptive_compression: true,
6442 min_compression_level: 2,
6443 max_compression_level: 8,
6444 ..Default::default()
6445 };
6446 let json = serde_json::to_string(&config).unwrap();
6447 let parsed: TransferConfig = serde_json::from_str(&json).unwrap();
6448 assert!(parsed.adaptive_compression);
6449 assert_eq!(parsed.min_compression_level, 2);
6450 assert_eq!(parsed.max_compression_level, 8);
6451 }
6452
6453 #[test]
6458 fn test_self_healing_config_defaults() {
6459 let _guard = test_guard!();
6460 let config = SelfHealingConfig::default();
6462
6463 assert!(
6465 config.hook_starts_daemon,
6466 "hook_starts_daemon should be true by default"
6467 );
6468 assert!(
6469 config.daemon_installs_hooks,
6470 "daemon_installs_hooks should be true by default"
6471 );
6472
6473 assert_eq!(
6475 config.auto_start_cooldown_secs, 30,
6476 "auto_start_cooldown_secs should default to 30"
6477 );
6478 assert_eq!(
6479 config.auto_start_timeout_secs, 3,
6480 "auto_start_timeout_secs should default to 3"
6481 );
6482 }
6484
6485 #[test]
6486 fn test_self_healing_config_serde_full() {
6487 let _guard = test_guard!();
6488 let config = SelfHealingConfig {
6490 hook_starts_daemon: false,
6491 daemon_installs_hooks: false,
6492 auto_start_cooldown_secs: 60,
6493 auto_start_timeout_secs: 10,
6494 self_healing_log_level: SelfHealingLogLevel::Debug,
6495 };
6496
6497 let json = serde_json::to_string(&config).unwrap();
6498 assert!(json.contains("\"hook_starts_daemon\":false"));
6499 assert!(json.contains("\"daemon_installs_hooks\":false"));
6500 assert!(json.contains("\"auto_start_cooldown_secs\":60"));
6501 assert!(json.contains("\"auto_start_timeout_secs\":10"));
6502 assert!(json.contains("\"self_healing_log_level\":\"debug\""));
6503
6504 let parsed: SelfHealingConfig = serde_json::from_str(&json).unwrap();
6505 assert!(!parsed.hook_starts_daemon);
6506 assert!(!parsed.daemon_installs_hooks);
6507 assert_eq!(parsed.auto_start_cooldown_secs, 60);
6508 assert_eq!(parsed.auto_start_timeout_secs, 10);
6509 assert_eq!(parsed.self_healing_log_level, SelfHealingLogLevel::Debug);
6510 }
6512
6513 #[test]
6516 fn test_self_healing_log_level_default_is_info() {
6517 let level = SelfHealingLogLevel::default();
6519 assert_eq!(level, SelfHealingLogLevel::Info);
6520 let config = SelfHealingConfig::default();
6522 assert_eq!(
6523 config.self_healing_log_level,
6524 SelfHealingLogLevel::Info,
6525 "config default should be Info"
6526 );
6527 }
6529
6530 #[test]
6531 fn test_self_healing_log_level_serializes_lowercase() {
6532 let json = serde_json::to_string(&SelfHealingLogLevel::Warn).unwrap();
6534 assert_eq!(json, "\"warn\"");
6535 let parsed: SelfHealingLogLevel = serde_json::from_str("\"error\"").unwrap();
6536 assert_eq!(parsed, SelfHealingLogLevel::Error);
6537 }
6539
6540 #[test]
6541 fn test_self_healing_log_level_from_env_str_accepts_known() {
6542 assert_eq!(
6544 SelfHealingLogLevel::from_env_str("debug"),
6545 Some(SelfHealingLogLevel::Debug)
6546 );
6547 assert_eq!(
6548 SelfHealingLogLevel::from_env_str("DEBUG"),
6549 Some(SelfHealingLogLevel::Debug)
6550 );
6551 assert_eq!(
6552 SelfHealingLogLevel::from_env_str("info"),
6553 Some(SelfHealingLogLevel::Info)
6554 );
6555 assert_eq!(
6556 SelfHealingLogLevel::from_env_str("warn"),
6557 Some(SelfHealingLogLevel::Warn)
6558 );
6559 assert_eq!(
6560 SelfHealingLogLevel::from_env_str("warning"),
6561 Some(SelfHealingLogLevel::Warn),
6562 "warning should map to Warn"
6563 );
6564 assert_eq!(
6565 SelfHealingLogLevel::from_env_str("error"),
6566 Some(SelfHealingLogLevel::Error)
6567 );
6568 assert_eq!(
6570 SelfHealingLogLevel::from_env_str(" debug "),
6571 Some(SelfHealingLogLevel::Debug)
6572 );
6573 }
6575
6576 #[test]
6577 fn test_self_healing_log_level_from_env_str_rejects_unknown() {
6578 assert!(SelfHealingLogLevel::from_env_str("banana").is_none());
6580 assert!(SelfHealingLogLevel::from_env_str("").is_none());
6581 assert!(SelfHealingLogLevel::from_env_str("trace").is_none());
6582 }
6584
6585 #[test]
6586 fn test_self_healing_config_serde_partial_uses_defaults() {
6587 let _guard = test_guard!();
6588 let json = r#"{"hook_starts_daemon": false}"#;
6590 let config: SelfHealingConfig = serde_json::from_str(json).unwrap();
6591
6592 assert!(
6593 !config.hook_starts_daemon,
6594 "Explicit false should be parsed"
6595 );
6596 assert!(
6597 config.daemon_installs_hooks,
6598 "Missing field should use default (true)"
6599 );
6600 assert_eq!(
6601 config.auto_start_cooldown_secs, 30,
6602 "Missing field should use default (30)"
6603 );
6604 assert_eq!(
6605 config.auto_start_timeout_secs, 3,
6606 "Missing field should use default (3)"
6607 );
6608 }
6610
6611 #[test]
6612 fn test_self_healing_config_toml_with_alias() {
6613 let _guard = test_guard!();
6614 let toml_str = r#"
6616 hook_starts_daemon = true
6617 daemon_installs_hooks = false
6618 auto_start_cooldown_secs = 45
6619 daemon_start_timeout = 7
6620 "#;
6621
6622 let config: SelfHealingConfig = toml::from_str(toml_str).unwrap();
6623 assert!(config.hook_starts_daemon);
6624 assert!(!config.daemon_installs_hooks);
6625 assert_eq!(config.auto_start_cooldown_secs, 45);
6626 assert_eq!(
6627 config.auto_start_timeout_secs, 7,
6628 "daemon_start_timeout alias should set auto_start_timeout_secs"
6629 );
6630 }
6632
6633 #[allow(unsafe_code)]
6634 mod self_healing_env_override_tests {
6635 use super::*;
6636 use crate::config::env_test_lock;
6637
6638 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
6639 env_test_lock()
6640 }
6641
6642 fn set_env(key: &str, value: &str) {
6643 unsafe { std::env::set_var(key, value) };
6645 }
6646
6647 fn remove_env(key: &str) {
6648 unsafe { std::env::remove_var(key) };
6650 }
6651
6652 struct EnvVarGuard {
6653 key: &'static str,
6654 old: Option<String>,
6655 }
6656
6657 impl EnvVarGuard {
6658 fn set(key: &'static str, value: &str) -> Self {
6659 let old = std::env::var(key).ok();
6660 set_env(key, value);
6661 Self { key, old }
6662 }
6663 }
6664
6665 impl Drop for EnvVarGuard {
6666 fn drop(&mut self) {
6667 if let Some(old) = &self.old {
6668 set_env(self.key, old);
6669 } else {
6670 remove_env(self.key);
6671 }
6672 }
6673 }
6674
6675 #[test]
6676 fn test_self_healing_config_with_env_overrides_master_disable() {
6677 let _guard = test_guard!();
6678 let _guard = env_guard();
6679
6680 let _no_self_healing = EnvVarGuard::set("RCH_NO_SELF_HEALING", "1");
6681 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "1");
6682 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "1");
6683 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "99");
6684 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "99");
6685
6686 let config = SelfHealingConfig::default().with_env_overrides();
6687 assert!(
6688 !config.hook_starts_daemon,
6689 "RCH_NO_SELF_HEALING should disable hook auto-start"
6690 );
6691 assert!(
6692 !config.daemon_installs_hooks,
6693 "RCH_NO_SELF_HEALING should disable daemon hook installation"
6694 );
6695
6696 assert_eq!(config.auto_start_cooldown_secs, 30);
6698 assert_eq!(config.auto_start_timeout_secs, 3);
6699 }
6700
6701 #[test]
6702 fn test_self_healing_config_with_env_overrides_toggles_and_numbers() {
6703 let _guard = test_guard!();
6704 let _guard = env_guard();
6705
6706 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "0");
6707 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "false");
6708 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "45");
6709 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "7");
6710
6711 let config = SelfHealingConfig::default().with_env_overrides();
6712 assert!(!config.hook_starts_daemon);
6713 assert!(!config.daemon_installs_hooks);
6714 assert_eq!(config.auto_start_cooldown_secs, 45);
6715 assert_eq!(config.auto_start_timeout_secs, 7);
6716 }
6717
6718 #[test]
6719 fn test_self_healing_config_with_env_overrides_invalid_numbers_ignored() {
6720 let _guard = test_guard!();
6721 let _guard = env_guard();
6722
6723 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "not-a-number");
6724 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "nope");
6725
6726 let config = SelfHealingConfig::default().with_env_overrides();
6727 assert_eq!(config.auto_start_cooldown_secs, 30);
6728 assert_eq!(config.auto_start_timeout_secs, 3);
6729 }
6730 }
6731
6732 #[test]
6737 fn test_load_per_core_calculation() {
6738 let _guard = test_guard!();
6739 let mut caps = WorkerCapabilities::new();
6740 assert!(caps.load_per_core().is_none());
6742
6743 caps.load_avg_1 = Some(4.0);
6745 assert!(caps.load_per_core().is_none());
6746
6747 caps.num_cpus = Some(4);
6749 assert_eq!(caps.load_per_core(), Some(1.0)); caps.load_avg_1 = Some(16.0);
6753 assert_eq!(caps.load_per_core(), Some(4.0)); }
6755
6756 #[test]
6757 fn test_is_high_load() {
6758 let _guard = test_guard!();
6759 let mut caps = WorkerCapabilities::new();
6760 caps.load_avg_1 = Some(8.0);
6761 caps.num_cpus = Some(4);
6762 assert_eq!(caps.is_high_load(3.0), Some(false));
6766 assert_eq!(caps.is_high_load(2.0), Some(false)); assert_eq!(caps.is_high_load(1.5), Some(true));
6770
6771 caps.load_avg_1 = None;
6773 assert!(caps.is_high_load(1.0).is_none());
6774 }
6775
6776 #[test]
6777 fn test_is_low_disk() {
6778 let _guard = test_guard!();
6779 let mut caps = WorkerCapabilities::new();
6780 caps.disk_free_gb = Some(15.0);
6781
6782 assert_eq!(caps.is_low_disk(10.0), Some(false));
6784 assert_eq!(caps.is_low_disk(15.0), Some(false)); assert_eq!(caps.is_low_disk(20.0), Some(true));
6788
6789 caps.disk_free_gb = None;
6791 assert!(caps.is_low_disk(10.0).is_none());
6792 }
6793
6794 #[test]
6795 fn low_disk_judges_the_build_disk_not_a_small_tmpfs() {
6796 let _guard = test_guard!();
6797 let mut caps = WorkerCapabilities::new();
6800 caps.disk_free_gb = Some(8.0);
6801 caps.disk_total_gb = Some(15.5);
6802 caps.build_disk_free_gb = Some(1700.0);
6803 caps.build_disk_total_gb = Some(1900.0);
6804 assert_eq!(caps.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6805 assert_eq!(caps.is_low_disk(10.0), Some(false));
6806
6807 caps.build_disk_free_gb = Some(4.0);
6809 assert_eq!(caps.is_low_disk(10.0), Some(true));
6810
6811 caps.build_disk_free_gb = None;
6813 caps.build_disk_total_gb = None;
6814 assert_eq!(caps.build_disk_gb(), (Some(8.0), Some(15.5)));
6815 assert_eq!(caps.is_low_disk(10.0), Some(true));
6816 }
6817
6818 #[test]
6819 fn build_disk_sample_is_optional_on_the_wire() {
6820 let _guard = test_guard!();
6821 let old: WorkerCapabilities =
6822 serde_json::from_str(r#"{"disk_free_gb":13.9,"disk_total_gb":15.5}"#).unwrap();
6823 assert_eq!(old.build_disk_free_gb, None);
6824 assert_eq!(old.build_disk_gb(), (Some(13.9), Some(15.5)));
6825
6826 let new: WorkerCapabilities = serde_json::from_str(
6827 r#"{"disk_free_gb":13.9,"disk_total_gb":15.5,"build_disk_free_gb":1700.0,"build_disk_total_gb":1900.0}"#,
6828 )
6829 .unwrap();
6830 assert_eq!(new.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6831 let json = serde_json::to_string(&new).unwrap();
6832 assert!(json.contains("\"build_disk_free_gb\":1700.0"), "{json}");
6833 }
6834
6835 #[test]
6836 fn test_selection_config_preflight_defaults() {
6837 let _guard = test_guard!();
6838 let config = SelectionConfig::default();
6839 assert_eq!(config.max_load_per_core, Some(2.0));
6841 assert_eq!(config.min_free_gb, Some(10.0));
6842 assert_eq!(config.disk_gb_per_slot, 10.0);
6843 }
6844
6845 #[test]
6846 fn test_selection_config_preflight_serde() {
6847 let _guard = test_guard!();
6848 let config = SelectionConfig {
6849 max_load_per_core: Some(3.5),
6850 min_free_gb: Some(25.0),
6851 disk_gb_per_slot: 0.83,
6852 ..Default::default()
6853 };
6854 let json = serde_json::to_string(&config).unwrap();
6855 let parsed: SelectionConfig = serde_json::from_str(&json).unwrap();
6856 assert_eq!(parsed.max_load_per_core, Some(3.5));
6857 assert_eq!(parsed.min_free_gb, Some(25.0));
6858 assert_eq!(parsed.disk_gb_per_slot, 0.83);
6859 }
6860
6861 #[test]
6862 fn test_selection_disk_slot_budget_validation() {
6863 let _guard = test_guard!();
6864 for value in ["0.0", "-1.0", "nan", "inf", "-inf"] {
6865 let result = toml::from_str::<SelectionConfig>(&format!("disk_gb_per_slot = {value}"));
6866 assert!(result.is_err(), "invalid disk slot budget {value}");
6867 }
6868 let default: SelectionConfig = toml::from_str("").unwrap();
6869 assert_eq!(default.disk_gb_per_slot, 10.0);
6870 }
6871
6872 #[test]
6873 fn test_selection_disk_weight_validation_and_roundtrip() {
6874 for value in ["-0.1", "1.1", "nan", "inf", "-inf"] {
6875 assert!(toml::from_str::<SelectionWeightConfig>(&format!("disk = {value}")).is_err());
6876 }
6877 for value in [0.0, 0.2, 1.0] {
6878 let weights: SelectionWeightConfig =
6879 toml::from_str(&format!("disk = {value}")).unwrap();
6880 assert_eq!(weights.disk, value);
6881 let roundtrip: SelectionWeightConfig =
6882 serde_json::from_str(&serde_json::to_string(&weights).unwrap()).unwrap();
6883 assert_eq!(roundtrip.disk, value);
6884 }
6885 let defaults: SelectionWeightConfig = toml::from_str("").unwrap();
6886 assert_eq!(defaults.disk, 0.2);
6887 }
6888
6889 #[test]
6890 fn test_worker_capabilities_health_metrics_serde() {
6891 let _guard = test_guard!();
6892 let caps = WorkerCapabilities {
6893 num_cpus: Some(8),
6894 load_avg_1: Some(1.5),
6895 load_avg_5: Some(2.0),
6896 load_avg_15: Some(1.8),
6897 disk_free_gb: Some(50.5),
6898 disk_total_gb: Some(100.0),
6899 ..Default::default()
6900 };
6901 let json = serde_json::to_string(&caps).unwrap();
6902 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
6903 assert_eq!(parsed.num_cpus, Some(8));
6904 assert_eq!(parsed.load_avg_1, Some(1.5));
6905 assert_eq!(parsed.disk_free_gb, Some(50.5));
6906 }
6907
6908 #[test]
6909 fn microarch_level_classifies_ivy_bridge_as_v2() {
6910 let ivy = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov \
6913 pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm \
6914 constant_tsc pni pclmulqdq ssse3 cx16 sse4_1 sse4_2 x2apic \
6915 popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm";
6916 assert_eq!(x86_64_microarch_level_from_flags(ivy), 2);
6917 }
6918
6919 #[test]
6920 fn microarch_level_classifies_v3_and_v4() {
6921 let v3 = "cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 \
6922 f16c fma movbe xsave";
6923 assert_eq!(x86_64_microarch_level_from_flags(v3), 3);
6924
6925 let v4 = format!("{v3} avx512f avx512bw avx512cd avx512dq avx512vl");
6926 assert_eq!(x86_64_microarch_level_from_flags(&v4), 4);
6927 }
6928
6929 #[test]
6930 fn microarch_level_v1_for_ancient_or_empty_flags() {
6931 assert_eq!(x86_64_microarch_level_from_flags(""), 1);
6932 assert_eq!(x86_64_microarch_level_from_flags("fpu mmx sse sse2"), 1);
6933 }
6934
6935 #[test]
6936 fn pre_v3_predicate_only_fires_on_affirmative_evidence() {
6937 let mut caps = WorkerCapabilities::new();
6938 assert!(!caps.is_pre_v3_x86(), "unknown must not deprioritize");
6939 caps.cpu_microarch_level = Some(2);
6940 assert!(caps.is_pre_v3_x86());
6941 caps.cpu_microarch_level = Some(3);
6942 assert!(!caps.is_pre_v3_x86());
6943 }
6944}