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")]
1536 pub max_load_average: f64,
1537 #[serde(default = "default_fleet_max_concurrent_workers")]
1539 pub max_concurrent_workers: usize,
1540 #[serde(default = "default_fleet_retry_count")]
1542 pub retry_count: u32,
1543 #[serde(default = "default_fleet_retry_delay_ms")]
1545 pub retry_delay_ms: u64,
1546}
1547
1548fn default_fleet_ssh_connect_timeout_secs() -> u64 {
1549 10
1550}
1551
1552fn default_fleet_ssh_command_timeout_secs() -> u64 {
1553 30
1554}
1555
1556fn default_fleet_min_disk_space_mb() -> u64 {
1557 500
1558}
1559
1560fn default_fleet_max_load_average() -> f64 {
1561 10.0
1562}
1563
1564fn default_fleet_max_concurrent_workers() -> usize {
1565 10
1566}
1567
1568fn default_fleet_retry_count() -> u32 {
1569 2
1570}
1571
1572fn default_fleet_retry_delay_ms() -> u64 {
1573 1000
1574}
1575
1576impl Default for FleetConfig {
1577 fn default() -> Self {
1578 Self {
1579 ssh_connect_timeout_secs: default_fleet_ssh_connect_timeout_secs(),
1580 ssh_command_timeout_secs: default_fleet_ssh_command_timeout_secs(),
1581 min_disk_space_mb: default_fleet_min_disk_space_mb(),
1582 max_load_average: default_fleet_max_load_average(),
1583 max_concurrent_workers: default_fleet_max_concurrent_workers(),
1584 retry_count: default_fleet_retry_count(),
1585 retry_delay_ms: default_fleet_retry_delay_ms(),
1586 }
1587 }
1588}
1589
1590impl FleetConfig {
1591 pub fn ssh_connect_timeout(&self) -> std::time::Duration {
1593 std::time::Duration::from_secs(self.ssh_connect_timeout_secs)
1594 }
1595
1596 pub fn ssh_command_timeout(&self) -> std::time::Duration {
1598 std::time::Duration::from_secs(self.ssh_command_timeout_secs)
1599 }
1600
1601 pub fn retry_delay(&self) -> std::time::Duration {
1603 std::time::Duration::from_millis(self.retry_delay_ms)
1604 }
1605}
1606
1607#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1610#[serde(rename_all = "lowercase")]
1611pub enum BoxRole {
1612 Dispatcher,
1617 Worker,
1620 #[default]
1622 Hybrid,
1623}
1624
1625impl BoxRole {
1626 #[must_use]
1628 pub fn as_str(self) -> &'static str {
1629 match self {
1630 BoxRole::Dispatcher => "dispatcher",
1631 BoxRole::Worker => "worker",
1632 BoxRole::Hybrid => "hybrid",
1633 }
1634 }
1635}
1636
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638pub struct GeneralConfig {
1639 #[serde(default = "default_true")]
1641 pub enabled: bool,
1642 #[serde(default)]
1645 pub role: BoxRole,
1646 #[serde(default)]
1650 pub force_local: bool,
1651 #[serde(default)]
1656 pub force_remote: bool,
1657 #[serde(default = "default_log_level")]
1659 pub log_level: String,
1660 #[serde(default = "default_socket_path")]
1662 pub socket_path: String,
1663}
1664
1665#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1667pub struct EnvironmentConfig {
1668 #[serde(default)]
1670 pub allowlist: Vec<String>,
1671}
1672
1673impl Default for GeneralConfig {
1674 fn default() -> Self {
1675 Self {
1676 enabled: true,
1677 role: BoxRole::default(),
1678 force_local: false,
1679 force_remote: false,
1680 log_level: "info".to_string(),
1681 socket_path: default_socket_path(),
1682 }
1683 }
1684}
1685
1686#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1688#[serde(rename_all = "lowercase")]
1689pub enum OutputVisibility {
1690 #[default]
1691 None,
1692 Summary,
1693 Verbose,
1694}
1695
1696impl std::fmt::Display for OutputVisibility {
1697 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1698 let value = match self {
1699 OutputVisibility::None => "none",
1700 OutputVisibility::Summary => "summary",
1701 OutputVisibility::Verbose => "verbose",
1702 };
1703 write!(f, "{}", value)
1704 }
1705}
1706
1707impl std::str::FromStr for OutputVisibility {
1708 type Err = ();
1709
1710 fn from_str(s: &str) -> Result<Self, Self::Err> {
1711 match s.trim().to_lowercase().as_str() {
1712 "none" | "silent" | "quiet" => Ok(Self::None),
1713 "summary" | "short" => Ok(Self::Summary),
1714 "verbose" | "debug" => Ok(Self::Verbose),
1715 _ => Err(()),
1716 }
1717 }
1718}
1719
1720#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1725#[serde(rename_all = "lowercase")]
1726pub enum ColorMode {
1727 #[default]
1730 Always,
1731 Auto,
1733 Never,
1735}
1736
1737impl std::fmt::Display for ColorMode {
1738 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1739 let value = match self {
1740 ColorMode::Always => "always",
1741 ColorMode::Auto => "auto",
1742 ColorMode::Never => "never",
1743 };
1744 write!(f, "{}", value)
1745 }
1746}
1747
1748impl std::str::FromStr for ColorMode {
1749 type Err = ();
1750
1751 fn from_str(s: &str) -> Result<Self, Self::Err> {
1752 match s.trim().to_lowercase().as_str() {
1753 "always" | "force" | "yes" | "true" => Ok(Self::Always),
1754 "auto" | "detect" => Ok(Self::Auto),
1755 "never" | "none" | "no" | "false" => Ok(Self::Never),
1756 _ => Err(()),
1757 }
1758 }
1759}
1760
1761fn default_color_mode() -> ColorMode {
1762 ColorMode::default()
1763}
1764
1765#[derive(Debug, Clone, Serialize, Deserialize)]
1767pub struct OutputConfig {
1768 #[serde(default = "default_output_visibility")]
1770 pub visibility: OutputVisibility,
1771 #[serde(default)]
1773 pub first_run_complete: bool,
1774 #[serde(default = "default_color_mode")]
1777 pub color_mode: ColorMode,
1778}
1779
1780impl Default for OutputConfig {
1781 fn default() -> Self {
1782 Self {
1783 visibility: OutputVisibility::None,
1784 first_run_complete: false,
1785 color_mode: ColorMode::default(),
1786 }
1787 }
1788}
1789
1790fn default_autostart_cooldown_secs() -> u64 {
1795 30
1796}
1797
1798fn default_autostart_timeout_secs() -> u64 {
1799 3
1800}
1801
1802#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1808#[serde(rename_all = "lowercase")]
1809pub enum SelfHealingLogLevel {
1810 Debug,
1811 #[default]
1812 Info,
1813 Warn,
1814 Error,
1815}
1816
1817impl SelfHealingLogLevel {
1818 pub fn from_env_str(s: &str) -> Option<Self> {
1820 match s.trim().to_ascii_lowercase().as_str() {
1821 "debug" => Some(Self::Debug),
1822 "info" => Some(Self::Info),
1823 "warn" | "warning" => Some(Self::Warn),
1824 "error" => Some(Self::Error),
1825 _ => None,
1826 }
1827 }
1828}
1829
1830#[derive(Debug, Clone, Serialize, Deserialize)]
1832pub struct SelfHealingConfig {
1833 #[serde(default = "default_true")]
1835 pub hook_starts_daemon: bool,
1836 #[serde(default = "default_true")]
1838 pub daemon_installs_hooks: bool,
1839 #[serde(default = "default_autostart_cooldown_secs")]
1841 pub auto_start_cooldown_secs: u64,
1842 #[serde(
1845 default = "default_autostart_timeout_secs",
1846 alias = "daemon_start_timeout"
1847 )]
1848 pub auto_start_timeout_secs: u64,
1849 #[serde(default)]
1855 pub self_healing_log_level: SelfHealingLogLevel,
1856}
1857
1858impl Default for SelfHealingConfig {
1859 fn default() -> Self {
1860 Self {
1861 hook_starts_daemon: default_true(),
1862 daemon_installs_hooks: default_true(),
1863 auto_start_cooldown_secs: default_autostart_cooldown_secs(),
1864 auto_start_timeout_secs: default_autostart_timeout_secs(),
1865 self_healing_log_level: SelfHealingLogLevel::default(),
1866 }
1867 }
1868}
1869
1870impl SelfHealingConfig {
1871 pub fn with_env_overrides(mut self) -> Self {
1880 if let Ok(val) = std::env::var("RCH_NO_SELF_HEALING")
1882 && (val == "1" || val.eq_ignore_ascii_case("true"))
1883 {
1884 self.hook_starts_daemon = false;
1885 self.daemon_installs_hooks = false;
1886 return self;
1887 }
1888
1889 if let Ok(val) = std::env::var("RCH_HOOK_STARTS_DAEMON") {
1891 self.hook_starts_daemon = val != "0" && !val.eq_ignore_ascii_case("false");
1892 }
1893 if let Ok(val) = std::env::var("RCH_DAEMON_INSTALLS_HOOKS") {
1894 self.daemon_installs_hooks = val != "0" && !val.eq_ignore_ascii_case("false");
1895 }
1896
1897 if let Ok(val) = std::env::var("RCH_AUTO_START_TIMEOUT_SECS")
1899 && let Ok(secs) = val.parse()
1900 {
1901 self.auto_start_timeout_secs = secs;
1902 }
1903 if let Ok(val) = std::env::var("RCH_AUTO_START_COOLDOWN_SECS")
1904 && let Ok(secs) = val.parse()
1905 {
1906 self.auto_start_cooldown_secs = secs;
1907 }
1908 if let Ok(val) = std::env::var("RCH_SELF_HEALING_LOG_LEVEL") {
1910 if let Some(level) = SelfHealingLogLevel::from_env_str(&val) {
1911 self.self_healing_log_level = level;
1912 } else {
1913 tracing::warn!(
1914 target: "rch::self_healing",
1915 invalid = %val,
1916 "RCH_SELF_HEALING_LOG_LEVEL has invalid value; using default=info"
1917 );
1918 }
1919 }
1920
1921 self
1922 }
1923}
1924
1925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1931#[serde(rename_all = "snake_case")]
1932pub enum SelfTestFailureAction {
1933 #[default]
1935 Alert,
1936 DisableWorker,
1938 AlertAndDisable,
1940}
1941
1942#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1944#[serde(untagged)]
1945pub enum SelfTestWorkers {
1946 All(String),
1948 List(Vec<WorkerId>),
1950}
1951
1952impl Default for SelfTestWorkers {
1953 fn default() -> Self {
1954 Self::All("all".to_string())
1955 }
1956}
1957
1958impl SelfTestWorkers {
1959 pub fn resolve(&self) -> Option<Vec<WorkerId>> {
1961 match self {
1962 SelfTestWorkers::All(value) => {
1963 if value.eq_ignore_ascii_case("all") {
1964 None
1965 } else {
1966 Some(vec![WorkerId::new(value.clone())])
1967 }
1968 }
1969 SelfTestWorkers::List(list) => Some(list.clone()),
1970 }
1971 }
1972}
1973
1974fn default_self_test_retry_count() -> u32 {
1975 3
1976}
1977
1978fn default_self_test_retry_delay() -> String {
1979 "5m".to_string()
1980}
1981
1982fn default_self_test_enabled() -> bool {
1983 false
1984}
1985
1986#[derive(Debug, Clone, Serialize, Deserialize)]
1988pub struct SelfTestConfig {
1989 #[serde(default = "default_self_test_enabled")]
1991 pub enabled: bool,
1992 #[serde(default)]
1994 pub schedule: Option<String>,
1995 #[serde(default)]
1997 pub interval: Option<String>,
1998 #[serde(default)]
2000 pub workers: SelfTestWorkers,
2001 #[serde(default)]
2003 pub on_failure: SelfTestFailureAction,
2004 #[serde(default = "default_self_test_retry_count")]
2006 pub retry_count: u32,
2007 #[serde(default = "default_self_test_retry_delay")]
2009 pub retry_delay: String,
2010}
2011
2012impl Default for SelfTestConfig {
2013 fn default() -> Self {
2014 Self {
2015 enabled: default_self_test_enabled(),
2016 schedule: None,
2017 interval: None,
2018 workers: SelfTestWorkers::default(),
2019 on_failure: SelfTestFailureAction::default(),
2020 retry_count: default_self_test_retry_count(),
2021 retry_delay: default_self_test_retry_delay(),
2022 }
2023 }
2024}
2025
2026#[derive(Debug, Clone, Serialize, Deserialize)]
2028pub struct CircuitBreakerConfig {
2029 #[serde(default = "default_circuit_failure_threshold")]
2031 pub failure_threshold: u32,
2032 #[serde(default = "default_circuit_success_threshold")]
2034 pub success_threshold: u32,
2035 #[serde(default = "default_circuit_error_rate_threshold")]
2037 pub error_rate_threshold: f64,
2038 #[serde(default = "default_circuit_window_secs")]
2040 pub window_secs: u64,
2041 #[serde(default = "default_circuit_open_cooldown_secs")]
2043 pub open_cooldown_secs: u64,
2044 #[serde(default = "default_circuit_half_open_max_probes")]
2046 pub half_open_max_probes: u32,
2047 #[serde(default = "default_circuit_command_failure_threshold")]
2058 pub command_failure_threshold: u32,
2059 #[serde(default = "default_circuit_open_cooldown_max_secs")]
2067 pub open_cooldown_max_secs: u64,
2068}
2069
2070impl Default for CircuitBreakerConfig {
2071 fn default() -> Self {
2072 Self {
2073 failure_threshold: default_circuit_failure_threshold(),
2074 success_threshold: default_circuit_success_threshold(),
2075 error_rate_threshold: default_circuit_error_rate_threshold(),
2076 window_secs: default_circuit_window_secs(),
2077 open_cooldown_secs: default_circuit_open_cooldown_secs(),
2078 half_open_max_probes: default_circuit_half_open_max_probes(),
2079 command_failure_threshold: default_circuit_command_failure_threshold(),
2080 open_cooldown_max_secs: default_circuit_open_cooldown_max_secs(),
2081 }
2082 }
2083}
2084
2085impl CircuitBreakerConfig {
2086 pub fn cooldown_ms_for_streak(&self, reopen_streak: u32) -> u64 {
2090 let base_ms = self.open_cooldown_secs.saturating_mul(1000);
2091 let cap_ms = self
2092 .open_cooldown_max_secs
2093 .saturating_mul(1000)
2094 .max(base_ms);
2095 let exponent = reopen_streak.saturating_sub(1).min(16);
2097 base_ms.saturating_mul(1u64 << exponent).min(cap_ms)
2098 }
2099}
2100
2101#[derive(Debug, Clone, Default)]
2106pub struct CircuitStats {
2107 state: CircuitState,
2109 consecutive_failures: u32,
2111 consecutive_successes: u32,
2113 window_successes: u32,
2115 window_failures: u32,
2117 opened_at: Option<u64>,
2119 last_state_change: u64,
2121 active_probes: u32,
2123 consecutive_command_failures: u32,
2128 reopen_streak: u32,
2131 recent_results: Vec<bool>,
2134}
2135
2136const CIRCUIT_HISTORY_SIZE: usize = 10;
2138
2139impl CircuitStats {
2140 pub fn new() -> Self {
2142 Self {
2143 state: CircuitState::Closed,
2144 consecutive_failures: 0,
2145 consecutive_successes: 0,
2146 window_successes: 0,
2147 window_failures: 0,
2148 opened_at: None,
2149 last_state_change: Self::now_millis(),
2150 active_probes: 0,
2151 consecutive_command_failures: 0,
2152 reopen_streak: 0,
2153 recent_results: Vec::with_capacity(CIRCUIT_HISTORY_SIZE),
2154 }
2155 }
2156
2157 pub fn consecutive_command_failures(&self) -> u32 {
2160 self.consecutive_command_failures
2161 }
2162
2163 pub fn reopen_streak(&self) -> u32 {
2166 self.reopen_streak
2167 }
2168
2169 fn effective_cooldown_ms(&self, config: &CircuitBreakerConfig) -> u64 {
2171 config.cooldown_ms_for_streak(self.reopen_streak.max(1))
2172 }
2173
2174 pub fn state(&self) -> CircuitState {
2176 self.state
2177 }
2178
2179 pub fn opened_at(&self) -> Option<u64> {
2181 self.opened_at
2182 }
2183
2184 pub fn last_state_change(&self) -> u64 {
2186 self.last_state_change
2187 }
2188
2189 pub fn consecutive_failures(&self) -> u32 {
2191 self.consecutive_failures
2192 }
2193
2194 pub fn error_rate(&self) -> f64 {
2204 if self.recent_results.is_empty() {
2205 return 0.0;
2206 }
2207 let failures = self.recent_results.iter().filter(|&&ok| !ok).count();
2208 failures as f64 / self.recent_results.len() as f64
2209 }
2210
2211 pub fn record_success(&mut self) {
2216 self.window_successes += 1;
2217 self.consecutive_failures = 0;
2218 self.push_result(true);
2219
2220 if self.state == CircuitState::HalfOpen {
2221 self.consecutive_successes += 1;
2222 if self.active_probes > 0 {
2223 self.active_probes -= 1;
2224 }
2225 }
2226 }
2227
2228 pub fn record_failure(&mut self) {
2233 self.window_failures += 1;
2234 self.consecutive_failures += 1;
2235 self.push_result(false);
2236
2237 if self.state == CircuitState::HalfOpen {
2238 self.consecutive_successes = 0;
2239 if self.active_probes > 0 {
2240 self.active_probes -= 1;
2241 }
2242 }
2243 }
2244
2245 pub fn should_open(&self, config: &CircuitBreakerConfig) -> bool {
2251 if self.state != CircuitState::Closed {
2252 return false;
2253 }
2254
2255 if self.consecutive_failures >= config.failure_threshold {
2257 return true;
2258 }
2259
2260 if self.recent_results.len() >= 5 && self.error_rate() >= config.error_rate_threshold {
2264 return true;
2265 }
2266
2267 false
2268 }
2269
2270 pub fn should_half_open(&self, config: &CircuitBreakerConfig) -> bool {
2274 if self.state != CircuitState::Open {
2275 return false;
2276 }
2277
2278 let now = Self::now_millis();
2279 let cooldown_ms = self.effective_cooldown_ms(config);
2280
2281 if let Some(opened_at) = self.opened_at {
2282 now.saturating_sub(opened_at) >= cooldown_ms
2283 } else {
2284 false
2285 }
2286 }
2287
2288 pub fn should_close(&self, config: &CircuitBreakerConfig) -> bool {
2297 if self.state != CircuitState::HalfOpen {
2298 return false;
2299 }
2300
2301 self.consecutive_successes >= config.success_threshold
2302 && self.consecutive_command_failures == 0
2303 }
2304
2305 pub fn can_probe(&self, config: &CircuitBreakerConfig) -> bool {
2309 if self.state != CircuitState::HalfOpen {
2310 return false;
2311 }
2312
2313 self.active_probes < config.half_open_max_probes
2314 }
2315
2316 pub fn start_probe(&mut self, config: &CircuitBreakerConfig) -> bool {
2320 if !self.can_probe(config) {
2321 return false;
2322 }
2323 self.active_probes += 1;
2324 true
2325 }
2326
2327 pub fn open(&mut self) {
2329 if self.state != CircuitState::Open {
2330 self.state = CircuitState::Open;
2331 self.opened_at = Some(Self::now_millis());
2332 self.last_state_change = Self::now_millis();
2333 self.consecutive_successes = 0;
2334 self.active_probes = 0;
2335 self.reopen_streak = self.reopen_streak.saturating_add(1);
2338 }
2339 }
2340
2341 pub fn half_open(&mut self) {
2343 if self.state != CircuitState::HalfOpen {
2344 self.state = CircuitState::HalfOpen;
2345 self.last_state_change = Self::now_millis();
2346 self.consecutive_successes = 0;
2347 self.active_probes = 0;
2348 }
2349 }
2350
2351 pub fn close(&mut self) {
2353 if self.state != CircuitState::Closed {
2354 self.state = CircuitState::Closed;
2355 self.last_state_change = Self::now_millis();
2356 self.opened_at = None;
2357 self.consecutive_failures = 0;
2358 self.consecutive_successes = 0;
2359 self.active_probes = 0;
2360 self.consecutive_command_failures = 0;
2361 self.reopen_streak = 0;
2362 self.window_successes = 0;
2368 self.window_failures = 0;
2369 self.recent_results.clear();
2370 }
2371 }
2372
2373 pub fn reset_window(&mut self) {
2379 self.window_successes = 0;
2380 self.window_failures = 0;
2381 self.recent_results.clear();
2382 }
2383
2384 pub fn recent_results(&self) -> &[bool] {
2389 &self.recent_results
2390 }
2391
2392 pub fn recovery_remaining_secs(&self, config: &CircuitBreakerConfig) -> Option<u64> {
2396 if self.state != CircuitState::Open {
2397 return None;
2398 }
2399
2400 let now = Self::now_millis();
2401 let cooldown_ms = self.effective_cooldown_ms(config);
2402
2403 if let Some(opened_at) = self.opened_at {
2404 let elapsed_ms = now.saturating_sub(opened_at);
2405 if elapsed_ms < cooldown_ms {
2406 return Some((cooldown_ms - elapsed_ms) / 1000);
2407 }
2408 }
2409 None
2410 }
2411
2412 fn push_result(&mut self, success: bool) {
2414 if self.recent_results.len() >= CIRCUIT_HISTORY_SIZE {
2415 self.recent_results.remove(0);
2416 }
2417 self.recent_results.push(success);
2418 }
2419
2420 fn now_millis() -> u64 {
2421 std::time::SystemTime::now()
2422 .duration_since(std::time::UNIX_EPOCH)
2423 .map(|d| d.as_millis() as u64)
2424 .unwrap_or(0)
2425 }
2426
2427 pub fn apply_health_outcome(
2453 &mut self,
2454 healthy: bool,
2455 transient: bool,
2456 config: &CircuitBreakerConfig,
2457 ) -> CircuitState {
2458 if healthy {
2459 if self.state == CircuitState::Open && self.should_half_open(config) {
2463 self.half_open();
2464 }
2465 self.record_success();
2466 if self.should_close(config) {
2467 self.close();
2468 }
2469 } else {
2470 let was_half_open = self.state == CircuitState::HalfOpen;
2471 self.record_failure();
2472 if self.should_open(config) {
2473 self.open();
2474 } else if was_half_open && !transient {
2475 self.open();
2479 }
2480 }
2481
2482 if self.state == CircuitState::Open && self.should_half_open(config) {
2485 self.half_open();
2486 }
2487
2488 self.state
2489 }
2490
2491 pub fn apply_command_outcome(
2515 &mut self,
2516 success: bool,
2517 config: &CircuitBreakerConfig,
2518 ) -> CircuitState {
2519 if success {
2520 self.consecutive_command_failures = 0;
2521 return self.apply_health_outcome(true, false, config);
2522 }
2523
2524 self.consecutive_command_failures = self.consecutive_command_failures.saturating_add(1);
2525 let was_half_open = self.state == CircuitState::HalfOpen;
2526 self.record_failure();
2527 let threshold = config.command_failure_threshold.max(1);
2528 if was_half_open
2529 || self.should_open(config)
2530 || (self.state == CircuitState::Closed
2531 && self.consecutive_command_failures >= threshold)
2532 {
2533 self.open();
2534 }
2535
2536 if self.state == CircuitState::Open && self.should_half_open(config) {
2537 self.half_open();
2538 }
2539
2540 self.state
2541 }
2542}
2543
2544#[derive(Debug, Clone, Serialize, Deserialize)]
2545pub struct CompilationConfig {
2546 #[serde(default = "default_confidence")]
2548 pub confidence_threshold: f64,
2549 #[serde(default = "default_min_local_time")]
2552 pub min_local_time_ms: u64,
2553 #[serde(default = "default_speedup_threshold")]
2557 pub remote_speedup_threshold: f64,
2558 #[serde(default)]
2564 pub local_slots: u32,
2565 #[serde(default = "default_build_slots")]
2567 pub build_slots: u32,
2568 #[serde(default = "default_test_slots")]
2571 pub test_slots: u32,
2572 #[serde(default = "default_check_slots")]
2575 pub check_slots: u32,
2576 #[serde(default)]
2582 pub disk_headroom_gib: u32,
2583 #[serde(default = "default_build_timeout")]
2586 pub build_timeout_sec: u64,
2587 #[serde(default = "default_test_timeout")]
2590 pub test_timeout_sec: u64,
2591 #[serde(default = "default_bun_timeout")]
2595 pub bun_timeout_sec: u64,
2596 #[serde(default = "default_external_timeout_enabled")]
2600 pub external_timeout_enabled: bool,
2601 #[serde(default = "default_allow_local_fallback")]
2614 pub allow_local_fallback: bool,
2615 #[serde(default)]
2626 pub remote_build_jobs: RemoteBuildJobs,
2627}
2628
2629#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2641pub enum RemoteBuildJobs {
2642 #[default]
2644 Auto,
2645 Off,
2647 Fixed(u32),
2649}
2650
2651impl RemoteBuildJobs {
2652 pub fn parse(value: &str) -> Result<Self, String> {
2654 let trimmed = value.trim();
2655 match trimmed.to_ascii_lowercase().as_str() {
2656 "auto" => Ok(Self::Auto),
2657 "off" | "none" | "disabled" => Ok(Self::Off),
2658 _ => match trimmed.parse::<u32>() {
2659 Ok(0) => Err(
2660 "remote_build_jobs must be >= 1 (cargo rejects `-j 0`); use `off` to disable"
2661 .to_string(),
2662 ),
2663 Ok(n) => Ok(Self::Fixed(n)),
2664 Err(_) => Err(format!(
2665 "'{trimmed}' is not a valid remote_build_jobs value (expected `auto`, `off`, or a positive integer)"
2666 )),
2667 },
2668 }
2669 }
2670}
2671
2672impl std::fmt::Display for RemoteBuildJobs {
2673 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2674 match self {
2675 Self::Auto => f.write_str("auto"),
2676 Self::Off => f.write_str("off"),
2677 Self::Fixed(n) => write!(f, "{n}"),
2678 }
2679 }
2680}
2681
2682impl std::str::FromStr for RemoteBuildJobs {
2683 type Err = String;
2684
2685 fn from_str(value: &str) -> Result<Self, Self::Err> {
2686 Self::parse(value)
2687 }
2688}
2689
2690impl Serialize for RemoteBuildJobs {
2691 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2692 match self {
2693 Self::Auto => serializer.serialize_str("auto"),
2694 Self::Off => serializer.serialize_str("off"),
2695 Self::Fixed(n) => serializer.serialize_u32(*n),
2696 }
2697 }
2698}
2699
2700impl<'de> Deserialize<'de> for RemoteBuildJobs {
2701 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2702 #[derive(Deserialize)]
2703 #[serde(untagged)]
2704 enum Raw {
2705 Count(u64),
2706 Spelling(String),
2707 }
2708 match Raw::deserialize(deserializer)? {
2709 Raw::Count(n) => {
2710 let n = u32::try_from(n).map_err(|_| {
2711 serde::de::Error::custom(format!("remote_build_jobs {n} is out of range"))
2712 })?;
2713 Self::parse(&n.to_string()).map_err(serde::de::Error::custom)
2714 }
2715 Raw::Spelling(s) => Self::parse(&s).map_err(serde::de::Error::custom),
2716 }
2717 }
2718}
2719
2720impl Default for CompilationConfig {
2721 fn default() -> Self {
2722 Self {
2723 confidence_threshold: 0.85,
2724 min_local_time_ms: 2000,
2725 remote_speedup_threshold: default_speedup_threshold(),
2726 local_slots: 0,
2727 build_slots: default_build_slots(),
2728 test_slots: default_test_slots(),
2729 check_slots: default_check_slots(),
2730 disk_headroom_gib: 0,
2731 build_timeout_sec: default_build_timeout(),
2732 test_timeout_sec: default_test_timeout(),
2733 bun_timeout_sec: default_bun_timeout(),
2734 external_timeout_enabled: default_external_timeout_enabled(),
2735 allow_local_fallback: default_allow_local_fallback(),
2736 remote_build_jobs: RemoteBuildJobs::default(),
2737 }
2738 }
2739}
2740
2741fn default_build_slots() -> u32 {
2742 4
2743}
2744
2745fn default_test_slots() -> u32 {
2746 8
2747}
2748
2749fn default_check_slots() -> u32 {
2750 2
2751}
2752
2753fn default_build_timeout() -> u64 {
2756 300
2757}
2758
2759fn default_test_timeout() -> u64 {
2762 1800
2763}
2764
2765fn default_bun_timeout() -> u64 {
2769 600
2770}
2771
2772fn default_external_timeout_enabled() -> bool {
2774 true
2775}
2776
2777fn default_allow_local_fallback() -> bool {
2781 true
2782}
2783
2784impl CompilationConfig {
2785 pub fn timeout_for_kind(&self, kind: Option<CompilationKind>) -> std::time::Duration {
2791 let secs = match kind {
2792 Some(CompilationKind::BunTest) | Some(CompilationKind::BunTypecheck) => {
2794 self.bun_timeout_sec
2795 }
2796 Some(CompilationKind::CargoTest) | Some(CompilationKind::CargoNextest) => {
2798 self.test_timeout_sec
2799 }
2800 Some(CompilationKind::NixBuild) => self.test_timeout_sec,
2804 Some(CompilationKind::GoTest) => self.test_timeout_sec,
2807 _ => self.build_timeout_sec,
2809 };
2810 std::time::Duration::from_secs(secs)
2811 }
2812
2813 pub fn external_timeout_enabled(&self) -> bool {
2815 self.external_timeout_enabled
2816 }
2817}
2818
2819pub const LAYER0_PACK_VERSION: &str = "2";
2825
2826#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
2837#[serde(default)]
2838pub struct Layer0Config {
2839 pub enabled: bool,
2841 pub release_lto_thin: bool,
2843 pub release_codegen_units_1: bool,
2846 pub debug_line_tables_only: bool,
2853}
2854
2855impl Layer0Config {
2856 #[must_use]
2862 pub fn active_env(&self) -> Vec<(&'static str, &'static str)> {
2863 if !self.enabled {
2864 return Vec::new();
2865 }
2866 let mut env = Vec::new();
2867 if self.release_lto_thin {
2868 env.push(("CARGO_PROFILE_RELEASE_LTO", "thin"));
2869 }
2870 if self.release_codegen_units_1 {
2871 env.push(("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"));
2872 }
2873 if self.debug_line_tables_only {
2874 env.push(("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"));
2875 env.push(("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"));
2876 }
2877 env
2878 }
2879}
2880
2881#[derive(Debug, Clone, Serialize, Deserialize)]
2882pub struct TransferConfig {
2883 #[serde(default, skip_serializing_if = "Option::is_none")]
2886 pub retrieval_staging_dir: Option<PathBuf>,
2887 #[serde(default)]
2890 pub retrieval_min_free_bytes: u64,
2891 #[serde(default = "default_compression")]
2893 pub compression_level: u32,
2894 #[serde(default = "default_excludes")]
2896 pub exclude_patterns: Vec<String>,
2897 #[serde(default, skip_serializing_if = "Option::is_none")]
2901 pub ssh_server_alive_interval_secs: Option<u64>,
2902 #[serde(default, skip_serializing_if = "Option::is_none")]
2907 pub ssh_control_persist_secs: Option<u64>,
2908 #[serde(default = "default_remote_base")]
2919 pub remote_base: String,
2920 #[serde(default, skip_serializing_if = "Option::is_none")]
2926 pub sync_timeout_ms: Option<u64>,
2927 #[serde(default = "default_source_sync_silence_timeout_secs")]
2939 pub source_sync_silence_timeout_secs: u64,
2940 #[serde(default)]
2942 pub retry: RetryConfig,
2943 #[serde(default)]
2950 pub verify_artifacts: bool,
2951 #[serde(default = "default_verify_max_size")]
2956 pub verify_max_size_bytes: u64,
2957
2958 #[serde(default, skip_serializing_if = "Option::is_none")]
2967 pub max_transfer_mb: Option<u64>,
2968
2969 #[serde(default, skip_serializing_if = "Option::is_none")]
2975 pub max_transfer_time_ms: Option<u64>,
2976
2977 #[serde(default, skip_serializing_if = "Option::is_none")]
2982 pub bwlimit_kbps: Option<u64>,
2983
2984 #[serde(default, skip_serializing_if = "Option::is_none")]
2993 pub rsync_bin: Option<String>,
2994
2995 #[serde(default, skip_serializing_if = "Option::is_none")]
3000 pub estimated_bandwidth_bps: Option<u64>,
3001
3002 #[serde(default)]
3015 pub adaptive_compression: bool,
3016
3017 #[serde(default = "default_min_compression")]
3022 pub min_compression_level: u32,
3023
3024 #[serde(default = "default_max_compression")]
3029 pub max_compression_level: u32,
3030}
3031
3032impl Default for TransferConfig {
3033 fn default() -> Self {
3034 Self {
3035 retrieval_staging_dir: None,
3036 retrieval_min_free_bytes: 0,
3037 compression_level: 3,
3038 exclude_patterns: default_excludes(),
3039 ssh_server_alive_interval_secs: None,
3040 ssh_control_persist_secs: None,
3041 remote_base: default_remote_base(),
3042 sync_timeout_ms: None,
3043 source_sync_silence_timeout_secs: default_source_sync_silence_timeout_secs(),
3044 retry: RetryConfig::default(),
3045 verify_artifacts: false,
3046 verify_max_size_bytes: default_verify_max_size(),
3047 max_transfer_mb: None,
3049 max_transfer_time_ms: None,
3050 bwlimit_kbps: None,
3051 rsync_bin: None,
3052 estimated_bandwidth_bps: None,
3053 adaptive_compression: false,
3055 min_compression_level: default_min_compression(),
3056 max_compression_level: default_max_compression(),
3057 }
3058 }
3059}
3060
3061impl TransferConfig {
3062 pub const MIN_SYNC_TIMEOUT_MS: u64 = 1_000;
3064 pub const MAX_SYNC_TIMEOUT_MS: u64 = 3_600_000;
3066 pub const DEFAULT_SYNC_TIMEOUT_FLOOR_MS: u64 = 30_000;
3068 const DEFAULT_SYNC_BYTES_PER_SECOND: u64 = 1024 * 1024;
3069
3070 #[must_use]
3072 pub fn valid_sync_timeout_ms(value: u64) -> bool {
3073 (Self::MIN_SYNC_TIMEOUT_MS..=Self::MAX_SYNC_TIMEOUT_MS).contains(&value)
3074 }
3075
3076 #[must_use]
3083 pub fn sync_timeout_for_payload(&self, payload_bytes: u64) -> std::time::Duration {
3084 let timeout_ms = self
3085 .sync_timeout_ms
3086 .filter(|value| Self::valid_sync_timeout_ms(*value))
3087 .unwrap_or_else(|| {
3088 let transfer_seconds = payload_bytes
3089 .saturating_add(Self::DEFAULT_SYNC_BYTES_PER_SECOND - 1)
3090 / Self::DEFAULT_SYNC_BYTES_PER_SECOND;
3091 Self::DEFAULT_SYNC_TIMEOUT_FLOOR_MS
3092 .saturating_add(transfer_seconds.saturating_mul(1_000))
3093 .min(Self::MAX_SYNC_TIMEOUT_MS)
3094 });
3095 std::time::Duration::from_millis(timeout_ms)
3096 }
3097
3098 pub fn select_compression_level(&self, estimated_bytes: Option<u64>) -> u32 {
3110 if !self.adaptive_compression {
3111 return self.compression_level;
3112 }
3113
3114 let Some(bytes) = estimated_bytes else {
3115 return self.compression_level;
3117 };
3118
3119 const SMALL_THRESHOLD: u64 = 10_000_000; const LARGE_THRESHOLD: u64 = 200_000_000; let level = if bytes < SMALL_THRESHOLD {
3124 1 } else if bytes < LARGE_THRESHOLD {
3126 3 } else {
3128 7 };
3130
3131 level.clamp(self.min_compression_level, self.max_compression_level)
3133 }
3134}
3135
3136#[rustfmt::skip]
3150pub const BUILTIN_EXECUTION_BASES: &[&str] = &[
3151 "cargo",
3153 "rustc",
3154 "nextest",
3156 "gcc",
3158 "g++",
3159 "clang",
3160 "clang++",
3161 "cc",
3162 "c++",
3163 "make",
3165 "cmake",
3166 "ninja",
3167 "meson",
3168 "bun",
3170 "nix",
3172 "go",
3174 "tsc",
3176];
3177
3178fn default_execution_allowlist() -> Vec<String> {
3179 BUILTIN_EXECUTION_BASES
3180 .iter()
3181 .map(|base| (*base).to_string())
3182 .collect()
3183}
3184
3185#[derive(Debug, Clone, Serialize, Deserialize)]
3190pub struct ExecutionConfig {
3191 #[serde(default = "default_execution_allowlist")]
3200 pub allowlist: Vec<String>,
3201}
3202
3203impl Default for ExecutionConfig {
3204 fn default() -> Self {
3205 Self {
3206 allowlist: default_execution_allowlist(),
3207 }
3208 }
3209}
3210
3211impl ExecutionConfig {
3212 pub fn is_allowed(&self, command_base: &str) -> bool {
3214 self.allowlist
3215 .iter()
3216 .any(|allowed| allowed.eq_ignore_ascii_case(command_base))
3217 }
3218
3219 pub fn missing_builtin_bases(&self) -> Vec<&'static str> {
3225 BUILTIN_EXECUTION_BASES
3226 .iter()
3227 .copied()
3228 .filter(|base| !self.is_allowed(base))
3229 .collect()
3230 }
3231}
3232
3233fn default_retry_max_attempts() -> u32 {
3238 3
3239}
3240
3241fn default_retry_base_delay_ms() -> u64 {
3242 100
3243}
3244
3245fn default_retry_max_delay_ms() -> u64 {
3246 5000
3247}
3248
3249fn default_retry_jitter_factor() -> f64 {
3250 0.1
3251}
3252
3253fn default_retry_total_timeout_ms() -> u64 {
3254 30000
3255}
3256
3257#[derive(Debug, Clone, Serialize, Deserialize)]
3262pub struct RetryConfig {
3263 #[serde(default = "default_retry_max_attempts")]
3265 pub max_attempts: u32,
3266
3267 #[serde(default = "default_retry_base_delay_ms")]
3269 pub base_delay_ms: u64,
3270
3271 #[serde(default = "default_retry_max_delay_ms")]
3273 pub max_delay_ms: u64,
3274
3275 #[serde(default = "default_retry_jitter_factor")]
3278 pub jitter_factor: f64,
3279
3280 #[serde(default = "default_retry_total_timeout_ms")]
3283 pub total_timeout_ms: u64,
3284}
3285
3286impl Default for RetryConfig {
3287 fn default() -> Self {
3288 Self {
3289 max_attempts: default_retry_max_attempts(),
3290 base_delay_ms: default_retry_base_delay_ms(),
3291 max_delay_ms: default_retry_max_delay_ms(),
3292 jitter_factor: default_retry_jitter_factor(),
3293 total_timeout_ms: default_retry_total_timeout_ms(),
3294 }
3295 }
3296}
3297
3298impl RetryConfig {
3299 pub fn no_retry() -> Self {
3301 Self {
3302 max_attempts: 1,
3303 ..Default::default()
3304 }
3305 }
3306
3307 pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
3311 if attempt == 0 {
3312 return std::time::Duration::ZERO;
3313 }
3314
3315 let base = self.base_delay_ms as f64;
3317 let delay = base * (2.0_f64.powi(attempt as i32 - 1));
3318 let capped = delay.min(self.max_delay_ms as f64);
3319
3320 let jittered = if self.jitter_factor > 0.0 {
3322 let jitter_range = capped * self.jitter_factor;
3323 let jitter = (rand::rng().random::<f64>() * 2.0 - 1.0) * jitter_range;
3324 (capped + jitter).max(0.0)
3325 } else {
3326 capped
3327 };
3328
3329 std::time::Duration::from_millis(jittered as u64)
3330 }
3331
3332 pub fn should_retry(&self, attempt: u32, elapsed: std::time::Duration) -> bool {
3334 if attempt >= self.max_attempts {
3335 return false;
3336 }
3337 elapsed.as_millis() < self.total_timeout_ms as u128
3338 }
3339}
3340
3341fn default_source_sync_silence_timeout_secs() -> u64 {
3346 120
3347}
3348
3349pub fn default_remote_base() -> String {
3358 "/data/tmp/rch".to_string()
3359}
3360
3361pub fn validate_remote_base(path: &str) -> Result<String, String> {
3364 let expanded = shellexpand::tilde(path).into_owned();
3366
3367 if !expanded.starts_with('/') {
3369 return Err(format!(
3370 "remote_base must be an absolute path, got: {}",
3371 path
3372 ));
3373 }
3374
3375 if expanded.contains("..") {
3377 return Err(format!(
3378 "remote_base must not contain path traversal (..): {}",
3379 path
3380 ));
3381 }
3382
3383 let normalized = expanded.trim_end_matches('/').to_string();
3385
3386 if normalized.is_empty() || normalized == "/" {
3388 return Err("remote_base cannot be the root directory (safety restriction)".to_string());
3389 }
3390
3391 let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
3395 if components.len() < 2 {
3396 return Err(format!(
3397 "remote_base must be at least 2 levels deep (e.g. /tmp/rch), got: {}",
3398 normalized
3399 ));
3400 }
3401
3402 Ok(normalized)
3403}
3404
3405fn default_circuit_failure_threshold() -> u32 {
3406 3
3407}
3408
3409fn default_circuit_success_threshold() -> u32 {
3410 2
3411}
3412
3413fn default_circuit_error_rate_threshold() -> f64 {
3414 0.5
3415}
3416
3417fn default_circuit_window_secs() -> u64 {
3418 60
3419}
3420
3421fn default_circuit_open_cooldown_secs() -> u64 {
3422 30
3423}
3424
3425fn default_circuit_half_open_max_probes() -> u32 {
3426 1
3427}
3428
3429fn default_circuit_command_failure_threshold() -> u32 {
3430 2
3431}
3432
3433fn default_circuit_open_cooldown_max_secs() -> u64 {
3434 480
3436}
3437
3438fn default_true() -> bool {
3439 true
3440}
3441
3442fn default_log_level() -> String {
3443 "info".to_string()
3444}
3445
3446pub fn default_socket_path() -> String {
3447 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR")
3449 && !runtime_dir.trim().is_empty()
3450 {
3451 let path = PathBuf::from(runtime_dir).join("rch.sock");
3452 return path.to_string_lossy().to_string();
3453 }
3454
3455 if let Some(cache_dir) = dirs::cache_dir() {
3457 let rch_cache = cache_dir.join("rch");
3458 let _ = std::fs::create_dir_all(&rch_cache);
3459 return rch_cache.join("rch.sock").to_string_lossy().to_string();
3460 }
3461
3462 "/tmp/rch.sock".to_string()
3464}
3465
3466#[allow(dead_code)]
3467fn default_output_visibility() -> OutputVisibility {
3468 OutputVisibility::None
3469}
3470
3471fn default_confidence() -> f64 {
3472 0.85
3473}
3474
3475fn default_min_local_time() -> u64 {
3476 2000
3477}
3478
3479fn default_speedup_threshold() -> f64 {
3483 1.2
3484}
3485
3486fn default_compression() -> u32 {
3487 3
3488}
3489
3490fn default_verify_max_size() -> u64 {
3492 100 * 1024 * 1024 }
3494
3495fn default_min_compression() -> u32 {
3497 1
3498}
3499
3500fn default_max_compression() -> u32 {
3502 9
3503}
3504
3505fn default_excludes() -> Vec<String> {
3506 vec![
3507 "target/".to_string(),
3509 "*.rlib".to_string(),
3510 "*.rmeta".to_string(),
3511 ".git/".to_string(),
3514 "node_modules/".to_string(),
3516 ".bun/".to_string(),
3518 ".npm/".to_string(),
3520 ".pnpm-store/".to_string(),
3521 "dist/".to_string(),
3523 "build/".to_string(),
3524 ".next/".to_string(),
3526 ".nuxt/".to_string(),
3527 ".turbo/".to_string(),
3528 ".parcel-cache/".to_string(),
3529 ".beads/".to_string(),
3531 ".doctor/".to_string(),
3533 "coverage/".to_string(),
3535 ".nyc_output/".to_string(),
3536 ".cargo/credentials".to_string(),
3538 ".cargo/credentials.toml".to_string(),
3539 ".env".to_string(),
3540 ".env.*".to_string(),
3541 "*.pem".to_string(),
3542 "*.key".to_string(),
3543 "credentials.json".to_string(),
3544 "secrets.json".to_string(),
3545 "secrets.yaml".to_string(),
3546 "secrets.yml".to_string(),
3547 "secrets.toml".to_string(),
3548 "secrets.env".to_string(),
3549 "secrets.txt".to_string(),
3550 "secrets.conf".to_string(),
3551 "secrets.cfg".to_string(),
3552 "secrets.properties".to_string(),
3553 "secrets.xml".to_string(),
3554 "id_rsa".to_string(),
3568 "id_ed25519".to_string(),
3569 "id_ecdsa".to_string(),
3570 "id_dsa".to_string(),
3571 "*.p12".to_string(),
3573 "*.pfx".to_string(),
3574 "*.keystore".to_string(),
3575 "*.jks".to_string(),
3576 ".netrc".to_string(),
3579 "_netrc".to_string(),
3580 ".aws/credentials".to_string(),
3581 ".docker/config.json".to_string(),
3582 ".npmrc".to_string(),
3586 ".pypirc".to_string(),
3587 "secring.*".to_string(),
3589 ]
3590}
3591
3592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3598#[serde(rename_all = "snake_case")]
3599pub enum BuildLocation {
3600 #[default]
3602 Local,
3603 Remote,
3605}
3606
3607#[derive(Debug, Clone, Serialize, Deserialize)]
3609pub struct BuildCancellationWorkerHealth {
3610 pub status: String,
3612 pub speed_score: f64,
3614 pub used_slots: u32,
3616 pub available_slots: u32,
3618 pub pressure_state: String,
3620 pub pressure_reason_code: String,
3622}
3623
3624#[derive(Debug, Clone, Serialize, Deserialize)]
3626pub struct BuildCancellationMetadata {
3627 pub operation_id: String,
3629 pub origin: String,
3631 pub reason_code: String,
3633 #[serde(default)]
3635 pub decision_path: Vec<String>,
3636 pub escalation_stage: String,
3638 pub escalation_count: u32,
3640 pub remote_kill_attempted: bool,
3642 pub cleanup_ok: bool,
3644 pub history_cancelled: bool,
3646 pub final_state: String,
3648 #[serde(default, skip_serializing_if = "Option::is_none")]
3650 pub worker_health: Option<BuildCancellationWorkerHealth>,
3651}
3652
3653#[derive(Debug, Clone, Serialize, Deserialize)]
3655pub struct BuildRecord {
3656 pub id: u64,
3658 pub started_at: String,
3660 pub completed_at: String,
3662 pub project_id: String,
3664 pub worker_id: Option<String>,
3666 pub command: String,
3668 pub exit_code: i32,
3670 pub duration_ms: u64,
3672 pub location: BuildLocation,
3674 pub bytes_transferred: Option<u64>,
3676 #[serde(default)]
3678 pub timing: Option<CommandTimingBreakdown>,
3679 #[serde(default, skip_serializing_if = "Option::is_none")]
3681 pub cancellation: Option<BuildCancellationMetadata>,
3682}
3683
3684#[derive(Debug, Clone, Serialize, Deserialize)]
3686pub struct BuildRecordInput {
3687 pub started_at: String,
3689 pub completed_at: String,
3691 pub project_id: String,
3693 pub worker_id: Option<String>,
3695 pub command: String,
3697 pub exit_code: i32,
3699 pub duration_ms: u64,
3701 pub location: BuildLocation,
3703 pub bytes_transferred: Option<u64>,
3705 #[serde(default)]
3707 pub timing: Option<CommandTimingBreakdown>,
3708 #[serde(default, skip_serializing_if = "Option::is_none")]
3710 pub cancellation: Option<BuildCancellationMetadata>,
3711}
3712
3713impl BuildRecordInput {
3714 pub fn into_record(self, id: u64) -> BuildRecord {
3716 BuildRecord {
3717 id,
3718 started_at: self.started_at,
3719 completed_at: self.completed_at,
3720 project_id: self.project_id,
3721 worker_id: self.worker_id,
3722 command: self.command,
3723 exit_code: self.exit_code,
3724 duration_ms: self.duration_ms,
3725 location: self.location,
3726 bytes_transferred: self.bytes_transferred,
3727 timing: self.timing,
3728 cancellation: self.cancellation,
3729 }
3730 }
3731}
3732
3733#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3738pub struct BuildStats {
3739 pub total_builds: usize,
3741 pub success_count: usize,
3743 pub failure_count: usize,
3745 pub remote_count: usize,
3747 pub local_count: usize,
3749 pub avg_duration_ms: u64,
3751}
3752
3753#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3760pub struct SavedTimeStats {
3761 pub total_remote_duration_ms: u64,
3763 pub estimated_local_duration_ms: u64,
3766 pub time_saved_ms: u64,
3769 pub builds_counted: usize,
3771 pub avg_speedup: f64,
3775 pub today_saved_ms: u64,
3777 pub week_saved_ms: u64,
3779 #[serde(default)]
3782 pub estimate_basis: String,
3783 #[serde(default)]
3785 pub local_baseline_builds: usize,
3786}
3787
3788use std::collections::VecDeque;
3793use std::time::{Duration, Instant};
3794
3795#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3797pub struct CompilationTimingBreakdown {
3798 #[serde(with = "duration_millis")]
3800 pub rsync_up: Duration,
3801 #[serde(with = "duration_millis")]
3803 pub remote_build: Duration,
3804 #[serde(with = "duration_millis")]
3806 pub rsync_down: Duration,
3807 #[serde(with = "duration_millis")]
3809 pub total: Duration,
3810}
3811
3812#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3816pub struct CommandTimingBreakdown {
3817 #[serde(with = "option_duration_millis")]
3819 pub classify: Option<Duration>,
3820 #[serde(with = "option_duration_millis")]
3822 pub select: Option<Duration>,
3823 #[serde(with = "option_duration_millis")]
3825 pub sync_up: Option<Duration>,
3826 #[serde(with = "option_duration_millis")]
3828 pub exec: Option<Duration>,
3829 #[serde(with = "option_duration_millis")]
3831 pub sync_down: Option<Duration>,
3832 #[serde(with = "option_duration_millis")]
3834 pub cleanup: Option<Duration>,
3835 #[serde(with = "option_duration_millis")]
3837 pub total: Option<Duration>,
3838}
3839
3840mod duration_millis {
3841 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3842 use std::time::Duration;
3843
3844 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
3845 where
3846 S: Serializer,
3847 {
3848 duration.as_millis().serialize(serializer)
3849 }
3850
3851 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
3852 where
3853 D: Deserializer<'de>,
3854 {
3855 let millis = u64::deserialize(deserializer)?;
3856 Ok(Duration::from_millis(millis))
3857 }
3858}
3859
3860#[derive(Debug, Clone, Serialize, Deserialize)]
3862pub struct CompilationMetrics {
3863 pub project_id: String,
3865 pub worker_id: String,
3867 pub timestamp: chrono::DateTime<chrono::Utc>,
3869 pub timing: CompilationTimingBreakdown,
3871 #[serde(with = "option_duration_millis")]
3873 pub local_build_time: Option<Duration>,
3874 pub speedup: Option<f64>,
3876 pub files_synced: u64,
3878 pub bytes_transferred: u64,
3880 pub exit_code: i32,
3882 pub success: bool,
3884}
3885
3886mod option_duration_millis {
3887 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3888 use std::time::Duration;
3889
3890 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
3891 where
3892 S: Serializer,
3893 {
3894 match duration {
3895 Some(d) => Some(d.as_millis() as u64).serialize(serializer),
3896 None => Option::<u64>::None.serialize(serializer),
3897 }
3898 }
3899
3900 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
3901 where
3902 D: Deserializer<'de>,
3903 {
3904 let opt: Option<u64> = Option::deserialize(deserializer)?;
3905 Ok(opt.map(Duration::from_millis))
3906 }
3907}
3908
3909impl CompilationMetrics {
3910 pub fn calculate_speedup(&mut self) {
3912 if let Some(local) = self.local_build_time
3913 && self.timing.total.as_millis() > 0
3914 {
3915 self.speedup = Some(local.as_secs_f64() / self.timing.total.as_secs_f64());
3916 }
3917 }
3918
3919 pub fn is_beneficial(&self) -> bool {
3921 self.speedup.map(|s| s > 1.0).unwrap_or(false)
3922 }
3923}
3924
3925impl Default for CompilationMetrics {
3926 fn default() -> Self {
3927 Self {
3928 project_id: String::new(),
3929 worker_id: String::new(),
3930 timestamp: chrono::Utc::now(),
3931 timing: CompilationTimingBreakdown::default(),
3932 local_build_time: None,
3933 speedup: None,
3934 files_synced: 0,
3935 bytes_transferred: 0,
3936 exit_code: 0,
3937 success: true,
3938 }
3939 }
3940}
3941
3942#[derive(Debug)]
3958pub struct CompilationTimer {
3959 project_id: String,
3960 worker_id: String,
3961 start: Instant,
3962 phase_start: Instant,
3963 rsync_up: Option<Duration>,
3964 remote_build: Option<Duration>,
3965 rsync_down: Option<Duration>,
3966}
3967
3968impl CompilationTimer {
3969 pub fn new(project_id: &str, worker_id: &str) -> Self {
3971 let now = Instant::now();
3972 Self {
3973 project_id: project_id.to_string(),
3974 worker_id: worker_id.to_string(),
3975 start: now,
3976 phase_start: now,
3977 rsync_up: None,
3978 remote_build: None,
3979 rsync_down: None,
3980 }
3981 }
3982
3983 pub fn end_rsync_up(&mut self) {
3985 self.rsync_up = Some(self.phase_start.elapsed());
3986 self.phase_start = Instant::now();
3987 tracing::info!(
3988 rsync_up_ms = %self.rsync_up.unwrap().as_millis(),
3989 "TIMING: rsync_up completed"
3990 );
3991 }
3992
3993 pub fn end_remote_build(&mut self) {
3995 self.remote_build = Some(self.phase_start.elapsed());
3996 self.phase_start = Instant::now();
3997 tracing::info!(
3998 remote_build_ms = %self.remote_build.unwrap().as_millis(),
3999 "TIMING: remote_build completed"
4000 );
4001 }
4002
4003 pub fn end_rsync_down(&mut self) {
4005 self.rsync_down = Some(self.phase_start.elapsed());
4006 tracing::info!(
4007 rsync_down_ms = %self.rsync_down.unwrap().as_millis(),
4008 "TIMING: rsync_down completed"
4009 );
4010 }
4011
4012 pub fn finish(self, exit_code: i32, files: u64, bytes: u64) -> CompilationMetrics {
4014 let total = self.start.elapsed();
4015 tracing::info!(
4016 total_ms = %total.as_millis(),
4017 exit_code = %exit_code,
4018 "TIMING: compilation completed"
4019 );
4020
4021 CompilationMetrics {
4022 project_id: self.project_id,
4023 worker_id: self.worker_id,
4024 timestamp: chrono::Utc::now(),
4025 timing: CompilationTimingBreakdown {
4026 rsync_up: self.rsync_up.unwrap_or_default(),
4027 remote_build: self.remote_build.unwrap_or_default(),
4028 rsync_down: self.rsync_down.unwrap_or_default(),
4029 total,
4030 },
4031 local_build_time: None,
4032 speedup: None,
4033 files_synced: files,
4034 bytes_transferred: bytes,
4035 exit_code,
4036 success: exit_code == 0,
4037 }
4038 }
4039}
4040
4041#[derive(Debug)]
4043pub struct MetricsAggregator {
4044 history: VecDeque<CompilationMetrics>,
4045 max_history: usize,
4046}
4047
4048impl MetricsAggregator {
4049 pub fn new(max_history: usize) -> Self {
4051 Self {
4052 history: VecDeque::with_capacity(max_history),
4053 max_history,
4054 }
4055 }
4056
4057 pub fn record(&mut self, metrics: CompilationMetrics) {
4059 if self.history.len() >= self.max_history {
4060 self.history.pop_front();
4061 }
4062 self.history.push_back(metrics);
4063 }
4064
4065 pub fn average_speedup(&self) -> Option<f64> {
4067 let speedups: Vec<f64> = self.history.iter().filter_map(|m| m.speedup).collect();
4068
4069 if speedups.is_empty() {
4070 None
4071 } else {
4072 Some(speedups.iter().sum::<f64>() / speedups.len() as f64)
4073 }
4074 }
4075
4076 pub fn p50_total_time(&self) -> Option<Duration> {
4078 self.percentile_total_time(0.50)
4079 }
4080
4081 pub fn p95_total_time(&self) -> Option<Duration> {
4083 self.percentile_total_time(0.95)
4084 }
4085
4086 pub fn p99_total_time(&self) -> Option<Duration> {
4088 self.percentile_total_time(0.99)
4089 }
4090
4091 fn percentile_total_time(&self, percentile: f64) -> Option<Duration> {
4096 let mut times: Vec<_> = self.history.iter().map(|m| m.timing.total).collect();
4097
4098 if times.is_empty() {
4099 return None;
4100 }
4101
4102 times.sort();
4103 let idx = (((times.len() - 1) as f64 * percentile).round() as usize).min(times.len() - 1);
4107 Some(times[idx])
4108 }
4109
4110 pub fn success_rate(&self) -> f64 {
4112 if self.history.is_empty() {
4113 return 100.0;
4114 }
4115 let successes = self.history.iter().filter(|m| m.success).count();
4116 (successes as f64 / self.history.len() as f64) * 100.0
4117 }
4118
4119 pub fn count(&self) -> usize {
4121 self.history.len()
4122 }
4123
4124 pub fn metrics(&self) -> &VecDeque<CompilationMetrics> {
4126 &self.history
4127 }
4128
4129 pub fn clear(&mut self) {
4131 self.history.clear();
4132 }
4133}
4134
4135impl Default for MetricsAggregator {
4136 fn default() -> Self {
4137 Self::new(1000)
4138 }
4139}
4140
4141#[cfg(test)]
4142mod tests {
4143 use super::*;
4144 use crate::test_guard;
4145
4146 #[test]
4147 fn test_declared_os_reads_reserved_tag() {
4148 assert_eq!(
4149 declared_os(&["rust".to_string(), "os:windows".to_string()]),
4150 Some("windows".to_string())
4151 );
4152 assert_eq!(
4153 declared_os(&["os:WINDOWS".to_string()]),
4154 Some("windows".to_string())
4155 );
4156 }
4157
4158 #[test]
4159 fn test_declared_os_absent_for_ordinary_tags() {
4160 assert_eq!(declared_os(&[]), None);
4163 assert_eq!(
4164 declared_os(&["rust".to_string(), "bun".to_string(), "go".to_string()]),
4165 None
4166 );
4167 assert_eq!(declared_os(&["os:".to_string()]), None);
4169 assert_eq!(declared_os(&["os: ".to_string()]), None);
4170 }
4171
4172 #[test]
4173 fn test_os_tag_roundtrips_through_declared_os() {
4174 for os in ["windows", "Darwin", " linux "] {
4175 let tag = os_tag(os);
4176 assert_eq!(
4177 declared_os(std::slice::from_ref(&tag)).as_deref(),
4178 Some(os.trim().to_ascii_lowercase().as_str())
4179 );
4180 }
4181 }
4182
4183 #[test]
4188 fn test_no_inline_secret_fields() {
4189 let endpoint = DoctorWebhookEndpoint {
4190 name: "ops".to_string(),
4191 url: "https://example.com/hook".to_string(),
4192 format: DoctorWebhookFormat::Pagerduty,
4193 on_transitions: vec!["any_to_failing".to_string()],
4194 retry_max: 3,
4195 retry_backoff_ms: 200,
4196 timeout_ms: 5000,
4197 enabled: true,
4198 routing_key_env: Some("RCH_PAGERDUTY_ROUTING_KEY".to_string()),
4199 bearer_token_env: Some("RCH_WEBHOOK_BEARER".to_string()),
4200 signing_secret_env: Some("RCH_WEBHOOK_HMAC".to_string()),
4201 };
4202 let value = serde_json::to_value(&endpoint).unwrap();
4205 let obj = value.as_object().expect("endpoint serializes to an object");
4206 for forbidden in ["secret", "routing_key", "bearer_token", "password", "token"] {
4207 assert!(
4208 !obj.contains_key(forbidden),
4209 "endpoint config must not expose an inline `{forbidden}` field"
4210 );
4211 }
4212 assert!(obj.contains_key("routing_key_env"));
4213 assert!(obj.contains_key("bearer_token_env"));
4214 assert!(obj.contains_key("signing_secret_env"));
4215 }
4216
4217 #[test]
4218 fn test_webhook_format_toml_spelling() {
4219 #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
4224 struct FormatWrap {
4225 format: DoctorWebhookFormat,
4226 }
4227 let rendered = toml::to_string(&FormatWrap {
4228 format: DoctorWebhookFormat::GenericJson,
4229 })
4230 .unwrap();
4231 assert!(
4232 rendered.contains("format = \"generic_json\""),
4233 "unexpected TOML: {rendered}"
4234 );
4235 let slack: FormatWrap = toml::from_str("format = \"slack\"").unwrap();
4236 assert_eq!(slack.format, DoctorWebhookFormat::Slack);
4237 let pd: FormatWrap = toml::from_str("format = \"pagerduty\"").unwrap();
4238 assert_eq!(pd.format, DoctorWebhookFormat::Pagerduty);
4239 }
4240
4241 #[test]
4242 fn test_doctor_webhooks_defaults() {
4243 let cfg = DoctorWebhooksConfig::default();
4244 assert!(cfg.endpoints.is_empty());
4245 assert_eq!(cfg.max_inflight, 16);
4246 let rch: RchConfig = toml::from_str("").unwrap();
4249 assert!(rch.doctor.webhooks.endpoints.is_empty());
4250 }
4251
4252 #[test]
4253 fn test_doctor_webhook_endpoint_parses_from_toml() {
4254 let toml_src = r#"
4255[doctor.webhooks]
4256max_inflight = 8
4257
4258[[doctor.webhooks.endpoints]]
4259name = "ops-slack"
4260url = "https://hooks.slack.com/services/T0/B0/x"
4261format = "slack"
4262on_transitions = ["healthy_to_degraded", "any_to_failing"]
4263retry_max = 2
4264"#;
4265 let cfg: RchConfig = toml::from_str(toml_src).unwrap();
4266 assert_eq!(cfg.doctor.webhooks.max_inflight, 8);
4267 assert_eq!(cfg.doctor.webhooks.endpoints.len(), 1);
4268 let ep = &cfg.doctor.webhooks.endpoints[0];
4269 assert_eq!(ep.name, "ops-slack");
4270 assert_eq!(ep.format, DoctorWebhookFormat::Slack);
4271 assert_eq!(ep.on_transitions.len(), 2);
4272 assert_eq!(ep.retry_max, 2);
4273 assert_eq!(ep.retry_backoff_ms, 200);
4275 assert_eq!(ep.timeout_ms, 5000);
4276 assert!(ep.enabled);
4277 }
4278
4279 fn assert_golden_json(
4280 actual: serde_json::Value,
4281 expected: &serde_json::Value,
4282 fixture_path: &str,
4283 ) {
4284 if &actual == expected {
4285 return;
4286 }
4287
4288 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
4289 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
4290 assert_eq!(
4291 &actual,
4292 expected,
4293 "golden mismatch in {fixture_path}\n\
4294 expected_blake3={}\n\
4295 actual_blake3={}\n\
4296 bless path: review the semantic diff, update the fixture expected_* field, \
4297 and record both hashes in the Beads closeout.\n\
4298 expected:\n{expected_json}\n\
4299 actual:\n{actual_json}",
4300 blake3::hash(expected_json.as_bytes()),
4301 blake3::hash(actual_json.as_bytes())
4302 );
4303 }
4304
4305 #[test]
4306 fn test_circuit_state_default() {
4307 let _guard = test_guard!();
4308 assert_eq!(CircuitState::default(), CircuitState::Closed);
4309 }
4310
4311 #[test]
4312 fn test_circuit_config_defaults() {
4313 let _guard = test_guard!();
4314 let config = CircuitBreakerConfig::default();
4315 assert_eq!(config.failure_threshold, 3);
4316 assert_eq!(config.success_threshold, 2);
4317 assert_eq!(config.error_rate_threshold, 0.5);
4318 assert_eq!(config.window_secs, 60);
4319 assert_eq!(config.open_cooldown_secs, 30);
4320 assert_eq!(config.half_open_max_probes, 1);
4321 }
4322
4323 #[test]
4324 fn test_rch_config_has_circuit_defaults() {
4325 let _guard = test_guard!();
4326 let config = RchConfig::default();
4327 assert_eq!(config.circuit.failure_threshold, 3);
4328 }
4329
4330 #[test]
4331 fn test_circuit_config_serde_roundtrip() {
4332 let _guard = test_guard!();
4333 let config = CircuitBreakerConfig {
4334 failure_threshold: 5,
4335 success_threshold: 3,
4336 error_rate_threshold: 0.75,
4337 window_secs: 120,
4338 open_cooldown_secs: 45,
4339 half_open_max_probes: 2,
4340 command_failure_threshold: 4,
4341 open_cooldown_max_secs: 900,
4342 };
4343 let json = serde_json::to_string(&config).unwrap();
4344 let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
4345 assert_eq!(parsed.command_failure_threshold, 4);
4346 assert_eq!(parsed.open_cooldown_max_secs, 900);
4347 assert_eq!(parsed.failure_threshold, 5);
4348 assert_eq!(parsed.success_threshold, 3);
4349 assert_eq!(parsed.error_rate_threshold, 0.75);
4350 assert_eq!(parsed.window_secs, 120);
4351 assert_eq!(parsed.open_cooldown_secs, 45);
4352 assert_eq!(parsed.half_open_max_probes, 2);
4353 }
4354
4355 #[test]
4360 fn test_worker_capabilities_default() {
4361 let _guard = test_guard!();
4362 let caps = WorkerCapabilities::default();
4363 assert!(caps.rustc_version.is_none());
4364 assert!(caps.rustup_toolchains.is_empty());
4365 assert!(caps.rustup_components.is_empty());
4366 assert!(caps.bun_version.is_none());
4367 assert!(caps.node_version.is_none());
4368 assert!(caps.npm_version.is_none());
4369 assert!(caps.projects_root_ok.is_none());
4370 assert!(caps.projects_root_issue.is_none());
4371 assert!(caps.projects_root_checked_at_unix_ms.is_none());
4372 }
4373
4374 #[test]
4375 fn test_worker_capabilities_new() {
4376 let _guard = test_guard!();
4377 let caps = WorkerCapabilities::new();
4378 assert!(caps.rustc_version.is_none());
4379 assert!(!caps.has_rust());
4380 assert!(!caps.has_bun());
4381 assert!(!caps.has_node());
4382 assert!(caps.is_topology_healthy().is_none());
4383 }
4384
4385 #[test]
4386 fn test_worker_capabilities_has_rust() {
4387 let _guard = test_guard!();
4388 let mut caps = WorkerCapabilities::new();
4389 assert!(!caps.has_rust());
4390
4391 caps.rustc_version = Some("rustc 1.76.0 (07dca489a 2024-02-04)".to_string());
4392 assert!(caps.has_rust());
4393 }
4394
4395 #[test]
4396 fn test_worker_capabilities_components_are_toolchain_scoped_and_exact() {
4397 let _guard = test_guard!();
4398 let caps = WorkerCapabilities {
4399 rustup_components: vec![
4400 "stable-x86_64-unknown-linux-gnu:clippy".to_string(),
4401 "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
4402 "beta-x86_64-unknown-linux-gnu:clippy-preview".to_string(),
4403 ],
4404 ..Default::default()
4405 };
4406
4407 assert!(caps.has_rustup_component("stable", "clippy"));
4408 assert!(caps.has_rustup_component("nightly-2026-07-05", "rustfmt"));
4409 assert!(!caps.has_rustup_component("nightly-2026-07-05", "clippy"));
4410 assert!(!caps.has_rustup_component("beta", "clippy"));
4411 assert!(!caps.has_rustup_component("nightly-2026-07-0", "rustfmt"));
4412 }
4413
4414 #[test]
4415 fn test_worker_capabilities_has_bun() {
4416 let _guard = test_guard!();
4417 let mut caps = WorkerCapabilities::new();
4418 assert!(!caps.has_bun());
4419
4420 caps.bun_version = Some("1.0.25".to_string());
4421 assert!(caps.has_bun());
4422 }
4423
4424 #[test]
4425 fn test_worker_capabilities_has_nix() {
4426 let _guard = test_guard!();
4427 let mut caps = WorkerCapabilities::new();
4428 assert!(!caps.has_nix());
4429
4430 caps.nix_version = Some("nix (Nix) 2.24.9".to_string());
4431 assert!(caps.has_nix());
4432 }
4433
4434 #[test]
4435 fn test_worker_capabilities_has_zig_requires_both_halves() {
4436 let _guard = test_guard!();
4437 let mut caps = WorkerCapabilities::new();
4438 assert!(!caps.has_zig());
4439
4440 caps.zig_version = Some("0.14.1".to_string());
4443 assert!(!caps.has_zig());
4444
4445 caps.zig_version = None;
4447 caps.cargo_zigbuild_version = Some("cargo-zigbuild 0.23.0".to_string());
4448 assert!(!caps.has_zig());
4449
4450 caps.zig_version = Some("0.14.1".to_string());
4451 assert!(caps.has_zig());
4452 }
4453
4454 #[test]
4455 fn test_nix_build_uses_generous_timeout() {
4456 let _guard = test_guard!();
4457 let cfg = CompilationConfig::default();
4458 assert_eq!(
4460 cfg.timeout_for_kind(Some(CompilationKind::NixBuild)),
4461 std::time::Duration::from_secs(cfg.test_timeout_sec)
4462 );
4463 }
4464
4465 #[test]
4466 fn test_job_uses_build_timeout_bucket() {
4467 let _guard = test_guard!();
4468 let cfg = CompilationConfig::default();
4471 assert_eq!(
4472 cfg.timeout_for_kind(Some(CompilationKind::Job)),
4473 std::time::Duration::from_secs(cfg.build_timeout_sec)
4474 );
4475 }
4476
4477 #[test]
4478 fn test_layer0_disabled_by_default_and_env_gated() {
4479 let _guard = test_guard!();
4480 let cfg = Layer0Config::default();
4481 assert!(!cfg.enabled);
4482 let knobs_on = Layer0Config {
4484 enabled: false,
4485 release_lto_thin: true,
4486 release_codegen_units_1: true,
4487 debug_line_tables_only: true,
4488 };
4489 assert!(cfg.active_env().is_empty());
4490 assert!(knobs_on.active_env().is_empty());
4491 }
4492
4493 #[test]
4494 fn test_layer0_knobs_emit_stable_env_pairs() {
4495 let _guard = test_guard!();
4496 let lto_only = Layer0Config {
4497 enabled: true,
4498 release_lto_thin: true,
4499 ..Layer0Config::default()
4500 };
4501 assert_eq!(
4502 lto_only.active_env(),
4503 vec![("CARGO_PROFILE_RELEASE_LTO", "thin")]
4504 );
4505 let debug_only = Layer0Config {
4506 enabled: true,
4507 debug_line_tables_only: true,
4508 ..Layer0Config::default()
4509 };
4510 assert_eq!(
4511 debug_only.active_env(),
4512 vec![
4513 ("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"),
4514 ("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"),
4515 ]
4516 );
4517 let all = Layer0Config {
4518 enabled: true,
4519 release_lto_thin: true,
4520 release_codegen_units_1: true,
4521 debug_line_tables_only: true,
4522 };
4523 assert_eq!(
4524 all.active_env(),
4525 vec![
4526 ("CARGO_PROFILE_RELEASE_LTO", "thin"),
4527 ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
4528 ("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"),
4529 ("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"),
4530 ],
4531 "emission order must stay stable for deterministic baselines"
4532 );
4533 }
4534
4535 #[test]
4536 fn test_layer0_pack_version_is_recorded() {
4537 let _guard = test_guard!();
4538 assert!(!LAYER0_PACK_VERSION.is_empty());
4540 }
4541 #[test]
4542 fn test_default_allowlist_includes_nix() {
4543 let _guard = test_guard!();
4544 let cfg = ExecutionConfig::default();
4545 assert!(cfg.is_allowed("nix"));
4546 }
4547
4548 #[test]
4549 fn test_worker_capabilities_has_node() {
4550 let _guard = test_guard!();
4551 let mut caps = WorkerCapabilities::new();
4552 assert!(!caps.has_node());
4553
4554 caps.node_version = Some("v20.11.0".to_string());
4555 assert!(caps.has_node());
4556 }
4557
4558 #[test]
4559 fn test_worker_capabilities_multiple_runtimes() {
4560 let _guard = test_guard!();
4561 let caps = WorkerCapabilities {
4562 rustc_version: Some("rustc 1.76.0".to_string()),
4563 bun_version: Some("1.0.25".to_string()),
4564 node_version: Some("v20.11.0".to_string()),
4565 npm_version: Some("10.2.4".to_string()),
4566 ..Default::default()
4567 };
4568
4569 assert!(caps.has_rust());
4570 assert!(caps.has_bun());
4571 assert!(caps.has_node());
4572 }
4573
4574 #[test]
4575 fn test_worker_capabilities_serialization_empty() {
4576 let _guard = test_guard!();
4577 let caps = WorkerCapabilities::new();
4578 let json = serde_json::to_string(&caps).unwrap();
4579 assert_eq!(json, "{}");
4581
4582 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4584 assert!(!parsed.has_rust());
4585 assert!(!parsed.has_bun());
4586 assert!(!parsed.has_node());
4587 }
4588
4589 #[test]
4590 fn test_worker_capabilities_serialization_with_versions() {
4591 let _guard = test_guard!();
4592 let caps = WorkerCapabilities {
4593 rustc_version: Some("rustc 1.76.0-nightly".to_string()),
4594 bun_version: Some("1.0.25".to_string()),
4595 node_version: None,
4596 npm_version: None,
4597 ..Default::default()
4598 };
4599
4600 let json = serde_json::to_string(&caps).unwrap();
4601 assert!(json.contains("rustc_version"));
4602 assert!(json.contains("rustc 1.76.0-nightly"));
4603 assert!(json.contains("bun_version"));
4604 assert!(json.contains("1.0.25"));
4605 assert!(!json.contains("node_version"));
4607 assert!(!json.contains("npm_version"));
4608
4609 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4610 assert!(parsed.has_rust());
4611 assert!(parsed.has_bun());
4612 assert!(!parsed.has_node());
4613 }
4614
4615 #[test]
4616 fn test_worker_capabilities_deserialization_partial() {
4617 let _guard = test_guard!();
4618 let json = r#"{"bun_version": "1.0.0"}"#;
4620 let caps: WorkerCapabilities = serde_json::from_str(json).unwrap();
4621
4622 assert!(!caps.has_rust());
4623 assert!(caps.has_bun());
4624 assert!(!caps.has_node());
4625 assert_eq!(caps.bun_version, Some("1.0.0".to_string()));
4626 }
4627
4628 #[test]
4629 fn test_worker_capabilities_clone() {
4630 let _guard = test_guard!();
4631 let caps = WorkerCapabilities {
4632 rustc_version: Some("1.76.0".to_string()),
4633 bun_version: None,
4634 node_version: Some("v20".to_string()),
4635 npm_version: None,
4636 projects_root_ok: Some(false),
4637 projects_root_issue: Some("alias_missing".to_string()),
4638 projects_root_checked_at_unix_ms: Some(123),
4639 ..Default::default()
4640 };
4641
4642 let cloned = caps.clone();
4643 assert_eq!(cloned.rustc_version, caps.rustc_version);
4644 assert_eq!(cloned.bun_version, caps.bun_version);
4645 assert_eq!(cloned.node_version, caps.node_version);
4646 assert_eq!(cloned.npm_version, caps.npm_version);
4647 assert_eq!(cloned.projects_root_ok, caps.projects_root_ok);
4648 assert_eq!(cloned.projects_root_issue, caps.projects_root_issue);
4649 assert_eq!(
4650 cloned.projects_root_checked_at_unix_ms,
4651 caps.projects_root_checked_at_unix_ms
4652 );
4653 }
4654
4655 #[test]
4656 fn test_worker_capabilities_topology_health_helper() {
4657 let _guard = test_guard!();
4658 let mut caps = WorkerCapabilities::new();
4659 assert_eq!(caps.is_topology_healthy(), None);
4660
4661 caps.projects_root_ok = Some(true);
4662 assert_eq!(caps.is_topology_healthy(), Some(true));
4663
4664 caps.projects_root_ok = Some(false);
4665 assert_eq!(caps.is_topology_healthy(), Some(false));
4666 }
4667
4668 #[test]
4669 fn test_selection_reason_serialization() {
4670 let _guard = test_guard!();
4671 assert_eq!(
4673 serde_json::to_string(&SelectionReason::Success).unwrap(),
4674 "\"success\""
4675 );
4676 assert_eq!(
4677 serde_json::to_string(&SelectionReason::NoWorkersConfigured).unwrap(),
4678 "\"no_workers_configured\""
4679 );
4680 assert_eq!(
4681 serde_json::to_string(&SelectionReason::AllWorkersUnreachable).unwrap(),
4682 "\"all_workers_unreachable\""
4683 );
4684 assert_eq!(
4685 serde_json::to_string(&SelectionReason::AllCircuitsOpen).unwrap(),
4686 "\"all_circuits_open\""
4687 );
4688 assert_eq!(
4689 serde_json::to_string(&SelectionReason::AllWorkersBusy).unwrap(),
4690 "\"all_workers_busy\""
4691 );
4692 assert_eq!(
4693 serde_json::to_string(&SelectionReason::NoWorkersPassedHealth).unwrap(),
4694 "\"no_workers_passed_health\""
4695 );
4696 assert_eq!(
4697 serde_json::to_string(&SelectionReason::AllWorkersFailedPreflight).unwrap(),
4698 "\"all_workers_failed_preflight\""
4699 );
4700 assert_eq!(
4701 serde_json::to_string(&SelectionReason::AllWorkersFailedConvergence).unwrap(),
4702 "\"all_workers_failed_convergence\""
4703 );
4704 assert_eq!(
4705 serde_json::to_string(&SelectionReason::NoAdmissibleWorkers(
4706 "critical_pressure=1,insufficient_slots=1".to_string()
4707 ))
4708 .unwrap(),
4709 "{\"no_admissible_workers\":\"critical_pressure=1,insufficient_slots=1\"}"
4710 );
4711 assert_eq!(
4712 serde_json::to_string(&SelectionReason::NoMatchingWorkers).unwrap(),
4713 "\"no_matching_workers\""
4714 );
4715 }
4716
4717 #[test]
4718 fn test_selection_reason_with_error() {
4719 let _guard = test_guard!();
4720 let reason = SelectionReason::SelectionError("test error".to_string());
4721 let json = serde_json::to_string(&reason).unwrap();
4722 assert!(json.contains("selection_error"));
4723 assert!(json.contains("test error"));
4724 }
4725
4726 #[test]
4727 fn test_selection_reason_deserialization() {
4728 let _guard = test_guard!();
4729 assert_eq!(
4730 serde_json::from_str::<SelectionReason>("\"success\"").unwrap(),
4731 SelectionReason::Success
4732 );
4733 assert_eq!(
4734 serde_json::from_str::<SelectionReason>("\"all_workers_busy\"").unwrap(),
4735 SelectionReason::AllWorkersBusy
4736 );
4737 assert_eq!(
4738 serde_json::from_str::<SelectionReason>("\"no_workers_passed_health\"").unwrap(),
4739 SelectionReason::NoWorkersPassedHealth
4740 );
4741 assert_eq!(
4742 serde_json::from_str::<SelectionReason>("\"all_workers_failed_preflight\"").unwrap(),
4743 SelectionReason::AllWorkersFailedPreflight
4744 );
4745 assert_eq!(
4746 serde_json::from_str::<SelectionReason>("\"all_workers_failed_convergence\"").unwrap(),
4747 SelectionReason::AllWorkersFailedConvergence
4748 );
4749 assert_eq!(
4750 serde_json::from_str::<SelectionReason>(
4751 "{\"no_admissible_workers\":\"critical_pressure=1\"}"
4752 )
4753 .unwrap(),
4754 SelectionReason::NoAdmissibleWorkers("critical_pressure=1".to_string())
4755 );
4756 }
4757
4758 #[test]
4759 fn test_selection_reason_display() {
4760 let _guard = test_guard!();
4761 assert_eq!(
4762 SelectionReason::Success.to_string(),
4763 "worker assigned successfully"
4764 );
4765 assert_eq!(
4766 SelectionReason::NoWorkersConfigured.to_string(),
4767 "no workers configured"
4768 );
4769 assert_eq!(
4770 SelectionReason::AllWorkersUnreachable.to_string(),
4771 "all workers unreachable"
4772 );
4773 assert_eq!(
4774 SelectionReason::AllWorkersBusy.to_string(),
4775 "all workers at capacity"
4776 );
4777 assert_eq!(
4778 SelectionReason::NoWorkersPassedHealth.to_string(),
4779 "no workers passed health thresholds"
4780 );
4781 assert_eq!(
4782 SelectionReason::AllWorkersFailedPreflight.to_string(),
4783 "all workers failed preflight checks"
4784 );
4785 assert_eq!(
4786 SelectionReason::AllWorkersFailedConvergence.to_string(),
4787 "all workers failed repo convergence checks"
4788 );
4789 assert_eq!(
4790 SelectionReason::NoAdmissibleWorkers(
4791 "critical_pressure=1,insufficient_slots=1".to_string()
4792 )
4793 .to_string(),
4794 "no admissible workers: critical_pressure=1,insufficient_slots=1"
4795 );
4796 assert_eq!(
4797 SelectionReason::SelectionError("oops".to_string()).to_string(),
4798 "selection error: oops"
4799 );
4800 }
4801
4802 #[test]
4803 fn test_selection_response_with_worker() {
4804 let _guard = test_guard!();
4805 let response = SelectionResponse {
4806 worker: Some(SelectedWorker {
4807 id: WorkerId::new("test"),
4808 host: "localhost".to_string(),
4809 user: "user".to_string(),
4810 identity_file: "~/.ssh/id_rsa".to_string(),
4811 slots_available: 8,
4812 speed_score: 75.0,
4813 declared_os: None,
4814 }),
4815 reason: SelectionReason::Success,
4816 build_id: None,
4817 diagnostics: None,
4818 };
4819
4820 let json = serde_json::to_string(&response).unwrap();
4821 assert!(json.contains("\"reason\":\"success\""));
4822 assert!(json.contains("\"id\":\"test\""));
4823 }
4824
4825 #[test]
4826 fn test_selection_response_without_worker() {
4827 let _guard = test_guard!();
4828 let response = SelectionResponse {
4829 worker: None,
4830 reason: SelectionReason::AllWorkersBusy,
4831 build_id: None,
4832 diagnostics: None,
4833 };
4834
4835 let json = serde_json::to_string(&response).unwrap();
4836 assert!(json.contains("\"worker\":null"));
4837 assert!(json.contains("\"reason\":\"all_workers_busy\""));
4838 }
4839
4840 #[test]
4841 fn golden_selection_response_with_no_workers_passed_health_diagnostics() {
4842 let _guard = test_guard!();
4843 const FIXTURE_PATH: &str =
4844 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json";
4845 let fixture: serde_json::Value = serde_json::from_str(include_str!(
4846 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json"
4847 ))
4848 .expect("selection diagnostics golden fixture parses");
4849 assert_eq!(
4850 fixture["schema_version"],
4851 "rch.golden.selection_diagnostics.v1"
4852 );
4853
4854 let response = SelectionResponse {
4855 worker: None,
4856 reason: SelectionReason::NoWorkersPassedHealth,
4857 build_id: None,
4858 diagnostics: Some(SelectionDiagnostics {
4859 required_runtime: RequiredRuntime::Rust,
4860 estimated_cores: 2,
4861 min_success_rate: 0.8,
4862 fallback_min_success_rate: 0.5,
4863 active_project_exclusion_count: 0,
4864 workers: vec![WorkerSelectionDiagnostic {
4865 worker_id: WorkerId::new("fixture-worker"),
4866 status: "healthy".to_string(),
4867 circuit_state: "closed".to_string(),
4868 pressure_state: "healthy".to_string(),
4869 pressure_reason_code: "pressure.ok".to_string(),
4870 success_rate: Some(0.2),
4871 min_success_rate: 0.8,
4872 fallback_min_success_rate: 0.5,
4873 required_runtime: RequiredRuntime::Rust,
4874 runtime_available: true,
4875 available_slots: 8,
4876 total_slots: 8,
4877 estimated_cores: 2,
4878 active_project_excluded: false,
4879 final_decision: WorkerSelectionDiagnosticDecision::Deny,
4880 final_reason: "success_rate 0.20 < fallback 0.50".to_string(),
4881 reason_codes: vec![
4882 "health.below_min_success_rate".to_string(),
4883 "health.below_fallback_min_success_rate".to_string(),
4884 ],
4885 }],
4886 }),
4887 };
4888
4889 assert_golden_json(
4890 serde_json::to_value(&response).expect("response serializes"),
4891 &fixture["expected_response"],
4892 FIXTURE_PATH,
4893 );
4894 }
4895
4896 #[test]
4897 fn test_selection_response_roundtrip() {
4898 let _guard = test_guard!();
4899 let original = SelectionResponse {
4900 worker: Some(SelectedWorker {
4901 id: WorkerId::new("worker-1"),
4902 host: "192.168.1.100".to_string(),
4903 user: "ubuntu".to_string(),
4904 identity_file: "/path/to/key".to_string(),
4905 slots_available: 16,
4906 speed_score: 90.5,
4907 declared_os: None,
4908 }),
4909 reason: SelectionReason::Success,
4910 build_id: None,
4911 diagnostics: None,
4912 };
4913
4914 let json = serde_json::to_string(&original).unwrap();
4915 let parsed: SelectionResponse = serde_json::from_str(&json).unwrap();
4916
4917 assert!(parsed.worker.is_some());
4918 let worker = parsed.worker.unwrap();
4919 assert_eq!(worker.id.as_str(), "worker-1");
4920 assert_eq!(worker.host, "192.168.1.100");
4921 assert_eq!(worker.slots_available, 16);
4922 assert_eq!(parsed.reason, SelectionReason::Success);
4923 }
4924
4925 #[test]
4926 fn test_build_heartbeat_phase_roundtrip() {
4927 let _guard = test_guard!();
4928 let json = serde_json::to_string(&BuildHeartbeatPhase::SyncUp).unwrap();
4929 assert_eq!(json, "\"sync_up\"");
4930 let parsed: BuildHeartbeatPhase = serde_json::from_str(&json).unwrap();
4931 assert_eq!(parsed, BuildHeartbeatPhase::SyncUp);
4932 }
4933
4934 #[test]
4935 fn test_build_heartbeat_request_roundtrip() {
4936 let _guard = test_guard!();
4937 let request = BuildHeartbeatRequest {
4938 build_id: 42,
4939 worker_id: WorkerId::new("worker-a"),
4940 hook_pid: Some(12345),
4941 local_wrapper_id: Some("wrapper-abc123".to_string()),
4942 remote_pgid_file: Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string()),
4943 phase: BuildHeartbeatPhase::Execute,
4944 detail: Some("Compiling crates".to_string()),
4945 progress_counter: Some(7),
4946 progress_percent: Some(42.5),
4947 };
4948
4949 let json = serde_json::to_string(&request).unwrap();
4950 let parsed: BuildHeartbeatRequest = serde_json::from_str(&json).unwrap();
4951 assert_eq!(parsed.build_id, 42);
4952 assert_eq!(parsed.worker_id.as_str(), "worker-a");
4953 assert_eq!(
4954 parsed.remote_pgid_file,
4955 Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string())
4956 );
4957 assert_eq!(parsed.phase, BuildHeartbeatPhase::Execute);
4958 assert_eq!(parsed.progress_counter, Some(7));
4959 assert_eq!(parsed.progress_percent, Some(42.5));
4960 }
4961
4962 #[test]
4965 fn test_circuit_stats_new() {
4966 let _guard = test_guard!();
4967 let stats = CircuitStats::new();
4968 assert_eq!(stats.state(), CircuitState::Closed);
4969 assert_eq!(stats.consecutive_failures(), 0);
4970 assert_eq!(stats.error_rate(), 0.0);
4971 assert!(stats.opened_at().is_none());
4972 }
4973
4974 #[test]
4975 fn test_circuit_stats_record_success() {
4976 let _guard = test_guard!();
4977 let mut stats = CircuitStats::new();
4978 stats.record_success();
4979 stats.record_success();
4980 assert_eq!(stats.error_rate(), 0.0);
4981 assert_eq!(stats.consecutive_failures(), 0);
4982 }
4983
4984 #[test]
4985 fn test_circuit_stats_record_failure() {
4986 let _guard = test_guard!();
4987 let mut stats = CircuitStats::new();
4988 stats.record_failure();
4989 stats.record_failure();
4990 assert_eq!(stats.consecutive_failures(), 2);
4991 assert_eq!(stats.error_rate(), 1.0); }
4993
4994 #[test]
4995 fn test_circuit_stats_error_rate() {
4996 let _guard = test_guard!();
4997 let mut stats = CircuitStats::new();
4998 stats.record_success();
4999 stats.record_success();
5000 stats.record_failure();
5001 assert!((stats.error_rate() - 0.333).abs() < 0.01);
5003 }
5004
5005 #[test]
5006 fn test_circuit_stats_should_open_consecutive_failures() {
5007 let _guard = test_guard!();
5008 let mut stats = CircuitStats::new();
5009 let config = CircuitBreakerConfig::default(); stats.record_failure();
5012 assert!(!stats.should_open(&config));
5013
5014 stats.record_failure();
5015 assert!(!stats.should_open(&config));
5016
5017 stats.record_failure();
5018 assert!(stats.should_open(&config)); }
5020
5021 #[test]
5022 fn test_circuit_stats_should_open_error_rate() {
5023 let _guard = test_guard!();
5024 let mut stats = CircuitStats::new();
5025 let config = CircuitBreakerConfig {
5026 error_rate_threshold: 0.5,
5027 ..Default::default()
5028 };
5029
5030 stats.record_success();
5032 stats.record_success();
5033 stats.record_failure();
5034 stats.record_failure();
5035 assert!(!stats.should_open(&config)); stats.record_failure(); assert!(stats.should_open(&config));
5039 }
5040
5041 #[test]
5042 fn test_circuit_close_clears_recent_results_no_flap() {
5043 let _guard = test_guard!();
5044 let mut stats = CircuitStats::new();
5048 let config = CircuitBreakerConfig {
5049 error_rate_threshold: 0.5,
5050 ..Default::default()
5051 };
5052 for _ in 0..6 {
5053 stats.record_failure();
5054 }
5055 assert!(stats.should_open(&config));
5056 stats.open();
5057 stats.half_open();
5059 stats.record_success();
5060 stats.record_success();
5061 stats.close();
5062 assert_eq!(stats.error_rate(), 0.0);
5064 assert!(!stats.should_open(&config));
5065 }
5066
5067 #[test]
5068 fn test_apply_health_outcome_opens_on_failure_threshold() {
5069 let _guard = test_guard!();
5070 let config = CircuitBreakerConfig {
5072 failure_threshold: 3,
5073 open_cooldown_secs: 300,
5074 ..Default::default()
5075 };
5076 let mut stats = CircuitStats::new();
5077 assert_eq!(
5078 stats.apply_health_outcome(false, false, &config),
5079 CircuitState::Closed
5080 );
5081 assert_eq!(
5082 stats.apply_health_outcome(false, false, &config),
5083 CircuitState::Closed
5084 );
5085 assert_eq!(
5086 stats.apply_health_outcome(false, false, &config),
5087 CircuitState::Open
5088 );
5089 }
5090
5091 #[test]
5092 fn test_apply_health_outcome_half_open_two_probe_recovery() {
5093 let _guard = test_guard!();
5094 let config = CircuitBreakerConfig {
5100 failure_threshold: 3,
5101 success_threshold: 2,
5102 open_cooldown_secs: 0,
5103 ..Default::default()
5104 };
5105 let mut stats = CircuitStats::new();
5106 for _ in 0..3 {
5107 stats.apply_health_outcome(false, false, &config);
5108 }
5109 assert_eq!(stats.state(), CircuitState::HalfOpen);
5111
5112 let after_first = stats.apply_health_outcome(true, false, &config);
5114 assert_eq!(after_first, CircuitState::HalfOpen);
5115
5116 let after_second = stats.apply_health_outcome(true, false, &config);
5118 assert_eq!(after_second, CircuitState::Closed);
5119 }
5120
5121 #[test]
5122 fn test_apply_health_outcome_transient_half_open_does_not_reopen() {
5123 let _guard = test_guard!();
5124 let config = CircuitBreakerConfig {
5127 failure_threshold: 3,
5128 success_threshold: 2,
5129 open_cooldown_secs: 0,
5130 ..Default::default()
5131 };
5132 let mut stats = CircuitStats::new();
5133 for _ in 0..3 {
5134 stats.apply_health_outcome(false, false, &config);
5135 }
5136 assert_eq!(stats.state(), CircuitState::HalfOpen);
5138
5139 assert_eq!(
5141 stats.apply_health_outcome(true, false, &config),
5142 CircuitState::HalfOpen
5143 );
5144
5145 let after_transient = stats.apply_health_outcome(false, true, &config);
5151 assert_eq!(after_transient, CircuitState::HalfOpen);
5152
5153 assert_eq!(
5155 stats.apply_health_outcome(true, false, &config),
5156 CircuitState::HalfOpen
5157 );
5158 assert_eq!(
5159 stats.apply_health_outcome(true, false, &config),
5160 CircuitState::Closed
5161 );
5162 }
5163
5164 #[test]
5165 fn test_apply_health_outcome_real_vs_transient_half_open_failure() {
5166 let _guard = test_guard!();
5167 let config = CircuitBreakerConfig {
5175 failure_threshold: 3,
5176 success_threshold: 2,
5177 open_cooldown_secs: 300,
5178 ..Default::default()
5179 };
5180
5181 let mut real = CircuitStats::new();
5183 real.open();
5184 real.half_open();
5185 assert_eq!(real.state(), CircuitState::HalfOpen);
5186 let after_real = real.apply_health_outcome(false, false, &config);
5187 assert_eq!(
5188 after_real,
5189 CircuitState::Open,
5190 "a real half-open failure must reopen the circuit"
5191 );
5192
5193 let mut transient = CircuitStats::new();
5195 transient.open();
5196 transient.half_open();
5197 assert_eq!(transient.state(), CircuitState::HalfOpen);
5198 let after_transient = transient.apply_health_outcome(false, true, &config);
5199 assert_eq!(
5200 after_transient,
5201 CircuitState::HalfOpen,
5202 "a transient half-open failure must NOT reopen the circuit"
5203 );
5204 }
5205
5206 #[test]
5211 fn test_command_failure_blocks_half_open_close_on_liveness_successes() {
5212 let _guard = test_guard!();
5213 let config = CircuitBreakerConfig {
5214 failure_threshold: 3,
5215 success_threshold: 2,
5216 open_cooldown_secs: 0,
5217 command_failure_threshold: 2,
5218 ..Default::default()
5219 };
5220 let mut stats = CircuitStats::new();
5221 stats.open();
5222 stats.half_open();
5223
5224 assert_eq!(
5226 stats.apply_command_outcome(false, &config),
5227 CircuitState::HalfOpen,
5228 "cooldown 0: reopen then immediately half-open again"
5229 );
5230 assert_eq!(stats.consecutive_command_failures(), 1);
5231
5232 for _ in 0..10 {
5234 assert_eq!(
5235 stats.apply_health_outcome(true, false, &config),
5236 CircuitState::HalfOpen,
5237 "liveness successes must not close the circuit while a command failure is outstanding"
5238 );
5239 }
5240
5241 assert_eq!(
5243 stats.apply_command_outcome(true, &config),
5244 CircuitState::Closed
5245 );
5246 assert_eq!(stats.consecutive_command_failures(), 0);
5247 assert_eq!(stats.reopen_streak(), 0);
5248 }
5249
5250 #[test]
5253 fn test_command_failures_open_closed_circuit_despite_liveness_successes() {
5254 let _guard = test_guard!();
5255 let config = CircuitBreakerConfig {
5256 failure_threshold: 3,
5257 command_failure_threshold: 2,
5258 open_cooldown_secs: 300,
5259 ..Default::default()
5260 };
5261 let mut stats = CircuitStats::new();
5262
5263 assert_eq!(
5264 stats.apply_command_outcome(false, &config),
5265 CircuitState::Closed
5266 );
5267 stats.apply_health_outcome(true, false, &config);
5269 stats.apply_health_outcome(true, false, &config);
5270 assert_eq!(stats.consecutive_failures(), 0);
5271 assert_eq!(
5273 stats.apply_command_outcome(false, &config),
5274 CircuitState::Open
5275 );
5276 assert_eq!(stats.reopen_streak(), 1);
5277 }
5278
5279 #[test]
5283 fn test_command_failure_in_half_open_always_reopens() {
5284 let _guard = test_guard!();
5285 let config = CircuitBreakerConfig {
5286 open_cooldown_secs: 300,
5287 ..Default::default()
5288 };
5289 let mut stats = CircuitStats::new();
5290 stats.open();
5291 stats.half_open();
5292 assert_eq!(
5293 stats.apply_command_outcome(false, &config),
5294 CircuitState::Open
5295 );
5296 assert_eq!(stats.reopen_streak(), 2);
5297 }
5298
5299 #[test]
5303 fn test_reopen_streak_backs_off_cooldown_until_cap() {
5304 let _guard = test_guard!();
5305 let config = CircuitBreakerConfig {
5306 open_cooldown_secs: 30,
5307 open_cooldown_max_secs: 100,
5308 ..Default::default()
5309 };
5310 assert_eq!(config.cooldown_ms_for_streak(0), 30_000);
5311 assert_eq!(config.cooldown_ms_for_streak(1), 30_000);
5312 assert_eq!(config.cooldown_ms_for_streak(2), 60_000);
5313 assert_eq!(config.cooldown_ms_for_streak(3), 100_000, "capped");
5314 assert_eq!(config.cooldown_ms_for_streak(40), 100_000, "no overflow");
5315
5316 let inverted = CircuitBreakerConfig {
5318 open_cooldown_secs: 30,
5319 open_cooldown_max_secs: 5,
5320 ..Default::default()
5321 };
5322 assert_eq!(inverted.cooldown_ms_for_streak(3), 30_000);
5323
5324 let mut stats = CircuitStats::new();
5327 stats.open();
5328 stats.half_open();
5329 stats.open();
5330 assert_eq!(stats.reopen_streak(), 2);
5331 let remaining = stats.recovery_remaining_secs(&config).unwrap();
5332 assert!(
5333 remaining > 30 && remaining <= 60,
5334 "second open should wait ~60s, got {remaining}"
5335 );
5336
5337 stats.close();
5339 assert_eq!(stats.reopen_streak(), 0);
5340 stats.open();
5341 assert!(stats.recovery_remaining_secs(&config).unwrap() <= 30);
5342 }
5343
5344 #[test]
5345 fn test_circuit_stats_success_resets_consecutive_failures() {
5346 let _guard = test_guard!();
5347 let mut stats = CircuitStats::new();
5348 stats.record_failure();
5349 stats.record_failure();
5350 assert_eq!(stats.consecutive_failures(), 2);
5351
5352 stats.record_success();
5353 assert_eq!(stats.consecutive_failures(), 0);
5354 }
5355
5356 #[test]
5357 fn test_circuit_stats_open_transition() {
5358 let _guard = test_guard!();
5359 let mut stats = CircuitStats::new();
5360 stats.open();
5361
5362 assert_eq!(stats.state(), CircuitState::Open);
5363 assert!(stats.opened_at().is_some());
5364 }
5365
5366 #[test]
5367 fn test_circuit_stats_half_open_transition() {
5368 let _guard = test_guard!();
5369 let mut stats = CircuitStats::new();
5370 stats.open();
5371 stats.half_open();
5372
5373 assert_eq!(stats.state(), CircuitState::HalfOpen);
5374 }
5375
5376 #[test]
5377 fn test_circuit_stats_close_transition() {
5378 let _guard = test_guard!();
5379 let mut stats = CircuitStats::new();
5380 stats.open();
5381 stats.half_open();
5382 stats.close();
5383
5384 assert_eq!(stats.state(), CircuitState::Closed);
5385 assert!(stats.opened_at().is_none());
5386 assert_eq!(stats.consecutive_failures(), 0);
5387 }
5388
5389 #[test]
5390 fn test_circuit_stats_should_close() {
5391 let _guard = test_guard!();
5392 let mut stats = CircuitStats::new();
5393 let config = CircuitBreakerConfig {
5394 success_threshold: 2,
5395 ..Default::default()
5396 };
5397
5398 stats.open();
5399 stats.half_open();
5400
5401 assert!(!stats.should_close(&config)); stats.record_success();
5404 assert!(!stats.should_close(&config)); stats.record_success();
5407 assert!(stats.should_close(&config)); }
5409
5410 #[test]
5411 fn test_circuit_stats_can_probe() {
5412 let _guard = test_guard!();
5413 let mut stats = CircuitStats::new();
5414 let config = CircuitBreakerConfig {
5415 half_open_max_probes: 1,
5416 ..Default::default()
5417 };
5418
5419 assert!(!stats.can_probe(&config));
5421
5422 stats.open();
5423 assert!(!stats.can_probe(&config));
5425
5426 stats.half_open();
5427 assert!(stats.can_probe(&config));
5429
5430 assert!(stats.start_probe(&config));
5432 assert!(!stats.can_probe(&config));
5434 assert!(!stats.start_probe(&config));
5435 }
5436
5437 #[test]
5438 fn test_circuit_stats_probe_completion() {
5439 let _guard = test_guard!();
5440 let mut stats = CircuitStats::new();
5441 let config = CircuitBreakerConfig {
5442 half_open_max_probes: 1,
5443 ..Default::default()
5444 };
5445
5446 stats.open();
5447 stats.half_open();
5448 stats.start_probe(&config);
5449
5450 stats.record_success();
5452
5453 assert!(stats.can_probe(&config));
5455 }
5456
5457 #[test]
5458 fn test_circuit_stats_failure_in_half_open() {
5459 let _guard = test_guard!();
5460 let mut stats = CircuitStats::new();
5461 let config = CircuitBreakerConfig {
5462 success_threshold: 2,
5463 ..Default::default()
5464 };
5465
5466 stats.open();
5467 stats.half_open();
5468
5469 stats.record_success();
5470 assert_eq!(stats.consecutive_failures(), 0);
5471
5472 stats.record_failure();
5474 assert!(!stats.should_close(&config));
5475
5476 stats.record_success();
5478 stats.record_success();
5479 assert!(stats.should_close(&config));
5480 }
5481
5482 #[test]
5483 fn test_circuit_stats_reset_window() {
5484 let _guard = test_guard!();
5485 let mut stats = CircuitStats::new();
5486 stats.record_success();
5487 stats.record_failure();
5488
5489 assert!(stats.error_rate() > 0.0);
5490
5491 stats.reset_window();
5492 assert_eq!(stats.error_rate(), 0.0);
5493 }
5494
5495 #[test]
5496 fn test_circuit_state_transitions_are_deterministic() {
5497 let _guard = test_guard!();
5498 let config = CircuitBreakerConfig::default();
5499 let mut stats = CircuitStats::new();
5500
5501 assert_eq!(stats.state(), CircuitState::Closed);
5503
5504 for _ in 0..3 {
5506 stats.record_failure();
5507 }
5508 assert!(stats.should_open(&config));
5509 stats.open();
5510 assert_eq!(stats.state(), CircuitState::Open);
5511
5512 stats.half_open();
5514 assert_eq!(stats.state(), CircuitState::HalfOpen);
5515
5516 for _ in 0..2 {
5518 stats.record_success();
5519 }
5520 assert!(stats.should_close(&config));
5521 stats.close();
5522 assert_eq!(stats.state(), CircuitState::Closed);
5523 }
5524
5525 fn make_test_metrics(
5530 speedup: Option<f64>,
5531 total_secs: u64,
5532 success: bool,
5533 ) -> CompilationMetrics {
5534 CompilationMetrics {
5535 project_id: "test-project".to_string(),
5536 worker_id: "worker-1".to_string(),
5537 timestamp: chrono::Utc::now(),
5538 timing: CompilationTimingBreakdown {
5539 rsync_up: Duration::from_millis(100),
5540 remote_build: Duration::from_millis(800),
5541 rsync_down: Duration::from_millis(100),
5542 total: Duration::from_secs(total_secs),
5543 },
5544 local_build_time: speedup.map(|s| Duration::from_secs_f64(total_secs as f64 * s)),
5545 speedup,
5546 files_synced: 100,
5547 bytes_transferred: 1_000_000,
5548 exit_code: if success { 0 } else { 1 },
5549 success,
5550 }
5551 }
5552
5553 #[test]
5554 fn test_compilation_timing_breakdown_default() {
5555 let _guard = test_guard!();
5556 let timing = CompilationTimingBreakdown::default();
5557 assert_eq!(timing.rsync_up, Duration::ZERO);
5558 assert_eq!(timing.remote_build, Duration::ZERO);
5559 assert_eq!(timing.rsync_down, Duration::ZERO);
5560 assert_eq!(timing.total, Duration::ZERO);
5561 }
5562
5563 #[test]
5564 fn test_compilation_timing_breakdown_serialization() {
5565 let _guard = test_guard!();
5566 let timing = CompilationTimingBreakdown {
5567 rsync_up: Duration::from_millis(100),
5568 remote_build: Duration::from_millis(2000),
5569 rsync_down: Duration::from_millis(50),
5570 total: Duration::from_millis(2150),
5571 };
5572
5573 let json = serde_json::to_string(&timing).unwrap();
5574 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();
5580 assert_eq!(parsed.rsync_up, timing.rsync_up);
5581 assert_eq!(parsed.remote_build, timing.remote_build);
5582 assert_eq!(parsed.rsync_down, timing.rsync_down);
5583 assert_eq!(parsed.total, timing.total);
5584 }
5585
5586 #[test]
5587 fn test_command_timing_breakdown_serialization() {
5588 let _guard = test_guard!();
5589 let timing = CommandTimingBreakdown {
5590 classify: Some(Duration::from_millis(2)),
5591 select: None,
5592 sync_up: Some(Duration::from_millis(120)),
5593 exec: Some(Duration::from_millis(900)),
5594 sync_down: None,
5595 cleanup: Some(Duration::from_millis(5)),
5596 total: Some(Duration::from_millis(1027)),
5597 };
5598
5599 let json = serde_json::to_string(&timing).unwrap();
5600 assert!(json.contains("\"classify\":2"));
5601 assert!(json.contains("\"select\":null"));
5602 assert!(json.contains("\"sync_up\":120"));
5603 assert!(json.contains("\"exec\":900"));
5604 assert!(json.contains("\"sync_down\":null"));
5605 assert!(json.contains("\"cleanup\":5"));
5606 assert!(json.contains("\"total\":1027"));
5607
5608 let parsed: CommandTimingBreakdown = serde_json::from_str(&json).unwrap();
5609 assert_eq!(parsed.classify, timing.classify);
5610 assert_eq!(parsed.select, timing.select);
5611 assert_eq!(parsed.sync_up, timing.sync_up);
5612 assert_eq!(parsed.exec, timing.exec);
5613 assert_eq!(parsed.sync_down, timing.sync_down);
5614 assert_eq!(parsed.cleanup, timing.cleanup);
5615 assert_eq!(parsed.total, timing.total);
5616 }
5617
5618 #[test]
5619 fn test_compilation_metrics_calculate_speedup() {
5620 let _guard = test_guard!();
5621 let mut metrics = CompilationMetrics {
5622 timing: CompilationTimingBreakdown {
5623 total: Duration::from_secs(10),
5624 ..Default::default()
5625 },
5626 local_build_time: Some(Duration::from_secs(30)),
5627 ..Default::default()
5628 };
5629
5630 metrics.calculate_speedup();
5631
5632 assert!(metrics.speedup.is_some());
5633 let speedup = metrics.speedup.unwrap();
5634 assert!(
5635 (speedup - 3.0).abs() < 0.01,
5636 "Expected 3.0x speedup, got {}",
5637 speedup
5638 );
5639 }
5640
5641 #[test]
5642 fn test_compilation_metrics_is_beneficial() {
5643 let _guard = test_guard!();
5644 let mut metrics = CompilationMetrics::default();
5645
5646 assert!(!metrics.is_beneficial());
5648
5649 metrics.speedup = Some(1.5);
5651 assert!(metrics.is_beneficial());
5652
5653 metrics.speedup = Some(0.8);
5655 assert!(!metrics.is_beneficial());
5656
5657 metrics.speedup = Some(1.0);
5659 assert!(!metrics.is_beneficial());
5660 }
5661
5662 #[test]
5663 fn test_compilation_metrics_serialization() {
5664 let _guard = test_guard!();
5665 let metrics = make_test_metrics(Some(2.5), 10, true);
5666 let json = serde_json::to_string(&metrics).unwrap();
5667
5668 assert!(json.contains("test-project"));
5669 assert!(json.contains("worker-1"));
5670 assert!(json.contains("2.5")); let parsed: CompilationMetrics = serde_json::from_str(&json).unwrap();
5673 assert_eq!(parsed.project_id, "test-project");
5674 assert_eq!(parsed.worker_id, "worker-1");
5675 assert!(parsed.success);
5676 }
5677
5678 #[test]
5679 fn test_compilation_timer_new() {
5680 let _guard = test_guard!();
5681 let timer = CompilationTimer::new("my-project", "my-worker");
5682 assert_eq!(timer.project_id, "my-project");
5683 assert_eq!(timer.worker_id, "my-worker");
5684 assert!(timer.rsync_up.is_none());
5685 assert!(timer.remote_build.is_none());
5686 assert!(timer.rsync_down.is_none());
5687 }
5688
5689 #[test]
5690 fn test_compilation_timer_phases() {
5691 let _guard = test_guard!();
5692 let mut timer = CompilationTimer::new("test", "worker");
5693
5694 std::thread::sleep(Duration::from_millis(5));
5696 timer.end_rsync_up();
5697 assert!(timer.rsync_up.is_some());
5698 assert!(timer.rsync_up.unwrap() >= Duration::from_millis(5));
5699
5700 std::thread::sleep(Duration::from_millis(10));
5702 timer.end_remote_build();
5703 assert!(timer.remote_build.is_some());
5704 assert!(timer.remote_build.unwrap() >= Duration::from_millis(10));
5705
5706 std::thread::sleep(Duration::from_millis(5));
5708 timer.end_rsync_down();
5709 assert!(timer.rsync_down.is_some());
5710 assert!(timer.rsync_down.unwrap() >= Duration::from_millis(5));
5711
5712 let metrics = timer.finish(0, 50, 500_000);
5714 assert_eq!(metrics.project_id, "test");
5715 assert_eq!(metrics.worker_id, "worker");
5716 assert!(metrics.success);
5717 assert!(metrics.timing.total >= Duration::from_millis(20));
5718 assert_eq!(metrics.files_synced, 50);
5719 assert_eq!(metrics.bytes_transferred, 500_000);
5720 }
5721
5722 #[test]
5723 fn test_metrics_aggregator_new() {
5724 let _guard = test_guard!();
5725 let agg = MetricsAggregator::new(100);
5726 assert_eq!(agg.count(), 0);
5727 assert!(agg.average_speedup().is_none());
5728 }
5729
5730 #[test]
5731 fn test_metrics_aggregator_record() {
5732 let _guard = test_guard!();
5733 let mut agg = MetricsAggregator::new(3);
5734
5735 agg.record(make_test_metrics(Some(2.0), 10, true));
5737 assert_eq!(agg.count(), 1);
5738
5739 agg.record(make_test_metrics(Some(3.0), 20, true));
5741 agg.record(make_test_metrics(Some(4.0), 30, true));
5742 assert_eq!(agg.count(), 3);
5743
5744 agg.record(make_test_metrics(Some(5.0), 40, true));
5746 assert_eq!(agg.count(), 3);
5747 }
5748
5749 #[test]
5750 fn test_metrics_aggregator_average_speedup() {
5751 let _guard = test_guard!();
5752 let mut agg = MetricsAggregator::new(100);
5753
5754 assert!(agg.average_speedup().is_none());
5756
5757 for i in 1..=5 {
5759 agg.record(make_test_metrics(Some(i as f64), 10, true));
5760 }
5761
5762 let avg = agg.average_speedup().unwrap();
5763 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5764 }
5765
5766 #[test]
5767 fn test_metrics_aggregator_average_speedup_with_none() {
5768 let _guard = test_guard!();
5769 let mut agg = MetricsAggregator::new(100);
5770
5771 agg.record(make_test_metrics(Some(2.0), 10, true));
5773 agg.record(make_test_metrics(None, 10, true)); agg.record(make_test_metrics(Some(4.0), 10, true));
5775
5776 let avg = agg.average_speedup().unwrap();
5778 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5779 }
5780
5781 #[test]
5782 fn test_metrics_aggregator_percentiles() {
5783 let _guard = test_guard!();
5784 let mut agg = MetricsAggregator::new(100);
5785
5786 for i in 1..=10 {
5788 agg.record(make_test_metrics(Some(1.0), i, true));
5789 }
5790
5791 let p50 = agg.p50_total_time().unwrap();
5793 assert!(p50 >= Duration::from_secs(5) && p50 <= Duration::from_secs(6));
5794
5795 let p95 = agg.p95_total_time().unwrap();
5797 assert!(p95 >= Duration::from_secs(9) && p95 <= Duration::from_secs(10));
5798
5799 let p99 = agg.p99_total_time().unwrap();
5801 assert!(p99 >= Duration::from_secs(9));
5802 }
5803
5804 #[test]
5805 fn test_metrics_aggregator_success_rate() {
5806 let _guard = test_guard!();
5807 let mut agg = MetricsAggregator::new(100);
5808
5809 assert_eq!(agg.success_rate(), 100.0);
5811
5812 agg.record(make_test_metrics(Some(1.0), 10, true));
5814 agg.record(make_test_metrics(Some(1.0), 10, true));
5815 assert_eq!(agg.success_rate(), 100.0);
5816
5817 agg.record(make_test_metrics(Some(1.0), 10, false));
5819 let rate = agg.success_rate();
5820 assert!((rate - 66.67).abs() < 1.0, "Expected ~66.67%, got {}", rate);
5821 }
5822
5823 #[test]
5824 fn test_metrics_aggregator_clear() {
5825 let _guard = test_guard!();
5826 let mut agg = MetricsAggregator::new(100);
5827 agg.record(make_test_metrics(Some(1.0), 10, true));
5828 agg.record(make_test_metrics(Some(2.0), 20, true));
5829
5830 assert_eq!(agg.count(), 2);
5831
5832 agg.clear();
5833 assert_eq!(agg.count(), 0);
5834 assert!(agg.average_speedup().is_none());
5835 }
5836
5837 #[test]
5840 fn test_remote_build_jobs_parse_display_and_serde() {
5841 let _guard = test_guard!();
5842 assert_eq!(RemoteBuildJobs::default(), RemoteBuildJobs::Auto);
5843 for (spelling, expected) in [
5844 ("auto", RemoteBuildJobs::Auto),
5845 (" AUTO ", RemoteBuildJobs::Auto),
5846 ("off", RemoteBuildJobs::Off),
5847 ("none", RemoteBuildJobs::Off),
5848 ("4", RemoteBuildJobs::Fixed(4)),
5849 ] {
5850 assert_eq!(
5851 RemoteBuildJobs::parse(spelling).unwrap(),
5852 expected,
5853 "{spelling}"
5854 );
5855 }
5856 assert!(RemoteBuildJobs::parse("0").is_err(), "cargo rejects -j 0");
5857 assert!(RemoteBuildJobs::parse("many").is_err());
5858 assert_eq!(RemoteBuildJobs::Fixed(4).to_string(), "4");
5859 assert_eq!(RemoteBuildJobs::Off.to_string(), "off");
5860
5861 #[derive(Deserialize)]
5864 struct Wrap {
5865 compilation: CompilationConfig,
5866 }
5867 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = \"off\"\n").unwrap();
5868 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Off);
5869 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = 6\n").unwrap();
5870 assert_eq!(
5871 parsed.compilation.remote_build_jobs,
5872 RemoteBuildJobs::Fixed(6)
5873 );
5874 let parsed: Wrap = toml::from_str("[compilation]\nbuild_slots = 4\n").unwrap();
5875 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Auto);
5876 assert!(toml::from_str::<Wrap>("[compilation]\nremote_build_jobs = 0\n").is_err());
5877
5878 let json = serde_json::to_string(&RemoteBuildJobs::Fixed(3)).unwrap();
5880 assert_eq!(json, "3");
5881 assert_eq!(
5882 serde_json::to_string(&RemoteBuildJobs::Auto).unwrap(),
5883 "\"auto\""
5884 );
5885 let back: RemoteBuildJobs = serde_json::from_str("\"auto\"").unwrap();
5886 assert_eq!(back, RemoteBuildJobs::Auto);
5887 }
5888
5889 #[test]
5890 fn test_compilation_config_default_timeouts() {
5891 let _guard = test_guard!();
5892 let config = CompilationConfig::default();
5893 assert_eq!(config.build_timeout_sec, 300);
5895 assert_eq!(config.test_timeout_sec, 1800);
5897 }
5898
5899 #[test]
5900 fn test_compilation_config_timeout_for_test_kinds() {
5901 let _guard = test_guard!();
5902 let config = CompilationConfig::default();
5903
5904 assert_eq!(
5906 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5907 std::time::Duration::from_secs(1800)
5908 );
5909 assert_eq!(
5910 config.timeout_for_kind(Some(crate::CompilationKind::CargoNextest)),
5911 std::time::Duration::from_secs(1800)
5912 );
5913 assert_eq!(
5914 config.timeout_for_kind(Some(crate::CompilationKind::BunTest)),
5915 std::time::Duration::from_secs(config.bun_timeout_sec)
5916 );
5917 }
5918
5919 #[test]
5920 fn test_compilation_config_timeout_for_build_kinds() {
5921 let _guard = test_guard!();
5922 let config = CompilationConfig::default();
5923
5924 assert_eq!(
5926 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5927 std::time::Duration::from_secs(300)
5928 );
5929 assert_eq!(
5930 config.timeout_for_kind(Some(crate::CompilationKind::CargoCheck)),
5931 std::time::Duration::from_secs(300)
5932 );
5933 assert_eq!(
5934 config.timeout_for_kind(Some(crate::CompilationKind::CargoClippy)),
5935 std::time::Duration::from_secs(300)
5936 );
5937 assert_eq!(
5938 config.timeout_for_kind(None),
5939 std::time::Duration::from_secs(300)
5940 );
5941 }
5942
5943 #[test]
5944 fn test_compilation_config_custom_timeouts() {
5945 let _guard = test_guard!();
5946 let config = CompilationConfig {
5947 build_timeout_sec: 600, test_timeout_sec: 3600, ..Default::default()
5950 };
5951
5952 assert_eq!(
5953 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5954 std::time::Duration::from_secs(600)
5955 );
5956 assert_eq!(
5957 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5958 std::time::Duration::from_secs(3600)
5959 );
5960 }
5961
5962 #[test]
5963 fn test_compilation_config_speedup_threshold_default() {
5964 let _guard = test_guard!();
5965 let config = CompilationConfig::default();
5966 assert!((config.remote_speedup_threshold - 1.2).abs() < 0.001);
5968 }
5969
5970 #[test]
5971 fn test_compilation_config_speedup_threshold_custom() {
5972 let _guard = test_guard!();
5973 let config = CompilationConfig {
5974 remote_speedup_threshold: 1.5, ..Default::default()
5976 };
5977 assert!((config.remote_speedup_threshold - 1.5).abs() < 0.001);
5978 }
5979
5980 #[test]
5981 fn test_compilation_config_speedup_threshold_no_minimum() {
5982 let _guard = test_guard!();
5983 let config = CompilationConfig {
5985 remote_speedup_threshold: 1.0,
5986 ..Default::default()
5987 };
5988 assert!((config.remote_speedup_threshold - 1.0).abs() < 0.001);
5989 }
5990
5991 #[test]
5992 fn test_compilation_config_min_local_time_ms_default() {
5993 let _guard = test_guard!();
5994 let config = CompilationConfig::default();
5995 assert_eq!(config.min_local_time_ms, 2000);
5997 }
5998
5999 #[test]
6000 fn test_compilation_config_serde_roundtrip_speedup_threshold() {
6001 let _guard = test_guard!();
6002 let config = CompilationConfig {
6003 remote_speedup_threshold: 2.5,
6004 min_local_time_ms: 5000,
6005 ..Default::default()
6006 };
6007 let json = serde_json::to_string(&config).unwrap();
6008 let parsed: CompilationConfig = serde_json::from_str(&json).unwrap();
6009 assert!((parsed.remote_speedup_threshold - 2.5).abs() < 0.001);
6010 assert_eq!(parsed.min_local_time_ms, 5000);
6011 }
6012
6013 #[test]
6018 fn test_validate_remote_base_absolute_path() {
6019 let _guard = test_guard!();
6020 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
6021 assert_eq!(
6022 validate_remote_base("/var/rch-builds").unwrap(),
6023 "/var/rch-builds"
6024 );
6025 assert_eq!(
6026 validate_remote_base("/home/builder/.rch").unwrap(),
6027 "/home/builder/.rch"
6028 );
6029 }
6030
6031 #[test]
6032 fn test_validate_remote_base_tilde_expansion() {
6033 let _guard = test_guard!();
6034 let result = validate_remote_base("~/rch");
6036 assert!(result.is_ok());
6037 let path = result.unwrap();
6038 assert!(
6039 path.starts_with('/'),
6040 "Path should be absolute after expansion: {}",
6041 path
6042 );
6043 assert!(!path.contains('~'), "Tilde should be expanded: {}", path);
6044 }
6045
6046 #[test]
6047 fn test_validate_remote_base_rejects_relative_path() {
6048 let _guard = test_guard!();
6049 let result = validate_remote_base("tmp/rch");
6050 assert!(result.is_err());
6051 assert!(result.unwrap_err().contains("absolute path"));
6052 }
6053
6054 #[test]
6055 fn test_validate_remote_base_rejects_path_traversal() {
6056 let _guard = test_guard!();
6057 let result = validate_remote_base("/tmp/../etc/rch");
6058 assert!(result.is_err());
6059 assert!(result.unwrap_err().contains("path traversal"));
6060
6061 let result = validate_remote_base("/tmp/rch/../other");
6062 assert!(result.is_err());
6063 assert!(result.unwrap_err().contains("path traversal"));
6064 }
6065
6066 #[test]
6067 fn test_validate_remote_base_normalizes_trailing_slash() {
6068 let _guard = test_guard!();
6069 assert_eq!(validate_remote_base("/tmp/rch/").unwrap(), "/tmp/rch");
6070 assert_eq!(validate_remote_base("/tmp/rch///").unwrap(), "/tmp/rch");
6071 }
6072
6073 #[test]
6074 fn test_validate_remote_base_root_path() {
6075 let _guard = test_guard!();
6076 let result = validate_remote_base("/");
6078 assert!(result.is_err());
6079 assert!(result.unwrap_err().contains("root directory"));
6080 }
6081
6082 #[test]
6083 fn test_validate_remote_base_rejects_top_level() {
6084 let _guard = test_guard!();
6085 let result = validate_remote_base("/tmp");
6087 assert!(result.is_err());
6088 assert!(result.unwrap_err().contains("at least 2 levels deep"));
6089
6090 let result = validate_remote_base("/home");
6091 assert!(result.is_err());
6092
6093 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
6095 }
6096
6097 #[test]
6098 fn test_default_remote_base() {
6099 let _guard = test_guard!();
6100 assert_eq!(default_remote_base(), "/data/tmp/rch");
6101 }
6102
6103 #[test]
6104 fn test_transfer_config_default_has_remote_base() {
6105 let _guard = test_guard!();
6106 let config = TransferConfig::default();
6107 assert_eq!(config.remote_base, "/data/tmp/rch");
6108 }
6109
6110 #[test]
6111 fn test_source_sync_timeout_validation_boundaries() {
6112 let _guard = test_guard!();
6113 assert!(!TransferConfig::valid_sync_timeout_ms(0));
6114 assert!(!TransferConfig::valid_sync_timeout_ms(999));
6115 assert!(TransferConfig::valid_sync_timeout_ms(1_000));
6116 assert!(TransferConfig::valid_sync_timeout_ms(3_600_000));
6117 assert!(!TransferConfig::valid_sync_timeout_ms(3_600_001));
6118 }
6119
6120 #[test]
6121 fn test_source_sync_timeout_is_explicit_or_payload_aware() {
6122 let _guard = test_guard!();
6123 let default = TransferConfig::default();
6124 assert_eq!(
6125 default.sync_timeout_for_payload(0),
6126 std::time::Duration::from_millis(30_000)
6127 );
6128 assert_eq!(
6129 default.sync_timeout_for_payload(10 * 1024 * 1024),
6130 std::time::Duration::from_millis(40_000)
6131 );
6132 assert_eq!(
6133 default.sync_timeout_for_payload(u64::MAX),
6134 std::time::Duration::from_millis(3_600_000)
6135 );
6136
6137 let explicit = TransferConfig {
6138 sync_timeout_ms: Some(120_000),
6139 ..TransferConfig::default()
6140 };
6141 assert_eq!(
6142 explicit.sync_timeout_for_payload(u64::MAX),
6143 std::time::Duration::from_millis(120_000)
6144 );
6145 }
6146
6147 #[test]
6152 fn test_retry_config_default() {
6153 let _guard = test_guard!();
6154 let config = RetryConfig::default();
6155 assert_eq!(config.max_attempts, 3);
6156 assert_eq!(config.base_delay_ms, 100);
6157 assert_eq!(config.max_delay_ms, 5000);
6158 assert_eq!(config.jitter_factor, 0.1);
6159 assert_eq!(config.total_timeout_ms, 30000);
6160 }
6161
6162 #[test]
6163 fn test_retry_config_no_retry() {
6164 let _guard = test_guard!();
6165 let config = RetryConfig::no_retry();
6166 assert_eq!(config.max_attempts, 1);
6167 }
6168
6169 #[test]
6170 fn test_retry_config_delay_for_attempt_zero() {
6171 let _guard = test_guard!();
6172 let config = RetryConfig::default();
6173 assert_eq!(config.delay_for_attempt(0), std::time::Duration::ZERO);
6174 }
6175
6176 #[test]
6177 fn test_retry_config_delay_for_attempt_exponential() {
6178 let _guard = test_guard!();
6179 let config = RetryConfig {
6180 base_delay_ms: 100,
6181 max_delay_ms: 10000,
6182 jitter_factor: 0.0, ..Default::default()
6184 };
6185
6186 assert_eq!(
6188 config.delay_for_attempt(1),
6189 std::time::Duration::from_millis(100)
6190 );
6191 assert_eq!(
6193 config.delay_for_attempt(2),
6194 std::time::Duration::from_millis(200)
6195 );
6196 assert_eq!(
6198 config.delay_for_attempt(3),
6199 std::time::Duration::from_millis(400)
6200 );
6201 }
6202
6203 #[test]
6204 fn test_retry_config_delay_capped_at_max() {
6205 let _guard = test_guard!();
6206 let config = RetryConfig {
6207 base_delay_ms: 1000,
6208 max_delay_ms: 2000,
6209 jitter_factor: 0.0,
6210 ..Default::default()
6211 };
6212
6213 assert_eq!(
6215 config.delay_for_attempt(2),
6216 std::time::Duration::from_millis(2000)
6217 );
6218 assert_eq!(
6220 config.delay_for_attempt(3),
6221 std::time::Duration::from_millis(2000)
6222 );
6223 }
6224
6225 #[test]
6226 fn test_retry_config_random_jitter_stays_within_bounds() {
6227 let _guard = test_guard!();
6228 let config = RetryConfig {
6229 base_delay_ms: 100,
6230 max_delay_ms: 200,
6231 jitter_factor: 0.25,
6232 ..Default::default()
6233 };
6234
6235 for (attempt, minimum_ms, maximum_ms) in [(1, 75, 125), (3, 150, 250)] {
6238 for _ in 0..128 {
6239 let delay = config.delay_for_attempt(attempt);
6240 assert!(delay >= std::time::Duration::from_millis(minimum_ms));
6241 assert!(delay <= std::time::Duration::from_millis(maximum_ms));
6242 }
6243 }
6244 }
6245
6246 #[test]
6247 fn test_retry_config_should_retry() {
6248 let _guard = test_guard!();
6249 let config = RetryConfig {
6250 max_attempts: 3,
6251 total_timeout_ms: 1000,
6252 ..Default::default()
6253 };
6254
6255 assert!(config.should_retry(0, std::time::Duration::from_millis(0)));
6257 assert!(config.should_retry(1, std::time::Duration::from_millis(500)));
6258 assert!(config.should_retry(2, std::time::Duration::from_millis(900)));
6259
6260 assert!(!config.should_retry(3, std::time::Duration::from_millis(0)));
6262
6263 assert!(!config.should_retry(1, std::time::Duration::from_millis(1001)));
6265 }
6266
6267 #[test]
6268 fn test_retry_config_serde_roundtrip() {
6269 let _guard = test_guard!();
6270 let config = RetryConfig {
6271 max_attempts: 5,
6272 base_delay_ms: 200,
6273 max_delay_ms: 8000,
6274 jitter_factor: 0.2,
6275 total_timeout_ms: 60000,
6276 };
6277 let json = serde_json::to_string(&config).unwrap();
6278 let parsed: RetryConfig = serde_json::from_str(&json).unwrap();
6279 assert_eq!(parsed.max_attempts, 5);
6280 assert_eq!(parsed.base_delay_ms, 200);
6281 assert_eq!(parsed.max_delay_ms, 8000);
6282 assert_eq!(parsed.jitter_factor, 0.2);
6283 assert_eq!(parsed.total_timeout_ms, 60000);
6284 }
6285
6286 #[test]
6287 fn test_transfer_config_includes_retry() {
6288 let _guard = test_guard!();
6289 let config = TransferConfig::default();
6290 assert_eq!(config.retry.max_attempts, 3);
6291 }
6292
6293 #[test]
6298 fn test_execution_config_default_allowlist() {
6299 let _guard = test_guard!();
6300 let config = ExecutionConfig::default();
6301 assert!(config.is_allowed("cargo"));
6303 assert!(config.is_allowed("rustc"));
6304 assert!(config.is_allowed("gcc"));
6305 assert!(config.is_allowed("g++"));
6306 assert!(config.is_allowed("clang"));
6307 assert!(config.is_allowed("clang++"));
6308 assert!(config.is_allowed("make"));
6309 assert!(config.is_allowed("cmake"));
6310 assert!(config.is_allowed("ninja"));
6311 assert!(config.is_allowed("meson"));
6312 assert!(config.is_allowed("bun"));
6313 assert!(config.is_allowed("nextest"));
6314 assert!(config.is_allowed("cc"));
6315 assert!(config.is_allowed("c++"));
6316 }
6317
6318 #[test]
6319 fn test_execution_config_is_allowed_case_insensitive() {
6320 let _guard = test_guard!();
6321 let config = ExecutionConfig::default();
6322 assert!(config.is_allowed("CARGO"));
6324 assert!(config.is_allowed("Cargo"));
6325 assert!(config.is_allowed("GCC"));
6326 assert!(config.is_allowed("Gcc"));
6327 }
6328
6329 #[test]
6330 fn test_execution_config_is_not_allowed() {
6331 let _guard = test_guard!();
6332 let config = ExecutionConfig::default();
6333 assert!(!config.is_allowed("python"));
6335 assert!(!config.is_allowed("npm"));
6336 assert!(!config.is_allowed(""));
6337 assert!(config.is_allowed("go"));
6342 assert!(config.is_allowed("tsc"));
6343 }
6344
6345 #[test]
6346 fn test_execution_config_empty_allowlist() {
6347 let _guard = test_guard!();
6348 let config = ExecutionConfig { allowlist: vec![] };
6349 assert!(!config.is_allowed("cargo"));
6351 assert!(!config.is_allowed("gcc"));
6352 }
6353
6354 #[test]
6355 fn test_execution_config_custom_allowlist() {
6356 let _guard = test_guard!();
6357 let config = ExecutionConfig {
6358 allowlist: vec!["cargo".to_string(), "custom_tool".to_string()],
6359 };
6360 assert!(config.is_allowed("cargo"));
6361 assert!(config.is_allowed("custom_tool"));
6362 assert!(!config.is_allowed("gcc"));
6363 }
6364
6365 #[test]
6366 fn test_execution_config_serde() {
6367 let _guard = test_guard!();
6368 let config = ExecutionConfig {
6369 allowlist: vec!["cargo".to_string(), "rustc".to_string()],
6370 };
6371 let json = serde_json::to_string(&config).unwrap();
6372 let parsed: ExecutionConfig = serde_json::from_str(&json).unwrap();
6373 assert_eq!(parsed.allowlist, config.allowlist);
6374 }
6375
6376 #[test]
6377 fn test_rch_config_includes_execution() {
6378 let _guard = test_guard!();
6379 let config = RchConfig::default();
6380 assert!(config.execution.is_allowed("cargo"));
6382 }
6383
6384 #[test]
6389 fn test_adaptive_compression_disabled_by_default() {
6390 let _guard = test_guard!();
6391 let config = TransferConfig::default();
6392 assert!(!config.adaptive_compression);
6393 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6395 assert_eq!(config.select_compression_level(Some(500_000_000)), 3);
6396 }
6397
6398 #[test]
6399 fn test_adaptive_compression_small_payload() {
6400 let _guard = test_guard!();
6401 let config = TransferConfig {
6402 adaptive_compression: true,
6403 ..Default::default()
6404 };
6405 assert_eq!(config.select_compression_level(Some(0)), 1);
6407 assert_eq!(config.select_compression_level(Some(1_000_000)), 1); assert_eq!(config.select_compression_level(Some(9_999_999)), 1); }
6410
6411 #[test]
6412 fn test_adaptive_compression_medium_payload() {
6413 let _guard = test_guard!();
6414 let config = TransferConfig {
6415 adaptive_compression: true,
6416 ..Default::default()
6417 };
6418 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); }
6423
6424 #[test]
6425 fn test_adaptive_compression_large_payload() {
6426 let _guard = test_guard!();
6427 let config = TransferConfig {
6428 adaptive_compression: true,
6429 ..Default::default()
6430 };
6431 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); }
6436
6437 #[test]
6438 fn test_adaptive_compression_no_estimate() {
6439 let _guard = test_guard!();
6440 let config = TransferConfig {
6441 adaptive_compression: true,
6442 compression_level: 5,
6443 ..Default::default()
6444 };
6445 assert_eq!(config.select_compression_level(None), 5);
6447 }
6448
6449 #[test]
6450 fn test_adaptive_compression_respects_min_level() {
6451 let _guard = test_guard!();
6452 let config = TransferConfig {
6453 adaptive_compression: true,
6454 min_compression_level: 3,
6455 ..Default::default()
6456 };
6457 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6459 }
6460
6461 #[test]
6462 fn test_adaptive_compression_respects_max_level() {
6463 let _guard = test_guard!();
6464 let config = TransferConfig {
6465 adaptive_compression: true,
6466 max_compression_level: 5,
6467 ..Default::default()
6468 };
6469 assert_eq!(config.select_compression_level(Some(500_000_000)), 5);
6471 }
6472
6473 #[test]
6474 fn test_adaptive_compression_serde_roundtrip() {
6475 let _guard = test_guard!();
6476 let config = TransferConfig {
6477 adaptive_compression: true,
6478 min_compression_level: 2,
6479 max_compression_level: 8,
6480 ..Default::default()
6481 };
6482 let json = serde_json::to_string(&config).unwrap();
6483 let parsed: TransferConfig = serde_json::from_str(&json).unwrap();
6484 assert!(parsed.adaptive_compression);
6485 assert_eq!(parsed.min_compression_level, 2);
6486 assert_eq!(parsed.max_compression_level, 8);
6487 }
6488
6489 #[test]
6494 fn test_self_healing_config_defaults() {
6495 let _guard = test_guard!();
6496 let config = SelfHealingConfig::default();
6498
6499 assert!(
6501 config.hook_starts_daemon,
6502 "hook_starts_daemon should be true by default"
6503 );
6504 assert!(
6505 config.daemon_installs_hooks,
6506 "daemon_installs_hooks should be true by default"
6507 );
6508
6509 assert_eq!(
6511 config.auto_start_cooldown_secs, 30,
6512 "auto_start_cooldown_secs should default to 30"
6513 );
6514 assert_eq!(
6515 config.auto_start_timeout_secs, 3,
6516 "auto_start_timeout_secs should default to 3"
6517 );
6518 }
6520
6521 #[test]
6522 fn test_self_healing_config_serde_full() {
6523 let _guard = test_guard!();
6524 let config = SelfHealingConfig {
6526 hook_starts_daemon: false,
6527 daemon_installs_hooks: false,
6528 auto_start_cooldown_secs: 60,
6529 auto_start_timeout_secs: 10,
6530 self_healing_log_level: SelfHealingLogLevel::Debug,
6531 };
6532
6533 let json = serde_json::to_string(&config).unwrap();
6534 assert!(json.contains("\"hook_starts_daemon\":false"));
6535 assert!(json.contains("\"daemon_installs_hooks\":false"));
6536 assert!(json.contains("\"auto_start_cooldown_secs\":60"));
6537 assert!(json.contains("\"auto_start_timeout_secs\":10"));
6538 assert!(json.contains("\"self_healing_log_level\":\"debug\""));
6539
6540 let parsed: SelfHealingConfig = serde_json::from_str(&json).unwrap();
6541 assert!(!parsed.hook_starts_daemon);
6542 assert!(!parsed.daemon_installs_hooks);
6543 assert_eq!(parsed.auto_start_cooldown_secs, 60);
6544 assert_eq!(parsed.auto_start_timeout_secs, 10);
6545 assert_eq!(parsed.self_healing_log_level, SelfHealingLogLevel::Debug);
6546 }
6548
6549 #[test]
6552 fn test_self_healing_log_level_default_is_info() {
6553 let level = SelfHealingLogLevel::default();
6555 assert_eq!(level, SelfHealingLogLevel::Info);
6556 let config = SelfHealingConfig::default();
6558 assert_eq!(
6559 config.self_healing_log_level,
6560 SelfHealingLogLevel::Info,
6561 "config default should be Info"
6562 );
6563 }
6565
6566 #[test]
6567 fn test_self_healing_log_level_serializes_lowercase() {
6568 let json = serde_json::to_string(&SelfHealingLogLevel::Warn).unwrap();
6570 assert_eq!(json, "\"warn\"");
6571 let parsed: SelfHealingLogLevel = serde_json::from_str("\"error\"").unwrap();
6572 assert_eq!(parsed, SelfHealingLogLevel::Error);
6573 }
6575
6576 #[test]
6577 fn test_self_healing_log_level_from_env_str_accepts_known() {
6578 assert_eq!(
6580 SelfHealingLogLevel::from_env_str("debug"),
6581 Some(SelfHealingLogLevel::Debug)
6582 );
6583 assert_eq!(
6584 SelfHealingLogLevel::from_env_str("DEBUG"),
6585 Some(SelfHealingLogLevel::Debug)
6586 );
6587 assert_eq!(
6588 SelfHealingLogLevel::from_env_str("info"),
6589 Some(SelfHealingLogLevel::Info)
6590 );
6591 assert_eq!(
6592 SelfHealingLogLevel::from_env_str("warn"),
6593 Some(SelfHealingLogLevel::Warn)
6594 );
6595 assert_eq!(
6596 SelfHealingLogLevel::from_env_str("warning"),
6597 Some(SelfHealingLogLevel::Warn),
6598 "warning should map to Warn"
6599 );
6600 assert_eq!(
6601 SelfHealingLogLevel::from_env_str("error"),
6602 Some(SelfHealingLogLevel::Error)
6603 );
6604 assert_eq!(
6606 SelfHealingLogLevel::from_env_str(" debug "),
6607 Some(SelfHealingLogLevel::Debug)
6608 );
6609 }
6611
6612 #[test]
6613 fn test_self_healing_log_level_from_env_str_rejects_unknown() {
6614 assert!(SelfHealingLogLevel::from_env_str("banana").is_none());
6616 assert!(SelfHealingLogLevel::from_env_str("").is_none());
6617 assert!(SelfHealingLogLevel::from_env_str("trace").is_none());
6618 }
6620
6621 #[test]
6622 fn test_self_healing_config_serde_partial_uses_defaults() {
6623 let _guard = test_guard!();
6624 let json = r#"{"hook_starts_daemon": false}"#;
6626 let config: SelfHealingConfig = serde_json::from_str(json).unwrap();
6627
6628 assert!(
6629 !config.hook_starts_daemon,
6630 "Explicit false should be parsed"
6631 );
6632 assert!(
6633 config.daemon_installs_hooks,
6634 "Missing field should use default (true)"
6635 );
6636 assert_eq!(
6637 config.auto_start_cooldown_secs, 30,
6638 "Missing field should use default (30)"
6639 );
6640 assert_eq!(
6641 config.auto_start_timeout_secs, 3,
6642 "Missing field should use default (3)"
6643 );
6644 }
6646
6647 #[test]
6648 fn test_self_healing_config_toml_with_alias() {
6649 let _guard = test_guard!();
6650 let toml_str = r#"
6652 hook_starts_daemon = true
6653 daemon_installs_hooks = false
6654 auto_start_cooldown_secs = 45
6655 daemon_start_timeout = 7
6656 "#;
6657
6658 let config: SelfHealingConfig = toml::from_str(toml_str).unwrap();
6659 assert!(config.hook_starts_daemon);
6660 assert!(!config.daemon_installs_hooks);
6661 assert_eq!(config.auto_start_cooldown_secs, 45);
6662 assert_eq!(
6663 config.auto_start_timeout_secs, 7,
6664 "daemon_start_timeout alias should set auto_start_timeout_secs"
6665 );
6666 }
6668
6669 #[allow(unsafe_code)]
6670 mod self_healing_env_override_tests {
6671 use super::*;
6672 use crate::config::env_test_lock;
6673
6674 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
6675 env_test_lock()
6676 }
6677
6678 fn set_env(key: &str, value: &str) {
6679 unsafe { std::env::set_var(key, value) };
6681 }
6682
6683 fn remove_env(key: &str) {
6684 unsafe { std::env::remove_var(key) };
6686 }
6687
6688 struct EnvVarGuard {
6689 key: &'static str,
6690 old: Option<String>,
6691 }
6692
6693 impl EnvVarGuard {
6694 fn set(key: &'static str, value: &str) -> Self {
6695 let old = std::env::var(key).ok();
6696 set_env(key, value);
6697 Self { key, old }
6698 }
6699 }
6700
6701 impl Drop for EnvVarGuard {
6702 fn drop(&mut self) {
6703 if let Some(old) = &self.old {
6704 set_env(self.key, old);
6705 } else {
6706 remove_env(self.key);
6707 }
6708 }
6709 }
6710
6711 #[test]
6712 fn test_self_healing_config_with_env_overrides_master_disable() {
6713 let _guard = test_guard!();
6714 let _guard = env_guard();
6715
6716 let _no_self_healing = EnvVarGuard::set("RCH_NO_SELF_HEALING", "1");
6717 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "1");
6718 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "1");
6719 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "99");
6720 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "99");
6721
6722 let config = SelfHealingConfig::default().with_env_overrides();
6723 assert!(
6724 !config.hook_starts_daemon,
6725 "RCH_NO_SELF_HEALING should disable hook auto-start"
6726 );
6727 assert!(
6728 !config.daemon_installs_hooks,
6729 "RCH_NO_SELF_HEALING should disable daemon hook installation"
6730 );
6731
6732 assert_eq!(config.auto_start_cooldown_secs, 30);
6734 assert_eq!(config.auto_start_timeout_secs, 3);
6735 }
6736
6737 #[test]
6738 fn test_self_healing_config_with_env_overrides_toggles_and_numbers() {
6739 let _guard = test_guard!();
6740 let _guard = env_guard();
6741
6742 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "0");
6743 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "false");
6744 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "45");
6745 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "7");
6746
6747 let config = SelfHealingConfig::default().with_env_overrides();
6748 assert!(!config.hook_starts_daemon);
6749 assert!(!config.daemon_installs_hooks);
6750 assert_eq!(config.auto_start_cooldown_secs, 45);
6751 assert_eq!(config.auto_start_timeout_secs, 7);
6752 }
6753
6754 #[test]
6755 fn test_self_healing_config_with_env_overrides_invalid_numbers_ignored() {
6756 let _guard = test_guard!();
6757 let _guard = env_guard();
6758
6759 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "not-a-number");
6760 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "nope");
6761
6762 let config = SelfHealingConfig::default().with_env_overrides();
6763 assert_eq!(config.auto_start_cooldown_secs, 30);
6764 assert_eq!(config.auto_start_timeout_secs, 3);
6765 }
6766 }
6767
6768 #[test]
6773 fn test_load_per_core_calculation() {
6774 let _guard = test_guard!();
6775 let mut caps = WorkerCapabilities::new();
6776 assert!(caps.load_per_core().is_none());
6778
6779 caps.load_avg_1 = Some(4.0);
6781 assert!(caps.load_per_core().is_none());
6782
6783 caps.num_cpus = Some(4);
6785 assert_eq!(caps.load_per_core(), Some(1.0)); caps.load_avg_1 = Some(16.0);
6789 assert_eq!(caps.load_per_core(), Some(4.0)); }
6791
6792 #[test]
6793 fn test_is_high_load() {
6794 let _guard = test_guard!();
6795 let mut caps = WorkerCapabilities::new();
6796 caps.load_avg_1 = Some(8.0);
6797 caps.num_cpus = Some(4);
6798 assert_eq!(caps.is_high_load(3.0), Some(false));
6802 assert_eq!(caps.is_high_load(2.0), Some(false)); assert_eq!(caps.is_high_load(1.5), Some(true));
6806
6807 caps.load_avg_1 = None;
6809 assert!(caps.is_high_load(1.0).is_none());
6810 }
6811
6812 #[test]
6813 fn test_is_low_disk() {
6814 let _guard = test_guard!();
6815 let mut caps = WorkerCapabilities::new();
6816 caps.disk_free_gb = Some(15.0);
6817
6818 assert_eq!(caps.is_low_disk(10.0), Some(false));
6820 assert_eq!(caps.is_low_disk(15.0), Some(false)); assert_eq!(caps.is_low_disk(20.0), Some(true));
6824
6825 caps.disk_free_gb = None;
6827 assert!(caps.is_low_disk(10.0).is_none());
6828 }
6829
6830 #[test]
6831 fn low_disk_judges_the_build_disk_not_a_small_tmpfs() {
6832 let _guard = test_guard!();
6833 let mut caps = WorkerCapabilities::new();
6836 caps.disk_free_gb = Some(8.0);
6837 caps.disk_total_gb = Some(15.5);
6838 caps.build_disk_free_gb = Some(1700.0);
6839 caps.build_disk_total_gb = Some(1900.0);
6840 assert_eq!(caps.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6841 assert_eq!(caps.is_low_disk(10.0), Some(false));
6842
6843 caps.build_disk_free_gb = Some(4.0);
6845 assert_eq!(caps.is_low_disk(10.0), Some(true));
6846
6847 caps.build_disk_free_gb = None;
6849 caps.build_disk_total_gb = None;
6850 assert_eq!(caps.build_disk_gb(), (Some(8.0), Some(15.5)));
6851 assert_eq!(caps.is_low_disk(10.0), Some(true));
6852 }
6853
6854 #[test]
6855 fn build_disk_sample_is_optional_on_the_wire() {
6856 let _guard = test_guard!();
6857 let old: WorkerCapabilities =
6858 serde_json::from_str(r#"{"disk_free_gb":13.9,"disk_total_gb":15.5}"#).unwrap();
6859 assert_eq!(old.build_disk_free_gb, None);
6860 assert_eq!(old.build_disk_gb(), (Some(13.9), Some(15.5)));
6861
6862 let new: WorkerCapabilities = serde_json::from_str(
6863 r#"{"disk_free_gb":13.9,"disk_total_gb":15.5,"build_disk_free_gb":1700.0,"build_disk_total_gb":1900.0}"#,
6864 )
6865 .unwrap();
6866 assert_eq!(new.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6867 let json = serde_json::to_string(&new).unwrap();
6868 assert!(json.contains("\"build_disk_free_gb\":1700.0"), "{json}");
6869 }
6870
6871 #[test]
6872 fn test_selection_config_preflight_defaults() {
6873 let _guard = test_guard!();
6874 let config = SelectionConfig::default();
6875 assert_eq!(config.max_load_per_core, Some(2.0));
6877 assert_eq!(config.min_free_gb, Some(10.0));
6878 assert_eq!(config.disk_gb_per_slot, 10.0);
6879 }
6880
6881 #[test]
6882 fn test_selection_config_preflight_serde() {
6883 let _guard = test_guard!();
6884 let config = SelectionConfig {
6885 max_load_per_core: Some(3.5),
6886 min_free_gb: Some(25.0),
6887 disk_gb_per_slot: 0.83,
6888 ..Default::default()
6889 };
6890 let json = serde_json::to_string(&config).unwrap();
6891 let parsed: SelectionConfig = serde_json::from_str(&json).unwrap();
6892 assert_eq!(parsed.max_load_per_core, Some(3.5));
6893 assert_eq!(parsed.min_free_gb, Some(25.0));
6894 assert_eq!(parsed.disk_gb_per_slot, 0.83);
6895 }
6896
6897 #[test]
6898 fn test_selection_disk_slot_budget_validation() {
6899 let _guard = test_guard!();
6900 for value in ["0.0", "-1.0", "nan", "inf", "-inf"] {
6901 let result = toml::from_str::<SelectionConfig>(&format!("disk_gb_per_slot = {value}"));
6902 assert!(result.is_err(), "invalid disk slot budget {value}");
6903 }
6904 let default: SelectionConfig = toml::from_str("").unwrap();
6905 assert_eq!(default.disk_gb_per_slot, 10.0);
6906 }
6907
6908 #[test]
6909 fn test_selection_disk_weight_validation_and_roundtrip() {
6910 for value in ["-0.1", "1.1", "nan", "inf", "-inf"] {
6911 assert!(toml::from_str::<SelectionWeightConfig>(&format!("disk = {value}")).is_err());
6912 }
6913 for value in [0.0, 0.2, 1.0] {
6914 let weights: SelectionWeightConfig =
6915 toml::from_str(&format!("disk = {value}")).unwrap();
6916 assert_eq!(weights.disk, value);
6917 let roundtrip: SelectionWeightConfig =
6918 serde_json::from_str(&serde_json::to_string(&weights).unwrap()).unwrap();
6919 assert_eq!(roundtrip.disk, value);
6920 }
6921 let defaults: SelectionWeightConfig = toml::from_str("").unwrap();
6922 assert_eq!(defaults.disk, 0.2);
6923 }
6924
6925 #[test]
6926 fn test_worker_capabilities_health_metrics_serde() {
6927 let _guard = test_guard!();
6928 let caps = WorkerCapabilities {
6929 num_cpus: Some(8),
6930 load_avg_1: Some(1.5),
6931 load_avg_5: Some(2.0),
6932 load_avg_15: Some(1.8),
6933 disk_free_gb: Some(50.5),
6934 disk_total_gb: Some(100.0),
6935 ..Default::default()
6936 };
6937 let json = serde_json::to_string(&caps).unwrap();
6938 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
6939 assert_eq!(parsed.num_cpus, Some(8));
6940 assert_eq!(parsed.load_avg_1, Some(1.5));
6941 assert_eq!(parsed.disk_free_gb, Some(50.5));
6942 }
6943
6944 #[test]
6945 fn microarch_level_classifies_ivy_bridge_as_v2() {
6946 let ivy = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov \
6949 pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm \
6950 constant_tsc pni pclmulqdq ssse3 cx16 sse4_1 sse4_2 x2apic \
6951 popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm";
6952 assert_eq!(x86_64_microarch_level_from_flags(ivy), 2);
6953 }
6954
6955 #[test]
6956 fn microarch_level_classifies_v3_and_v4() {
6957 let v3 = "cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 \
6958 f16c fma movbe xsave";
6959 assert_eq!(x86_64_microarch_level_from_flags(v3), 3);
6960
6961 let v4 = format!("{v3} avx512f avx512bw avx512cd avx512dq avx512vl");
6962 assert_eq!(x86_64_microarch_level_from_flags(&v4), 4);
6963 }
6964
6965 #[test]
6966 fn microarch_level_v1_for_ancient_or_empty_flags() {
6967 assert_eq!(x86_64_microarch_level_from_flags(""), 1);
6968 assert_eq!(x86_64_microarch_level_from_flags("fpu mmx sse sse2"), 1);
6969 }
6970
6971 #[test]
6972 fn pre_v3_predicate_only_fires_on_affirmative_evidence() {
6973 let mut caps = WorkerCapabilities::new();
6974 assert!(!caps.is_pre_v3_x86(), "unknown must not deprioritize");
6975 caps.cpu_microarch_level = Some(2);
6976 assert!(caps.is_pre_v3_x86());
6977 caps.cpu_microarch_level = Some(3);
6978 assert!(!caps.is_pre_v3_x86());
6979 }
6980}