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")]
2618 pub allow_local_fallback: bool,
2619 #[serde(default)]
2630 pub remote_build_jobs: RemoteBuildJobs,
2631}
2632
2633#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2645pub enum RemoteBuildJobs {
2646 #[default]
2648 Auto,
2649 Off,
2651 Fixed(u32),
2653}
2654
2655impl RemoteBuildJobs {
2656 pub fn parse(value: &str) -> Result<Self, String> {
2658 let trimmed = value.trim();
2659 match trimmed.to_ascii_lowercase().as_str() {
2660 "auto" => Ok(Self::Auto),
2661 "off" | "none" | "disabled" => Ok(Self::Off),
2662 _ => match trimmed.parse::<u32>() {
2663 Ok(0) => Err(
2664 "remote_build_jobs must be >= 1 (cargo rejects `-j 0`); use `off` to disable"
2665 .to_string(),
2666 ),
2667 Ok(n) => Ok(Self::Fixed(n)),
2668 Err(_) => Err(format!(
2669 "'{trimmed}' is not a valid remote_build_jobs value (expected `auto`, `off`, or a positive integer)"
2670 )),
2671 },
2672 }
2673 }
2674}
2675
2676impl std::fmt::Display for RemoteBuildJobs {
2677 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2678 match self {
2679 Self::Auto => f.write_str("auto"),
2680 Self::Off => f.write_str("off"),
2681 Self::Fixed(n) => write!(f, "{n}"),
2682 }
2683 }
2684}
2685
2686impl std::str::FromStr for RemoteBuildJobs {
2687 type Err = String;
2688
2689 fn from_str(value: &str) -> Result<Self, Self::Err> {
2690 Self::parse(value)
2691 }
2692}
2693
2694impl Serialize for RemoteBuildJobs {
2695 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2696 match self {
2697 Self::Auto => serializer.serialize_str("auto"),
2698 Self::Off => serializer.serialize_str("off"),
2699 Self::Fixed(n) => serializer.serialize_u32(*n),
2700 }
2701 }
2702}
2703
2704impl<'de> Deserialize<'de> for RemoteBuildJobs {
2705 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2706 #[derive(Deserialize)]
2707 #[serde(untagged)]
2708 enum Raw {
2709 Count(u64),
2710 Spelling(String),
2711 }
2712 match Raw::deserialize(deserializer)? {
2713 Raw::Count(n) => {
2714 let n = u32::try_from(n).map_err(|_| {
2715 serde::de::Error::custom(format!("remote_build_jobs {n} is out of range"))
2716 })?;
2717 Self::parse(&n.to_string()).map_err(serde::de::Error::custom)
2718 }
2719 Raw::Spelling(s) => Self::parse(&s).map_err(serde::de::Error::custom),
2720 }
2721 }
2722}
2723
2724impl Default for CompilationConfig {
2725 fn default() -> Self {
2726 Self {
2727 confidence_threshold: 0.85,
2728 min_local_time_ms: 2000,
2729 remote_speedup_threshold: default_speedup_threshold(),
2730 local_slots: 0,
2731 build_slots: default_build_slots(),
2732 test_slots: default_test_slots(),
2733 check_slots: default_check_slots(),
2734 disk_headroom_gib: 0,
2735 build_timeout_sec: default_build_timeout(),
2736 test_timeout_sec: default_test_timeout(),
2737 bun_timeout_sec: default_bun_timeout(),
2738 external_timeout_enabled: default_external_timeout_enabled(),
2739 allow_local_fallback: default_allow_local_fallback(),
2740 remote_build_jobs: RemoteBuildJobs::default(),
2741 }
2742 }
2743}
2744
2745fn default_build_slots() -> u32 {
2746 4
2747}
2748
2749fn default_test_slots() -> u32 {
2750 8
2751}
2752
2753fn default_check_slots() -> u32 {
2754 2
2755}
2756
2757fn default_build_timeout() -> u64 {
2760 300
2761}
2762
2763fn default_test_timeout() -> u64 {
2766 1800
2767}
2768
2769fn default_bun_timeout() -> u64 {
2773 600
2774}
2775
2776fn default_external_timeout_enabled() -> bool {
2778 true
2779}
2780
2781fn default_allow_local_fallback() -> bool {
2785 true
2786}
2787
2788impl CompilationConfig {
2789 pub fn timeout_for_kind(&self, kind: Option<CompilationKind>) -> std::time::Duration {
2795 let secs = match kind {
2796 Some(CompilationKind::BunTest) | Some(CompilationKind::BunTypecheck) => {
2798 self.bun_timeout_sec
2799 }
2800 Some(CompilationKind::CargoTest) | Some(CompilationKind::CargoNextest) => {
2802 self.test_timeout_sec
2803 }
2804 Some(CompilationKind::NixBuild) => self.test_timeout_sec,
2808 Some(CompilationKind::GoTest) => self.test_timeout_sec,
2811 _ => self.build_timeout_sec,
2813 };
2814 std::time::Duration::from_secs(secs)
2815 }
2816
2817 pub fn external_timeout_enabled(&self) -> bool {
2819 self.external_timeout_enabled
2820 }
2821}
2822
2823pub const LAYER0_PACK_VERSION: &str = "2";
2829
2830#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
2841#[serde(default)]
2842pub struct Layer0Config {
2843 pub enabled: bool,
2845 pub release_lto_thin: bool,
2847 pub release_codegen_units_1: bool,
2850 pub debug_line_tables_only: bool,
2857}
2858
2859impl Layer0Config {
2860 #[must_use]
2866 pub fn active_env(&self) -> Vec<(&'static str, &'static str)> {
2867 if !self.enabled {
2868 return Vec::new();
2869 }
2870 let mut env = Vec::new();
2871 if self.release_lto_thin {
2872 env.push(("CARGO_PROFILE_RELEASE_LTO", "thin"));
2873 }
2874 if self.release_codegen_units_1 {
2875 env.push(("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"));
2876 }
2877 if self.debug_line_tables_only {
2878 env.push(("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"));
2879 env.push(("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"));
2880 }
2881 env
2882 }
2883}
2884
2885#[derive(Debug, Clone, Serialize, Deserialize)]
2886pub struct TransferConfig {
2887 #[serde(default, skip_serializing_if = "Option::is_none")]
2890 pub retrieval_staging_dir: Option<PathBuf>,
2891 #[serde(default)]
2894 pub retrieval_min_free_bytes: u64,
2895 #[serde(default = "default_compression")]
2897 pub compression_level: u32,
2898 #[serde(default = "default_excludes")]
2900 pub exclude_patterns: Vec<String>,
2901 #[serde(default, skip_serializing_if = "Option::is_none")]
2905 pub ssh_server_alive_interval_secs: Option<u64>,
2906 #[serde(default, skip_serializing_if = "Option::is_none")]
2911 pub ssh_control_persist_secs: Option<u64>,
2912 #[serde(default = "default_remote_base")]
2923 pub remote_base: String,
2924 #[serde(default, skip_serializing_if = "Option::is_none")]
2930 pub sync_timeout_ms: Option<u64>,
2931 #[serde(default = "default_source_sync_silence_timeout_secs")]
2943 pub source_sync_silence_timeout_secs: u64,
2944 #[serde(default)]
2946 pub retry: RetryConfig,
2947 #[serde(default)]
2954 pub verify_artifacts: bool,
2955 #[serde(default = "default_verify_max_size")]
2960 pub verify_max_size_bytes: u64,
2961
2962 #[serde(default, skip_serializing_if = "Option::is_none")]
2971 pub max_transfer_mb: Option<u64>,
2972
2973 #[serde(default, skip_serializing_if = "Option::is_none")]
2979 pub max_transfer_time_ms: Option<u64>,
2980
2981 #[serde(default, skip_serializing_if = "Option::is_none")]
2986 pub bwlimit_kbps: Option<u64>,
2987
2988 #[serde(default, skip_serializing_if = "Option::is_none")]
2997 pub rsync_bin: Option<String>,
2998
2999 #[serde(default, skip_serializing_if = "Option::is_none")]
3004 pub estimated_bandwidth_bps: Option<u64>,
3005
3006 #[serde(default)]
3019 pub adaptive_compression: bool,
3020
3021 #[serde(default = "default_min_compression")]
3026 pub min_compression_level: u32,
3027
3028 #[serde(default = "default_max_compression")]
3033 pub max_compression_level: u32,
3034}
3035
3036impl Default for TransferConfig {
3037 fn default() -> Self {
3038 Self {
3039 retrieval_staging_dir: None,
3040 retrieval_min_free_bytes: 0,
3041 compression_level: 3,
3042 exclude_patterns: default_excludes(),
3043 ssh_server_alive_interval_secs: None,
3044 ssh_control_persist_secs: None,
3045 remote_base: default_remote_base(),
3046 sync_timeout_ms: None,
3047 source_sync_silence_timeout_secs: default_source_sync_silence_timeout_secs(),
3048 retry: RetryConfig::default(),
3049 verify_artifacts: false,
3050 verify_max_size_bytes: default_verify_max_size(),
3051 max_transfer_mb: None,
3053 max_transfer_time_ms: None,
3054 bwlimit_kbps: None,
3055 rsync_bin: None,
3056 estimated_bandwidth_bps: None,
3057 adaptive_compression: false,
3059 min_compression_level: default_min_compression(),
3060 max_compression_level: default_max_compression(),
3061 }
3062 }
3063}
3064
3065impl TransferConfig {
3066 pub const MIN_SYNC_TIMEOUT_MS: u64 = 1_000;
3068 pub const MAX_SYNC_TIMEOUT_MS: u64 = 3_600_000;
3070 pub const DEFAULT_SYNC_TIMEOUT_FLOOR_MS: u64 = 30_000;
3072 const DEFAULT_SYNC_BYTES_PER_SECOND: u64 = 1024 * 1024;
3073
3074 #[must_use]
3076 pub fn valid_sync_timeout_ms(value: u64) -> bool {
3077 (Self::MIN_SYNC_TIMEOUT_MS..=Self::MAX_SYNC_TIMEOUT_MS).contains(&value)
3078 }
3079
3080 #[must_use]
3087 pub fn sync_timeout_for_payload(&self, payload_bytes: u64) -> std::time::Duration {
3088 let timeout_ms = self
3089 .sync_timeout_ms
3090 .filter(|value| Self::valid_sync_timeout_ms(*value))
3091 .unwrap_or_else(|| {
3092 let transfer_seconds = payload_bytes
3093 .saturating_add(Self::DEFAULT_SYNC_BYTES_PER_SECOND - 1)
3094 / Self::DEFAULT_SYNC_BYTES_PER_SECOND;
3095 Self::DEFAULT_SYNC_TIMEOUT_FLOOR_MS
3096 .saturating_add(transfer_seconds.saturating_mul(1_000))
3097 .min(Self::MAX_SYNC_TIMEOUT_MS)
3098 });
3099 std::time::Duration::from_millis(timeout_ms)
3100 }
3101
3102 pub fn select_compression_level(&self, estimated_bytes: Option<u64>) -> u32 {
3114 if !self.adaptive_compression {
3115 return self.compression_level;
3116 }
3117
3118 let Some(bytes) = estimated_bytes else {
3119 return self.compression_level;
3121 };
3122
3123 const SMALL_THRESHOLD: u64 = 10_000_000; const LARGE_THRESHOLD: u64 = 200_000_000; let level = if bytes < SMALL_THRESHOLD {
3128 1 } else if bytes < LARGE_THRESHOLD {
3130 3 } else {
3132 7 };
3134
3135 level.clamp(self.min_compression_level, self.max_compression_level)
3137 }
3138}
3139
3140#[rustfmt::skip]
3154pub const BUILTIN_EXECUTION_BASES: &[&str] = &[
3155 "cargo",
3157 "rustc",
3158 "nextest",
3160 "gcc",
3162 "g++",
3163 "clang",
3164 "clang++",
3165 "cc",
3166 "c++",
3167 "make",
3169 "cmake",
3170 "ninja",
3171 "meson",
3172 "bun",
3174 "nix",
3176 "go",
3178 "tsc",
3180];
3181
3182fn default_execution_allowlist() -> Vec<String> {
3183 BUILTIN_EXECUTION_BASES
3184 .iter()
3185 .map(|base| (*base).to_string())
3186 .collect()
3187}
3188
3189#[derive(Debug, Clone, Serialize, Deserialize)]
3194pub struct ExecutionConfig {
3195 #[serde(default = "default_execution_allowlist")]
3204 pub allowlist: Vec<String>,
3205}
3206
3207impl Default for ExecutionConfig {
3208 fn default() -> Self {
3209 Self {
3210 allowlist: default_execution_allowlist(),
3211 }
3212 }
3213}
3214
3215impl ExecutionConfig {
3216 pub fn is_allowed(&self, command_base: &str) -> bool {
3218 self.allowlist
3219 .iter()
3220 .any(|allowed| allowed.eq_ignore_ascii_case(command_base))
3221 }
3222
3223 pub fn missing_builtin_bases(&self) -> Vec<&'static str> {
3229 BUILTIN_EXECUTION_BASES
3230 .iter()
3231 .copied()
3232 .filter(|base| !self.is_allowed(base))
3233 .collect()
3234 }
3235}
3236
3237fn default_retry_max_attempts() -> u32 {
3242 3
3243}
3244
3245fn default_retry_base_delay_ms() -> u64 {
3246 100
3247}
3248
3249fn default_retry_max_delay_ms() -> u64 {
3250 5000
3251}
3252
3253fn default_retry_jitter_factor() -> f64 {
3254 0.1
3255}
3256
3257fn default_retry_total_timeout_ms() -> u64 {
3258 30000
3259}
3260
3261#[derive(Debug, Clone, Serialize, Deserialize)]
3266pub struct RetryConfig {
3267 #[serde(default = "default_retry_max_attempts")]
3269 pub max_attempts: u32,
3270
3271 #[serde(default = "default_retry_base_delay_ms")]
3273 pub base_delay_ms: u64,
3274
3275 #[serde(default = "default_retry_max_delay_ms")]
3277 pub max_delay_ms: u64,
3278
3279 #[serde(default = "default_retry_jitter_factor")]
3282 pub jitter_factor: f64,
3283
3284 #[serde(default = "default_retry_total_timeout_ms")]
3287 pub total_timeout_ms: u64,
3288}
3289
3290impl Default for RetryConfig {
3291 fn default() -> Self {
3292 Self {
3293 max_attempts: default_retry_max_attempts(),
3294 base_delay_ms: default_retry_base_delay_ms(),
3295 max_delay_ms: default_retry_max_delay_ms(),
3296 jitter_factor: default_retry_jitter_factor(),
3297 total_timeout_ms: default_retry_total_timeout_ms(),
3298 }
3299 }
3300}
3301
3302impl RetryConfig {
3303 pub fn no_retry() -> Self {
3305 Self {
3306 max_attempts: 1,
3307 ..Default::default()
3308 }
3309 }
3310
3311 pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
3315 if attempt == 0 {
3316 return std::time::Duration::ZERO;
3317 }
3318
3319 let base = self.base_delay_ms as f64;
3321 let delay = base * (2.0_f64.powi(attempt as i32 - 1));
3322 let capped = delay.min(self.max_delay_ms as f64);
3323
3324 let jittered = if self.jitter_factor > 0.0 {
3326 let jitter_range = capped * self.jitter_factor;
3327 let jitter = (rand::rng().random::<f64>() * 2.0 - 1.0) * jitter_range;
3328 (capped + jitter).max(0.0)
3329 } else {
3330 capped
3331 };
3332
3333 std::time::Duration::from_millis(jittered as u64)
3334 }
3335
3336 pub fn should_retry(&self, attempt: u32, elapsed: std::time::Duration) -> bool {
3338 if attempt >= self.max_attempts {
3339 return false;
3340 }
3341 elapsed.as_millis() < self.total_timeout_ms as u128
3342 }
3343}
3344
3345fn default_source_sync_silence_timeout_secs() -> u64 {
3350 120
3351}
3352
3353pub fn default_remote_base() -> String {
3362 "/data/tmp/rch".to_string()
3363}
3364
3365pub fn validate_remote_base(path: &str) -> Result<String, String> {
3368 let expanded = shellexpand::tilde(path).into_owned();
3370
3371 if !expanded.starts_with('/') {
3373 return Err(format!(
3374 "remote_base must be an absolute path, got: {}",
3375 path
3376 ));
3377 }
3378
3379 if expanded.contains("..") {
3381 return Err(format!(
3382 "remote_base must not contain path traversal (..): {}",
3383 path
3384 ));
3385 }
3386
3387 let normalized = expanded.trim_end_matches('/').to_string();
3389
3390 if normalized.is_empty() || normalized == "/" {
3392 return Err("remote_base cannot be the root directory (safety restriction)".to_string());
3393 }
3394
3395 let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
3399 if components.len() < 2 {
3400 return Err(format!(
3401 "remote_base must be at least 2 levels deep (e.g. /tmp/rch), got: {}",
3402 normalized
3403 ));
3404 }
3405
3406 Ok(normalized)
3407}
3408
3409fn default_circuit_failure_threshold() -> u32 {
3410 3
3411}
3412
3413fn default_circuit_success_threshold() -> u32 {
3414 2
3415}
3416
3417fn default_circuit_error_rate_threshold() -> f64 {
3418 0.5
3419}
3420
3421fn default_circuit_window_secs() -> u64 {
3422 60
3423}
3424
3425fn default_circuit_open_cooldown_secs() -> u64 {
3426 30
3427}
3428
3429fn default_circuit_half_open_max_probes() -> u32 {
3430 1
3431}
3432
3433fn default_circuit_command_failure_threshold() -> u32 {
3434 2
3435}
3436
3437fn default_circuit_open_cooldown_max_secs() -> u64 {
3438 480
3440}
3441
3442fn default_true() -> bool {
3443 true
3444}
3445
3446fn default_log_level() -> String {
3447 "info".to_string()
3448}
3449
3450pub fn default_socket_path() -> String {
3451 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR")
3453 && !runtime_dir.trim().is_empty()
3454 {
3455 let path = PathBuf::from(runtime_dir).join("rch.sock");
3456 return path.to_string_lossy().to_string();
3457 }
3458
3459 if let Some(cache_dir) = dirs::cache_dir() {
3461 let rch_cache = cache_dir.join("rch");
3462 let _ = std::fs::create_dir_all(&rch_cache);
3463 return rch_cache.join("rch.sock").to_string_lossy().to_string();
3464 }
3465
3466 "/tmp/rch.sock".to_string()
3468}
3469
3470#[allow(dead_code)]
3471fn default_output_visibility() -> OutputVisibility {
3472 OutputVisibility::None
3473}
3474
3475fn default_confidence() -> f64 {
3476 0.85
3477}
3478
3479fn default_min_local_time() -> u64 {
3480 2000
3481}
3482
3483fn default_speedup_threshold() -> f64 {
3487 1.2
3488}
3489
3490fn default_compression() -> u32 {
3491 3
3492}
3493
3494fn default_verify_max_size() -> u64 {
3496 100 * 1024 * 1024 }
3498
3499fn default_min_compression() -> u32 {
3501 1
3502}
3503
3504fn default_max_compression() -> u32 {
3506 9
3507}
3508
3509fn default_excludes() -> Vec<String> {
3510 vec![
3511 "target/".to_string(),
3513 "*.rlib".to_string(),
3514 "*.rmeta".to_string(),
3515 ".git/".to_string(),
3518 "node_modules/".to_string(),
3520 ".bun/".to_string(),
3522 ".npm/".to_string(),
3524 ".pnpm-store/".to_string(),
3525 "dist/".to_string(),
3527 "build/".to_string(),
3528 ".next/".to_string(),
3530 ".nuxt/".to_string(),
3531 ".turbo/".to_string(),
3532 ".parcel-cache/".to_string(),
3533 ".beads/".to_string(),
3537 ".ntm/".to_string(),
3538 ".beads-recovery/".to_string(),
3539 ".doctor/".to_string(),
3544 "coverage/".to_string(),
3546 ".nyc_output/".to_string(),
3547 ".cargo/credentials".to_string(),
3549 ".cargo/credentials.toml".to_string(),
3550 ".env".to_string(),
3551 ".env.*".to_string(),
3552 "*.pem".to_string(),
3553 "*.key".to_string(),
3554 "credentials.json".to_string(),
3555 "secrets.json".to_string(),
3556 "secrets.yaml".to_string(),
3557 "secrets.yml".to_string(),
3558 "secrets.toml".to_string(),
3559 "secrets.env".to_string(),
3560 "secrets.txt".to_string(),
3561 "secrets.conf".to_string(),
3562 "secrets.cfg".to_string(),
3563 "secrets.properties".to_string(),
3564 "secrets.xml".to_string(),
3565 "id_rsa".to_string(),
3579 "id_ed25519".to_string(),
3580 "id_ecdsa".to_string(),
3581 "id_dsa".to_string(),
3582 "*.p12".to_string(),
3584 "*.pfx".to_string(),
3585 "*.keystore".to_string(),
3586 "*.jks".to_string(),
3587 ".netrc".to_string(),
3590 "_netrc".to_string(),
3591 ".aws/credentials".to_string(),
3592 ".docker/config.json".to_string(),
3593 ".npmrc".to_string(),
3597 ".pypirc".to_string(),
3598 "secring.*".to_string(),
3600 ]
3601}
3602
3603#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3609#[serde(rename_all = "snake_case")]
3610pub enum BuildLocation {
3611 #[default]
3613 Local,
3614 Remote,
3616}
3617
3618#[derive(Debug, Clone, Serialize, Deserialize)]
3620pub struct BuildCancellationWorkerHealth {
3621 pub status: String,
3623 pub speed_score: f64,
3625 pub used_slots: u32,
3627 pub available_slots: u32,
3629 pub pressure_state: String,
3631 pub pressure_reason_code: String,
3633}
3634
3635#[derive(Debug, Clone, Serialize, Deserialize)]
3637pub struct BuildCancellationMetadata {
3638 pub operation_id: String,
3640 pub origin: String,
3642 pub reason_code: String,
3644 #[serde(default)]
3646 pub decision_path: Vec<String>,
3647 pub escalation_stage: String,
3649 pub escalation_count: u32,
3651 pub remote_kill_attempted: bool,
3653 pub cleanup_ok: bool,
3655 pub history_cancelled: bool,
3657 pub final_state: String,
3659 #[serde(default, skip_serializing_if = "Option::is_none")]
3661 pub worker_health: Option<BuildCancellationWorkerHealth>,
3662}
3663
3664#[derive(Debug, Clone, Serialize, Deserialize)]
3666pub struct BuildRecord {
3667 pub id: u64,
3669 pub started_at: String,
3671 pub completed_at: String,
3673 pub project_id: String,
3675 pub worker_id: Option<String>,
3677 pub command: String,
3679 pub exit_code: i32,
3681 pub duration_ms: u64,
3683 pub location: BuildLocation,
3685 pub bytes_transferred: Option<u64>,
3687 #[serde(default)]
3689 pub timing: Option<CommandTimingBreakdown>,
3690 #[serde(default, skip_serializing_if = "Option::is_none")]
3692 pub cancellation: Option<BuildCancellationMetadata>,
3693}
3694
3695#[derive(Debug, Clone, Serialize, Deserialize)]
3697pub struct BuildRecordInput {
3698 pub started_at: String,
3700 pub completed_at: String,
3702 pub project_id: String,
3704 pub worker_id: Option<String>,
3706 pub command: String,
3708 pub exit_code: i32,
3710 pub duration_ms: u64,
3712 pub location: BuildLocation,
3714 pub bytes_transferred: Option<u64>,
3716 #[serde(default)]
3718 pub timing: Option<CommandTimingBreakdown>,
3719 #[serde(default, skip_serializing_if = "Option::is_none")]
3721 pub cancellation: Option<BuildCancellationMetadata>,
3722}
3723
3724impl BuildRecordInput {
3725 pub fn into_record(self, id: u64) -> BuildRecord {
3727 BuildRecord {
3728 id,
3729 started_at: self.started_at,
3730 completed_at: self.completed_at,
3731 project_id: self.project_id,
3732 worker_id: self.worker_id,
3733 command: self.command,
3734 exit_code: self.exit_code,
3735 duration_ms: self.duration_ms,
3736 location: self.location,
3737 bytes_transferred: self.bytes_transferred,
3738 timing: self.timing,
3739 cancellation: self.cancellation,
3740 }
3741 }
3742}
3743
3744#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3749pub struct BuildStats {
3750 pub total_builds: usize,
3752 pub success_count: usize,
3754 pub failure_count: usize,
3756 pub remote_count: usize,
3758 pub local_count: usize,
3760 pub avg_duration_ms: u64,
3762}
3763
3764#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3771pub struct SavedTimeStats {
3772 pub total_remote_duration_ms: u64,
3774 pub estimated_local_duration_ms: u64,
3777 pub time_saved_ms: u64,
3780 pub builds_counted: usize,
3782 pub avg_speedup: f64,
3786 pub today_saved_ms: u64,
3788 pub week_saved_ms: u64,
3790 #[serde(default)]
3793 pub estimate_basis: String,
3794 #[serde(default)]
3796 pub local_baseline_builds: usize,
3797}
3798
3799use std::collections::VecDeque;
3804use std::time::{Duration, Instant};
3805
3806#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3808pub struct CompilationTimingBreakdown {
3809 #[serde(with = "duration_millis")]
3811 pub rsync_up: Duration,
3812 #[serde(with = "duration_millis")]
3814 pub remote_build: Duration,
3815 #[serde(with = "duration_millis")]
3817 pub rsync_down: Duration,
3818 #[serde(with = "duration_millis")]
3820 pub total: Duration,
3821}
3822
3823#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3827pub struct CommandTimingBreakdown {
3828 #[serde(with = "option_duration_millis")]
3830 pub classify: Option<Duration>,
3831 #[serde(with = "option_duration_millis")]
3833 pub select: Option<Duration>,
3834 #[serde(with = "option_duration_millis")]
3836 pub sync_up: Option<Duration>,
3837 #[serde(with = "option_duration_millis")]
3839 pub exec: Option<Duration>,
3840 #[serde(with = "option_duration_millis")]
3842 pub sync_down: Option<Duration>,
3843 #[serde(with = "option_duration_millis")]
3845 pub cleanup: Option<Duration>,
3846 #[serde(with = "option_duration_millis")]
3848 pub total: Option<Duration>,
3849}
3850
3851mod duration_millis {
3852 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3853 use std::time::Duration;
3854
3855 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
3856 where
3857 S: Serializer,
3858 {
3859 duration.as_millis().serialize(serializer)
3860 }
3861
3862 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
3863 where
3864 D: Deserializer<'de>,
3865 {
3866 let millis = u64::deserialize(deserializer)?;
3867 Ok(Duration::from_millis(millis))
3868 }
3869}
3870
3871#[derive(Debug, Clone, Serialize, Deserialize)]
3873pub struct CompilationMetrics {
3874 pub project_id: String,
3876 pub worker_id: String,
3878 pub timestamp: chrono::DateTime<chrono::Utc>,
3880 pub timing: CompilationTimingBreakdown,
3882 #[serde(with = "option_duration_millis")]
3884 pub local_build_time: Option<Duration>,
3885 pub speedup: Option<f64>,
3887 pub files_synced: u64,
3889 pub bytes_transferred: u64,
3891 pub exit_code: i32,
3893 pub success: bool,
3895}
3896
3897mod option_duration_millis {
3898 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3899 use std::time::Duration;
3900
3901 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
3902 where
3903 S: Serializer,
3904 {
3905 match duration {
3906 Some(d) => Some(d.as_millis() as u64).serialize(serializer),
3907 None => Option::<u64>::None.serialize(serializer),
3908 }
3909 }
3910
3911 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
3912 where
3913 D: Deserializer<'de>,
3914 {
3915 let opt: Option<u64> = Option::deserialize(deserializer)?;
3916 Ok(opt.map(Duration::from_millis))
3917 }
3918}
3919
3920impl CompilationMetrics {
3921 pub fn calculate_speedup(&mut self) {
3923 if let Some(local) = self.local_build_time
3924 && self.timing.total.as_millis() > 0
3925 {
3926 self.speedup = Some(local.as_secs_f64() / self.timing.total.as_secs_f64());
3927 }
3928 }
3929
3930 pub fn is_beneficial(&self) -> bool {
3932 self.speedup.map(|s| s > 1.0).unwrap_or(false)
3933 }
3934}
3935
3936impl Default for CompilationMetrics {
3937 fn default() -> Self {
3938 Self {
3939 project_id: String::new(),
3940 worker_id: String::new(),
3941 timestamp: chrono::Utc::now(),
3942 timing: CompilationTimingBreakdown::default(),
3943 local_build_time: None,
3944 speedup: None,
3945 files_synced: 0,
3946 bytes_transferred: 0,
3947 exit_code: 0,
3948 success: true,
3949 }
3950 }
3951}
3952
3953#[derive(Debug)]
3969pub struct CompilationTimer {
3970 project_id: String,
3971 worker_id: String,
3972 start: Instant,
3973 phase_start: Instant,
3974 rsync_up: Option<Duration>,
3975 remote_build: Option<Duration>,
3976 rsync_down: Option<Duration>,
3977}
3978
3979impl CompilationTimer {
3980 pub fn new(project_id: &str, worker_id: &str) -> Self {
3982 let now = Instant::now();
3983 Self {
3984 project_id: project_id.to_string(),
3985 worker_id: worker_id.to_string(),
3986 start: now,
3987 phase_start: now,
3988 rsync_up: None,
3989 remote_build: None,
3990 rsync_down: None,
3991 }
3992 }
3993
3994 pub fn end_rsync_up(&mut self) {
3996 self.rsync_up = Some(self.phase_start.elapsed());
3997 self.phase_start = Instant::now();
3998 tracing::info!(
3999 rsync_up_ms = %self.rsync_up.unwrap().as_millis(),
4000 "TIMING: rsync_up completed"
4001 );
4002 }
4003
4004 pub fn end_remote_build(&mut self) {
4006 self.remote_build = Some(self.phase_start.elapsed());
4007 self.phase_start = Instant::now();
4008 tracing::info!(
4009 remote_build_ms = %self.remote_build.unwrap().as_millis(),
4010 "TIMING: remote_build completed"
4011 );
4012 }
4013
4014 pub fn end_rsync_down(&mut self) {
4016 self.rsync_down = Some(self.phase_start.elapsed());
4017 tracing::info!(
4018 rsync_down_ms = %self.rsync_down.unwrap().as_millis(),
4019 "TIMING: rsync_down completed"
4020 );
4021 }
4022
4023 pub fn finish(self, exit_code: i32, files: u64, bytes: u64) -> CompilationMetrics {
4025 let total = self.start.elapsed();
4026 tracing::info!(
4027 total_ms = %total.as_millis(),
4028 exit_code = %exit_code,
4029 "TIMING: compilation completed"
4030 );
4031
4032 CompilationMetrics {
4033 project_id: self.project_id,
4034 worker_id: self.worker_id,
4035 timestamp: chrono::Utc::now(),
4036 timing: CompilationTimingBreakdown {
4037 rsync_up: self.rsync_up.unwrap_or_default(),
4038 remote_build: self.remote_build.unwrap_or_default(),
4039 rsync_down: self.rsync_down.unwrap_or_default(),
4040 total,
4041 },
4042 local_build_time: None,
4043 speedup: None,
4044 files_synced: files,
4045 bytes_transferred: bytes,
4046 exit_code,
4047 success: exit_code == 0,
4048 }
4049 }
4050}
4051
4052#[derive(Debug)]
4054pub struct MetricsAggregator {
4055 history: VecDeque<CompilationMetrics>,
4056 max_history: usize,
4057}
4058
4059impl MetricsAggregator {
4060 pub fn new(max_history: usize) -> Self {
4062 Self {
4063 history: VecDeque::with_capacity(max_history),
4064 max_history,
4065 }
4066 }
4067
4068 pub fn record(&mut self, metrics: CompilationMetrics) {
4070 if self.history.len() >= self.max_history {
4071 self.history.pop_front();
4072 }
4073 self.history.push_back(metrics);
4074 }
4075
4076 pub fn average_speedup(&self) -> Option<f64> {
4078 let speedups: Vec<f64> = self.history.iter().filter_map(|m| m.speedup).collect();
4079
4080 if speedups.is_empty() {
4081 None
4082 } else {
4083 Some(speedups.iter().sum::<f64>() / speedups.len() as f64)
4084 }
4085 }
4086
4087 pub fn p50_total_time(&self) -> Option<Duration> {
4089 self.percentile_total_time(0.50)
4090 }
4091
4092 pub fn p95_total_time(&self) -> Option<Duration> {
4094 self.percentile_total_time(0.95)
4095 }
4096
4097 pub fn p99_total_time(&self) -> Option<Duration> {
4099 self.percentile_total_time(0.99)
4100 }
4101
4102 fn percentile_total_time(&self, percentile: f64) -> Option<Duration> {
4107 let mut times: Vec<_> = self.history.iter().map(|m| m.timing.total).collect();
4108
4109 if times.is_empty() {
4110 return None;
4111 }
4112
4113 times.sort();
4114 let idx = (((times.len() - 1) as f64 * percentile).round() as usize).min(times.len() - 1);
4118 Some(times[idx])
4119 }
4120
4121 pub fn success_rate(&self) -> f64 {
4123 if self.history.is_empty() {
4124 return 100.0;
4125 }
4126 let successes = self.history.iter().filter(|m| m.success).count();
4127 (successes as f64 / self.history.len() as f64) * 100.0
4128 }
4129
4130 pub fn count(&self) -> usize {
4132 self.history.len()
4133 }
4134
4135 pub fn metrics(&self) -> &VecDeque<CompilationMetrics> {
4137 &self.history
4138 }
4139
4140 pub fn clear(&mut self) {
4142 self.history.clear();
4143 }
4144}
4145
4146impl Default for MetricsAggregator {
4147 fn default() -> Self {
4148 Self::new(1000)
4149 }
4150}
4151
4152#[cfg(test)]
4153mod tests {
4154 use super::*;
4155 use crate::test_guard;
4156
4157 #[test]
4158 fn test_declared_os_reads_reserved_tag() {
4159 assert_eq!(
4160 declared_os(&["rust".to_string(), "os:windows".to_string()]),
4161 Some("windows".to_string())
4162 );
4163 assert_eq!(
4164 declared_os(&["os:WINDOWS".to_string()]),
4165 Some("windows".to_string())
4166 );
4167 }
4168
4169 #[test]
4170 fn test_declared_os_absent_for_ordinary_tags() {
4171 assert_eq!(declared_os(&[]), None);
4174 assert_eq!(
4175 declared_os(&["rust".to_string(), "bun".to_string(), "go".to_string()]),
4176 None
4177 );
4178 assert_eq!(declared_os(&["os:".to_string()]), None);
4180 assert_eq!(declared_os(&["os: ".to_string()]), None);
4181 }
4182
4183 #[test]
4184 fn test_os_tag_roundtrips_through_declared_os() {
4185 for os in ["windows", "Darwin", " linux "] {
4186 let tag = os_tag(os);
4187 assert_eq!(
4188 declared_os(std::slice::from_ref(&tag)).as_deref(),
4189 Some(os.trim().to_ascii_lowercase().as_str())
4190 );
4191 }
4192 }
4193
4194 #[test]
4199 fn test_no_inline_secret_fields() {
4200 let endpoint = DoctorWebhookEndpoint {
4201 name: "ops".to_string(),
4202 url: "https://example.com/hook".to_string(),
4203 format: DoctorWebhookFormat::Pagerduty,
4204 on_transitions: vec!["any_to_failing".to_string()],
4205 retry_max: 3,
4206 retry_backoff_ms: 200,
4207 timeout_ms: 5000,
4208 enabled: true,
4209 routing_key_env: Some("RCH_PAGERDUTY_ROUTING_KEY".to_string()),
4210 bearer_token_env: Some("RCH_WEBHOOK_BEARER".to_string()),
4211 signing_secret_env: Some("RCH_WEBHOOK_HMAC".to_string()),
4212 };
4213 let value = serde_json::to_value(&endpoint).unwrap();
4216 let obj = value.as_object().expect("endpoint serializes to an object");
4217 for forbidden in ["secret", "routing_key", "bearer_token", "password", "token"] {
4218 assert!(
4219 !obj.contains_key(forbidden),
4220 "endpoint config must not expose an inline `{forbidden}` field"
4221 );
4222 }
4223 assert!(obj.contains_key("routing_key_env"));
4224 assert!(obj.contains_key("bearer_token_env"));
4225 assert!(obj.contains_key("signing_secret_env"));
4226 }
4227
4228 #[test]
4229 fn test_webhook_format_toml_spelling() {
4230 #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
4235 struct FormatWrap {
4236 format: DoctorWebhookFormat,
4237 }
4238 let rendered = toml::to_string(&FormatWrap {
4239 format: DoctorWebhookFormat::GenericJson,
4240 })
4241 .unwrap();
4242 assert!(
4243 rendered.contains("format = \"generic_json\""),
4244 "unexpected TOML: {rendered}"
4245 );
4246 let slack: FormatWrap = toml::from_str("format = \"slack\"").unwrap();
4247 assert_eq!(slack.format, DoctorWebhookFormat::Slack);
4248 let pd: FormatWrap = toml::from_str("format = \"pagerduty\"").unwrap();
4249 assert_eq!(pd.format, DoctorWebhookFormat::Pagerduty);
4250 }
4251
4252 #[test]
4253 fn test_doctor_webhooks_defaults() {
4254 let cfg = DoctorWebhooksConfig::default();
4255 assert!(cfg.endpoints.is_empty());
4256 assert_eq!(cfg.max_inflight, 16);
4257 let rch: RchConfig = toml::from_str("").unwrap();
4260 assert!(rch.doctor.webhooks.endpoints.is_empty());
4261 }
4262
4263 #[test]
4264 fn test_doctor_webhook_endpoint_parses_from_toml() {
4265 let toml_src = r#"
4266[doctor.webhooks]
4267max_inflight = 8
4268
4269[[doctor.webhooks.endpoints]]
4270name = "ops-slack"
4271url = "https://hooks.slack.com/services/T0/B0/x"
4272format = "slack"
4273on_transitions = ["healthy_to_degraded", "any_to_failing"]
4274retry_max = 2
4275"#;
4276 let cfg: RchConfig = toml::from_str(toml_src).unwrap();
4277 assert_eq!(cfg.doctor.webhooks.max_inflight, 8);
4278 assert_eq!(cfg.doctor.webhooks.endpoints.len(), 1);
4279 let ep = &cfg.doctor.webhooks.endpoints[0];
4280 assert_eq!(ep.name, "ops-slack");
4281 assert_eq!(ep.format, DoctorWebhookFormat::Slack);
4282 assert_eq!(ep.on_transitions.len(), 2);
4283 assert_eq!(ep.retry_max, 2);
4284 assert_eq!(ep.retry_backoff_ms, 200);
4286 assert_eq!(ep.timeout_ms, 5000);
4287 assert!(ep.enabled);
4288 }
4289
4290 fn assert_golden_json(
4291 actual: serde_json::Value,
4292 expected: &serde_json::Value,
4293 fixture_path: &str,
4294 ) {
4295 if &actual == expected {
4296 return;
4297 }
4298
4299 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
4300 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
4301 assert_eq!(
4302 &actual,
4303 expected,
4304 "golden mismatch in {fixture_path}\n\
4305 expected_blake3={}\n\
4306 actual_blake3={}\n\
4307 bless path: review the semantic diff, update the fixture expected_* field, \
4308 and record both hashes in the Beads closeout.\n\
4309 expected:\n{expected_json}\n\
4310 actual:\n{actual_json}",
4311 blake3::hash(expected_json.as_bytes()),
4312 blake3::hash(actual_json.as_bytes())
4313 );
4314 }
4315
4316 #[test]
4317 fn test_circuit_state_default() {
4318 let _guard = test_guard!();
4319 assert_eq!(CircuitState::default(), CircuitState::Closed);
4320 }
4321
4322 #[test]
4323 fn test_circuit_config_defaults() {
4324 let _guard = test_guard!();
4325 let config = CircuitBreakerConfig::default();
4326 assert_eq!(config.failure_threshold, 3);
4327 assert_eq!(config.success_threshold, 2);
4328 assert_eq!(config.error_rate_threshold, 0.5);
4329 assert_eq!(config.window_secs, 60);
4330 assert_eq!(config.open_cooldown_secs, 30);
4331 assert_eq!(config.half_open_max_probes, 1);
4332 }
4333
4334 #[test]
4335 fn test_rch_config_has_circuit_defaults() {
4336 let _guard = test_guard!();
4337 let config = RchConfig::default();
4338 assert_eq!(config.circuit.failure_threshold, 3);
4339 }
4340
4341 #[test]
4342 fn test_circuit_config_serde_roundtrip() {
4343 let _guard = test_guard!();
4344 let config = CircuitBreakerConfig {
4345 failure_threshold: 5,
4346 success_threshold: 3,
4347 error_rate_threshold: 0.75,
4348 window_secs: 120,
4349 open_cooldown_secs: 45,
4350 half_open_max_probes: 2,
4351 command_failure_threshold: 4,
4352 open_cooldown_max_secs: 900,
4353 };
4354 let json = serde_json::to_string(&config).unwrap();
4355 let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
4356 assert_eq!(parsed.command_failure_threshold, 4);
4357 assert_eq!(parsed.open_cooldown_max_secs, 900);
4358 assert_eq!(parsed.failure_threshold, 5);
4359 assert_eq!(parsed.success_threshold, 3);
4360 assert_eq!(parsed.error_rate_threshold, 0.75);
4361 assert_eq!(parsed.window_secs, 120);
4362 assert_eq!(parsed.open_cooldown_secs, 45);
4363 assert_eq!(parsed.half_open_max_probes, 2);
4364 }
4365
4366 #[test]
4371 fn test_worker_capabilities_default() {
4372 let _guard = test_guard!();
4373 let caps = WorkerCapabilities::default();
4374 assert!(caps.rustc_version.is_none());
4375 assert!(caps.rustup_toolchains.is_empty());
4376 assert!(caps.rustup_components.is_empty());
4377 assert!(caps.bun_version.is_none());
4378 assert!(caps.node_version.is_none());
4379 assert!(caps.npm_version.is_none());
4380 assert!(caps.projects_root_ok.is_none());
4381 assert!(caps.projects_root_issue.is_none());
4382 assert!(caps.projects_root_checked_at_unix_ms.is_none());
4383 }
4384
4385 #[test]
4386 fn test_worker_capabilities_new() {
4387 let _guard = test_guard!();
4388 let caps = WorkerCapabilities::new();
4389 assert!(caps.rustc_version.is_none());
4390 assert!(!caps.has_rust());
4391 assert!(!caps.has_bun());
4392 assert!(!caps.has_node());
4393 assert!(caps.is_topology_healthy().is_none());
4394 }
4395
4396 #[test]
4397 fn test_worker_capabilities_has_rust() {
4398 let _guard = test_guard!();
4399 let mut caps = WorkerCapabilities::new();
4400 assert!(!caps.has_rust());
4401
4402 caps.rustc_version = Some("rustc 1.76.0 (07dca489a 2024-02-04)".to_string());
4403 assert!(caps.has_rust());
4404 }
4405
4406 #[test]
4407 fn test_worker_capabilities_components_are_toolchain_scoped_and_exact() {
4408 let _guard = test_guard!();
4409 let caps = WorkerCapabilities {
4410 rustup_components: vec![
4411 "stable-x86_64-unknown-linux-gnu:clippy".to_string(),
4412 "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
4413 "beta-x86_64-unknown-linux-gnu:clippy-preview".to_string(),
4414 ],
4415 ..Default::default()
4416 };
4417
4418 assert!(caps.has_rustup_component("stable", "clippy"));
4419 assert!(caps.has_rustup_component("nightly-2026-07-05", "rustfmt"));
4420 assert!(!caps.has_rustup_component("nightly-2026-07-05", "clippy"));
4421 assert!(!caps.has_rustup_component("beta", "clippy"));
4422 assert!(!caps.has_rustup_component("nightly-2026-07-0", "rustfmt"));
4423 }
4424
4425 #[test]
4426 fn test_worker_capabilities_has_bun() {
4427 let _guard = test_guard!();
4428 let mut caps = WorkerCapabilities::new();
4429 assert!(!caps.has_bun());
4430
4431 caps.bun_version = Some("1.0.25".to_string());
4432 assert!(caps.has_bun());
4433 }
4434
4435 #[test]
4436 fn test_worker_capabilities_has_nix() {
4437 let _guard = test_guard!();
4438 let mut caps = WorkerCapabilities::new();
4439 assert!(!caps.has_nix());
4440
4441 caps.nix_version = Some("nix (Nix) 2.24.9".to_string());
4442 assert!(caps.has_nix());
4443 }
4444
4445 #[test]
4446 fn test_worker_capabilities_has_zig_requires_both_halves() {
4447 let _guard = test_guard!();
4448 let mut caps = WorkerCapabilities::new();
4449 assert!(!caps.has_zig());
4450
4451 caps.zig_version = Some("0.14.1".to_string());
4454 assert!(!caps.has_zig());
4455
4456 caps.zig_version = None;
4458 caps.cargo_zigbuild_version = Some("cargo-zigbuild 0.23.0".to_string());
4459 assert!(!caps.has_zig());
4460
4461 caps.zig_version = Some("0.14.1".to_string());
4462 assert!(caps.has_zig());
4463 }
4464
4465 #[test]
4466 fn test_nix_build_uses_generous_timeout() {
4467 let _guard = test_guard!();
4468 let cfg = CompilationConfig::default();
4469 assert_eq!(
4471 cfg.timeout_for_kind(Some(CompilationKind::NixBuild)),
4472 std::time::Duration::from_secs(cfg.test_timeout_sec)
4473 );
4474 }
4475
4476 #[test]
4477 fn test_job_uses_build_timeout_bucket() {
4478 let _guard = test_guard!();
4479 let cfg = CompilationConfig::default();
4482 assert_eq!(
4483 cfg.timeout_for_kind(Some(CompilationKind::Job)),
4484 std::time::Duration::from_secs(cfg.build_timeout_sec)
4485 );
4486 }
4487
4488 #[test]
4489 fn test_layer0_disabled_by_default_and_env_gated() {
4490 let _guard = test_guard!();
4491 let cfg = Layer0Config::default();
4492 assert!(!cfg.enabled);
4493 let knobs_on = Layer0Config {
4495 enabled: false,
4496 release_lto_thin: true,
4497 release_codegen_units_1: true,
4498 debug_line_tables_only: true,
4499 };
4500 assert!(cfg.active_env().is_empty());
4501 assert!(knobs_on.active_env().is_empty());
4502 }
4503
4504 #[test]
4505 fn test_layer0_knobs_emit_stable_env_pairs() {
4506 let _guard = test_guard!();
4507 let lto_only = Layer0Config {
4508 enabled: true,
4509 release_lto_thin: true,
4510 ..Layer0Config::default()
4511 };
4512 assert_eq!(
4513 lto_only.active_env(),
4514 vec![("CARGO_PROFILE_RELEASE_LTO", "thin")]
4515 );
4516 let debug_only = Layer0Config {
4517 enabled: true,
4518 debug_line_tables_only: true,
4519 ..Layer0Config::default()
4520 };
4521 assert_eq!(
4522 debug_only.active_env(),
4523 vec![
4524 ("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"),
4525 ("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"),
4526 ]
4527 );
4528 let all = Layer0Config {
4529 enabled: true,
4530 release_lto_thin: true,
4531 release_codegen_units_1: true,
4532 debug_line_tables_only: true,
4533 };
4534 assert_eq!(
4535 all.active_env(),
4536 vec![
4537 ("CARGO_PROFILE_RELEASE_LTO", "thin"),
4538 ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
4539 ("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"),
4540 ("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"),
4541 ],
4542 "emission order must stay stable for deterministic baselines"
4543 );
4544 }
4545
4546 #[test]
4547 fn test_layer0_pack_version_is_recorded() {
4548 let _guard = test_guard!();
4549 assert!(!LAYER0_PACK_VERSION.is_empty());
4551 }
4552 #[test]
4553 fn test_default_allowlist_includes_nix() {
4554 let _guard = test_guard!();
4555 let cfg = ExecutionConfig::default();
4556 assert!(cfg.is_allowed("nix"));
4557 }
4558
4559 #[test]
4560 fn test_worker_capabilities_has_node() {
4561 let _guard = test_guard!();
4562 let mut caps = WorkerCapabilities::new();
4563 assert!(!caps.has_node());
4564
4565 caps.node_version = Some("v20.11.0".to_string());
4566 assert!(caps.has_node());
4567 }
4568
4569 #[test]
4570 fn test_worker_capabilities_multiple_runtimes() {
4571 let _guard = test_guard!();
4572 let caps = WorkerCapabilities {
4573 rustc_version: Some("rustc 1.76.0".to_string()),
4574 bun_version: Some("1.0.25".to_string()),
4575 node_version: Some("v20.11.0".to_string()),
4576 npm_version: Some("10.2.4".to_string()),
4577 ..Default::default()
4578 };
4579
4580 assert!(caps.has_rust());
4581 assert!(caps.has_bun());
4582 assert!(caps.has_node());
4583 }
4584
4585 #[test]
4586 fn test_worker_capabilities_serialization_empty() {
4587 let _guard = test_guard!();
4588 let caps = WorkerCapabilities::new();
4589 let json = serde_json::to_string(&caps).unwrap();
4590 assert_eq!(json, "{}");
4592
4593 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4595 assert!(!parsed.has_rust());
4596 assert!(!parsed.has_bun());
4597 assert!(!parsed.has_node());
4598 }
4599
4600 #[test]
4601 fn test_worker_capabilities_serialization_with_versions() {
4602 let _guard = test_guard!();
4603 let caps = WorkerCapabilities {
4604 rustc_version: Some("rustc 1.76.0-nightly".to_string()),
4605 bun_version: Some("1.0.25".to_string()),
4606 node_version: None,
4607 npm_version: None,
4608 ..Default::default()
4609 };
4610
4611 let json = serde_json::to_string(&caps).unwrap();
4612 assert!(json.contains("rustc_version"));
4613 assert!(json.contains("rustc 1.76.0-nightly"));
4614 assert!(json.contains("bun_version"));
4615 assert!(json.contains("1.0.25"));
4616 assert!(!json.contains("node_version"));
4618 assert!(!json.contains("npm_version"));
4619
4620 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4621 assert!(parsed.has_rust());
4622 assert!(parsed.has_bun());
4623 assert!(!parsed.has_node());
4624 }
4625
4626 #[test]
4627 fn test_worker_capabilities_deserialization_partial() {
4628 let _guard = test_guard!();
4629 let json = r#"{"bun_version": "1.0.0"}"#;
4631 let caps: WorkerCapabilities = serde_json::from_str(json).unwrap();
4632
4633 assert!(!caps.has_rust());
4634 assert!(caps.has_bun());
4635 assert!(!caps.has_node());
4636 assert_eq!(caps.bun_version, Some("1.0.0".to_string()));
4637 }
4638
4639 #[test]
4640 fn test_worker_capabilities_clone() {
4641 let _guard = test_guard!();
4642 let caps = WorkerCapabilities {
4643 rustc_version: Some("1.76.0".to_string()),
4644 bun_version: None,
4645 node_version: Some("v20".to_string()),
4646 npm_version: None,
4647 projects_root_ok: Some(false),
4648 projects_root_issue: Some("alias_missing".to_string()),
4649 projects_root_checked_at_unix_ms: Some(123),
4650 ..Default::default()
4651 };
4652
4653 let cloned = caps.clone();
4654 assert_eq!(cloned.rustc_version, caps.rustc_version);
4655 assert_eq!(cloned.bun_version, caps.bun_version);
4656 assert_eq!(cloned.node_version, caps.node_version);
4657 assert_eq!(cloned.npm_version, caps.npm_version);
4658 assert_eq!(cloned.projects_root_ok, caps.projects_root_ok);
4659 assert_eq!(cloned.projects_root_issue, caps.projects_root_issue);
4660 assert_eq!(
4661 cloned.projects_root_checked_at_unix_ms,
4662 caps.projects_root_checked_at_unix_ms
4663 );
4664 }
4665
4666 #[test]
4667 fn test_worker_capabilities_topology_health_helper() {
4668 let _guard = test_guard!();
4669 let mut caps = WorkerCapabilities::new();
4670 assert_eq!(caps.is_topology_healthy(), None);
4671
4672 caps.projects_root_ok = Some(true);
4673 assert_eq!(caps.is_topology_healthy(), Some(true));
4674
4675 caps.projects_root_ok = Some(false);
4676 assert_eq!(caps.is_topology_healthy(), Some(false));
4677 }
4678
4679 #[test]
4680 fn test_selection_reason_serialization() {
4681 let _guard = test_guard!();
4682 assert_eq!(
4684 serde_json::to_string(&SelectionReason::Success).unwrap(),
4685 "\"success\""
4686 );
4687 assert_eq!(
4688 serde_json::to_string(&SelectionReason::NoWorkersConfigured).unwrap(),
4689 "\"no_workers_configured\""
4690 );
4691 assert_eq!(
4692 serde_json::to_string(&SelectionReason::AllWorkersUnreachable).unwrap(),
4693 "\"all_workers_unreachable\""
4694 );
4695 assert_eq!(
4696 serde_json::to_string(&SelectionReason::AllCircuitsOpen).unwrap(),
4697 "\"all_circuits_open\""
4698 );
4699 assert_eq!(
4700 serde_json::to_string(&SelectionReason::AllWorkersBusy).unwrap(),
4701 "\"all_workers_busy\""
4702 );
4703 assert_eq!(
4704 serde_json::to_string(&SelectionReason::NoWorkersPassedHealth).unwrap(),
4705 "\"no_workers_passed_health\""
4706 );
4707 assert_eq!(
4708 serde_json::to_string(&SelectionReason::AllWorkersFailedPreflight).unwrap(),
4709 "\"all_workers_failed_preflight\""
4710 );
4711 assert_eq!(
4712 serde_json::to_string(&SelectionReason::AllWorkersFailedConvergence).unwrap(),
4713 "\"all_workers_failed_convergence\""
4714 );
4715 assert_eq!(
4716 serde_json::to_string(&SelectionReason::NoAdmissibleWorkers(
4717 "critical_pressure=1,insufficient_slots=1".to_string()
4718 ))
4719 .unwrap(),
4720 "{\"no_admissible_workers\":\"critical_pressure=1,insufficient_slots=1\"}"
4721 );
4722 assert_eq!(
4723 serde_json::to_string(&SelectionReason::NoMatchingWorkers).unwrap(),
4724 "\"no_matching_workers\""
4725 );
4726 }
4727
4728 #[test]
4729 fn test_selection_reason_with_error() {
4730 let _guard = test_guard!();
4731 let reason = SelectionReason::SelectionError("test error".to_string());
4732 let json = serde_json::to_string(&reason).unwrap();
4733 assert!(json.contains("selection_error"));
4734 assert!(json.contains("test error"));
4735 }
4736
4737 #[test]
4738 fn test_selection_reason_deserialization() {
4739 let _guard = test_guard!();
4740 assert_eq!(
4741 serde_json::from_str::<SelectionReason>("\"success\"").unwrap(),
4742 SelectionReason::Success
4743 );
4744 assert_eq!(
4745 serde_json::from_str::<SelectionReason>("\"all_workers_busy\"").unwrap(),
4746 SelectionReason::AllWorkersBusy
4747 );
4748 assert_eq!(
4749 serde_json::from_str::<SelectionReason>("\"no_workers_passed_health\"").unwrap(),
4750 SelectionReason::NoWorkersPassedHealth
4751 );
4752 assert_eq!(
4753 serde_json::from_str::<SelectionReason>("\"all_workers_failed_preflight\"").unwrap(),
4754 SelectionReason::AllWorkersFailedPreflight
4755 );
4756 assert_eq!(
4757 serde_json::from_str::<SelectionReason>("\"all_workers_failed_convergence\"").unwrap(),
4758 SelectionReason::AllWorkersFailedConvergence
4759 );
4760 assert_eq!(
4761 serde_json::from_str::<SelectionReason>(
4762 "{\"no_admissible_workers\":\"critical_pressure=1\"}"
4763 )
4764 .unwrap(),
4765 SelectionReason::NoAdmissibleWorkers("critical_pressure=1".to_string())
4766 );
4767 }
4768
4769 #[test]
4770 fn test_selection_reason_display() {
4771 let _guard = test_guard!();
4772 assert_eq!(
4773 SelectionReason::Success.to_string(),
4774 "worker assigned successfully"
4775 );
4776 assert_eq!(
4777 SelectionReason::NoWorkersConfigured.to_string(),
4778 "no workers configured"
4779 );
4780 assert_eq!(
4781 SelectionReason::AllWorkersUnreachable.to_string(),
4782 "all workers unreachable"
4783 );
4784 assert_eq!(
4785 SelectionReason::AllWorkersBusy.to_string(),
4786 "all workers at capacity"
4787 );
4788 assert_eq!(
4789 SelectionReason::NoWorkersPassedHealth.to_string(),
4790 "no workers passed health thresholds"
4791 );
4792 assert_eq!(
4793 SelectionReason::AllWorkersFailedPreflight.to_string(),
4794 "all workers failed preflight checks"
4795 );
4796 assert_eq!(
4797 SelectionReason::AllWorkersFailedConvergence.to_string(),
4798 "all workers failed repo convergence checks"
4799 );
4800 assert_eq!(
4801 SelectionReason::NoAdmissibleWorkers(
4802 "critical_pressure=1,insufficient_slots=1".to_string()
4803 )
4804 .to_string(),
4805 "no admissible workers: critical_pressure=1,insufficient_slots=1"
4806 );
4807 assert_eq!(
4808 SelectionReason::SelectionError("oops".to_string()).to_string(),
4809 "selection error: oops"
4810 );
4811 }
4812
4813 #[test]
4814 fn test_selection_response_with_worker() {
4815 let _guard = test_guard!();
4816 let response = SelectionResponse {
4817 worker: Some(SelectedWorker {
4818 id: WorkerId::new("test"),
4819 host: "localhost".to_string(),
4820 user: "user".to_string(),
4821 identity_file: "~/.ssh/id_rsa".to_string(),
4822 slots_available: 8,
4823 speed_score: 75.0,
4824 declared_os: None,
4825 }),
4826 reason: SelectionReason::Success,
4827 build_id: None,
4828 diagnostics: None,
4829 };
4830
4831 let json = serde_json::to_string(&response).unwrap();
4832 assert!(json.contains("\"reason\":\"success\""));
4833 assert!(json.contains("\"id\":\"test\""));
4834 }
4835
4836 #[test]
4837 fn test_selection_response_without_worker() {
4838 let _guard = test_guard!();
4839 let response = SelectionResponse {
4840 worker: None,
4841 reason: SelectionReason::AllWorkersBusy,
4842 build_id: None,
4843 diagnostics: None,
4844 };
4845
4846 let json = serde_json::to_string(&response).unwrap();
4847 assert!(json.contains("\"worker\":null"));
4848 assert!(json.contains("\"reason\":\"all_workers_busy\""));
4849 }
4850
4851 #[test]
4852 fn golden_selection_response_with_no_workers_passed_health_diagnostics() {
4853 let _guard = test_guard!();
4854 const FIXTURE_PATH: &str =
4855 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json";
4856 let fixture: serde_json::Value = serde_json::from_str(include_str!(
4857 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json"
4858 ))
4859 .expect("selection diagnostics golden fixture parses");
4860 assert_eq!(
4861 fixture["schema_version"],
4862 "rch.golden.selection_diagnostics.v1"
4863 );
4864
4865 let response = SelectionResponse {
4866 worker: None,
4867 reason: SelectionReason::NoWorkersPassedHealth,
4868 build_id: None,
4869 diagnostics: Some(SelectionDiagnostics {
4870 required_runtime: RequiredRuntime::Rust,
4871 estimated_cores: 2,
4872 min_success_rate: 0.8,
4873 fallback_min_success_rate: 0.5,
4874 active_project_exclusion_count: 0,
4875 workers: vec![WorkerSelectionDiagnostic {
4876 worker_id: WorkerId::new("fixture-worker"),
4877 status: "healthy".to_string(),
4878 circuit_state: "closed".to_string(),
4879 pressure_state: "healthy".to_string(),
4880 pressure_reason_code: "pressure.ok".to_string(),
4881 success_rate: Some(0.2),
4882 min_success_rate: 0.8,
4883 fallback_min_success_rate: 0.5,
4884 required_runtime: RequiredRuntime::Rust,
4885 runtime_available: true,
4886 available_slots: 8,
4887 total_slots: 8,
4888 estimated_cores: 2,
4889 active_project_excluded: false,
4890 final_decision: WorkerSelectionDiagnosticDecision::Deny,
4891 final_reason: "success_rate 0.20 < fallback 0.50".to_string(),
4892 reason_codes: vec![
4893 "health.below_min_success_rate".to_string(),
4894 "health.below_fallback_min_success_rate".to_string(),
4895 ],
4896 }],
4897 }),
4898 };
4899
4900 assert_golden_json(
4901 serde_json::to_value(&response).expect("response serializes"),
4902 &fixture["expected_response"],
4903 FIXTURE_PATH,
4904 );
4905 }
4906
4907 #[test]
4908 fn test_selection_response_roundtrip() {
4909 let _guard = test_guard!();
4910 let original = SelectionResponse {
4911 worker: Some(SelectedWorker {
4912 id: WorkerId::new("worker-1"),
4913 host: "192.168.1.100".to_string(),
4914 user: "ubuntu".to_string(),
4915 identity_file: "/path/to/key".to_string(),
4916 slots_available: 16,
4917 speed_score: 90.5,
4918 declared_os: None,
4919 }),
4920 reason: SelectionReason::Success,
4921 build_id: None,
4922 diagnostics: None,
4923 };
4924
4925 let json = serde_json::to_string(&original).unwrap();
4926 let parsed: SelectionResponse = serde_json::from_str(&json).unwrap();
4927
4928 assert!(parsed.worker.is_some());
4929 let worker = parsed.worker.unwrap();
4930 assert_eq!(worker.id.as_str(), "worker-1");
4931 assert_eq!(worker.host, "192.168.1.100");
4932 assert_eq!(worker.slots_available, 16);
4933 assert_eq!(parsed.reason, SelectionReason::Success);
4934 }
4935
4936 #[test]
4937 fn test_build_heartbeat_phase_roundtrip() {
4938 let _guard = test_guard!();
4939 let json = serde_json::to_string(&BuildHeartbeatPhase::SyncUp).unwrap();
4940 assert_eq!(json, "\"sync_up\"");
4941 let parsed: BuildHeartbeatPhase = serde_json::from_str(&json).unwrap();
4942 assert_eq!(parsed, BuildHeartbeatPhase::SyncUp);
4943 }
4944
4945 #[test]
4946 fn test_build_heartbeat_request_roundtrip() {
4947 let _guard = test_guard!();
4948 let request = BuildHeartbeatRequest {
4949 build_id: 42,
4950 worker_id: WorkerId::new("worker-a"),
4951 hook_pid: Some(12345),
4952 local_wrapper_id: Some("wrapper-abc123".to_string()),
4953 remote_pgid_file: Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string()),
4954 phase: BuildHeartbeatPhase::Execute,
4955 detail: Some("Compiling crates".to_string()),
4956 progress_counter: Some(7),
4957 progress_percent: Some(42.5),
4958 };
4959
4960 let json = serde_json::to_string(&request).unwrap();
4961 let parsed: BuildHeartbeatRequest = serde_json::from_str(&json).unwrap();
4962 assert_eq!(parsed.build_id, 42);
4963 assert_eq!(parsed.worker_id.as_str(), "worker-a");
4964 assert_eq!(
4965 parsed.remote_pgid_file,
4966 Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string())
4967 );
4968 assert_eq!(parsed.phase, BuildHeartbeatPhase::Execute);
4969 assert_eq!(parsed.progress_counter, Some(7));
4970 assert_eq!(parsed.progress_percent, Some(42.5));
4971 }
4972
4973 #[test]
4976 fn test_circuit_stats_new() {
4977 let _guard = test_guard!();
4978 let stats = CircuitStats::new();
4979 assert_eq!(stats.state(), CircuitState::Closed);
4980 assert_eq!(stats.consecutive_failures(), 0);
4981 assert_eq!(stats.error_rate(), 0.0);
4982 assert!(stats.opened_at().is_none());
4983 }
4984
4985 #[test]
4986 fn test_circuit_stats_record_success() {
4987 let _guard = test_guard!();
4988 let mut stats = CircuitStats::new();
4989 stats.record_success();
4990 stats.record_success();
4991 assert_eq!(stats.error_rate(), 0.0);
4992 assert_eq!(stats.consecutive_failures(), 0);
4993 }
4994
4995 #[test]
4996 fn test_circuit_stats_record_failure() {
4997 let _guard = test_guard!();
4998 let mut stats = CircuitStats::new();
4999 stats.record_failure();
5000 stats.record_failure();
5001 assert_eq!(stats.consecutive_failures(), 2);
5002 assert_eq!(stats.error_rate(), 1.0); }
5004
5005 #[test]
5006 fn test_circuit_stats_error_rate() {
5007 let _guard = test_guard!();
5008 let mut stats = CircuitStats::new();
5009 stats.record_success();
5010 stats.record_success();
5011 stats.record_failure();
5012 assert!((stats.error_rate() - 0.333).abs() < 0.01);
5014 }
5015
5016 #[test]
5017 fn test_circuit_stats_should_open_consecutive_failures() {
5018 let _guard = test_guard!();
5019 let mut stats = CircuitStats::new();
5020 let config = CircuitBreakerConfig::default(); stats.record_failure();
5023 assert!(!stats.should_open(&config));
5024
5025 stats.record_failure();
5026 assert!(!stats.should_open(&config));
5027
5028 stats.record_failure();
5029 assert!(stats.should_open(&config)); }
5031
5032 #[test]
5033 fn test_circuit_stats_should_open_error_rate() {
5034 let _guard = test_guard!();
5035 let mut stats = CircuitStats::new();
5036 let config = CircuitBreakerConfig {
5037 error_rate_threshold: 0.5,
5038 ..Default::default()
5039 };
5040
5041 stats.record_success();
5043 stats.record_success();
5044 stats.record_failure();
5045 stats.record_failure();
5046 assert!(!stats.should_open(&config)); stats.record_failure(); assert!(stats.should_open(&config));
5050 }
5051
5052 #[test]
5053 fn test_circuit_close_clears_recent_results_no_flap() {
5054 let _guard = test_guard!();
5055 let mut stats = CircuitStats::new();
5059 let config = CircuitBreakerConfig {
5060 error_rate_threshold: 0.5,
5061 ..Default::default()
5062 };
5063 for _ in 0..6 {
5064 stats.record_failure();
5065 }
5066 assert!(stats.should_open(&config));
5067 stats.open();
5068 stats.half_open();
5070 stats.record_success();
5071 stats.record_success();
5072 stats.close();
5073 assert_eq!(stats.error_rate(), 0.0);
5075 assert!(!stats.should_open(&config));
5076 }
5077
5078 #[test]
5079 fn test_apply_health_outcome_opens_on_failure_threshold() {
5080 let _guard = test_guard!();
5081 let config = CircuitBreakerConfig {
5083 failure_threshold: 3,
5084 open_cooldown_secs: 300,
5085 ..Default::default()
5086 };
5087 let mut stats = CircuitStats::new();
5088 assert_eq!(
5089 stats.apply_health_outcome(false, false, &config),
5090 CircuitState::Closed
5091 );
5092 assert_eq!(
5093 stats.apply_health_outcome(false, false, &config),
5094 CircuitState::Closed
5095 );
5096 assert_eq!(
5097 stats.apply_health_outcome(false, false, &config),
5098 CircuitState::Open
5099 );
5100 }
5101
5102 #[test]
5103 fn test_apply_health_outcome_half_open_two_probe_recovery() {
5104 let _guard = test_guard!();
5105 let config = CircuitBreakerConfig {
5111 failure_threshold: 3,
5112 success_threshold: 2,
5113 open_cooldown_secs: 0,
5114 ..Default::default()
5115 };
5116 let mut stats = CircuitStats::new();
5117 for _ in 0..3 {
5118 stats.apply_health_outcome(false, false, &config);
5119 }
5120 assert_eq!(stats.state(), CircuitState::HalfOpen);
5122
5123 let after_first = stats.apply_health_outcome(true, false, &config);
5125 assert_eq!(after_first, CircuitState::HalfOpen);
5126
5127 let after_second = stats.apply_health_outcome(true, false, &config);
5129 assert_eq!(after_second, CircuitState::Closed);
5130 }
5131
5132 #[test]
5133 fn test_apply_health_outcome_transient_half_open_does_not_reopen() {
5134 let _guard = test_guard!();
5135 let config = CircuitBreakerConfig {
5138 failure_threshold: 3,
5139 success_threshold: 2,
5140 open_cooldown_secs: 0,
5141 ..Default::default()
5142 };
5143 let mut stats = CircuitStats::new();
5144 for _ in 0..3 {
5145 stats.apply_health_outcome(false, false, &config);
5146 }
5147 assert_eq!(stats.state(), CircuitState::HalfOpen);
5149
5150 assert_eq!(
5152 stats.apply_health_outcome(true, false, &config),
5153 CircuitState::HalfOpen
5154 );
5155
5156 let after_transient = stats.apply_health_outcome(false, true, &config);
5162 assert_eq!(after_transient, CircuitState::HalfOpen);
5163
5164 assert_eq!(
5166 stats.apply_health_outcome(true, false, &config),
5167 CircuitState::HalfOpen
5168 );
5169 assert_eq!(
5170 stats.apply_health_outcome(true, false, &config),
5171 CircuitState::Closed
5172 );
5173 }
5174
5175 #[test]
5176 fn test_apply_health_outcome_real_vs_transient_half_open_failure() {
5177 let _guard = test_guard!();
5178 let config = CircuitBreakerConfig {
5186 failure_threshold: 3,
5187 success_threshold: 2,
5188 open_cooldown_secs: 300,
5189 ..Default::default()
5190 };
5191
5192 let mut real = CircuitStats::new();
5194 real.open();
5195 real.half_open();
5196 assert_eq!(real.state(), CircuitState::HalfOpen);
5197 let after_real = real.apply_health_outcome(false, false, &config);
5198 assert_eq!(
5199 after_real,
5200 CircuitState::Open,
5201 "a real half-open failure must reopen the circuit"
5202 );
5203
5204 let mut transient = CircuitStats::new();
5206 transient.open();
5207 transient.half_open();
5208 assert_eq!(transient.state(), CircuitState::HalfOpen);
5209 let after_transient = transient.apply_health_outcome(false, true, &config);
5210 assert_eq!(
5211 after_transient,
5212 CircuitState::HalfOpen,
5213 "a transient half-open failure must NOT reopen the circuit"
5214 );
5215 }
5216
5217 #[test]
5222 fn test_command_failure_blocks_half_open_close_on_liveness_successes() {
5223 let _guard = test_guard!();
5224 let config = CircuitBreakerConfig {
5225 failure_threshold: 3,
5226 success_threshold: 2,
5227 open_cooldown_secs: 0,
5228 command_failure_threshold: 2,
5229 ..Default::default()
5230 };
5231 let mut stats = CircuitStats::new();
5232 stats.open();
5233 stats.half_open();
5234
5235 assert_eq!(
5237 stats.apply_command_outcome(false, &config),
5238 CircuitState::HalfOpen,
5239 "cooldown 0: reopen then immediately half-open again"
5240 );
5241 assert_eq!(stats.consecutive_command_failures(), 1);
5242
5243 for _ in 0..10 {
5245 assert_eq!(
5246 stats.apply_health_outcome(true, false, &config),
5247 CircuitState::HalfOpen,
5248 "liveness successes must not close the circuit while a command failure is outstanding"
5249 );
5250 }
5251
5252 assert_eq!(
5254 stats.apply_command_outcome(true, &config),
5255 CircuitState::Closed
5256 );
5257 assert_eq!(stats.consecutive_command_failures(), 0);
5258 assert_eq!(stats.reopen_streak(), 0);
5259 }
5260
5261 #[test]
5264 fn test_command_failures_open_closed_circuit_despite_liveness_successes() {
5265 let _guard = test_guard!();
5266 let config = CircuitBreakerConfig {
5267 failure_threshold: 3,
5268 command_failure_threshold: 2,
5269 open_cooldown_secs: 300,
5270 ..Default::default()
5271 };
5272 let mut stats = CircuitStats::new();
5273
5274 assert_eq!(
5275 stats.apply_command_outcome(false, &config),
5276 CircuitState::Closed
5277 );
5278 stats.apply_health_outcome(true, false, &config);
5280 stats.apply_health_outcome(true, false, &config);
5281 assert_eq!(stats.consecutive_failures(), 0);
5282 assert_eq!(
5284 stats.apply_command_outcome(false, &config),
5285 CircuitState::Open
5286 );
5287 assert_eq!(stats.reopen_streak(), 1);
5288 }
5289
5290 #[test]
5294 fn test_command_failure_in_half_open_always_reopens() {
5295 let _guard = test_guard!();
5296 let config = CircuitBreakerConfig {
5297 open_cooldown_secs: 300,
5298 ..Default::default()
5299 };
5300 let mut stats = CircuitStats::new();
5301 stats.open();
5302 stats.half_open();
5303 assert_eq!(
5304 stats.apply_command_outcome(false, &config),
5305 CircuitState::Open
5306 );
5307 assert_eq!(stats.reopen_streak(), 2);
5308 }
5309
5310 #[test]
5314 fn test_reopen_streak_backs_off_cooldown_until_cap() {
5315 let _guard = test_guard!();
5316 let config = CircuitBreakerConfig {
5317 open_cooldown_secs: 30,
5318 open_cooldown_max_secs: 100,
5319 ..Default::default()
5320 };
5321 assert_eq!(config.cooldown_ms_for_streak(0), 30_000);
5322 assert_eq!(config.cooldown_ms_for_streak(1), 30_000);
5323 assert_eq!(config.cooldown_ms_for_streak(2), 60_000);
5324 assert_eq!(config.cooldown_ms_for_streak(3), 100_000, "capped");
5325 assert_eq!(config.cooldown_ms_for_streak(40), 100_000, "no overflow");
5326
5327 let inverted = CircuitBreakerConfig {
5329 open_cooldown_secs: 30,
5330 open_cooldown_max_secs: 5,
5331 ..Default::default()
5332 };
5333 assert_eq!(inverted.cooldown_ms_for_streak(3), 30_000);
5334
5335 let mut stats = CircuitStats::new();
5338 stats.open();
5339 stats.half_open();
5340 stats.open();
5341 assert_eq!(stats.reopen_streak(), 2);
5342 let remaining = stats.recovery_remaining_secs(&config).unwrap();
5343 assert!(
5344 remaining > 30 && remaining <= 60,
5345 "second open should wait ~60s, got {remaining}"
5346 );
5347
5348 stats.close();
5350 assert_eq!(stats.reopen_streak(), 0);
5351 stats.open();
5352 assert!(stats.recovery_remaining_secs(&config).unwrap() <= 30);
5353 }
5354
5355 #[test]
5356 fn test_circuit_stats_success_resets_consecutive_failures() {
5357 let _guard = test_guard!();
5358 let mut stats = CircuitStats::new();
5359 stats.record_failure();
5360 stats.record_failure();
5361 assert_eq!(stats.consecutive_failures(), 2);
5362
5363 stats.record_success();
5364 assert_eq!(stats.consecutive_failures(), 0);
5365 }
5366
5367 #[test]
5368 fn test_circuit_stats_open_transition() {
5369 let _guard = test_guard!();
5370 let mut stats = CircuitStats::new();
5371 stats.open();
5372
5373 assert_eq!(stats.state(), CircuitState::Open);
5374 assert!(stats.opened_at().is_some());
5375 }
5376
5377 #[test]
5378 fn test_circuit_stats_half_open_transition() {
5379 let _guard = test_guard!();
5380 let mut stats = CircuitStats::new();
5381 stats.open();
5382 stats.half_open();
5383
5384 assert_eq!(stats.state(), CircuitState::HalfOpen);
5385 }
5386
5387 #[test]
5388 fn test_circuit_stats_close_transition() {
5389 let _guard = test_guard!();
5390 let mut stats = CircuitStats::new();
5391 stats.open();
5392 stats.half_open();
5393 stats.close();
5394
5395 assert_eq!(stats.state(), CircuitState::Closed);
5396 assert!(stats.opened_at().is_none());
5397 assert_eq!(stats.consecutive_failures(), 0);
5398 }
5399
5400 #[test]
5401 fn test_circuit_stats_should_close() {
5402 let _guard = test_guard!();
5403 let mut stats = CircuitStats::new();
5404 let config = CircuitBreakerConfig {
5405 success_threshold: 2,
5406 ..Default::default()
5407 };
5408
5409 stats.open();
5410 stats.half_open();
5411
5412 assert!(!stats.should_close(&config)); stats.record_success();
5415 assert!(!stats.should_close(&config)); stats.record_success();
5418 assert!(stats.should_close(&config)); }
5420
5421 #[test]
5422 fn test_circuit_stats_can_probe() {
5423 let _guard = test_guard!();
5424 let mut stats = CircuitStats::new();
5425 let config = CircuitBreakerConfig {
5426 half_open_max_probes: 1,
5427 ..Default::default()
5428 };
5429
5430 assert!(!stats.can_probe(&config));
5432
5433 stats.open();
5434 assert!(!stats.can_probe(&config));
5436
5437 stats.half_open();
5438 assert!(stats.can_probe(&config));
5440
5441 assert!(stats.start_probe(&config));
5443 assert!(!stats.can_probe(&config));
5445 assert!(!stats.start_probe(&config));
5446 }
5447
5448 #[test]
5449 fn test_circuit_stats_probe_completion() {
5450 let _guard = test_guard!();
5451 let mut stats = CircuitStats::new();
5452 let config = CircuitBreakerConfig {
5453 half_open_max_probes: 1,
5454 ..Default::default()
5455 };
5456
5457 stats.open();
5458 stats.half_open();
5459 stats.start_probe(&config);
5460
5461 stats.record_success();
5463
5464 assert!(stats.can_probe(&config));
5466 }
5467
5468 #[test]
5469 fn test_circuit_stats_failure_in_half_open() {
5470 let _guard = test_guard!();
5471 let mut stats = CircuitStats::new();
5472 let config = CircuitBreakerConfig {
5473 success_threshold: 2,
5474 ..Default::default()
5475 };
5476
5477 stats.open();
5478 stats.half_open();
5479
5480 stats.record_success();
5481 assert_eq!(stats.consecutive_failures(), 0);
5482
5483 stats.record_failure();
5485 assert!(!stats.should_close(&config));
5486
5487 stats.record_success();
5489 stats.record_success();
5490 assert!(stats.should_close(&config));
5491 }
5492
5493 #[test]
5494 fn test_circuit_stats_reset_window() {
5495 let _guard = test_guard!();
5496 let mut stats = CircuitStats::new();
5497 stats.record_success();
5498 stats.record_failure();
5499
5500 assert!(stats.error_rate() > 0.0);
5501
5502 stats.reset_window();
5503 assert_eq!(stats.error_rate(), 0.0);
5504 }
5505
5506 #[test]
5507 fn test_circuit_state_transitions_are_deterministic() {
5508 let _guard = test_guard!();
5509 let config = CircuitBreakerConfig::default();
5510 let mut stats = CircuitStats::new();
5511
5512 assert_eq!(stats.state(), CircuitState::Closed);
5514
5515 for _ in 0..3 {
5517 stats.record_failure();
5518 }
5519 assert!(stats.should_open(&config));
5520 stats.open();
5521 assert_eq!(stats.state(), CircuitState::Open);
5522
5523 stats.half_open();
5525 assert_eq!(stats.state(), CircuitState::HalfOpen);
5526
5527 for _ in 0..2 {
5529 stats.record_success();
5530 }
5531 assert!(stats.should_close(&config));
5532 stats.close();
5533 assert_eq!(stats.state(), CircuitState::Closed);
5534 }
5535
5536 fn make_test_metrics(
5541 speedup: Option<f64>,
5542 total_secs: u64,
5543 success: bool,
5544 ) -> CompilationMetrics {
5545 CompilationMetrics {
5546 project_id: "test-project".to_string(),
5547 worker_id: "worker-1".to_string(),
5548 timestamp: chrono::Utc::now(),
5549 timing: CompilationTimingBreakdown {
5550 rsync_up: Duration::from_millis(100),
5551 remote_build: Duration::from_millis(800),
5552 rsync_down: Duration::from_millis(100),
5553 total: Duration::from_secs(total_secs),
5554 },
5555 local_build_time: speedup.map(|s| Duration::from_secs_f64(total_secs as f64 * s)),
5556 speedup,
5557 files_synced: 100,
5558 bytes_transferred: 1_000_000,
5559 exit_code: if success { 0 } else { 1 },
5560 success,
5561 }
5562 }
5563
5564 #[test]
5565 fn test_compilation_timing_breakdown_default() {
5566 let _guard = test_guard!();
5567 let timing = CompilationTimingBreakdown::default();
5568 assert_eq!(timing.rsync_up, Duration::ZERO);
5569 assert_eq!(timing.remote_build, Duration::ZERO);
5570 assert_eq!(timing.rsync_down, Duration::ZERO);
5571 assert_eq!(timing.total, Duration::ZERO);
5572 }
5573
5574 #[test]
5575 fn test_compilation_timing_breakdown_serialization() {
5576 let _guard = test_guard!();
5577 let timing = CompilationTimingBreakdown {
5578 rsync_up: Duration::from_millis(100),
5579 remote_build: Duration::from_millis(2000),
5580 rsync_down: Duration::from_millis(50),
5581 total: Duration::from_millis(2150),
5582 };
5583
5584 let json = serde_json::to_string(&timing).unwrap();
5585 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();
5591 assert_eq!(parsed.rsync_up, timing.rsync_up);
5592 assert_eq!(parsed.remote_build, timing.remote_build);
5593 assert_eq!(parsed.rsync_down, timing.rsync_down);
5594 assert_eq!(parsed.total, timing.total);
5595 }
5596
5597 #[test]
5598 fn test_command_timing_breakdown_serialization() {
5599 let _guard = test_guard!();
5600 let timing = CommandTimingBreakdown {
5601 classify: Some(Duration::from_millis(2)),
5602 select: None,
5603 sync_up: Some(Duration::from_millis(120)),
5604 exec: Some(Duration::from_millis(900)),
5605 sync_down: None,
5606 cleanup: Some(Duration::from_millis(5)),
5607 total: Some(Duration::from_millis(1027)),
5608 };
5609
5610 let json = serde_json::to_string(&timing).unwrap();
5611 assert!(json.contains("\"classify\":2"));
5612 assert!(json.contains("\"select\":null"));
5613 assert!(json.contains("\"sync_up\":120"));
5614 assert!(json.contains("\"exec\":900"));
5615 assert!(json.contains("\"sync_down\":null"));
5616 assert!(json.contains("\"cleanup\":5"));
5617 assert!(json.contains("\"total\":1027"));
5618
5619 let parsed: CommandTimingBreakdown = serde_json::from_str(&json).unwrap();
5620 assert_eq!(parsed.classify, timing.classify);
5621 assert_eq!(parsed.select, timing.select);
5622 assert_eq!(parsed.sync_up, timing.sync_up);
5623 assert_eq!(parsed.exec, timing.exec);
5624 assert_eq!(parsed.sync_down, timing.sync_down);
5625 assert_eq!(parsed.cleanup, timing.cleanup);
5626 assert_eq!(parsed.total, timing.total);
5627 }
5628
5629 #[test]
5630 fn test_compilation_metrics_calculate_speedup() {
5631 let _guard = test_guard!();
5632 let mut metrics = CompilationMetrics {
5633 timing: CompilationTimingBreakdown {
5634 total: Duration::from_secs(10),
5635 ..Default::default()
5636 },
5637 local_build_time: Some(Duration::from_secs(30)),
5638 ..Default::default()
5639 };
5640
5641 metrics.calculate_speedup();
5642
5643 assert!(metrics.speedup.is_some());
5644 let speedup = metrics.speedup.unwrap();
5645 assert!(
5646 (speedup - 3.0).abs() < 0.01,
5647 "Expected 3.0x speedup, got {}",
5648 speedup
5649 );
5650 }
5651
5652 #[test]
5653 fn test_compilation_metrics_is_beneficial() {
5654 let _guard = test_guard!();
5655 let mut metrics = CompilationMetrics::default();
5656
5657 assert!(!metrics.is_beneficial());
5659
5660 metrics.speedup = Some(1.5);
5662 assert!(metrics.is_beneficial());
5663
5664 metrics.speedup = Some(0.8);
5666 assert!(!metrics.is_beneficial());
5667
5668 metrics.speedup = Some(1.0);
5670 assert!(!metrics.is_beneficial());
5671 }
5672
5673 #[test]
5674 fn test_compilation_metrics_serialization() {
5675 let _guard = test_guard!();
5676 let metrics = make_test_metrics(Some(2.5), 10, true);
5677 let json = serde_json::to_string(&metrics).unwrap();
5678
5679 assert!(json.contains("test-project"));
5680 assert!(json.contains("worker-1"));
5681 assert!(json.contains("2.5")); let parsed: CompilationMetrics = serde_json::from_str(&json).unwrap();
5684 assert_eq!(parsed.project_id, "test-project");
5685 assert_eq!(parsed.worker_id, "worker-1");
5686 assert!(parsed.success);
5687 }
5688
5689 #[test]
5690 fn test_compilation_timer_new() {
5691 let _guard = test_guard!();
5692 let timer = CompilationTimer::new("my-project", "my-worker");
5693 assert_eq!(timer.project_id, "my-project");
5694 assert_eq!(timer.worker_id, "my-worker");
5695 assert!(timer.rsync_up.is_none());
5696 assert!(timer.remote_build.is_none());
5697 assert!(timer.rsync_down.is_none());
5698 }
5699
5700 #[test]
5701 fn test_compilation_timer_phases() {
5702 let _guard = test_guard!();
5703 let mut timer = CompilationTimer::new("test", "worker");
5704
5705 std::thread::sleep(Duration::from_millis(5));
5707 timer.end_rsync_up();
5708 assert!(timer.rsync_up.is_some());
5709 assert!(timer.rsync_up.unwrap() >= Duration::from_millis(5));
5710
5711 std::thread::sleep(Duration::from_millis(10));
5713 timer.end_remote_build();
5714 assert!(timer.remote_build.is_some());
5715 assert!(timer.remote_build.unwrap() >= Duration::from_millis(10));
5716
5717 std::thread::sleep(Duration::from_millis(5));
5719 timer.end_rsync_down();
5720 assert!(timer.rsync_down.is_some());
5721 assert!(timer.rsync_down.unwrap() >= Duration::from_millis(5));
5722
5723 let metrics = timer.finish(0, 50, 500_000);
5725 assert_eq!(metrics.project_id, "test");
5726 assert_eq!(metrics.worker_id, "worker");
5727 assert!(metrics.success);
5728 assert!(metrics.timing.total >= Duration::from_millis(20));
5729 assert_eq!(metrics.files_synced, 50);
5730 assert_eq!(metrics.bytes_transferred, 500_000);
5731 }
5732
5733 #[test]
5734 fn test_metrics_aggregator_new() {
5735 let _guard = test_guard!();
5736 let agg = MetricsAggregator::new(100);
5737 assert_eq!(agg.count(), 0);
5738 assert!(agg.average_speedup().is_none());
5739 }
5740
5741 #[test]
5742 fn test_metrics_aggregator_record() {
5743 let _guard = test_guard!();
5744 let mut agg = MetricsAggregator::new(3);
5745
5746 agg.record(make_test_metrics(Some(2.0), 10, true));
5748 assert_eq!(agg.count(), 1);
5749
5750 agg.record(make_test_metrics(Some(3.0), 20, true));
5752 agg.record(make_test_metrics(Some(4.0), 30, true));
5753 assert_eq!(agg.count(), 3);
5754
5755 agg.record(make_test_metrics(Some(5.0), 40, true));
5757 assert_eq!(agg.count(), 3);
5758 }
5759
5760 #[test]
5761 fn test_metrics_aggregator_average_speedup() {
5762 let _guard = test_guard!();
5763 let mut agg = MetricsAggregator::new(100);
5764
5765 assert!(agg.average_speedup().is_none());
5767
5768 for i in 1..=5 {
5770 agg.record(make_test_metrics(Some(i as f64), 10, true));
5771 }
5772
5773 let avg = agg.average_speedup().unwrap();
5774 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5775 }
5776
5777 #[test]
5778 fn test_metrics_aggregator_average_speedup_with_none() {
5779 let _guard = test_guard!();
5780 let mut agg = MetricsAggregator::new(100);
5781
5782 agg.record(make_test_metrics(Some(2.0), 10, true));
5784 agg.record(make_test_metrics(None, 10, true)); agg.record(make_test_metrics(Some(4.0), 10, true));
5786
5787 let avg = agg.average_speedup().unwrap();
5789 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5790 }
5791
5792 #[test]
5793 fn test_metrics_aggregator_percentiles() {
5794 let _guard = test_guard!();
5795 let mut agg = MetricsAggregator::new(100);
5796
5797 for i in 1..=10 {
5799 agg.record(make_test_metrics(Some(1.0), i, true));
5800 }
5801
5802 let p50 = agg.p50_total_time().unwrap();
5804 assert!(p50 >= Duration::from_secs(5) && p50 <= Duration::from_secs(6));
5805
5806 let p95 = agg.p95_total_time().unwrap();
5808 assert!(p95 >= Duration::from_secs(9) && p95 <= Duration::from_secs(10));
5809
5810 let p99 = agg.p99_total_time().unwrap();
5812 assert!(p99 >= Duration::from_secs(9));
5813 }
5814
5815 #[test]
5816 fn test_metrics_aggregator_success_rate() {
5817 let _guard = test_guard!();
5818 let mut agg = MetricsAggregator::new(100);
5819
5820 assert_eq!(agg.success_rate(), 100.0);
5822
5823 agg.record(make_test_metrics(Some(1.0), 10, true));
5825 agg.record(make_test_metrics(Some(1.0), 10, true));
5826 assert_eq!(agg.success_rate(), 100.0);
5827
5828 agg.record(make_test_metrics(Some(1.0), 10, false));
5830 let rate = agg.success_rate();
5831 assert!((rate - 66.67).abs() < 1.0, "Expected ~66.67%, got {}", rate);
5832 }
5833
5834 #[test]
5835 fn test_metrics_aggregator_clear() {
5836 let _guard = test_guard!();
5837 let mut agg = MetricsAggregator::new(100);
5838 agg.record(make_test_metrics(Some(1.0), 10, true));
5839 agg.record(make_test_metrics(Some(2.0), 20, true));
5840
5841 assert_eq!(agg.count(), 2);
5842
5843 agg.clear();
5844 assert_eq!(agg.count(), 0);
5845 assert!(agg.average_speedup().is_none());
5846 }
5847
5848 #[test]
5851 fn test_remote_build_jobs_parse_display_and_serde() {
5852 let _guard = test_guard!();
5853 assert_eq!(RemoteBuildJobs::default(), RemoteBuildJobs::Auto);
5854 for (spelling, expected) in [
5855 ("auto", RemoteBuildJobs::Auto),
5856 (" AUTO ", RemoteBuildJobs::Auto),
5857 ("off", RemoteBuildJobs::Off),
5858 ("none", RemoteBuildJobs::Off),
5859 ("4", RemoteBuildJobs::Fixed(4)),
5860 ] {
5861 assert_eq!(
5862 RemoteBuildJobs::parse(spelling).unwrap(),
5863 expected,
5864 "{spelling}"
5865 );
5866 }
5867 assert!(RemoteBuildJobs::parse("0").is_err(), "cargo rejects -j 0");
5868 assert!(RemoteBuildJobs::parse("many").is_err());
5869 assert_eq!(RemoteBuildJobs::Fixed(4).to_string(), "4");
5870 assert_eq!(RemoteBuildJobs::Off.to_string(), "off");
5871
5872 #[derive(Deserialize)]
5875 struct Wrap {
5876 compilation: CompilationConfig,
5877 }
5878 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = \"off\"\n").unwrap();
5879 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Off);
5880 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = 6\n").unwrap();
5881 assert_eq!(
5882 parsed.compilation.remote_build_jobs,
5883 RemoteBuildJobs::Fixed(6)
5884 );
5885 let parsed: Wrap = toml::from_str("[compilation]\nbuild_slots = 4\n").unwrap();
5886 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Auto);
5887 assert!(toml::from_str::<Wrap>("[compilation]\nremote_build_jobs = 0\n").is_err());
5888
5889 let json = serde_json::to_string(&RemoteBuildJobs::Fixed(3)).unwrap();
5891 assert_eq!(json, "3");
5892 assert_eq!(
5893 serde_json::to_string(&RemoteBuildJobs::Auto).unwrap(),
5894 "\"auto\""
5895 );
5896 let back: RemoteBuildJobs = serde_json::from_str("\"auto\"").unwrap();
5897 assert_eq!(back, RemoteBuildJobs::Auto);
5898 }
5899
5900 #[test]
5901 fn test_compilation_config_default_timeouts() {
5902 let _guard = test_guard!();
5903 let config = CompilationConfig::default();
5904 assert_eq!(config.build_timeout_sec, 300);
5906 assert_eq!(config.test_timeout_sec, 1800);
5908 }
5909
5910 #[test]
5911 fn test_compilation_config_timeout_for_test_kinds() {
5912 let _guard = test_guard!();
5913 let config = CompilationConfig::default();
5914
5915 assert_eq!(
5917 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5918 std::time::Duration::from_secs(1800)
5919 );
5920 assert_eq!(
5921 config.timeout_for_kind(Some(crate::CompilationKind::CargoNextest)),
5922 std::time::Duration::from_secs(1800)
5923 );
5924 assert_eq!(
5925 config.timeout_for_kind(Some(crate::CompilationKind::BunTest)),
5926 std::time::Duration::from_secs(config.bun_timeout_sec)
5927 );
5928 }
5929
5930 #[test]
5931 fn test_compilation_config_timeout_for_build_kinds() {
5932 let _guard = test_guard!();
5933 let config = CompilationConfig::default();
5934
5935 assert_eq!(
5937 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5938 std::time::Duration::from_secs(300)
5939 );
5940 assert_eq!(
5941 config.timeout_for_kind(Some(crate::CompilationKind::CargoCheck)),
5942 std::time::Duration::from_secs(300)
5943 );
5944 assert_eq!(
5945 config.timeout_for_kind(Some(crate::CompilationKind::CargoClippy)),
5946 std::time::Duration::from_secs(300)
5947 );
5948 assert_eq!(
5949 config.timeout_for_kind(None),
5950 std::time::Duration::from_secs(300)
5951 );
5952 }
5953
5954 #[test]
5955 fn test_compilation_config_custom_timeouts() {
5956 let _guard = test_guard!();
5957 let config = CompilationConfig {
5958 build_timeout_sec: 600, test_timeout_sec: 3600, ..Default::default()
5961 };
5962
5963 assert_eq!(
5964 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5965 std::time::Duration::from_secs(600)
5966 );
5967 assert_eq!(
5968 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5969 std::time::Duration::from_secs(3600)
5970 );
5971 }
5972
5973 #[test]
5974 fn test_compilation_config_speedup_threshold_default() {
5975 let _guard = test_guard!();
5976 let config = CompilationConfig::default();
5977 assert!((config.remote_speedup_threshold - 1.2).abs() < 0.001);
5979 }
5980
5981 #[test]
5982 fn test_compilation_config_speedup_threshold_custom() {
5983 let _guard = test_guard!();
5984 let config = CompilationConfig {
5985 remote_speedup_threshold: 1.5, ..Default::default()
5987 };
5988 assert!((config.remote_speedup_threshold - 1.5).abs() < 0.001);
5989 }
5990
5991 #[test]
5992 fn test_compilation_config_speedup_threshold_no_minimum() {
5993 let _guard = test_guard!();
5994 let config = CompilationConfig {
5996 remote_speedup_threshold: 1.0,
5997 ..Default::default()
5998 };
5999 assert!((config.remote_speedup_threshold - 1.0).abs() < 0.001);
6000 }
6001
6002 #[test]
6003 fn test_compilation_config_min_local_time_ms_default() {
6004 let _guard = test_guard!();
6005 let config = CompilationConfig::default();
6006 assert_eq!(config.min_local_time_ms, 2000);
6008 }
6009
6010 #[test]
6011 fn test_compilation_config_serde_roundtrip_speedup_threshold() {
6012 let _guard = test_guard!();
6013 let config = CompilationConfig {
6014 remote_speedup_threshold: 2.5,
6015 min_local_time_ms: 5000,
6016 ..Default::default()
6017 };
6018 let json = serde_json::to_string(&config).unwrap();
6019 let parsed: CompilationConfig = serde_json::from_str(&json).unwrap();
6020 assert!((parsed.remote_speedup_threshold - 2.5).abs() < 0.001);
6021 assert_eq!(parsed.min_local_time_ms, 5000);
6022 }
6023
6024 #[test]
6029 fn test_validate_remote_base_absolute_path() {
6030 let _guard = test_guard!();
6031 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
6032 assert_eq!(
6033 validate_remote_base("/var/rch-builds").unwrap(),
6034 "/var/rch-builds"
6035 );
6036 assert_eq!(
6037 validate_remote_base("/home/builder/.rch").unwrap(),
6038 "/home/builder/.rch"
6039 );
6040 }
6041
6042 #[test]
6043 fn test_validate_remote_base_tilde_expansion() {
6044 let _guard = test_guard!();
6045 let result = validate_remote_base("~/rch");
6047 assert!(result.is_ok());
6048 let path = result.unwrap();
6049 assert!(
6050 path.starts_with('/'),
6051 "Path should be absolute after expansion: {}",
6052 path
6053 );
6054 assert!(!path.contains('~'), "Tilde should be expanded: {}", path);
6055 }
6056
6057 #[test]
6058 fn test_validate_remote_base_rejects_relative_path() {
6059 let _guard = test_guard!();
6060 let result = validate_remote_base("tmp/rch");
6061 assert!(result.is_err());
6062 assert!(result.unwrap_err().contains("absolute path"));
6063 }
6064
6065 #[test]
6066 fn test_validate_remote_base_rejects_path_traversal() {
6067 let _guard = test_guard!();
6068 let result = validate_remote_base("/tmp/../etc/rch");
6069 assert!(result.is_err());
6070 assert!(result.unwrap_err().contains("path traversal"));
6071
6072 let result = validate_remote_base("/tmp/rch/../other");
6073 assert!(result.is_err());
6074 assert!(result.unwrap_err().contains("path traversal"));
6075 }
6076
6077 #[test]
6078 fn test_validate_remote_base_normalizes_trailing_slash() {
6079 let _guard = test_guard!();
6080 assert_eq!(validate_remote_base("/tmp/rch/").unwrap(), "/tmp/rch");
6081 assert_eq!(validate_remote_base("/tmp/rch///").unwrap(), "/tmp/rch");
6082 }
6083
6084 #[test]
6085 fn test_validate_remote_base_root_path() {
6086 let _guard = test_guard!();
6087 let result = validate_remote_base("/");
6089 assert!(result.is_err());
6090 assert!(result.unwrap_err().contains("root directory"));
6091 }
6092
6093 #[test]
6094 fn test_validate_remote_base_rejects_top_level() {
6095 let _guard = test_guard!();
6096 let result = validate_remote_base("/tmp");
6098 assert!(result.is_err());
6099 assert!(result.unwrap_err().contains("at least 2 levels deep"));
6100
6101 let result = validate_remote_base("/home");
6102 assert!(result.is_err());
6103
6104 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
6106 }
6107
6108 #[test]
6109 fn test_default_remote_base() {
6110 let _guard = test_guard!();
6111 assert_eq!(default_remote_base(), "/data/tmp/rch");
6112 }
6113
6114 #[test]
6115 fn test_transfer_config_default_has_remote_base() {
6116 let _guard = test_guard!();
6117 let config = TransferConfig::default();
6118 assert_eq!(config.remote_base, "/data/tmp/rch");
6119 }
6120
6121 #[test]
6122 fn test_source_sync_timeout_validation_boundaries() {
6123 let _guard = test_guard!();
6124 assert!(!TransferConfig::valid_sync_timeout_ms(0));
6125 assert!(!TransferConfig::valid_sync_timeout_ms(999));
6126 assert!(TransferConfig::valid_sync_timeout_ms(1_000));
6127 assert!(TransferConfig::valid_sync_timeout_ms(3_600_000));
6128 assert!(!TransferConfig::valid_sync_timeout_ms(3_600_001));
6129 }
6130
6131 #[test]
6132 fn test_source_sync_timeout_is_explicit_or_payload_aware() {
6133 let _guard = test_guard!();
6134 let default = TransferConfig::default();
6135 assert_eq!(
6136 default.sync_timeout_for_payload(0),
6137 std::time::Duration::from_millis(30_000)
6138 );
6139 assert_eq!(
6140 default.sync_timeout_for_payload(10 * 1024 * 1024),
6141 std::time::Duration::from_millis(40_000)
6142 );
6143 assert_eq!(
6144 default.sync_timeout_for_payload(u64::MAX),
6145 std::time::Duration::from_millis(3_600_000)
6146 );
6147
6148 let explicit = TransferConfig {
6149 sync_timeout_ms: Some(120_000),
6150 ..TransferConfig::default()
6151 };
6152 assert_eq!(
6153 explicit.sync_timeout_for_payload(u64::MAX),
6154 std::time::Duration::from_millis(120_000)
6155 );
6156 }
6157
6158 #[test]
6163 fn test_retry_config_default() {
6164 let _guard = test_guard!();
6165 let config = RetryConfig::default();
6166 assert_eq!(config.max_attempts, 3);
6167 assert_eq!(config.base_delay_ms, 100);
6168 assert_eq!(config.max_delay_ms, 5000);
6169 assert_eq!(config.jitter_factor, 0.1);
6170 assert_eq!(config.total_timeout_ms, 30000);
6171 }
6172
6173 #[test]
6174 fn test_retry_config_no_retry() {
6175 let _guard = test_guard!();
6176 let config = RetryConfig::no_retry();
6177 assert_eq!(config.max_attempts, 1);
6178 }
6179
6180 #[test]
6181 fn test_retry_config_delay_for_attempt_zero() {
6182 let _guard = test_guard!();
6183 let config = RetryConfig::default();
6184 assert_eq!(config.delay_for_attempt(0), std::time::Duration::ZERO);
6185 }
6186
6187 #[test]
6188 fn test_retry_config_delay_for_attempt_exponential() {
6189 let _guard = test_guard!();
6190 let config = RetryConfig {
6191 base_delay_ms: 100,
6192 max_delay_ms: 10000,
6193 jitter_factor: 0.0, ..Default::default()
6195 };
6196
6197 assert_eq!(
6199 config.delay_for_attempt(1),
6200 std::time::Duration::from_millis(100)
6201 );
6202 assert_eq!(
6204 config.delay_for_attempt(2),
6205 std::time::Duration::from_millis(200)
6206 );
6207 assert_eq!(
6209 config.delay_for_attempt(3),
6210 std::time::Duration::from_millis(400)
6211 );
6212 }
6213
6214 #[test]
6215 fn test_retry_config_delay_capped_at_max() {
6216 let _guard = test_guard!();
6217 let config = RetryConfig {
6218 base_delay_ms: 1000,
6219 max_delay_ms: 2000,
6220 jitter_factor: 0.0,
6221 ..Default::default()
6222 };
6223
6224 assert_eq!(
6226 config.delay_for_attempt(2),
6227 std::time::Duration::from_millis(2000)
6228 );
6229 assert_eq!(
6231 config.delay_for_attempt(3),
6232 std::time::Duration::from_millis(2000)
6233 );
6234 }
6235
6236 #[test]
6237 fn test_retry_config_random_jitter_stays_within_bounds() {
6238 let _guard = test_guard!();
6239 let config = RetryConfig {
6240 base_delay_ms: 100,
6241 max_delay_ms: 200,
6242 jitter_factor: 0.25,
6243 ..Default::default()
6244 };
6245
6246 for (attempt, minimum_ms, maximum_ms) in [(1, 75, 125), (3, 150, 250)] {
6249 for _ in 0..128 {
6250 let delay = config.delay_for_attempt(attempt);
6251 assert!(delay >= std::time::Duration::from_millis(minimum_ms));
6252 assert!(delay <= std::time::Duration::from_millis(maximum_ms));
6253 }
6254 }
6255 }
6256
6257 #[test]
6258 fn test_retry_config_should_retry() {
6259 let _guard = test_guard!();
6260 let config = RetryConfig {
6261 max_attempts: 3,
6262 total_timeout_ms: 1000,
6263 ..Default::default()
6264 };
6265
6266 assert!(config.should_retry(0, std::time::Duration::from_millis(0)));
6268 assert!(config.should_retry(1, std::time::Duration::from_millis(500)));
6269 assert!(config.should_retry(2, std::time::Duration::from_millis(900)));
6270
6271 assert!(!config.should_retry(3, std::time::Duration::from_millis(0)));
6273
6274 assert!(!config.should_retry(1, std::time::Duration::from_millis(1001)));
6276 }
6277
6278 #[test]
6279 fn test_retry_config_serde_roundtrip() {
6280 let _guard = test_guard!();
6281 let config = RetryConfig {
6282 max_attempts: 5,
6283 base_delay_ms: 200,
6284 max_delay_ms: 8000,
6285 jitter_factor: 0.2,
6286 total_timeout_ms: 60000,
6287 };
6288 let json = serde_json::to_string(&config).unwrap();
6289 let parsed: RetryConfig = serde_json::from_str(&json).unwrap();
6290 assert_eq!(parsed.max_attempts, 5);
6291 assert_eq!(parsed.base_delay_ms, 200);
6292 assert_eq!(parsed.max_delay_ms, 8000);
6293 assert_eq!(parsed.jitter_factor, 0.2);
6294 assert_eq!(parsed.total_timeout_ms, 60000);
6295 }
6296
6297 #[test]
6298 fn test_transfer_config_includes_retry() {
6299 let _guard = test_guard!();
6300 let config = TransferConfig::default();
6301 assert_eq!(config.retry.max_attempts, 3);
6302 }
6303
6304 #[test]
6309 fn test_execution_config_default_allowlist() {
6310 let _guard = test_guard!();
6311 let config = ExecutionConfig::default();
6312 assert!(config.is_allowed("cargo"));
6314 assert!(config.is_allowed("rustc"));
6315 assert!(config.is_allowed("gcc"));
6316 assert!(config.is_allowed("g++"));
6317 assert!(config.is_allowed("clang"));
6318 assert!(config.is_allowed("clang++"));
6319 assert!(config.is_allowed("make"));
6320 assert!(config.is_allowed("cmake"));
6321 assert!(config.is_allowed("ninja"));
6322 assert!(config.is_allowed("meson"));
6323 assert!(config.is_allowed("bun"));
6324 assert!(config.is_allowed("nextest"));
6325 assert!(config.is_allowed("cc"));
6326 assert!(config.is_allowed("c++"));
6327 }
6328
6329 #[test]
6330 fn test_execution_config_is_allowed_case_insensitive() {
6331 let _guard = test_guard!();
6332 let config = ExecutionConfig::default();
6333 assert!(config.is_allowed("CARGO"));
6335 assert!(config.is_allowed("Cargo"));
6336 assert!(config.is_allowed("GCC"));
6337 assert!(config.is_allowed("Gcc"));
6338 }
6339
6340 #[test]
6341 fn test_execution_config_is_not_allowed() {
6342 let _guard = test_guard!();
6343 let config = ExecutionConfig::default();
6344 assert!(!config.is_allowed("python"));
6346 assert!(!config.is_allowed("npm"));
6347 assert!(!config.is_allowed(""));
6348 assert!(config.is_allowed("go"));
6353 assert!(config.is_allowed("tsc"));
6354 }
6355
6356 #[test]
6357 fn test_execution_config_empty_allowlist() {
6358 let _guard = test_guard!();
6359 let config = ExecutionConfig { allowlist: vec![] };
6360 assert!(!config.is_allowed("cargo"));
6362 assert!(!config.is_allowed("gcc"));
6363 }
6364
6365 #[test]
6366 fn test_execution_config_custom_allowlist() {
6367 let _guard = test_guard!();
6368 let config = ExecutionConfig {
6369 allowlist: vec!["cargo".to_string(), "custom_tool".to_string()],
6370 };
6371 assert!(config.is_allowed("cargo"));
6372 assert!(config.is_allowed("custom_tool"));
6373 assert!(!config.is_allowed("gcc"));
6374 }
6375
6376 #[test]
6377 fn test_execution_config_serde() {
6378 let _guard = test_guard!();
6379 let config = ExecutionConfig {
6380 allowlist: vec!["cargo".to_string(), "rustc".to_string()],
6381 };
6382 let json = serde_json::to_string(&config).unwrap();
6383 let parsed: ExecutionConfig = serde_json::from_str(&json).unwrap();
6384 assert_eq!(parsed.allowlist, config.allowlist);
6385 }
6386
6387 #[test]
6388 fn test_rch_config_includes_execution() {
6389 let _guard = test_guard!();
6390 let config = RchConfig::default();
6391 assert!(config.execution.is_allowed("cargo"));
6393 }
6394
6395 #[test]
6400 fn test_adaptive_compression_disabled_by_default() {
6401 let _guard = test_guard!();
6402 let config = TransferConfig::default();
6403 assert!(!config.adaptive_compression);
6404 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6406 assert_eq!(config.select_compression_level(Some(500_000_000)), 3);
6407 }
6408
6409 #[test]
6410 fn test_adaptive_compression_small_payload() {
6411 let _guard = test_guard!();
6412 let config = TransferConfig {
6413 adaptive_compression: true,
6414 ..Default::default()
6415 };
6416 assert_eq!(config.select_compression_level(Some(0)), 1);
6418 assert_eq!(config.select_compression_level(Some(1_000_000)), 1); assert_eq!(config.select_compression_level(Some(9_999_999)), 1); }
6421
6422 #[test]
6423 fn test_adaptive_compression_medium_payload() {
6424 let _guard = test_guard!();
6425 let config = TransferConfig {
6426 adaptive_compression: true,
6427 ..Default::default()
6428 };
6429 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); }
6434
6435 #[test]
6436 fn test_adaptive_compression_large_payload() {
6437 let _guard = test_guard!();
6438 let config = TransferConfig {
6439 adaptive_compression: true,
6440 ..Default::default()
6441 };
6442 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); }
6447
6448 #[test]
6449 fn test_adaptive_compression_no_estimate() {
6450 let _guard = test_guard!();
6451 let config = TransferConfig {
6452 adaptive_compression: true,
6453 compression_level: 5,
6454 ..Default::default()
6455 };
6456 assert_eq!(config.select_compression_level(None), 5);
6458 }
6459
6460 #[test]
6461 fn test_adaptive_compression_respects_min_level() {
6462 let _guard = test_guard!();
6463 let config = TransferConfig {
6464 adaptive_compression: true,
6465 min_compression_level: 3,
6466 ..Default::default()
6467 };
6468 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6470 }
6471
6472 #[test]
6473 fn test_adaptive_compression_respects_max_level() {
6474 let _guard = test_guard!();
6475 let config = TransferConfig {
6476 adaptive_compression: true,
6477 max_compression_level: 5,
6478 ..Default::default()
6479 };
6480 assert_eq!(config.select_compression_level(Some(500_000_000)), 5);
6482 }
6483
6484 #[test]
6485 fn test_adaptive_compression_serde_roundtrip() {
6486 let _guard = test_guard!();
6487 let config = TransferConfig {
6488 adaptive_compression: true,
6489 min_compression_level: 2,
6490 max_compression_level: 8,
6491 ..Default::default()
6492 };
6493 let json = serde_json::to_string(&config).unwrap();
6494 let parsed: TransferConfig = serde_json::from_str(&json).unwrap();
6495 assert!(parsed.adaptive_compression);
6496 assert_eq!(parsed.min_compression_level, 2);
6497 assert_eq!(parsed.max_compression_level, 8);
6498 }
6499
6500 #[test]
6505 fn test_self_healing_config_defaults() {
6506 let _guard = test_guard!();
6507 let config = SelfHealingConfig::default();
6509
6510 assert!(
6512 config.hook_starts_daemon,
6513 "hook_starts_daemon should be true by default"
6514 );
6515 assert!(
6516 config.daemon_installs_hooks,
6517 "daemon_installs_hooks should be true by default"
6518 );
6519
6520 assert_eq!(
6522 config.auto_start_cooldown_secs, 30,
6523 "auto_start_cooldown_secs should default to 30"
6524 );
6525 assert_eq!(
6526 config.auto_start_timeout_secs, 3,
6527 "auto_start_timeout_secs should default to 3"
6528 );
6529 }
6531
6532 #[test]
6533 fn test_self_healing_config_serde_full() {
6534 let _guard = test_guard!();
6535 let config = SelfHealingConfig {
6537 hook_starts_daemon: false,
6538 daemon_installs_hooks: false,
6539 auto_start_cooldown_secs: 60,
6540 auto_start_timeout_secs: 10,
6541 self_healing_log_level: SelfHealingLogLevel::Debug,
6542 };
6543
6544 let json = serde_json::to_string(&config).unwrap();
6545 assert!(json.contains("\"hook_starts_daemon\":false"));
6546 assert!(json.contains("\"daemon_installs_hooks\":false"));
6547 assert!(json.contains("\"auto_start_cooldown_secs\":60"));
6548 assert!(json.contains("\"auto_start_timeout_secs\":10"));
6549 assert!(json.contains("\"self_healing_log_level\":\"debug\""));
6550
6551 let parsed: SelfHealingConfig = serde_json::from_str(&json).unwrap();
6552 assert!(!parsed.hook_starts_daemon);
6553 assert!(!parsed.daemon_installs_hooks);
6554 assert_eq!(parsed.auto_start_cooldown_secs, 60);
6555 assert_eq!(parsed.auto_start_timeout_secs, 10);
6556 assert_eq!(parsed.self_healing_log_level, SelfHealingLogLevel::Debug);
6557 }
6559
6560 #[test]
6563 fn test_self_healing_log_level_default_is_info() {
6564 let level = SelfHealingLogLevel::default();
6566 assert_eq!(level, SelfHealingLogLevel::Info);
6567 let config = SelfHealingConfig::default();
6569 assert_eq!(
6570 config.self_healing_log_level,
6571 SelfHealingLogLevel::Info,
6572 "config default should be Info"
6573 );
6574 }
6576
6577 #[test]
6578 fn test_self_healing_log_level_serializes_lowercase() {
6579 let json = serde_json::to_string(&SelfHealingLogLevel::Warn).unwrap();
6581 assert_eq!(json, "\"warn\"");
6582 let parsed: SelfHealingLogLevel = serde_json::from_str("\"error\"").unwrap();
6583 assert_eq!(parsed, SelfHealingLogLevel::Error);
6584 }
6586
6587 #[test]
6588 fn test_self_healing_log_level_from_env_str_accepts_known() {
6589 assert_eq!(
6591 SelfHealingLogLevel::from_env_str("debug"),
6592 Some(SelfHealingLogLevel::Debug)
6593 );
6594 assert_eq!(
6595 SelfHealingLogLevel::from_env_str("DEBUG"),
6596 Some(SelfHealingLogLevel::Debug)
6597 );
6598 assert_eq!(
6599 SelfHealingLogLevel::from_env_str("info"),
6600 Some(SelfHealingLogLevel::Info)
6601 );
6602 assert_eq!(
6603 SelfHealingLogLevel::from_env_str("warn"),
6604 Some(SelfHealingLogLevel::Warn)
6605 );
6606 assert_eq!(
6607 SelfHealingLogLevel::from_env_str("warning"),
6608 Some(SelfHealingLogLevel::Warn),
6609 "warning should map to Warn"
6610 );
6611 assert_eq!(
6612 SelfHealingLogLevel::from_env_str("error"),
6613 Some(SelfHealingLogLevel::Error)
6614 );
6615 assert_eq!(
6617 SelfHealingLogLevel::from_env_str(" debug "),
6618 Some(SelfHealingLogLevel::Debug)
6619 );
6620 }
6622
6623 #[test]
6624 fn test_self_healing_log_level_from_env_str_rejects_unknown() {
6625 assert!(SelfHealingLogLevel::from_env_str("banana").is_none());
6627 assert!(SelfHealingLogLevel::from_env_str("").is_none());
6628 assert!(SelfHealingLogLevel::from_env_str("trace").is_none());
6629 }
6631
6632 #[test]
6633 fn test_self_healing_config_serde_partial_uses_defaults() {
6634 let _guard = test_guard!();
6635 let json = r#"{"hook_starts_daemon": false}"#;
6637 let config: SelfHealingConfig = serde_json::from_str(json).unwrap();
6638
6639 assert!(
6640 !config.hook_starts_daemon,
6641 "Explicit false should be parsed"
6642 );
6643 assert!(
6644 config.daemon_installs_hooks,
6645 "Missing field should use default (true)"
6646 );
6647 assert_eq!(
6648 config.auto_start_cooldown_secs, 30,
6649 "Missing field should use default (30)"
6650 );
6651 assert_eq!(
6652 config.auto_start_timeout_secs, 3,
6653 "Missing field should use default (3)"
6654 );
6655 }
6657
6658 #[test]
6659 fn test_self_healing_config_toml_with_alias() {
6660 let _guard = test_guard!();
6661 let toml_str = r#"
6663 hook_starts_daemon = true
6664 daemon_installs_hooks = false
6665 auto_start_cooldown_secs = 45
6666 daemon_start_timeout = 7
6667 "#;
6668
6669 let config: SelfHealingConfig = toml::from_str(toml_str).unwrap();
6670 assert!(config.hook_starts_daemon);
6671 assert!(!config.daemon_installs_hooks);
6672 assert_eq!(config.auto_start_cooldown_secs, 45);
6673 assert_eq!(
6674 config.auto_start_timeout_secs, 7,
6675 "daemon_start_timeout alias should set auto_start_timeout_secs"
6676 );
6677 }
6679
6680 #[allow(unsafe_code)]
6681 mod self_healing_env_override_tests {
6682 use super::*;
6683 use crate::config::env_test_lock;
6684
6685 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
6686 env_test_lock()
6687 }
6688
6689 fn set_env(key: &str, value: &str) {
6690 unsafe { std::env::set_var(key, value) };
6692 }
6693
6694 fn remove_env(key: &str) {
6695 unsafe { std::env::remove_var(key) };
6697 }
6698
6699 struct EnvVarGuard {
6700 key: &'static str,
6701 old: Option<String>,
6702 }
6703
6704 impl EnvVarGuard {
6705 fn set(key: &'static str, value: &str) -> Self {
6706 let old = std::env::var(key).ok();
6707 set_env(key, value);
6708 Self { key, old }
6709 }
6710 }
6711
6712 impl Drop for EnvVarGuard {
6713 fn drop(&mut self) {
6714 if let Some(old) = &self.old {
6715 set_env(self.key, old);
6716 } else {
6717 remove_env(self.key);
6718 }
6719 }
6720 }
6721
6722 #[test]
6723 fn test_self_healing_config_with_env_overrides_master_disable() {
6724 let _guard = test_guard!();
6725 let _guard = env_guard();
6726
6727 let _no_self_healing = EnvVarGuard::set("RCH_NO_SELF_HEALING", "1");
6728 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "1");
6729 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "1");
6730 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "99");
6731 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "99");
6732
6733 let config = SelfHealingConfig::default().with_env_overrides();
6734 assert!(
6735 !config.hook_starts_daemon,
6736 "RCH_NO_SELF_HEALING should disable hook auto-start"
6737 );
6738 assert!(
6739 !config.daemon_installs_hooks,
6740 "RCH_NO_SELF_HEALING should disable daemon hook installation"
6741 );
6742
6743 assert_eq!(config.auto_start_cooldown_secs, 30);
6745 assert_eq!(config.auto_start_timeout_secs, 3);
6746 }
6747
6748 #[test]
6749 fn test_self_healing_config_with_env_overrides_toggles_and_numbers() {
6750 let _guard = test_guard!();
6751 let _guard = env_guard();
6752
6753 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "0");
6754 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "false");
6755 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "45");
6756 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "7");
6757
6758 let config = SelfHealingConfig::default().with_env_overrides();
6759 assert!(!config.hook_starts_daemon);
6760 assert!(!config.daemon_installs_hooks);
6761 assert_eq!(config.auto_start_cooldown_secs, 45);
6762 assert_eq!(config.auto_start_timeout_secs, 7);
6763 }
6764
6765 #[test]
6766 fn test_self_healing_config_with_env_overrides_invalid_numbers_ignored() {
6767 let _guard = test_guard!();
6768 let _guard = env_guard();
6769
6770 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "not-a-number");
6771 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "nope");
6772
6773 let config = SelfHealingConfig::default().with_env_overrides();
6774 assert_eq!(config.auto_start_cooldown_secs, 30);
6775 assert_eq!(config.auto_start_timeout_secs, 3);
6776 }
6777 }
6778
6779 #[test]
6784 fn test_load_per_core_calculation() {
6785 let _guard = test_guard!();
6786 let mut caps = WorkerCapabilities::new();
6787 assert!(caps.load_per_core().is_none());
6789
6790 caps.load_avg_1 = Some(4.0);
6792 assert!(caps.load_per_core().is_none());
6793
6794 caps.num_cpus = Some(4);
6796 assert_eq!(caps.load_per_core(), Some(1.0)); caps.load_avg_1 = Some(16.0);
6800 assert_eq!(caps.load_per_core(), Some(4.0)); }
6802
6803 #[test]
6804 fn test_is_high_load() {
6805 let _guard = test_guard!();
6806 let mut caps = WorkerCapabilities::new();
6807 caps.load_avg_1 = Some(8.0);
6808 caps.num_cpus = Some(4);
6809 assert_eq!(caps.is_high_load(3.0), Some(false));
6813 assert_eq!(caps.is_high_load(2.0), Some(false)); assert_eq!(caps.is_high_load(1.5), Some(true));
6817
6818 caps.load_avg_1 = None;
6820 assert!(caps.is_high_load(1.0).is_none());
6821 }
6822
6823 #[test]
6824 fn test_is_low_disk() {
6825 let _guard = test_guard!();
6826 let mut caps = WorkerCapabilities::new();
6827 caps.disk_free_gb = Some(15.0);
6828
6829 assert_eq!(caps.is_low_disk(10.0), Some(false));
6831 assert_eq!(caps.is_low_disk(15.0), Some(false)); assert_eq!(caps.is_low_disk(20.0), Some(true));
6835
6836 caps.disk_free_gb = None;
6838 assert!(caps.is_low_disk(10.0).is_none());
6839 }
6840
6841 #[test]
6842 fn low_disk_judges_the_build_disk_not_a_small_tmpfs() {
6843 let _guard = test_guard!();
6844 let mut caps = WorkerCapabilities::new();
6847 caps.disk_free_gb = Some(8.0);
6848 caps.disk_total_gb = Some(15.5);
6849 caps.build_disk_free_gb = Some(1700.0);
6850 caps.build_disk_total_gb = Some(1900.0);
6851 assert_eq!(caps.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6852 assert_eq!(caps.is_low_disk(10.0), Some(false));
6853
6854 caps.build_disk_free_gb = Some(4.0);
6856 assert_eq!(caps.is_low_disk(10.0), Some(true));
6857
6858 caps.build_disk_free_gb = None;
6860 caps.build_disk_total_gb = None;
6861 assert_eq!(caps.build_disk_gb(), (Some(8.0), Some(15.5)));
6862 assert_eq!(caps.is_low_disk(10.0), Some(true));
6863 }
6864
6865 #[test]
6866 fn build_disk_sample_is_optional_on_the_wire() {
6867 let _guard = test_guard!();
6868 let old: WorkerCapabilities =
6869 serde_json::from_str(r#"{"disk_free_gb":13.9,"disk_total_gb":15.5}"#).unwrap();
6870 assert_eq!(old.build_disk_free_gb, None);
6871 assert_eq!(old.build_disk_gb(), (Some(13.9), Some(15.5)));
6872
6873 let new: WorkerCapabilities = serde_json::from_str(
6874 r#"{"disk_free_gb":13.9,"disk_total_gb":15.5,"build_disk_free_gb":1700.0,"build_disk_total_gb":1900.0}"#,
6875 )
6876 .unwrap();
6877 assert_eq!(new.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6878 let json = serde_json::to_string(&new).unwrap();
6879 assert!(json.contains("\"build_disk_free_gb\":1700.0"), "{json}");
6880 }
6881
6882 #[test]
6883 fn test_selection_config_preflight_defaults() {
6884 let _guard = test_guard!();
6885 let config = SelectionConfig::default();
6886 assert_eq!(config.max_load_per_core, Some(2.0));
6888 assert_eq!(config.min_free_gb, Some(10.0));
6889 assert_eq!(config.disk_gb_per_slot, 10.0);
6890 }
6891
6892 #[test]
6893 fn test_selection_config_preflight_serde() {
6894 let _guard = test_guard!();
6895 let config = SelectionConfig {
6896 max_load_per_core: Some(3.5),
6897 min_free_gb: Some(25.0),
6898 disk_gb_per_slot: 0.83,
6899 ..Default::default()
6900 };
6901 let json = serde_json::to_string(&config).unwrap();
6902 let parsed: SelectionConfig = serde_json::from_str(&json).unwrap();
6903 assert_eq!(parsed.max_load_per_core, Some(3.5));
6904 assert_eq!(parsed.min_free_gb, Some(25.0));
6905 assert_eq!(parsed.disk_gb_per_slot, 0.83);
6906 }
6907
6908 #[test]
6909 fn test_selection_disk_slot_budget_validation() {
6910 let _guard = test_guard!();
6911 for value in ["0.0", "-1.0", "nan", "inf", "-inf"] {
6912 let result = toml::from_str::<SelectionConfig>(&format!("disk_gb_per_slot = {value}"));
6913 assert!(result.is_err(), "invalid disk slot budget {value}");
6914 }
6915 let default: SelectionConfig = toml::from_str("").unwrap();
6916 assert_eq!(default.disk_gb_per_slot, 10.0);
6917 }
6918
6919 #[test]
6920 fn test_selection_disk_weight_validation_and_roundtrip() {
6921 for value in ["-0.1", "1.1", "nan", "inf", "-inf"] {
6922 assert!(toml::from_str::<SelectionWeightConfig>(&format!("disk = {value}")).is_err());
6923 }
6924 for value in [0.0, 0.2, 1.0] {
6925 let weights: SelectionWeightConfig =
6926 toml::from_str(&format!("disk = {value}")).unwrap();
6927 assert_eq!(weights.disk, value);
6928 let roundtrip: SelectionWeightConfig =
6929 serde_json::from_str(&serde_json::to_string(&weights).unwrap()).unwrap();
6930 assert_eq!(roundtrip.disk, value);
6931 }
6932 let defaults: SelectionWeightConfig = toml::from_str("").unwrap();
6933 assert_eq!(defaults.disk, 0.2);
6934 }
6935
6936 #[test]
6937 fn test_worker_capabilities_health_metrics_serde() {
6938 let _guard = test_guard!();
6939 let caps = WorkerCapabilities {
6940 num_cpus: Some(8),
6941 load_avg_1: Some(1.5),
6942 load_avg_5: Some(2.0),
6943 load_avg_15: Some(1.8),
6944 disk_free_gb: Some(50.5),
6945 disk_total_gb: Some(100.0),
6946 ..Default::default()
6947 };
6948 let json = serde_json::to_string(&caps).unwrap();
6949 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
6950 assert_eq!(parsed.num_cpus, Some(8));
6951 assert_eq!(parsed.load_avg_1, Some(1.5));
6952 assert_eq!(parsed.disk_free_gb, Some(50.5));
6953 }
6954
6955 #[test]
6956 fn microarch_level_classifies_ivy_bridge_as_v2() {
6957 let ivy = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov \
6960 pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm \
6961 constant_tsc pni pclmulqdq ssse3 cx16 sse4_1 sse4_2 x2apic \
6962 popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm";
6963 assert_eq!(x86_64_microarch_level_from_flags(ivy), 2);
6964 }
6965
6966 #[test]
6967 fn microarch_level_classifies_v3_and_v4() {
6968 let v3 = "cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 \
6969 f16c fma movbe xsave";
6970 assert_eq!(x86_64_microarch_level_from_flags(v3), 3);
6971
6972 let v4 = format!("{v3} avx512f avx512bw avx512cd avx512dq avx512vl");
6973 assert_eq!(x86_64_microarch_level_from_flags(&v4), 4);
6974 }
6975
6976 #[test]
6977 fn microarch_level_v1_for_ancient_or_empty_flags() {
6978 assert_eq!(x86_64_microarch_level_from_flags(""), 1);
6979 assert_eq!(x86_64_microarch_level_from_flags("fpu mmx sse sse2"), 1);
6980 }
6981
6982 #[test]
6983 fn pre_v3_predicate_only_fires_on_affirmative_evidence() {
6984 let mut caps = WorkerCapabilities::new();
6985 assert!(!caps.is_pre_v3_x86(), "unknown must not deprioritize");
6986 caps.cpu_microarch_level = Some(2);
6987 assert!(caps.is_pre_v3_x86());
6988 caps.cpu_microarch_level = Some(3);
6989 assert!(!caps.is_pre_v3_x86());
6990 }
6991}