1use crate::{CompilationKind, toolchain::ToolchainInfo};
4use rand::RngExt;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
11pub struct WorkerId(pub String);
12
13impl WorkerId {
14 pub fn new(id: impl Into<String>) -> Self {
15 Self(id.into())
16 }
17
18 pub fn as_str(&self) -> &str {
19 &self.0
20 }
21}
22
23impl std::fmt::Display for WorkerId {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 write!(f, "{}", self.0)
26 }
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum WorkerStatus {
48 #[default]
50 Healthy,
51 Degraded,
53 Unreachable,
55 Draining,
57 Drained,
59 Disabled,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[serde(rename_all = "snake_case")]
66pub enum CircuitState {
67 #[default]
69 Closed,
70 Open,
72 HalfOpen,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
78#[serde(rename_all = "lowercase")]
79pub enum RequiredRuntime {
80 #[default]
82 None,
83 Rust,
85 Bun,
87 Node,
89 Nix,
91 Go,
93 Zig,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
102#[serde(rename_all = "lowercase")]
103pub enum CommandPriority {
104 Low,
105 #[default]
106 Normal,
107 High,
108}
109
110impl std::fmt::Display for CommandPriority {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 let value = match self {
113 Self::Low => "low",
114 Self::Normal => "normal",
115 Self::High => "high",
116 };
117 write!(f, "{}", value)
118 }
119}
120
121impl std::str::FromStr for CommandPriority {
122 type Err = ();
123
124 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 match s.trim().to_lowercase().as_str() {
126 "low" => Ok(Self::Low),
127 "normal" => Ok(Self::Normal),
128 "high" => Ok(Self::High),
129 _ => Err(()),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SelectionRequest {
137 pub project: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub command: Option<String>,
142 #[serde(default)]
144 pub command_priority: CommandPriority,
145 pub estimated_cores: u32,
147 #[serde(default, skip_serializing_if = "disk_headroom_disabled")]
151 pub disk_headroom_gib: u32,
152 #[serde(default)]
154 pub preferred_workers: Vec<WorkerId>,
155 #[serde(default)]
157 pub toolchain: Option<ToolchainInfo>,
158 #[serde(default)]
160 pub required_runtime: RequiredRuntime,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub classification_duration_us: Option<u64>,
165 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
171 pub job_mode: bool,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub required_tools: Vec<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub hook_pid: Option<u32>,
186}
187
188fn disk_headroom_disabled(gib: &u32) -> bool {
189 *gib == 0
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197#[serde(rename_all = "snake_case")]
198pub enum SelectionReason {
199 Success,
201 NoWorkersConfigured,
203 AllWorkersUnreachable,
205 AllCircuitsOpen,
207 AllWorkersBusy,
209 NoWorkersPassedHealth,
211 AllWorkersFailedPreflight,
213 AllWorkersFailedConvergence,
215 NoAdmissibleWorkers(String),
217 NoMatchingWorkers,
219 NoWorkersWithRuntime(String),
221 SelectionError(String),
223 AffinityPinned,
225 AffinityFallback,
227}
228
229pub const SELECTION_RESPONSE_PROTOCOL_VERSION: u64 = 1;
235
236impl std::fmt::Display for SelectionReason {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 match self {
239 Self::Success => write!(f, "worker assigned successfully"),
240 Self::NoWorkersConfigured => write!(f, "no workers configured"),
241 Self::AllWorkersUnreachable => write!(f, "all workers unreachable"),
242 Self::AllCircuitsOpen => write!(f, "all worker circuits open"),
243 Self::AllWorkersBusy => write!(f, "all workers at capacity"),
244 Self::NoWorkersPassedHealth => write!(f, "no workers passed health thresholds"),
245 Self::AllWorkersFailedPreflight => write!(f, "all workers failed preflight checks"),
246 Self::AllWorkersFailedConvergence => {
247 write!(f, "all workers failed repo convergence checks")
248 }
249 Self::NoAdmissibleWorkers(summary) => {
250 write!(f, "no admissible workers: {}", summary)
251 }
252 Self::NoMatchingWorkers => write!(f, "no matching workers found"),
253 Self::NoWorkersWithRuntime(rt) => write!(f, "no workers with {} installed", rt),
254 Self::SelectionError(e) => write!(f, "selection error: {}", e),
255 Self::AffinityPinned => write!(f, "worker assigned via affinity pinning"),
256 Self::AffinityFallback => write!(f, "worker assigned via last-success fallback"),
257 }
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
267#[serde(rename_all = "snake_case")]
268pub enum SelectionStrategy {
269 Priority,
272 Fastest,
275 #[default]
278 Balanced,
279 CacheAffinity,
282 FairFastest,
285}
286
287impl std::fmt::Display for SelectionStrategy {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 let name = match self {
290 Self::Priority => "priority",
291 Self::Fastest => "fastest",
292 Self::Balanced => "balanced",
293 Self::CacheAffinity => "cache_affinity",
294 Self::FairFastest => "fair_fastest",
295 };
296 write!(f, "{}", name)
297 }
298}
299
300impl std::str::FromStr for SelectionStrategy {
301 type Err = String;
302
303 fn from_str(s: &str) -> Result<Self, Self::Err> {
304 match s.trim().to_lowercase().as_str() {
305 "priority" => Ok(Self::Priority),
306 "fastest" => Ok(Self::Fastest),
307 "balanced" => Ok(Self::Balanced),
308 "cache_affinity" | "cache-affinity" | "cacheaffinity" => Ok(Self::CacheAffinity),
309 "fair_fastest" | "fair-fastest" | "fairfastest" => Ok(Self::FairFastest),
310 _ => Err(format!(
311 "unknown selection strategy '{}', expected one of: priority, fastest, balanced, cache_affinity, fair_fastest",
312 s
313 )),
314 }
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct SelectionConfig {
321 #[serde(default)]
323 pub strategy: SelectionStrategy,
324 #[serde(default = "default_min_success_rate")]
326 pub min_success_rate: f64,
327 #[serde(default)]
329 pub weights: SelectionWeightConfig,
330 #[serde(default)]
332 pub fairness: FairnessConfig,
333 #[serde(default)]
335 pub affinity: AffinityConfig,
336
337 #[serde(default = "default_max_load_per_core")]
341 pub max_load_per_core: Option<f64>,
342 #[serde(default = "default_min_free_gb")]
345 pub min_free_gb: Option<f64>,
346 #[serde(
349 default = "default_disk_gb_per_slot",
350 deserialize_with = "deserialize_disk_gb_per_slot"
351 )]
352 pub disk_gb_per_slot: f64,
353 #[serde(default = "default_disk_critical_free_ratio")]
356 pub disk_critical_free_ratio: f64,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub disk_critical_ratio_cap_gb: Option<f64>,
362}
363
364fn default_disk_critical_free_ratio() -> f64 {
365 0.05
366}
367
368impl Default for SelectionConfig {
369 fn default() -> Self {
370 Self {
371 strategy: SelectionStrategy::default(),
372 min_success_rate: default_min_success_rate(),
373 weights: SelectionWeightConfig::default(),
374 fairness: FairnessConfig::default(),
375 affinity: AffinityConfig::default(),
376 max_load_per_core: default_max_load_per_core(),
377 min_free_gb: default_min_free_gb(),
378 disk_gb_per_slot: default_disk_gb_per_slot(),
379 disk_critical_free_ratio: default_disk_critical_free_ratio(),
380 disk_critical_ratio_cap_gb: None,
381 }
382 }
383}
384
385fn default_min_success_rate() -> f64 {
386 0.8
387}
388
389fn default_max_load_per_core() -> Option<f64> {
390 Some(2.0) }
392
393fn default_min_free_gb() -> Option<f64> {
394 Some(10.0) }
396
397fn default_disk_gb_per_slot() -> f64 {
398 10.0
399}
400
401fn deserialize_disk_gb_per_slot<'de, D>(deserializer: D) -> Result<f64, D::Error>
402where
403 D: serde::Deserializer<'de>,
404{
405 let budget = f64::deserialize(deserializer)?;
406 if budget.is_finite() && budget > 0.0 {
407 Ok(budget)
408 } else {
409 Err(serde::de::Error::custom(
410 "disk_gb_per_slot must be finite and greater than zero",
411 ))
412 }
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct SelectionWeightConfig {
418 #[serde(default = "default_weight_speedscore")]
420 pub speedscore: f64,
421 #[serde(default = "default_weight_slots")]
423 pub slots: f64,
424 #[serde(default = "default_weight_health")]
426 pub health: f64,
427 #[serde(default = "default_weight_cache")]
429 pub cache: f64,
430 #[serde(default = "default_weight_network")]
432 pub network: f64,
433 #[serde(default = "default_weight_priority")]
435 pub priority: f64,
436 #[serde(
438 default = "default_weight_disk",
439 deserialize_with = "deserialize_weight_disk"
440 )]
441 pub disk: f64,
442 #[serde(default = "default_half_open_penalty")]
444 pub half_open_penalty: f64,
445}
446
447impl Default for SelectionWeightConfig {
448 fn default() -> Self {
449 Self {
450 speedscore: default_weight_speedscore(),
451 slots: default_weight_slots(),
452 health: default_weight_health(),
453 cache: default_weight_cache(),
454 network: default_weight_network(),
455 priority: default_weight_priority(),
456 disk: default_weight_disk(),
457 half_open_penalty: default_half_open_penalty(),
458 }
459 }
460}
461
462fn default_weight_speedscore() -> f64 {
463 0.5
464}
465fn default_weight_slots() -> f64 {
466 0.4
467}
468fn default_weight_health() -> f64 {
469 0.3
470}
471fn default_weight_cache() -> f64 {
472 0.2
473}
474fn default_weight_network() -> f64 {
475 0.1
476}
477fn default_weight_priority() -> f64 {
478 0.5
485}
486fn default_half_open_penalty() -> f64 {
487 0.5
488}
489
490fn default_weight_disk() -> f64 {
491 0.2
492}
493
494fn deserialize_weight_disk<'de, D>(deserializer: D) -> Result<f64, D::Error>
495where
496 D: serde::Deserializer<'de>,
497{
498 let weight = f64::deserialize(deserializer)?;
499 if weight.is_finite() && (0.0..=1.0).contains(&weight) {
500 Ok(weight)
501 } else {
502 Err(serde::de::Error::custom(
503 "disk weight must be finite and between zero and one",
504 ))
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct FairnessConfig {
511 #[serde(default = "default_fairness_lookback_secs")]
513 pub lookback_secs: u64,
514 #[serde(default = "default_max_consecutive_selections")]
516 pub max_consecutive_selections: u32,
517}
518
519impl Default for FairnessConfig {
520 fn default() -> Self {
521 Self {
522 lookback_secs: default_fairness_lookback_secs(),
523 max_consecutive_selections: default_max_consecutive_selections(),
524 }
525 }
526}
527
528fn default_fairness_lookback_secs() -> u64 {
529 300 }
531
532fn default_max_consecutive_selections() -> u32 {
533 3
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct AffinityConfig {
539 #[serde(default = "default_affinity_enabled")]
541 pub enabled: bool,
542 #[serde(default = "default_affinity_pin_minutes")]
545 pub pin_minutes: u64,
546 #[serde(default = "default_last_success_fallback")]
550 pub enable_last_success_fallback: bool,
551 #[serde(default = "default_fallback_min_success_rate")]
554 pub fallback_min_success_rate: f64,
555}
556
557impl Default for AffinityConfig {
558 fn default() -> Self {
559 Self {
560 enabled: default_affinity_enabled(),
561 pin_minutes: default_affinity_pin_minutes(),
562 enable_last_success_fallback: default_last_success_fallback(),
563 fallback_min_success_rate: default_fallback_min_success_rate(),
564 }
565 }
566}
567
568fn default_affinity_enabled() -> bool {
569 true
570}
571
572fn default_affinity_pin_minutes() -> u64 {
573 60 }
575
576fn default_last_success_fallback() -> bool {
577 true
578}
579
580fn default_fallback_min_success_rate() -> f64 {
581 0.5 }
583
584#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
586pub struct SelectedWorker {
587 pub id: WorkerId,
589 pub host: String,
591 pub user: String,
593 pub identity_file: String,
595 pub slots_available: u32,
597 pub speed_score: f64,
599 #[serde(default)]
605 pub declared_os: Option<String>,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
610pub struct SelectionResponse {
611 pub worker: Option<SelectedWorker>,
613 pub reason: SelectionReason,
615 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub build_id: Option<u64>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub diagnostics: Option<SelectionDiagnostics>,
621}
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
625#[serde(rename_all = "snake_case")]
626pub enum WorkerSelectionDiagnosticDecision {
627 Allow,
629 FallbackCandidate,
631 Deny,
633}
634
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
637pub struct WorkerSelectionDiagnostic {
638 pub worker_id: WorkerId,
640 pub status: String,
642 pub circuit_state: String,
644 pub pressure_state: String,
646 pub pressure_reason_code: String,
648 #[serde(skip_serializing_if = "Option::is_none")]
650 pub success_rate: Option<f64>,
651 pub min_success_rate: f64,
653 pub fallback_min_success_rate: f64,
655 pub required_runtime: RequiredRuntime,
657 pub runtime_available: bool,
659 pub available_slots: u32,
661 pub total_slots: u32,
663 pub estimated_cores: u32,
665 pub active_project_excluded: bool,
667 pub final_decision: WorkerSelectionDiagnosticDecision,
669 pub final_reason: String,
671 pub reason_codes: Vec<String>,
673}
674
675#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
677pub struct SelectionDiagnostics {
678 pub required_runtime: RequiredRuntime,
680 pub estimated_cores: u32,
682 pub min_success_rate: f64,
684 pub fallback_min_success_rate: f64,
686 pub active_project_exclusion_count: usize,
688 pub workers: Vec<WorkerSelectionDiagnostic>,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct ReleaseRequest {
695 pub worker_id: WorkerId,
697 pub slots: u32,
699 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub build_id: Option<u64>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub local_wrapper_id: Option<String>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub exit_code: Option<i32>,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub duration_ms: Option<u64>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
715 pub bytes_transferred: Option<u64>,
716 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub timing: Option<CommandTimingBreakdown>,
719 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
723 pub worker_fault: bool,
724 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
728 pub worker_disk_full: bool,
729 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub worker_disk_roots: Vec<String>,
733 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
737 pub worker_live_checkout_refused: bool,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
742#[serde(rename_all = "snake_case")]
743pub enum BuildHeartbeatPhase {
744 SyncUp,
746 Execute,
748 SyncDown,
750 Finalize,
752}
753
754#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
756pub struct BuildHeartbeatRequest {
757 pub build_id: u64,
759 pub worker_id: WorkerId,
761 #[serde(default, skip_serializing_if = "Option::is_none")]
763 pub hook_pid: Option<u32>,
764 #[serde(default, skip_serializing_if = "Option::is_none")]
766 pub local_wrapper_id: Option<String>,
767 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub remote_pgid_file: Option<String>,
770 pub phase: BuildHeartbeatPhase,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
774 pub detail: Option<String>,
775 #[serde(default, skip_serializing_if = "Option::is_none")]
780 pub progress_counter: Option<u64>,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
783 pub progress_percent: Option<f64>,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize)]
788pub struct WorkerConfig {
789 pub id: WorkerId,
791 pub host: String,
793 pub user: String,
795 pub identity_file: String,
797 pub total_slots: u32,
799 #[serde(default = "default_priority")]
801 pub priority: u32,
802 #[serde(default)]
808 pub tags: Vec<String>,
809 #[serde(default, skip_serializing_if = "Vec::is_empty")]
818 pub tools: Vec<WorkerToolProbe>,
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827pub struct WorkerToolProbe {
828 pub name: String,
830 pub command: Vec<String>,
832}
833
834fn default_priority() -> u32 {
835 100
836}
837
838pub const OS_TAG_PREFIX: &str = "os:";
840
841#[must_use]
852pub fn declared_os(tags: &[String]) -> Option<String> {
853 tags.iter()
858 .filter_map(|tag| tag.strip_prefix(OS_TAG_PREFIX))
859 .map(|os| os.trim().to_ascii_lowercase())
860 .find(|os| !os.is_empty())
861}
862
863#[must_use]
865pub fn os_tag(os: &str) -> String {
866 format!("{OS_TAG_PREFIX}{}", os.trim().to_ascii_lowercase())
867}
868
869impl Default for WorkerConfig {
870 fn default() -> Self {
871 Self {
872 id: WorkerId::new("default-worker"),
873 host: "localhost".to_string(),
874 user: "user".to_string(),
875 identity_file: "~/.ssh/id_rsa".to_string(),
876 total_slots: 4,
877 priority: default_priority(),
878 tags: Vec::new(),
879 tools: Vec::new(),
880 }
881 }
882}
883
884#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
890pub struct WorkerCapabilities {
891 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub rustc_version: Option<String>,
894 #[serde(default, skip_serializing_if = "Vec::is_empty")]
897 pub rustup_toolchains: Vec<String>,
898 #[serde(default, skip_serializing_if = "Vec::is_empty")]
902 pub rustup_components: Vec<String>,
903 #[serde(default, skip_serializing_if = "Vec::is_empty")]
909 pub probe_warnings: Vec<String>,
910 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub bun_version: Option<String>,
913 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub node_version: Option<String>,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
918 pub npm_version: Option<String>,
919 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub nix_version: Option<String>,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub go_version: Option<String>,
928 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub zig_version: Option<String>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
937 pub cargo_zigbuild_version: Option<String>,
938 #[serde(default, skip_serializing_if = "Option::is_none")]
945 pub cpu_microarch_level: Option<u8>,
946 #[serde(default, skip_serializing_if = "Vec::is_empty")]
953 pub tools_present: Vec<String>,
954 #[serde(default, skip_serializing_if = "Vec::is_empty")]
960 pub tools_absent: Vec<String>,
961
962 #[serde(default, skip_serializing_if = "Option::is_none")]
965 pub num_cpus: Option<u32>,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
968 pub load_avg_1: Option<f64>,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub load_avg_5: Option<f64>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub load_avg_15: Option<f64>,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub disk_free_gb: Option<f64>,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub disk_total_gb: Option<f64>,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub build_disk_free_gb: Option<f64>,
989 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub build_disk_total_gb: Option<f64>,
992 #[serde(default, skip_serializing_if = "Option::is_none")]
997 pub projects_root_ok: Option<bool>,
998 #[serde(default, skip_serializing_if = "Option::is_none")]
1000 pub projects_root_issue: Option<String>,
1001 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub projects_root_checked_at_unix_ms: Option<i64>,
1004}
1005
1006impl WorkerCapabilities {
1007 pub fn new() -> Self {
1009 Self::default()
1010 }
1011
1012 pub fn mock_with_rust() -> Self {
1016 Self {
1017 rustc_version: Some("1.85.0-nightly (mock)".to_string()),
1018 projects_root_ok: Some(true),
1019 ..Self::default()
1020 }
1021 }
1022
1023 pub fn is_pre_v3_x86(&self) -> bool {
1028 matches!(self.cpu_microarch_level, Some(1 | 2))
1029 }
1030
1031 pub fn has_bun(&self) -> bool {
1033 self.bun_version.is_some()
1034 }
1035
1036 pub fn has_node(&self) -> bool {
1038 self.node_version.is_some()
1039 }
1040
1041 pub fn has_rust(&self) -> bool {
1043 self.rustc_version.is_some()
1044 }
1045
1046 pub fn has_rustup_component(&self, toolchain: &str, component: &str) -> bool {
1053 let owner_prefix = format!("{toolchain}-");
1054 self.rustup_components.iter().any(|fact| {
1055 fact.split_once(':').is_some_and(|(owner, name)| {
1056 name == component && (owner == toolchain || owner.starts_with(&owner_prefix))
1057 })
1058 })
1059 }
1060
1061 pub fn has_nix(&self) -> bool {
1063 self.nix_version.is_some()
1064 }
1065
1066 pub fn has_go(&self) -> bool {
1068 self.go_version.is_some()
1069 }
1070
1071 pub fn has_zig(&self) -> bool {
1081 self.zig_version.is_some() && self.cargo_zigbuild_version.is_some()
1082 }
1083
1084 pub fn load_per_core(&self) -> Option<f64> {
1087 match (self.load_avg_1, self.num_cpus) {
1088 (Some(load), Some(cpus)) if cpus > 0 => Some(load / cpus as f64),
1089 _ => None,
1090 }
1091 }
1092
1093 pub fn is_high_load(&self, max_load_per_core: f64) -> Option<bool> {
1096 self.load_per_core().map(|lpc| lpc > max_load_per_core)
1097 }
1098
1099 pub fn build_disk_gb(&self) -> (Option<f64>, Option<f64>) {
1107 if self.build_disk_free_gb.is_some() {
1108 (self.build_disk_free_gb, self.build_disk_total_gb)
1109 } else {
1110 (self.disk_free_gb, self.disk_total_gb)
1111 }
1112 }
1113
1114 pub fn is_low_disk(&self, min_free_gb: f64) -> Option<bool> {
1117 self.build_disk_gb().0.map(|free| free < min_free_gb)
1118 }
1119
1120 pub fn is_topology_healthy(&self) -> Option<bool> {
1124 self.projects_root_ok
1125 }
1126}
1127
1128#[must_use]
1137pub fn x86_64_microarch_level_from_flags(flags: &str) -> u8 {
1138 let set: std::collections::HashSet<&str> = flags.split_whitespace().collect();
1139 let has_all = |required: &[&str]| required.iter().all(|flag| set.contains(flag));
1140 const V2: &[&str] = &["cx16", "lahf_lm", "popcnt", "sse4_1", "sse4_2", "ssse3"];
1142 const V3: &[&str] = &[
1144 "abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe", "xsave",
1145 ];
1146 const V4: &[&str] = &["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"];
1148 if !has_all(V2) {
1149 return 1;
1150 }
1151 if !has_all(V3) {
1152 return 2;
1153 }
1154 if !has_all(V4) {
1155 return 3;
1156 }
1157 4
1158}
1159
1160#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1173pub struct PathTopologyConfig {
1174 #[serde(default)]
1177 pub canonical_root: Option<String>,
1178
1179 #[serde(default)]
1182 pub alias_root: Option<String>,
1183}
1184
1185impl PathTopologyConfig {
1186 pub fn to_policy(&self) -> crate::path_topology::PathTopologyPolicy {
1192 let canonical = self
1193 .canonical_root
1194 .as_deref()
1195 .filter(|s| !s.is_empty())
1196 .map(|s| shellexpand::tilde(s).into_owned())
1197 .unwrap_or_else(|| crate::path_topology::DEFAULT_CANONICAL_PROJECT_ROOT.to_string());
1198 let alias = self
1199 .alias_root
1200 .as_deref()
1201 .filter(|s| !s.is_empty())
1202 .map(|s| shellexpand::tilde(s).into_owned())
1203 .unwrap_or_else(|| crate::path_topology::DEFAULT_ALIAS_PROJECT_ROOT.to_string());
1204 crate::path_topology::PathTopologyPolicy::new(
1205 PathBuf::from(canonical),
1206 PathBuf::from(alias),
1207 )
1208 }
1209}
1210
1211#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1213pub struct RchConfig {
1214 #[serde(default)]
1215 pub general: GeneralConfig,
1216 #[serde(default)]
1217 pub compilation: CompilationConfig,
1218 #[serde(default)]
1219 pub transfer: TransferConfig,
1220 #[serde(default)]
1221 pub environment: EnvironmentConfig,
1222 #[serde(default)]
1223 pub circuit: CircuitBreakerConfig,
1224 #[serde(default)]
1225 pub output: OutputConfig,
1226 #[serde(default)]
1227 pub self_healing: SelfHealingConfig,
1228 #[serde(default)]
1229 pub self_test: SelfTestConfig,
1230 #[serde(default)]
1232 pub selection: SelectionConfig,
1233 #[serde(default)]
1235 pub execution: ExecutionConfig,
1236 #[serde(default)]
1238 pub alerts: AlertsConfig,
1239 #[serde(default)]
1241 pub fleet: FleetConfig,
1242 #[serde(default)]
1244 pub path_topology: PathTopologyConfig,
1245 #[serde(default)]
1247 pub doctor: DoctorConfig,
1248 #[serde(default)]
1253 pub remediation: crate::remediation_config::RemediationConfig,
1254 #[serde(default)]
1257 pub layer0: Layer0Config,
1258 #[serde(default)]
1261 pub api: ApiConfig,
1262 #[serde(default)]
1264 pub dashboard: DashboardConfig,
1265 #[serde(default)]
1267 pub jobs: JobsConfig,
1268}
1269
1270#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1272pub struct JobsConfig {
1273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1283 pub required_tools: Vec<String>,
1284}
1285
1286#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1303#[serde(default)]
1304pub struct ApiConfig {
1305 pub bind: String,
1309 pub token: Option<String>,
1311 pub token_file: Option<String>,
1313 pub no_token: bool,
1317 pub allow_any_addr: bool,
1319}
1320
1321#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1323#[serde(default)]
1324pub struct DashboardConfig {
1325 pub url: Option<String>,
1327}
1328
1329#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1335pub struct DoctorConfig {
1336 #[serde(default)]
1338 pub webhooks: DoctorWebhooksConfig,
1339}
1340
1341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1347pub struct DoctorWebhooksConfig {
1348 #[serde(default)]
1350 pub endpoints: Vec<DoctorWebhookEndpoint>,
1351 #[serde(default = "default_webhook_max_inflight")]
1356 pub max_inflight: usize,
1357}
1358
1359impl Default for DoctorWebhooksConfig {
1363 fn default() -> Self {
1364 Self {
1365 endpoints: Vec::new(),
1366 max_inflight: default_webhook_max_inflight(),
1367 }
1368 }
1369}
1370
1371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1379pub struct DoctorWebhookEndpoint {
1380 pub name: String,
1382 pub url: String,
1384 #[serde(default)]
1386 pub format: DoctorWebhookFormat,
1387 #[serde(default)]
1392 pub on_transitions: Vec<String>,
1393 #[serde(default = "default_webhook_retry_max")]
1395 pub retry_max: u32,
1396 #[serde(default = "default_webhook_retry_backoff_ms")]
1398 pub retry_backoff_ms: u64,
1399 #[serde(default = "default_webhook_timeout_ms")]
1401 pub timeout_ms: u64,
1402 #[serde(default = "default_true")]
1404 pub enabled: bool,
1405 #[serde(default)]
1408 pub routing_key_env: Option<String>,
1409 #[serde(default)]
1412 pub bearer_token_env: Option<String>,
1413 #[serde(default)]
1417 pub signing_secret_env: Option<String>,
1418}
1419
1420#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1422#[serde(rename_all = "snake_case")]
1423pub enum DoctorWebhookFormat {
1424 #[default]
1426 GenericJson,
1427 Slack,
1429 Pagerduty,
1431}
1432
1433fn default_webhook_max_inflight() -> usize {
1434 16
1435}
1436
1437fn default_webhook_retry_max() -> u32 {
1438 3
1439}
1440
1441fn default_webhook_retry_backoff_ms() -> u64 {
1442 200
1443}
1444
1445fn default_webhook_timeout_ms() -> u64 {
1446 5000
1447}
1448
1449#[derive(Debug, Clone, Serialize, Deserialize)]
1451pub struct AlertsConfig {
1452 #[serde(default = "default_true")]
1454 pub enabled: bool,
1455 #[serde(default = "default_alert_suppress_duplicates_secs")]
1457 pub suppress_duplicates_secs: u64,
1458 #[serde(default = "default_alert_cleared_retention_secs")]
1463 pub cleared_retention_secs: u64,
1464 #[serde(default)]
1466 pub webhook: Option<WebhookConfig>,
1467}
1468
1469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1471pub struct WebhookConfig {
1472 pub url: Option<String>,
1474 #[serde(default)]
1476 pub secret: Option<String>,
1477 #[serde(default = "default_webhook_timeout_secs")]
1479 pub timeout_secs: u64,
1480 #[serde(default = "default_webhook_retry_count")]
1482 pub retry_count: u32,
1483 #[serde(default)]
1486 pub events: Vec<String>,
1487}
1488
1489fn default_webhook_timeout_secs() -> u64 {
1490 5
1491}
1492
1493fn default_webhook_retry_count() -> u32 {
1494 3
1495}
1496
1497fn default_alert_suppress_duplicates_secs() -> u64 {
1498 300
1499}
1500
1501fn default_alert_cleared_retention_secs() -> u64 {
1502 300
1503}
1504
1505impl Default for AlertsConfig {
1506 fn default() -> Self {
1507 Self {
1508 enabled: true,
1509 suppress_duplicates_secs: default_alert_suppress_duplicates_secs(),
1510 cleared_retention_secs: default_alert_cleared_retention_secs(),
1511 webhook: None,
1512 }
1513 }
1514}
1515
1516#[derive(Debug, Clone, Serialize, Deserialize)]
1521pub struct FleetConfig {
1522 #[serde(default = "default_fleet_ssh_connect_timeout_secs")]
1524 pub ssh_connect_timeout_secs: u64,
1525 #[serde(default = "default_fleet_ssh_command_timeout_secs")]
1527 pub ssh_command_timeout_secs: u64,
1528 #[serde(default = "default_fleet_min_disk_space_mb")]
1530 pub min_disk_space_mb: u64,
1531 #[serde(default = "default_fleet_max_load_average")]
1536 pub max_load_average: f64,
1537 #[serde(default = "default_fleet_max_concurrent_workers")]
1539 pub max_concurrent_workers: usize,
1540 #[serde(default = "default_fleet_retry_count")]
1542 pub retry_count: u32,
1543 #[serde(default = "default_fleet_retry_delay_ms")]
1545 pub retry_delay_ms: u64,
1546}
1547
1548fn default_fleet_ssh_connect_timeout_secs() -> u64 {
1549 10
1550}
1551
1552fn default_fleet_ssh_command_timeout_secs() -> u64 {
1553 30
1554}
1555
1556fn default_fleet_min_disk_space_mb() -> u64 {
1557 500
1558}
1559
1560fn default_fleet_max_load_average() -> f64 {
1561 10.0
1562}
1563
1564fn default_fleet_max_concurrent_workers() -> usize {
1565 10
1566}
1567
1568fn default_fleet_retry_count() -> u32 {
1569 2
1570}
1571
1572fn default_fleet_retry_delay_ms() -> u64 {
1573 1000
1574}
1575
1576impl Default for FleetConfig {
1577 fn default() -> Self {
1578 Self {
1579 ssh_connect_timeout_secs: default_fleet_ssh_connect_timeout_secs(),
1580 ssh_command_timeout_secs: default_fleet_ssh_command_timeout_secs(),
1581 min_disk_space_mb: default_fleet_min_disk_space_mb(),
1582 max_load_average: default_fleet_max_load_average(),
1583 max_concurrent_workers: default_fleet_max_concurrent_workers(),
1584 retry_count: default_fleet_retry_count(),
1585 retry_delay_ms: default_fleet_retry_delay_ms(),
1586 }
1587 }
1588}
1589
1590impl FleetConfig {
1591 pub fn ssh_connect_timeout(&self) -> std::time::Duration {
1593 std::time::Duration::from_secs(self.ssh_connect_timeout_secs)
1594 }
1595
1596 pub fn ssh_command_timeout(&self) -> std::time::Duration {
1598 std::time::Duration::from_secs(self.ssh_command_timeout_secs)
1599 }
1600
1601 pub fn retry_delay(&self) -> std::time::Duration {
1603 std::time::Duration::from_millis(self.retry_delay_ms)
1604 }
1605}
1606
1607#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1610#[serde(rename_all = "lowercase")]
1611pub enum BoxRole {
1612 Dispatcher,
1617 Worker,
1620 #[default]
1622 Hybrid,
1623}
1624
1625impl BoxRole {
1626 #[must_use]
1628 pub fn as_str(self) -> &'static str {
1629 match self {
1630 BoxRole::Dispatcher => "dispatcher",
1631 BoxRole::Worker => "worker",
1632 BoxRole::Hybrid => "hybrid",
1633 }
1634 }
1635}
1636
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638pub struct GeneralConfig {
1639 #[serde(default = "default_true")]
1641 pub enabled: bool,
1642 #[serde(default)]
1645 pub role: BoxRole,
1646 #[serde(default)]
1650 pub force_local: bool,
1651 #[serde(default)]
1656 pub force_remote: bool,
1657 #[serde(default = "default_log_level")]
1659 pub log_level: String,
1660 #[serde(default = "default_socket_path")]
1662 pub socket_path: String,
1663}
1664
1665#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1667pub struct EnvironmentConfig {
1668 #[serde(default)]
1670 pub allowlist: Vec<String>,
1671}
1672
1673impl Default for GeneralConfig {
1674 fn default() -> Self {
1675 Self {
1676 enabled: true,
1677 role: BoxRole::default(),
1678 force_local: false,
1679 force_remote: false,
1680 log_level: "info".to_string(),
1681 socket_path: default_socket_path(),
1682 }
1683 }
1684}
1685
1686#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1688#[serde(rename_all = "lowercase")]
1689pub enum OutputVisibility {
1690 #[default]
1691 None,
1692 Summary,
1693 Verbose,
1694}
1695
1696impl std::fmt::Display for OutputVisibility {
1697 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1698 let value = match self {
1699 OutputVisibility::None => "none",
1700 OutputVisibility::Summary => "summary",
1701 OutputVisibility::Verbose => "verbose",
1702 };
1703 write!(f, "{}", value)
1704 }
1705}
1706
1707impl std::str::FromStr for OutputVisibility {
1708 type Err = ();
1709
1710 fn from_str(s: &str) -> Result<Self, Self::Err> {
1711 match s.trim().to_lowercase().as_str() {
1712 "none" | "silent" | "quiet" => Ok(Self::None),
1713 "summary" | "short" => Ok(Self::Summary),
1714 "verbose" | "debug" => Ok(Self::Verbose),
1715 _ => Err(()),
1716 }
1717 }
1718}
1719
1720#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1725#[serde(rename_all = "lowercase")]
1726pub enum ColorMode {
1727 #[default]
1730 Always,
1731 Auto,
1733 Never,
1735}
1736
1737impl std::fmt::Display for ColorMode {
1738 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1739 let value = match self {
1740 ColorMode::Always => "always",
1741 ColorMode::Auto => "auto",
1742 ColorMode::Never => "never",
1743 };
1744 write!(f, "{}", value)
1745 }
1746}
1747
1748impl std::str::FromStr for ColorMode {
1749 type Err = ();
1750
1751 fn from_str(s: &str) -> Result<Self, Self::Err> {
1752 match s.trim().to_lowercase().as_str() {
1753 "always" | "force" | "yes" | "true" => Ok(Self::Always),
1754 "auto" | "detect" => Ok(Self::Auto),
1755 "never" | "none" | "no" | "false" => Ok(Self::Never),
1756 _ => Err(()),
1757 }
1758 }
1759}
1760
1761fn default_color_mode() -> ColorMode {
1762 ColorMode::default()
1763}
1764
1765#[derive(Debug, Clone, Serialize, Deserialize)]
1767pub struct OutputConfig {
1768 #[serde(default = "default_output_visibility")]
1770 pub visibility: OutputVisibility,
1771 #[serde(default)]
1773 pub first_run_complete: bool,
1774 #[serde(default = "default_color_mode")]
1777 pub color_mode: ColorMode,
1778}
1779
1780impl Default for OutputConfig {
1781 fn default() -> Self {
1782 Self {
1783 visibility: OutputVisibility::None,
1784 first_run_complete: false,
1785 color_mode: ColorMode::default(),
1786 }
1787 }
1788}
1789
1790fn default_autostart_cooldown_secs() -> u64 {
1795 30
1796}
1797
1798fn default_autostart_timeout_secs() -> u64 {
1799 3
1800}
1801
1802#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1808#[serde(rename_all = "lowercase")]
1809pub enum SelfHealingLogLevel {
1810 Debug,
1811 #[default]
1812 Info,
1813 Warn,
1814 Error,
1815}
1816
1817impl SelfHealingLogLevel {
1818 pub fn from_env_str(s: &str) -> Option<Self> {
1820 match s.trim().to_ascii_lowercase().as_str() {
1821 "debug" => Some(Self::Debug),
1822 "info" => Some(Self::Info),
1823 "warn" | "warning" => Some(Self::Warn),
1824 "error" => Some(Self::Error),
1825 _ => None,
1826 }
1827 }
1828}
1829
1830#[derive(Debug, Clone, Serialize, Deserialize)]
1832pub struct SelfHealingConfig {
1833 #[serde(default = "default_true")]
1835 pub hook_starts_daemon: bool,
1836 #[serde(default = "default_true")]
1838 pub daemon_installs_hooks: bool,
1839 #[serde(default = "default_autostart_cooldown_secs")]
1841 pub auto_start_cooldown_secs: u64,
1842 #[serde(
1845 default = "default_autostart_timeout_secs",
1846 alias = "daemon_start_timeout"
1847 )]
1848 pub auto_start_timeout_secs: u64,
1849 #[serde(default)]
1855 pub self_healing_log_level: SelfHealingLogLevel,
1856}
1857
1858impl Default for SelfHealingConfig {
1859 fn default() -> Self {
1860 Self {
1861 hook_starts_daemon: default_true(),
1862 daemon_installs_hooks: default_true(),
1863 auto_start_cooldown_secs: default_autostart_cooldown_secs(),
1864 auto_start_timeout_secs: default_autostart_timeout_secs(),
1865 self_healing_log_level: SelfHealingLogLevel::default(),
1866 }
1867 }
1868}
1869
1870impl SelfHealingConfig {
1871 pub fn with_env_overrides(mut self) -> Self {
1880 if let Ok(val) = std::env::var("RCH_NO_SELF_HEALING")
1882 && (val == "1" || val.eq_ignore_ascii_case("true"))
1883 {
1884 self.hook_starts_daemon = false;
1885 self.daemon_installs_hooks = false;
1886 return self;
1887 }
1888
1889 if let Ok(val) = std::env::var("RCH_HOOK_STARTS_DAEMON") {
1891 self.hook_starts_daemon = val != "0" && !val.eq_ignore_ascii_case("false");
1892 }
1893 if let Ok(val) = std::env::var("RCH_DAEMON_INSTALLS_HOOKS") {
1894 self.daemon_installs_hooks = val != "0" && !val.eq_ignore_ascii_case("false");
1895 }
1896
1897 if let Ok(val) = std::env::var("RCH_AUTO_START_TIMEOUT_SECS")
1899 && let Ok(secs) = val.parse()
1900 {
1901 self.auto_start_timeout_secs = secs;
1902 }
1903 if let Ok(val) = std::env::var("RCH_AUTO_START_COOLDOWN_SECS")
1904 && let Ok(secs) = val.parse()
1905 {
1906 self.auto_start_cooldown_secs = secs;
1907 }
1908 if let Ok(val) = std::env::var("RCH_SELF_HEALING_LOG_LEVEL") {
1910 if let Some(level) = SelfHealingLogLevel::from_env_str(&val) {
1911 self.self_healing_log_level = level;
1912 } else {
1913 tracing::warn!(
1914 target: "rch::self_healing",
1915 invalid = %val,
1916 "RCH_SELF_HEALING_LOG_LEVEL has invalid value; using default=info"
1917 );
1918 }
1919 }
1920
1921 self
1922 }
1923}
1924
1925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1931#[serde(rename_all = "snake_case")]
1932pub enum SelfTestFailureAction {
1933 #[default]
1935 Alert,
1936 DisableWorker,
1938 AlertAndDisable,
1940}
1941
1942#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1944#[serde(untagged)]
1945pub enum SelfTestWorkers {
1946 All(String),
1948 List(Vec<WorkerId>),
1950}
1951
1952impl Default for SelfTestWorkers {
1953 fn default() -> Self {
1954 Self::All("all".to_string())
1955 }
1956}
1957
1958impl SelfTestWorkers {
1959 pub fn resolve(&self) -> Option<Vec<WorkerId>> {
1961 match self {
1962 SelfTestWorkers::All(value) => {
1963 if value.eq_ignore_ascii_case("all") {
1964 None
1965 } else {
1966 Some(vec![WorkerId::new(value.clone())])
1967 }
1968 }
1969 SelfTestWorkers::List(list) => Some(list.clone()),
1970 }
1971 }
1972}
1973
1974fn default_self_test_retry_count() -> u32 {
1975 3
1976}
1977
1978fn default_self_test_retry_delay() -> String {
1979 "5m".to_string()
1980}
1981
1982fn default_self_test_enabled() -> bool {
1983 false
1984}
1985
1986#[derive(Debug, Clone, Serialize, Deserialize)]
1988pub struct SelfTestConfig {
1989 #[serde(default = "default_self_test_enabled")]
1991 pub enabled: bool,
1992 #[serde(default)]
1994 pub schedule: Option<String>,
1995 #[serde(default)]
1997 pub interval: Option<String>,
1998 #[serde(default)]
2000 pub workers: SelfTestWorkers,
2001 #[serde(default)]
2003 pub on_failure: SelfTestFailureAction,
2004 #[serde(default = "default_self_test_retry_count")]
2006 pub retry_count: u32,
2007 #[serde(default = "default_self_test_retry_delay")]
2009 pub retry_delay: String,
2010}
2011
2012impl Default for SelfTestConfig {
2013 fn default() -> Self {
2014 Self {
2015 enabled: default_self_test_enabled(),
2016 schedule: None,
2017 interval: None,
2018 workers: SelfTestWorkers::default(),
2019 on_failure: SelfTestFailureAction::default(),
2020 retry_count: default_self_test_retry_count(),
2021 retry_delay: default_self_test_retry_delay(),
2022 }
2023 }
2024}
2025
2026#[derive(Debug, Clone, Serialize, Deserialize)]
2028pub struct CircuitBreakerConfig {
2029 #[serde(default = "default_circuit_failure_threshold")]
2031 pub failure_threshold: u32,
2032 #[serde(default = "default_circuit_success_threshold")]
2034 pub success_threshold: u32,
2035 #[serde(default = "default_circuit_error_rate_threshold")]
2037 pub error_rate_threshold: f64,
2038 #[serde(default = "default_circuit_window_secs")]
2040 pub window_secs: u64,
2041 #[serde(default = "default_circuit_open_cooldown_secs")]
2043 pub open_cooldown_secs: u64,
2044 #[serde(default = "default_circuit_half_open_max_probes")]
2046 pub half_open_max_probes: u32,
2047 #[serde(default = "default_circuit_command_failure_threshold")]
2058 pub command_failure_threshold: u32,
2059 #[serde(default = "default_circuit_open_cooldown_max_secs")]
2067 pub open_cooldown_max_secs: u64,
2068}
2069
2070impl Default for CircuitBreakerConfig {
2071 fn default() -> Self {
2072 Self {
2073 failure_threshold: default_circuit_failure_threshold(),
2074 success_threshold: default_circuit_success_threshold(),
2075 error_rate_threshold: default_circuit_error_rate_threshold(),
2076 window_secs: default_circuit_window_secs(),
2077 open_cooldown_secs: default_circuit_open_cooldown_secs(),
2078 half_open_max_probes: default_circuit_half_open_max_probes(),
2079 command_failure_threshold: default_circuit_command_failure_threshold(),
2080 open_cooldown_max_secs: default_circuit_open_cooldown_max_secs(),
2081 }
2082 }
2083}
2084
2085impl CircuitBreakerConfig {
2086 pub fn cooldown_ms_for_streak(&self, reopen_streak: u32) -> u64 {
2090 let base_ms = self.open_cooldown_secs.saturating_mul(1000);
2091 let cap_ms = self
2092 .open_cooldown_max_secs
2093 .saturating_mul(1000)
2094 .max(base_ms);
2095 let exponent = reopen_streak.saturating_sub(1).min(16);
2097 base_ms.saturating_mul(1u64 << exponent).min(cap_ms)
2098 }
2099}
2100
2101#[derive(Debug, Clone, Default)]
2106pub struct CircuitStats {
2107 state: CircuitState,
2109 consecutive_failures: u32,
2111 consecutive_successes: u32,
2113 window_successes: u32,
2115 window_failures: u32,
2117 opened_at: Option<u64>,
2119 last_state_change: u64,
2121 active_probes: u32,
2123 consecutive_command_failures: u32,
2128 reopen_streak: u32,
2131 recent_results: Vec<bool>,
2134}
2135
2136const CIRCUIT_HISTORY_SIZE: usize = 10;
2138
2139impl CircuitStats {
2140 pub fn new() -> Self {
2142 Self {
2143 state: CircuitState::Closed,
2144 consecutive_failures: 0,
2145 consecutive_successes: 0,
2146 window_successes: 0,
2147 window_failures: 0,
2148 opened_at: None,
2149 last_state_change: Self::now_millis(),
2150 active_probes: 0,
2151 consecutive_command_failures: 0,
2152 reopen_streak: 0,
2153 recent_results: Vec::with_capacity(CIRCUIT_HISTORY_SIZE),
2154 }
2155 }
2156
2157 pub fn consecutive_command_failures(&self) -> u32 {
2160 self.consecutive_command_failures
2161 }
2162
2163 pub fn reopen_streak(&self) -> u32 {
2166 self.reopen_streak
2167 }
2168
2169 fn effective_cooldown_ms(&self, config: &CircuitBreakerConfig) -> u64 {
2171 config.cooldown_ms_for_streak(self.reopen_streak.max(1))
2172 }
2173
2174 pub fn state(&self) -> CircuitState {
2176 self.state
2177 }
2178
2179 pub fn opened_at(&self) -> Option<u64> {
2181 self.opened_at
2182 }
2183
2184 pub fn last_state_change(&self) -> u64 {
2186 self.last_state_change
2187 }
2188
2189 pub fn consecutive_failures(&self) -> u32 {
2191 self.consecutive_failures
2192 }
2193
2194 pub fn error_rate(&self) -> f64 {
2204 if self.recent_results.is_empty() {
2205 return 0.0;
2206 }
2207 let failures = self.recent_results.iter().filter(|&&ok| !ok).count();
2208 failures as f64 / self.recent_results.len() as f64
2209 }
2210
2211 pub fn record_success(&mut self) {
2216 self.window_successes += 1;
2217 self.consecutive_failures = 0;
2218 self.push_result(true);
2219
2220 if self.state == CircuitState::HalfOpen {
2221 self.consecutive_successes += 1;
2222 if self.active_probes > 0 {
2223 self.active_probes -= 1;
2224 }
2225 }
2226 }
2227
2228 pub fn record_failure(&mut self) {
2233 self.window_failures += 1;
2234 self.consecutive_failures += 1;
2235 self.push_result(false);
2236
2237 if self.state == CircuitState::HalfOpen {
2238 self.consecutive_successes = 0;
2239 if self.active_probes > 0 {
2240 self.active_probes -= 1;
2241 }
2242 }
2243 }
2244
2245 pub fn should_open(&self, config: &CircuitBreakerConfig) -> bool {
2251 if self.state != CircuitState::Closed {
2252 return false;
2253 }
2254
2255 if self.consecutive_failures >= config.failure_threshold {
2257 return true;
2258 }
2259
2260 if self.recent_results.len() >= 5 && self.error_rate() >= config.error_rate_threshold {
2264 return true;
2265 }
2266
2267 false
2268 }
2269
2270 pub fn should_half_open(&self, config: &CircuitBreakerConfig) -> bool {
2274 if self.state != CircuitState::Open {
2275 return false;
2276 }
2277
2278 let now = Self::now_millis();
2279 let cooldown_ms = self.effective_cooldown_ms(config);
2280
2281 if let Some(opened_at) = self.opened_at {
2282 now.saturating_sub(opened_at) >= cooldown_ms
2283 } else {
2284 false
2285 }
2286 }
2287
2288 pub fn should_close(&self, config: &CircuitBreakerConfig) -> bool {
2297 if self.state != CircuitState::HalfOpen {
2298 return false;
2299 }
2300
2301 self.consecutive_successes >= config.success_threshold
2302 && self.consecutive_command_failures == 0
2303 }
2304
2305 pub fn can_probe(&self, config: &CircuitBreakerConfig) -> bool {
2309 if self.state != CircuitState::HalfOpen {
2310 return false;
2311 }
2312
2313 self.active_probes < config.half_open_max_probes
2314 }
2315
2316 pub fn start_probe(&mut self, config: &CircuitBreakerConfig) -> bool {
2320 if !self.can_probe(config) {
2321 return false;
2322 }
2323 self.active_probes += 1;
2324 true
2325 }
2326
2327 pub fn open(&mut self) {
2329 if self.state != CircuitState::Open {
2330 self.state = CircuitState::Open;
2331 self.opened_at = Some(Self::now_millis());
2332 self.last_state_change = Self::now_millis();
2333 self.consecutive_successes = 0;
2334 self.active_probes = 0;
2335 self.reopen_streak = self.reopen_streak.saturating_add(1);
2338 }
2339 }
2340
2341 pub fn half_open(&mut self) {
2343 if self.state != CircuitState::HalfOpen {
2344 self.state = CircuitState::HalfOpen;
2345 self.last_state_change = Self::now_millis();
2346 self.consecutive_successes = 0;
2347 self.active_probes = 0;
2348 }
2349 }
2350
2351 pub fn close(&mut self) {
2353 if self.state != CircuitState::Closed {
2354 self.state = CircuitState::Closed;
2355 self.last_state_change = Self::now_millis();
2356 self.opened_at = None;
2357 self.consecutive_failures = 0;
2358 self.consecutive_successes = 0;
2359 self.active_probes = 0;
2360 self.consecutive_command_failures = 0;
2361 self.reopen_streak = 0;
2362 self.window_successes = 0;
2368 self.window_failures = 0;
2369 self.recent_results.clear();
2370 }
2371 }
2372
2373 pub fn reset_window(&mut self) {
2379 self.window_successes = 0;
2380 self.window_failures = 0;
2381 self.recent_results.clear();
2382 }
2383
2384 pub fn recent_results(&self) -> &[bool] {
2389 &self.recent_results
2390 }
2391
2392 pub fn recovery_remaining_secs(&self, config: &CircuitBreakerConfig) -> Option<u64> {
2396 if self.state != CircuitState::Open {
2397 return None;
2398 }
2399
2400 let now = Self::now_millis();
2401 let cooldown_ms = self.effective_cooldown_ms(config);
2402
2403 if let Some(opened_at) = self.opened_at {
2404 let elapsed_ms = now.saturating_sub(opened_at);
2405 if elapsed_ms < cooldown_ms {
2406 return Some((cooldown_ms - elapsed_ms) / 1000);
2407 }
2408 }
2409 None
2410 }
2411
2412 fn push_result(&mut self, success: bool) {
2414 if self.recent_results.len() >= CIRCUIT_HISTORY_SIZE {
2415 self.recent_results.remove(0);
2416 }
2417 self.recent_results.push(success);
2418 }
2419
2420 fn now_millis() -> u64 {
2421 std::time::SystemTime::now()
2422 .duration_since(std::time::UNIX_EPOCH)
2423 .map(|d| d.as_millis() as u64)
2424 .unwrap_or(0)
2425 }
2426
2427 pub fn apply_health_outcome(
2453 &mut self,
2454 healthy: bool,
2455 transient: bool,
2456 config: &CircuitBreakerConfig,
2457 ) -> CircuitState {
2458 if healthy {
2459 if self.state == CircuitState::Open && self.should_half_open(config) {
2463 self.half_open();
2464 }
2465 self.record_success();
2466 if self.should_close(config) {
2467 self.close();
2468 }
2469 } else {
2470 let was_half_open = self.state == CircuitState::HalfOpen;
2471 self.record_failure();
2472 if self.should_open(config) {
2473 self.open();
2474 } else if was_half_open && !transient {
2475 self.open();
2479 }
2480 }
2481
2482 if self.state == CircuitState::Open && self.should_half_open(config) {
2485 self.half_open();
2486 }
2487
2488 self.state
2489 }
2490
2491 pub fn apply_command_outcome(
2515 &mut self,
2516 success: bool,
2517 config: &CircuitBreakerConfig,
2518 ) -> CircuitState {
2519 if success {
2520 self.consecutive_command_failures = 0;
2521 return self.apply_health_outcome(true, false, config);
2522 }
2523
2524 self.consecutive_command_failures = self.consecutive_command_failures.saturating_add(1);
2525 let was_half_open = self.state == CircuitState::HalfOpen;
2526 self.record_failure();
2527 let threshold = config.command_failure_threshold.max(1);
2528 if was_half_open
2529 || self.should_open(config)
2530 || (self.state == CircuitState::Closed
2531 && self.consecutive_command_failures >= threshold)
2532 {
2533 self.open();
2534 }
2535
2536 if self.state == CircuitState::Open && self.should_half_open(config) {
2537 self.half_open();
2538 }
2539
2540 self.state
2541 }
2542}
2543
2544#[derive(Debug, Clone, Serialize, Deserialize)]
2545pub struct CompilationConfig {
2546 #[serde(default = "default_confidence")]
2548 pub confidence_threshold: f64,
2549 #[serde(default = "default_min_local_time")]
2552 pub min_local_time_ms: u64,
2553 #[serde(default = "default_speedup_threshold")]
2557 pub remote_speedup_threshold: f64,
2558 #[serde(default)]
2564 pub local_slots: u32,
2565 #[serde(default = "default_build_slots")]
2567 pub build_slots: u32,
2568 #[serde(default = "default_test_slots")]
2571 pub test_slots: u32,
2572 #[serde(default = "default_check_slots")]
2575 pub check_slots: u32,
2576 #[serde(default)]
2582 pub disk_headroom_gib: u32,
2583 #[serde(default = "default_build_timeout")]
2586 pub build_timeout_sec: u64,
2587 #[serde(default = "default_test_timeout")]
2590 pub test_timeout_sec: u64,
2591 #[serde(default = "default_bun_timeout")]
2595 pub bun_timeout_sec: u64,
2596 #[serde(default = "default_external_timeout_enabled")]
2600 pub external_timeout_enabled: bool,
2601 #[serde(default = "default_allow_local_fallback")]
2614 pub allow_local_fallback: bool,
2615 #[serde(default)]
2626 pub remote_build_jobs: RemoteBuildJobs,
2627}
2628
2629#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2641pub enum RemoteBuildJobs {
2642 #[default]
2644 Auto,
2645 Off,
2647 Fixed(u32),
2649}
2650
2651impl RemoteBuildJobs {
2652 pub fn parse(value: &str) -> Result<Self, String> {
2654 let trimmed = value.trim();
2655 match trimmed.to_ascii_lowercase().as_str() {
2656 "auto" => Ok(Self::Auto),
2657 "off" | "none" | "disabled" => Ok(Self::Off),
2658 _ => match trimmed.parse::<u32>() {
2659 Ok(0) => Err(
2660 "remote_build_jobs must be >= 1 (cargo rejects `-j 0`); use `off` to disable"
2661 .to_string(),
2662 ),
2663 Ok(n) => Ok(Self::Fixed(n)),
2664 Err(_) => Err(format!(
2665 "'{trimmed}' is not a valid remote_build_jobs value (expected `auto`, `off`, or a positive integer)"
2666 )),
2667 },
2668 }
2669 }
2670}
2671
2672impl std::fmt::Display for RemoteBuildJobs {
2673 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2674 match self {
2675 Self::Auto => f.write_str("auto"),
2676 Self::Off => f.write_str("off"),
2677 Self::Fixed(n) => write!(f, "{n}"),
2678 }
2679 }
2680}
2681
2682impl std::str::FromStr for RemoteBuildJobs {
2683 type Err = String;
2684
2685 fn from_str(value: &str) -> Result<Self, Self::Err> {
2686 Self::parse(value)
2687 }
2688}
2689
2690impl Serialize for RemoteBuildJobs {
2691 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2692 match self {
2693 Self::Auto => serializer.serialize_str("auto"),
2694 Self::Off => serializer.serialize_str("off"),
2695 Self::Fixed(n) => serializer.serialize_u32(*n),
2696 }
2697 }
2698}
2699
2700impl<'de> Deserialize<'de> for RemoteBuildJobs {
2701 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2702 #[derive(Deserialize)]
2703 #[serde(untagged)]
2704 enum Raw {
2705 Count(u64),
2706 Spelling(String),
2707 }
2708 match Raw::deserialize(deserializer)? {
2709 Raw::Count(n) => {
2710 let n = u32::try_from(n).map_err(|_| {
2711 serde::de::Error::custom(format!("remote_build_jobs {n} is out of range"))
2712 })?;
2713 Self::parse(&n.to_string()).map_err(serde::de::Error::custom)
2714 }
2715 Raw::Spelling(s) => Self::parse(&s).map_err(serde::de::Error::custom),
2716 }
2717 }
2718}
2719
2720impl Default for CompilationConfig {
2721 fn default() -> Self {
2722 Self {
2723 confidence_threshold: 0.85,
2724 min_local_time_ms: 2000,
2725 remote_speedup_threshold: default_speedup_threshold(),
2726 local_slots: 0,
2727 build_slots: default_build_slots(),
2728 test_slots: default_test_slots(),
2729 check_slots: default_check_slots(),
2730 disk_headroom_gib: 0,
2731 build_timeout_sec: default_build_timeout(),
2732 test_timeout_sec: default_test_timeout(),
2733 bun_timeout_sec: default_bun_timeout(),
2734 external_timeout_enabled: default_external_timeout_enabled(),
2735 allow_local_fallback: default_allow_local_fallback(),
2736 remote_build_jobs: RemoteBuildJobs::default(),
2737 }
2738 }
2739}
2740
2741fn default_build_slots() -> u32 {
2742 4
2743}
2744
2745fn default_test_slots() -> u32 {
2746 8
2747}
2748
2749fn default_check_slots() -> u32 {
2750 2
2751}
2752
2753fn default_build_timeout() -> u64 {
2756 300
2757}
2758
2759fn default_test_timeout() -> u64 {
2762 1800
2763}
2764
2765fn default_bun_timeout() -> u64 {
2769 600
2770}
2771
2772fn default_external_timeout_enabled() -> bool {
2774 true
2775}
2776
2777fn default_allow_local_fallback() -> bool {
2781 true
2782}
2783
2784impl CompilationConfig {
2785 pub fn timeout_for_kind(&self, kind: Option<CompilationKind>) -> std::time::Duration {
2791 let secs = match kind {
2792 Some(CompilationKind::BunTest) | Some(CompilationKind::BunTypecheck) => {
2794 self.bun_timeout_sec
2795 }
2796 Some(CompilationKind::CargoTest) | Some(CompilationKind::CargoNextest) => {
2798 self.test_timeout_sec
2799 }
2800 Some(CompilationKind::NixBuild) => self.test_timeout_sec,
2804 Some(CompilationKind::GoTest) => self.test_timeout_sec,
2807 _ => self.build_timeout_sec,
2809 };
2810 std::time::Duration::from_secs(secs)
2811 }
2812
2813 pub fn external_timeout_enabled(&self) -> bool {
2815 self.external_timeout_enabled
2816 }
2817}
2818
2819pub const LAYER0_PACK_VERSION: &str = "2";
2825
2826#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
2837#[serde(default)]
2838pub struct Layer0Config {
2839 pub enabled: bool,
2841 pub release_lto_thin: bool,
2843 pub release_codegen_units_1: bool,
2846 pub debug_line_tables_only: bool,
2853}
2854
2855impl Layer0Config {
2856 #[must_use]
2862 pub fn active_env(&self) -> Vec<(&'static str, &'static str)> {
2863 if !self.enabled {
2864 return Vec::new();
2865 }
2866 let mut env = Vec::new();
2867 if self.release_lto_thin {
2868 env.push(("CARGO_PROFILE_RELEASE_LTO", "thin"));
2869 }
2870 if self.release_codegen_units_1 {
2871 env.push(("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"));
2872 }
2873 if self.debug_line_tables_only {
2874 env.push(("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"));
2875 env.push(("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"));
2876 }
2877 env
2878 }
2879}
2880
2881#[derive(Debug, Clone, Serialize, Deserialize)]
2882pub struct TransferConfig {
2883 #[serde(default, skip_serializing_if = "Option::is_none")]
2886 pub retrieval_staging_dir: Option<PathBuf>,
2887 #[serde(default)]
2890 pub retrieval_min_free_bytes: u64,
2891 #[serde(default = "default_compression")]
2893 pub compression_level: u32,
2894 #[serde(default = "default_excludes")]
2896 pub exclude_patterns: Vec<String>,
2897 #[serde(default, skip_serializing_if = "Option::is_none")]
2901 pub ssh_server_alive_interval_secs: Option<u64>,
2902 #[serde(default, skip_serializing_if = "Option::is_none")]
2907 pub ssh_control_persist_secs: Option<u64>,
2908 #[serde(default = "default_remote_base")]
2919 pub remote_base: String,
2920 #[serde(default, skip_serializing_if = "Option::is_none")]
2926 pub sync_timeout_ms: Option<u64>,
2927 #[serde(default = "default_source_sync_silence_timeout_secs")]
2939 pub source_sync_silence_timeout_secs: u64,
2940 #[serde(default)]
2942 pub retry: RetryConfig,
2943 #[serde(default)]
2950 pub verify_artifacts: bool,
2951 #[serde(default = "default_verify_max_size")]
2956 pub verify_max_size_bytes: u64,
2957
2958 #[serde(default, skip_serializing_if = "Option::is_none")]
2967 pub max_transfer_mb: Option<u64>,
2968
2969 #[serde(default, skip_serializing_if = "Option::is_none")]
2975 pub max_transfer_time_ms: Option<u64>,
2976
2977 #[serde(default, skip_serializing_if = "Option::is_none")]
2982 pub bwlimit_kbps: Option<u64>,
2983
2984 #[serde(default, skip_serializing_if = "Option::is_none")]
2993 pub rsync_bin: Option<String>,
2994
2995 #[serde(default, skip_serializing_if = "Option::is_none")]
3000 pub estimated_bandwidth_bps: Option<u64>,
3001
3002 #[serde(default)]
3015 pub adaptive_compression: bool,
3016
3017 #[serde(default = "default_min_compression")]
3022 pub min_compression_level: u32,
3023
3024 #[serde(default = "default_max_compression")]
3029 pub max_compression_level: u32,
3030}
3031
3032impl Default for TransferConfig {
3033 fn default() -> Self {
3034 Self {
3035 retrieval_staging_dir: None,
3036 retrieval_min_free_bytes: 0,
3037 compression_level: 3,
3038 exclude_patterns: default_excludes(),
3039 ssh_server_alive_interval_secs: None,
3040 ssh_control_persist_secs: None,
3041 remote_base: default_remote_base(),
3042 sync_timeout_ms: None,
3043 source_sync_silence_timeout_secs: default_source_sync_silence_timeout_secs(),
3044 retry: RetryConfig::default(),
3045 verify_artifacts: false,
3046 verify_max_size_bytes: default_verify_max_size(),
3047 max_transfer_mb: None,
3049 max_transfer_time_ms: None,
3050 bwlimit_kbps: None,
3051 rsync_bin: None,
3052 estimated_bandwidth_bps: None,
3053 adaptive_compression: false,
3055 min_compression_level: default_min_compression(),
3056 max_compression_level: default_max_compression(),
3057 }
3058 }
3059}
3060
3061impl TransferConfig {
3062 pub const MIN_SYNC_TIMEOUT_MS: u64 = 1_000;
3064 pub const MAX_SYNC_TIMEOUT_MS: u64 = 3_600_000;
3066 pub const DEFAULT_SYNC_TIMEOUT_FLOOR_MS: u64 = 30_000;
3068 const DEFAULT_SYNC_BYTES_PER_SECOND: u64 = 1024 * 1024;
3069
3070 #[must_use]
3072 pub fn valid_sync_timeout_ms(value: u64) -> bool {
3073 (Self::MIN_SYNC_TIMEOUT_MS..=Self::MAX_SYNC_TIMEOUT_MS).contains(&value)
3074 }
3075
3076 #[must_use]
3083 pub fn sync_timeout_for_payload(&self, payload_bytes: u64) -> std::time::Duration {
3084 let timeout_ms = self
3085 .sync_timeout_ms
3086 .filter(|value| Self::valid_sync_timeout_ms(*value))
3087 .unwrap_or_else(|| {
3088 let transfer_seconds = payload_bytes
3089 .saturating_add(Self::DEFAULT_SYNC_BYTES_PER_SECOND - 1)
3090 / Self::DEFAULT_SYNC_BYTES_PER_SECOND;
3091 Self::DEFAULT_SYNC_TIMEOUT_FLOOR_MS
3092 .saturating_add(transfer_seconds.saturating_mul(1_000))
3093 .min(Self::MAX_SYNC_TIMEOUT_MS)
3094 });
3095 std::time::Duration::from_millis(timeout_ms)
3096 }
3097
3098 pub fn select_compression_level(&self, estimated_bytes: Option<u64>) -> u32 {
3110 if !self.adaptive_compression {
3111 return self.compression_level;
3112 }
3113
3114 let Some(bytes) = estimated_bytes else {
3115 return self.compression_level;
3117 };
3118
3119 const SMALL_THRESHOLD: u64 = 10_000_000; const LARGE_THRESHOLD: u64 = 200_000_000; let level = if bytes < SMALL_THRESHOLD {
3124 1 } else if bytes < LARGE_THRESHOLD {
3126 3 } else {
3128 7 };
3130
3131 level.clamp(self.min_compression_level, self.max_compression_level)
3133 }
3134}
3135
3136#[rustfmt::skip]
3150pub const BUILTIN_EXECUTION_BASES: &[&str] = &[
3151 "cargo",
3153 "rustc",
3154 "nextest",
3156 "gcc",
3158 "g++",
3159 "clang",
3160 "clang++",
3161 "cc",
3162 "c++",
3163 "make",
3165 "cmake",
3166 "ninja",
3167 "meson",
3168 "bun",
3170 "nix",
3172 "go",
3174 "tsc",
3176];
3177
3178fn default_execution_allowlist() -> Vec<String> {
3179 BUILTIN_EXECUTION_BASES
3180 .iter()
3181 .map(|base| (*base).to_string())
3182 .collect()
3183}
3184
3185#[derive(Debug, Clone, Serialize, Deserialize)]
3190pub struct ExecutionConfig {
3191 #[serde(default = "default_execution_allowlist")]
3200 pub allowlist: Vec<String>,
3201}
3202
3203impl Default for ExecutionConfig {
3204 fn default() -> Self {
3205 Self {
3206 allowlist: default_execution_allowlist(),
3207 }
3208 }
3209}
3210
3211impl ExecutionConfig {
3212 pub fn is_allowed(&self, command_base: &str) -> bool {
3214 self.allowlist
3215 .iter()
3216 .any(|allowed| allowed.eq_ignore_ascii_case(command_base))
3217 }
3218
3219 pub fn missing_builtin_bases(&self) -> Vec<&'static str> {
3225 BUILTIN_EXECUTION_BASES
3226 .iter()
3227 .copied()
3228 .filter(|base| !self.is_allowed(base))
3229 .collect()
3230 }
3231}
3232
3233fn default_retry_max_attempts() -> u32 {
3238 3
3239}
3240
3241fn default_retry_base_delay_ms() -> u64 {
3242 100
3243}
3244
3245fn default_retry_max_delay_ms() -> u64 {
3246 5000
3247}
3248
3249fn default_retry_jitter_factor() -> f64 {
3250 0.1
3251}
3252
3253fn default_retry_total_timeout_ms() -> u64 {
3254 30000
3255}
3256
3257#[derive(Debug, Clone, Serialize, Deserialize)]
3262pub struct RetryConfig {
3263 #[serde(default = "default_retry_max_attempts")]
3265 pub max_attempts: u32,
3266
3267 #[serde(default = "default_retry_base_delay_ms")]
3269 pub base_delay_ms: u64,
3270
3271 #[serde(default = "default_retry_max_delay_ms")]
3273 pub max_delay_ms: u64,
3274
3275 #[serde(default = "default_retry_jitter_factor")]
3278 pub jitter_factor: f64,
3279
3280 #[serde(default = "default_retry_total_timeout_ms")]
3283 pub total_timeout_ms: u64,
3284}
3285
3286impl Default for RetryConfig {
3287 fn default() -> Self {
3288 Self {
3289 max_attempts: default_retry_max_attempts(),
3290 base_delay_ms: default_retry_base_delay_ms(),
3291 max_delay_ms: default_retry_max_delay_ms(),
3292 jitter_factor: default_retry_jitter_factor(),
3293 total_timeout_ms: default_retry_total_timeout_ms(),
3294 }
3295 }
3296}
3297
3298impl RetryConfig {
3299 pub fn no_retry() -> Self {
3301 Self {
3302 max_attempts: 1,
3303 ..Default::default()
3304 }
3305 }
3306
3307 pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
3311 if attempt == 0 {
3312 return std::time::Duration::ZERO;
3313 }
3314
3315 let base = self.base_delay_ms as f64;
3317 let delay = base * (2.0_f64.powi(attempt as i32 - 1));
3318 let capped = delay.min(self.max_delay_ms as f64);
3319
3320 let jittered = if self.jitter_factor > 0.0 {
3322 let jitter_range = capped * self.jitter_factor;
3323 let jitter = (rand::rng().random::<f64>() * 2.0 - 1.0) * jitter_range;
3324 (capped + jitter).max(0.0)
3325 } else {
3326 capped
3327 };
3328
3329 std::time::Duration::from_millis(jittered as u64)
3330 }
3331
3332 pub fn should_retry(&self, attempt: u32, elapsed: std::time::Duration) -> bool {
3334 if attempt >= self.max_attempts {
3335 return false;
3336 }
3337 elapsed.as_millis() < self.total_timeout_ms as u128
3338 }
3339}
3340
3341fn default_source_sync_silence_timeout_secs() -> u64 {
3346 120
3347}
3348
3349pub fn default_remote_base() -> String {
3358 "/data/tmp/rch".to_string()
3359}
3360
3361pub fn validate_remote_base(path: &str) -> Result<String, String> {
3364 let expanded = shellexpand::tilde(path).into_owned();
3366
3367 if !expanded.starts_with('/') {
3369 return Err(format!(
3370 "remote_base must be an absolute path, got: {}",
3371 path
3372 ));
3373 }
3374
3375 if expanded.contains("..") {
3377 return Err(format!(
3378 "remote_base must not contain path traversal (..): {}",
3379 path
3380 ));
3381 }
3382
3383 let normalized = expanded.trim_end_matches('/').to_string();
3385
3386 if normalized.is_empty() || normalized == "/" {
3388 return Err("remote_base cannot be the root directory (safety restriction)".to_string());
3389 }
3390
3391 let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
3395 if components.len() < 2 {
3396 return Err(format!(
3397 "remote_base must be at least 2 levels deep (e.g. /tmp/rch), got: {}",
3398 normalized
3399 ));
3400 }
3401
3402 Ok(normalized)
3403}
3404
3405fn default_circuit_failure_threshold() -> u32 {
3406 3
3407}
3408
3409fn default_circuit_success_threshold() -> u32 {
3410 2
3411}
3412
3413fn default_circuit_error_rate_threshold() -> f64 {
3414 0.5
3415}
3416
3417fn default_circuit_window_secs() -> u64 {
3418 60
3419}
3420
3421fn default_circuit_open_cooldown_secs() -> u64 {
3422 30
3423}
3424
3425fn default_circuit_half_open_max_probes() -> u32 {
3426 1
3427}
3428
3429fn default_circuit_command_failure_threshold() -> u32 {
3430 2
3431}
3432
3433fn default_circuit_open_cooldown_max_secs() -> u64 {
3434 480
3436}
3437
3438fn default_true() -> bool {
3439 true
3440}
3441
3442fn default_log_level() -> String {
3443 "info".to_string()
3444}
3445
3446pub fn default_socket_path() -> String {
3447 if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR")
3449 && !runtime_dir.trim().is_empty()
3450 {
3451 let path = PathBuf::from(runtime_dir).join("rch.sock");
3452 return path.to_string_lossy().to_string();
3453 }
3454
3455 if let Some(cache_dir) = dirs::cache_dir() {
3457 let rch_cache = cache_dir.join("rch");
3458 let _ = std::fs::create_dir_all(&rch_cache);
3459 return rch_cache.join("rch.sock").to_string_lossy().to_string();
3460 }
3461
3462 "/tmp/rch.sock".to_string()
3464}
3465
3466#[allow(dead_code)]
3467fn default_output_visibility() -> OutputVisibility {
3468 OutputVisibility::None
3469}
3470
3471fn default_confidence() -> f64 {
3472 0.85
3473}
3474
3475fn default_min_local_time() -> u64 {
3476 2000
3477}
3478
3479fn default_speedup_threshold() -> f64 {
3483 1.2
3484}
3485
3486fn default_compression() -> u32 {
3487 3
3488}
3489
3490fn default_verify_max_size() -> u64 {
3492 100 * 1024 * 1024 }
3494
3495fn default_min_compression() -> u32 {
3497 1
3498}
3499
3500fn default_max_compression() -> u32 {
3502 9
3503}
3504
3505fn default_excludes() -> Vec<String> {
3506 vec![
3507 "target/".to_string(),
3509 "*.rlib".to_string(),
3510 "*.rmeta".to_string(),
3511 ".git/".to_string(),
3514 "node_modules/".to_string(),
3516 ".bun/".to_string(),
3518 ".npm/".to_string(),
3520 ".pnpm-store/".to_string(),
3521 "dist/".to_string(),
3523 "build/".to_string(),
3524 ".next/".to_string(),
3526 ".nuxt/".to_string(),
3527 ".turbo/".to_string(),
3528 ".parcel-cache/".to_string(),
3529 ".beads/".to_string(),
3533 ".ntm/".to_string(),
3534 ".beads-recovery/".to_string(),
3535 ".doctor/".to_string(),
3540 "coverage/".to_string(),
3542 ".nyc_output/".to_string(),
3543 ".cargo/credentials".to_string(),
3545 ".cargo/credentials.toml".to_string(),
3546 ".env".to_string(),
3547 ".env.*".to_string(),
3548 "*.pem".to_string(),
3549 "*.key".to_string(),
3550 "credentials.json".to_string(),
3551 "secrets.json".to_string(),
3552 "secrets.yaml".to_string(),
3553 "secrets.yml".to_string(),
3554 "secrets.toml".to_string(),
3555 "secrets.env".to_string(),
3556 "secrets.txt".to_string(),
3557 "secrets.conf".to_string(),
3558 "secrets.cfg".to_string(),
3559 "secrets.properties".to_string(),
3560 "secrets.xml".to_string(),
3561 "id_rsa".to_string(),
3575 "id_ed25519".to_string(),
3576 "id_ecdsa".to_string(),
3577 "id_dsa".to_string(),
3578 "*.p12".to_string(),
3580 "*.pfx".to_string(),
3581 "*.keystore".to_string(),
3582 "*.jks".to_string(),
3583 ".netrc".to_string(),
3586 "_netrc".to_string(),
3587 ".aws/credentials".to_string(),
3588 ".docker/config.json".to_string(),
3589 ".npmrc".to_string(),
3593 ".pypirc".to_string(),
3594 "secring.*".to_string(),
3596 ]
3597}
3598
3599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3605#[serde(rename_all = "snake_case")]
3606pub enum BuildLocation {
3607 #[default]
3609 Local,
3610 Remote,
3612}
3613
3614#[derive(Debug, Clone, Serialize, Deserialize)]
3616pub struct BuildCancellationWorkerHealth {
3617 pub status: String,
3619 pub speed_score: f64,
3621 pub used_slots: u32,
3623 pub available_slots: u32,
3625 pub pressure_state: String,
3627 pub pressure_reason_code: String,
3629}
3630
3631#[derive(Debug, Clone, Serialize, Deserialize)]
3633pub struct BuildCancellationMetadata {
3634 pub operation_id: String,
3636 pub origin: String,
3638 pub reason_code: String,
3640 #[serde(default)]
3642 pub decision_path: Vec<String>,
3643 pub escalation_stage: String,
3645 pub escalation_count: u32,
3647 pub remote_kill_attempted: bool,
3649 pub cleanup_ok: bool,
3651 pub history_cancelled: bool,
3653 pub final_state: String,
3655 #[serde(default, skip_serializing_if = "Option::is_none")]
3657 pub worker_health: Option<BuildCancellationWorkerHealth>,
3658}
3659
3660#[derive(Debug, Clone, Serialize, Deserialize)]
3662pub struct BuildRecord {
3663 pub id: u64,
3665 pub started_at: String,
3667 pub completed_at: String,
3669 pub project_id: String,
3671 pub worker_id: Option<String>,
3673 pub command: String,
3675 pub exit_code: i32,
3677 pub duration_ms: u64,
3679 pub location: BuildLocation,
3681 pub bytes_transferred: Option<u64>,
3683 #[serde(default)]
3685 pub timing: Option<CommandTimingBreakdown>,
3686 #[serde(default, skip_serializing_if = "Option::is_none")]
3688 pub cancellation: Option<BuildCancellationMetadata>,
3689}
3690
3691#[derive(Debug, Clone, Serialize, Deserialize)]
3693pub struct BuildRecordInput {
3694 pub started_at: String,
3696 pub completed_at: String,
3698 pub project_id: String,
3700 pub worker_id: Option<String>,
3702 pub command: String,
3704 pub exit_code: i32,
3706 pub duration_ms: u64,
3708 pub location: BuildLocation,
3710 pub bytes_transferred: Option<u64>,
3712 #[serde(default)]
3714 pub timing: Option<CommandTimingBreakdown>,
3715 #[serde(default, skip_serializing_if = "Option::is_none")]
3717 pub cancellation: Option<BuildCancellationMetadata>,
3718}
3719
3720impl BuildRecordInput {
3721 pub fn into_record(self, id: u64) -> BuildRecord {
3723 BuildRecord {
3724 id,
3725 started_at: self.started_at,
3726 completed_at: self.completed_at,
3727 project_id: self.project_id,
3728 worker_id: self.worker_id,
3729 command: self.command,
3730 exit_code: self.exit_code,
3731 duration_ms: self.duration_ms,
3732 location: self.location,
3733 bytes_transferred: self.bytes_transferred,
3734 timing: self.timing,
3735 cancellation: self.cancellation,
3736 }
3737 }
3738}
3739
3740#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3745pub struct BuildStats {
3746 pub total_builds: usize,
3748 pub success_count: usize,
3750 pub failure_count: usize,
3752 pub remote_count: usize,
3754 pub local_count: usize,
3756 pub avg_duration_ms: u64,
3758}
3759
3760#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3767pub struct SavedTimeStats {
3768 pub total_remote_duration_ms: u64,
3770 pub estimated_local_duration_ms: u64,
3773 pub time_saved_ms: u64,
3776 pub builds_counted: usize,
3778 pub avg_speedup: f64,
3782 pub today_saved_ms: u64,
3784 pub week_saved_ms: u64,
3786 #[serde(default)]
3789 pub estimate_basis: String,
3790 #[serde(default)]
3792 pub local_baseline_builds: usize,
3793}
3794
3795use std::collections::VecDeque;
3800use std::time::{Duration, Instant};
3801
3802#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3804pub struct CompilationTimingBreakdown {
3805 #[serde(with = "duration_millis")]
3807 pub rsync_up: Duration,
3808 #[serde(with = "duration_millis")]
3810 pub remote_build: Duration,
3811 #[serde(with = "duration_millis")]
3813 pub rsync_down: Duration,
3814 #[serde(with = "duration_millis")]
3816 pub total: Duration,
3817}
3818
3819#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3823pub struct CommandTimingBreakdown {
3824 #[serde(with = "option_duration_millis")]
3826 pub classify: Option<Duration>,
3827 #[serde(with = "option_duration_millis")]
3829 pub select: Option<Duration>,
3830 #[serde(with = "option_duration_millis")]
3832 pub sync_up: Option<Duration>,
3833 #[serde(with = "option_duration_millis")]
3835 pub exec: Option<Duration>,
3836 #[serde(with = "option_duration_millis")]
3838 pub sync_down: Option<Duration>,
3839 #[serde(with = "option_duration_millis")]
3841 pub cleanup: Option<Duration>,
3842 #[serde(with = "option_duration_millis")]
3844 pub total: Option<Duration>,
3845}
3846
3847mod duration_millis {
3848 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3849 use std::time::Duration;
3850
3851 pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
3852 where
3853 S: Serializer,
3854 {
3855 duration.as_millis().serialize(serializer)
3856 }
3857
3858 pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
3859 where
3860 D: Deserializer<'de>,
3861 {
3862 let millis = u64::deserialize(deserializer)?;
3863 Ok(Duration::from_millis(millis))
3864 }
3865}
3866
3867#[derive(Debug, Clone, Serialize, Deserialize)]
3869pub struct CompilationMetrics {
3870 pub project_id: String,
3872 pub worker_id: String,
3874 pub timestamp: chrono::DateTime<chrono::Utc>,
3876 pub timing: CompilationTimingBreakdown,
3878 #[serde(with = "option_duration_millis")]
3880 pub local_build_time: Option<Duration>,
3881 pub speedup: Option<f64>,
3883 pub files_synced: u64,
3885 pub bytes_transferred: u64,
3887 pub exit_code: i32,
3889 pub success: bool,
3891}
3892
3893mod option_duration_millis {
3894 use serde::{Deserialize, Deserializer, Serialize, Serializer};
3895 use std::time::Duration;
3896
3897 pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
3898 where
3899 S: Serializer,
3900 {
3901 match duration {
3902 Some(d) => Some(d.as_millis() as u64).serialize(serializer),
3903 None => Option::<u64>::None.serialize(serializer),
3904 }
3905 }
3906
3907 pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
3908 where
3909 D: Deserializer<'de>,
3910 {
3911 let opt: Option<u64> = Option::deserialize(deserializer)?;
3912 Ok(opt.map(Duration::from_millis))
3913 }
3914}
3915
3916impl CompilationMetrics {
3917 pub fn calculate_speedup(&mut self) {
3919 if let Some(local) = self.local_build_time
3920 && self.timing.total.as_millis() > 0
3921 {
3922 self.speedup = Some(local.as_secs_f64() / self.timing.total.as_secs_f64());
3923 }
3924 }
3925
3926 pub fn is_beneficial(&self) -> bool {
3928 self.speedup.map(|s| s > 1.0).unwrap_or(false)
3929 }
3930}
3931
3932impl Default for CompilationMetrics {
3933 fn default() -> Self {
3934 Self {
3935 project_id: String::new(),
3936 worker_id: String::new(),
3937 timestamp: chrono::Utc::now(),
3938 timing: CompilationTimingBreakdown::default(),
3939 local_build_time: None,
3940 speedup: None,
3941 files_synced: 0,
3942 bytes_transferred: 0,
3943 exit_code: 0,
3944 success: true,
3945 }
3946 }
3947}
3948
3949#[derive(Debug)]
3965pub struct CompilationTimer {
3966 project_id: String,
3967 worker_id: String,
3968 start: Instant,
3969 phase_start: Instant,
3970 rsync_up: Option<Duration>,
3971 remote_build: Option<Duration>,
3972 rsync_down: Option<Duration>,
3973}
3974
3975impl CompilationTimer {
3976 pub fn new(project_id: &str, worker_id: &str) -> Self {
3978 let now = Instant::now();
3979 Self {
3980 project_id: project_id.to_string(),
3981 worker_id: worker_id.to_string(),
3982 start: now,
3983 phase_start: now,
3984 rsync_up: None,
3985 remote_build: None,
3986 rsync_down: None,
3987 }
3988 }
3989
3990 pub fn end_rsync_up(&mut self) {
3992 self.rsync_up = Some(self.phase_start.elapsed());
3993 self.phase_start = Instant::now();
3994 tracing::info!(
3995 rsync_up_ms = %self.rsync_up.unwrap().as_millis(),
3996 "TIMING: rsync_up completed"
3997 );
3998 }
3999
4000 pub fn end_remote_build(&mut self) {
4002 self.remote_build = Some(self.phase_start.elapsed());
4003 self.phase_start = Instant::now();
4004 tracing::info!(
4005 remote_build_ms = %self.remote_build.unwrap().as_millis(),
4006 "TIMING: remote_build completed"
4007 );
4008 }
4009
4010 pub fn end_rsync_down(&mut self) {
4012 self.rsync_down = Some(self.phase_start.elapsed());
4013 tracing::info!(
4014 rsync_down_ms = %self.rsync_down.unwrap().as_millis(),
4015 "TIMING: rsync_down completed"
4016 );
4017 }
4018
4019 pub fn finish(self, exit_code: i32, files: u64, bytes: u64) -> CompilationMetrics {
4021 let total = self.start.elapsed();
4022 tracing::info!(
4023 total_ms = %total.as_millis(),
4024 exit_code = %exit_code,
4025 "TIMING: compilation completed"
4026 );
4027
4028 CompilationMetrics {
4029 project_id: self.project_id,
4030 worker_id: self.worker_id,
4031 timestamp: chrono::Utc::now(),
4032 timing: CompilationTimingBreakdown {
4033 rsync_up: self.rsync_up.unwrap_or_default(),
4034 remote_build: self.remote_build.unwrap_or_default(),
4035 rsync_down: self.rsync_down.unwrap_or_default(),
4036 total,
4037 },
4038 local_build_time: None,
4039 speedup: None,
4040 files_synced: files,
4041 bytes_transferred: bytes,
4042 exit_code,
4043 success: exit_code == 0,
4044 }
4045 }
4046}
4047
4048#[derive(Debug)]
4050pub struct MetricsAggregator {
4051 history: VecDeque<CompilationMetrics>,
4052 max_history: usize,
4053}
4054
4055impl MetricsAggregator {
4056 pub fn new(max_history: usize) -> Self {
4058 Self {
4059 history: VecDeque::with_capacity(max_history),
4060 max_history,
4061 }
4062 }
4063
4064 pub fn record(&mut self, metrics: CompilationMetrics) {
4066 if self.history.len() >= self.max_history {
4067 self.history.pop_front();
4068 }
4069 self.history.push_back(metrics);
4070 }
4071
4072 pub fn average_speedup(&self) -> Option<f64> {
4074 let speedups: Vec<f64> = self.history.iter().filter_map(|m| m.speedup).collect();
4075
4076 if speedups.is_empty() {
4077 None
4078 } else {
4079 Some(speedups.iter().sum::<f64>() / speedups.len() as f64)
4080 }
4081 }
4082
4083 pub fn p50_total_time(&self) -> Option<Duration> {
4085 self.percentile_total_time(0.50)
4086 }
4087
4088 pub fn p95_total_time(&self) -> Option<Duration> {
4090 self.percentile_total_time(0.95)
4091 }
4092
4093 pub fn p99_total_time(&self) -> Option<Duration> {
4095 self.percentile_total_time(0.99)
4096 }
4097
4098 fn percentile_total_time(&self, percentile: f64) -> Option<Duration> {
4103 let mut times: Vec<_> = self.history.iter().map(|m| m.timing.total).collect();
4104
4105 if times.is_empty() {
4106 return None;
4107 }
4108
4109 times.sort();
4110 let idx = (((times.len() - 1) as f64 * percentile).round() as usize).min(times.len() - 1);
4114 Some(times[idx])
4115 }
4116
4117 pub fn success_rate(&self) -> f64 {
4119 if self.history.is_empty() {
4120 return 100.0;
4121 }
4122 let successes = self.history.iter().filter(|m| m.success).count();
4123 (successes as f64 / self.history.len() as f64) * 100.0
4124 }
4125
4126 pub fn count(&self) -> usize {
4128 self.history.len()
4129 }
4130
4131 pub fn metrics(&self) -> &VecDeque<CompilationMetrics> {
4133 &self.history
4134 }
4135
4136 pub fn clear(&mut self) {
4138 self.history.clear();
4139 }
4140}
4141
4142impl Default for MetricsAggregator {
4143 fn default() -> Self {
4144 Self::new(1000)
4145 }
4146}
4147
4148#[cfg(test)]
4149mod tests {
4150 use super::*;
4151 use crate::test_guard;
4152
4153 #[test]
4154 fn test_declared_os_reads_reserved_tag() {
4155 assert_eq!(
4156 declared_os(&["rust".to_string(), "os:windows".to_string()]),
4157 Some("windows".to_string())
4158 );
4159 assert_eq!(
4160 declared_os(&["os:WINDOWS".to_string()]),
4161 Some("windows".to_string())
4162 );
4163 }
4164
4165 #[test]
4166 fn test_declared_os_absent_for_ordinary_tags() {
4167 assert_eq!(declared_os(&[]), None);
4170 assert_eq!(
4171 declared_os(&["rust".to_string(), "bun".to_string(), "go".to_string()]),
4172 None
4173 );
4174 assert_eq!(declared_os(&["os:".to_string()]), None);
4176 assert_eq!(declared_os(&["os: ".to_string()]), None);
4177 }
4178
4179 #[test]
4180 fn test_os_tag_roundtrips_through_declared_os() {
4181 for os in ["windows", "Darwin", " linux "] {
4182 let tag = os_tag(os);
4183 assert_eq!(
4184 declared_os(std::slice::from_ref(&tag)).as_deref(),
4185 Some(os.trim().to_ascii_lowercase().as_str())
4186 );
4187 }
4188 }
4189
4190 #[test]
4195 fn test_no_inline_secret_fields() {
4196 let endpoint = DoctorWebhookEndpoint {
4197 name: "ops".to_string(),
4198 url: "https://example.com/hook".to_string(),
4199 format: DoctorWebhookFormat::Pagerduty,
4200 on_transitions: vec!["any_to_failing".to_string()],
4201 retry_max: 3,
4202 retry_backoff_ms: 200,
4203 timeout_ms: 5000,
4204 enabled: true,
4205 routing_key_env: Some("RCH_PAGERDUTY_ROUTING_KEY".to_string()),
4206 bearer_token_env: Some("RCH_WEBHOOK_BEARER".to_string()),
4207 signing_secret_env: Some("RCH_WEBHOOK_HMAC".to_string()),
4208 };
4209 let value = serde_json::to_value(&endpoint).unwrap();
4212 let obj = value.as_object().expect("endpoint serializes to an object");
4213 for forbidden in ["secret", "routing_key", "bearer_token", "password", "token"] {
4214 assert!(
4215 !obj.contains_key(forbidden),
4216 "endpoint config must not expose an inline `{forbidden}` field"
4217 );
4218 }
4219 assert!(obj.contains_key("routing_key_env"));
4220 assert!(obj.contains_key("bearer_token_env"));
4221 assert!(obj.contains_key("signing_secret_env"));
4222 }
4223
4224 #[test]
4225 fn test_webhook_format_toml_spelling() {
4226 #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
4231 struct FormatWrap {
4232 format: DoctorWebhookFormat,
4233 }
4234 let rendered = toml::to_string(&FormatWrap {
4235 format: DoctorWebhookFormat::GenericJson,
4236 })
4237 .unwrap();
4238 assert!(
4239 rendered.contains("format = \"generic_json\""),
4240 "unexpected TOML: {rendered}"
4241 );
4242 let slack: FormatWrap = toml::from_str("format = \"slack\"").unwrap();
4243 assert_eq!(slack.format, DoctorWebhookFormat::Slack);
4244 let pd: FormatWrap = toml::from_str("format = \"pagerduty\"").unwrap();
4245 assert_eq!(pd.format, DoctorWebhookFormat::Pagerduty);
4246 }
4247
4248 #[test]
4249 fn test_doctor_webhooks_defaults() {
4250 let cfg = DoctorWebhooksConfig::default();
4251 assert!(cfg.endpoints.is_empty());
4252 assert_eq!(cfg.max_inflight, 16);
4253 let rch: RchConfig = toml::from_str("").unwrap();
4256 assert!(rch.doctor.webhooks.endpoints.is_empty());
4257 }
4258
4259 #[test]
4260 fn test_doctor_webhook_endpoint_parses_from_toml() {
4261 let toml_src = r#"
4262[doctor.webhooks]
4263max_inflight = 8
4264
4265[[doctor.webhooks.endpoints]]
4266name = "ops-slack"
4267url = "https://hooks.slack.com/services/T0/B0/x"
4268format = "slack"
4269on_transitions = ["healthy_to_degraded", "any_to_failing"]
4270retry_max = 2
4271"#;
4272 let cfg: RchConfig = toml::from_str(toml_src).unwrap();
4273 assert_eq!(cfg.doctor.webhooks.max_inflight, 8);
4274 assert_eq!(cfg.doctor.webhooks.endpoints.len(), 1);
4275 let ep = &cfg.doctor.webhooks.endpoints[0];
4276 assert_eq!(ep.name, "ops-slack");
4277 assert_eq!(ep.format, DoctorWebhookFormat::Slack);
4278 assert_eq!(ep.on_transitions.len(), 2);
4279 assert_eq!(ep.retry_max, 2);
4280 assert_eq!(ep.retry_backoff_ms, 200);
4282 assert_eq!(ep.timeout_ms, 5000);
4283 assert!(ep.enabled);
4284 }
4285
4286 fn assert_golden_json(
4287 actual: serde_json::Value,
4288 expected: &serde_json::Value,
4289 fixture_path: &str,
4290 ) {
4291 if &actual == expected {
4292 return;
4293 }
4294
4295 let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
4296 let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
4297 assert_eq!(
4298 &actual,
4299 expected,
4300 "golden mismatch in {fixture_path}\n\
4301 expected_blake3={}\n\
4302 actual_blake3={}\n\
4303 bless path: review the semantic diff, update the fixture expected_* field, \
4304 and record both hashes in the Beads closeout.\n\
4305 expected:\n{expected_json}\n\
4306 actual:\n{actual_json}",
4307 blake3::hash(expected_json.as_bytes()),
4308 blake3::hash(actual_json.as_bytes())
4309 );
4310 }
4311
4312 #[test]
4313 fn test_circuit_state_default() {
4314 let _guard = test_guard!();
4315 assert_eq!(CircuitState::default(), CircuitState::Closed);
4316 }
4317
4318 #[test]
4319 fn test_circuit_config_defaults() {
4320 let _guard = test_guard!();
4321 let config = CircuitBreakerConfig::default();
4322 assert_eq!(config.failure_threshold, 3);
4323 assert_eq!(config.success_threshold, 2);
4324 assert_eq!(config.error_rate_threshold, 0.5);
4325 assert_eq!(config.window_secs, 60);
4326 assert_eq!(config.open_cooldown_secs, 30);
4327 assert_eq!(config.half_open_max_probes, 1);
4328 }
4329
4330 #[test]
4331 fn test_rch_config_has_circuit_defaults() {
4332 let _guard = test_guard!();
4333 let config = RchConfig::default();
4334 assert_eq!(config.circuit.failure_threshold, 3);
4335 }
4336
4337 #[test]
4338 fn test_circuit_config_serde_roundtrip() {
4339 let _guard = test_guard!();
4340 let config = CircuitBreakerConfig {
4341 failure_threshold: 5,
4342 success_threshold: 3,
4343 error_rate_threshold: 0.75,
4344 window_secs: 120,
4345 open_cooldown_secs: 45,
4346 half_open_max_probes: 2,
4347 command_failure_threshold: 4,
4348 open_cooldown_max_secs: 900,
4349 };
4350 let json = serde_json::to_string(&config).unwrap();
4351 let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
4352 assert_eq!(parsed.command_failure_threshold, 4);
4353 assert_eq!(parsed.open_cooldown_max_secs, 900);
4354 assert_eq!(parsed.failure_threshold, 5);
4355 assert_eq!(parsed.success_threshold, 3);
4356 assert_eq!(parsed.error_rate_threshold, 0.75);
4357 assert_eq!(parsed.window_secs, 120);
4358 assert_eq!(parsed.open_cooldown_secs, 45);
4359 assert_eq!(parsed.half_open_max_probes, 2);
4360 }
4361
4362 #[test]
4367 fn test_worker_capabilities_default() {
4368 let _guard = test_guard!();
4369 let caps = WorkerCapabilities::default();
4370 assert!(caps.rustc_version.is_none());
4371 assert!(caps.rustup_toolchains.is_empty());
4372 assert!(caps.rustup_components.is_empty());
4373 assert!(caps.bun_version.is_none());
4374 assert!(caps.node_version.is_none());
4375 assert!(caps.npm_version.is_none());
4376 assert!(caps.projects_root_ok.is_none());
4377 assert!(caps.projects_root_issue.is_none());
4378 assert!(caps.projects_root_checked_at_unix_ms.is_none());
4379 }
4380
4381 #[test]
4382 fn test_worker_capabilities_new() {
4383 let _guard = test_guard!();
4384 let caps = WorkerCapabilities::new();
4385 assert!(caps.rustc_version.is_none());
4386 assert!(!caps.has_rust());
4387 assert!(!caps.has_bun());
4388 assert!(!caps.has_node());
4389 assert!(caps.is_topology_healthy().is_none());
4390 }
4391
4392 #[test]
4393 fn test_worker_capabilities_has_rust() {
4394 let _guard = test_guard!();
4395 let mut caps = WorkerCapabilities::new();
4396 assert!(!caps.has_rust());
4397
4398 caps.rustc_version = Some("rustc 1.76.0 (07dca489a 2024-02-04)".to_string());
4399 assert!(caps.has_rust());
4400 }
4401
4402 #[test]
4403 fn test_worker_capabilities_components_are_toolchain_scoped_and_exact() {
4404 let _guard = test_guard!();
4405 let caps = WorkerCapabilities {
4406 rustup_components: vec![
4407 "stable-x86_64-unknown-linux-gnu:clippy".to_string(),
4408 "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
4409 "beta-x86_64-unknown-linux-gnu:clippy-preview".to_string(),
4410 ],
4411 ..Default::default()
4412 };
4413
4414 assert!(caps.has_rustup_component("stable", "clippy"));
4415 assert!(caps.has_rustup_component("nightly-2026-07-05", "rustfmt"));
4416 assert!(!caps.has_rustup_component("nightly-2026-07-05", "clippy"));
4417 assert!(!caps.has_rustup_component("beta", "clippy"));
4418 assert!(!caps.has_rustup_component("nightly-2026-07-0", "rustfmt"));
4419 }
4420
4421 #[test]
4422 fn test_worker_capabilities_has_bun() {
4423 let _guard = test_guard!();
4424 let mut caps = WorkerCapabilities::new();
4425 assert!(!caps.has_bun());
4426
4427 caps.bun_version = Some("1.0.25".to_string());
4428 assert!(caps.has_bun());
4429 }
4430
4431 #[test]
4432 fn test_worker_capabilities_has_nix() {
4433 let _guard = test_guard!();
4434 let mut caps = WorkerCapabilities::new();
4435 assert!(!caps.has_nix());
4436
4437 caps.nix_version = Some("nix (Nix) 2.24.9".to_string());
4438 assert!(caps.has_nix());
4439 }
4440
4441 #[test]
4442 fn test_worker_capabilities_has_zig_requires_both_halves() {
4443 let _guard = test_guard!();
4444 let mut caps = WorkerCapabilities::new();
4445 assert!(!caps.has_zig());
4446
4447 caps.zig_version = Some("0.14.1".to_string());
4450 assert!(!caps.has_zig());
4451
4452 caps.zig_version = None;
4454 caps.cargo_zigbuild_version = Some("cargo-zigbuild 0.23.0".to_string());
4455 assert!(!caps.has_zig());
4456
4457 caps.zig_version = Some("0.14.1".to_string());
4458 assert!(caps.has_zig());
4459 }
4460
4461 #[test]
4462 fn test_nix_build_uses_generous_timeout() {
4463 let _guard = test_guard!();
4464 let cfg = CompilationConfig::default();
4465 assert_eq!(
4467 cfg.timeout_for_kind(Some(CompilationKind::NixBuild)),
4468 std::time::Duration::from_secs(cfg.test_timeout_sec)
4469 );
4470 }
4471
4472 #[test]
4473 fn test_job_uses_build_timeout_bucket() {
4474 let _guard = test_guard!();
4475 let cfg = CompilationConfig::default();
4478 assert_eq!(
4479 cfg.timeout_for_kind(Some(CompilationKind::Job)),
4480 std::time::Duration::from_secs(cfg.build_timeout_sec)
4481 );
4482 }
4483
4484 #[test]
4485 fn test_layer0_disabled_by_default_and_env_gated() {
4486 let _guard = test_guard!();
4487 let cfg = Layer0Config::default();
4488 assert!(!cfg.enabled);
4489 let knobs_on = Layer0Config {
4491 enabled: false,
4492 release_lto_thin: true,
4493 release_codegen_units_1: true,
4494 debug_line_tables_only: true,
4495 };
4496 assert!(cfg.active_env().is_empty());
4497 assert!(knobs_on.active_env().is_empty());
4498 }
4499
4500 #[test]
4501 fn test_layer0_knobs_emit_stable_env_pairs() {
4502 let _guard = test_guard!();
4503 let lto_only = Layer0Config {
4504 enabled: true,
4505 release_lto_thin: true,
4506 ..Layer0Config::default()
4507 };
4508 assert_eq!(
4509 lto_only.active_env(),
4510 vec![("CARGO_PROFILE_RELEASE_LTO", "thin")]
4511 );
4512 let debug_only = Layer0Config {
4513 enabled: true,
4514 debug_line_tables_only: true,
4515 ..Layer0Config::default()
4516 };
4517 assert_eq!(
4518 debug_only.active_env(),
4519 vec![
4520 ("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"),
4521 ("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"),
4522 ]
4523 );
4524 let all = Layer0Config {
4525 enabled: true,
4526 release_lto_thin: true,
4527 release_codegen_units_1: true,
4528 debug_line_tables_only: true,
4529 };
4530 assert_eq!(
4531 all.active_env(),
4532 vec![
4533 ("CARGO_PROFILE_RELEASE_LTO", "thin"),
4534 ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
4535 ("CARGO_PROFILE_DEV_DEBUG", "line-tables-only"),
4536 ("CARGO_PROFILE_TEST_DEBUG", "line-tables-only"),
4537 ],
4538 "emission order must stay stable for deterministic baselines"
4539 );
4540 }
4541
4542 #[test]
4543 fn test_layer0_pack_version_is_recorded() {
4544 let _guard = test_guard!();
4545 assert!(!LAYER0_PACK_VERSION.is_empty());
4547 }
4548 #[test]
4549 fn test_default_allowlist_includes_nix() {
4550 let _guard = test_guard!();
4551 let cfg = ExecutionConfig::default();
4552 assert!(cfg.is_allowed("nix"));
4553 }
4554
4555 #[test]
4556 fn test_worker_capabilities_has_node() {
4557 let _guard = test_guard!();
4558 let mut caps = WorkerCapabilities::new();
4559 assert!(!caps.has_node());
4560
4561 caps.node_version = Some("v20.11.0".to_string());
4562 assert!(caps.has_node());
4563 }
4564
4565 #[test]
4566 fn test_worker_capabilities_multiple_runtimes() {
4567 let _guard = test_guard!();
4568 let caps = WorkerCapabilities {
4569 rustc_version: Some("rustc 1.76.0".to_string()),
4570 bun_version: Some("1.0.25".to_string()),
4571 node_version: Some("v20.11.0".to_string()),
4572 npm_version: Some("10.2.4".to_string()),
4573 ..Default::default()
4574 };
4575
4576 assert!(caps.has_rust());
4577 assert!(caps.has_bun());
4578 assert!(caps.has_node());
4579 }
4580
4581 #[test]
4582 fn test_worker_capabilities_serialization_empty() {
4583 let _guard = test_guard!();
4584 let caps = WorkerCapabilities::new();
4585 let json = serde_json::to_string(&caps).unwrap();
4586 assert_eq!(json, "{}");
4588
4589 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4591 assert!(!parsed.has_rust());
4592 assert!(!parsed.has_bun());
4593 assert!(!parsed.has_node());
4594 }
4595
4596 #[test]
4597 fn test_worker_capabilities_serialization_with_versions() {
4598 let _guard = test_guard!();
4599 let caps = WorkerCapabilities {
4600 rustc_version: Some("rustc 1.76.0-nightly".to_string()),
4601 bun_version: Some("1.0.25".to_string()),
4602 node_version: None,
4603 npm_version: None,
4604 ..Default::default()
4605 };
4606
4607 let json = serde_json::to_string(&caps).unwrap();
4608 assert!(json.contains("rustc_version"));
4609 assert!(json.contains("rustc 1.76.0-nightly"));
4610 assert!(json.contains("bun_version"));
4611 assert!(json.contains("1.0.25"));
4612 assert!(!json.contains("node_version"));
4614 assert!(!json.contains("npm_version"));
4615
4616 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4617 assert!(parsed.has_rust());
4618 assert!(parsed.has_bun());
4619 assert!(!parsed.has_node());
4620 }
4621
4622 #[test]
4623 fn test_worker_capabilities_deserialization_partial() {
4624 let _guard = test_guard!();
4625 let json = r#"{"bun_version": "1.0.0"}"#;
4627 let caps: WorkerCapabilities = serde_json::from_str(json).unwrap();
4628
4629 assert!(!caps.has_rust());
4630 assert!(caps.has_bun());
4631 assert!(!caps.has_node());
4632 assert_eq!(caps.bun_version, Some("1.0.0".to_string()));
4633 }
4634
4635 #[test]
4636 fn test_worker_capabilities_clone() {
4637 let _guard = test_guard!();
4638 let caps = WorkerCapabilities {
4639 rustc_version: Some("1.76.0".to_string()),
4640 bun_version: None,
4641 node_version: Some("v20".to_string()),
4642 npm_version: None,
4643 projects_root_ok: Some(false),
4644 projects_root_issue: Some("alias_missing".to_string()),
4645 projects_root_checked_at_unix_ms: Some(123),
4646 ..Default::default()
4647 };
4648
4649 let cloned = caps.clone();
4650 assert_eq!(cloned.rustc_version, caps.rustc_version);
4651 assert_eq!(cloned.bun_version, caps.bun_version);
4652 assert_eq!(cloned.node_version, caps.node_version);
4653 assert_eq!(cloned.npm_version, caps.npm_version);
4654 assert_eq!(cloned.projects_root_ok, caps.projects_root_ok);
4655 assert_eq!(cloned.projects_root_issue, caps.projects_root_issue);
4656 assert_eq!(
4657 cloned.projects_root_checked_at_unix_ms,
4658 caps.projects_root_checked_at_unix_ms
4659 );
4660 }
4661
4662 #[test]
4663 fn test_worker_capabilities_topology_health_helper() {
4664 let _guard = test_guard!();
4665 let mut caps = WorkerCapabilities::new();
4666 assert_eq!(caps.is_topology_healthy(), None);
4667
4668 caps.projects_root_ok = Some(true);
4669 assert_eq!(caps.is_topology_healthy(), Some(true));
4670
4671 caps.projects_root_ok = Some(false);
4672 assert_eq!(caps.is_topology_healthy(), Some(false));
4673 }
4674
4675 #[test]
4676 fn test_selection_reason_serialization() {
4677 let _guard = test_guard!();
4678 assert_eq!(
4680 serde_json::to_string(&SelectionReason::Success).unwrap(),
4681 "\"success\""
4682 );
4683 assert_eq!(
4684 serde_json::to_string(&SelectionReason::NoWorkersConfigured).unwrap(),
4685 "\"no_workers_configured\""
4686 );
4687 assert_eq!(
4688 serde_json::to_string(&SelectionReason::AllWorkersUnreachable).unwrap(),
4689 "\"all_workers_unreachable\""
4690 );
4691 assert_eq!(
4692 serde_json::to_string(&SelectionReason::AllCircuitsOpen).unwrap(),
4693 "\"all_circuits_open\""
4694 );
4695 assert_eq!(
4696 serde_json::to_string(&SelectionReason::AllWorkersBusy).unwrap(),
4697 "\"all_workers_busy\""
4698 );
4699 assert_eq!(
4700 serde_json::to_string(&SelectionReason::NoWorkersPassedHealth).unwrap(),
4701 "\"no_workers_passed_health\""
4702 );
4703 assert_eq!(
4704 serde_json::to_string(&SelectionReason::AllWorkersFailedPreflight).unwrap(),
4705 "\"all_workers_failed_preflight\""
4706 );
4707 assert_eq!(
4708 serde_json::to_string(&SelectionReason::AllWorkersFailedConvergence).unwrap(),
4709 "\"all_workers_failed_convergence\""
4710 );
4711 assert_eq!(
4712 serde_json::to_string(&SelectionReason::NoAdmissibleWorkers(
4713 "critical_pressure=1,insufficient_slots=1".to_string()
4714 ))
4715 .unwrap(),
4716 "{\"no_admissible_workers\":\"critical_pressure=1,insufficient_slots=1\"}"
4717 );
4718 assert_eq!(
4719 serde_json::to_string(&SelectionReason::NoMatchingWorkers).unwrap(),
4720 "\"no_matching_workers\""
4721 );
4722 }
4723
4724 #[test]
4725 fn test_selection_reason_with_error() {
4726 let _guard = test_guard!();
4727 let reason = SelectionReason::SelectionError("test error".to_string());
4728 let json = serde_json::to_string(&reason).unwrap();
4729 assert!(json.contains("selection_error"));
4730 assert!(json.contains("test error"));
4731 }
4732
4733 #[test]
4734 fn test_selection_reason_deserialization() {
4735 let _guard = test_guard!();
4736 assert_eq!(
4737 serde_json::from_str::<SelectionReason>("\"success\"").unwrap(),
4738 SelectionReason::Success
4739 );
4740 assert_eq!(
4741 serde_json::from_str::<SelectionReason>("\"all_workers_busy\"").unwrap(),
4742 SelectionReason::AllWorkersBusy
4743 );
4744 assert_eq!(
4745 serde_json::from_str::<SelectionReason>("\"no_workers_passed_health\"").unwrap(),
4746 SelectionReason::NoWorkersPassedHealth
4747 );
4748 assert_eq!(
4749 serde_json::from_str::<SelectionReason>("\"all_workers_failed_preflight\"").unwrap(),
4750 SelectionReason::AllWorkersFailedPreflight
4751 );
4752 assert_eq!(
4753 serde_json::from_str::<SelectionReason>("\"all_workers_failed_convergence\"").unwrap(),
4754 SelectionReason::AllWorkersFailedConvergence
4755 );
4756 assert_eq!(
4757 serde_json::from_str::<SelectionReason>(
4758 "{\"no_admissible_workers\":\"critical_pressure=1\"}"
4759 )
4760 .unwrap(),
4761 SelectionReason::NoAdmissibleWorkers("critical_pressure=1".to_string())
4762 );
4763 }
4764
4765 #[test]
4766 fn test_selection_reason_display() {
4767 let _guard = test_guard!();
4768 assert_eq!(
4769 SelectionReason::Success.to_string(),
4770 "worker assigned successfully"
4771 );
4772 assert_eq!(
4773 SelectionReason::NoWorkersConfigured.to_string(),
4774 "no workers configured"
4775 );
4776 assert_eq!(
4777 SelectionReason::AllWorkersUnreachable.to_string(),
4778 "all workers unreachable"
4779 );
4780 assert_eq!(
4781 SelectionReason::AllWorkersBusy.to_string(),
4782 "all workers at capacity"
4783 );
4784 assert_eq!(
4785 SelectionReason::NoWorkersPassedHealth.to_string(),
4786 "no workers passed health thresholds"
4787 );
4788 assert_eq!(
4789 SelectionReason::AllWorkersFailedPreflight.to_string(),
4790 "all workers failed preflight checks"
4791 );
4792 assert_eq!(
4793 SelectionReason::AllWorkersFailedConvergence.to_string(),
4794 "all workers failed repo convergence checks"
4795 );
4796 assert_eq!(
4797 SelectionReason::NoAdmissibleWorkers(
4798 "critical_pressure=1,insufficient_slots=1".to_string()
4799 )
4800 .to_string(),
4801 "no admissible workers: critical_pressure=1,insufficient_slots=1"
4802 );
4803 assert_eq!(
4804 SelectionReason::SelectionError("oops".to_string()).to_string(),
4805 "selection error: oops"
4806 );
4807 }
4808
4809 #[test]
4810 fn test_selection_response_with_worker() {
4811 let _guard = test_guard!();
4812 let response = SelectionResponse {
4813 worker: Some(SelectedWorker {
4814 id: WorkerId::new("test"),
4815 host: "localhost".to_string(),
4816 user: "user".to_string(),
4817 identity_file: "~/.ssh/id_rsa".to_string(),
4818 slots_available: 8,
4819 speed_score: 75.0,
4820 declared_os: None,
4821 }),
4822 reason: SelectionReason::Success,
4823 build_id: None,
4824 diagnostics: None,
4825 };
4826
4827 let json = serde_json::to_string(&response).unwrap();
4828 assert!(json.contains("\"reason\":\"success\""));
4829 assert!(json.contains("\"id\":\"test\""));
4830 }
4831
4832 #[test]
4833 fn test_selection_response_without_worker() {
4834 let _guard = test_guard!();
4835 let response = SelectionResponse {
4836 worker: None,
4837 reason: SelectionReason::AllWorkersBusy,
4838 build_id: None,
4839 diagnostics: None,
4840 };
4841
4842 let json = serde_json::to_string(&response).unwrap();
4843 assert!(json.contains("\"worker\":null"));
4844 assert!(json.contains("\"reason\":\"all_workers_busy\""));
4845 }
4846
4847 #[test]
4848 fn golden_selection_response_with_no_workers_passed_health_diagnostics() {
4849 let _guard = test_guard!();
4850 const FIXTURE_PATH: &str =
4851 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json";
4852 let fixture: serde_json::Value = serde_json::from_str(include_str!(
4853 "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json"
4854 ))
4855 .expect("selection diagnostics golden fixture parses");
4856 assert_eq!(
4857 fixture["schema_version"],
4858 "rch.golden.selection_diagnostics.v1"
4859 );
4860
4861 let response = SelectionResponse {
4862 worker: None,
4863 reason: SelectionReason::NoWorkersPassedHealth,
4864 build_id: None,
4865 diagnostics: Some(SelectionDiagnostics {
4866 required_runtime: RequiredRuntime::Rust,
4867 estimated_cores: 2,
4868 min_success_rate: 0.8,
4869 fallback_min_success_rate: 0.5,
4870 active_project_exclusion_count: 0,
4871 workers: vec![WorkerSelectionDiagnostic {
4872 worker_id: WorkerId::new("fixture-worker"),
4873 status: "healthy".to_string(),
4874 circuit_state: "closed".to_string(),
4875 pressure_state: "healthy".to_string(),
4876 pressure_reason_code: "pressure.ok".to_string(),
4877 success_rate: Some(0.2),
4878 min_success_rate: 0.8,
4879 fallback_min_success_rate: 0.5,
4880 required_runtime: RequiredRuntime::Rust,
4881 runtime_available: true,
4882 available_slots: 8,
4883 total_slots: 8,
4884 estimated_cores: 2,
4885 active_project_excluded: false,
4886 final_decision: WorkerSelectionDiagnosticDecision::Deny,
4887 final_reason: "success_rate 0.20 < fallback 0.50".to_string(),
4888 reason_codes: vec![
4889 "health.below_min_success_rate".to_string(),
4890 "health.below_fallback_min_success_rate".to_string(),
4891 ],
4892 }],
4893 }),
4894 };
4895
4896 assert_golden_json(
4897 serde_json::to_value(&response).expect("response serializes"),
4898 &fixture["expected_response"],
4899 FIXTURE_PATH,
4900 );
4901 }
4902
4903 #[test]
4904 fn test_selection_response_roundtrip() {
4905 let _guard = test_guard!();
4906 let original = SelectionResponse {
4907 worker: Some(SelectedWorker {
4908 id: WorkerId::new("worker-1"),
4909 host: "192.168.1.100".to_string(),
4910 user: "ubuntu".to_string(),
4911 identity_file: "/path/to/key".to_string(),
4912 slots_available: 16,
4913 speed_score: 90.5,
4914 declared_os: None,
4915 }),
4916 reason: SelectionReason::Success,
4917 build_id: None,
4918 diagnostics: None,
4919 };
4920
4921 let json = serde_json::to_string(&original).unwrap();
4922 let parsed: SelectionResponse = serde_json::from_str(&json).unwrap();
4923
4924 assert!(parsed.worker.is_some());
4925 let worker = parsed.worker.unwrap();
4926 assert_eq!(worker.id.as_str(), "worker-1");
4927 assert_eq!(worker.host, "192.168.1.100");
4928 assert_eq!(worker.slots_available, 16);
4929 assert_eq!(parsed.reason, SelectionReason::Success);
4930 }
4931
4932 #[test]
4933 fn test_build_heartbeat_phase_roundtrip() {
4934 let _guard = test_guard!();
4935 let json = serde_json::to_string(&BuildHeartbeatPhase::SyncUp).unwrap();
4936 assert_eq!(json, "\"sync_up\"");
4937 let parsed: BuildHeartbeatPhase = serde_json::from_str(&json).unwrap();
4938 assert_eq!(parsed, BuildHeartbeatPhase::SyncUp);
4939 }
4940
4941 #[test]
4942 fn test_build_heartbeat_request_roundtrip() {
4943 let _guard = test_guard!();
4944 let request = BuildHeartbeatRequest {
4945 build_id: 42,
4946 worker_id: WorkerId::new("worker-a"),
4947 hook_pid: Some(12345),
4948 local_wrapper_id: Some("wrapper-abc123".to_string()),
4949 remote_pgid_file: Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string()),
4950 phase: BuildHeartbeatPhase::Execute,
4951 detail: Some("Compiling crates".to_string()),
4952 progress_counter: Some(7),
4953 progress_percent: Some(42.5),
4954 };
4955
4956 let json = serde_json::to_string(&request).unwrap();
4957 let parsed: BuildHeartbeatRequest = serde_json::from_str(&json).unwrap();
4958 assert_eq!(parsed.build_id, 42);
4959 assert_eq!(parsed.worker_id.as_str(), "worker-a");
4960 assert_eq!(
4961 parsed.remote_pgid_file,
4962 Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string())
4963 );
4964 assert_eq!(parsed.phase, BuildHeartbeatPhase::Execute);
4965 assert_eq!(parsed.progress_counter, Some(7));
4966 assert_eq!(parsed.progress_percent, Some(42.5));
4967 }
4968
4969 #[test]
4972 fn test_circuit_stats_new() {
4973 let _guard = test_guard!();
4974 let stats = CircuitStats::new();
4975 assert_eq!(stats.state(), CircuitState::Closed);
4976 assert_eq!(stats.consecutive_failures(), 0);
4977 assert_eq!(stats.error_rate(), 0.0);
4978 assert!(stats.opened_at().is_none());
4979 }
4980
4981 #[test]
4982 fn test_circuit_stats_record_success() {
4983 let _guard = test_guard!();
4984 let mut stats = CircuitStats::new();
4985 stats.record_success();
4986 stats.record_success();
4987 assert_eq!(stats.error_rate(), 0.0);
4988 assert_eq!(stats.consecutive_failures(), 0);
4989 }
4990
4991 #[test]
4992 fn test_circuit_stats_record_failure() {
4993 let _guard = test_guard!();
4994 let mut stats = CircuitStats::new();
4995 stats.record_failure();
4996 stats.record_failure();
4997 assert_eq!(stats.consecutive_failures(), 2);
4998 assert_eq!(stats.error_rate(), 1.0); }
5000
5001 #[test]
5002 fn test_circuit_stats_error_rate() {
5003 let _guard = test_guard!();
5004 let mut stats = CircuitStats::new();
5005 stats.record_success();
5006 stats.record_success();
5007 stats.record_failure();
5008 assert!((stats.error_rate() - 0.333).abs() < 0.01);
5010 }
5011
5012 #[test]
5013 fn test_circuit_stats_should_open_consecutive_failures() {
5014 let _guard = test_guard!();
5015 let mut stats = CircuitStats::new();
5016 let config = CircuitBreakerConfig::default(); stats.record_failure();
5019 assert!(!stats.should_open(&config));
5020
5021 stats.record_failure();
5022 assert!(!stats.should_open(&config));
5023
5024 stats.record_failure();
5025 assert!(stats.should_open(&config)); }
5027
5028 #[test]
5029 fn test_circuit_stats_should_open_error_rate() {
5030 let _guard = test_guard!();
5031 let mut stats = CircuitStats::new();
5032 let config = CircuitBreakerConfig {
5033 error_rate_threshold: 0.5,
5034 ..Default::default()
5035 };
5036
5037 stats.record_success();
5039 stats.record_success();
5040 stats.record_failure();
5041 stats.record_failure();
5042 assert!(!stats.should_open(&config)); stats.record_failure(); assert!(stats.should_open(&config));
5046 }
5047
5048 #[test]
5049 fn test_circuit_close_clears_recent_results_no_flap() {
5050 let _guard = test_guard!();
5051 let mut stats = CircuitStats::new();
5055 let config = CircuitBreakerConfig {
5056 error_rate_threshold: 0.5,
5057 ..Default::default()
5058 };
5059 for _ in 0..6 {
5060 stats.record_failure();
5061 }
5062 assert!(stats.should_open(&config));
5063 stats.open();
5064 stats.half_open();
5066 stats.record_success();
5067 stats.record_success();
5068 stats.close();
5069 assert_eq!(stats.error_rate(), 0.0);
5071 assert!(!stats.should_open(&config));
5072 }
5073
5074 #[test]
5075 fn test_apply_health_outcome_opens_on_failure_threshold() {
5076 let _guard = test_guard!();
5077 let config = CircuitBreakerConfig {
5079 failure_threshold: 3,
5080 open_cooldown_secs: 300,
5081 ..Default::default()
5082 };
5083 let mut stats = CircuitStats::new();
5084 assert_eq!(
5085 stats.apply_health_outcome(false, false, &config),
5086 CircuitState::Closed
5087 );
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::Open
5095 );
5096 }
5097
5098 #[test]
5099 fn test_apply_health_outcome_half_open_two_probe_recovery() {
5100 let _guard = test_guard!();
5101 let config = CircuitBreakerConfig {
5107 failure_threshold: 3,
5108 success_threshold: 2,
5109 open_cooldown_secs: 0,
5110 ..Default::default()
5111 };
5112 let mut stats = CircuitStats::new();
5113 for _ in 0..3 {
5114 stats.apply_health_outcome(false, false, &config);
5115 }
5116 assert_eq!(stats.state(), CircuitState::HalfOpen);
5118
5119 let after_first = stats.apply_health_outcome(true, false, &config);
5121 assert_eq!(after_first, CircuitState::HalfOpen);
5122
5123 let after_second = stats.apply_health_outcome(true, false, &config);
5125 assert_eq!(after_second, CircuitState::Closed);
5126 }
5127
5128 #[test]
5129 fn test_apply_health_outcome_transient_half_open_does_not_reopen() {
5130 let _guard = test_guard!();
5131 let config = CircuitBreakerConfig {
5134 failure_threshold: 3,
5135 success_threshold: 2,
5136 open_cooldown_secs: 0,
5137 ..Default::default()
5138 };
5139 let mut stats = CircuitStats::new();
5140 for _ in 0..3 {
5141 stats.apply_health_outcome(false, false, &config);
5142 }
5143 assert_eq!(stats.state(), CircuitState::HalfOpen);
5145
5146 assert_eq!(
5148 stats.apply_health_outcome(true, false, &config),
5149 CircuitState::HalfOpen
5150 );
5151
5152 let after_transient = stats.apply_health_outcome(false, true, &config);
5158 assert_eq!(after_transient, CircuitState::HalfOpen);
5159
5160 assert_eq!(
5162 stats.apply_health_outcome(true, false, &config),
5163 CircuitState::HalfOpen
5164 );
5165 assert_eq!(
5166 stats.apply_health_outcome(true, false, &config),
5167 CircuitState::Closed
5168 );
5169 }
5170
5171 #[test]
5172 fn test_apply_health_outcome_real_vs_transient_half_open_failure() {
5173 let _guard = test_guard!();
5174 let config = CircuitBreakerConfig {
5182 failure_threshold: 3,
5183 success_threshold: 2,
5184 open_cooldown_secs: 300,
5185 ..Default::default()
5186 };
5187
5188 let mut real = CircuitStats::new();
5190 real.open();
5191 real.half_open();
5192 assert_eq!(real.state(), CircuitState::HalfOpen);
5193 let after_real = real.apply_health_outcome(false, false, &config);
5194 assert_eq!(
5195 after_real,
5196 CircuitState::Open,
5197 "a real half-open failure must reopen the circuit"
5198 );
5199
5200 let mut transient = CircuitStats::new();
5202 transient.open();
5203 transient.half_open();
5204 assert_eq!(transient.state(), CircuitState::HalfOpen);
5205 let after_transient = transient.apply_health_outcome(false, true, &config);
5206 assert_eq!(
5207 after_transient,
5208 CircuitState::HalfOpen,
5209 "a transient half-open failure must NOT reopen the circuit"
5210 );
5211 }
5212
5213 #[test]
5218 fn test_command_failure_blocks_half_open_close_on_liveness_successes() {
5219 let _guard = test_guard!();
5220 let config = CircuitBreakerConfig {
5221 failure_threshold: 3,
5222 success_threshold: 2,
5223 open_cooldown_secs: 0,
5224 command_failure_threshold: 2,
5225 ..Default::default()
5226 };
5227 let mut stats = CircuitStats::new();
5228 stats.open();
5229 stats.half_open();
5230
5231 assert_eq!(
5233 stats.apply_command_outcome(false, &config),
5234 CircuitState::HalfOpen,
5235 "cooldown 0: reopen then immediately half-open again"
5236 );
5237 assert_eq!(stats.consecutive_command_failures(), 1);
5238
5239 for _ in 0..10 {
5241 assert_eq!(
5242 stats.apply_health_outcome(true, false, &config),
5243 CircuitState::HalfOpen,
5244 "liveness successes must not close the circuit while a command failure is outstanding"
5245 );
5246 }
5247
5248 assert_eq!(
5250 stats.apply_command_outcome(true, &config),
5251 CircuitState::Closed
5252 );
5253 assert_eq!(stats.consecutive_command_failures(), 0);
5254 assert_eq!(stats.reopen_streak(), 0);
5255 }
5256
5257 #[test]
5260 fn test_command_failures_open_closed_circuit_despite_liveness_successes() {
5261 let _guard = test_guard!();
5262 let config = CircuitBreakerConfig {
5263 failure_threshold: 3,
5264 command_failure_threshold: 2,
5265 open_cooldown_secs: 300,
5266 ..Default::default()
5267 };
5268 let mut stats = CircuitStats::new();
5269
5270 assert_eq!(
5271 stats.apply_command_outcome(false, &config),
5272 CircuitState::Closed
5273 );
5274 stats.apply_health_outcome(true, false, &config);
5276 stats.apply_health_outcome(true, false, &config);
5277 assert_eq!(stats.consecutive_failures(), 0);
5278 assert_eq!(
5280 stats.apply_command_outcome(false, &config),
5281 CircuitState::Open
5282 );
5283 assert_eq!(stats.reopen_streak(), 1);
5284 }
5285
5286 #[test]
5290 fn test_command_failure_in_half_open_always_reopens() {
5291 let _guard = test_guard!();
5292 let config = CircuitBreakerConfig {
5293 open_cooldown_secs: 300,
5294 ..Default::default()
5295 };
5296 let mut stats = CircuitStats::new();
5297 stats.open();
5298 stats.half_open();
5299 assert_eq!(
5300 stats.apply_command_outcome(false, &config),
5301 CircuitState::Open
5302 );
5303 assert_eq!(stats.reopen_streak(), 2);
5304 }
5305
5306 #[test]
5310 fn test_reopen_streak_backs_off_cooldown_until_cap() {
5311 let _guard = test_guard!();
5312 let config = CircuitBreakerConfig {
5313 open_cooldown_secs: 30,
5314 open_cooldown_max_secs: 100,
5315 ..Default::default()
5316 };
5317 assert_eq!(config.cooldown_ms_for_streak(0), 30_000);
5318 assert_eq!(config.cooldown_ms_for_streak(1), 30_000);
5319 assert_eq!(config.cooldown_ms_for_streak(2), 60_000);
5320 assert_eq!(config.cooldown_ms_for_streak(3), 100_000, "capped");
5321 assert_eq!(config.cooldown_ms_for_streak(40), 100_000, "no overflow");
5322
5323 let inverted = CircuitBreakerConfig {
5325 open_cooldown_secs: 30,
5326 open_cooldown_max_secs: 5,
5327 ..Default::default()
5328 };
5329 assert_eq!(inverted.cooldown_ms_for_streak(3), 30_000);
5330
5331 let mut stats = CircuitStats::new();
5334 stats.open();
5335 stats.half_open();
5336 stats.open();
5337 assert_eq!(stats.reopen_streak(), 2);
5338 let remaining = stats.recovery_remaining_secs(&config).unwrap();
5339 assert!(
5340 remaining > 30 && remaining <= 60,
5341 "second open should wait ~60s, got {remaining}"
5342 );
5343
5344 stats.close();
5346 assert_eq!(stats.reopen_streak(), 0);
5347 stats.open();
5348 assert!(stats.recovery_remaining_secs(&config).unwrap() <= 30);
5349 }
5350
5351 #[test]
5352 fn test_circuit_stats_success_resets_consecutive_failures() {
5353 let _guard = test_guard!();
5354 let mut stats = CircuitStats::new();
5355 stats.record_failure();
5356 stats.record_failure();
5357 assert_eq!(stats.consecutive_failures(), 2);
5358
5359 stats.record_success();
5360 assert_eq!(stats.consecutive_failures(), 0);
5361 }
5362
5363 #[test]
5364 fn test_circuit_stats_open_transition() {
5365 let _guard = test_guard!();
5366 let mut stats = CircuitStats::new();
5367 stats.open();
5368
5369 assert_eq!(stats.state(), CircuitState::Open);
5370 assert!(stats.opened_at().is_some());
5371 }
5372
5373 #[test]
5374 fn test_circuit_stats_half_open_transition() {
5375 let _guard = test_guard!();
5376 let mut stats = CircuitStats::new();
5377 stats.open();
5378 stats.half_open();
5379
5380 assert_eq!(stats.state(), CircuitState::HalfOpen);
5381 }
5382
5383 #[test]
5384 fn test_circuit_stats_close_transition() {
5385 let _guard = test_guard!();
5386 let mut stats = CircuitStats::new();
5387 stats.open();
5388 stats.half_open();
5389 stats.close();
5390
5391 assert_eq!(stats.state(), CircuitState::Closed);
5392 assert!(stats.opened_at().is_none());
5393 assert_eq!(stats.consecutive_failures(), 0);
5394 }
5395
5396 #[test]
5397 fn test_circuit_stats_should_close() {
5398 let _guard = test_guard!();
5399 let mut stats = CircuitStats::new();
5400 let config = CircuitBreakerConfig {
5401 success_threshold: 2,
5402 ..Default::default()
5403 };
5404
5405 stats.open();
5406 stats.half_open();
5407
5408 assert!(!stats.should_close(&config)); stats.record_success();
5411 assert!(!stats.should_close(&config)); stats.record_success();
5414 assert!(stats.should_close(&config)); }
5416
5417 #[test]
5418 fn test_circuit_stats_can_probe() {
5419 let _guard = test_guard!();
5420 let mut stats = CircuitStats::new();
5421 let config = CircuitBreakerConfig {
5422 half_open_max_probes: 1,
5423 ..Default::default()
5424 };
5425
5426 assert!(!stats.can_probe(&config));
5428
5429 stats.open();
5430 assert!(!stats.can_probe(&config));
5432
5433 stats.half_open();
5434 assert!(stats.can_probe(&config));
5436
5437 assert!(stats.start_probe(&config));
5439 assert!(!stats.can_probe(&config));
5441 assert!(!stats.start_probe(&config));
5442 }
5443
5444 #[test]
5445 fn test_circuit_stats_probe_completion() {
5446 let _guard = test_guard!();
5447 let mut stats = CircuitStats::new();
5448 let config = CircuitBreakerConfig {
5449 half_open_max_probes: 1,
5450 ..Default::default()
5451 };
5452
5453 stats.open();
5454 stats.half_open();
5455 stats.start_probe(&config);
5456
5457 stats.record_success();
5459
5460 assert!(stats.can_probe(&config));
5462 }
5463
5464 #[test]
5465 fn test_circuit_stats_failure_in_half_open() {
5466 let _guard = test_guard!();
5467 let mut stats = CircuitStats::new();
5468 let config = CircuitBreakerConfig {
5469 success_threshold: 2,
5470 ..Default::default()
5471 };
5472
5473 stats.open();
5474 stats.half_open();
5475
5476 stats.record_success();
5477 assert_eq!(stats.consecutive_failures(), 0);
5478
5479 stats.record_failure();
5481 assert!(!stats.should_close(&config));
5482
5483 stats.record_success();
5485 stats.record_success();
5486 assert!(stats.should_close(&config));
5487 }
5488
5489 #[test]
5490 fn test_circuit_stats_reset_window() {
5491 let _guard = test_guard!();
5492 let mut stats = CircuitStats::new();
5493 stats.record_success();
5494 stats.record_failure();
5495
5496 assert!(stats.error_rate() > 0.0);
5497
5498 stats.reset_window();
5499 assert_eq!(stats.error_rate(), 0.0);
5500 }
5501
5502 #[test]
5503 fn test_circuit_state_transitions_are_deterministic() {
5504 let _guard = test_guard!();
5505 let config = CircuitBreakerConfig::default();
5506 let mut stats = CircuitStats::new();
5507
5508 assert_eq!(stats.state(), CircuitState::Closed);
5510
5511 for _ in 0..3 {
5513 stats.record_failure();
5514 }
5515 assert!(stats.should_open(&config));
5516 stats.open();
5517 assert_eq!(stats.state(), CircuitState::Open);
5518
5519 stats.half_open();
5521 assert_eq!(stats.state(), CircuitState::HalfOpen);
5522
5523 for _ in 0..2 {
5525 stats.record_success();
5526 }
5527 assert!(stats.should_close(&config));
5528 stats.close();
5529 assert_eq!(stats.state(), CircuitState::Closed);
5530 }
5531
5532 fn make_test_metrics(
5537 speedup: Option<f64>,
5538 total_secs: u64,
5539 success: bool,
5540 ) -> CompilationMetrics {
5541 CompilationMetrics {
5542 project_id: "test-project".to_string(),
5543 worker_id: "worker-1".to_string(),
5544 timestamp: chrono::Utc::now(),
5545 timing: CompilationTimingBreakdown {
5546 rsync_up: Duration::from_millis(100),
5547 remote_build: Duration::from_millis(800),
5548 rsync_down: Duration::from_millis(100),
5549 total: Duration::from_secs(total_secs),
5550 },
5551 local_build_time: speedup.map(|s| Duration::from_secs_f64(total_secs as f64 * s)),
5552 speedup,
5553 files_synced: 100,
5554 bytes_transferred: 1_000_000,
5555 exit_code: if success { 0 } else { 1 },
5556 success,
5557 }
5558 }
5559
5560 #[test]
5561 fn test_compilation_timing_breakdown_default() {
5562 let _guard = test_guard!();
5563 let timing = CompilationTimingBreakdown::default();
5564 assert_eq!(timing.rsync_up, Duration::ZERO);
5565 assert_eq!(timing.remote_build, Duration::ZERO);
5566 assert_eq!(timing.rsync_down, Duration::ZERO);
5567 assert_eq!(timing.total, Duration::ZERO);
5568 }
5569
5570 #[test]
5571 fn test_compilation_timing_breakdown_serialization() {
5572 let _guard = test_guard!();
5573 let timing = CompilationTimingBreakdown {
5574 rsync_up: Duration::from_millis(100),
5575 remote_build: Duration::from_millis(2000),
5576 rsync_down: Duration::from_millis(50),
5577 total: Duration::from_millis(2150),
5578 };
5579
5580 let json = serde_json::to_string(&timing).unwrap();
5581 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();
5587 assert_eq!(parsed.rsync_up, timing.rsync_up);
5588 assert_eq!(parsed.remote_build, timing.remote_build);
5589 assert_eq!(parsed.rsync_down, timing.rsync_down);
5590 assert_eq!(parsed.total, timing.total);
5591 }
5592
5593 #[test]
5594 fn test_command_timing_breakdown_serialization() {
5595 let _guard = test_guard!();
5596 let timing = CommandTimingBreakdown {
5597 classify: Some(Duration::from_millis(2)),
5598 select: None,
5599 sync_up: Some(Duration::from_millis(120)),
5600 exec: Some(Duration::from_millis(900)),
5601 sync_down: None,
5602 cleanup: Some(Duration::from_millis(5)),
5603 total: Some(Duration::from_millis(1027)),
5604 };
5605
5606 let json = serde_json::to_string(&timing).unwrap();
5607 assert!(json.contains("\"classify\":2"));
5608 assert!(json.contains("\"select\":null"));
5609 assert!(json.contains("\"sync_up\":120"));
5610 assert!(json.contains("\"exec\":900"));
5611 assert!(json.contains("\"sync_down\":null"));
5612 assert!(json.contains("\"cleanup\":5"));
5613 assert!(json.contains("\"total\":1027"));
5614
5615 let parsed: CommandTimingBreakdown = serde_json::from_str(&json).unwrap();
5616 assert_eq!(parsed.classify, timing.classify);
5617 assert_eq!(parsed.select, timing.select);
5618 assert_eq!(parsed.sync_up, timing.sync_up);
5619 assert_eq!(parsed.exec, timing.exec);
5620 assert_eq!(parsed.sync_down, timing.sync_down);
5621 assert_eq!(parsed.cleanup, timing.cleanup);
5622 assert_eq!(parsed.total, timing.total);
5623 }
5624
5625 #[test]
5626 fn test_compilation_metrics_calculate_speedup() {
5627 let _guard = test_guard!();
5628 let mut metrics = CompilationMetrics {
5629 timing: CompilationTimingBreakdown {
5630 total: Duration::from_secs(10),
5631 ..Default::default()
5632 },
5633 local_build_time: Some(Duration::from_secs(30)),
5634 ..Default::default()
5635 };
5636
5637 metrics.calculate_speedup();
5638
5639 assert!(metrics.speedup.is_some());
5640 let speedup = metrics.speedup.unwrap();
5641 assert!(
5642 (speedup - 3.0).abs() < 0.01,
5643 "Expected 3.0x speedup, got {}",
5644 speedup
5645 );
5646 }
5647
5648 #[test]
5649 fn test_compilation_metrics_is_beneficial() {
5650 let _guard = test_guard!();
5651 let mut metrics = CompilationMetrics::default();
5652
5653 assert!(!metrics.is_beneficial());
5655
5656 metrics.speedup = Some(1.5);
5658 assert!(metrics.is_beneficial());
5659
5660 metrics.speedup = Some(0.8);
5662 assert!(!metrics.is_beneficial());
5663
5664 metrics.speedup = Some(1.0);
5666 assert!(!metrics.is_beneficial());
5667 }
5668
5669 #[test]
5670 fn test_compilation_metrics_serialization() {
5671 let _guard = test_guard!();
5672 let metrics = make_test_metrics(Some(2.5), 10, true);
5673 let json = serde_json::to_string(&metrics).unwrap();
5674
5675 assert!(json.contains("test-project"));
5676 assert!(json.contains("worker-1"));
5677 assert!(json.contains("2.5")); let parsed: CompilationMetrics = serde_json::from_str(&json).unwrap();
5680 assert_eq!(parsed.project_id, "test-project");
5681 assert_eq!(parsed.worker_id, "worker-1");
5682 assert!(parsed.success);
5683 }
5684
5685 #[test]
5686 fn test_compilation_timer_new() {
5687 let _guard = test_guard!();
5688 let timer = CompilationTimer::new("my-project", "my-worker");
5689 assert_eq!(timer.project_id, "my-project");
5690 assert_eq!(timer.worker_id, "my-worker");
5691 assert!(timer.rsync_up.is_none());
5692 assert!(timer.remote_build.is_none());
5693 assert!(timer.rsync_down.is_none());
5694 }
5695
5696 #[test]
5697 fn test_compilation_timer_phases() {
5698 let _guard = test_guard!();
5699 let mut timer = CompilationTimer::new("test", "worker");
5700
5701 std::thread::sleep(Duration::from_millis(5));
5703 timer.end_rsync_up();
5704 assert!(timer.rsync_up.is_some());
5705 assert!(timer.rsync_up.unwrap() >= Duration::from_millis(5));
5706
5707 std::thread::sleep(Duration::from_millis(10));
5709 timer.end_remote_build();
5710 assert!(timer.remote_build.is_some());
5711 assert!(timer.remote_build.unwrap() >= Duration::from_millis(10));
5712
5713 std::thread::sleep(Duration::from_millis(5));
5715 timer.end_rsync_down();
5716 assert!(timer.rsync_down.is_some());
5717 assert!(timer.rsync_down.unwrap() >= Duration::from_millis(5));
5718
5719 let metrics = timer.finish(0, 50, 500_000);
5721 assert_eq!(metrics.project_id, "test");
5722 assert_eq!(metrics.worker_id, "worker");
5723 assert!(metrics.success);
5724 assert!(metrics.timing.total >= Duration::from_millis(20));
5725 assert_eq!(metrics.files_synced, 50);
5726 assert_eq!(metrics.bytes_transferred, 500_000);
5727 }
5728
5729 #[test]
5730 fn test_metrics_aggregator_new() {
5731 let _guard = test_guard!();
5732 let agg = MetricsAggregator::new(100);
5733 assert_eq!(agg.count(), 0);
5734 assert!(agg.average_speedup().is_none());
5735 }
5736
5737 #[test]
5738 fn test_metrics_aggregator_record() {
5739 let _guard = test_guard!();
5740 let mut agg = MetricsAggregator::new(3);
5741
5742 agg.record(make_test_metrics(Some(2.0), 10, true));
5744 assert_eq!(agg.count(), 1);
5745
5746 agg.record(make_test_metrics(Some(3.0), 20, true));
5748 agg.record(make_test_metrics(Some(4.0), 30, true));
5749 assert_eq!(agg.count(), 3);
5750
5751 agg.record(make_test_metrics(Some(5.0), 40, true));
5753 assert_eq!(agg.count(), 3);
5754 }
5755
5756 #[test]
5757 fn test_metrics_aggregator_average_speedup() {
5758 let _guard = test_guard!();
5759 let mut agg = MetricsAggregator::new(100);
5760
5761 assert!(agg.average_speedup().is_none());
5763
5764 for i in 1..=5 {
5766 agg.record(make_test_metrics(Some(i as f64), 10, true));
5767 }
5768
5769 let avg = agg.average_speedup().unwrap();
5770 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5771 }
5772
5773 #[test]
5774 fn test_metrics_aggregator_average_speedup_with_none() {
5775 let _guard = test_guard!();
5776 let mut agg = MetricsAggregator::new(100);
5777
5778 agg.record(make_test_metrics(Some(2.0), 10, true));
5780 agg.record(make_test_metrics(None, 10, true)); agg.record(make_test_metrics(Some(4.0), 10, true));
5782
5783 let avg = agg.average_speedup().unwrap();
5785 assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5786 }
5787
5788 #[test]
5789 fn test_metrics_aggregator_percentiles() {
5790 let _guard = test_guard!();
5791 let mut agg = MetricsAggregator::new(100);
5792
5793 for i in 1..=10 {
5795 agg.record(make_test_metrics(Some(1.0), i, true));
5796 }
5797
5798 let p50 = agg.p50_total_time().unwrap();
5800 assert!(p50 >= Duration::from_secs(5) && p50 <= Duration::from_secs(6));
5801
5802 let p95 = agg.p95_total_time().unwrap();
5804 assert!(p95 >= Duration::from_secs(9) && p95 <= Duration::from_secs(10));
5805
5806 let p99 = agg.p99_total_time().unwrap();
5808 assert!(p99 >= Duration::from_secs(9));
5809 }
5810
5811 #[test]
5812 fn test_metrics_aggregator_success_rate() {
5813 let _guard = test_guard!();
5814 let mut agg = MetricsAggregator::new(100);
5815
5816 assert_eq!(agg.success_rate(), 100.0);
5818
5819 agg.record(make_test_metrics(Some(1.0), 10, true));
5821 agg.record(make_test_metrics(Some(1.0), 10, true));
5822 assert_eq!(agg.success_rate(), 100.0);
5823
5824 agg.record(make_test_metrics(Some(1.0), 10, false));
5826 let rate = agg.success_rate();
5827 assert!((rate - 66.67).abs() < 1.0, "Expected ~66.67%, got {}", rate);
5828 }
5829
5830 #[test]
5831 fn test_metrics_aggregator_clear() {
5832 let _guard = test_guard!();
5833 let mut agg = MetricsAggregator::new(100);
5834 agg.record(make_test_metrics(Some(1.0), 10, true));
5835 agg.record(make_test_metrics(Some(2.0), 20, true));
5836
5837 assert_eq!(agg.count(), 2);
5838
5839 agg.clear();
5840 assert_eq!(agg.count(), 0);
5841 assert!(agg.average_speedup().is_none());
5842 }
5843
5844 #[test]
5847 fn test_remote_build_jobs_parse_display_and_serde() {
5848 let _guard = test_guard!();
5849 assert_eq!(RemoteBuildJobs::default(), RemoteBuildJobs::Auto);
5850 for (spelling, expected) in [
5851 ("auto", RemoteBuildJobs::Auto),
5852 (" AUTO ", RemoteBuildJobs::Auto),
5853 ("off", RemoteBuildJobs::Off),
5854 ("none", RemoteBuildJobs::Off),
5855 ("4", RemoteBuildJobs::Fixed(4)),
5856 ] {
5857 assert_eq!(
5858 RemoteBuildJobs::parse(spelling).unwrap(),
5859 expected,
5860 "{spelling}"
5861 );
5862 }
5863 assert!(RemoteBuildJobs::parse("0").is_err(), "cargo rejects -j 0");
5864 assert!(RemoteBuildJobs::parse("many").is_err());
5865 assert_eq!(RemoteBuildJobs::Fixed(4).to_string(), "4");
5866 assert_eq!(RemoteBuildJobs::Off.to_string(), "off");
5867
5868 #[derive(Deserialize)]
5871 struct Wrap {
5872 compilation: CompilationConfig,
5873 }
5874 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = \"off\"\n").unwrap();
5875 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Off);
5876 let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = 6\n").unwrap();
5877 assert_eq!(
5878 parsed.compilation.remote_build_jobs,
5879 RemoteBuildJobs::Fixed(6)
5880 );
5881 let parsed: Wrap = toml::from_str("[compilation]\nbuild_slots = 4\n").unwrap();
5882 assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Auto);
5883 assert!(toml::from_str::<Wrap>("[compilation]\nremote_build_jobs = 0\n").is_err());
5884
5885 let json = serde_json::to_string(&RemoteBuildJobs::Fixed(3)).unwrap();
5887 assert_eq!(json, "3");
5888 assert_eq!(
5889 serde_json::to_string(&RemoteBuildJobs::Auto).unwrap(),
5890 "\"auto\""
5891 );
5892 let back: RemoteBuildJobs = serde_json::from_str("\"auto\"").unwrap();
5893 assert_eq!(back, RemoteBuildJobs::Auto);
5894 }
5895
5896 #[test]
5897 fn test_compilation_config_default_timeouts() {
5898 let _guard = test_guard!();
5899 let config = CompilationConfig::default();
5900 assert_eq!(config.build_timeout_sec, 300);
5902 assert_eq!(config.test_timeout_sec, 1800);
5904 }
5905
5906 #[test]
5907 fn test_compilation_config_timeout_for_test_kinds() {
5908 let _guard = test_guard!();
5909 let config = CompilationConfig::default();
5910
5911 assert_eq!(
5913 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5914 std::time::Duration::from_secs(1800)
5915 );
5916 assert_eq!(
5917 config.timeout_for_kind(Some(crate::CompilationKind::CargoNextest)),
5918 std::time::Duration::from_secs(1800)
5919 );
5920 assert_eq!(
5921 config.timeout_for_kind(Some(crate::CompilationKind::BunTest)),
5922 std::time::Duration::from_secs(config.bun_timeout_sec)
5923 );
5924 }
5925
5926 #[test]
5927 fn test_compilation_config_timeout_for_build_kinds() {
5928 let _guard = test_guard!();
5929 let config = CompilationConfig::default();
5930
5931 assert_eq!(
5933 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5934 std::time::Duration::from_secs(300)
5935 );
5936 assert_eq!(
5937 config.timeout_for_kind(Some(crate::CompilationKind::CargoCheck)),
5938 std::time::Duration::from_secs(300)
5939 );
5940 assert_eq!(
5941 config.timeout_for_kind(Some(crate::CompilationKind::CargoClippy)),
5942 std::time::Duration::from_secs(300)
5943 );
5944 assert_eq!(
5945 config.timeout_for_kind(None),
5946 std::time::Duration::from_secs(300)
5947 );
5948 }
5949
5950 #[test]
5951 fn test_compilation_config_custom_timeouts() {
5952 let _guard = test_guard!();
5953 let config = CompilationConfig {
5954 build_timeout_sec: 600, test_timeout_sec: 3600, ..Default::default()
5957 };
5958
5959 assert_eq!(
5960 config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5961 std::time::Duration::from_secs(600)
5962 );
5963 assert_eq!(
5964 config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5965 std::time::Duration::from_secs(3600)
5966 );
5967 }
5968
5969 #[test]
5970 fn test_compilation_config_speedup_threshold_default() {
5971 let _guard = test_guard!();
5972 let config = CompilationConfig::default();
5973 assert!((config.remote_speedup_threshold - 1.2).abs() < 0.001);
5975 }
5976
5977 #[test]
5978 fn test_compilation_config_speedup_threshold_custom() {
5979 let _guard = test_guard!();
5980 let config = CompilationConfig {
5981 remote_speedup_threshold: 1.5, ..Default::default()
5983 };
5984 assert!((config.remote_speedup_threshold - 1.5).abs() < 0.001);
5985 }
5986
5987 #[test]
5988 fn test_compilation_config_speedup_threshold_no_minimum() {
5989 let _guard = test_guard!();
5990 let config = CompilationConfig {
5992 remote_speedup_threshold: 1.0,
5993 ..Default::default()
5994 };
5995 assert!((config.remote_speedup_threshold - 1.0).abs() < 0.001);
5996 }
5997
5998 #[test]
5999 fn test_compilation_config_min_local_time_ms_default() {
6000 let _guard = test_guard!();
6001 let config = CompilationConfig::default();
6002 assert_eq!(config.min_local_time_ms, 2000);
6004 }
6005
6006 #[test]
6007 fn test_compilation_config_serde_roundtrip_speedup_threshold() {
6008 let _guard = test_guard!();
6009 let config = CompilationConfig {
6010 remote_speedup_threshold: 2.5,
6011 min_local_time_ms: 5000,
6012 ..Default::default()
6013 };
6014 let json = serde_json::to_string(&config).unwrap();
6015 let parsed: CompilationConfig = serde_json::from_str(&json).unwrap();
6016 assert!((parsed.remote_speedup_threshold - 2.5).abs() < 0.001);
6017 assert_eq!(parsed.min_local_time_ms, 5000);
6018 }
6019
6020 #[test]
6025 fn test_validate_remote_base_absolute_path() {
6026 let _guard = test_guard!();
6027 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
6028 assert_eq!(
6029 validate_remote_base("/var/rch-builds").unwrap(),
6030 "/var/rch-builds"
6031 );
6032 assert_eq!(
6033 validate_remote_base("/home/builder/.rch").unwrap(),
6034 "/home/builder/.rch"
6035 );
6036 }
6037
6038 #[test]
6039 fn test_validate_remote_base_tilde_expansion() {
6040 let _guard = test_guard!();
6041 let result = validate_remote_base("~/rch");
6043 assert!(result.is_ok());
6044 let path = result.unwrap();
6045 assert!(
6046 path.starts_with('/'),
6047 "Path should be absolute after expansion: {}",
6048 path
6049 );
6050 assert!(!path.contains('~'), "Tilde should be expanded: {}", path);
6051 }
6052
6053 #[test]
6054 fn test_validate_remote_base_rejects_relative_path() {
6055 let _guard = test_guard!();
6056 let result = validate_remote_base("tmp/rch");
6057 assert!(result.is_err());
6058 assert!(result.unwrap_err().contains("absolute path"));
6059 }
6060
6061 #[test]
6062 fn test_validate_remote_base_rejects_path_traversal() {
6063 let _guard = test_guard!();
6064 let result = validate_remote_base("/tmp/../etc/rch");
6065 assert!(result.is_err());
6066 assert!(result.unwrap_err().contains("path traversal"));
6067
6068 let result = validate_remote_base("/tmp/rch/../other");
6069 assert!(result.is_err());
6070 assert!(result.unwrap_err().contains("path traversal"));
6071 }
6072
6073 #[test]
6074 fn test_validate_remote_base_normalizes_trailing_slash() {
6075 let _guard = test_guard!();
6076 assert_eq!(validate_remote_base("/tmp/rch/").unwrap(), "/tmp/rch");
6077 assert_eq!(validate_remote_base("/tmp/rch///").unwrap(), "/tmp/rch");
6078 }
6079
6080 #[test]
6081 fn test_validate_remote_base_root_path() {
6082 let _guard = test_guard!();
6083 let result = validate_remote_base("/");
6085 assert!(result.is_err());
6086 assert!(result.unwrap_err().contains("root directory"));
6087 }
6088
6089 #[test]
6090 fn test_validate_remote_base_rejects_top_level() {
6091 let _guard = test_guard!();
6092 let result = validate_remote_base("/tmp");
6094 assert!(result.is_err());
6095 assert!(result.unwrap_err().contains("at least 2 levels deep"));
6096
6097 let result = validate_remote_base("/home");
6098 assert!(result.is_err());
6099
6100 assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
6102 }
6103
6104 #[test]
6105 fn test_default_remote_base() {
6106 let _guard = test_guard!();
6107 assert_eq!(default_remote_base(), "/data/tmp/rch");
6108 }
6109
6110 #[test]
6111 fn test_transfer_config_default_has_remote_base() {
6112 let _guard = test_guard!();
6113 let config = TransferConfig::default();
6114 assert_eq!(config.remote_base, "/data/tmp/rch");
6115 }
6116
6117 #[test]
6118 fn test_source_sync_timeout_validation_boundaries() {
6119 let _guard = test_guard!();
6120 assert!(!TransferConfig::valid_sync_timeout_ms(0));
6121 assert!(!TransferConfig::valid_sync_timeout_ms(999));
6122 assert!(TransferConfig::valid_sync_timeout_ms(1_000));
6123 assert!(TransferConfig::valid_sync_timeout_ms(3_600_000));
6124 assert!(!TransferConfig::valid_sync_timeout_ms(3_600_001));
6125 }
6126
6127 #[test]
6128 fn test_source_sync_timeout_is_explicit_or_payload_aware() {
6129 let _guard = test_guard!();
6130 let default = TransferConfig::default();
6131 assert_eq!(
6132 default.sync_timeout_for_payload(0),
6133 std::time::Duration::from_millis(30_000)
6134 );
6135 assert_eq!(
6136 default.sync_timeout_for_payload(10 * 1024 * 1024),
6137 std::time::Duration::from_millis(40_000)
6138 );
6139 assert_eq!(
6140 default.sync_timeout_for_payload(u64::MAX),
6141 std::time::Duration::from_millis(3_600_000)
6142 );
6143
6144 let explicit = TransferConfig {
6145 sync_timeout_ms: Some(120_000),
6146 ..TransferConfig::default()
6147 };
6148 assert_eq!(
6149 explicit.sync_timeout_for_payload(u64::MAX),
6150 std::time::Duration::from_millis(120_000)
6151 );
6152 }
6153
6154 #[test]
6159 fn test_retry_config_default() {
6160 let _guard = test_guard!();
6161 let config = RetryConfig::default();
6162 assert_eq!(config.max_attempts, 3);
6163 assert_eq!(config.base_delay_ms, 100);
6164 assert_eq!(config.max_delay_ms, 5000);
6165 assert_eq!(config.jitter_factor, 0.1);
6166 assert_eq!(config.total_timeout_ms, 30000);
6167 }
6168
6169 #[test]
6170 fn test_retry_config_no_retry() {
6171 let _guard = test_guard!();
6172 let config = RetryConfig::no_retry();
6173 assert_eq!(config.max_attempts, 1);
6174 }
6175
6176 #[test]
6177 fn test_retry_config_delay_for_attempt_zero() {
6178 let _guard = test_guard!();
6179 let config = RetryConfig::default();
6180 assert_eq!(config.delay_for_attempt(0), std::time::Duration::ZERO);
6181 }
6182
6183 #[test]
6184 fn test_retry_config_delay_for_attempt_exponential() {
6185 let _guard = test_guard!();
6186 let config = RetryConfig {
6187 base_delay_ms: 100,
6188 max_delay_ms: 10000,
6189 jitter_factor: 0.0, ..Default::default()
6191 };
6192
6193 assert_eq!(
6195 config.delay_for_attempt(1),
6196 std::time::Duration::from_millis(100)
6197 );
6198 assert_eq!(
6200 config.delay_for_attempt(2),
6201 std::time::Duration::from_millis(200)
6202 );
6203 assert_eq!(
6205 config.delay_for_attempt(3),
6206 std::time::Duration::from_millis(400)
6207 );
6208 }
6209
6210 #[test]
6211 fn test_retry_config_delay_capped_at_max() {
6212 let _guard = test_guard!();
6213 let config = RetryConfig {
6214 base_delay_ms: 1000,
6215 max_delay_ms: 2000,
6216 jitter_factor: 0.0,
6217 ..Default::default()
6218 };
6219
6220 assert_eq!(
6222 config.delay_for_attempt(2),
6223 std::time::Duration::from_millis(2000)
6224 );
6225 assert_eq!(
6227 config.delay_for_attempt(3),
6228 std::time::Duration::from_millis(2000)
6229 );
6230 }
6231
6232 #[test]
6233 fn test_retry_config_random_jitter_stays_within_bounds() {
6234 let _guard = test_guard!();
6235 let config = RetryConfig {
6236 base_delay_ms: 100,
6237 max_delay_ms: 200,
6238 jitter_factor: 0.25,
6239 ..Default::default()
6240 };
6241
6242 for (attempt, minimum_ms, maximum_ms) in [(1, 75, 125), (3, 150, 250)] {
6245 for _ in 0..128 {
6246 let delay = config.delay_for_attempt(attempt);
6247 assert!(delay >= std::time::Duration::from_millis(minimum_ms));
6248 assert!(delay <= std::time::Duration::from_millis(maximum_ms));
6249 }
6250 }
6251 }
6252
6253 #[test]
6254 fn test_retry_config_should_retry() {
6255 let _guard = test_guard!();
6256 let config = RetryConfig {
6257 max_attempts: 3,
6258 total_timeout_ms: 1000,
6259 ..Default::default()
6260 };
6261
6262 assert!(config.should_retry(0, std::time::Duration::from_millis(0)));
6264 assert!(config.should_retry(1, std::time::Duration::from_millis(500)));
6265 assert!(config.should_retry(2, std::time::Duration::from_millis(900)));
6266
6267 assert!(!config.should_retry(3, std::time::Duration::from_millis(0)));
6269
6270 assert!(!config.should_retry(1, std::time::Duration::from_millis(1001)));
6272 }
6273
6274 #[test]
6275 fn test_retry_config_serde_roundtrip() {
6276 let _guard = test_guard!();
6277 let config = RetryConfig {
6278 max_attempts: 5,
6279 base_delay_ms: 200,
6280 max_delay_ms: 8000,
6281 jitter_factor: 0.2,
6282 total_timeout_ms: 60000,
6283 };
6284 let json = serde_json::to_string(&config).unwrap();
6285 let parsed: RetryConfig = serde_json::from_str(&json).unwrap();
6286 assert_eq!(parsed.max_attempts, 5);
6287 assert_eq!(parsed.base_delay_ms, 200);
6288 assert_eq!(parsed.max_delay_ms, 8000);
6289 assert_eq!(parsed.jitter_factor, 0.2);
6290 assert_eq!(parsed.total_timeout_ms, 60000);
6291 }
6292
6293 #[test]
6294 fn test_transfer_config_includes_retry() {
6295 let _guard = test_guard!();
6296 let config = TransferConfig::default();
6297 assert_eq!(config.retry.max_attempts, 3);
6298 }
6299
6300 #[test]
6305 fn test_execution_config_default_allowlist() {
6306 let _guard = test_guard!();
6307 let config = ExecutionConfig::default();
6308 assert!(config.is_allowed("cargo"));
6310 assert!(config.is_allowed("rustc"));
6311 assert!(config.is_allowed("gcc"));
6312 assert!(config.is_allowed("g++"));
6313 assert!(config.is_allowed("clang"));
6314 assert!(config.is_allowed("clang++"));
6315 assert!(config.is_allowed("make"));
6316 assert!(config.is_allowed("cmake"));
6317 assert!(config.is_allowed("ninja"));
6318 assert!(config.is_allowed("meson"));
6319 assert!(config.is_allowed("bun"));
6320 assert!(config.is_allowed("nextest"));
6321 assert!(config.is_allowed("cc"));
6322 assert!(config.is_allowed("c++"));
6323 }
6324
6325 #[test]
6326 fn test_execution_config_is_allowed_case_insensitive() {
6327 let _guard = test_guard!();
6328 let config = ExecutionConfig::default();
6329 assert!(config.is_allowed("CARGO"));
6331 assert!(config.is_allowed("Cargo"));
6332 assert!(config.is_allowed("GCC"));
6333 assert!(config.is_allowed("Gcc"));
6334 }
6335
6336 #[test]
6337 fn test_execution_config_is_not_allowed() {
6338 let _guard = test_guard!();
6339 let config = ExecutionConfig::default();
6340 assert!(!config.is_allowed("python"));
6342 assert!(!config.is_allowed("npm"));
6343 assert!(!config.is_allowed(""));
6344 assert!(config.is_allowed("go"));
6349 assert!(config.is_allowed("tsc"));
6350 }
6351
6352 #[test]
6353 fn test_execution_config_empty_allowlist() {
6354 let _guard = test_guard!();
6355 let config = ExecutionConfig { allowlist: vec![] };
6356 assert!(!config.is_allowed("cargo"));
6358 assert!(!config.is_allowed("gcc"));
6359 }
6360
6361 #[test]
6362 fn test_execution_config_custom_allowlist() {
6363 let _guard = test_guard!();
6364 let config = ExecutionConfig {
6365 allowlist: vec!["cargo".to_string(), "custom_tool".to_string()],
6366 };
6367 assert!(config.is_allowed("cargo"));
6368 assert!(config.is_allowed("custom_tool"));
6369 assert!(!config.is_allowed("gcc"));
6370 }
6371
6372 #[test]
6373 fn test_execution_config_serde() {
6374 let _guard = test_guard!();
6375 let config = ExecutionConfig {
6376 allowlist: vec!["cargo".to_string(), "rustc".to_string()],
6377 };
6378 let json = serde_json::to_string(&config).unwrap();
6379 let parsed: ExecutionConfig = serde_json::from_str(&json).unwrap();
6380 assert_eq!(parsed.allowlist, config.allowlist);
6381 }
6382
6383 #[test]
6384 fn test_rch_config_includes_execution() {
6385 let _guard = test_guard!();
6386 let config = RchConfig::default();
6387 assert!(config.execution.is_allowed("cargo"));
6389 }
6390
6391 #[test]
6396 fn test_adaptive_compression_disabled_by_default() {
6397 let _guard = test_guard!();
6398 let config = TransferConfig::default();
6399 assert!(!config.adaptive_compression);
6400 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6402 assert_eq!(config.select_compression_level(Some(500_000_000)), 3);
6403 }
6404
6405 #[test]
6406 fn test_adaptive_compression_small_payload() {
6407 let _guard = test_guard!();
6408 let config = TransferConfig {
6409 adaptive_compression: true,
6410 ..Default::default()
6411 };
6412 assert_eq!(config.select_compression_level(Some(0)), 1);
6414 assert_eq!(config.select_compression_level(Some(1_000_000)), 1); assert_eq!(config.select_compression_level(Some(9_999_999)), 1); }
6417
6418 #[test]
6419 fn test_adaptive_compression_medium_payload() {
6420 let _guard = test_guard!();
6421 let config = TransferConfig {
6422 adaptive_compression: true,
6423 ..Default::default()
6424 };
6425 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); }
6430
6431 #[test]
6432 fn test_adaptive_compression_large_payload() {
6433 let _guard = test_guard!();
6434 let config = TransferConfig {
6435 adaptive_compression: true,
6436 ..Default::default()
6437 };
6438 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); }
6443
6444 #[test]
6445 fn test_adaptive_compression_no_estimate() {
6446 let _guard = test_guard!();
6447 let config = TransferConfig {
6448 adaptive_compression: true,
6449 compression_level: 5,
6450 ..Default::default()
6451 };
6452 assert_eq!(config.select_compression_level(None), 5);
6454 }
6455
6456 #[test]
6457 fn test_adaptive_compression_respects_min_level() {
6458 let _guard = test_guard!();
6459 let config = TransferConfig {
6460 adaptive_compression: true,
6461 min_compression_level: 3,
6462 ..Default::default()
6463 };
6464 assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6466 }
6467
6468 #[test]
6469 fn test_adaptive_compression_respects_max_level() {
6470 let _guard = test_guard!();
6471 let config = TransferConfig {
6472 adaptive_compression: true,
6473 max_compression_level: 5,
6474 ..Default::default()
6475 };
6476 assert_eq!(config.select_compression_level(Some(500_000_000)), 5);
6478 }
6479
6480 #[test]
6481 fn test_adaptive_compression_serde_roundtrip() {
6482 let _guard = test_guard!();
6483 let config = TransferConfig {
6484 adaptive_compression: true,
6485 min_compression_level: 2,
6486 max_compression_level: 8,
6487 ..Default::default()
6488 };
6489 let json = serde_json::to_string(&config).unwrap();
6490 let parsed: TransferConfig = serde_json::from_str(&json).unwrap();
6491 assert!(parsed.adaptive_compression);
6492 assert_eq!(parsed.min_compression_level, 2);
6493 assert_eq!(parsed.max_compression_level, 8);
6494 }
6495
6496 #[test]
6501 fn test_self_healing_config_defaults() {
6502 let _guard = test_guard!();
6503 let config = SelfHealingConfig::default();
6505
6506 assert!(
6508 config.hook_starts_daemon,
6509 "hook_starts_daemon should be true by default"
6510 );
6511 assert!(
6512 config.daemon_installs_hooks,
6513 "daemon_installs_hooks should be true by default"
6514 );
6515
6516 assert_eq!(
6518 config.auto_start_cooldown_secs, 30,
6519 "auto_start_cooldown_secs should default to 30"
6520 );
6521 assert_eq!(
6522 config.auto_start_timeout_secs, 3,
6523 "auto_start_timeout_secs should default to 3"
6524 );
6525 }
6527
6528 #[test]
6529 fn test_self_healing_config_serde_full() {
6530 let _guard = test_guard!();
6531 let config = SelfHealingConfig {
6533 hook_starts_daemon: false,
6534 daemon_installs_hooks: false,
6535 auto_start_cooldown_secs: 60,
6536 auto_start_timeout_secs: 10,
6537 self_healing_log_level: SelfHealingLogLevel::Debug,
6538 };
6539
6540 let json = serde_json::to_string(&config).unwrap();
6541 assert!(json.contains("\"hook_starts_daemon\":false"));
6542 assert!(json.contains("\"daemon_installs_hooks\":false"));
6543 assert!(json.contains("\"auto_start_cooldown_secs\":60"));
6544 assert!(json.contains("\"auto_start_timeout_secs\":10"));
6545 assert!(json.contains("\"self_healing_log_level\":\"debug\""));
6546
6547 let parsed: SelfHealingConfig = serde_json::from_str(&json).unwrap();
6548 assert!(!parsed.hook_starts_daemon);
6549 assert!(!parsed.daemon_installs_hooks);
6550 assert_eq!(parsed.auto_start_cooldown_secs, 60);
6551 assert_eq!(parsed.auto_start_timeout_secs, 10);
6552 assert_eq!(parsed.self_healing_log_level, SelfHealingLogLevel::Debug);
6553 }
6555
6556 #[test]
6559 fn test_self_healing_log_level_default_is_info() {
6560 let level = SelfHealingLogLevel::default();
6562 assert_eq!(level, SelfHealingLogLevel::Info);
6563 let config = SelfHealingConfig::default();
6565 assert_eq!(
6566 config.self_healing_log_level,
6567 SelfHealingLogLevel::Info,
6568 "config default should be Info"
6569 );
6570 }
6572
6573 #[test]
6574 fn test_self_healing_log_level_serializes_lowercase() {
6575 let json = serde_json::to_string(&SelfHealingLogLevel::Warn).unwrap();
6577 assert_eq!(json, "\"warn\"");
6578 let parsed: SelfHealingLogLevel = serde_json::from_str("\"error\"").unwrap();
6579 assert_eq!(parsed, SelfHealingLogLevel::Error);
6580 }
6582
6583 #[test]
6584 fn test_self_healing_log_level_from_env_str_accepts_known() {
6585 assert_eq!(
6587 SelfHealingLogLevel::from_env_str("debug"),
6588 Some(SelfHealingLogLevel::Debug)
6589 );
6590 assert_eq!(
6591 SelfHealingLogLevel::from_env_str("DEBUG"),
6592 Some(SelfHealingLogLevel::Debug)
6593 );
6594 assert_eq!(
6595 SelfHealingLogLevel::from_env_str("info"),
6596 Some(SelfHealingLogLevel::Info)
6597 );
6598 assert_eq!(
6599 SelfHealingLogLevel::from_env_str("warn"),
6600 Some(SelfHealingLogLevel::Warn)
6601 );
6602 assert_eq!(
6603 SelfHealingLogLevel::from_env_str("warning"),
6604 Some(SelfHealingLogLevel::Warn),
6605 "warning should map to Warn"
6606 );
6607 assert_eq!(
6608 SelfHealingLogLevel::from_env_str("error"),
6609 Some(SelfHealingLogLevel::Error)
6610 );
6611 assert_eq!(
6613 SelfHealingLogLevel::from_env_str(" debug "),
6614 Some(SelfHealingLogLevel::Debug)
6615 );
6616 }
6618
6619 #[test]
6620 fn test_self_healing_log_level_from_env_str_rejects_unknown() {
6621 assert!(SelfHealingLogLevel::from_env_str("banana").is_none());
6623 assert!(SelfHealingLogLevel::from_env_str("").is_none());
6624 assert!(SelfHealingLogLevel::from_env_str("trace").is_none());
6625 }
6627
6628 #[test]
6629 fn test_self_healing_config_serde_partial_uses_defaults() {
6630 let _guard = test_guard!();
6631 let json = r#"{"hook_starts_daemon": false}"#;
6633 let config: SelfHealingConfig = serde_json::from_str(json).unwrap();
6634
6635 assert!(
6636 !config.hook_starts_daemon,
6637 "Explicit false should be parsed"
6638 );
6639 assert!(
6640 config.daemon_installs_hooks,
6641 "Missing field should use default (true)"
6642 );
6643 assert_eq!(
6644 config.auto_start_cooldown_secs, 30,
6645 "Missing field should use default (30)"
6646 );
6647 assert_eq!(
6648 config.auto_start_timeout_secs, 3,
6649 "Missing field should use default (3)"
6650 );
6651 }
6653
6654 #[test]
6655 fn test_self_healing_config_toml_with_alias() {
6656 let _guard = test_guard!();
6657 let toml_str = r#"
6659 hook_starts_daemon = true
6660 daemon_installs_hooks = false
6661 auto_start_cooldown_secs = 45
6662 daemon_start_timeout = 7
6663 "#;
6664
6665 let config: SelfHealingConfig = toml::from_str(toml_str).unwrap();
6666 assert!(config.hook_starts_daemon);
6667 assert!(!config.daemon_installs_hooks);
6668 assert_eq!(config.auto_start_cooldown_secs, 45);
6669 assert_eq!(
6670 config.auto_start_timeout_secs, 7,
6671 "daemon_start_timeout alias should set auto_start_timeout_secs"
6672 );
6673 }
6675
6676 #[allow(unsafe_code)]
6677 mod self_healing_env_override_tests {
6678 use super::*;
6679 use crate::config::env_test_lock;
6680
6681 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
6682 env_test_lock()
6683 }
6684
6685 fn set_env(key: &str, value: &str) {
6686 unsafe { std::env::set_var(key, value) };
6688 }
6689
6690 fn remove_env(key: &str) {
6691 unsafe { std::env::remove_var(key) };
6693 }
6694
6695 struct EnvVarGuard {
6696 key: &'static str,
6697 old: Option<String>,
6698 }
6699
6700 impl EnvVarGuard {
6701 fn set(key: &'static str, value: &str) -> Self {
6702 let old = std::env::var(key).ok();
6703 set_env(key, value);
6704 Self { key, old }
6705 }
6706 }
6707
6708 impl Drop for EnvVarGuard {
6709 fn drop(&mut self) {
6710 if let Some(old) = &self.old {
6711 set_env(self.key, old);
6712 } else {
6713 remove_env(self.key);
6714 }
6715 }
6716 }
6717
6718 #[test]
6719 fn test_self_healing_config_with_env_overrides_master_disable() {
6720 let _guard = test_guard!();
6721 let _guard = env_guard();
6722
6723 let _no_self_healing = EnvVarGuard::set("RCH_NO_SELF_HEALING", "1");
6724 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "1");
6725 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "1");
6726 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "99");
6727 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "99");
6728
6729 let config = SelfHealingConfig::default().with_env_overrides();
6730 assert!(
6731 !config.hook_starts_daemon,
6732 "RCH_NO_SELF_HEALING should disable hook auto-start"
6733 );
6734 assert!(
6735 !config.daemon_installs_hooks,
6736 "RCH_NO_SELF_HEALING should disable daemon hook installation"
6737 );
6738
6739 assert_eq!(config.auto_start_cooldown_secs, 30);
6741 assert_eq!(config.auto_start_timeout_secs, 3);
6742 }
6743
6744 #[test]
6745 fn test_self_healing_config_with_env_overrides_toggles_and_numbers() {
6746 let _guard = test_guard!();
6747 let _guard = env_guard();
6748
6749 let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "0");
6750 let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "false");
6751 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "45");
6752 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "7");
6753
6754 let config = SelfHealingConfig::default().with_env_overrides();
6755 assert!(!config.hook_starts_daemon);
6756 assert!(!config.daemon_installs_hooks);
6757 assert_eq!(config.auto_start_cooldown_secs, 45);
6758 assert_eq!(config.auto_start_timeout_secs, 7);
6759 }
6760
6761 #[test]
6762 fn test_self_healing_config_with_env_overrides_invalid_numbers_ignored() {
6763 let _guard = test_guard!();
6764 let _guard = env_guard();
6765
6766 let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "not-a-number");
6767 let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "nope");
6768
6769 let config = SelfHealingConfig::default().with_env_overrides();
6770 assert_eq!(config.auto_start_cooldown_secs, 30);
6771 assert_eq!(config.auto_start_timeout_secs, 3);
6772 }
6773 }
6774
6775 #[test]
6780 fn test_load_per_core_calculation() {
6781 let _guard = test_guard!();
6782 let mut caps = WorkerCapabilities::new();
6783 assert!(caps.load_per_core().is_none());
6785
6786 caps.load_avg_1 = Some(4.0);
6788 assert!(caps.load_per_core().is_none());
6789
6790 caps.num_cpus = Some(4);
6792 assert_eq!(caps.load_per_core(), Some(1.0)); caps.load_avg_1 = Some(16.0);
6796 assert_eq!(caps.load_per_core(), Some(4.0)); }
6798
6799 #[test]
6800 fn test_is_high_load() {
6801 let _guard = test_guard!();
6802 let mut caps = WorkerCapabilities::new();
6803 caps.load_avg_1 = Some(8.0);
6804 caps.num_cpus = Some(4);
6805 assert_eq!(caps.is_high_load(3.0), Some(false));
6809 assert_eq!(caps.is_high_load(2.0), Some(false)); assert_eq!(caps.is_high_load(1.5), Some(true));
6813
6814 caps.load_avg_1 = None;
6816 assert!(caps.is_high_load(1.0).is_none());
6817 }
6818
6819 #[test]
6820 fn test_is_low_disk() {
6821 let _guard = test_guard!();
6822 let mut caps = WorkerCapabilities::new();
6823 caps.disk_free_gb = Some(15.0);
6824
6825 assert_eq!(caps.is_low_disk(10.0), Some(false));
6827 assert_eq!(caps.is_low_disk(15.0), Some(false)); assert_eq!(caps.is_low_disk(20.0), Some(true));
6831
6832 caps.disk_free_gb = None;
6834 assert!(caps.is_low_disk(10.0).is_none());
6835 }
6836
6837 #[test]
6838 fn low_disk_judges_the_build_disk_not_a_small_tmpfs() {
6839 let _guard = test_guard!();
6840 let mut caps = WorkerCapabilities::new();
6843 caps.disk_free_gb = Some(8.0);
6844 caps.disk_total_gb = Some(15.5);
6845 caps.build_disk_free_gb = Some(1700.0);
6846 caps.build_disk_total_gb = Some(1900.0);
6847 assert_eq!(caps.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6848 assert_eq!(caps.is_low_disk(10.0), Some(false));
6849
6850 caps.build_disk_free_gb = Some(4.0);
6852 assert_eq!(caps.is_low_disk(10.0), Some(true));
6853
6854 caps.build_disk_free_gb = None;
6856 caps.build_disk_total_gb = None;
6857 assert_eq!(caps.build_disk_gb(), (Some(8.0), Some(15.5)));
6858 assert_eq!(caps.is_low_disk(10.0), Some(true));
6859 }
6860
6861 #[test]
6862 fn build_disk_sample_is_optional_on_the_wire() {
6863 let _guard = test_guard!();
6864 let old: WorkerCapabilities =
6865 serde_json::from_str(r#"{"disk_free_gb":13.9,"disk_total_gb":15.5}"#).unwrap();
6866 assert_eq!(old.build_disk_free_gb, None);
6867 assert_eq!(old.build_disk_gb(), (Some(13.9), Some(15.5)));
6868
6869 let new: WorkerCapabilities = serde_json::from_str(
6870 r#"{"disk_free_gb":13.9,"disk_total_gb":15.5,"build_disk_free_gb":1700.0,"build_disk_total_gb":1900.0}"#,
6871 )
6872 .unwrap();
6873 assert_eq!(new.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6874 let json = serde_json::to_string(&new).unwrap();
6875 assert!(json.contains("\"build_disk_free_gb\":1700.0"), "{json}");
6876 }
6877
6878 #[test]
6879 fn test_selection_config_preflight_defaults() {
6880 let _guard = test_guard!();
6881 let config = SelectionConfig::default();
6882 assert_eq!(config.max_load_per_core, Some(2.0));
6884 assert_eq!(config.min_free_gb, Some(10.0));
6885 assert_eq!(config.disk_gb_per_slot, 10.0);
6886 }
6887
6888 #[test]
6889 fn test_selection_config_preflight_serde() {
6890 let _guard = test_guard!();
6891 let config = SelectionConfig {
6892 max_load_per_core: Some(3.5),
6893 min_free_gb: Some(25.0),
6894 disk_gb_per_slot: 0.83,
6895 ..Default::default()
6896 };
6897 let json = serde_json::to_string(&config).unwrap();
6898 let parsed: SelectionConfig = serde_json::from_str(&json).unwrap();
6899 assert_eq!(parsed.max_load_per_core, Some(3.5));
6900 assert_eq!(parsed.min_free_gb, Some(25.0));
6901 assert_eq!(parsed.disk_gb_per_slot, 0.83);
6902 }
6903
6904 #[test]
6905 fn test_selection_disk_slot_budget_validation() {
6906 let _guard = test_guard!();
6907 for value in ["0.0", "-1.0", "nan", "inf", "-inf"] {
6908 let result = toml::from_str::<SelectionConfig>(&format!("disk_gb_per_slot = {value}"));
6909 assert!(result.is_err(), "invalid disk slot budget {value}");
6910 }
6911 let default: SelectionConfig = toml::from_str("").unwrap();
6912 assert_eq!(default.disk_gb_per_slot, 10.0);
6913 }
6914
6915 #[test]
6916 fn test_selection_disk_weight_validation_and_roundtrip() {
6917 for value in ["-0.1", "1.1", "nan", "inf", "-inf"] {
6918 assert!(toml::from_str::<SelectionWeightConfig>(&format!("disk = {value}")).is_err());
6919 }
6920 for value in [0.0, 0.2, 1.0] {
6921 let weights: SelectionWeightConfig =
6922 toml::from_str(&format!("disk = {value}")).unwrap();
6923 assert_eq!(weights.disk, value);
6924 let roundtrip: SelectionWeightConfig =
6925 serde_json::from_str(&serde_json::to_string(&weights).unwrap()).unwrap();
6926 assert_eq!(roundtrip.disk, value);
6927 }
6928 let defaults: SelectionWeightConfig = toml::from_str("").unwrap();
6929 assert_eq!(defaults.disk, 0.2);
6930 }
6931
6932 #[test]
6933 fn test_worker_capabilities_health_metrics_serde() {
6934 let _guard = test_guard!();
6935 let caps = WorkerCapabilities {
6936 num_cpus: Some(8),
6937 load_avg_1: Some(1.5),
6938 load_avg_5: Some(2.0),
6939 load_avg_15: Some(1.8),
6940 disk_free_gb: Some(50.5),
6941 disk_total_gb: Some(100.0),
6942 ..Default::default()
6943 };
6944 let json = serde_json::to_string(&caps).unwrap();
6945 let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
6946 assert_eq!(parsed.num_cpus, Some(8));
6947 assert_eq!(parsed.load_avg_1, Some(1.5));
6948 assert_eq!(parsed.disk_free_gb, Some(50.5));
6949 }
6950
6951 #[test]
6952 fn microarch_level_classifies_ivy_bridge_as_v2() {
6953 let ivy = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov \
6956 pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm \
6957 constant_tsc pni pclmulqdq ssse3 cx16 sse4_1 sse4_2 x2apic \
6958 popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm";
6959 assert_eq!(x86_64_microarch_level_from_flags(ivy), 2);
6960 }
6961
6962 #[test]
6963 fn microarch_level_classifies_v3_and_v4() {
6964 let v3 = "cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 \
6965 f16c fma movbe xsave";
6966 assert_eq!(x86_64_microarch_level_from_flags(v3), 3);
6967
6968 let v4 = format!("{v3} avx512f avx512bw avx512cd avx512dq avx512vl");
6969 assert_eq!(x86_64_microarch_level_from_flags(&v4), 4);
6970 }
6971
6972 #[test]
6973 fn microarch_level_v1_for_ancient_or_empty_flags() {
6974 assert_eq!(x86_64_microarch_level_from_flags(""), 1);
6975 assert_eq!(x86_64_microarch_level_from_flags("fpu mmx sse sse2"), 1);
6976 }
6977
6978 #[test]
6979 fn pre_v3_predicate_only_fires_on_affirmative_evidence() {
6980 let mut caps = WorkerCapabilities::new();
6981 assert!(!caps.is_pre_v3_x86(), "unknown must not deprioritize");
6982 caps.cpu_microarch_level = Some(2);
6983 assert!(caps.is_pre_v3_x86());
6984 caps.cpu_microarch_level = Some(3);
6985 assert!(!caps.is_pre_v3_x86());
6986 }
6987}