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