Skip to main content

rch_common/
types.rs

1//! Common types used across RCH components.
2
3use crate::{CompilationKind, toolchain::ToolchainInfo};
4use rand::RngExt;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// Unique identifier for a worker in the fleet.
10#[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/// Status of a worker in the fleet.
30///
31/// State transitions:
32/// ```text
33/// HEALTHY ←→ DEGRADED (automatic based on response times)
34///    ↓           ↓
35///    ↓       UNREACHABLE (automatic on heartbeat failure)
36///    ↓
37/// DRAINING (via `rch workers drain`) - finishing current jobs
38///    ↓
39/// DRAINED (automatic when all jobs complete) - idle, ready to disable
40///    ↓
41/// DISABLED (via `rch workers disable`) - completely offline
42///
43/// Use `rch workers enable` to return from DRAINING/DRAINED/DISABLED → HEALTHY
44/// ```
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "snake_case")]
47pub enum WorkerStatus {
48    /// Worker is healthy and accepting jobs.
49    #[default]
50    Healthy,
51    /// Worker is responding slowly.
52    Degraded,
53    /// Worker failed to respond to heartbeat.
54    Unreachable,
55    /// Worker is not accepting new jobs (finishing current).
56    Draining,
57    /// Worker has finished draining (no active jobs, idle).
58    Drained,
59    /// Worker is manually disabled.
60    Disabled,
61}
62
63/// Circuit breaker state for a worker.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[serde(rename_all = "snake_case")]
66pub enum CircuitState {
67    /// Normal operation.
68    #[default]
69    Closed,
70    /// Circuit is open (short-circuit).
71    Open,
72    /// Circuit is half-open (probing).
73    HalfOpen,
74}
75
76/// Required runtime for command execution.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
78#[serde(rename_all = "lowercase")]
79pub enum RequiredRuntime {
80    /// No specific runtime required (default).
81    #[default]
82    None,
83    /// Requires Rust toolchain.
84    Rust,
85    /// Requires Bun runtime.
86    Bun,
87    /// Requires Node.js runtime.
88    Node,
89    /// Requires the Nix package manager (nix binary + populated `/nix/store`).
90    Nix,
91    /// Requires the Go toolchain (`go` binary).
92    Go,
93    /// Requires the zig cross-compilation toolchain (`cargo-zigbuild` + `zig`).
94    Zig,
95}
96
97/// Per-command priority hint for worker selection.
98///
99/// This is an *input hint* from the caller (typically the hook) and should not
100/// change default behavior when omitted.
101#[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/// Worker selection request sent from hook to daemon.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct SelectionRequest {
137    /// Project identifier (usually directory name or hash).
138    pub project: String,
139    /// Full command being executed (optional, for active build tracking).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub command: Option<String>,
142    /// Priority hint for this request.
143    #[serde(default)]
144    pub command_priority: CommandPriority,
145    /// Estimated CPU cores needed for this compilation.
146    pub estimated_cores: u32,
147    /// Preferred worker IDs (e.g., from project config).
148    #[serde(default)]
149    pub preferred_workers: Vec<WorkerId>,
150    /// Rust toolchain information for the project.
151    #[serde(default)]
152    pub toolchain: Option<ToolchainInfo>,
153    /// Required runtime for command execution.
154    #[serde(default)]
155    pub required_runtime: RequiredRuntime,
156    /// Classification decision latency in microseconds (for AGENTS.md compliance).
157    /// This tracks how long the 5-tier classification took on the hook side.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub classification_duration_us: Option<u64>,
160    /// Job-mode admission (bd-g7rpy): set by `rch exec --job` only. When the
161    /// one-active-job-per-project-per-worker guard excludes every otherwise-
162    /// eligible worker, a job request may QUEUE on that exclusion instead of
163    /// failing over to local execution. Compilation requests never set it,
164    /// preserving their immediate NoAdmissibleWorkers behavior.
165    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
166    pub job_mode: bool,
167    /// Operator-declared named tools this work requires VERIFIED on the worker
168    /// (`rch exec --job --require-tool NAME`, or a project's
169    /// `jobs.required_tools`).
170    ///
171    /// Job mode only — the compilation classifier never sets it, so ordinary
172    /// offloaded builds keep their existing admission behavior exactly. Empty
173    /// means no gate; a name no worker has verified excludes every worker,
174    /// because the caller asked for the gate precisely because the job cannot
175    /// run without the tool.
176    #[serde(default, skip_serializing_if = "Vec::is_empty")]
177    pub required_tools: Vec<String>,
178    /// Process ID of the hook (for active build tracking).
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub hook_pid: Option<u32>,
181}
182
183/// Reason for worker selection result.
184///
185/// Provides context when no worker is available, enabling informative
186/// fallback messages in the hook.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
188#[serde(rename_all = "snake_case")]
189pub enum SelectionReason {
190    /// Worker assigned successfully.
191    Success,
192    /// No workers configured in workers.toml.
193    NoWorkersConfigured,
194    /// All workers are unreachable (failed health checks).
195    AllWorkersUnreachable,
196    /// All workers have circuits open (after repeated failures).
197    AllCircuitsOpen,
198    /// All workers are at capacity (no available slots).
199    AllWorkersBusy,
200    /// Workers were present but none passed selection health thresholds.
201    NoWorkersPassedHealth,
202    /// All candidate workers failed hard preflight checks.
203    AllWorkersFailedPreflight,
204    /// All candidate workers failed repo convergence checks (repos missing/stale/failed).
205    AllWorkersFailedConvergence,
206    /// Workers exist but admission was blocked by concrete capacity/preflight reasons.
207    NoAdmissibleWorkers(String),
208    /// No workers match required tags or preferences.
209    NoMatchingWorkers,
210    /// No workers have the required runtime (e.g., Bun, Node).
211    NoWorkersWithRuntime(String),
212    /// Internal error during selection.
213    SelectionError(String),
214    /// Worker assigned via affinity pinning (recent successful build).
215    AffinityPinned,
216    /// Worker assigned via last-success fallback (all others unavailable).
217    AffinityFallback,
218}
219
220/// Wire protocol version for daemon `/select-worker` responses.
221///
222/// This is intentionally separate from the broader CLI API envelope version:
223/// selection responses are emitted as a raw [`SelectionResponse`] on the hook
224/// hot path, so the client needs a small explicit compatibility marker.
225pub const SELECTION_RESPONSE_PROTOCOL_VERSION: u64 = 1;
226
227impl std::fmt::Display for SelectionReason {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        match self {
230            Self::Success => write!(f, "worker assigned successfully"),
231            Self::NoWorkersConfigured => write!(f, "no workers configured"),
232            Self::AllWorkersUnreachable => write!(f, "all workers unreachable"),
233            Self::AllCircuitsOpen => write!(f, "all worker circuits open"),
234            Self::AllWorkersBusy => write!(f, "all workers at capacity"),
235            Self::NoWorkersPassedHealth => write!(f, "no workers passed health thresholds"),
236            Self::AllWorkersFailedPreflight => write!(f, "all workers failed preflight checks"),
237            Self::AllWorkersFailedConvergence => {
238                write!(f, "all workers failed repo convergence checks")
239            }
240            Self::NoAdmissibleWorkers(summary) => {
241                write!(f, "no admissible workers: {}", summary)
242            }
243            Self::NoMatchingWorkers => write!(f, "no matching workers found"),
244            Self::NoWorkersWithRuntime(rt) => write!(f, "no workers with {} installed", rt),
245            Self::SelectionError(e) => write!(f, "selection error: {}", e),
246            Self::AffinityPinned => write!(f, "worker assigned via affinity pinning"),
247            Self::AffinityFallback => write!(f, "worker assigned via last-success fallback"),
248        }
249    }
250}
251
252// ============================================================================
253// Worker Selection Strategy
254// ============================================================================
255
256/// Worker selection strategy determining how workers are chosen for jobs.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
258#[serde(rename_all = "snake_case")]
259pub enum SelectionStrategy {
260    /// Legacy/manual control: prioritize worker.priority first, then break ties
261    /// using command-aware cache/speed hints.
262    Priority,
263    /// Select worker with highest SpeedScore.
264    /// Best for performance-critical builds with homogeneous workers.
265    Fastest,
266    /// Balance all factors: SpeedScore, load, health, cache affinity.
267    /// Default for general use across diverse worker pools.
268    #[default]
269    Balanced,
270    /// Prefer workers with warm caches for the project.
271    /// Best for incremental builds on large codebases.
272    CacheAffinity,
273    /// Weighted random selection favoring fast workers but ensuring fairness.
274    /// Prevents hot-spotting when many workers are available.
275    FairFastest,
276}
277
278impl std::fmt::Display for SelectionStrategy {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        let name = match self {
281            Self::Priority => "priority",
282            Self::Fastest => "fastest",
283            Self::Balanced => "balanced",
284            Self::CacheAffinity => "cache_affinity",
285            Self::FairFastest => "fair_fastest",
286        };
287        write!(f, "{}", name)
288    }
289}
290
291impl std::str::FromStr for SelectionStrategy {
292    type Err = String;
293
294    fn from_str(s: &str) -> Result<Self, Self::Err> {
295        match s.trim().to_lowercase().as_str() {
296            "priority" => Ok(Self::Priority),
297            "fastest" => Ok(Self::Fastest),
298            "balanced" => Ok(Self::Balanced),
299            "cache_affinity" | "cache-affinity" | "cacheaffinity" => Ok(Self::CacheAffinity),
300            "fair_fastest" | "fair-fastest" | "fairfastest" => Ok(Self::FairFastest),
301            _ => Err(format!(
302                "unknown selection strategy '{}', expected one of: priority, fastest, balanced, cache_affinity, fair_fastest",
303                s
304            )),
305        }
306    }
307}
308
309/// Configuration for the worker selection algorithm.
310#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct SelectionConfig {
312    /// Selection strategy to use.
313    #[serde(default)]
314    pub strategy: SelectionStrategy,
315    /// Minimum success rate (0.0-1.0) for a worker to be considered.
316    #[serde(default = "default_min_success_rate")]
317    pub min_success_rate: f64,
318    /// Factor weights for the balanced strategy.
319    #[serde(default)]
320    pub weights: SelectionWeightConfig,
321    /// Fairness settings for fair_fastest strategy.
322    #[serde(default)]
323    pub fairness: FairnessConfig,
324    /// Cache affinity settings for project-to-worker pinning.
325    #[serde(default)]
326    pub affinity: AffinityConfig,
327
328    // Preflight health guards (bd-3eaa)
329    /// Maximum load-per-core threshold. Workers exceeding this are skipped.
330    /// Set to None to disable load-based filtering.
331    #[serde(default = "default_max_load_per_core")]
332    pub max_load_per_core: Option<f64>,
333    /// Minimum free disk space in GB. Workers below this are skipped.
334    /// Set to None to disable the free-space floor; per-slot disk derating still applies.
335    #[serde(default = "default_min_free_gb")]
336    pub min_free_gb: Option<f64>,
337    /// Disk budget in GiB per concurrent slot, after reserving `min_free_gb`.
338    /// Must be finite and positive. Unknown disk telemetry preserves configured slots.
339    #[serde(
340        default = "default_disk_gb_per_slot",
341        deserialize_with = "deserialize_disk_gb_per_slot"
342    )]
343    pub disk_gb_per_slot: f64,
344}
345
346impl Default for SelectionConfig {
347    fn default() -> Self {
348        Self {
349            strategy: SelectionStrategy::default(),
350            min_success_rate: default_min_success_rate(),
351            weights: SelectionWeightConfig::default(),
352            fairness: FairnessConfig::default(),
353            affinity: AffinityConfig::default(),
354            max_load_per_core: default_max_load_per_core(),
355            min_free_gb: default_min_free_gb(),
356            disk_gb_per_slot: default_disk_gb_per_slot(),
357        }
358    }
359}
360
361fn default_min_success_rate() -> f64 {
362    0.8
363}
364
365fn default_max_load_per_core() -> Option<f64> {
366    Some(2.0) // Skip workers with load/core > 2.0
367}
368
369fn default_min_free_gb() -> Option<f64> {
370    Some(10.0) // Skip workers with < 10 GB free
371}
372
373fn default_disk_gb_per_slot() -> f64 {
374    10.0
375}
376
377fn deserialize_disk_gb_per_slot<'de, D>(deserializer: D) -> Result<f64, D::Error>
378where
379    D: serde::Deserializer<'de>,
380{
381    let budget = f64::deserialize(deserializer)?;
382    if budget.is_finite() && budget > 0.0 {
383        Ok(budget)
384    } else {
385        Err(serde::de::Error::custom(
386            "disk_gb_per_slot must be finite and greater than zero",
387        ))
388    }
389}
390
391/// Weight configuration for the balanced selection strategy.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct SelectionWeightConfig {
394    /// Weight for SpeedScore (0.0-1.0).
395    #[serde(default = "default_weight_speedscore")]
396    pub speedscore: f64,
397    /// Weight for available slots (0.0-1.0).
398    #[serde(default = "default_weight_slots")]
399    pub slots: f64,
400    /// Weight for health/success rate (0.0-1.0).
401    #[serde(default = "default_weight_health")]
402    pub health: f64,
403    /// Weight for cache affinity (0.0-1.0).
404    #[serde(default = "default_weight_cache")]
405    pub cache: f64,
406    /// Weight for network latency (0.0-1.0).
407    #[serde(default = "default_weight_network")]
408    pub network: f64,
409    /// Weight for worker priority (0.0-1.0).
410    #[serde(default = "default_weight_priority")]
411    pub priority: f64,
412    /// Weight for disk headroom (0.0-1.0); zero disables disk ranking.
413    #[serde(
414        default = "default_weight_disk",
415        deserialize_with = "deserialize_weight_disk"
416    )]
417    pub disk: f64,
418    /// Penalty multiplier for half-open circuit workers (0.0-1.0).
419    #[serde(default = "default_half_open_penalty")]
420    pub half_open_penalty: f64,
421}
422
423impl Default for SelectionWeightConfig {
424    fn default() -> Self {
425        Self {
426            speedscore: default_weight_speedscore(),
427            slots: default_weight_slots(),
428            health: default_weight_health(),
429            cache: default_weight_cache(),
430            network: default_weight_network(),
431            priority: default_weight_priority(),
432            disk: default_weight_disk(),
433            half_open_penalty: default_half_open_penalty(),
434        }
435    }
436}
437
438fn default_weight_speedscore() -> f64 {
439    0.5
440}
441fn default_weight_slots() -> f64 {
442    0.4
443}
444fn default_weight_health() -> f64 {
445    0.3
446}
447fn default_weight_cache() -> f64 {
448    0.2
449}
450fn default_weight_network() -> f64 {
451    0.1
452}
453fn default_weight_priority() -> f64 {
454    // Worker priority is a first-class selection factor: operators set high
455    // priority on preferred boxes (e.g. fast dedicated workers) and expect them
456    // to actually receive work. At the old 0.1 a top-priority worker gained at
457    // most +0.1 to its balanced score — too weak to overcome a warm-cache or
458    // lower-latency competitor, so the highest-priority workers could sit idle
459    // indefinitely. Load/slots still throttle a high-priority worker as it fills.
460    0.5
461}
462fn default_half_open_penalty() -> f64 {
463    0.5
464}
465
466fn default_weight_disk() -> f64 {
467    0.2
468}
469
470fn deserialize_weight_disk<'de, D>(deserializer: D) -> Result<f64, D::Error>
471where
472    D: serde::Deserializer<'de>,
473{
474    let weight = f64::deserialize(deserializer)?;
475    if weight.is_finite() && (0.0..=1.0).contains(&weight) {
476        Ok(weight)
477    } else {
478        Err(serde::de::Error::custom(
479            "disk weight must be finite and between zero and one",
480        ))
481    }
482}
483
484/// Fairness settings for the fair_fastest selection strategy.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct FairnessConfig {
487    /// Lookback window in seconds for tracking recent selections.
488    #[serde(default = "default_fairness_lookback_secs")]
489    pub lookback_secs: u64,
490    /// Maximum consecutive selections for a single worker before penalty.
491    #[serde(default = "default_max_consecutive_selections")]
492    pub max_consecutive_selections: u32,
493}
494
495impl Default for FairnessConfig {
496    fn default() -> Self {
497        Self {
498            lookback_secs: default_fairness_lookback_secs(),
499            max_consecutive_selections: default_max_consecutive_selections(),
500        }
501    }
502}
503
504fn default_fairness_lookback_secs() -> u64 {
505    300 // 5 minutes
506}
507
508fn default_max_consecutive_selections() -> u32 {
509    3
510}
511
512/// Cache affinity settings for project-to-worker pinning.
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct AffinityConfig {
515    /// Enable affinity pinning (pin projects to last successful worker).
516    #[serde(default = "default_affinity_enabled")]
517    pub enabled: bool,
518    /// Time window in minutes to pin a project to its last successful worker.
519    /// After this window expires, normal selection resumes.
520    #[serde(default = "default_affinity_pin_minutes")]
521    pub pin_minutes: u64,
522    /// Enable last-success fallback when no workers match selection criteria.
523    /// If enabled, the daemon will attempt the last successful worker for a project
524    /// when all other workers are unavailable or fail selection.
525    #[serde(default = "default_last_success_fallback")]
526    pub enable_last_success_fallback: bool,
527    /// Minimum success rate for the last-success worker to be used as fallback.
528    /// Must be lower than min_success_rate to be meaningful as a fallback.
529    #[serde(default = "default_fallback_min_success_rate")]
530    pub fallback_min_success_rate: f64,
531}
532
533impl Default for AffinityConfig {
534    fn default() -> Self {
535        Self {
536            enabled: default_affinity_enabled(),
537            pin_minutes: default_affinity_pin_minutes(),
538            enable_last_success_fallback: default_last_success_fallback(),
539            fallback_min_success_rate: default_fallback_min_success_rate(),
540        }
541    }
542}
543
544fn default_affinity_enabled() -> bool {
545    true
546}
547
548fn default_affinity_pin_minutes() -> u64 {
549    60 // 1 hour default
550}
551
552fn default_last_success_fallback() -> bool {
553    true
554}
555
556fn default_fallback_min_success_rate() -> f64 {
557    0.5 // More lenient than normal min_success_rate (0.8)
558}
559
560/// Details about a selected worker for remote compilation.
561#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
562pub struct SelectedWorker {
563    /// Selected worker ID.
564    pub id: WorkerId,
565    /// Host address for SSH.
566    pub host: String,
567    /// SSH user.
568    pub user: String,
569    /// Path to SSH identity file.
570    pub identity_file: String,
571    /// Number of slots available on this worker after reservation.
572    pub slots_available: u32,
573    /// Worker's speed score (0-100).
574    pub speed_score: f64,
575    /// Operator-declared host OS (e.g. `windows`), if any. Propagated so the
576    /// hook can pick the right transport (Windows workers use `C:/rch` +
577    /// tar-over-ssh instead of the Unix rsync path). `None` for the historical
578    /// unlabelled workers. `#[serde(default)]` keeps old daemons/hooks
579    /// wire-compatible.
580    #[serde(default)]
581    pub declared_os: Option<String>,
582}
583
584/// Worker selection response from daemon to hook.
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct SelectionResponse {
587    /// Selected worker details, if available.
588    pub worker: Option<SelectedWorker>,
589    /// Reason for the selection result.
590    pub reason: SelectionReason,
591    /// Optional build ID assigned by the daemon (for active build tracking).
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub build_id: Option<u64>,
594    /// Optional per-worker selector diagnostics for machine-readable triage.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub diagnostics: Option<SelectionDiagnostics>,
597}
598
599/// Final selector decision for a worker considered during routing.
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
601#[serde(rename_all = "snake_case")]
602pub enum WorkerSelectionDiagnosticDecision {
603    /// Worker passed hard gates and normal health threshold.
604    Allow,
605    /// Worker failed normal health threshold but passed fallback threshold.
606    FallbackCandidate,
607    /// Worker was denied by a hard gate or both health thresholds.
608    Deny,
609}
610
611/// Per-worker selector facts captured at worker-selection time.
612#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
613pub struct WorkerSelectionDiagnostic {
614    /// Worker ID.
615    pub worker_id: WorkerId,
616    /// Worker lifecycle/status label.
617    pub status: String,
618    /// Circuit breaker state label.
619    pub circuit_state: String,
620    /// Current normalized pressure state.
621    pub pressure_state: String,
622    /// Stable pressure reason code.
623    pub pressure_reason_code: String,
624    /// Rolling selector success rate, where available.
625    #[serde(skip_serializing_if = "Option::is_none")]
626    pub success_rate: Option<f64>,
627    /// Normal selection success-rate threshold.
628    pub min_success_rate: f64,
629    /// Last-success fallback success-rate threshold.
630    pub fallback_min_success_rate: f64,
631    /// Required runtime for this command.
632    pub required_runtime: RequiredRuntime,
633    /// Whether this worker has the required runtime.
634    pub runtime_available: bool,
635    /// Available slots at selection time.
636    pub available_slots: u32,
637    /// Total configured slots.
638    pub total_slots: u32,
639    /// Estimated slots requested by this command.
640    pub estimated_cores: u32,
641    /// Whether this worker was excluded because it already runs this project.
642    pub active_project_excluded: bool,
643    /// Final per-worker selector decision.
644    pub final_decision: WorkerSelectionDiagnosticDecision,
645    /// Human-readable final reason.
646    pub final_reason: String,
647    /// Stable machine-readable reasons collected for this worker.
648    pub reason_codes: Vec<String>,
649}
650
651/// Machine-readable worker selector diagnostics for a selection request.
652#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
653pub struct SelectionDiagnostics {
654    /// Required runtime for this command.
655    pub required_runtime: RequiredRuntime,
656    /// Estimated slots requested by this command.
657    pub estimated_cores: u32,
658    /// Normal success-rate threshold.
659    pub min_success_rate: f64,
660    /// Last-success fallback success-rate threshold.
661    pub fallback_min_success_rate: f64,
662    /// Count of workers excluded because they already run this project.
663    pub active_project_exclusion_count: usize,
664    /// One diagnostic entry per configured worker.
665    pub workers: Vec<WorkerSelectionDiagnostic>,
666}
667
668/// Request to release reserved worker slots.
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct ReleaseRequest {
671    /// ID of the worker to release slots on.
672    pub worker_id: WorkerId,
673    /// Number of slots to release.
674    pub slots: u32,
675    /// Optional build ID to mark complete.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub build_id: Option<u64>,
678    /// Exact client wrapper identity associated with the admitted build.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub local_wrapper_id: Option<String>,
681    /// Optional exit code for the build (used to finalize active build tracking).
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub exit_code: Option<i32>,
684    /// Optional total duration in milliseconds (pipeline or execution).
685    ///
686    /// If omitted, the daemon will compute duration from the active build's start time.
687    #[serde(default, skip_serializing_if = "Option::is_none")]
688    pub duration_ms: Option<u64>,
689    /// Optional bytes transferred during the pipeline (upload + artifact download).
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub bytes_transferred: Option<u64>,
692    /// Optional per-phase timing breakdown for the build pipeline.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub timing: Option<CommandTimingBreakdown>,
695    /// The hook classified this non-zero exit as a worker fault (missing
696    /// toolchain or system library, CPU-capability signal, full disk), so the
697    /// daemon records no cache warmth for it (review of GH #81).
698    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
699    pub worker_fault: bool,
700}
701
702/// Build execution phase for daemon heartbeat tracking.
703#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
704#[serde(rename_all = "snake_case")]
705pub enum BuildHeartbeatPhase {
706    /// Uploading sources/dependencies to the worker.
707    SyncUp,
708    /// Running the remote compilation/test command.
709    Execute,
710    /// Downloading build artifacts from the worker.
711    SyncDown,
712    /// Build is completing or finalizing.
713    Finalize,
714}
715
716/// Request to update liveness/progress for an active build.
717#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
718pub struct BuildHeartbeatRequest {
719    /// Active build identifier assigned by the daemon.
720    pub build_id: u64,
721    /// Worker executing the build.
722    pub worker_id: WorkerId,
723    /// Hook process ID sending the heartbeat.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub hook_pid: Option<u32>,
726    /// Client-minted wrapper identity for local durable-lease correlation.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub local_wrapper_id: Option<String>,
729    /// Remote file containing the process-group leader PID for cancellation.
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub remote_pgid_file: Option<String>,
732    /// Current build execution phase.
733    pub phase: BuildHeartbeatPhase,
734    /// Optional human-readable progress detail.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub detail: Option<String>,
737    /// Optional monotonic progress counter.
738    ///
739    /// The hook increments this when it observes meaningful progress
740    /// (phase transitions, new output, or richer progress signals).
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub progress_counter: Option<u64>,
743    /// Optional progress estimate in [0,100].
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub progress_percent: Option<f64>,
746}
747
748/// Configuration for a remote worker.
749#[derive(Debug, Clone, Serialize, Deserialize)]
750pub struct WorkerConfig {
751    /// Unique identifier for this worker.
752    pub id: WorkerId,
753    /// SSH hostname or IP address.
754    pub host: String,
755    /// SSH username.
756    pub user: String,
757    /// Path to SSH private key.
758    pub identity_file: String,
759    /// Total CPU slots available on this worker.
760    pub total_slots: u32,
761    /// Priority for worker selection (higher = preferred).
762    #[serde(default = "default_priority")]
763    pub priority: u32,
764    /// Optional tags for filtering.
765    ///
766    /// Mostly descriptive, with one reserved form: `os:<name>` (see
767    /// [`crate::declared_os`]) is a hard admission gate, written in
768    /// `workers.toml` as `os = "<name>"`.
769    #[serde(default)]
770    pub tags: Vec<String>,
771    /// Operator-declared named tool probes for this worker, written in
772    /// `workers.toml` as
773    /// `tools = [{ name = "clang", command = ["clang", "--version"] }]`.
774    ///
775    /// Unlike a tag, which is an unverified naming convention, each entry is
776    /// VERIFIED by running its fixed argv on the worker as the configured user;
777    /// only a zero exit makes the tool present. That is what lets
778    /// `--require-tool` gate selection on evidence instead of on a promise.
779    #[serde(default, skip_serializing_if = "Vec::is_empty")]
780    pub tools: Vec<WorkerToolProbe>,
781}
782
783/// One operator-declared tool probe as it appears in `workers.toml`.
784///
785/// Kept as plain configuration data; [`crate::capability_probe::NamedToolProbe`]
786/// is the validated form, and the conversion is where a malformed name is
787/// refused.
788#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
789pub struct WorkerToolProbe {
790    /// The name `--require-tool` and `jobs.required_tools` refer to.
791    pub name: String,
792    /// The exact argv to run. Zero exit = present; anything else = absent.
793    pub command: Vec<String>,
794}
795
796fn default_priority() -> u32 {
797    100
798}
799
800/// Tag prefix reserved for the operator-declared host OS.
801pub const OS_TAG_PREFIX: &str = "os:";
802
803/// The host OS an operator declared for a worker, from its tags.
804///
805/// Written in `workers.toml` as `os = "windows"` and normalized into the
806/// reserved `os:windows` tag on load, so it rides the existing tag vector
807/// through every path that already carries a [`WorkerConfig`].
808///
809/// Declaring it makes the worker **exclusive**: selection admits it only for
810/// commands that require that OS, so a Windows box cannot quietly absorb a
811/// `cargo check` dispatched from Linux and return wrong-platform artifacts.
812/// Workers without the tag — every historical worker — keep taking anything.
813#[must_use]
814pub fn declared_os(tags: &[String]) -> Option<String> {
815    // `find` over the mapped values, not `find_map` over the tags: a malformed
816    // `os:` entry must not mask a valid declaration later in the list, because
817    // failing open here is precisely the "Windows worker silently takes Linux
818    // jobs" outcome the gate exists to prevent.
819    tags.iter()
820        .filter_map(|tag| tag.strip_prefix(OS_TAG_PREFIX))
821        .map(|os| os.trim().to_ascii_lowercase())
822        .find(|os| !os.is_empty())
823}
824
825/// Build the reserved tag for a declared OS.
826#[must_use]
827pub fn os_tag(os: &str) -> String {
828    format!("{OS_TAG_PREFIX}{}", os.trim().to_ascii_lowercase())
829}
830
831impl Default for WorkerConfig {
832    fn default() -> Self {
833        Self {
834            id: WorkerId::new("default-worker"),
835            host: "localhost".to_string(),
836            user: "user".to_string(),
837            identity_file: "~/.ssh/id_rsa".to_string(),
838            total_slots: 4,
839            priority: default_priority(),
840            tags: Vec::new(),
841            tools: Vec::new(),
842        }
843    }
844}
845
846/// Runtime capabilities detected on a worker.
847///
848/// These are probed during health checks and cached for routing decisions.
849/// Commands requiring specific runtimes (e.g., `bun test`) can be routed
850/// only to workers with the corresponding capability.
851#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
852pub struct WorkerCapabilities {
853    /// Rust compiler version (from `rustc --version`).
854    #[serde(default, skip_serializing_if = "Option::is_none")]
855    pub rustc_version: Option<String>,
856    /// Installed rustup toolchains, including their host triple when rustup
857    /// reports one (for example `nightly-2026-07-05-x86_64-unknown-linux-gnu`).
858    #[serde(default, skip_serializing_if = "Vec::is_empty")]
859    pub rustup_toolchains: Vec<String>,
860    /// Installed rustup components as `<toolchain>:<component>` facts. Component
861    /// names are normalized by removing only the probed host-triple suffix, so
862    /// `clippy-preview` can never satisfy a `clippy` requirement.
863    #[serde(default, skip_serializing_if = "Vec::is_empty")]
864    pub rustup_components: Vec<String>,
865    /// Non-fatal diagnostics gathered while probing (for example a tool binary
866    /// that had to be resolved from a well-known fallback location because the
867    /// caller's PATH was minimal). Empty in the common case; clients may
868    /// surface these so "no facts" is distinguishable from "facts show
869    /// absence" (bd-deft5).
870    #[serde(default, skip_serializing_if = "Vec::is_empty")]
871    pub probe_warnings: Vec<String>,
872    /// Bun runtime version (from `bun --version`).
873    #[serde(default, skip_serializing_if = "Option::is_none")]
874    pub bun_version: Option<String>,
875    /// Node.js version (from `node --version`).
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    pub node_version: Option<String>,
878    /// npm version (from `npm --version`).
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub npm_version: Option<String>,
881    /// Nix version (from `nix --version`). Presence implies a usable nix binary
882    /// with an accompanying `/nix/store`, gating `nix build`/`nix develop -c`
883    /// routing to this worker.
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub nix_version: Option<String>,
886    /// Go toolchain version (from `go version`). Presence gates `go build`/
887    /// `go test`/`go vet` routing to this worker.
888    #[serde(default, skip_serializing_if = "Option::is_none")]
889    pub go_version: Option<String>,
890    /// Zig version (from `zig version`). `cargo zigbuild` shells out to `zig`
891    /// as the linker/C toolchain, so the bare `zig` binary is necessary but not
892    /// sufficient — see [`WorkerCapabilities::has_zig`].
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub zig_version: Option<String>,
895    /// cargo-zigbuild version (from `cargo-zigbuild --version`). Probed as the
896    /// hyphenated binary rather than `cargo zigbuild --version`, which the
897    /// subcommand's own parser rejects.
898    #[serde(default, skip_serializing_if = "Option::is_none")]
899    pub cargo_zigbuild_version: Option<String>,
900    /// x86-64 microarchitecture level (1..=4) parsed from `/proc/cpuinfo`
901    /// flags on the worker (bd-6qchz / bd-68hon item 4). `Some(2)` marks a
902    /// pre-AVX2 box (e.g. ovh-b's Ivy Bridge) whose build scripts SIGILL on
903    /// x86-64-v3 codegen; selection deprioritizes such workers proactively
904    /// while the reactive SIGILL quarantine remains the primary mechanism.
905    /// `None` on non-x86 workers or when the probe cannot read cpuinfo.
906    #[serde(default, skip_serializing_if = "Option::is_none")]
907    pub cpu_microarch_level: Option<u8>,
908    /// Operator-declared named tools whose probe exited ZERO on this worker.
909    ///
910    /// The list is evidence, not configuration: a name appears here only
911    /// because the worker ran the declared argv and it succeeded. Selection
912    /// gates `--require-tool` on this, which is the difference between a
913    /// verified capability and a tag somebody wrote by hand.
914    #[serde(default, skip_serializing_if = "Vec::is_empty")]
915    pub tools_present: Vec<String>,
916    /// Operator-declared named tools whose probe RAN and did not exit zero.
917    ///
918    /// Kept separate from "never declared" so an operator can tell a worker
919    /// that is missing a tool from a fleet that never probed for it — the two
920    /// have different fixes.
921    #[serde(default, skip_serializing_if = "Vec::is_empty")]
922    pub tools_absent: Vec<String>,
923
924    // Health metrics (bd-3eaa)
925    /// Number of CPU cores on the worker.
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub num_cpus: Option<u32>,
928    /// 1-minute load average.
929    #[serde(default, skip_serializing_if = "Option::is_none")]
930    pub load_avg_1: Option<f64>,
931    /// 5-minute load average.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub load_avg_5: Option<f64>,
934    /// 15-minute load average.
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub load_avg_15: Option<f64>,
937    /// Free space in GB on the tightest mount a build touches: whichever of
938    /// the projects root, its alias and `/tmp` has the lowest free ratio.
939    /// Drives pressure classification (a full `/tmp` breaks scp/mktemp).
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub disk_free_gb: Option<f64>,
942    /// Total size in GB of the mount reported in `disk_free_gb`.
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub disk_total_gb: Option<f64>,
945    /// Free space in GB on the filesystem that holds build trees (the fuller
946    /// of the projects root and its alias; `/tmp` excluded). Sizes capacity:
947    /// slot derating, disk headroom and the free-space floor (GH #78).
948    /// `None` from an `rch-wkr` that predates the field.
949    #[serde(default, skip_serializing_if = "Option::is_none")]
950    pub build_disk_free_gb: Option<f64>,
951    /// Total size in GB of the mount reported in `build_disk_free_gb`.
952    #[serde(default, skip_serializing_if = "Option::is_none")]
953    pub build_disk_total_gb: Option<f64>,
954    /// Canonical path-topology preflight status (`/data/projects` + `/dp` alias).
955    ///
956    /// `Some(false)` indicates a hard preflight failure that should exclude this
957    /// worker from remote scheduling until a subsequent successful revalidation.
958    #[serde(default, skip_serializing_if = "Option::is_none")]
959    pub projects_root_ok: Option<bool>,
960    /// Machine-readable failure reason for path-topology preflight.
961    #[serde(default, skip_serializing_if = "Option::is_none")]
962    pub projects_root_issue: Option<String>,
963    /// Unix timestamp (ms) when topology preflight last executed.
964    #[serde(default, skip_serializing_if = "Option::is_none")]
965    pub projects_root_checked_at_unix_ms: Option<i64>,
966}
967
968impl WorkerCapabilities {
969    /// Create new empty capabilities.
970    pub fn new() -> Self {
971        Self::default()
972    }
973
974    /// Create mock capabilities with Rust installed.
975    ///
976    /// Used by mock transport to simulate a worker with Rust toolchain.
977    pub fn mock_with_rust() -> Self {
978        Self {
979            rustc_version: Some("1.85.0-nightly (mock)".to_string()),
980            projects_root_ok: Some(true),
981            ..Self::default()
982        }
983    }
984
985    /// Whether this is a known pre-x86-64-v3 (no AVX2) x86 worker — the class
986    /// whose build-script/proc-macro binaries SIGILL on v3 codegen (bd-68hon).
987    /// `None` (unknown/non-x86) is NOT pre-v3: only affirmative evidence
988    /// deprioritizes a worker.
989    pub fn is_pre_v3_x86(&self) -> bool {
990        matches!(self.cpu_microarch_level, Some(1 | 2))
991    }
992
993    /// Check if this worker has Bun installed.
994    pub fn has_bun(&self) -> bool {
995        self.bun_version.is_some()
996    }
997
998    /// Check if this worker has Node.js installed.
999    pub fn has_node(&self) -> bool {
1000        self.node_version.is_some()
1001    }
1002
1003    /// Check if this worker has Rust installed.
1004    pub fn has_rust(&self) -> bool {
1005        self.rustc_version.is_some()
1006    }
1007
1008    /// Check whether `component` is installed for `toolchain`.
1009    ///
1010    /// Rustup reports pinned toolchains with a host-triple suffix. The request
1011    /// carries the project pin without that suffix, so the owner half uses the
1012    /// same exact-or-`-`-boundary prefix rule as toolchain admission. The
1013    /// component half is exact.
1014    pub fn has_rustup_component(&self, toolchain: &str, component: &str) -> bool {
1015        let owner_prefix = format!("{toolchain}-");
1016        self.rustup_components.iter().any(|fact| {
1017            fact.split_once(':').is_some_and(|(owner, name)| {
1018                name == component && (owner == toolchain || owner.starts_with(&owner_prefix))
1019            })
1020        })
1021    }
1022
1023    /// Check if this worker has Nix installed (nix binary + `/nix/store`).
1024    pub fn has_nix(&self) -> bool {
1025        self.nix_version.is_some()
1026    }
1027
1028    /// Check if this worker has the Go toolchain installed.
1029    pub fn has_go(&self) -> bool {
1030        self.go_version.is_some()
1031    }
1032
1033    /// Check if this worker can run `cargo zigbuild` cross-compiles.
1034    ///
1035    /// Requires BOTH halves of the toolchain: the `cargo-zigbuild` cargo
1036    /// subcommand and the `zig` binary it drives as the linker. Either one
1037    /// alone fails at build time with an error the remote-result heuristics do
1038    /// not recognize as infrastructure (`error: no such command: 'zigbuild'` is
1039    /// not a rustup message), so the exit code would be surfaced verbatim
1040    /// instead of falling open to local. Gating both here keeps that failure
1041    /// out of the user's build.
1042    pub fn has_zig(&self) -> bool {
1043        self.zig_version.is_some() && self.cargo_zigbuild_version.is_some()
1044    }
1045
1046    /// Calculate load per core (1-minute load average / num_cpus).
1047    /// Returns None if metrics are unavailable.
1048    pub fn load_per_core(&self) -> Option<f64> {
1049        match (self.load_avg_1, self.num_cpus) {
1050            (Some(load), Some(cpus)) if cpus > 0 => Some(load / cpus as f64),
1051            _ => None,
1052        }
1053    }
1054
1055    /// Check if worker has high load (exceeds threshold).
1056    /// Returns None if metrics unavailable (fail-open).
1057    pub fn is_high_load(&self, max_load_per_core: f64) -> Option<bool> {
1058        self.load_per_core().map(|lpc| lpc > max_load_per_core)
1059    }
1060
1061    /// `(free_gb, total_gb)` of the filesystem that holds build trees.
1062    ///
1063    /// Capacity questions (how many builds fit, is the free-space floor met)
1064    /// are about that filesystem, not about a small tmpfs `/tmp` that happens
1065    /// to be the tightest mount. Falls back to the tightest-mount sample when
1066    /// the worker does not report a build-disk sample (older `rch-wkr`), which
1067    /// is the conservative pre-GH-#78 behaviour.
1068    pub fn build_disk_gb(&self) -> (Option<f64>, Option<f64>) {
1069        if self.build_disk_free_gb.is_some() {
1070            (self.build_disk_free_gb, self.build_disk_total_gb)
1071        } else {
1072            (self.disk_free_gb, self.disk_total_gb)
1073        }
1074    }
1075
1076    /// Check if the build disk is below the free-space threshold.
1077    /// Returns None if metrics unavailable (fail-open).
1078    pub fn is_low_disk(&self, min_free_gb: f64) -> Option<bool> {
1079        self.build_disk_gb().0.map(|free| free < min_free_gb)
1080    }
1081
1082    /// Check if canonical path-topology preflight is healthy.
1083    ///
1084    /// Returns `None` when probe data is unavailable (fail-open behavior).
1085    pub fn is_topology_healthy(&self) -> Option<bool> {
1086        self.projects_root_ok
1087    }
1088}
1089
1090/// Classify an x86-64 microarchitecture level (1..=4) from a `/proc/cpuinfo`
1091/// `flags` line (bd-6qchz).
1092///
1093/// Levels follow the psABI x86-64-v2/v3/v4 feature baselines, restricted to
1094/// flags as they appear in Linux cpuinfo. A CPU missing any flag of a level is
1095/// classified at the level below — e.g. ovh-b's Ivy Bridge E3-1245 V2 has AVX
1096/// but no `avx2`/`bmi2`/`fma`, so it classifies as 2 and its v3-codegen build
1097/// scripts SIGILL (the bd-68hon incident class).
1098#[must_use]
1099pub fn x86_64_microarch_level_from_flags(flags: &str) -> u8 {
1100    let set: std::collections::HashSet<&str> = flags.split_whitespace().collect();
1101    let has_all = |required: &[&str]| required.iter().all(|flag| set.contains(flag));
1102    // x86-64-v2: CMPXCHG16B, LAHF/SAHF, POPCNT, SSE3/SSSE3/SSE4.x.
1103    const V2: &[&str] = &["cx16", "lahf_lm", "popcnt", "sse4_1", "sse4_2", "ssse3"];
1104    // x86-64-v3: AVX/AVX2, BMI1/2, F16C, FMA, MOVBE, XSAVE (LZCNT is `abm`).
1105    const V3: &[&str] = &[
1106        "abm", "avx", "avx2", "bmi1", "bmi2", "f16c", "fma", "movbe", "xsave",
1107    ];
1108    // x86-64-v4: the AVX-512 F/BW/CD/DQ/VL baseline.
1109    const V4: &[&str] = &["avx512f", "avx512bw", "avx512cd", "avx512dq", "avx512vl"];
1110    if !has_all(V2) {
1111        return 1;
1112    }
1113    if !has_all(V3) {
1114        return 2;
1115    }
1116    if !has_all(V4) {
1117        return 3;
1118    }
1119    4
1120}
1121
1122/// Path topology configuration.
1123///
1124/// Overrides the default canonical and alias project roots used by
1125/// [`crate::path_topology::PathTopologyPolicy`].  This is useful on platforms
1126/// such as macOS where SIP prevents using `/data/projects` and the user must
1127/// choose a different root (e.g. `~/Projects`).
1128///
1129/// ```toml
1130/// [path_topology]
1131/// canonical_root = "/Users/me/Projects"
1132/// alias_root = "/Users/me/p"
1133/// ```
1134#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1135pub struct PathTopologyConfig {
1136    /// Canonical project root directory.
1137    /// Defaults to `/data/projects` when absent.
1138    #[serde(default)]
1139    pub canonical_root: Option<String>,
1140
1141    /// Alias (symlink) root that points at the canonical root.
1142    /// Defaults to `/dp` when absent.
1143    #[serde(default)]
1144    pub alias_root: Option<String>,
1145}
1146
1147impl PathTopologyConfig {
1148    /// Build a [`crate::path_topology::PathTopologyPolicy`] from this config,
1149    /// falling back to the compiled-in defaults for any field left unset.
1150    ///
1151    /// Empty strings are treated as unset (fall back to defaults).
1152    /// Tilde prefixes (`~/...`) are expanded to the user's home directory.
1153    pub fn to_policy(&self) -> crate::path_topology::PathTopologyPolicy {
1154        let canonical = self
1155            .canonical_root
1156            .as_deref()
1157            .filter(|s| !s.is_empty())
1158            .map(|s| shellexpand::tilde(s).into_owned())
1159            .unwrap_or_else(|| crate::path_topology::DEFAULT_CANONICAL_PROJECT_ROOT.to_string());
1160        let alias = self
1161            .alias_root
1162            .as_deref()
1163            .filter(|s| !s.is_empty())
1164            .map(|s| shellexpand::tilde(s).into_owned())
1165            .unwrap_or_else(|| crate::path_topology::DEFAULT_ALIAS_PROJECT_ROOT.to_string());
1166        crate::path_topology::PathTopologyPolicy::new(
1167            PathBuf::from(canonical),
1168            PathBuf::from(alias),
1169        )
1170    }
1171}
1172
1173/// RCH configuration.
1174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1175pub struct RchConfig {
1176    #[serde(default)]
1177    pub general: GeneralConfig,
1178    #[serde(default)]
1179    pub compilation: CompilationConfig,
1180    #[serde(default)]
1181    pub transfer: TransferConfig,
1182    #[serde(default)]
1183    pub environment: EnvironmentConfig,
1184    #[serde(default)]
1185    pub circuit: CircuitBreakerConfig,
1186    #[serde(default)]
1187    pub output: OutputConfig,
1188    #[serde(default)]
1189    pub self_healing: SelfHealingConfig,
1190    #[serde(default)]
1191    pub self_test: SelfTestConfig,
1192    /// Worker selection algorithm configuration.
1193    #[serde(default)]
1194    pub selection: SelectionConfig,
1195    /// Execution allowlist configuration (bd-785w).
1196    #[serde(default)]
1197    pub execution: ExecutionConfig,
1198    /// Worker health alerting configuration (daemon).
1199    #[serde(default)]
1200    pub alerts: AlertsConfig,
1201    /// Fleet operation configuration (bd-rs7w.2).
1202    #[serde(default)]
1203    pub fleet: FleetConfig,
1204    /// Path topology overrides for project root directories.
1205    #[serde(default)]
1206    pub path_topology: PathTopologyConfig,
1207    /// Doctor / reliability subsystem configuration (verdict webhooks).
1208    #[serde(default)]
1209    pub doctor: DoctorConfig,
1210    /// Session-history remediation knobs: the central schema and default policy
1211    /// for temporary bypass, auto-rejoin, reconciliation, proof, incident
1212    /// ledger, build-root, pooled targets, telemetry freshness, log retention,
1213    /// disk pressure, and smoke defaults (bd-...remediation-ocv9i.17.1).
1214    #[serde(default)]
1215    pub remediation: crate::remediation_config::RemediationConfig,
1216    /// Layer 0 configuration pack knobs (bd-bqu38 / M-1 §149). Defaults OFF;
1217    /// injected as CARGO_PROFILE_* env vars into remote cargo builds.
1218    #[serde(default)]
1219    pub layer0: Layer0Config,
1220    /// Daemon status API over TCP for other machines on the tailnet
1221    /// (`[api]`, bd-2f5ms). Off unless `bind` is set.
1222    #[serde(default)]
1223    pub api: ApiConfig,
1224    /// Where the fleet dashboard lives (`[dashboard]`), for `rch web`.
1225    #[serde(default)]
1226    pub dashboard: DashboardConfig,
1227    /// Job-mode defaults (`[jobs]`), for `rch exec --job`.
1228    #[serde(default)]
1229    pub jobs: JobsConfig,
1230}
1231
1232/// Job-mode configuration (`[jobs]`).
1233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1234pub struct JobsConfig {
1235    /// Named tools every job from this project requires a worker to have
1236    /// VERIFIED, as though each were passed with `--require-tool`.
1237    ///
1238    /// This is the project saying "my jobs cannot run on a worker without
1239    /// these", so it applies on top of any `--require-tool` given on the
1240    /// command line rather than being replaced by it. It affects job mode
1241    /// ONLY: ordinary offloaded compilation keeps its existing admission
1242    /// behavior, because a project-wide requirement that silently narrowed
1243    /// every build's worker pool would be a surprising way to lose the fleet.
1244    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1245    pub required_tools: Vec<String>,
1246}
1247
1248/// Daemon status API over TCP (`[api]`).
1249///
1250/// rchd's rich state (`/status`, `/workers/capabilities`) lives on a `0600`
1251/// Unix socket, which only the local user can read. This section opens the
1252/// same JSON on a TCP listener so agents and the fleet dashboard on OTHER
1253/// machines can ask a dispatcher "why are you local-only right now?" without
1254/// ssh. It is meant for a tailnet: the bind address must be loopback or a
1255/// Tailscale address (100.64.0.0/10, fd7a:115c:a1e0::/48) unless
1256/// `allow_any_addr` is set, and every status route requires the bearer token
1257/// unless `token` is unset AND `no_token` is explicitly true.
1258///
1259/// ```toml
1260/// [api]
1261/// bind = "tailscale"       # or "tailscale:9101", "100.68.51.94:9101", "127.0.0.1:9101"
1262/// token = "…"              # or token_file = "~/.config/rch/api.token"
1263/// ```
1264#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1265#[serde(default)]
1266pub struct ApiConfig {
1267    /// Where to listen. `"tailscale"` resolves this machine's Tailscale IPv4
1268    /// at startup (default port 9101); `"tailscale:PORT"`, `"IP:PORT"` or
1269    /// `"[v6]:PORT"` are taken literally. Unset (empty) = API off.
1270    pub bind: String,
1271    /// Bearer token required on `/status` and `/workers/capabilities`.
1272    pub token: Option<String>,
1273    /// File holding the bearer token (trimmed). Takes precedence over `token`.
1274    pub token_file: Option<String>,
1275    /// Serve the status routes with NO token. Only honoured when no token is
1276    /// configured; must be set explicitly so an empty token is never an
1277    /// accidental open door.
1278    pub no_token: bool,
1279    /// Permit a bind address outside loopback and the Tailscale ranges.
1280    pub allow_any_addr: bool,
1281}
1282
1283/// Where the fleet dashboard is served (`[dashboard]`).
1284#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1285#[serde(default)]
1286pub struct DashboardConfig {
1287    /// URL `rch web` opens, e.g. `https://rch-fleet.vercel.app`.
1288    pub url: Option<String>,
1289}
1290
1291/// Doctor reliability subsystem configuration.
1292///
1293/// Currently carries the verdict-transition webhook surface. Kept as its own
1294/// section (`[doctor]`) so future doctor knobs land here rather than bloating
1295/// the top-level config.
1296#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1297pub struct DoctorConfig {
1298    /// Verdict-transition webhook destinations.
1299    #[serde(default)]
1300    pub webhooks: DoctorWebhooksConfig,
1301}
1302
1303/// Webhook notification surface for reliability verdict transitions.
1304///
1305/// Fires when `rch doctor --reliability --watch` observes a verdict change
1306/// (`Healthy → Degraded`, `Degraded → Failing`, etc.). Each endpoint selects
1307/// which transitions it cares about and how the payload is formatted.
1308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1309pub struct DoctorWebhooksConfig {
1310    /// Named webhook destinations. Empty = no webhook dispatch.
1311    #[serde(default)]
1312    pub endpoints: Vec<DoctorWebhookEndpoint>,
1313    /// Maximum number of in-flight webhook deliveries. Bounds memory and
1314    /// outbound connection pressure: when the queue is full, a transition's
1315    /// delivery is dropped (and journaled) rather than growing unboundedly.
1316    /// Per-process, shared across all endpoints.
1317    #[serde(default = "default_webhook_max_inflight")]
1318    pub max_inflight: usize,
1319}
1320
1321// Manual `Default` so the in-code default matches the serde default
1322// (`#[derive(Default)]` would give `max_inflight = 0`, diverging from a
1323// from-TOML load that fills in 16).
1324impl Default for DoctorWebhooksConfig {
1325    fn default() -> Self {
1326        Self {
1327            endpoints: Vec::new(),
1328            max_inflight: default_webhook_max_inflight(),
1329        }
1330    }
1331}
1332
1333/// A single webhook destination for verdict transitions.
1334///
1335/// SECRETS ARE NEVER STORED HERE. Routing keys, bearer tokens, and signing
1336/// secrets are referenced by the *name* of an environment variable
1337/// (`*_env` fields) read at dispatch time. There is deliberately no
1338/// `secret` / `routing_key` / `bearer_token` / `password` field on this
1339/// struct (enforced by `test_no_inline_secret_fields`).
1340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1341pub struct DoctorWebhookEndpoint {
1342    /// Operator-facing name (appears in logs and the audit trail).
1343    pub name: String,
1344    /// Destination URL.
1345    pub url: String,
1346    /// Payload format.
1347    #[serde(default)]
1348    pub format: DoctorWebhookFormat,
1349    /// Transition predicates that arm this endpoint. Empty = never fire.
1350    /// Supported: `healthy_to_degraded`, `degraded_to_failing`,
1351    /// `any_to_failing`, `any_to_degraded`, `failing_to_healthy`,
1352    /// `degraded_to_healthy`, `any_to_healthy`, `any_to_any`.
1353    #[serde(default)]
1354    pub on_transitions: Vec<String>,
1355    /// Maximum retry attempts after the first try fails.
1356    #[serde(default = "default_webhook_retry_max")]
1357    pub retry_max: u32,
1358    /// Base backoff between retries (milliseconds); doubles each attempt.
1359    #[serde(default = "default_webhook_retry_backoff_ms")]
1360    pub retry_backoff_ms: u64,
1361    /// Per-attempt request timeout (milliseconds).
1362    #[serde(default = "default_webhook_timeout_ms")]
1363    pub timeout_ms: u64,
1364    /// Per-endpoint kill switch.
1365    #[serde(default = "default_true")]
1366    pub enabled: bool,
1367    /// Name of the env var holding the PagerDuty Events API routing key.
1368    /// Required for `format = "pagerduty"`. Read at runtime, never stored.
1369    #[serde(default)]
1370    pub routing_key_env: Option<String>,
1371    /// Name of the env var holding a bearer token sent as
1372    /// `Authorization: Bearer <token>`. Read at runtime, never stored.
1373    #[serde(default)]
1374    pub bearer_token_env: Option<String>,
1375    /// Name of the env var holding an HMAC-SHA256 signing secret. When set,
1376    /// the body is signed and the signature sent as `X-RCH-Signature:
1377    /// sha256=<hex>`. Read at runtime, never stored.
1378    #[serde(default)]
1379    pub signing_secret_env: Option<String>,
1380}
1381
1382/// Built-in webhook payload formats.
1383#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1384#[serde(rename_all = "snake_case")]
1385pub enum DoctorWebhookFormat {
1386    /// Opinionated RCH JSON schema (default).
1387    #[default]
1388    GenericJson,
1389    /// Slack Block Kit payload.
1390    Slack,
1391    /// PagerDuty Events API v2 payload.
1392    Pagerduty,
1393}
1394
1395fn default_webhook_max_inflight() -> usize {
1396    16
1397}
1398
1399fn default_webhook_retry_max() -> u32 {
1400    3
1401}
1402
1403fn default_webhook_retry_backoff_ms() -> u64 {
1404    200
1405}
1406
1407fn default_webhook_timeout_ms() -> u64 {
1408    5000
1409}
1410
1411/// Worker health alerting configuration.
1412#[derive(Debug, Clone, Serialize, Deserialize)]
1413pub struct AlertsConfig {
1414    /// Enable or disable alert generation in the daemon.
1415    #[serde(default = "default_true")]
1416    pub enabled: bool,
1417    /// Suppress duplicate alerts for this many seconds.
1418    #[serde(default = "default_alert_suppress_duplicates_secs")]
1419    pub suppress_duplicates_secs: u64,
1420    /// How long to keep a cleared alert visible (with state
1421    /// `cleared_pending_clean`) before removing it entirely. Prevents
1422    /// transient, self-healing circuit-breaker warnings from lingering on
1423    /// `rch status` long after the fleet returned to healthy (bd-3ogaz).
1424    #[serde(default = "default_alert_cleared_retention_secs")]
1425    pub cleared_retention_secs: u64,
1426    /// Webhook configuration for external notifications.
1427    #[serde(default)]
1428    pub webhook: Option<WebhookConfig>,
1429}
1430
1431/// Webhook configuration for alert dispatch.
1432#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1433pub struct WebhookConfig {
1434    /// Webhook endpoint URL (e.g., Slack/Discord webhook URL).
1435    pub url: Option<String>,
1436    /// Secret for HMAC-SHA256 signing (optional).
1437    #[serde(default)]
1438    pub secret: Option<String>,
1439    /// Timeout in seconds for webhook requests.
1440    #[serde(default = "default_webhook_timeout_secs")]
1441    pub timeout_secs: u64,
1442    /// Number of retries on failure.
1443    #[serde(default = "default_webhook_retry_count")]
1444    pub retry_count: u32,
1445    /// Events to send (empty = all events).
1446    /// Supported: worker_offline, worker_degraded, circuit_open, all_workers_offline
1447    #[serde(default)]
1448    pub events: Vec<String>,
1449}
1450
1451fn default_webhook_timeout_secs() -> u64 {
1452    5
1453}
1454
1455fn default_webhook_retry_count() -> u32 {
1456    3
1457}
1458
1459fn default_alert_suppress_duplicates_secs() -> u64 {
1460    300
1461}
1462
1463fn default_alert_cleared_retention_secs() -> u64 {
1464    300
1465}
1466
1467impl Default for AlertsConfig {
1468    fn default() -> Self {
1469        Self {
1470            enabled: true,
1471            suppress_duplicates_secs: default_alert_suppress_duplicates_secs(),
1472            cleared_retention_secs: default_alert_cleared_retention_secs(),
1473            webhook: None,
1474        }
1475    }
1476}
1477
1478/// Fleet operation configuration.
1479///
1480/// Controls timeouts, thresholds, and behavior for fleet management commands
1481/// like `rch fleet preflight`, `rch fleet status`, and `rch fleet deploy`.
1482#[derive(Debug, Clone, Serialize, Deserialize)]
1483pub struct FleetConfig {
1484    /// SSH connection timeout in seconds.
1485    #[serde(default = "default_fleet_ssh_connect_timeout_secs")]
1486    pub ssh_connect_timeout_secs: u64,
1487    /// SSH command execution timeout in seconds.
1488    #[serde(default = "default_fleet_ssh_command_timeout_secs")]
1489    pub ssh_command_timeout_secs: u64,
1490    /// Minimum required disk space in MB for worker health.
1491    #[serde(default = "default_fleet_min_disk_space_mb")]
1492    pub min_disk_space_mb: u64,
1493    /// Maximum acceptable load average for worker health.
1494    #[serde(default = "default_fleet_max_load_average")]
1495    pub max_load_average: f64,
1496    /// Maximum number of concurrent SSH connections.
1497    #[serde(default = "default_fleet_max_concurrent_workers")]
1498    pub max_concurrent_workers: usize,
1499    /// Number of retry attempts for transient failures.
1500    #[serde(default = "default_fleet_retry_count")]
1501    pub retry_count: u32,
1502    /// Delay between retries in milliseconds.
1503    #[serde(default = "default_fleet_retry_delay_ms")]
1504    pub retry_delay_ms: u64,
1505}
1506
1507fn default_fleet_ssh_connect_timeout_secs() -> u64 {
1508    10
1509}
1510
1511fn default_fleet_ssh_command_timeout_secs() -> u64 {
1512    30
1513}
1514
1515fn default_fleet_min_disk_space_mb() -> u64 {
1516    500
1517}
1518
1519fn default_fleet_max_load_average() -> f64 {
1520    10.0
1521}
1522
1523fn default_fleet_max_concurrent_workers() -> usize {
1524    10
1525}
1526
1527fn default_fleet_retry_count() -> u32 {
1528    2
1529}
1530
1531fn default_fleet_retry_delay_ms() -> u64 {
1532    1000
1533}
1534
1535impl Default for FleetConfig {
1536    fn default() -> Self {
1537        Self {
1538            ssh_connect_timeout_secs: default_fleet_ssh_connect_timeout_secs(),
1539            ssh_command_timeout_secs: default_fleet_ssh_command_timeout_secs(),
1540            min_disk_space_mb: default_fleet_min_disk_space_mb(),
1541            max_load_average: default_fleet_max_load_average(),
1542            max_concurrent_workers: default_fleet_max_concurrent_workers(),
1543            retry_count: default_fleet_retry_count(),
1544            retry_delay_ms: default_fleet_retry_delay_ms(),
1545        }
1546    }
1547}
1548
1549impl FleetConfig {
1550    /// Get SSH connect timeout as Duration.
1551    pub fn ssh_connect_timeout(&self) -> std::time::Duration {
1552        std::time::Duration::from_secs(self.ssh_connect_timeout_secs)
1553    }
1554
1555    /// Get SSH command timeout as Duration.
1556    pub fn ssh_command_timeout(&self) -> std::time::Duration {
1557        std::time::Duration::from_secs(self.ssh_command_timeout_secs)
1558    }
1559
1560    /// Get retry delay as Duration.
1561    pub fn retry_delay(&self) -> std::time::Duration {
1562        std::time::Duration::from_millis(self.retry_delay_ms)
1563    }
1564}
1565
1566/// Box-level role (bd-wywsj): what this MACHINE is for, so "never
1567/// build locally" is configuration rather than a per-call env var.
1568#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1569#[serde(rename_all = "lowercase")]
1570pub enum BoxRole {
1571    /// Orchestrator box: offloadable builds default FAIL-CLOSED +
1572    /// queue (RCH_REQUIRE_REMOTE semantics) — a saturated fleet waits
1573    /// for a worker instead of repopulating local rustc. Explicit
1574    /// per-call env still overrides.
1575    Dispatcher,
1576    /// The box IS the compute: fail-open/local; `rch shim install`
1577    /// refuses here.
1578    Worker,
1579    /// Today's behavior (fully backward compatible).
1580    #[default]
1581    Hybrid,
1582}
1583
1584impl BoxRole {
1585    /// Stable lowercase token.
1586    #[must_use]
1587    pub fn as_str(self) -> &'static str {
1588        match self {
1589            BoxRole::Dispatcher => "dispatcher",
1590            BoxRole::Worker => "worker",
1591            BoxRole::Hybrid => "hybrid",
1592        }
1593    }
1594}
1595
1596#[derive(Debug, Clone, Serialize, Deserialize)]
1597pub struct GeneralConfig {
1598    /// Whether RCH is enabled.
1599    #[serde(default = "default_true")]
1600    pub enabled: bool,
1601    /// Box-level role (bd-wywsj): dispatcher boxes default fail-closed
1602    /// + queue for offloadable builds; worker boxes are the compute.
1603    #[serde(default)]
1604    pub role: BoxRole,
1605    /// Force local execution for this project (never offload), even if the command is classified.
1606    ///
1607    /// Intended for sensitive projects or cases where local state must be authoritative.
1608    #[serde(default)]
1609    pub force_local: bool,
1610    /// Force remote execution for this project (always attempt offload) when safe.
1611    ///
1612    /// This bypasses heuristic gating (e.g. confidence threshold overrides) but still respects
1613    /// structural safety checks and NEVER_INTERCEPT patterns.
1614    #[serde(default)]
1615    pub force_remote: bool,
1616    /// Log level (trace, debug, info, warn, error).
1617    #[serde(default = "default_log_level")]
1618    pub log_level: String,
1619    /// Path to Unix socket for daemon communication.
1620    #[serde(default = "default_socket_path")]
1621    pub socket_path: String,
1622}
1623
1624/// Environment variable passthrough configuration.
1625#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1626pub struct EnvironmentConfig {
1627    /// Allowlist of environment variables to forward to remote workers.
1628    #[serde(default)]
1629    pub allowlist: Vec<String>,
1630}
1631
1632impl Default for GeneralConfig {
1633    fn default() -> Self {
1634        Self {
1635            enabled: true,
1636            role: BoxRole::default(),
1637            force_local: false,
1638            force_remote: false,
1639            log_level: "info".to_string(),
1640            socket_path: default_socket_path(),
1641        }
1642    }
1643}
1644
1645/// Visibility level for hook output.
1646#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
1647#[serde(rename_all = "lowercase")]
1648pub enum OutputVisibility {
1649    #[default]
1650    None,
1651    Summary,
1652    Verbose,
1653}
1654
1655impl std::fmt::Display for OutputVisibility {
1656    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1657        let value = match self {
1658            OutputVisibility::None => "none",
1659            OutputVisibility::Summary => "summary",
1660            OutputVisibility::Verbose => "verbose",
1661        };
1662        write!(f, "{}", value)
1663    }
1664}
1665
1666impl std::str::FromStr for OutputVisibility {
1667    type Err = ();
1668
1669    fn from_str(s: &str) -> Result<Self, Self::Err> {
1670        match s.trim().to_lowercase().as_str() {
1671            "none" | "silent" | "quiet" => Ok(Self::None),
1672            "summary" | "short" => Ok(Self::Summary),
1673            "verbose" | "debug" => Ok(Self::Verbose),
1674            _ => Err(()),
1675        }
1676    }
1677}
1678
1679/// Color mode for remote command output.
1680///
1681/// Controls whether ANSI color codes are preserved when streaming
1682/// output from remote compilation commands.
1683#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1684#[serde(rename_all = "lowercase")]
1685pub enum ColorMode {
1686    /// Force color output regardless of terminal detection.
1687    /// Sets CARGO_TERM_COLOR=always and similar environment variables.
1688    #[default]
1689    Always,
1690    /// Let the remote command detect terminal capabilities (may lose colors).
1691    Auto,
1692    /// Disable color output entirely.
1693    Never,
1694}
1695
1696impl std::fmt::Display for ColorMode {
1697    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1698        let value = match self {
1699            ColorMode::Always => "always",
1700            ColorMode::Auto => "auto",
1701            ColorMode::Never => "never",
1702        };
1703        write!(f, "{}", value)
1704    }
1705}
1706
1707impl std::str::FromStr for ColorMode {
1708    type Err = ();
1709
1710    fn from_str(s: &str) -> Result<Self, Self::Err> {
1711        match s.trim().to_lowercase().as_str() {
1712            "always" | "force" | "yes" | "true" => Ok(Self::Always),
1713            "auto" | "detect" => Ok(Self::Auto),
1714            "never" | "none" | "no" | "false" => Ok(Self::Never),
1715            _ => Err(()),
1716        }
1717    }
1718}
1719
1720fn default_color_mode() -> ColorMode {
1721    ColorMode::default()
1722}
1723
1724/// Hook output configuration.
1725#[derive(Debug, Clone, Serialize, Deserialize)]
1726pub struct OutputConfig {
1727    /// Output visibility level for hook messages.
1728    #[serde(default = "default_output_visibility")]
1729    pub visibility: OutputVisibility,
1730    /// Whether the first-run success message has been shown.
1731    #[serde(default)]
1732    pub first_run_complete: bool,
1733    /// Color mode for remote command output.
1734    /// Controls whether ANSI color codes are preserved in remote output.
1735    #[serde(default = "default_color_mode")]
1736    pub color_mode: ColorMode,
1737}
1738
1739impl Default for OutputConfig {
1740    fn default() -> Self {
1741        Self {
1742            visibility: OutputVisibility::None,
1743            first_run_complete: false,
1744            color_mode: ColorMode::default(),
1745        }
1746    }
1747}
1748
1749// ============================================================================
1750// Self-Healing Configuration
1751// ============================================================================
1752
1753fn default_autostart_cooldown_secs() -> u64 {
1754    30
1755}
1756
1757fn default_autostart_timeout_secs() -> u64 {
1758    3
1759}
1760
1761/// Log level for self-healing diagnostic output.
1762///
1763/// Used to filter `tracing` events emitted by the self-healing subsystem
1764/// (hook auto-start, daemon hook-install). Read from
1765/// `RCH_SELF_HEALING_LOG_LEVEL=debug|info|warn|error` (case-insensitive).
1766#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
1767#[serde(rename_all = "lowercase")]
1768pub enum SelfHealingLogLevel {
1769    Debug,
1770    #[default]
1771    Info,
1772    Warn,
1773    Error,
1774}
1775
1776impl SelfHealingLogLevel {
1777    /// Parse a human-friendly level name. Returns `None` for unknown input.
1778    pub fn from_env_str(s: &str) -> Option<Self> {
1779        match s.trim().to_ascii_lowercase().as_str() {
1780            "debug" => Some(Self::Debug),
1781            "info" => Some(Self::Info),
1782            "warn" | "warning" => Some(Self::Warn),
1783            "error" => Some(Self::Error),
1784            _ => None,
1785        }
1786    }
1787}
1788
1789/// Self-healing behaviors for the hook and daemon.
1790#[derive(Debug, Clone, Serialize, Deserialize)]
1791pub struct SelfHealingConfig {
1792    /// Whether the hook should attempt to auto-start the daemon.
1793    #[serde(default = "default_true")]
1794    pub hook_starts_daemon: bool,
1795    /// Whether the daemon should auto-install Claude Code hooks on startup.
1796    #[serde(default = "default_true")]
1797    pub daemon_installs_hooks: bool,
1798    /// Cooldown between auto-start attempts in seconds.
1799    #[serde(default = "default_autostart_cooldown_secs")]
1800    pub auto_start_cooldown_secs: u64,
1801    /// Time to wait for the daemon socket after spawning in seconds.
1802    /// Also aliased as `daemon_start_timeout` for backwards compatibility.
1803    #[serde(
1804        default = "default_autostart_timeout_secs",
1805        alias = "daemon_start_timeout"
1806    )]
1807    pub auto_start_timeout_secs: u64,
1808    /// Log level for self-healing diagnostic events. Default: info.
1809    /// Override via `RCH_SELF_HEALING_LOG_LEVEL=debug|info|warn|error`.
1810    #[serde(default)]
1811    pub self_healing_log_level: SelfHealingLogLevel,
1812}
1813
1814impl Default for SelfHealingConfig {
1815    fn default() -> Self {
1816        Self {
1817            hook_starts_daemon: default_true(),
1818            daemon_installs_hooks: default_true(),
1819            auto_start_cooldown_secs: default_autostart_cooldown_secs(),
1820            auto_start_timeout_secs: default_autostart_timeout_secs(),
1821            self_healing_log_level: SelfHealingLogLevel::default(),
1822        }
1823    }
1824}
1825
1826impl SelfHealingConfig {
1827    /// Apply environment variable overrides to this config.
1828    ///
1829    /// Supported environment variables:
1830    /// - `RCH_NO_SELF_HEALING=1` - Master switch to disable all self-healing
1831    /// - `RCH_HOOK_STARTS_DAEMON=0|1` - Control hook auto-starting daemon
1832    /// - `RCH_DAEMON_INSTALLS_HOOKS=0|1` - Control daemon auto-installing hooks
1833    /// - `RCH_AUTO_START_TIMEOUT_SECS=<seconds>` - Max wait for daemon start
1834    /// - `RCH_AUTO_START_COOLDOWN_SECS=<seconds>` - Min time between auto-starts
1835    pub fn with_env_overrides(mut self) -> Self {
1836        // Master disable switch
1837        if let Ok(val) = std::env::var("RCH_NO_SELF_HEALING")
1838            && (val == "1" || val.eq_ignore_ascii_case("true"))
1839        {
1840            self.hook_starts_daemon = false;
1841            self.daemon_installs_hooks = false;
1842            return self;
1843        }
1844
1845        // Individual toggles
1846        if let Ok(val) = std::env::var("RCH_HOOK_STARTS_DAEMON") {
1847            self.hook_starts_daemon = val != "0" && !val.eq_ignore_ascii_case("false");
1848        }
1849        if let Ok(val) = std::env::var("RCH_DAEMON_INSTALLS_HOOKS") {
1850            self.daemon_installs_hooks = val != "0" && !val.eq_ignore_ascii_case("false");
1851        }
1852
1853        // Numeric settings
1854        if let Ok(val) = std::env::var("RCH_AUTO_START_TIMEOUT_SECS")
1855            && let Ok(secs) = val.parse()
1856        {
1857            self.auto_start_timeout_secs = secs;
1858        }
1859        if let Ok(val) = std::env::var("RCH_AUTO_START_COOLDOWN_SECS")
1860            && let Ok(secs) = val.parse()
1861        {
1862            self.auto_start_cooldown_secs = secs;
1863        }
1864        // br-4zf3p: log-level override.
1865        if let Ok(val) = std::env::var("RCH_SELF_HEALING_LOG_LEVEL") {
1866            if let Some(level) = SelfHealingLogLevel::from_env_str(&val) {
1867                self.self_healing_log_level = level;
1868            } else {
1869                tracing::warn!(
1870                    target: "rch::self_healing",
1871                    invalid = %val,
1872                    "RCH_SELF_HEALING_LOG_LEVEL has invalid value; using default=info"
1873                );
1874            }
1875        }
1876
1877        self
1878    }
1879}
1880
1881// ============================================================================
1882// Self-Test Configuration
1883// ============================================================================
1884
1885/// Action to take when a scheduled self-test fails.
1886#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1887#[serde(rename_all = "snake_case")]
1888pub enum SelfTestFailureAction {
1889    /// Only emit an alert/log entry.
1890    #[default]
1891    Alert,
1892    /// Disable the failing worker automatically.
1893    DisableWorker,
1894    /// Alert and disable the worker.
1895    AlertAndDisable,
1896}
1897
1898/// Which workers to include in self-test runs.
1899#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1900#[serde(untagged)]
1901pub enum SelfTestWorkers {
1902    /// Special value "all" (or a single worker id string).
1903    All(String),
1904    /// Explicit worker list.
1905    List(Vec<WorkerId>),
1906}
1907
1908impl Default for SelfTestWorkers {
1909    fn default() -> Self {
1910        Self::All("all".to_string())
1911    }
1912}
1913
1914impl SelfTestWorkers {
1915    /// Resolve to an explicit list if configured, or None to indicate "all".
1916    pub fn resolve(&self) -> Option<Vec<WorkerId>> {
1917        match self {
1918            SelfTestWorkers::All(value) => {
1919                if value.eq_ignore_ascii_case("all") {
1920                    None
1921                } else {
1922                    Some(vec![WorkerId::new(value.clone())])
1923                }
1924            }
1925            SelfTestWorkers::List(list) => Some(list.clone()),
1926        }
1927    }
1928}
1929
1930fn default_self_test_retry_count() -> u32 {
1931    3
1932}
1933
1934fn default_self_test_retry_delay() -> String {
1935    "5m".to_string()
1936}
1937
1938fn default_self_test_enabled() -> bool {
1939    false
1940}
1941
1942/// Self-test scheduling and behavior configuration.
1943#[derive(Debug, Clone, Serialize, Deserialize)]
1944pub struct SelfTestConfig {
1945    /// Enable scheduled self-tests.
1946    #[serde(default = "default_self_test_enabled")]
1947    pub enabled: bool,
1948    /// Cron schedule (e.g., "0 3 * * *").
1949    #[serde(default)]
1950    pub schedule: Option<String>,
1951    /// Interval duration (e.g., "24h").
1952    #[serde(default)]
1953    pub interval: Option<String>,
1954    /// Which workers to test (default: "all").
1955    #[serde(default)]
1956    pub workers: SelfTestWorkers,
1957    /// Action on failure.
1958    #[serde(default)]
1959    pub on_failure: SelfTestFailureAction,
1960    /// Retry count for failed tests.
1961    #[serde(default = "default_self_test_retry_count")]
1962    pub retry_count: u32,
1963    /// Delay between retries.
1964    #[serde(default = "default_self_test_retry_delay")]
1965    pub retry_delay: String,
1966}
1967
1968impl Default for SelfTestConfig {
1969    fn default() -> Self {
1970        Self {
1971            enabled: default_self_test_enabled(),
1972            schedule: None,
1973            interval: None,
1974            workers: SelfTestWorkers::default(),
1975            on_failure: SelfTestFailureAction::default(),
1976            retry_count: default_self_test_retry_count(),
1977            retry_delay: default_self_test_retry_delay(),
1978        }
1979    }
1980}
1981
1982/// Circuit breaker configuration.
1983#[derive(Debug, Clone, Serialize, Deserialize)]
1984pub struct CircuitBreakerConfig {
1985    /// Consecutive failures to open circuit.
1986    #[serde(default = "default_circuit_failure_threshold")]
1987    pub failure_threshold: u32,
1988    /// Consecutive successes to close circuit.
1989    #[serde(default = "default_circuit_success_threshold")]
1990    pub success_threshold: u32,
1991    /// Error rate threshold (0.0-1.0) to open circuit.
1992    #[serde(default = "default_circuit_error_rate_threshold")]
1993    pub error_rate_threshold: f64,
1994    /// Rolling window size in seconds.
1995    #[serde(default = "default_circuit_window_secs")]
1996    pub window_secs: u64,
1997    /// Cooldown duration before half-open (seconds).
1998    #[serde(default = "default_circuit_open_cooldown_secs")]
1999    pub open_cooldown_secs: u64,
2000    /// Max concurrent probes in half-open.
2001    #[serde(default = "default_circuit_half_open_max_probes")]
2002    pub half_open_max_probes: u32,
2003    /// Consecutive *command-class* failures to open the circuit.
2004    ///
2005    /// Command-class evidence is an operation of the same shape as dispatched
2006    /// work — a remote binary that must run to completion (the telemetry poll,
2007    /// or a health probe whose session connected but whose command never
2008    /// finished). It is tracked separately from the plain liveness probe
2009    /// because a host under fork exhaustion or swap thrash keeps answering
2010    /// `echo` over a warm connection while every real command times out;
2011    /// interleaved liveness successes would otherwise keep resetting
2012    /// `failure_threshold` and the breaker would never trip.
2013    #[serde(default = "default_circuit_command_failure_threshold")]
2014    pub command_failure_threshold: u32,
2015    /// Upper bound (seconds) for the open cooldown after repeated reopens.
2016    ///
2017    /// Each Open -> HalfOpen -> Open cycle without an intervening Closed
2018    /// doubles the cooldown (`open_cooldown_secs`, x2, x4, ...) up to this
2019    /// cap, so a flapping worker converges to Open instead of being
2020    /// re-admitted every cooldown. Set equal to `open_cooldown_secs` to
2021    /// disable the backoff.
2022    #[serde(default = "default_circuit_open_cooldown_max_secs")]
2023    pub open_cooldown_max_secs: u64,
2024}
2025
2026impl Default for CircuitBreakerConfig {
2027    fn default() -> Self {
2028        Self {
2029            failure_threshold: default_circuit_failure_threshold(),
2030            success_threshold: default_circuit_success_threshold(),
2031            error_rate_threshold: default_circuit_error_rate_threshold(),
2032            window_secs: default_circuit_window_secs(),
2033            open_cooldown_secs: default_circuit_open_cooldown_secs(),
2034            half_open_max_probes: default_circuit_half_open_max_probes(),
2035            command_failure_threshold: default_circuit_command_failure_threshold(),
2036            open_cooldown_max_secs: default_circuit_open_cooldown_max_secs(),
2037        }
2038    }
2039}
2040
2041impl CircuitBreakerConfig {
2042    /// Effective cooldown (ms) for the given reopen streak, with exponential
2043    /// backoff capped at `open_cooldown_max_secs`. `reopen_streak` counts
2044    /// Open transitions since the circuit last closed (1 = first open).
2045    pub fn cooldown_ms_for_streak(&self, reopen_streak: u32) -> u64 {
2046        let base_ms = self.open_cooldown_secs.saturating_mul(1000);
2047        let cap_ms = self
2048            .open_cooldown_max_secs
2049            .saturating_mul(1000)
2050            .max(base_ms);
2051        // Exponent bounded so the shift cannot overflow; the cap clamps anyway.
2052        let exponent = reopen_streak.saturating_sub(1).min(16);
2053        base_ms.saturating_mul(1u64 << exponent).min(cap_ms)
2054    }
2055}
2056
2057/// Circuit breaker statistics for tracking state transitions.
2058///
2059/// Maintains a rolling window of successes and failures to determine
2060/// when the circuit should open, close, or enter half-open state.
2061#[derive(Debug, Clone, Default)]
2062pub struct CircuitStats {
2063    /// Current circuit state.
2064    state: CircuitState,
2065    /// Consecutive failure count (reset on success).
2066    consecutive_failures: u32,
2067    /// Consecutive success count in half-open state (reset on failure).
2068    consecutive_successes: u32,
2069    /// Total successes in the current rolling window.
2070    window_successes: u32,
2071    /// Total failures in the current rolling window.
2072    window_failures: u32,
2073    /// Timestamp when circuit entered Open state (epoch millis).
2074    opened_at: Option<u64>,
2075    /// Timestamp of last state change (epoch millis).
2076    last_state_change: u64,
2077    /// Current active probes in half-open state.
2078    active_probes: u32,
2079    /// Consecutive command-class failures (telemetry poll exhaustion, probe
2080    /// sessions that connected but never completed). Reset only by a
2081    /// command-class success or a circuit close — NOT by a liveness success,
2082    /// which is the whole point (see `CircuitBreakerConfig::command_failure_threshold`).
2083    consecutive_command_failures: u32,
2084    /// Number of Open transitions since the circuit last closed. Drives the
2085    /// cooldown backoff; 0 while Closed-since-recovery.
2086    reopen_streak: u32,
2087    /// Recent health check results (true=success, false=failure).
2088    /// Used for history visualization in status display.
2089    recent_results: Vec<bool>,
2090}
2091
2092/// Maximum number of recent results to keep for history visualization.
2093const CIRCUIT_HISTORY_SIZE: usize = 10;
2094
2095impl CircuitStats {
2096    /// Create new circuit stats in the closed state.
2097    pub fn new() -> Self {
2098        Self {
2099            state: CircuitState::Closed,
2100            consecutive_failures: 0,
2101            consecutive_successes: 0,
2102            window_successes: 0,
2103            window_failures: 0,
2104            opened_at: None,
2105            last_state_change: Self::now_millis(),
2106            active_probes: 0,
2107            consecutive_command_failures: 0,
2108            reopen_streak: 0,
2109            recent_results: Vec::with_capacity(CIRCUIT_HISTORY_SIZE),
2110        }
2111    }
2112
2113    /// Consecutive command-class failures observed since the last
2114    /// command-class success (or circuit close).
2115    pub fn consecutive_command_failures(&self) -> u32 {
2116        self.consecutive_command_failures
2117    }
2118
2119    /// Open transitions since the circuit last closed (0 when it has not
2120    /// opened since recovery). Exposed for diagnostics.
2121    pub fn reopen_streak(&self) -> u32 {
2122        self.reopen_streak
2123    }
2124
2125    /// Cooldown currently in force (ms), including reopen backoff.
2126    fn effective_cooldown_ms(&self, config: &CircuitBreakerConfig) -> u64 {
2127        config.cooldown_ms_for_streak(self.reopen_streak.max(1))
2128    }
2129
2130    /// Get the current circuit state.
2131    pub fn state(&self) -> CircuitState {
2132        self.state
2133    }
2134
2135    /// Get the timestamp when the circuit opened (if open).
2136    pub fn opened_at(&self) -> Option<u64> {
2137        self.opened_at
2138    }
2139
2140    /// Get the timestamp of the last state change.
2141    pub fn last_state_change(&self) -> u64 {
2142        self.last_state_change
2143    }
2144
2145    /// Get the consecutive failure count.
2146    pub fn consecutive_failures(&self) -> u32 {
2147        self.consecutive_failures
2148    }
2149
2150    /// Get the error rate in the current window (0.0-1.0).
2151    ///
2152    /// Computed over the bounded recent-results window (the last
2153    /// `CIRCUIT_HISTORY_SIZE` health checks) rather than the unbounded lifetime
2154    /// `window_successes`/`window_failures` counters. A long-lived daemon polling
2155    /// distant workers accumulates occasional transient failures over days; with a
2156    /// lifetime ratio a currently-healthy worker could stay gated (circuit kept
2157    /// open, depressed health score) until the daemon was restarted. A recency
2158    /// window lets the rate self-heal as fresh successes age out old failures.
2159    pub fn error_rate(&self) -> f64 {
2160        if self.recent_results.is_empty() {
2161            return 0.0;
2162        }
2163        let failures = self.recent_results.iter().filter(|&&ok| !ok).count();
2164        failures as f64 / self.recent_results.len() as f64
2165    }
2166
2167    /// Record a successful operation.
2168    ///
2169    /// In closed state: resets consecutive failures, increments window successes.
2170    /// In half-open state: increments consecutive successes.
2171    pub fn record_success(&mut self) {
2172        self.window_successes += 1;
2173        self.consecutive_failures = 0;
2174        self.push_result(true);
2175
2176        if self.state == CircuitState::HalfOpen {
2177            self.consecutive_successes += 1;
2178            if self.active_probes > 0 {
2179                self.active_probes -= 1;
2180            }
2181        }
2182    }
2183
2184    /// Record a failed operation.
2185    ///
2186    /// Increments consecutive failures and window failures.
2187    /// In half-open state, resets consecutive successes.
2188    pub fn record_failure(&mut self) {
2189        self.window_failures += 1;
2190        self.consecutive_failures += 1;
2191        self.push_result(false);
2192
2193        if self.state == CircuitState::HalfOpen {
2194            self.consecutive_successes = 0;
2195            if self.active_probes > 0 {
2196                self.active_probes -= 1;
2197            }
2198        }
2199    }
2200
2201    /// Check if the circuit should open based on config thresholds.
2202    ///
2203    /// Returns true if:
2204    /// - Consecutive failures exceed threshold, OR
2205    /// - Error rate exceeds threshold (with minimum sample size)
2206    pub fn should_open(&self, config: &CircuitBreakerConfig) -> bool {
2207        if self.state != CircuitState::Closed {
2208            return false;
2209        }
2210
2211        // Open if consecutive failures exceed threshold
2212        if self.consecutive_failures >= config.failure_threshold {
2213            return true;
2214        }
2215
2216        // Open if error rate exceeds threshold (with a minimum sample size).
2217        // Counts samples in the bounded recent-results window so the gate
2218        // reflects recent behavior, matching error_rate() above.
2219        if self.recent_results.len() >= 5 && self.error_rate() >= config.error_rate_threshold {
2220            return true;
2221        }
2222
2223        false
2224    }
2225
2226    /// Check if the circuit should transition to half-open.
2227    ///
2228    /// Returns true if the circuit is open and the cooldown period has elapsed.
2229    pub fn should_half_open(&self, config: &CircuitBreakerConfig) -> bool {
2230        if self.state != CircuitState::Open {
2231            return false;
2232        }
2233
2234        let now = Self::now_millis();
2235        let cooldown_ms = self.effective_cooldown_ms(config);
2236
2237        if let Some(opened_at) = self.opened_at {
2238            now.saturating_sub(opened_at) >= cooldown_ms
2239        } else {
2240            false
2241        }
2242    }
2243
2244    /// Check if the circuit should close.
2245    ///
2246    /// Returns true if in half-open state, consecutive successes meet or
2247    /// exceed the success threshold, AND no command-class failure is
2248    /// outstanding. A worker whose last real command timed out is not
2249    /// recovered no matter how many liveness probes it answers; it stays
2250    /// HalfOpen (degraded, probe-limited) until a command-class success
2251    /// clears the evidence.
2252    pub fn should_close(&self, config: &CircuitBreakerConfig) -> bool {
2253        if self.state != CircuitState::HalfOpen {
2254            return false;
2255        }
2256
2257        self.consecutive_successes >= config.success_threshold
2258            && self.consecutive_command_failures == 0
2259    }
2260
2261    /// Check if a probe request can be made in half-open state.
2262    ///
2263    /// Returns true if active probes are below the maximum allowed.
2264    pub fn can_probe(&self, config: &CircuitBreakerConfig) -> bool {
2265        if self.state != CircuitState::HalfOpen {
2266            return false;
2267        }
2268
2269        self.active_probes < config.half_open_max_probes
2270    }
2271
2272    /// Start a probe request in half-open state.
2273    ///
2274    /// Returns true if the probe was started, false if already at max probes.
2275    pub fn start_probe(&mut self, config: &CircuitBreakerConfig) -> bool {
2276        if !self.can_probe(config) {
2277            return false;
2278        }
2279        self.active_probes += 1;
2280        true
2281    }
2282
2283    /// Transition the circuit to open state.
2284    pub fn open(&mut self) {
2285        if self.state != CircuitState::Open {
2286            self.state = CircuitState::Open;
2287            self.opened_at = Some(Self::now_millis());
2288            self.last_state_change = Self::now_millis();
2289            self.consecutive_successes = 0;
2290            self.active_probes = 0;
2291            // Every open without an intervening close lengthens the next
2292            // cooldown (see `CircuitBreakerConfig::cooldown_ms_for_streak`).
2293            self.reopen_streak = self.reopen_streak.saturating_add(1);
2294        }
2295    }
2296
2297    /// Transition the circuit to half-open state.
2298    pub fn half_open(&mut self) {
2299        if self.state != CircuitState::HalfOpen {
2300            self.state = CircuitState::HalfOpen;
2301            self.last_state_change = Self::now_millis();
2302            self.consecutive_successes = 0;
2303            self.active_probes = 0;
2304        }
2305    }
2306
2307    /// Transition the circuit to closed state.
2308    pub fn close(&mut self) {
2309        if self.state != CircuitState::Closed {
2310            self.state = CircuitState::Closed;
2311            self.last_state_change = Self::now_millis();
2312            self.opened_at = None;
2313            self.consecutive_failures = 0;
2314            self.consecutive_successes = 0;
2315            self.active_probes = 0;
2316            self.consecutive_command_failures = 0;
2317            self.reopen_streak = 0;
2318            // Reset the window on close, INCLUDING the recent-results history
2319            // that error_rate() now reads. Otherwise a freshly-recovered circuit
2320            // would still compute a high error rate from the pre-failure samples
2321            // and immediately re-open (flapping). Matches the original intent of
2322            // resetting the error-rate inputs when the circuit recovers.
2323            self.window_successes = 0;
2324            self.window_failures = 0;
2325            self.recent_results.clear();
2326        }
2327    }
2328
2329    /// Reset the rolling window counters.
2330    ///
2331    /// Called periodically to ensure the window reflects recent activity.
2332    /// Also clears the recent-results history that `error_rate()` is computed
2333    /// from, so a reset truly zeroes the observed error rate.
2334    pub fn reset_window(&mut self) {
2335        self.window_successes = 0;
2336        self.window_failures = 0;
2337        self.recent_results.clear();
2338    }
2339
2340    /// Get recent health check results for history visualization.
2341    ///
2342    /// Returns a slice of recent results (true=success, false=failure),
2343    /// with the most recent result at the end.
2344    pub fn recent_results(&self) -> &[bool] {
2345        &self.recent_results
2346    }
2347
2348    /// Calculate seconds remaining until circuit auto-transitions to half-open.
2349    ///
2350    /// Returns None if circuit is not open or cooldown has already elapsed.
2351    pub fn recovery_remaining_secs(&self, config: &CircuitBreakerConfig) -> Option<u64> {
2352        if self.state != CircuitState::Open {
2353            return None;
2354        }
2355
2356        let now = Self::now_millis();
2357        let cooldown_ms = self.effective_cooldown_ms(config);
2358
2359        if let Some(opened_at) = self.opened_at {
2360            let elapsed_ms = now.saturating_sub(opened_at);
2361            if elapsed_ms < cooldown_ms {
2362                return Some((cooldown_ms - elapsed_ms) / 1000);
2363            }
2364        }
2365        None
2366    }
2367
2368    /// Push a result to the history, maintaining the maximum size.
2369    fn push_result(&mut self, success: bool) {
2370        if self.recent_results.len() >= CIRCUIT_HISTORY_SIZE {
2371            self.recent_results.remove(0);
2372        }
2373        self.recent_results.push(success);
2374    }
2375
2376    fn now_millis() -> u64 {
2377        std::time::SystemTime::now()
2378            .duration_since(std::time::UNIX_EPOCH)
2379            .map(|d| d.as_millis() as u64)
2380            .unwrap_or(0)
2381    }
2382
2383    /// Drive the circuit through a single health-check outcome and return the
2384    /// resulting [`CircuitState`].
2385    ///
2386    /// This is the single, self-contained transition engine shared by every
2387    /// circuit consumer (the daemon health monitor via
2388    /// `WorkerState::record_health_check`, and the diagnostic `WorkerHealth`
2389    /// panel via `WorkerHealth::observe`). Keeping the transition logic in ONE
2390    /// place is what makes `WorkerState.circuit` (which selection reads) and the
2391    /// diagnostic `WorkerHealth.circuit` agree.
2392    ///
2393    /// Behaviour:
2394    /// - On a **healthy** outcome: if the circuit is Open and its cooldown has
2395    ///   elapsed it first transitions Open → HalfOpen *before* crediting the
2396    ///   success, so recovery needs exactly `success_threshold` clean probes
2397    ///   (crediting a success against an Open circuit would be lost). Then the
2398    ///   success is recorded and the circuit closes once the threshold is met.
2399    /// - On an **unhealthy** outcome: the failure is recorded, the circuit opens
2400    ///   if the closed-state open condition is met, and a HalfOpen probe failure
2401    ///   reopens the circuit — UNLESS the failure was `transient` (a retryable
2402    ///   transport blip that survived the in-check retries). A transient
2403    ///   half-open failure neither reopens nor resets the cooldown, so a single
2404    ///   network hiccup during recovery cannot bounce a worker back to Open.
2405    /// - After either branch, an Open circuit past its cooldown is nudged to
2406    ///   HalfOpen so probing resumes on the next poll even for a still-failing
2407    ///   worker.
2408    pub fn apply_health_outcome(
2409        &mut self,
2410        healthy: bool,
2411        transient: bool,
2412        config: &CircuitBreakerConfig,
2413    ) -> CircuitState {
2414        if healthy {
2415            // Transition to half-open BEFORE crediting the success so the probe
2416            // counts toward recovery. record_success() only increments the
2417            // consecutive-success counter while in HalfOpen.
2418            if self.state == CircuitState::Open && self.should_half_open(config) {
2419                self.half_open();
2420            }
2421            self.record_success();
2422            if self.should_close(config) {
2423                self.close();
2424            }
2425        } else {
2426            let was_half_open = self.state == CircuitState::HalfOpen;
2427            self.record_failure();
2428            if self.should_open(config) {
2429                self.open();
2430            } else if was_half_open && !transient {
2431                // A real (non-transient) probe failure in half-open reopens the
2432                // circuit and restarts the cooldown. A transient failure is
2433                // ignored so a network blip can't undo an in-progress recovery.
2434                self.open();
2435            }
2436        }
2437
2438        // Even for a still-failing worker, resume probing once the cooldown
2439        // elapses so a recovered worker is retried promptly.
2440        if self.state == CircuitState::Open && self.should_half_open(config) {
2441            self.half_open();
2442        }
2443
2444        self.state
2445    }
2446
2447    /// Drive the circuit through a single *command-class* outcome and return
2448    /// the resulting [`CircuitState`].
2449    ///
2450    /// Command-class evidence comes from an operation of the same shape as
2451    /// dispatched work: a remote binary that must run to completion (the
2452    /// telemetry poll, or a health probe whose SSH session connected but whose
2453    /// command never finished). It is the signal the plain liveness probe
2454    /// cannot provide — a host under fork exhaustion or swap thrash keeps
2455    /// answering `echo` over a warm connection while every real command hangs,
2456    /// and on that evidence alone the breaker would happily re-admit it.
2457    ///
2458    /// Behaviour:
2459    /// - **Success**: clears the outstanding command-failure count and then
2460    ///   counts as an ordinary healthy outcome (may advance Open -> HalfOpen ->
2461    ///   Closed exactly like a liveness success).
2462    /// - **Failure**: increments the command-failure count and records a plain
2463    ///   failure. The circuit opens when either the ordinary open condition or
2464    ///   `command_failure_threshold` is met. In HalfOpen a command failure
2465    ///   ALWAYS reopens — there is no transient exemption, because "connected
2466    ///   but the command did not complete" is precisely the condition the
2467    ///   half-open trial exists to detect. While any command failure is
2468    ///   outstanding, [`Self::should_close`] refuses to close on liveness
2469    ///   successes alone.
2470    pub fn apply_command_outcome(
2471        &mut self,
2472        success: bool,
2473        config: &CircuitBreakerConfig,
2474    ) -> CircuitState {
2475        if success {
2476            self.consecutive_command_failures = 0;
2477            return self.apply_health_outcome(true, false, config);
2478        }
2479
2480        self.consecutive_command_failures = self.consecutive_command_failures.saturating_add(1);
2481        let was_half_open = self.state == CircuitState::HalfOpen;
2482        self.record_failure();
2483        let threshold = config.command_failure_threshold.max(1);
2484        if was_half_open
2485            || self.should_open(config)
2486            || (self.state == CircuitState::Closed
2487                && self.consecutive_command_failures >= threshold)
2488        {
2489            self.open();
2490        }
2491
2492        if self.state == CircuitState::Open && self.should_half_open(config) {
2493            self.half_open();
2494        }
2495
2496        self.state
2497    }
2498}
2499
2500#[derive(Debug, Clone, Serialize, Deserialize)]
2501pub struct CompilationConfig {
2502    /// Minimum confidence score to intercept (0.0-1.0).
2503    #[serde(default = "default_confidence")]
2504    pub confidence_threshold: f64,
2505    /// Skip interception if estimated local time < this (ms).
2506    #[serde(default = "default_min_local_time")]
2507    pub min_local_time_ms: u64,
2508    /// Minimum expected speedup ratio (local_time / remote_time) to offload.
2509    /// If predicted speedup < threshold, run locally. Default: 1.2 (20% faster).
2510    /// Set to 1.0 to always offload when other criteria are met.
2511    #[serde(default = "default_speedup_threshold")]
2512    pub remote_speedup_threshold: f64,
2513    /// Default slot estimate for build commands (cargo build, gcc, etc.).
2514    #[serde(default = "default_build_slots")]
2515    pub build_slots: u32,
2516    /// Default slot estimate for test commands (cargo test, nextest).
2517    /// Tests typically use more parallelism than builds.
2518    #[serde(default = "default_test_slots")]
2519    pub test_slots: u32,
2520    /// Default slot estimate for check/lint commands (cargo check, clippy).
2521    /// These are typically faster and use fewer resources.
2522    #[serde(default = "default_check_slots")]
2523    pub check_slots: u32,
2524    /// Timeout in seconds for build commands.
2525    /// Build commands (cargo build, gcc, etc.) typically complete faster than tests.
2526    #[serde(default = "default_build_timeout")]
2527    pub build_timeout_sec: u64,
2528    /// Timeout in seconds for test commands.
2529    /// Test commands often need longer timeouts due to test suite execution time.
2530    #[serde(default = "default_test_timeout")]
2531    pub test_timeout_sec: u64,
2532    /// Timeout in seconds for bun commands (bun test, bun typecheck).
2533    /// Bun has known issues where its internal --timeout flag doesn't work for
2534    /// CPU-bound hangs, so we use a shorter external timeout by default.
2535    #[serde(default = "default_bun_timeout")]
2536    pub bun_timeout_sec: u64,
2537    /// Whether to wrap remote commands with external timeout protection.
2538    /// This prevents runaway/stuck processes from consuming worker slots indefinitely.
2539    /// Default: true. Set to false to disable timeout wrapping entirely.
2540    #[serde(default = "default_external_timeout_enabled")]
2541    pub external_timeout_enabled: bool,
2542    /// Whether a remote build FAILURE may fall back to LOCAL execution on the
2543    /// orchestrator once every eligible remote worker has been exhausted.
2544    ///
2545    /// On a remote failure the hook first retries the build on a *different,
2546    /// higher-capacity* worker (see `run_exec`'s worker-fallback loop). Local
2547    /// execution runs the heavy compile on the orchestrator that coordinates the
2548    /// whole fleet, so it is a genuine last resort and can flood that box. This
2549    /// flag gates it. Default `true` preserves the historical fail-open behavior
2550    /// (local only when no worker can serve); set to `false` on an orchestrator
2551    /// to refuse local fallback and fail the build closed instead. `force_remote`
2552    /// / proof mode / `RCH_REQUIRE_REMOTE` still override this by refusing local
2553    /// unconditionally.
2554    #[serde(default = "default_allow_local_fallback")]
2555    pub allow_local_fallback: bool,
2556    /// Per-job rustc parallelism cap applied on the WORKER (issue #49).
2557    ///
2558    /// `total_slots` bounds concurrent *jobs* per worker, but cargo defaults
2559    /// `-j` to the worker's `nproc`, so one offloaded `cargo test --workspace`
2560    /// forks `nproc` rustc regardless of slots and can swap-thrash a
2561    /// RAM-light box. This policy injects `CARGO_BUILD_JOBS` into the remote
2562    /// build environment; see [`RemoteBuildJobs`] for the modes. It never
2563    /// overrides a `CARGO_BUILD_JOBS` the worker session already carries, an
2564    /// inline `CARGO_BUILD_JOBS=N` or `-j N` in the command, or a project
2565    /// `.cargo/config.toml` `[build] jobs` — explicit intent always wins.
2566    #[serde(default)]
2567    pub remote_build_jobs: RemoteBuildJobs,
2568}
2569
2570/// How rch derives the `CARGO_BUILD_JOBS` it injects into a remote build.
2571///
2572/// - `auto` (default): computed on the worker from its live `nproc` and
2573///   total RAM as `clamp(ram_gib / 8, 2, min(nproc, 8))` — budgeting roughly
2574///   8 GiB of RAM per concurrent rustc leaves headroom for two to three
2575///   concurrent jobs at a realistic per-rustc peak of ~3 GiB.
2576/// - `off`: inject nothing (historical behavior; cargo uses `nproc`).
2577/// - `<n>`: inject a fixed job count fleet-wide.
2578///
2579/// In every mode an explicitly set `CARGO_BUILD_JOBS` on the worker (for
2580/// example from `/etc/environment`) or in the command is left untouched.
2581#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2582pub enum RemoteBuildJobs {
2583    /// Derive from the worker's cores and RAM at execution time.
2584    #[default]
2585    Auto,
2586    /// Do not inject `CARGO_BUILD_JOBS`.
2587    Off,
2588    /// Inject a fixed job count (must be >= 1).
2589    Fixed(u32),
2590}
2591
2592impl RemoteBuildJobs {
2593    /// Parse the config/CLI spelling: `auto`, `off`, or a positive integer.
2594    pub fn parse(value: &str) -> Result<Self, String> {
2595        let trimmed = value.trim();
2596        match trimmed.to_ascii_lowercase().as_str() {
2597            "auto" => Ok(Self::Auto),
2598            "off" | "none" | "disabled" => Ok(Self::Off),
2599            _ => match trimmed.parse::<u32>() {
2600                Ok(0) => Err(
2601                    "remote_build_jobs must be >= 1 (cargo rejects `-j 0`); use `off` to disable"
2602                        .to_string(),
2603                ),
2604                Ok(n) => Ok(Self::Fixed(n)),
2605                Err(_) => Err(format!(
2606                    "'{trimmed}' is not a valid remote_build_jobs value (expected `auto`, `off`, or a positive integer)"
2607                )),
2608            },
2609        }
2610    }
2611}
2612
2613impl std::fmt::Display for RemoteBuildJobs {
2614    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2615        match self {
2616            Self::Auto => f.write_str("auto"),
2617            Self::Off => f.write_str("off"),
2618            Self::Fixed(n) => write!(f, "{n}"),
2619        }
2620    }
2621}
2622
2623impl std::str::FromStr for RemoteBuildJobs {
2624    type Err = String;
2625
2626    fn from_str(value: &str) -> Result<Self, Self::Err> {
2627        Self::parse(value)
2628    }
2629}
2630
2631impl Serialize for RemoteBuildJobs {
2632    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2633        match self {
2634            Self::Auto => serializer.serialize_str("auto"),
2635            Self::Off => serializer.serialize_str("off"),
2636            Self::Fixed(n) => serializer.serialize_u32(*n),
2637        }
2638    }
2639}
2640
2641impl<'de> Deserialize<'de> for RemoteBuildJobs {
2642    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2643        #[derive(Deserialize)]
2644        #[serde(untagged)]
2645        enum Raw {
2646            Count(u64),
2647            Spelling(String),
2648        }
2649        match Raw::deserialize(deserializer)? {
2650            Raw::Count(n) => {
2651                let n = u32::try_from(n).map_err(|_| {
2652                    serde::de::Error::custom(format!("remote_build_jobs {n} is out of range"))
2653                })?;
2654                Self::parse(&n.to_string()).map_err(serde::de::Error::custom)
2655            }
2656            Raw::Spelling(s) => Self::parse(&s).map_err(serde::de::Error::custom),
2657        }
2658    }
2659}
2660
2661impl Default for CompilationConfig {
2662    fn default() -> Self {
2663        Self {
2664            confidence_threshold: 0.85,
2665            min_local_time_ms: 2000,
2666            remote_speedup_threshold: default_speedup_threshold(),
2667            build_slots: default_build_slots(),
2668            test_slots: default_test_slots(),
2669            check_slots: default_check_slots(),
2670            build_timeout_sec: default_build_timeout(),
2671            test_timeout_sec: default_test_timeout(),
2672            bun_timeout_sec: default_bun_timeout(),
2673            external_timeout_enabled: default_external_timeout_enabled(),
2674            allow_local_fallback: default_allow_local_fallback(),
2675            remote_build_jobs: RemoteBuildJobs::default(),
2676        }
2677    }
2678}
2679
2680fn default_build_slots() -> u32 {
2681    4
2682}
2683
2684fn default_test_slots() -> u32 {
2685    8
2686}
2687
2688fn default_check_slots() -> u32 {
2689    2
2690}
2691
2692/// Default build timeout: 5 minutes (300 seconds).
2693/// Most builds complete well within this time.
2694fn default_build_timeout() -> u64 {
2695    300
2696}
2697
2698/// Default test timeout: 30 minutes (1800 seconds).
2699/// Test suites often take significantly longer than builds.
2700fn default_test_timeout() -> u64 {
2701    1800
2702}
2703
2704/// Default bun timeout: 10 minutes (600 seconds).
2705/// Bun's internal --timeout flag doesn't work for CPU-bound hangs,
2706/// so we use a shorter external timeout to prevent zombies.
2707fn default_bun_timeout() -> u64 {
2708    600
2709}
2710
2711/// Default: external timeout protection is enabled.
2712fn default_external_timeout_enabled() -> bool {
2713    true
2714}
2715
2716/// Default: permit last-resort local fallback after remote workers are
2717/// exhausted. Set `compilation.allow_local_fallback = false` to fail closed and
2718/// keep heavy compiles off the orchestrator entirely.
2719fn default_allow_local_fallback() -> bool {
2720    true
2721}
2722
2723impl CompilationConfig {
2724    /// Returns the appropriate external timeout for the given compilation kind.
2725    ///
2726    /// - Bun commands get the shorter bun_timeout (to protect against known hang issues)
2727    /// - Test commands get the longer test_timeout
2728    /// - All other commands (builds, checks, clippy) get the build_timeout
2729    pub fn timeout_for_kind(&self, kind: Option<CompilationKind>) -> std::time::Duration {
2730        let secs = match kind {
2731            // Bun has known issues where internal timeout doesn't work for CPU hangs
2732            Some(CompilationKind::BunTest) | Some(CompilationKind::BunTypecheck) => {
2733                self.bun_timeout_sec
2734            }
2735            // Other test commands use the standard test timeout
2736            Some(CompilationKind::CargoTest) | Some(CompilationKind::CargoNextest) => {
2737                self.test_timeout_sec
2738            }
2739            // Nix derivation builds can take a long time (fetch + compile large
2740            // closures); give them the generous test-timeout bucket rather than
2741            // the short build timeout so legitimate builds are not killed early.
2742            Some(CompilationKind::NixBuild) => self.test_timeout_sec,
2743            // Go test suites are long-running like cargo test; give them the
2744            // test bucket rather than the short build timeout.
2745            Some(CompilationKind::GoTest) => self.test_timeout_sec,
2746            // Builds, checks, clippy, and unknown use build timeout
2747            _ => self.build_timeout_sec,
2748        };
2749        std::time::Duration::from_secs(secs)
2750    }
2751
2752    /// Returns whether external timeout wrapping is enabled.
2753    pub fn external_timeout_enabled(&self) -> bool {
2754        self.external_timeout_enabled
2755    }
2756}
2757
2758/// Version of the Layer 0 configuration pack schema (bd-bqu38 / M-1 §149).
2759///
2760/// Bump when a knob is added, removed, or its meaning changes; consumers
2761/// record the version alongside emitted baselines so regressions are
2762/// attributable to a specific pack revision.
2763pub const LAYER0_PACK_VERSION: &str = "1";
2764
2765/// Layer 0 configuration pack knobs (bd-bqu38).
2766///
2767/// Every knob defaults to OFF and toggles independently — the kill/rollback
2768/// contract from the master plan (§149: removed from defaults if it regresses
2769/// representative p95, output equivalence, debugger behavior, or
2770/// compatibility). Knobs are injected into remote cargo builds as
2771/// `CARGO_PROFILE_*` environment variables, so enabling one never mutates any
2772/// user-owned `.cargo/config.toml`.
2773#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2774#[serde(default)]
2775pub struct Layer0Config {
2776    /// Master switch: nothing is injected unless this is on.
2777    pub enabled: bool,
2778    /// `profile.release.lto = "thin"` via `CARGO_PROFILE_RELEASE_LTO=thin`.
2779    pub release_lto_thin: bool,
2780    /// `profile.release.codegen-units = 1` via
2781    /// `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1`.
2782    pub release_codegen_units_1: bool,
2783}
2784
2785impl Layer0Config {
2786    /// Environment pairs to inject into a remote cargo build.
2787    ///
2788    /// Empty unless [`Self::enabled`] — the master switch gates every knob so
2789    /// an operator can disable the whole pack with one key. Order is stable
2790    /// (declaration order) for deterministic emission and test assertions.
2791    #[must_use]
2792    pub fn active_env(&self) -> Vec<(&'static str, &'static str)> {
2793        if !self.enabled {
2794            return Vec::new();
2795        }
2796        let mut env = Vec::new();
2797        if self.release_lto_thin {
2798            env.push(("CARGO_PROFILE_RELEASE_LTO", "thin"));
2799        }
2800        if self.release_codegen_units_1 {
2801            env.push(("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"));
2802        }
2803        env
2804    }
2805}
2806
2807#[derive(Debug, Clone, Serialize, Deserialize)]
2808pub struct TransferConfig {
2809    /// zstd compression level (1-19).
2810    #[serde(default = "default_compression")]
2811    pub compression_level: u32,
2812    /// Patterns to exclude from transfer.
2813    #[serde(default = "default_excludes")]
2814    pub exclude_patterns: Vec<String>,
2815    /// SSH keepalive interval in seconds (`ssh -o ServerAliveInterval=<N>`).
2816    ///
2817    /// When unset, build transfers use 15 seconds. Zero defers to OpenSSH configuration.
2818    #[serde(default, skip_serializing_if = "Option::is_none")]
2819    pub ssh_server_alive_interval_secs: Option<u64>,
2820    /// SSH ControlPersist idle timeout in seconds (`ssh -o ControlPersist=<N>s`).
2821    ///
2822    /// - Unset preserves OpenSSH defaults (ControlPersist enabled by default via mux).
2823    /// - `0` disables persistence (`ControlPersist=no`).
2824    #[serde(default, skip_serializing_if = "Option::is_none")]
2825    pub ssh_control_persist_secs: Option<u64>,
2826    /// Staging base on the worker for the transfer paths that do NOT mirror
2827    /// the client's project layout: `--clean-overlay` roots, Windows workers,
2828    /// and `rch cache warm`. Must be an absolute path; defaults to
2829    /// [`default_remote_base`] (`/data/tmp/rch`).
2830    ///
2831    /// An ordinary `rch exec` does not use this: it mirrors the project under
2832    /// the worker's canonical root (`[path_topology] canonical_root`), and the
2833    /// pooled Cargo target store rides along inside that mirror. To move
2834    /// pooled stores onto another filesystem, set
2835    /// `[remediation.pooled_target] store_base` (issue #64).
2836    #[serde(default = "default_remote_base")]
2837    pub remote_base: String,
2838    /// Per-attempt timeout for source synchronization, in milliseconds.
2839    ///
2840    /// This is deliberately separate from remote compilation and artifact-return
2841    /// timeouts. When unset, RCH derives a conservative timeout from the source
2842    /// payload size (30 seconds plus one second per MiB, capped at one hour).
2843    #[serde(default, skip_serializing_if = "Option::is_none")]
2844    pub sync_timeout_ms: Option<u64>,
2845    /// Silence-based stall timeout for source synchronization, in seconds
2846    /// (issue #59).
2847    ///
2848    /// Aborts a source sync when rsync emits NO output at all — no progress
2849    /// refresh, no stats, no itemized line — for this long. This is deliberately
2850    /// far tighter than the wall-clock `sync_timeout_ms` cap (which scales with
2851    /// payload size up to an hour): a large first-time sync keeps refreshing its
2852    /// progress line and is never killed, while a dead channel or wedged rsync
2853    /// is detected within this window instead of holding the reservation until
2854    /// the wall-clock cap. On a stall the hook releases the worker and re-enters
2855    /// selection excluding it. `0` disables silence detection.
2856    #[serde(default = "default_source_sync_silence_timeout_secs")]
2857    pub source_sync_silence_timeout_secs: u64,
2858    /// Retry policy for transient network errors during transfer.
2859    #[serde(default)]
2860    pub retry: RetryConfig,
2861    /// Verify artifact integrity using blake3 hashes after transfer (bd-377q).
2862    ///
2863    /// When enabled, computes blake3 hashes of key artifacts (binaries, test outputs)
2864    /// on the worker after build, then verifies them locally after download.
2865    /// Disabled by default to avoid extra overhead.
2866    #[serde(default)]
2867    pub verify_artifacts: bool,
2868    /// Maximum file size (bytes) for artifact verification (bd-377q).
2869    ///
2870    /// Files larger than this limit are skipped during verification to avoid
2871    /// excessive I/O. Defaults to 100MB.
2872    #[serde(default = "default_verify_max_size")]
2873    pub verify_max_size_bytes: u64,
2874
2875    // =========================================================================
2876    // Transfer Optimization (bd-3hho)
2877    // =========================================================================
2878    /// Maximum transfer size in MB before skipping remote execution.
2879    ///
2880    /// When set, runs `rsync --dry-run --stats` to estimate transfer size.
2881    /// If the estimated size exceeds this threshold, remote offload is skipped
2882    /// and the command runs locally. Set to `None` (default) to disable.
2883    #[serde(default, skip_serializing_if = "Option::is_none")]
2884    pub max_transfer_mb: Option<u64>,
2885
2886    /// Maximum estimated transfer time in milliseconds before skipping remote.
2887    ///
2888    /// Uses `estimated_bandwidth_bps` (or measured link speed) to calculate
2889    /// expected transfer time. If it exceeds this threshold, remote offload
2890    /// is skipped. Set to `None` (default) to disable.
2891    #[serde(default, skip_serializing_if = "Option::is_none")]
2892    pub max_transfer_time_ms: Option<u64>,
2893
2894    /// Bandwidth limit for rsync in KB/s.
2895    ///
2896    /// Passed as `rsync --bwlimit=<N>` to limit transfer speed and prevent
2897    /// network saturation. Set to `None` or `0` (default) for unlimited.
2898    #[serde(default, skip_serializing_if = "Option::is_none")]
2899    pub bwlimit_kbps: Option<u64>,
2900
2901    /// Explicit rsync binary for every local transfer (issue #66).
2902    ///
2903    /// Unset (the default) lets rch pick: the PATH `rsync` when it is a
2904    /// modern (3.1+) rsync, otherwise a modern rsync from the well-known
2905    /// Homebrew/MacPorts locations, otherwise the PATH binary driven with the
2906    /// openrsync/2.6.9-compatible argv. Set this to force one binary
2907    /// (`~` is expanded; a bare name is looked up on PATH). The
2908    /// `RCH_RSYNC_BIN` environment variable overrides this setting.
2909    #[serde(default, skip_serializing_if = "Option::is_none")]
2910    pub rsync_bin: Option<String>,
2911
2912    /// Estimated link bandwidth in bytes per second.
2913    ///
2914    /// Used for transfer time estimation when `max_transfer_time_ms` is set.
2915    /// If not provided, defaults to 10 MB/s (10485760 bytes/sec).
2916    #[serde(default, skip_serializing_if = "Option::is_none")]
2917    pub estimated_bandwidth_bps: Option<u64>,
2918
2919    // =========================================================================
2920    // Adaptive Compression (bd-243w)
2921    // =========================================================================
2922    /// Enable adaptive zstd compression based on transfer size (bd-243w).
2923    ///
2924    /// When enabled, automatically selects compression level based on estimated
2925    /// payload size:
2926    /// - < 10MB: level 1 (fast, minimal compression)
2927    /// - 10-200MB: level 3 (balanced, default)
2928    /// - > 200MB: level 7 (slower, better compression)
2929    ///
2930    /// When disabled (default), uses the fixed `compression_level` setting.
2931    #[serde(default)]
2932    pub adaptive_compression: bool,
2933
2934    /// Minimum compression level for adaptive mode (bd-243w).
2935    ///
2936    /// Even in adaptive mode, compression level won't go below this.
2937    /// Defaults to 1.
2938    #[serde(default = "default_min_compression")]
2939    pub min_compression_level: u32,
2940
2941    /// Maximum compression level for adaptive mode (bd-243w).
2942    ///
2943    /// Even in adaptive mode, compression level won't exceed this.
2944    /// Defaults to 9 (avoids CPU-intensive levels 10+).
2945    #[serde(default = "default_max_compression")]
2946    pub max_compression_level: u32,
2947}
2948
2949impl Default for TransferConfig {
2950    fn default() -> Self {
2951        Self {
2952            compression_level: 3,
2953            exclude_patterns: default_excludes(),
2954            ssh_server_alive_interval_secs: None,
2955            ssh_control_persist_secs: None,
2956            remote_base: default_remote_base(),
2957            sync_timeout_ms: None,
2958            source_sync_silence_timeout_secs: default_source_sync_silence_timeout_secs(),
2959            retry: RetryConfig::default(),
2960            verify_artifacts: false,
2961            verify_max_size_bytes: default_verify_max_size(),
2962            // Transfer optimization (bd-3hho)
2963            max_transfer_mb: None,
2964            max_transfer_time_ms: None,
2965            bwlimit_kbps: None,
2966            rsync_bin: None,
2967            estimated_bandwidth_bps: None,
2968            // Adaptive compression (bd-243w)
2969            adaptive_compression: false,
2970            min_compression_level: default_min_compression(),
2971            max_compression_level: default_max_compression(),
2972        }
2973    }
2974}
2975
2976impl TransferConfig {
2977    /// Smallest accepted explicit source-sync timeout (one second).
2978    pub const MIN_SYNC_TIMEOUT_MS: u64 = 1_000;
2979    /// Largest accepted explicit or derived source-sync timeout (one hour).
2980    pub const MAX_SYNC_TIMEOUT_MS: u64 = 3_600_000;
2981    /// Fixed floor used by the payload-aware default policy.
2982    pub const DEFAULT_SYNC_TIMEOUT_FLOOR_MS: u64 = 30_000;
2983    const DEFAULT_SYNC_BYTES_PER_SECOND: u64 = 1024 * 1024;
2984
2985    /// Whether an explicit source-sync timeout is within the supported range.
2986    #[must_use]
2987    pub fn valid_sync_timeout_ms(value: u64) -> bool {
2988        (Self::MIN_SYNC_TIMEOUT_MS..=Self::MAX_SYNC_TIMEOUT_MS).contains(&value)
2989    }
2990
2991    /// Resolve the per-attempt source-sync timeout for an exact payload size.
2992    ///
2993    /// Explicit configuration wins. Otherwise the default grants 30 seconds of
2994    /// setup time plus one second per MiB at a conservative 1 MiB/s floor. This
2995    /// prevents a cold, large pinned tree from inheriting the old fixed 30-second
2996    /// ceiling while still bounding every individual attempt.
2997    #[must_use]
2998    pub fn sync_timeout_for_payload(&self, payload_bytes: u64) -> std::time::Duration {
2999        let timeout_ms = self
3000            .sync_timeout_ms
3001            .filter(|value| Self::valid_sync_timeout_ms(*value))
3002            .unwrap_or_else(|| {
3003                let transfer_seconds = payload_bytes
3004                    .saturating_add(Self::DEFAULT_SYNC_BYTES_PER_SECOND - 1)
3005                    / Self::DEFAULT_SYNC_BYTES_PER_SECOND;
3006                Self::DEFAULT_SYNC_TIMEOUT_FLOOR_MS
3007                    .saturating_add(transfer_seconds.saturating_mul(1_000))
3008                    .min(Self::MAX_SYNC_TIMEOUT_MS)
3009            });
3010        std::time::Duration::from_millis(timeout_ms)
3011    }
3012
3013    /// Select compression level based on estimated transfer size (bd-243w).
3014    ///
3015    /// When adaptive compression is enabled, selects an appropriate level
3016    /// based on payload size:
3017    /// - < 10MB: level 1 (fast, minimal compression)
3018    /// - 10-200MB: level 3 (balanced)
3019    /// - > 200MB: level 7 (better compression)
3020    ///
3021    /// The result is clamped between `min_compression_level` and `max_compression_level`.
3022    ///
3023    /// When adaptive compression is disabled, returns the fixed `compression_level`.
3024    pub fn select_compression_level(&self, estimated_bytes: Option<u64>) -> u32 {
3025        if !self.adaptive_compression {
3026            return self.compression_level;
3027        }
3028
3029        let Some(bytes) = estimated_bytes else {
3030            // No estimate available, use default
3031            return self.compression_level;
3032        };
3033
3034        // Size thresholds
3035        const SMALL_THRESHOLD: u64 = 10_000_000; // 10 MB (decimal)
3036        const LARGE_THRESHOLD: u64 = 200_000_000; // 200 MB (decimal)
3037
3038        let level = if bytes < SMALL_THRESHOLD {
3039            1 // Fast compression for small payloads
3040        } else if bytes < LARGE_THRESHOLD {
3041            3 // Balanced for medium payloads
3042        } else {
3043            7 // Better compression for large payloads
3044        };
3045
3046        // Clamp to configured bounds
3047        level.clamp(self.min_compression_level, self.max_compression_level)
3048    }
3049}
3050
3051// =============================================================================
3052// Execution Configuration (bd-785w)
3053// =============================================================================
3054
3055/// Default allowlist of commands that can be executed remotely.
3056/// Aligned with the classifier's supported CompilationKind values.
3057/// Command bases of every compilation kind rch knows how to offload.
3058///
3059/// This is the built-in `execution.allowlist`. It is also what a persisted,
3060/// explicit allowlist is compared against: a config file written before a tool
3061/// family was added (Go, Nix, TypeScript, ...) silently keeps that family local,
3062/// and the only symptom is a `[RCH] local (... not in execution.allowlist)` line
3063/// (issue #51). `ExecutionConfig::missing_builtin_bases` surfaces the gap.
3064#[rustfmt::skip]
3065pub const BUILTIN_EXECUTION_BASES: &[&str] = &[
3066    // Rust
3067    "cargo",
3068    "rustc",
3069    // cargo-nextest
3070    "nextest",
3071    // C/C++
3072    "gcc",
3073    "g++",
3074    "clang",
3075    "clang++",
3076    "cc",
3077    "c++",
3078    // Build systems
3079    "make",
3080    "cmake",
3081    "ninja",
3082    "meson",
3083    // Bun
3084    "bun",
3085    // Nix (covers both `nix build` and the legacy `nix-build`)
3086    "nix",
3087    // Go
3088    "go",
3089    // TypeScript (covers both `tsc` and `npx tsc`; command_base is "tsc")
3090    "tsc",
3091];
3092
3093fn default_execution_allowlist() -> Vec<String> {
3094    BUILTIN_EXECUTION_BASES
3095        .iter()
3096        .map(|base| (*base).to_string())
3097        .collect()
3098}
3099
3100/// Execution allowlist configuration for remote builds (bd-785w).
3101///
3102/// Controls which command base names are permitted for remote execution.
3103/// Commands not in the allowlist will fail-open to local execution.
3104#[derive(Debug, Clone, Serialize, Deserialize)]
3105pub struct ExecutionConfig {
3106    /// Allowlist of command base names permitted for remote execution.
3107    ///
3108    /// Examples: "cargo", "rustc", "gcc", "bun"
3109    ///
3110    /// If a classified compilation command's base name is not in this list,
3111    /// RCH will fail-open to local execution with a clear reason.
3112    ///
3113    /// An empty allowlist disables all remote execution (local only).
3114    #[serde(default = "default_execution_allowlist")]
3115    pub allowlist: Vec<String>,
3116}
3117
3118impl Default for ExecutionConfig {
3119    fn default() -> Self {
3120        Self {
3121            allowlist: default_execution_allowlist(),
3122        }
3123    }
3124}
3125
3126impl ExecutionConfig {
3127    /// Check if a command base name is allowed for remote execution.
3128    pub fn is_allowed(&self, command_base: &str) -> bool {
3129        self.allowlist
3130            .iter()
3131            .any(|allowed| allowed.eq_ignore_ascii_case(command_base))
3132    }
3133
3134    /// Built-in compilation tool bases that this allowlist does NOT admit.
3135    ///
3136    /// Empty for the default allowlist. Non-empty means some command family rch
3137    /// classifies as compilation will always fall open to local execution on
3138    /// this box — usually because the config file predates that family.
3139    pub fn missing_builtin_bases(&self) -> Vec<&'static str> {
3140        BUILTIN_EXECUTION_BASES
3141            .iter()
3142            .copied()
3143            .filter(|base| !self.is_allowed(base))
3144            .collect()
3145    }
3146}
3147
3148// =============================================================================
3149// Retry Configuration (bd-x1ek)
3150// =============================================================================
3151
3152fn default_retry_max_attempts() -> u32 {
3153    3
3154}
3155
3156fn default_retry_base_delay_ms() -> u64 {
3157    100
3158}
3159
3160fn default_retry_max_delay_ms() -> u64 {
3161    5000
3162}
3163
3164fn default_retry_jitter_factor() -> f64 {
3165    0.1
3166}
3167
3168fn default_retry_total_timeout_ms() -> u64 {
3169    30000
3170}
3171
3172/// Configuration for retry behavior on transient errors.
3173///
3174/// Used by rsync and SSH operations to recover from transient network failures
3175/// without blocking too long or retrying non-recoverable errors.
3176#[derive(Debug, Clone, Serialize, Deserialize)]
3177pub struct RetryConfig {
3178    /// Maximum number of retry attempts (1 = no retries, just initial attempt).
3179    #[serde(default = "default_retry_max_attempts")]
3180    pub max_attempts: u32,
3181
3182    /// Initial delay between retries in milliseconds.
3183    #[serde(default = "default_retry_base_delay_ms")]
3184    pub base_delay_ms: u64,
3185
3186    /// Maximum delay between retries in milliseconds (caps exponential backoff).
3187    #[serde(default = "default_retry_max_delay_ms")]
3188    pub max_delay_ms: u64,
3189
3190    /// Jitter factor (0.0 - 1.0) to randomize delay.
3191    /// A value of 0.1 means ±10% randomization.
3192    #[serde(default = "default_retry_jitter_factor")]
3193    pub jitter_factor: f64,
3194
3195    /// Total timeout for all retry attempts in milliseconds.
3196    /// After this, retries stop even if max_attempts not reached.
3197    #[serde(default = "default_retry_total_timeout_ms")]
3198    pub total_timeout_ms: u64,
3199}
3200
3201impl Default for RetryConfig {
3202    fn default() -> Self {
3203        Self {
3204            max_attempts: default_retry_max_attempts(),
3205            base_delay_ms: default_retry_base_delay_ms(),
3206            max_delay_ms: default_retry_max_delay_ms(),
3207            jitter_factor: default_retry_jitter_factor(),
3208            total_timeout_ms: default_retry_total_timeout_ms(),
3209        }
3210    }
3211}
3212
3213impl RetryConfig {
3214    /// Create a config that disables retries (single attempt only).
3215    pub fn no_retry() -> Self {
3216        Self {
3217            max_attempts: 1,
3218            ..Default::default()
3219        }
3220    }
3221
3222    /// Calculate delay for a given attempt number (0-indexed).
3223    ///
3224    /// Uses exponential backoff with optional jitter.
3225    pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
3226        if attempt == 0 {
3227            return std::time::Duration::ZERO;
3228        }
3229
3230        // Exponential backoff: base * 2^attempt
3231        let base = self.base_delay_ms as f64;
3232        let delay = base * (2.0_f64.powi(attempt as i32 - 1));
3233        let capped = delay.min(self.max_delay_ms as f64);
3234
3235        // Apply jitter: delay ± (delay * jitter_factor)
3236        let jittered = if self.jitter_factor > 0.0 {
3237            let jitter_range = capped * self.jitter_factor;
3238            let jitter = (rand::rng().random::<f64>() * 2.0 - 1.0) * jitter_range;
3239            (capped + jitter).max(0.0)
3240        } else {
3241            capped
3242        };
3243
3244        std::time::Duration::from_millis(jittered as u64)
3245    }
3246
3247    /// Check if we should retry given elapsed time since start.
3248    pub fn should_retry(&self, attempt: u32, elapsed: std::time::Duration) -> bool {
3249        if attempt >= self.max_attempts {
3250            return false;
3251        }
3252        elapsed.as_millis() < self.total_timeout_ms as u128
3253    }
3254}
3255
3256/// Default source-sync silence timeout (issue #59): two minutes without a
3257/// single byte of rsync output means a dead channel or wedged rsync, not a
3258/// large transfer (progress2 refreshes its line continuously while bytes
3259/// flow, and each refresh counts as forward progress).
3260fn default_source_sync_silence_timeout_secs() -> u64 {
3261    120
3262}
3263
3264/// Default remote base path for project transfers.
3265///
3266/// Rooted under `/data/tmp` (the fleet-standard managed ephemeral-build zone)
3267/// rather than `/tmp`: on the Linux workers `/tmp` is a RAM-backed tmpfs, so
3268/// dispatched build artifacts placed there consume RAM and evade the disk-GC
3269/// (sbh / datatmp-janitor) that watches `/data/tmp`. Keeping the remote base in
3270/// the managed zone lets those reapers reclaim orphaned build trees. Must agree
3271/// with the rchd cache-cleanup and reclaim `remote_base` defaults.
3272pub fn default_remote_base() -> String {
3273    "/data/tmp/rch".to_string()
3274}
3275
3276/// Validate and normalize a remote base path.
3277/// Returns an error if the path is not absolute or contains traversal sequences.
3278pub fn validate_remote_base(path: &str) -> Result<String, String> {
3279    // Expand tilde for user convenience
3280    let expanded = shellexpand::tilde(path).into_owned();
3281
3282    // Check for absolute path
3283    if !expanded.starts_with('/') {
3284        return Err(format!(
3285            "remote_base must be an absolute path, got: {}",
3286            path
3287        ));
3288    }
3289
3290    // Check for path traversal attempts
3291    if expanded.contains("..") {
3292        return Err(format!(
3293            "remote_base must not contain path traversal (..): {}",
3294            path
3295        ));
3296    }
3297
3298    // Normalize: remove trailing slashes
3299    let normalized = expanded.trim_end_matches('/').to_string();
3300
3301    // Safety checks
3302    if normalized.is_empty() || normalized == "/" {
3303        return Err("remote_base cannot be the root directory (safety restriction)".to_string());
3304    }
3305
3306    // Enforce at least 2 levels deep (e.g. /tmp/rch, /home/user/rch)
3307    // /tmp -> depth 1 (unsafe for recursive delete)
3308    // /tmp/rch -> depth 2 (safe)
3309    let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
3310    if components.len() < 2 {
3311        return Err(format!(
3312            "remote_base must be at least 2 levels deep (e.g. /tmp/rch), got: {}",
3313            normalized
3314        ));
3315    }
3316
3317    Ok(normalized)
3318}
3319
3320fn default_circuit_failure_threshold() -> u32 {
3321    3
3322}
3323
3324fn default_circuit_success_threshold() -> u32 {
3325    2
3326}
3327
3328fn default_circuit_error_rate_threshold() -> f64 {
3329    0.5
3330}
3331
3332fn default_circuit_window_secs() -> u64 {
3333    60
3334}
3335
3336fn default_circuit_open_cooldown_secs() -> u64 {
3337    30
3338}
3339
3340fn default_circuit_half_open_max_probes() -> u32 {
3341    1
3342}
3343
3344fn default_circuit_command_failure_threshold() -> u32 {
3345    2
3346}
3347
3348fn default_circuit_open_cooldown_max_secs() -> u64 {
3349    // 30s base doubled four times: 30, 60, 120, 240, 480.
3350    480
3351}
3352
3353fn default_true() -> bool {
3354    true
3355}
3356
3357fn default_log_level() -> String {
3358    "info".to_string()
3359}
3360
3361pub fn default_socket_path() -> String {
3362    // Prefer XDG_RUNTIME_DIR if available (per-user runtime directory).
3363    if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR")
3364        && !runtime_dir.trim().is_empty()
3365    {
3366        let path = PathBuf::from(runtime_dir).join("rch.sock");
3367        return path.to_string_lossy().to_string();
3368    }
3369
3370    // Fallback to ~/.cache/rch/rch.sock (persistent across reboots).
3371    if let Some(cache_dir) = dirs::cache_dir() {
3372        let rch_cache = cache_dir.join("rch");
3373        let _ = std::fs::create_dir_all(&rch_cache);
3374        return rch_cache.join("rch.sock").to_string_lossy().to_string();
3375    }
3376
3377    // Last resort: /tmp/rch.sock
3378    "/tmp/rch.sock".to_string()
3379}
3380
3381#[allow(dead_code)]
3382fn default_output_visibility() -> OutputVisibility {
3383    OutputVisibility::None
3384}
3385
3386fn default_confidence() -> f64 {
3387    0.85
3388}
3389
3390fn default_min_local_time() -> u64 {
3391    2000
3392}
3393
3394/// Default speedup threshold: 1.2 (require 20% speedup to offload).
3395/// This is conservative to avoid offloading builds that would be only
3396/// marginally faster remotely when accounting for transfer overhead.
3397fn default_speedup_threshold() -> f64 {
3398    1.2
3399}
3400
3401fn default_compression() -> u32 {
3402    3
3403}
3404
3405/// Default maximum file size for artifact verification (100MB).
3406fn default_verify_max_size() -> u64 {
3407    100 * 1024 * 1024 // 100MB
3408}
3409
3410/// Default minimum compression level for adaptive mode.
3411fn default_min_compression() -> u32 {
3412    1
3413}
3414
3415/// Default maximum compression level for adaptive mode.
3416fn default_max_compression() -> u32 {
3417    9
3418}
3419
3420fn default_excludes() -> Vec<String> {
3421    vec![
3422        // Rust build artifacts
3423        "target/".to_string(),
3424        "*.rlib".to_string(),
3425        "*.rmeta".to_string(),
3426        // Git metadata is large, often permission-sensitive on workers, and
3427        // a partial `.git/` tree is not reliable for build scripts anyway.
3428        ".git/".to_string(),
3429        // Node.js / Bun dependencies (massive, reinstalled on worker)
3430        "node_modules/".to_string(),
3431        // Bun cache and runtime files
3432        ".bun/".to_string(),
3433        // npm / pnpm caches
3434        ".npm/".to_string(),
3435        ".pnpm-store/".to_string(),
3436        // Common build output directories
3437        "dist/".to_string(),
3438        "build/".to_string(),
3439        // Framework-specific caches
3440        ".next/".to_string(),
3441        ".nuxt/".to_string(),
3442        ".turbo/".to_string(),
3443        ".parcel-cache/".to_string(),
3444        // Beads runtime state (local issue DB + lock/temp files); not needed remotely.
3445        ".beads/".to_string(),
3446        // Local diagnostic runs may contain host-specific symlinks and are not build inputs.
3447        ".doctor/".to_string(),
3448        // Coverage reports (generated during tests)
3449        "coverage/".to_string(),
3450        ".nyc_output/".to_string(),
3451        // Credentials and secrets (must never be transferred to workers)
3452        ".cargo/credentials".to_string(),
3453        ".cargo/credentials.toml".to_string(),
3454        ".env".to_string(),
3455        ".env.*".to_string(),
3456        "*.pem".to_string(),
3457        "*.key".to_string(),
3458        "credentials.json".to_string(),
3459        "secrets.json".to_string(),
3460        "secrets.yaml".to_string(),
3461        "secrets.yml".to_string(),
3462        "secrets.toml".to_string(),
3463        "secrets.env".to_string(),
3464        "secrets.txt".to_string(),
3465        "secrets.conf".to_string(),
3466        "secrets.cfg".to_string(),
3467        "secrets.properties".to_string(),
3468        "secrets.xml".to_string(),
3469        // The classes above were already covered. These are the ones
3470        // RABS's E027 source-capture seed set denies
3471        // (rabs-sandbox/src/source_capture.rs) that this list did not,
3472        // so the two halves of the product now agree about what a
3473        // secret is. Two components disagreeing on that is the kind of
3474        // gap nobody notices until one of them is the path that ran.
3475        //
3476        // rsync patterns without a `/` match a basename at any depth,
3477        // so these cover nested copies too — a key checked into a
3478        // fixtures directory is excluded exactly like one at the root.
3479        //
3480        // SSH private keys. The public halves (.pub) are deliberately
3481        // not excluded: they are not secret and some builds read them.
3482        "id_rsa".to_string(),
3483        "id_ed25519".to_string(),
3484        "id_ecdsa".to_string(),
3485        "id_dsa".to_string(),
3486        // Key/certificate container formats (*.pem and *.key are above).
3487        "*.p12".to_string(),
3488        "*.pfx".to_string(),
3489        "*.keystore".to_string(),
3490        "*.jks".to_string(),
3491        // Machine credentials and cloud/container config that carries
3492        // long-lived tokens.
3493        ".netrc".to_string(),
3494        "_netrc".to_string(),
3495        ".aws/credentials".to_string(),
3496        ".docker/config.json".to_string(),
3497        // Package-registry auth tokens. These sit in project roots far
3498        // more often than the key formats above and are the likeliest
3499        // real leak in a normal repo.
3500        ".npmrc".to_string(),
3501        ".pypirc".to_string(),
3502        // GnuPG private keyring.
3503        "secring.*".to_string(),
3504    ]
3505}
3506
3507// ============================================================================
3508// Build History Types
3509// ============================================================================
3510
3511/// Location where a build was executed.
3512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3513#[serde(rename_all = "snake_case")]
3514pub enum BuildLocation {
3515    /// Build executed locally.
3516    #[default]
3517    Local,
3518    /// Build executed on a remote worker.
3519    Remote,
3520}
3521
3522/// Worker health snapshot captured when a cancellation completes.
3523#[derive(Debug, Clone, Serialize, Deserialize)]
3524pub struct BuildCancellationWorkerHealth {
3525    /// Worker status at cancellation completion.
3526    pub status: String,
3527    /// Worker speed score at cancellation completion.
3528    pub speed_score: f64,
3529    /// Slots currently used on the worker.
3530    pub used_slots: u32,
3531    /// Slots currently available on the worker.
3532    pub available_slots: u32,
3533    /// Normalized pressure state.
3534    pub pressure_state: String,
3535    /// Stable pressure reason code.
3536    pub pressure_reason_code: String,
3537}
3538
3539/// Structured cancellation metadata attached to cancelled build records.
3540#[derive(Debug, Clone, Serialize, Deserialize)]
3541pub struct BuildCancellationMetadata {
3542    /// Stable cancellation operation identifier.
3543    pub operation_id: String,
3544    /// Cancellation origin (for example `user`, `timeout`).
3545    pub origin: String,
3546    /// Stable reason code for cancellation.
3547    pub reason_code: String,
3548    /// Ordered cancellation state path.
3549    #[serde(default)]
3550    pub decision_path: Vec<String>,
3551    /// Highest escalation stage reached (`term`, `remote_kill`, `sigkill`).
3552    pub escalation_stage: String,
3553    /// Number of escalation transitions taken.
3554    pub escalation_count: u32,
3555    /// Whether remote kill was attempted.
3556    pub remote_kill_attempted: bool,
3557    /// Whether cleanup completed successfully.
3558    pub cleanup_ok: bool,
3559    /// Whether active build history was successfully claimed/cancelled.
3560    pub history_cancelled: bool,
3561    /// Final cancellation state.
3562    pub final_state: String,
3563    /// Worker health snapshot at completion.
3564    #[serde(default, skip_serializing_if = "Option::is_none")]
3565    pub worker_health: Option<BuildCancellationWorkerHealth>,
3566}
3567
3568/// Record of a completed build.
3569#[derive(Debug, Clone, Serialize, Deserialize)]
3570pub struct BuildRecord {
3571    /// Unique build identifier.
3572    pub id: u64,
3573    /// When the build started (ISO 8601 timestamp).
3574    pub started_at: String,
3575    /// When the build completed (ISO 8601 timestamp).
3576    pub completed_at: String,
3577    /// Project identifier (usually directory name or hash).
3578    pub project_id: String,
3579    /// Worker that executed the build (None if local).
3580    pub worker_id: Option<String>,
3581    /// Full command executed.
3582    pub command: String,
3583    /// Exit code (0 = success).
3584    pub exit_code: i32,
3585    /// Duration in milliseconds.
3586    pub duration_ms: u64,
3587    /// Build location (local or remote).
3588    pub location: BuildLocation,
3589    /// Bytes transferred (if remote).
3590    pub bytes_transferred: Option<u64>,
3591    /// Optional timing breakdown for the pipeline.
3592    #[serde(default)]
3593    pub timing: Option<CommandTimingBreakdown>,
3594    /// Structured cancellation metadata for cancelled builds.
3595    #[serde(default, skip_serializing_if = "Option::is_none")]
3596    pub cancellation: Option<BuildCancellationMetadata>,
3597}
3598
3599/// Input payload for recording a completed build.
3600#[derive(Debug, Clone, Serialize, Deserialize)]
3601pub struct BuildRecordInput {
3602    /// When the build started (ISO 8601 timestamp).
3603    pub started_at: String,
3604    /// When the build completed (ISO 8601 timestamp).
3605    pub completed_at: String,
3606    /// Project identifier (usually directory name or hash).
3607    pub project_id: String,
3608    /// Worker that executed the build (None if local).
3609    pub worker_id: Option<String>,
3610    /// Full command executed.
3611    pub command: String,
3612    /// Exit code (0 = success).
3613    pub exit_code: i32,
3614    /// Duration in milliseconds.
3615    pub duration_ms: u64,
3616    /// Build location (local or remote).
3617    pub location: BuildLocation,
3618    /// Bytes transferred (if remote).
3619    pub bytes_transferred: Option<u64>,
3620    /// Optional timing breakdown for the pipeline.
3621    #[serde(default)]
3622    pub timing: Option<CommandTimingBreakdown>,
3623    /// Structured cancellation metadata for cancelled builds.
3624    #[serde(default, skip_serializing_if = "Option::is_none")]
3625    pub cancellation: Option<BuildCancellationMetadata>,
3626}
3627
3628impl BuildRecordInput {
3629    /// Convert input payload into a build record with a supplied ID.
3630    pub fn into_record(self, id: u64) -> BuildRecord {
3631        BuildRecord {
3632            id,
3633            started_at: self.started_at,
3634            completed_at: self.completed_at,
3635            project_id: self.project_id,
3636            worker_id: self.worker_id,
3637            command: self.command,
3638            exit_code: self.exit_code,
3639            duration_ms: self.duration_ms,
3640            location: self.location,
3641            bytes_transferred: self.bytes_transferred,
3642            timing: self.timing,
3643            cancellation: self.cancellation,
3644        }
3645    }
3646}
3647
3648/// Aggregate terminal-command statistics for build history.
3649///
3650/// These counts do not infer whether compilation completed before a failing
3651/// test or interrupted command. The denominator includes every stored record.
3652#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3653pub struct BuildStats {
3654    /// Total number of builds in history.
3655    pub total_builds: usize,
3656    /// Number of successfully completed commands (exit_code == 0).
3657    pub success_count: usize,
3658    /// Number of unsuccessful commands, including interruption (exit_code != 0).
3659    pub failure_count: usize,
3660    /// Number of remote builds.
3661    pub remote_count: usize,
3662    /// Number of local builds.
3663    pub local_count: usize,
3664    /// Average build duration in milliseconds.
3665    pub avg_duration_ms: u64,
3666}
3667
3668/// Saved time statistics from remote builds.
3669///
3670/// Tracks estimated time savings from offloading builds to remote workers.
3671/// Estimates are only produced from an observed local baseline; without one the
3672/// stats carry measured remote durations with `estimate_basis = "none"` and
3673/// zero estimated savings rather than a fabricated speedup factor.
3674#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3675pub struct SavedTimeStats {
3676    /// Total time spent on remote builds (milliseconds).
3677    pub total_remote_duration_ms: u64,
3678    /// Estimated total time if those builds ran locally (milliseconds).
3679    /// Zero when no local baseline exists.
3680    pub estimated_local_duration_ms: u64,
3681    /// Time saved by offloading (milliseconds).
3682    /// Computed as max(0, estimated_local - remote); zero without a baseline.
3683    pub time_saved_ms: u64,
3684    /// Number of remote builds included in calculation.
3685    pub builds_counted: usize,
3686    /// Average speedup factor (local_estimate / remote_duration).
3687    /// A value of 2.0 means remote builds are ~2x faster than local.
3688    /// Zero when the estimate basis is "none".
3689    pub avg_speedup: f64,
3690    /// Total time saved today (milliseconds).
3691    pub today_saved_ms: u64,
3692    /// Total time saved this week (milliseconds).
3693    pub week_saved_ms: u64,
3694    /// How the local-time estimate was derived: "observed_local_mean" when
3695    /// successful local builds back the baseline, "none" otherwise.
3696    #[serde(default)]
3697    pub estimate_basis: String,
3698    /// Number of successful local builds backing the baseline (0 for "none").
3699    #[serde(default)]
3700    pub local_baseline_builds: usize,
3701}
3702
3703// ============================================================================
3704// Compilation Timing and Metrics
3705// ============================================================================
3706
3707use std::collections::VecDeque;
3708use std::time::{Duration, Instant};
3709
3710/// Breakdown of timing for each compilation phase.
3711#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3712pub struct CompilationTimingBreakdown {
3713    /// Time to sync source files to worker.
3714    #[serde(with = "duration_millis")]
3715    pub rsync_up: Duration,
3716    /// Time for cargo build on worker.
3717    #[serde(with = "duration_millis")]
3718    pub remote_build: Duration,
3719    /// Time to sync artifacts back.
3720    #[serde(with = "duration_millis")]
3721    pub rsync_down: Duration,
3722    /// Total end-to-end latency.
3723    #[serde(with = "duration_millis")]
3724    pub total: Duration,
3725}
3726
3727/// Timing breakdown for a single command pipeline.
3728///
3729/// Uses optional durations so missing phases serialize as `null`.
3730#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3731pub struct CommandTimingBreakdown {
3732    /// Time spent classifying the command.
3733    #[serde(with = "option_duration_millis")]
3734    pub classify: Option<Duration>,
3735    /// Time spent selecting a worker.
3736    #[serde(with = "option_duration_millis")]
3737    pub select: Option<Duration>,
3738    /// Time spent syncing source files to the worker.
3739    #[serde(with = "option_duration_millis")]
3740    pub sync_up: Option<Duration>,
3741    /// Time spent executing the remote command.
3742    #[serde(with = "option_duration_millis")]
3743    pub exec: Option<Duration>,
3744    /// Time spent syncing artifacts back.
3745    #[serde(with = "option_duration_millis")]
3746    pub sync_down: Option<Duration>,
3747    /// Time spent on cleanup (slot release, bookkeeping).
3748    #[serde(with = "option_duration_millis")]
3749    pub cleanup: Option<Duration>,
3750    /// Total end-to-end time for the pipeline.
3751    #[serde(with = "option_duration_millis")]
3752    pub total: Option<Duration>,
3753}
3754
3755mod duration_millis {
3756    use serde::{Deserialize, Deserializer, Serialize, Serializer};
3757    use std::time::Duration;
3758
3759    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
3760    where
3761        S: Serializer,
3762    {
3763        duration.as_millis().serialize(serializer)
3764    }
3765
3766    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
3767    where
3768        D: Deserializer<'de>,
3769    {
3770        let millis = u64::deserialize(deserializer)?;
3771        Ok(Duration::from_millis(millis))
3772    }
3773}
3774
3775/// Comprehensive metrics for a single compilation.
3776#[derive(Debug, Clone, Serialize, Deserialize)]
3777pub struct CompilationMetrics {
3778    /// Project identifier (usually directory name or hash).
3779    pub project_id: String,
3780    /// Worker that performed the compilation.
3781    pub worker_id: String,
3782    /// When the compilation occurred.
3783    pub timestamp: chrono::DateTime<chrono::Utc>,
3784    /// Timing breakdown for each phase.
3785    pub timing: CompilationTimingBreakdown,
3786    /// Local build time for comparison (if available).
3787    #[serde(with = "option_duration_millis")]
3788    pub local_build_time: Option<Duration>,
3789    /// Speedup ratio (local_time / remote_time).
3790    pub speedup: Option<f64>,
3791    /// Number of files synced to worker.
3792    pub files_synced: u64,
3793    /// Total bytes transferred.
3794    pub bytes_transferred: u64,
3795    /// Exit code from compilation.
3796    pub exit_code: i32,
3797    /// Whether compilation succeeded.
3798    pub success: bool,
3799}
3800
3801mod option_duration_millis {
3802    use serde::{Deserialize, Deserializer, Serialize, Serializer};
3803    use std::time::Duration;
3804
3805    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
3806    where
3807        S: Serializer,
3808    {
3809        match duration {
3810            Some(d) => Some(d.as_millis() as u64).serialize(serializer),
3811            None => Option::<u64>::None.serialize(serializer),
3812        }
3813    }
3814
3815    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
3816    where
3817        D: Deserializer<'de>,
3818    {
3819        let opt: Option<u64> = Option::deserialize(deserializer)?;
3820        Ok(opt.map(Duration::from_millis))
3821    }
3822}
3823
3824impl CompilationMetrics {
3825    /// Calculate speedup based on local vs remote build time.
3826    pub fn calculate_speedup(&mut self) {
3827        if let Some(local) = self.local_build_time
3828            && self.timing.total.as_millis() > 0
3829        {
3830            self.speedup = Some(local.as_secs_f64() / self.timing.total.as_secs_f64());
3831        }
3832    }
3833
3834    /// Check if remote compilation was beneficial (faster than local).
3835    pub fn is_beneficial(&self) -> bool {
3836        self.speedup.map(|s| s > 1.0).unwrap_or(false)
3837    }
3838}
3839
3840impl Default for CompilationMetrics {
3841    fn default() -> Self {
3842        Self {
3843            project_id: String::new(),
3844            worker_id: String::new(),
3845            timestamp: chrono::Utc::now(),
3846            timing: CompilationTimingBreakdown::default(),
3847            local_build_time: None,
3848            speedup: None,
3849            files_synced: 0,
3850            bytes_transferred: 0,
3851            exit_code: 0,
3852            success: true,
3853        }
3854    }
3855}
3856
3857/// Timer for tracking compilation phases.
3858///
3859/// Use this to measure the duration of each phase of remote compilation.
3860///
3861/// # Example
3862/// ```ignore
3863/// let mut timer = CompilationTimer::new("my-project", "worker-1");
3864/// // ... rsync source files ...
3865/// timer.end_rsync_up();
3866/// // ... run cargo build ...
3867/// timer.end_remote_build();
3868/// // ... rsync artifacts back ...
3869/// timer.end_rsync_down();
3870/// let metrics = timer.finish(0, 100, 1_000_000);
3871/// ```
3872#[derive(Debug)]
3873pub struct CompilationTimer {
3874    project_id: String,
3875    worker_id: String,
3876    start: Instant,
3877    phase_start: Instant,
3878    rsync_up: Option<Duration>,
3879    remote_build: Option<Duration>,
3880    rsync_down: Option<Duration>,
3881}
3882
3883impl CompilationTimer {
3884    /// Create a new timer for a compilation.
3885    pub fn new(project_id: &str, worker_id: &str) -> Self {
3886        let now = Instant::now();
3887        Self {
3888            project_id: project_id.to_string(),
3889            worker_id: worker_id.to_string(),
3890            start: now,
3891            phase_start: now,
3892            rsync_up: None,
3893            remote_build: None,
3894            rsync_down: None,
3895        }
3896    }
3897
3898    /// Mark the end of the rsync upload phase.
3899    pub fn end_rsync_up(&mut self) {
3900        self.rsync_up = Some(self.phase_start.elapsed());
3901        self.phase_start = Instant::now();
3902        tracing::info!(
3903            rsync_up_ms = %self.rsync_up.unwrap().as_millis(),
3904            "TIMING: rsync_up completed"
3905        );
3906    }
3907
3908    /// Mark the end of the remote build phase.
3909    pub fn end_remote_build(&mut self) {
3910        self.remote_build = Some(self.phase_start.elapsed());
3911        self.phase_start = Instant::now();
3912        tracing::info!(
3913            remote_build_ms = %self.remote_build.unwrap().as_millis(),
3914            "TIMING: remote_build completed"
3915        );
3916    }
3917
3918    /// Mark the end of the rsync download phase.
3919    pub fn end_rsync_down(&mut self) {
3920        self.rsync_down = Some(self.phase_start.elapsed());
3921        tracing::info!(
3922            rsync_down_ms = %self.rsync_down.unwrap().as_millis(),
3923            "TIMING: rsync_down completed"
3924        );
3925    }
3926
3927    /// Finish timing and produce metrics.
3928    pub fn finish(self, exit_code: i32, files: u64, bytes: u64) -> CompilationMetrics {
3929        let total = self.start.elapsed();
3930        tracing::info!(
3931            total_ms = %total.as_millis(),
3932            exit_code = %exit_code,
3933            "TIMING: compilation completed"
3934        );
3935
3936        CompilationMetrics {
3937            project_id: self.project_id,
3938            worker_id: self.worker_id,
3939            timestamp: chrono::Utc::now(),
3940            timing: CompilationTimingBreakdown {
3941                rsync_up: self.rsync_up.unwrap_or_default(),
3942                remote_build: self.remote_build.unwrap_or_default(),
3943                rsync_down: self.rsync_down.unwrap_or_default(),
3944                total,
3945            },
3946            local_build_time: None,
3947            speedup: None,
3948            files_synced: files,
3949            bytes_transferred: bytes,
3950            exit_code,
3951            success: exit_code == 0,
3952        }
3953    }
3954}
3955
3956/// Aggregator for compilation metrics with statistics.
3957#[derive(Debug)]
3958pub struct MetricsAggregator {
3959    history: VecDeque<CompilationMetrics>,
3960    max_history: usize,
3961}
3962
3963impl MetricsAggregator {
3964    /// Create a new aggregator with a maximum history size.
3965    pub fn new(max_history: usize) -> Self {
3966        Self {
3967            history: VecDeque::with_capacity(max_history),
3968            max_history,
3969        }
3970    }
3971
3972    /// Record a new compilation's metrics.
3973    pub fn record(&mut self, metrics: CompilationMetrics) {
3974        if self.history.len() >= self.max_history {
3975            self.history.pop_front();
3976        }
3977        self.history.push_back(metrics);
3978    }
3979
3980    /// Get the average speedup across all recorded compilations.
3981    pub fn average_speedup(&self) -> Option<f64> {
3982        let speedups: Vec<f64> = self.history.iter().filter_map(|m| m.speedup).collect();
3983
3984        if speedups.is_empty() {
3985            None
3986        } else {
3987            Some(speedups.iter().sum::<f64>() / speedups.len() as f64)
3988        }
3989    }
3990
3991    /// Get the p50 (median) total compilation time.
3992    pub fn p50_total_time(&self) -> Option<Duration> {
3993        self.percentile_total_time(0.50)
3994    }
3995
3996    /// Get the p95 total compilation time.
3997    pub fn p95_total_time(&self) -> Option<Duration> {
3998        self.percentile_total_time(0.95)
3999    }
4000
4001    /// Get the p99 total compilation time.
4002    pub fn p99_total_time(&self) -> Option<Duration> {
4003        self.percentile_total_time(0.99)
4004    }
4005
4006    /// Get a percentile of total compilation times.
4007    ///
4008    /// Uses the "inclusive" percentile method (matching numpy.percentile, Excel PERCENTILE.INC):
4009    /// index = (n - 1) * percentile, where n is the number of samples.
4010    fn percentile_total_time(&self, percentile: f64) -> Option<Duration> {
4011        let mut times: Vec<_> = self.history.iter().map(|m| m.timing.total).collect();
4012
4013        if times.is_empty() {
4014            return None;
4015        }
4016
4017        times.sort();
4018        // Use inclusive percentile: index = (n - 1) * p
4019        // For p50 with 100 items: (99 * 0.50) = 49.5 -> 49 (true median position)
4020        // For p99 with 100 items: (99 * 0.99) = 98.01 -> 98
4021        let idx = (((times.len() - 1) as f64 * percentile).round() as usize).min(times.len() - 1);
4022        Some(times[idx])
4023    }
4024
4025    /// Get the success rate as a percentage.
4026    pub fn success_rate(&self) -> f64 {
4027        if self.history.is_empty() {
4028            return 100.0;
4029        }
4030        let successes = self.history.iter().filter(|m| m.success).count();
4031        (successes as f64 / self.history.len() as f64) * 100.0
4032    }
4033
4034    /// Get the number of recorded compilations.
4035    pub fn count(&self) -> usize {
4036        self.history.len()
4037    }
4038
4039    /// Get all recorded metrics.
4040    pub fn metrics(&self) -> &VecDeque<CompilationMetrics> {
4041        &self.history
4042    }
4043
4044    /// Clear all recorded metrics.
4045    pub fn clear(&mut self) {
4046        self.history.clear();
4047    }
4048}
4049
4050impl Default for MetricsAggregator {
4051    fn default() -> Self {
4052        Self::new(1000)
4053    }
4054}
4055
4056#[cfg(test)]
4057mod tests {
4058    use super::*;
4059    use crate::test_guard;
4060
4061    #[test]
4062    fn test_declared_os_reads_reserved_tag() {
4063        assert_eq!(
4064            declared_os(&["rust".to_string(), "os:windows".to_string()]),
4065            Some("windows".to_string())
4066        );
4067        assert_eq!(
4068            declared_os(&["os:WINDOWS".to_string()]),
4069            Some("windows".to_string())
4070        );
4071    }
4072
4073    #[test]
4074    fn test_declared_os_absent_for_ordinary_tags() {
4075        // Every historical worker: no reserved tag, so no exclusivity and the
4076        // worker stays a candidate for any job.
4077        assert_eq!(declared_os(&[]), None);
4078        assert_eq!(
4079            declared_os(&["rust".to_string(), "bun".to_string(), "go".to_string()]),
4080            None
4081        );
4082        // A malformed declaration must not silently fence the worker off.
4083        assert_eq!(declared_os(&["os:".to_string()]), None);
4084        assert_eq!(declared_os(&["os:  ".to_string()]), None);
4085    }
4086
4087    #[test]
4088    fn test_os_tag_roundtrips_through_declared_os() {
4089        for os in ["windows", "Darwin", " linux "] {
4090            let tag = os_tag(os);
4091            assert_eq!(
4092                declared_os(std::slice::from_ref(&tag)).as_deref(),
4093                Some(os.trim().to_ascii_lowercase().as_str())
4094            );
4095        }
4096    }
4097
4098    /// Verdict-webhook config must NEVER carry an inline secret. Operators
4099    /// reference secrets by env-var name (`*_env`); the resolved value is
4100    /// read at dispatch time and never persisted to the cache or config TOML.
4101    /// This guards against a future careless `pub secret: String` addition.
4102    #[test]
4103    fn test_no_inline_secret_fields() {
4104        let endpoint = DoctorWebhookEndpoint {
4105            name: "ops".to_string(),
4106            url: "https://example.com/hook".to_string(),
4107            format: DoctorWebhookFormat::Pagerduty,
4108            on_transitions: vec!["any_to_failing".to_string()],
4109            retry_max: 3,
4110            retry_backoff_ms: 200,
4111            timeout_ms: 5000,
4112            enabled: true,
4113            routing_key_env: Some("RCH_PAGERDUTY_ROUTING_KEY".to_string()),
4114            bearer_token_env: Some("RCH_WEBHOOK_BEARER".to_string()),
4115            signing_secret_env: Some("RCH_WEBHOOK_HMAC".to_string()),
4116        };
4117        // Serialize to a JSON object and inspect the *keys*. The forbidden
4118        // field names must be entirely absent; only `*_env` references survive.
4119        let value = serde_json::to_value(&endpoint).unwrap();
4120        let obj = value.as_object().expect("endpoint serializes to an object");
4121        for forbidden in ["secret", "routing_key", "bearer_token", "password", "token"] {
4122            assert!(
4123                !obj.contains_key(forbidden),
4124                "endpoint config must not expose an inline `{forbidden}` field"
4125            );
4126        }
4127        assert!(obj.contains_key("routing_key_env"));
4128        assert!(obj.contains_key("bearer_token_env"));
4129        assert!(obj.contains_key("signing_secret_env"));
4130    }
4131
4132    #[test]
4133    fn test_webhook_format_toml_spelling() {
4134        // The config TOML uses snake_case spellings. TOML documents are tables
4135        // at the root, so a bare enum cannot be (de)serialized standalone —
4136        // verify the wire spelling through a `format = "..."` table field, the
4137        // shape it actually appears in within the config.
4138        #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
4139        struct FormatWrap {
4140            format: DoctorWebhookFormat,
4141        }
4142        let rendered = toml::to_string(&FormatWrap {
4143            format: DoctorWebhookFormat::GenericJson,
4144        })
4145        .unwrap();
4146        assert!(
4147            rendered.contains("format = \"generic_json\""),
4148            "unexpected TOML: {rendered}"
4149        );
4150        let slack: FormatWrap = toml::from_str("format = \"slack\"").unwrap();
4151        assert_eq!(slack.format, DoctorWebhookFormat::Slack);
4152        let pd: FormatWrap = toml::from_str("format = \"pagerduty\"").unwrap();
4153        assert_eq!(pd.format, DoctorWebhookFormat::Pagerduty);
4154    }
4155
4156    #[test]
4157    fn test_doctor_webhooks_defaults() {
4158        let cfg = DoctorWebhooksConfig::default();
4159        assert!(cfg.endpoints.is_empty());
4160        assert_eq!(cfg.max_inflight, 16);
4161        // RchConfig wires the section in with serde(default), so an empty
4162        // config still yields an empty (never-firing) webhook surface.
4163        let rch: RchConfig = toml::from_str("").unwrap();
4164        assert!(rch.doctor.webhooks.endpoints.is_empty());
4165    }
4166
4167    #[test]
4168    fn test_doctor_webhook_endpoint_parses_from_toml() {
4169        let toml_src = r#"
4170[doctor.webhooks]
4171max_inflight = 8
4172
4173[[doctor.webhooks.endpoints]]
4174name = "ops-slack"
4175url = "https://hooks.slack.com/services/T0/B0/x"
4176format = "slack"
4177on_transitions = ["healthy_to_degraded", "any_to_failing"]
4178retry_max = 2
4179"#;
4180        let cfg: RchConfig = toml::from_str(toml_src).unwrap();
4181        assert_eq!(cfg.doctor.webhooks.max_inflight, 8);
4182        assert_eq!(cfg.doctor.webhooks.endpoints.len(), 1);
4183        let ep = &cfg.doctor.webhooks.endpoints[0];
4184        assert_eq!(ep.name, "ops-slack");
4185        assert_eq!(ep.format, DoctorWebhookFormat::Slack);
4186        assert_eq!(ep.on_transitions.len(), 2);
4187        assert_eq!(ep.retry_max, 2);
4188        // Defaults filled in for omitted fields.
4189        assert_eq!(ep.retry_backoff_ms, 200);
4190        assert_eq!(ep.timeout_ms, 5000);
4191        assert!(ep.enabled);
4192    }
4193
4194    fn assert_golden_json(
4195        actual: serde_json::Value,
4196        expected: &serde_json::Value,
4197        fixture_path: &str,
4198    ) {
4199        if &actual == expected {
4200            return;
4201        }
4202
4203        let actual_json = serde_json::to_string_pretty(&actual).expect("actual JSON renders");
4204        let expected_json = serde_json::to_string_pretty(expected).expect("expected JSON renders");
4205        assert_eq!(
4206            &actual,
4207            expected,
4208            "golden mismatch in {fixture_path}\n\
4209             expected_blake3={}\n\
4210             actual_blake3={}\n\
4211             bless path: review the semantic diff, update the fixture expected_* field, \
4212             and record both hashes in the Beads closeout.\n\
4213             expected:\n{expected_json}\n\
4214             actual:\n{actual_json}",
4215            blake3::hash(expected_json.as_bytes()),
4216            blake3::hash(actual_json.as_bytes())
4217        );
4218    }
4219
4220    #[test]
4221    fn test_circuit_state_default() {
4222        let _guard = test_guard!();
4223        assert_eq!(CircuitState::default(), CircuitState::Closed);
4224    }
4225
4226    #[test]
4227    fn test_circuit_config_defaults() {
4228        let _guard = test_guard!();
4229        let config = CircuitBreakerConfig::default();
4230        assert_eq!(config.failure_threshold, 3);
4231        assert_eq!(config.success_threshold, 2);
4232        assert_eq!(config.error_rate_threshold, 0.5);
4233        assert_eq!(config.window_secs, 60);
4234        assert_eq!(config.open_cooldown_secs, 30);
4235        assert_eq!(config.half_open_max_probes, 1);
4236    }
4237
4238    #[test]
4239    fn test_rch_config_has_circuit_defaults() {
4240        let _guard = test_guard!();
4241        let config = RchConfig::default();
4242        assert_eq!(config.circuit.failure_threshold, 3);
4243    }
4244
4245    #[test]
4246    fn test_circuit_config_serde_roundtrip() {
4247        let _guard = test_guard!();
4248        let config = CircuitBreakerConfig {
4249            failure_threshold: 5,
4250            success_threshold: 3,
4251            error_rate_threshold: 0.75,
4252            window_secs: 120,
4253            open_cooldown_secs: 45,
4254            half_open_max_probes: 2,
4255            command_failure_threshold: 4,
4256            open_cooldown_max_secs: 900,
4257        };
4258        let json = serde_json::to_string(&config).unwrap();
4259        let parsed: CircuitBreakerConfig = serde_json::from_str(&json).unwrap();
4260        assert_eq!(parsed.command_failure_threshold, 4);
4261        assert_eq!(parsed.open_cooldown_max_secs, 900);
4262        assert_eq!(parsed.failure_threshold, 5);
4263        assert_eq!(parsed.success_threshold, 3);
4264        assert_eq!(parsed.error_rate_threshold, 0.75);
4265        assert_eq!(parsed.window_secs, 120);
4266        assert_eq!(parsed.open_cooldown_secs, 45);
4267        assert_eq!(parsed.half_open_max_probes, 2);
4268    }
4269
4270    // ========================================================================
4271    // WorkerCapabilities Tests
4272    // ========================================================================
4273
4274    #[test]
4275    fn test_worker_capabilities_default() {
4276        let _guard = test_guard!();
4277        let caps = WorkerCapabilities::default();
4278        assert!(caps.rustc_version.is_none());
4279        assert!(caps.rustup_toolchains.is_empty());
4280        assert!(caps.rustup_components.is_empty());
4281        assert!(caps.bun_version.is_none());
4282        assert!(caps.node_version.is_none());
4283        assert!(caps.npm_version.is_none());
4284        assert!(caps.projects_root_ok.is_none());
4285        assert!(caps.projects_root_issue.is_none());
4286        assert!(caps.projects_root_checked_at_unix_ms.is_none());
4287    }
4288
4289    #[test]
4290    fn test_worker_capabilities_new() {
4291        let _guard = test_guard!();
4292        let caps = WorkerCapabilities::new();
4293        assert!(caps.rustc_version.is_none());
4294        assert!(!caps.has_rust());
4295        assert!(!caps.has_bun());
4296        assert!(!caps.has_node());
4297        assert!(caps.is_topology_healthy().is_none());
4298    }
4299
4300    #[test]
4301    fn test_worker_capabilities_has_rust() {
4302        let _guard = test_guard!();
4303        let mut caps = WorkerCapabilities::new();
4304        assert!(!caps.has_rust());
4305
4306        caps.rustc_version = Some("rustc 1.76.0 (07dca489a 2024-02-04)".to_string());
4307        assert!(caps.has_rust());
4308    }
4309
4310    #[test]
4311    fn test_worker_capabilities_components_are_toolchain_scoped_and_exact() {
4312        let _guard = test_guard!();
4313        let caps = WorkerCapabilities {
4314            rustup_components: vec![
4315                "stable-x86_64-unknown-linux-gnu:clippy".to_string(),
4316                "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
4317                "beta-x86_64-unknown-linux-gnu:clippy-preview".to_string(),
4318            ],
4319            ..Default::default()
4320        };
4321
4322        assert!(caps.has_rustup_component("stable", "clippy"));
4323        assert!(caps.has_rustup_component("nightly-2026-07-05", "rustfmt"));
4324        assert!(!caps.has_rustup_component("nightly-2026-07-05", "clippy"));
4325        assert!(!caps.has_rustup_component("beta", "clippy"));
4326        assert!(!caps.has_rustup_component("nightly-2026-07-0", "rustfmt"));
4327    }
4328
4329    #[test]
4330    fn test_worker_capabilities_has_bun() {
4331        let _guard = test_guard!();
4332        let mut caps = WorkerCapabilities::new();
4333        assert!(!caps.has_bun());
4334
4335        caps.bun_version = Some("1.0.25".to_string());
4336        assert!(caps.has_bun());
4337    }
4338
4339    #[test]
4340    fn test_worker_capabilities_has_nix() {
4341        let _guard = test_guard!();
4342        let mut caps = WorkerCapabilities::new();
4343        assert!(!caps.has_nix());
4344
4345        caps.nix_version = Some("nix (Nix) 2.24.9".to_string());
4346        assert!(caps.has_nix());
4347    }
4348
4349    #[test]
4350    fn test_worker_capabilities_has_zig_requires_both_halves() {
4351        let _guard = test_guard!();
4352        let mut caps = WorkerCapabilities::new();
4353        assert!(!caps.has_zig());
4354
4355        // `zig` alone cannot run `cargo zigbuild` — cargo would report
4356        // `no such command: 'zigbuild'`.
4357        caps.zig_version = Some("0.14.1".to_string());
4358        assert!(!caps.has_zig());
4359
4360        // cargo-zigbuild alone cannot link — it shells out to `zig cc`.
4361        caps.zig_version = None;
4362        caps.cargo_zigbuild_version = Some("cargo-zigbuild 0.23.0".to_string());
4363        assert!(!caps.has_zig());
4364
4365        caps.zig_version = Some("0.14.1".to_string());
4366        assert!(caps.has_zig());
4367    }
4368
4369    #[test]
4370    fn test_nix_build_uses_generous_timeout() {
4371        let _guard = test_guard!();
4372        let cfg = CompilationConfig::default();
4373        // Nix builds get the long test-timeout bucket, not the short build timeout.
4374        assert_eq!(
4375            cfg.timeout_for_kind(Some(CompilationKind::NixBuild)),
4376            std::time::Duration::from_secs(cfg.test_timeout_sec)
4377        );
4378    }
4379
4380    #[test]
4381    fn test_job_uses_build_timeout_bucket() {
4382        let _guard = test_guard!();
4383        // Jobs (explicit `rch exec --job`) reuse the standard build-timeout
4384        // bucket, so `RCH_BUILD_TIMEOUT_SEC` governs them without new config.
4385        let cfg = CompilationConfig::default();
4386        assert_eq!(
4387            cfg.timeout_for_kind(Some(CompilationKind::Job)),
4388            std::time::Duration::from_secs(cfg.build_timeout_sec)
4389        );
4390    }
4391
4392    #[test]
4393    fn test_layer0_disabled_by_default_and_env_gated() {
4394        let _guard = test_guard!();
4395        let cfg = Layer0Config::default();
4396        assert!(!cfg.enabled);
4397        // Master switch off ⇒ nothing injects even with knobs on.
4398        let knobs_on = Layer0Config {
4399            enabled: false,
4400            release_lto_thin: true,
4401            release_codegen_units_1: true,
4402        };
4403        assert!(cfg.active_env().is_empty());
4404        assert!(knobs_on.active_env().is_empty());
4405    }
4406
4407    #[test]
4408    fn test_layer0_knobs_emit_stable_env_pairs() {
4409        let _guard = test_guard!();
4410        let lto_only = Layer0Config {
4411            enabled: true,
4412            release_lto_thin: true,
4413            release_codegen_units_1: false,
4414        };
4415        assert_eq!(
4416            lto_only.active_env(),
4417            vec![("CARGO_PROFILE_RELEASE_LTO", "thin")]
4418        );
4419        let both = Layer0Config {
4420            enabled: true,
4421            release_lto_thin: true,
4422            release_codegen_units_1: true,
4423        };
4424        assert_eq!(
4425            both.active_env(),
4426            vec![
4427                ("CARGO_PROFILE_RELEASE_LTO", "thin"),
4428                ("CARGO_PROFILE_RELEASE_CODEGEN_UNITS", "1"),
4429            ],
4430            "emission order must stay stable for deterministic baselines"
4431        );
4432    }
4433
4434    #[test]
4435    fn test_layer0_pack_version_is_recorded() {
4436        let _guard = test_guard!();
4437        // Baselines record the pack version; it must exist and be non-empty.
4438        assert!(!LAYER0_PACK_VERSION.is_empty());
4439    }
4440    #[test]
4441    fn test_default_allowlist_includes_nix() {
4442        let _guard = test_guard!();
4443        let cfg = ExecutionConfig::default();
4444        assert!(cfg.is_allowed("nix"));
4445    }
4446
4447    #[test]
4448    fn test_worker_capabilities_has_node() {
4449        let _guard = test_guard!();
4450        let mut caps = WorkerCapabilities::new();
4451        assert!(!caps.has_node());
4452
4453        caps.node_version = Some("v20.11.0".to_string());
4454        assert!(caps.has_node());
4455    }
4456
4457    #[test]
4458    fn test_worker_capabilities_multiple_runtimes() {
4459        let _guard = test_guard!();
4460        let caps = WorkerCapabilities {
4461            rustc_version: Some("rustc 1.76.0".to_string()),
4462            bun_version: Some("1.0.25".to_string()),
4463            node_version: Some("v20.11.0".to_string()),
4464            npm_version: Some("10.2.4".to_string()),
4465            ..Default::default()
4466        };
4467
4468        assert!(caps.has_rust());
4469        assert!(caps.has_bun());
4470        assert!(caps.has_node());
4471    }
4472
4473    #[test]
4474    fn test_worker_capabilities_serialization_empty() {
4475        let _guard = test_guard!();
4476        let caps = WorkerCapabilities::new();
4477        let json = serde_json::to_string(&caps).unwrap();
4478        // Empty capabilities should serialize to {}
4479        assert_eq!(json, "{}");
4480
4481        // And deserialize back correctly
4482        let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4483        assert!(!parsed.has_rust());
4484        assert!(!parsed.has_bun());
4485        assert!(!parsed.has_node());
4486    }
4487
4488    #[test]
4489    fn test_worker_capabilities_serialization_with_versions() {
4490        let _guard = test_guard!();
4491        let caps = WorkerCapabilities {
4492            rustc_version: Some("rustc 1.76.0-nightly".to_string()),
4493            bun_version: Some("1.0.25".to_string()),
4494            node_version: None,
4495            npm_version: None,
4496            ..Default::default()
4497        };
4498
4499        let json = serde_json::to_string(&caps).unwrap();
4500        assert!(json.contains("rustc_version"));
4501        assert!(json.contains("rustc 1.76.0-nightly"));
4502        assert!(json.contains("bun_version"));
4503        assert!(json.contains("1.0.25"));
4504        // None fields should be skipped
4505        assert!(!json.contains("node_version"));
4506        assert!(!json.contains("npm_version"));
4507
4508        let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
4509        assert!(parsed.has_rust());
4510        assert!(parsed.has_bun());
4511        assert!(!parsed.has_node());
4512    }
4513
4514    #[test]
4515    fn test_worker_capabilities_deserialization_partial() {
4516        let _guard = test_guard!();
4517        // Deserialize JSON with only some fields
4518        let json = r#"{"bun_version": "1.0.0"}"#;
4519        let caps: WorkerCapabilities = serde_json::from_str(json).unwrap();
4520
4521        assert!(!caps.has_rust());
4522        assert!(caps.has_bun());
4523        assert!(!caps.has_node());
4524        assert_eq!(caps.bun_version, Some("1.0.0".to_string()));
4525    }
4526
4527    #[test]
4528    fn test_worker_capabilities_clone() {
4529        let _guard = test_guard!();
4530        let caps = WorkerCapabilities {
4531            rustc_version: Some("1.76.0".to_string()),
4532            bun_version: None,
4533            node_version: Some("v20".to_string()),
4534            npm_version: None,
4535            projects_root_ok: Some(false),
4536            projects_root_issue: Some("alias_missing".to_string()),
4537            projects_root_checked_at_unix_ms: Some(123),
4538            ..Default::default()
4539        };
4540
4541        let cloned = caps.clone();
4542        assert_eq!(cloned.rustc_version, caps.rustc_version);
4543        assert_eq!(cloned.bun_version, caps.bun_version);
4544        assert_eq!(cloned.node_version, caps.node_version);
4545        assert_eq!(cloned.npm_version, caps.npm_version);
4546        assert_eq!(cloned.projects_root_ok, caps.projects_root_ok);
4547        assert_eq!(cloned.projects_root_issue, caps.projects_root_issue);
4548        assert_eq!(
4549            cloned.projects_root_checked_at_unix_ms,
4550            caps.projects_root_checked_at_unix_ms
4551        );
4552    }
4553
4554    #[test]
4555    fn test_worker_capabilities_topology_health_helper() {
4556        let _guard = test_guard!();
4557        let mut caps = WorkerCapabilities::new();
4558        assert_eq!(caps.is_topology_healthy(), None);
4559
4560        caps.projects_root_ok = Some(true);
4561        assert_eq!(caps.is_topology_healthy(), Some(true));
4562
4563        caps.projects_root_ok = Some(false);
4564        assert_eq!(caps.is_topology_healthy(), Some(false));
4565    }
4566
4567    #[test]
4568    fn test_selection_reason_serialization() {
4569        let _guard = test_guard!();
4570        // Test that all variants serialize to snake_case
4571        assert_eq!(
4572            serde_json::to_string(&SelectionReason::Success).unwrap(),
4573            "\"success\""
4574        );
4575        assert_eq!(
4576            serde_json::to_string(&SelectionReason::NoWorkersConfigured).unwrap(),
4577            "\"no_workers_configured\""
4578        );
4579        assert_eq!(
4580            serde_json::to_string(&SelectionReason::AllWorkersUnreachable).unwrap(),
4581            "\"all_workers_unreachable\""
4582        );
4583        assert_eq!(
4584            serde_json::to_string(&SelectionReason::AllCircuitsOpen).unwrap(),
4585            "\"all_circuits_open\""
4586        );
4587        assert_eq!(
4588            serde_json::to_string(&SelectionReason::AllWorkersBusy).unwrap(),
4589            "\"all_workers_busy\""
4590        );
4591        assert_eq!(
4592            serde_json::to_string(&SelectionReason::NoWorkersPassedHealth).unwrap(),
4593            "\"no_workers_passed_health\""
4594        );
4595        assert_eq!(
4596            serde_json::to_string(&SelectionReason::AllWorkersFailedPreflight).unwrap(),
4597            "\"all_workers_failed_preflight\""
4598        );
4599        assert_eq!(
4600            serde_json::to_string(&SelectionReason::AllWorkersFailedConvergence).unwrap(),
4601            "\"all_workers_failed_convergence\""
4602        );
4603        assert_eq!(
4604            serde_json::to_string(&SelectionReason::NoAdmissibleWorkers(
4605                "critical_pressure=1,insufficient_slots=1".to_string()
4606            ))
4607            .unwrap(),
4608            "{\"no_admissible_workers\":\"critical_pressure=1,insufficient_slots=1\"}"
4609        );
4610        assert_eq!(
4611            serde_json::to_string(&SelectionReason::NoMatchingWorkers).unwrap(),
4612            "\"no_matching_workers\""
4613        );
4614    }
4615
4616    #[test]
4617    fn test_selection_reason_with_error() {
4618        let _guard = test_guard!();
4619        let reason = SelectionReason::SelectionError("test error".to_string());
4620        let json = serde_json::to_string(&reason).unwrap();
4621        assert!(json.contains("selection_error"));
4622        assert!(json.contains("test error"));
4623    }
4624
4625    #[test]
4626    fn test_selection_reason_deserialization() {
4627        let _guard = test_guard!();
4628        assert_eq!(
4629            serde_json::from_str::<SelectionReason>("\"success\"").unwrap(),
4630            SelectionReason::Success
4631        );
4632        assert_eq!(
4633            serde_json::from_str::<SelectionReason>("\"all_workers_busy\"").unwrap(),
4634            SelectionReason::AllWorkersBusy
4635        );
4636        assert_eq!(
4637            serde_json::from_str::<SelectionReason>("\"no_workers_passed_health\"").unwrap(),
4638            SelectionReason::NoWorkersPassedHealth
4639        );
4640        assert_eq!(
4641            serde_json::from_str::<SelectionReason>("\"all_workers_failed_preflight\"").unwrap(),
4642            SelectionReason::AllWorkersFailedPreflight
4643        );
4644        assert_eq!(
4645            serde_json::from_str::<SelectionReason>("\"all_workers_failed_convergence\"").unwrap(),
4646            SelectionReason::AllWorkersFailedConvergence
4647        );
4648        assert_eq!(
4649            serde_json::from_str::<SelectionReason>(
4650                "{\"no_admissible_workers\":\"critical_pressure=1\"}"
4651            )
4652            .unwrap(),
4653            SelectionReason::NoAdmissibleWorkers("critical_pressure=1".to_string())
4654        );
4655    }
4656
4657    #[test]
4658    fn test_selection_reason_display() {
4659        let _guard = test_guard!();
4660        assert_eq!(
4661            SelectionReason::Success.to_string(),
4662            "worker assigned successfully"
4663        );
4664        assert_eq!(
4665            SelectionReason::NoWorkersConfigured.to_string(),
4666            "no workers configured"
4667        );
4668        assert_eq!(
4669            SelectionReason::AllWorkersUnreachable.to_string(),
4670            "all workers unreachable"
4671        );
4672        assert_eq!(
4673            SelectionReason::AllWorkersBusy.to_string(),
4674            "all workers at capacity"
4675        );
4676        assert_eq!(
4677            SelectionReason::NoWorkersPassedHealth.to_string(),
4678            "no workers passed health thresholds"
4679        );
4680        assert_eq!(
4681            SelectionReason::AllWorkersFailedPreflight.to_string(),
4682            "all workers failed preflight checks"
4683        );
4684        assert_eq!(
4685            SelectionReason::AllWorkersFailedConvergence.to_string(),
4686            "all workers failed repo convergence checks"
4687        );
4688        assert_eq!(
4689            SelectionReason::NoAdmissibleWorkers(
4690                "critical_pressure=1,insufficient_slots=1".to_string()
4691            )
4692            .to_string(),
4693            "no admissible workers: critical_pressure=1,insufficient_slots=1"
4694        );
4695        assert_eq!(
4696            SelectionReason::SelectionError("oops".to_string()).to_string(),
4697            "selection error: oops"
4698        );
4699    }
4700
4701    #[test]
4702    fn test_selection_response_with_worker() {
4703        let _guard = test_guard!();
4704        let response = SelectionResponse {
4705            worker: Some(SelectedWorker {
4706                id: WorkerId::new("test"),
4707                host: "localhost".to_string(),
4708                user: "user".to_string(),
4709                identity_file: "~/.ssh/id_rsa".to_string(),
4710                slots_available: 8,
4711                speed_score: 75.0,
4712                declared_os: None,
4713            }),
4714            reason: SelectionReason::Success,
4715            build_id: None,
4716            diagnostics: None,
4717        };
4718
4719        let json = serde_json::to_string(&response).unwrap();
4720        assert!(json.contains("\"reason\":\"success\""));
4721        assert!(json.contains("\"id\":\"test\""));
4722    }
4723
4724    #[test]
4725    fn test_selection_response_without_worker() {
4726        let _guard = test_guard!();
4727        let response = SelectionResponse {
4728            worker: None,
4729            reason: SelectionReason::AllWorkersBusy,
4730            build_id: None,
4731            diagnostics: None,
4732        };
4733
4734        let json = serde_json::to_string(&response).unwrap();
4735        assert!(json.contains("\"worker\":null"));
4736        assert!(json.contains("\"reason\":\"all_workers_busy\""));
4737    }
4738
4739    #[test]
4740    fn golden_selection_response_with_no_workers_passed_health_diagnostics() {
4741        let _guard = test_guard!();
4742        const FIXTURE_PATH: &str =
4743            "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json";
4744        let fixture: serde_json::Value = serde_json::from_str(include_str!(
4745            "../../tests/goldens/ft_4tp7g/selection_diagnostics_no_workers_passed_health.json"
4746        ))
4747        .expect("selection diagnostics golden fixture parses");
4748        assert_eq!(
4749            fixture["schema_version"],
4750            "rch.golden.selection_diagnostics.v1"
4751        );
4752
4753        let response = SelectionResponse {
4754            worker: None,
4755            reason: SelectionReason::NoWorkersPassedHealth,
4756            build_id: None,
4757            diagnostics: Some(SelectionDiagnostics {
4758                required_runtime: RequiredRuntime::Rust,
4759                estimated_cores: 2,
4760                min_success_rate: 0.8,
4761                fallback_min_success_rate: 0.5,
4762                active_project_exclusion_count: 0,
4763                workers: vec![WorkerSelectionDiagnostic {
4764                    worker_id: WorkerId::new("fixture-worker"),
4765                    status: "healthy".to_string(),
4766                    circuit_state: "closed".to_string(),
4767                    pressure_state: "healthy".to_string(),
4768                    pressure_reason_code: "pressure.ok".to_string(),
4769                    success_rate: Some(0.2),
4770                    min_success_rate: 0.8,
4771                    fallback_min_success_rate: 0.5,
4772                    required_runtime: RequiredRuntime::Rust,
4773                    runtime_available: true,
4774                    available_slots: 8,
4775                    total_slots: 8,
4776                    estimated_cores: 2,
4777                    active_project_excluded: false,
4778                    final_decision: WorkerSelectionDiagnosticDecision::Deny,
4779                    final_reason: "success_rate 0.20 < fallback 0.50".to_string(),
4780                    reason_codes: vec![
4781                        "health.below_min_success_rate".to_string(),
4782                        "health.below_fallback_min_success_rate".to_string(),
4783                    ],
4784                }],
4785            }),
4786        };
4787
4788        assert_golden_json(
4789            serde_json::to_value(&response).expect("response serializes"),
4790            &fixture["expected_response"],
4791            FIXTURE_PATH,
4792        );
4793    }
4794
4795    #[test]
4796    fn test_selection_response_roundtrip() {
4797        let _guard = test_guard!();
4798        let original = SelectionResponse {
4799            worker: Some(SelectedWorker {
4800                id: WorkerId::new("worker-1"),
4801                host: "192.168.1.100".to_string(),
4802                user: "ubuntu".to_string(),
4803                identity_file: "/path/to/key".to_string(),
4804                slots_available: 16,
4805                speed_score: 90.5,
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(&original).unwrap();
4814        let parsed: SelectionResponse = serde_json::from_str(&json).unwrap();
4815
4816        assert!(parsed.worker.is_some());
4817        let worker = parsed.worker.unwrap();
4818        assert_eq!(worker.id.as_str(), "worker-1");
4819        assert_eq!(worker.host, "192.168.1.100");
4820        assert_eq!(worker.slots_available, 16);
4821        assert_eq!(parsed.reason, SelectionReason::Success);
4822    }
4823
4824    #[test]
4825    fn test_build_heartbeat_phase_roundtrip() {
4826        let _guard = test_guard!();
4827        let json = serde_json::to_string(&BuildHeartbeatPhase::SyncUp).unwrap();
4828        assert_eq!(json, "\"sync_up\"");
4829        let parsed: BuildHeartbeatPhase = serde_json::from_str(&json).unwrap();
4830        assert_eq!(parsed, BuildHeartbeatPhase::SyncUp);
4831    }
4832
4833    #[test]
4834    fn test_build_heartbeat_request_roundtrip() {
4835        let _guard = test_guard!();
4836        let request = BuildHeartbeatRequest {
4837            build_id: 42,
4838            worker_id: WorkerId::new("worker-a"),
4839            hook_pid: Some(12345),
4840            local_wrapper_id: Some("wrapper-abc123".to_string()),
4841            remote_pgid_file: Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string()),
4842            phase: BuildHeartbeatPhase::Execute,
4843            detail: Some("Compiling crates".to_string()),
4844            progress_counter: Some(7),
4845            progress_percent: Some(42.5),
4846        };
4847
4848        let json = serde_json::to_string(&request).unwrap();
4849        let parsed: BuildHeartbeatRequest = serde_json::from_str(&json).unwrap();
4850        assert_eq!(parsed.build_id, 42);
4851        assert_eq!(parsed.worker_id.as_str(), "worker-a");
4852        assert_eq!(
4853            parsed.remote_pgid_file,
4854            Some("/tmp/rch/project/hash/.rch-run/42.pgid".to_string())
4855        );
4856        assert_eq!(parsed.phase, BuildHeartbeatPhase::Execute);
4857        assert_eq!(parsed.progress_counter, Some(7));
4858        assert_eq!(parsed.progress_percent, Some(42.5));
4859    }
4860
4861    // CircuitStats tests
4862
4863    #[test]
4864    fn test_circuit_stats_new() {
4865        let _guard = test_guard!();
4866        let stats = CircuitStats::new();
4867        assert_eq!(stats.state(), CircuitState::Closed);
4868        assert_eq!(stats.consecutive_failures(), 0);
4869        assert_eq!(stats.error_rate(), 0.0);
4870        assert!(stats.opened_at().is_none());
4871    }
4872
4873    #[test]
4874    fn test_circuit_stats_record_success() {
4875        let _guard = test_guard!();
4876        let mut stats = CircuitStats::new();
4877        stats.record_success();
4878        stats.record_success();
4879        assert_eq!(stats.error_rate(), 0.0);
4880        assert_eq!(stats.consecutive_failures(), 0);
4881    }
4882
4883    #[test]
4884    fn test_circuit_stats_record_failure() {
4885        let _guard = test_guard!();
4886        let mut stats = CircuitStats::new();
4887        stats.record_failure();
4888        stats.record_failure();
4889        assert_eq!(stats.consecutive_failures(), 2);
4890        assert_eq!(stats.error_rate(), 1.0); // 2 failures, 0 successes
4891    }
4892
4893    #[test]
4894    fn test_circuit_stats_error_rate() {
4895        let _guard = test_guard!();
4896        let mut stats = CircuitStats::new();
4897        stats.record_success();
4898        stats.record_success();
4899        stats.record_failure();
4900        // 1 failure / 3 total = 0.333...
4901        assert!((stats.error_rate() - 0.333).abs() < 0.01);
4902    }
4903
4904    #[test]
4905    fn test_circuit_stats_should_open_consecutive_failures() {
4906        let _guard = test_guard!();
4907        let mut stats = CircuitStats::new();
4908        let config = CircuitBreakerConfig::default(); // threshold = 3
4909
4910        stats.record_failure();
4911        assert!(!stats.should_open(&config));
4912
4913        stats.record_failure();
4914        assert!(!stats.should_open(&config));
4915
4916        stats.record_failure();
4917        assert!(stats.should_open(&config)); // 3 consecutive failures
4918    }
4919
4920    #[test]
4921    fn test_circuit_stats_should_open_error_rate() {
4922        let _guard = test_guard!();
4923        let mut stats = CircuitStats::new();
4924        let config = CircuitBreakerConfig {
4925            error_rate_threshold: 0.5,
4926            ..Default::default()
4927        };
4928
4929        // Need at least 5 samples
4930        stats.record_success();
4931        stats.record_success();
4932        stats.record_failure();
4933        stats.record_failure();
4934        assert!(!stats.should_open(&config)); // Only 4 samples
4935
4936        stats.record_failure(); // 5 samples: 2 success, 3 failures = 60% error rate
4937        assert!(stats.should_open(&config));
4938    }
4939
4940    #[test]
4941    fn test_circuit_close_clears_recent_results_no_flap() {
4942        let _guard = test_guard!();
4943        // Regression: error_rate() is computed over recent_results, so close()
4944        // must clear that history or a recovered circuit immediately re-opens
4945        // on stale pre-failure samples (flapping).
4946        let mut stats = CircuitStats::new();
4947        let config = CircuitBreakerConfig {
4948            error_rate_threshold: 0.5,
4949            ..Default::default()
4950        };
4951        for _ in 0..6 {
4952            stats.record_failure();
4953        }
4954        assert!(stats.should_open(&config));
4955        stats.open();
4956        // Recover via half-open probes, then close.
4957        stats.half_open();
4958        stats.record_success();
4959        stats.record_success();
4960        stats.close();
4961        // Error rate must be reset and the circuit must NOT immediately re-open.
4962        assert_eq!(stats.error_rate(), 0.0);
4963        assert!(!stats.should_open(&config));
4964    }
4965
4966    #[test]
4967    fn test_apply_health_outcome_opens_on_failure_threshold() {
4968        let _guard = test_guard!();
4969        // failure_threshold=3: three unhealthy outcomes open the circuit.
4970        let config = CircuitBreakerConfig {
4971            failure_threshold: 3,
4972            open_cooldown_secs: 300,
4973            ..Default::default()
4974        };
4975        let mut stats = CircuitStats::new();
4976        assert_eq!(
4977            stats.apply_health_outcome(false, false, &config),
4978            CircuitState::Closed
4979        );
4980        assert_eq!(
4981            stats.apply_health_outcome(false, false, &config),
4982            CircuitState::Closed
4983        );
4984        assert_eq!(
4985            stats.apply_health_outcome(false, false, &config),
4986            CircuitState::Open
4987        );
4988    }
4989
4990    #[test]
4991    fn test_apply_health_outcome_half_open_two_probe_recovery() {
4992        let _guard = test_guard!();
4993        // success_threshold=2: recovery from Open needs exactly two clean probes.
4994        // open_cooldown_secs=0 makes should_half_open() true immediately, so the
4995        // engine's trailing nudge leaves an opened circuit already in HalfOpen —
4996        // exactly the state a real daemon would be in on the next poll once the
4997        // cooldown had elapsed. From HalfOpen the engine credits successes.
4998        let config = CircuitBreakerConfig {
4999            failure_threshold: 3,
5000            success_threshold: 2,
5001            open_cooldown_secs: 0,
5002            ..Default::default()
5003        };
5004        let mut stats = CircuitStats::new();
5005        for _ in 0..3 {
5006            stats.apply_health_outcome(false, false, &config);
5007        }
5008        // With a 0s cooldown the engine nudges Open -> HalfOpen immediately.
5009        assert_eq!(stats.state(), CircuitState::HalfOpen);
5010
5011        // First healthy probe in half-open: one success credited, not yet closed.
5012        let after_first = stats.apply_health_outcome(true, false, &config);
5013        assert_eq!(after_first, CircuitState::HalfOpen);
5014
5015        // Second healthy probe: threshold met -> Closed.
5016        let after_second = stats.apply_health_outcome(true, false, &config);
5017        assert_eq!(after_second, CircuitState::Closed);
5018    }
5019
5020    #[test]
5021    fn test_apply_health_outcome_transient_half_open_does_not_reopen() {
5022        let _guard = test_guard!();
5023        // A transient failure while half-open must NOT reset the recovery streak
5024        // — a network blip can't undo an in-progress recovery.
5025        let config = CircuitBreakerConfig {
5026            failure_threshold: 3,
5027            success_threshold: 2,
5028            open_cooldown_secs: 0,
5029            ..Default::default()
5030        };
5031        let mut stats = CircuitStats::new();
5032        for _ in 0..3 {
5033            stats.apply_health_outcome(false, false, &config);
5034        }
5035        // 0s cooldown -> already nudged to HalfOpen.
5036        assert_eq!(stats.state(), CircuitState::HalfOpen);
5037
5038        // One clean probe (1 of 2 needed).
5039        assert_eq!(
5040            stats.apply_health_outcome(true, false, &config),
5041            CircuitState::HalfOpen
5042        );
5043
5044        // Transient failure in half-open: stays HalfOpen (does NOT reopen the
5045        // circuit or restart the cooldown). The key contrast with a real failure
5046        // is that the circuit does not drop back to Open — probing continues in
5047        // place, so recovery resumes immediately on the next clean probes rather
5048        // than waiting out a fresh cooldown.
5049        let after_transient = stats.apply_health_outcome(false, true, &config);
5050        assert_eq!(after_transient, CircuitState::HalfOpen);
5051
5052        // Recovery still completes from half-open with the required clean probes.
5053        assert_eq!(
5054            stats.apply_health_outcome(true, false, &config),
5055            CircuitState::HalfOpen
5056        );
5057        assert_eq!(
5058            stats.apply_health_outcome(true, false, &config),
5059            CircuitState::Closed
5060        );
5061    }
5062
5063    #[test]
5064    fn test_apply_health_outcome_real_vs_transient_half_open_failure() {
5065        let _guard = test_guard!();
5066        // With a positive cooldown the difference is directly observable: a REAL
5067        // half-open failure reopens the circuit (state -> Open, must wait out the
5068        // cooldown again), whereas a TRANSIENT half-open failure keeps it in
5069        // HalfOpen so recovery continues without a fresh cooldown.
5070        //
5071        // Manually drive to HalfOpen (open() + half_open()) so the positive
5072        // cooldown doesn't force a real wallclock wait to reach half-open.
5073        let config = CircuitBreakerConfig {
5074            failure_threshold: 3,
5075            success_threshold: 2,
5076            open_cooldown_secs: 300,
5077            ..Default::default()
5078        };
5079
5080        // Real failure path.
5081        let mut real = CircuitStats::new();
5082        real.open();
5083        real.half_open();
5084        assert_eq!(real.state(), CircuitState::HalfOpen);
5085        let after_real = real.apply_health_outcome(false, false, &config);
5086        assert_eq!(
5087            after_real,
5088            CircuitState::Open,
5089            "a real half-open failure must reopen the circuit"
5090        );
5091
5092        // Transient failure path (same starting state).
5093        let mut transient = CircuitStats::new();
5094        transient.open();
5095        transient.half_open();
5096        assert_eq!(transient.state(), CircuitState::HalfOpen);
5097        let after_transient = transient.apply_health_outcome(false, true, &config);
5098        assert_eq!(
5099            after_transient,
5100            CircuitState::HalfOpen,
5101            "a transient half-open failure must NOT reopen the circuit"
5102        );
5103    }
5104
5105    /// Issue #48 regression: a worker whose liveness probe (`echo` over a warm
5106    /// session) keeps succeeding while every real command times out must NOT
5107    /// be re-admitted. Liveness successes alone cannot close a half-open
5108    /// circuit while command-class evidence is outstanding.
5109    #[test]
5110    fn test_command_failure_blocks_half_open_close_on_liveness_successes() {
5111        let _guard = test_guard!();
5112        let config = CircuitBreakerConfig {
5113            failure_threshold: 3,
5114            success_threshold: 2,
5115            open_cooldown_secs: 0,
5116            command_failure_threshold: 2,
5117            ..Default::default()
5118        };
5119        let mut stats = CircuitStats::new();
5120        stats.open();
5121        stats.half_open();
5122
5123        // The half-open trial: a real command hangs on the worker.
5124        assert_eq!(
5125            stats.apply_command_outcome(false, &config),
5126            CircuitState::HalfOpen,
5127            "cooldown 0: reopen then immediately half-open again"
5128        );
5129        assert_eq!(stats.consecutive_command_failures(), 1);
5130
5131        // Liveness keeps answering — many times over.
5132        for _ in 0..10 {
5133            assert_eq!(
5134                stats.apply_health_outcome(true, false, &config),
5135                CircuitState::HalfOpen,
5136                "liveness successes must not close the circuit while a command failure is outstanding"
5137            );
5138        }
5139
5140        // Only a command-class success clears the evidence and recovers.
5141        assert_eq!(
5142            stats.apply_command_outcome(true, &config),
5143            CircuitState::Closed
5144        );
5145        assert_eq!(stats.consecutive_command_failures(), 0);
5146        assert_eq!(stats.reopen_streak(), 0);
5147    }
5148
5149    /// Issue #48: interleaved liveness successes reset `failure_threshold`, so
5150    /// command failures need their own threshold to open a Closed circuit.
5151    #[test]
5152    fn test_command_failures_open_closed_circuit_despite_liveness_successes() {
5153        let _guard = test_guard!();
5154        let config = CircuitBreakerConfig {
5155            failure_threshold: 3,
5156            command_failure_threshold: 2,
5157            open_cooldown_secs: 300,
5158            ..Default::default()
5159        };
5160        let mut stats = CircuitStats::new();
5161
5162        assert_eq!(
5163            stats.apply_command_outcome(false, &config),
5164            CircuitState::Closed
5165        );
5166        // Two liveness successes in between: consecutive_failures is reset...
5167        stats.apply_health_outcome(true, false, &config);
5168        stats.apply_health_outcome(true, false, &config);
5169        assert_eq!(stats.consecutive_failures(), 0);
5170        // ...but the command-failure count is not, and the second one opens.
5171        assert_eq!(
5172            stats.apply_command_outcome(false, &config),
5173            CircuitState::Open
5174        );
5175        assert_eq!(stats.reopen_streak(), 1);
5176    }
5177
5178    /// A command failure in HalfOpen always reopens: "connected but the
5179    /// command never completed" is exactly what the trial exists to detect,
5180    /// so there is no transient exemption on this channel.
5181    #[test]
5182    fn test_command_failure_in_half_open_always_reopens() {
5183        let _guard = test_guard!();
5184        let config = CircuitBreakerConfig {
5185            open_cooldown_secs: 300,
5186            ..Default::default()
5187        };
5188        let mut stats = CircuitStats::new();
5189        stats.open();
5190        stats.half_open();
5191        assert_eq!(
5192            stats.apply_command_outcome(false, &config),
5193            CircuitState::Open
5194        );
5195        assert_eq!(stats.reopen_streak(), 2);
5196    }
5197
5198    /// Repeated Open -> HalfOpen -> Open cycles lengthen the cooldown
5199    /// (exponential, capped) so a flapping worker converges to Open instead of
5200    /// being re-admitted on every base cooldown.
5201    #[test]
5202    fn test_reopen_streak_backs_off_cooldown_until_cap() {
5203        let _guard = test_guard!();
5204        let config = CircuitBreakerConfig {
5205            open_cooldown_secs: 30,
5206            open_cooldown_max_secs: 100,
5207            ..Default::default()
5208        };
5209        assert_eq!(config.cooldown_ms_for_streak(0), 30_000);
5210        assert_eq!(config.cooldown_ms_for_streak(1), 30_000);
5211        assert_eq!(config.cooldown_ms_for_streak(2), 60_000);
5212        assert_eq!(config.cooldown_ms_for_streak(3), 100_000, "capped");
5213        assert_eq!(config.cooldown_ms_for_streak(40), 100_000, "no overflow");
5214
5215        // A cap below the base never shortens the base cooldown.
5216        let inverted = CircuitBreakerConfig {
5217            open_cooldown_secs: 30,
5218            open_cooldown_max_secs: 5,
5219            ..Default::default()
5220        };
5221        assert_eq!(inverted.cooldown_ms_for_streak(3), 30_000);
5222
5223        // The streak is observable through recovery_remaining_secs: after two
5224        // opens the remaining cooldown exceeds the base.
5225        let mut stats = CircuitStats::new();
5226        stats.open();
5227        stats.half_open();
5228        stats.open();
5229        assert_eq!(stats.reopen_streak(), 2);
5230        let remaining = stats.recovery_remaining_secs(&config).unwrap();
5231        assert!(
5232            remaining > 30 && remaining <= 60,
5233            "second open should wait ~60s, got {remaining}"
5234        );
5235
5236        // Closing resets the streak, so the next open starts from the base.
5237        stats.close();
5238        assert_eq!(stats.reopen_streak(), 0);
5239        stats.open();
5240        assert!(stats.recovery_remaining_secs(&config).unwrap() <= 30);
5241    }
5242
5243    #[test]
5244    fn test_circuit_stats_success_resets_consecutive_failures() {
5245        let _guard = test_guard!();
5246        let mut stats = CircuitStats::new();
5247        stats.record_failure();
5248        stats.record_failure();
5249        assert_eq!(stats.consecutive_failures(), 2);
5250
5251        stats.record_success();
5252        assert_eq!(stats.consecutive_failures(), 0);
5253    }
5254
5255    #[test]
5256    fn test_circuit_stats_open_transition() {
5257        let _guard = test_guard!();
5258        let mut stats = CircuitStats::new();
5259        stats.open();
5260
5261        assert_eq!(stats.state(), CircuitState::Open);
5262        assert!(stats.opened_at().is_some());
5263    }
5264
5265    #[test]
5266    fn test_circuit_stats_half_open_transition() {
5267        let _guard = test_guard!();
5268        let mut stats = CircuitStats::new();
5269        stats.open();
5270        stats.half_open();
5271
5272        assert_eq!(stats.state(), CircuitState::HalfOpen);
5273    }
5274
5275    #[test]
5276    fn test_circuit_stats_close_transition() {
5277        let _guard = test_guard!();
5278        let mut stats = CircuitStats::new();
5279        stats.open();
5280        stats.half_open();
5281        stats.close();
5282
5283        assert_eq!(stats.state(), CircuitState::Closed);
5284        assert!(stats.opened_at().is_none());
5285        assert_eq!(stats.consecutive_failures(), 0);
5286    }
5287
5288    #[test]
5289    fn test_circuit_stats_should_close() {
5290        let _guard = test_guard!();
5291        let mut stats = CircuitStats::new();
5292        let config = CircuitBreakerConfig {
5293            success_threshold: 2,
5294            ..Default::default()
5295        };
5296
5297        stats.open();
5298        stats.half_open();
5299
5300        assert!(!stats.should_close(&config)); // 0 successes
5301
5302        stats.record_success();
5303        assert!(!stats.should_close(&config)); // 1 success
5304
5305        stats.record_success();
5306        assert!(stats.should_close(&config)); // 2 successes
5307    }
5308
5309    #[test]
5310    fn test_circuit_stats_can_probe() {
5311        let _guard = test_guard!();
5312        let mut stats = CircuitStats::new();
5313        let config = CircuitBreakerConfig {
5314            half_open_max_probes: 1,
5315            ..Default::default()
5316        };
5317
5318        // Can't probe when closed
5319        assert!(!stats.can_probe(&config));
5320
5321        stats.open();
5322        // Can't probe when open
5323        assert!(!stats.can_probe(&config));
5324
5325        stats.half_open();
5326        // Can probe when half-open
5327        assert!(stats.can_probe(&config));
5328
5329        // Start a probe
5330        assert!(stats.start_probe(&config));
5331        // Can't start another probe
5332        assert!(!stats.can_probe(&config));
5333        assert!(!stats.start_probe(&config));
5334    }
5335
5336    #[test]
5337    fn test_circuit_stats_probe_completion() {
5338        let _guard = test_guard!();
5339        let mut stats = CircuitStats::new();
5340        let config = CircuitBreakerConfig {
5341            half_open_max_probes: 1,
5342            ..Default::default()
5343        };
5344
5345        stats.open();
5346        stats.half_open();
5347        stats.start_probe(&config);
5348
5349        // Probe completes with success
5350        stats.record_success();
5351
5352        // Can start another probe
5353        assert!(stats.can_probe(&config));
5354    }
5355
5356    #[test]
5357    fn test_circuit_stats_failure_in_half_open() {
5358        let _guard = test_guard!();
5359        let mut stats = CircuitStats::new();
5360        let config = CircuitBreakerConfig {
5361            success_threshold: 2,
5362            ..Default::default()
5363        };
5364
5365        stats.open();
5366        stats.half_open();
5367
5368        stats.record_success();
5369        assert_eq!(stats.consecutive_failures(), 0);
5370
5371        // Failure resets consecutive successes
5372        stats.record_failure();
5373        assert!(!stats.should_close(&config));
5374
5375        // Need 2 more successes
5376        stats.record_success();
5377        stats.record_success();
5378        assert!(stats.should_close(&config));
5379    }
5380
5381    #[test]
5382    fn test_circuit_stats_reset_window() {
5383        let _guard = test_guard!();
5384        let mut stats = CircuitStats::new();
5385        stats.record_success();
5386        stats.record_failure();
5387
5388        assert!(stats.error_rate() > 0.0);
5389
5390        stats.reset_window();
5391        assert_eq!(stats.error_rate(), 0.0);
5392    }
5393
5394    #[test]
5395    fn test_circuit_state_transitions_are_deterministic() {
5396        let _guard = test_guard!();
5397        let config = CircuitBreakerConfig::default();
5398        let mut stats = CircuitStats::new();
5399
5400        // Start closed
5401        assert_eq!(stats.state(), CircuitState::Closed);
5402
5403        // Cause failures to open
5404        for _ in 0..3 {
5405            stats.record_failure();
5406        }
5407        assert!(stats.should_open(&config));
5408        stats.open();
5409        assert_eq!(stats.state(), CircuitState::Open);
5410
5411        // Transition to half-open after cooldown would be checked
5412        stats.half_open();
5413        assert_eq!(stats.state(), CircuitState::HalfOpen);
5414
5415        // Successes to close
5416        for _ in 0..2 {
5417            stats.record_success();
5418        }
5419        assert!(stats.should_close(&config));
5420        stats.close();
5421        assert_eq!(stats.state(), CircuitState::Closed);
5422    }
5423
5424    // ========================================================================
5425    // Compilation Timing Tests
5426    // ========================================================================
5427
5428    fn make_test_metrics(
5429        speedup: Option<f64>,
5430        total_secs: u64,
5431        success: bool,
5432    ) -> CompilationMetrics {
5433        CompilationMetrics {
5434            project_id: "test-project".to_string(),
5435            worker_id: "worker-1".to_string(),
5436            timestamp: chrono::Utc::now(),
5437            timing: CompilationTimingBreakdown {
5438                rsync_up: Duration::from_millis(100),
5439                remote_build: Duration::from_millis(800),
5440                rsync_down: Duration::from_millis(100),
5441                total: Duration::from_secs(total_secs),
5442            },
5443            local_build_time: speedup.map(|s| Duration::from_secs_f64(total_secs as f64 * s)),
5444            speedup,
5445            files_synced: 100,
5446            bytes_transferred: 1_000_000,
5447            exit_code: if success { 0 } else { 1 },
5448            success,
5449        }
5450    }
5451
5452    #[test]
5453    fn test_compilation_timing_breakdown_default() {
5454        let _guard = test_guard!();
5455        let timing = CompilationTimingBreakdown::default();
5456        assert_eq!(timing.rsync_up, Duration::ZERO);
5457        assert_eq!(timing.remote_build, Duration::ZERO);
5458        assert_eq!(timing.rsync_down, Duration::ZERO);
5459        assert_eq!(timing.total, Duration::ZERO);
5460    }
5461
5462    #[test]
5463    fn test_compilation_timing_breakdown_serialization() {
5464        let _guard = test_guard!();
5465        let timing = CompilationTimingBreakdown {
5466            rsync_up: Duration::from_millis(100),
5467            remote_build: Duration::from_millis(2000),
5468            rsync_down: Duration::from_millis(50),
5469            total: Duration::from_millis(2150),
5470        };
5471
5472        let json = serde_json::to_string(&timing).unwrap();
5473        assert!(json.contains("100")); // rsync_up
5474        assert!(json.contains("2000")); // remote_build
5475        assert!(json.contains("50")); // rsync_down
5476        assert!(json.contains("2150")); // total
5477
5478        let parsed: CompilationTimingBreakdown = serde_json::from_str(&json).unwrap();
5479        assert_eq!(parsed.rsync_up, timing.rsync_up);
5480        assert_eq!(parsed.remote_build, timing.remote_build);
5481        assert_eq!(parsed.rsync_down, timing.rsync_down);
5482        assert_eq!(parsed.total, timing.total);
5483    }
5484
5485    #[test]
5486    fn test_command_timing_breakdown_serialization() {
5487        let _guard = test_guard!();
5488        let timing = CommandTimingBreakdown {
5489            classify: Some(Duration::from_millis(2)),
5490            select: None,
5491            sync_up: Some(Duration::from_millis(120)),
5492            exec: Some(Duration::from_millis(900)),
5493            sync_down: None,
5494            cleanup: Some(Duration::from_millis(5)),
5495            total: Some(Duration::from_millis(1027)),
5496        };
5497
5498        let json = serde_json::to_string(&timing).unwrap();
5499        assert!(json.contains("\"classify\":2"));
5500        assert!(json.contains("\"select\":null"));
5501        assert!(json.contains("\"sync_up\":120"));
5502        assert!(json.contains("\"exec\":900"));
5503        assert!(json.contains("\"sync_down\":null"));
5504        assert!(json.contains("\"cleanup\":5"));
5505        assert!(json.contains("\"total\":1027"));
5506
5507        let parsed: CommandTimingBreakdown = serde_json::from_str(&json).unwrap();
5508        assert_eq!(parsed.classify, timing.classify);
5509        assert_eq!(parsed.select, timing.select);
5510        assert_eq!(parsed.sync_up, timing.sync_up);
5511        assert_eq!(parsed.exec, timing.exec);
5512        assert_eq!(parsed.sync_down, timing.sync_down);
5513        assert_eq!(parsed.cleanup, timing.cleanup);
5514        assert_eq!(parsed.total, timing.total);
5515    }
5516
5517    #[test]
5518    fn test_compilation_metrics_calculate_speedup() {
5519        let _guard = test_guard!();
5520        let mut metrics = CompilationMetrics {
5521            timing: CompilationTimingBreakdown {
5522                total: Duration::from_secs(10),
5523                ..Default::default()
5524            },
5525            local_build_time: Some(Duration::from_secs(30)),
5526            ..Default::default()
5527        };
5528
5529        metrics.calculate_speedup();
5530
5531        assert!(metrics.speedup.is_some());
5532        let speedup = metrics.speedup.unwrap();
5533        assert!(
5534            (speedup - 3.0).abs() < 0.01,
5535            "Expected 3.0x speedup, got {}",
5536            speedup
5537        );
5538    }
5539
5540    #[test]
5541    fn test_compilation_metrics_is_beneficial() {
5542        let _guard = test_guard!();
5543        let mut metrics = CompilationMetrics::default();
5544
5545        // No speedup calculated
5546        assert!(!metrics.is_beneficial());
5547
5548        // Speedup > 1 is beneficial
5549        metrics.speedup = Some(1.5);
5550        assert!(metrics.is_beneficial());
5551
5552        // Speedup < 1 is not beneficial
5553        metrics.speedup = Some(0.8);
5554        assert!(!metrics.is_beneficial());
5555
5556        // Speedup = 1 is not beneficial (exactly same speed)
5557        metrics.speedup = Some(1.0);
5558        assert!(!metrics.is_beneficial());
5559    }
5560
5561    #[test]
5562    fn test_compilation_metrics_serialization() {
5563        let _guard = test_guard!();
5564        let metrics = make_test_metrics(Some(2.5), 10, true);
5565        let json = serde_json::to_string(&metrics).unwrap();
5566
5567        assert!(json.contains("test-project"));
5568        assert!(json.contains("worker-1"));
5569        assert!(json.contains("2.5")); // speedup
5570
5571        let parsed: CompilationMetrics = serde_json::from_str(&json).unwrap();
5572        assert_eq!(parsed.project_id, "test-project");
5573        assert_eq!(parsed.worker_id, "worker-1");
5574        assert!(parsed.success);
5575    }
5576
5577    #[test]
5578    fn test_compilation_timer_new() {
5579        let _guard = test_guard!();
5580        let timer = CompilationTimer::new("my-project", "my-worker");
5581        assert_eq!(timer.project_id, "my-project");
5582        assert_eq!(timer.worker_id, "my-worker");
5583        assert!(timer.rsync_up.is_none());
5584        assert!(timer.remote_build.is_none());
5585        assert!(timer.rsync_down.is_none());
5586    }
5587
5588    #[test]
5589    fn test_compilation_timer_phases() {
5590        let _guard = test_guard!();
5591        let mut timer = CompilationTimer::new("test", "worker");
5592
5593        // Simulate rsync up
5594        std::thread::sleep(Duration::from_millis(5));
5595        timer.end_rsync_up();
5596        assert!(timer.rsync_up.is_some());
5597        assert!(timer.rsync_up.unwrap() >= Duration::from_millis(5));
5598
5599        // Simulate remote build
5600        std::thread::sleep(Duration::from_millis(10));
5601        timer.end_remote_build();
5602        assert!(timer.remote_build.is_some());
5603        assert!(timer.remote_build.unwrap() >= Duration::from_millis(10));
5604
5605        // Simulate rsync down
5606        std::thread::sleep(Duration::from_millis(5));
5607        timer.end_rsync_down();
5608        assert!(timer.rsync_down.is_some());
5609        assert!(timer.rsync_down.unwrap() >= Duration::from_millis(5));
5610
5611        // Finish
5612        let metrics = timer.finish(0, 50, 500_000);
5613        assert_eq!(metrics.project_id, "test");
5614        assert_eq!(metrics.worker_id, "worker");
5615        assert!(metrics.success);
5616        assert!(metrics.timing.total >= Duration::from_millis(20));
5617        assert_eq!(metrics.files_synced, 50);
5618        assert_eq!(metrics.bytes_transferred, 500_000);
5619    }
5620
5621    #[test]
5622    fn test_metrics_aggregator_new() {
5623        let _guard = test_guard!();
5624        let agg = MetricsAggregator::new(100);
5625        assert_eq!(agg.count(), 0);
5626        assert!(agg.average_speedup().is_none());
5627    }
5628
5629    #[test]
5630    fn test_metrics_aggregator_record() {
5631        let _guard = test_guard!();
5632        let mut agg = MetricsAggregator::new(3);
5633
5634        // Record first metric
5635        agg.record(make_test_metrics(Some(2.0), 10, true));
5636        assert_eq!(agg.count(), 1);
5637
5638        // Record up to max
5639        agg.record(make_test_metrics(Some(3.0), 20, true));
5640        agg.record(make_test_metrics(Some(4.0), 30, true));
5641        assert_eq!(agg.count(), 3);
5642
5643        // Exceed max should drop oldest
5644        agg.record(make_test_metrics(Some(5.0), 40, true));
5645        assert_eq!(agg.count(), 3);
5646    }
5647
5648    #[test]
5649    fn test_metrics_aggregator_average_speedup() {
5650        let _guard = test_guard!();
5651        let mut agg = MetricsAggregator::new(100);
5652
5653        // Empty case
5654        assert!(agg.average_speedup().is_none());
5655
5656        // Add metrics with speedups 1, 2, 3, 4, 5 -> average = 3
5657        for i in 1..=5 {
5658            agg.record(make_test_metrics(Some(i as f64), 10, true));
5659        }
5660
5661        let avg = agg.average_speedup().unwrap();
5662        assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5663    }
5664
5665    #[test]
5666    fn test_metrics_aggregator_average_speedup_with_none() {
5667        let _guard = test_guard!();
5668        let mut agg = MetricsAggregator::new(100);
5669
5670        // Mix of Some and None speedups
5671        agg.record(make_test_metrics(Some(2.0), 10, true));
5672        agg.record(make_test_metrics(None, 10, true)); // No speedup
5673        agg.record(make_test_metrics(Some(4.0), 10, true));
5674
5675        // Average should only consider metrics with speedup
5676        let avg = agg.average_speedup().unwrap();
5677        assert!((avg - 3.0).abs() < 0.01, "Expected 3.0, got {}", avg);
5678    }
5679
5680    #[test]
5681    fn test_metrics_aggregator_percentiles() {
5682        let _guard = test_guard!();
5683        let mut agg = MetricsAggregator::new(100);
5684
5685        // Add metrics with total times 1s, 2s, 3s, ..., 10s
5686        for i in 1..=10 {
5687            agg.record(make_test_metrics(Some(1.0), i, true));
5688        }
5689
5690        // p50 (median) should be around 5s
5691        let p50 = agg.p50_total_time().unwrap();
5692        assert!(p50 >= Duration::from_secs(5) && p50 <= Duration::from_secs(6));
5693
5694        // p95 should be around 9s or 10s
5695        let p95 = agg.p95_total_time().unwrap();
5696        assert!(p95 >= Duration::from_secs(9) && p95 <= Duration::from_secs(10));
5697
5698        // p99 should be 10s
5699        let p99 = agg.p99_total_time().unwrap();
5700        assert!(p99 >= Duration::from_secs(9));
5701    }
5702
5703    #[test]
5704    fn test_metrics_aggregator_success_rate() {
5705        let _guard = test_guard!();
5706        let mut agg = MetricsAggregator::new(100);
5707
5708        // Empty case
5709        assert_eq!(agg.success_rate(), 100.0);
5710
5711        // All successful
5712        agg.record(make_test_metrics(Some(1.0), 10, true));
5713        agg.record(make_test_metrics(Some(1.0), 10, true));
5714        assert_eq!(agg.success_rate(), 100.0);
5715
5716        // Add a failure (2 success, 1 failure = 66.67%)
5717        agg.record(make_test_metrics(Some(1.0), 10, false));
5718        let rate = agg.success_rate();
5719        assert!((rate - 66.67).abs() < 1.0, "Expected ~66.67%, got {}", rate);
5720    }
5721
5722    #[test]
5723    fn test_metrics_aggregator_clear() {
5724        let _guard = test_guard!();
5725        let mut agg = MetricsAggregator::new(100);
5726        agg.record(make_test_metrics(Some(1.0), 10, true));
5727        agg.record(make_test_metrics(Some(2.0), 20, true));
5728
5729        assert_eq!(agg.count(), 2);
5730
5731        agg.clear();
5732        assert_eq!(agg.count(), 0);
5733        assert!(agg.average_speedup().is_none());
5734    }
5735
5736    // CompilationConfig timeout tests
5737
5738    #[test]
5739    fn test_remote_build_jobs_parse_display_and_serde() {
5740        let _guard = test_guard!();
5741        assert_eq!(RemoteBuildJobs::default(), RemoteBuildJobs::Auto);
5742        for (spelling, expected) in [
5743            ("auto", RemoteBuildJobs::Auto),
5744            (" AUTO ", RemoteBuildJobs::Auto),
5745            ("off", RemoteBuildJobs::Off),
5746            ("none", RemoteBuildJobs::Off),
5747            ("4", RemoteBuildJobs::Fixed(4)),
5748        ] {
5749            assert_eq!(
5750                RemoteBuildJobs::parse(spelling).unwrap(),
5751                expected,
5752                "{spelling}"
5753            );
5754        }
5755        assert!(RemoteBuildJobs::parse("0").is_err(), "cargo rejects -j 0");
5756        assert!(RemoteBuildJobs::parse("many").is_err());
5757        assert_eq!(RemoteBuildJobs::Fixed(4).to_string(), "4");
5758        assert_eq!(RemoteBuildJobs::Off.to_string(), "off");
5759
5760        // TOML accepts both the string spellings and a bare integer, and an
5761        // absent key keeps the default.
5762        #[derive(Deserialize)]
5763        struct Wrap {
5764            compilation: CompilationConfig,
5765        }
5766        let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = \"off\"\n").unwrap();
5767        assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Off);
5768        let parsed: Wrap = toml::from_str("[compilation]\nremote_build_jobs = 6\n").unwrap();
5769        assert_eq!(
5770            parsed.compilation.remote_build_jobs,
5771            RemoteBuildJobs::Fixed(6)
5772        );
5773        let parsed: Wrap = toml::from_str("[compilation]\nbuild_slots = 4\n").unwrap();
5774        assert_eq!(parsed.compilation.remote_build_jobs, RemoteBuildJobs::Auto);
5775        assert!(toml::from_str::<Wrap>("[compilation]\nremote_build_jobs = 0\n").is_err());
5776
5777        // Serialization round-trips through the same spellings.
5778        let json = serde_json::to_string(&RemoteBuildJobs::Fixed(3)).unwrap();
5779        assert_eq!(json, "3");
5780        assert_eq!(
5781            serde_json::to_string(&RemoteBuildJobs::Auto).unwrap(),
5782            "\"auto\""
5783        );
5784        let back: RemoteBuildJobs = serde_json::from_str("\"auto\"").unwrap();
5785        assert_eq!(back, RemoteBuildJobs::Auto);
5786    }
5787
5788    #[test]
5789    fn test_compilation_config_default_timeouts() {
5790        let _guard = test_guard!();
5791        let config = CompilationConfig::default();
5792        // Default build timeout: 5 minutes
5793        assert_eq!(config.build_timeout_sec, 300);
5794        // Default test timeout: 30 minutes
5795        assert_eq!(config.test_timeout_sec, 1800);
5796    }
5797
5798    #[test]
5799    fn test_compilation_config_timeout_for_test_kinds() {
5800        let _guard = test_guard!();
5801        let config = CompilationConfig::default();
5802
5803        // Test commands should get test_timeout_sec
5804        assert_eq!(
5805            config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5806            std::time::Duration::from_secs(1800)
5807        );
5808        assert_eq!(
5809            config.timeout_for_kind(Some(crate::CompilationKind::CargoNextest)),
5810            std::time::Duration::from_secs(1800)
5811        );
5812        assert_eq!(
5813            config.timeout_for_kind(Some(crate::CompilationKind::BunTest)),
5814            std::time::Duration::from_secs(config.bun_timeout_sec)
5815        );
5816    }
5817
5818    #[test]
5819    fn test_compilation_config_timeout_for_build_kinds() {
5820        let _guard = test_guard!();
5821        let config = CompilationConfig::default();
5822
5823        // Build/check commands should get build_timeout_sec
5824        assert_eq!(
5825            config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5826            std::time::Duration::from_secs(300)
5827        );
5828        assert_eq!(
5829            config.timeout_for_kind(Some(crate::CompilationKind::CargoCheck)),
5830            std::time::Duration::from_secs(300)
5831        );
5832        assert_eq!(
5833            config.timeout_for_kind(Some(crate::CompilationKind::CargoClippy)),
5834            std::time::Duration::from_secs(300)
5835        );
5836        assert_eq!(
5837            config.timeout_for_kind(None),
5838            std::time::Duration::from_secs(300)
5839        );
5840    }
5841
5842    #[test]
5843    fn test_compilation_config_custom_timeouts() {
5844        let _guard = test_guard!();
5845        let config = CompilationConfig {
5846            build_timeout_sec: 600, // 10 minutes
5847            test_timeout_sec: 3600, // 1 hour
5848            ..Default::default()
5849        };
5850
5851        assert_eq!(
5852            config.timeout_for_kind(Some(crate::CompilationKind::CargoBuild)),
5853            std::time::Duration::from_secs(600)
5854        );
5855        assert_eq!(
5856            config.timeout_for_kind(Some(crate::CompilationKind::CargoTest)),
5857            std::time::Duration::from_secs(3600)
5858        );
5859    }
5860
5861    #[test]
5862    fn test_compilation_config_speedup_threshold_default() {
5863        let _guard = test_guard!();
5864        let config = CompilationConfig::default();
5865        // Default speedup threshold: 1.2 (20% faster required)
5866        assert!((config.remote_speedup_threshold - 1.2).abs() < 0.001);
5867    }
5868
5869    #[test]
5870    fn test_compilation_config_speedup_threshold_custom() {
5871        let _guard = test_guard!();
5872        let config = CompilationConfig {
5873            remote_speedup_threshold: 1.5, // Require 50% faster
5874            ..Default::default()
5875        };
5876        assert!((config.remote_speedup_threshold - 1.5).abs() < 0.001);
5877    }
5878
5879    #[test]
5880    fn test_compilation_config_speedup_threshold_no_minimum() {
5881        let _guard = test_guard!();
5882        // Setting to 1.0 means "always offload when other criteria met"
5883        let config = CompilationConfig {
5884            remote_speedup_threshold: 1.0,
5885            ..Default::default()
5886        };
5887        assert!((config.remote_speedup_threshold - 1.0).abs() < 0.001);
5888    }
5889
5890    #[test]
5891    fn test_compilation_config_min_local_time_ms_default() {
5892        let _guard = test_guard!();
5893        let config = CompilationConfig::default();
5894        // Default min_local_time_ms: 2000ms
5895        assert_eq!(config.min_local_time_ms, 2000);
5896    }
5897
5898    #[test]
5899    fn test_compilation_config_serde_roundtrip_speedup_threshold() {
5900        let _guard = test_guard!();
5901        let config = CompilationConfig {
5902            remote_speedup_threshold: 2.5,
5903            min_local_time_ms: 5000,
5904            ..Default::default()
5905        };
5906        let json = serde_json::to_string(&config).unwrap();
5907        let parsed: CompilationConfig = serde_json::from_str(&json).unwrap();
5908        assert!((parsed.remote_speedup_threshold - 2.5).abs() < 0.001);
5909        assert_eq!(parsed.min_local_time_ms, 5000);
5910    }
5911
5912    // ========================================================================
5913    // validate_remote_base Tests
5914    // ========================================================================
5915
5916    #[test]
5917    fn test_validate_remote_base_absolute_path() {
5918        let _guard = test_guard!();
5919        assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
5920        assert_eq!(
5921            validate_remote_base("/var/rch-builds").unwrap(),
5922            "/var/rch-builds"
5923        );
5924        assert_eq!(
5925            validate_remote_base("/home/builder/.rch").unwrap(),
5926            "/home/builder/.rch"
5927        );
5928    }
5929
5930    #[test]
5931    fn test_validate_remote_base_tilde_expansion() {
5932        let _guard = test_guard!();
5933        // Tilde expansion should work
5934        let result = validate_remote_base("~/rch");
5935        assert!(result.is_ok());
5936        let path = result.unwrap();
5937        assert!(
5938            path.starts_with('/'),
5939            "Path should be absolute after expansion: {}",
5940            path
5941        );
5942        assert!(!path.contains('~'), "Tilde should be expanded: {}", path);
5943    }
5944
5945    #[test]
5946    fn test_validate_remote_base_rejects_relative_path() {
5947        let _guard = test_guard!();
5948        let result = validate_remote_base("tmp/rch");
5949        assert!(result.is_err());
5950        assert!(result.unwrap_err().contains("absolute path"));
5951    }
5952
5953    #[test]
5954    fn test_validate_remote_base_rejects_path_traversal() {
5955        let _guard = test_guard!();
5956        let result = validate_remote_base("/tmp/../etc/rch");
5957        assert!(result.is_err());
5958        assert!(result.unwrap_err().contains("path traversal"));
5959
5960        let result = validate_remote_base("/tmp/rch/../other");
5961        assert!(result.is_err());
5962        assert!(result.unwrap_err().contains("path traversal"));
5963    }
5964
5965    #[test]
5966    fn test_validate_remote_base_normalizes_trailing_slash() {
5967        let _guard = test_guard!();
5968        assert_eq!(validate_remote_base("/tmp/rch/").unwrap(), "/tmp/rch");
5969        assert_eq!(validate_remote_base("/tmp/rch///").unwrap(), "/tmp/rch");
5970    }
5971
5972    #[test]
5973    fn test_validate_remote_base_root_path() {
5974        let _guard = test_guard!();
5975        // Root path should be rejected
5976        let result = validate_remote_base("/");
5977        assert!(result.is_err());
5978        assert!(result.unwrap_err().contains("root directory"));
5979    }
5980
5981    #[test]
5982    fn test_validate_remote_base_rejects_top_level() {
5983        let _guard = test_guard!();
5984        // Top-level directories should be rejected (depth 1)
5985        let result = validate_remote_base("/tmp");
5986        assert!(result.is_err());
5987        assert!(result.unwrap_err().contains("at least 2 levels deep"));
5988
5989        let result = validate_remote_base("/home");
5990        assert!(result.is_err());
5991
5992        // Depth 2 should be allowed
5993        assert_eq!(validate_remote_base("/tmp/rch").unwrap(), "/tmp/rch");
5994    }
5995
5996    #[test]
5997    fn test_default_remote_base() {
5998        let _guard = test_guard!();
5999        assert_eq!(default_remote_base(), "/data/tmp/rch");
6000    }
6001
6002    #[test]
6003    fn test_transfer_config_default_has_remote_base() {
6004        let _guard = test_guard!();
6005        let config = TransferConfig::default();
6006        assert_eq!(config.remote_base, "/data/tmp/rch");
6007    }
6008
6009    #[test]
6010    fn test_source_sync_timeout_validation_boundaries() {
6011        let _guard = test_guard!();
6012        assert!(!TransferConfig::valid_sync_timeout_ms(0));
6013        assert!(!TransferConfig::valid_sync_timeout_ms(999));
6014        assert!(TransferConfig::valid_sync_timeout_ms(1_000));
6015        assert!(TransferConfig::valid_sync_timeout_ms(3_600_000));
6016        assert!(!TransferConfig::valid_sync_timeout_ms(3_600_001));
6017    }
6018
6019    #[test]
6020    fn test_source_sync_timeout_is_explicit_or_payload_aware() {
6021        let _guard = test_guard!();
6022        let default = TransferConfig::default();
6023        assert_eq!(
6024            default.sync_timeout_for_payload(0),
6025            std::time::Duration::from_millis(30_000)
6026        );
6027        assert_eq!(
6028            default.sync_timeout_for_payload(10 * 1024 * 1024),
6029            std::time::Duration::from_millis(40_000)
6030        );
6031        assert_eq!(
6032            default.sync_timeout_for_payload(u64::MAX),
6033            std::time::Duration::from_millis(3_600_000)
6034        );
6035
6036        let explicit = TransferConfig {
6037            sync_timeout_ms: Some(120_000),
6038            ..TransferConfig::default()
6039        };
6040        assert_eq!(
6041            explicit.sync_timeout_for_payload(u64::MAX),
6042            std::time::Duration::from_millis(120_000)
6043        );
6044    }
6045
6046    // ========================================================================
6047    // RetryConfig Tests (bd-x1ek)
6048    // ========================================================================
6049
6050    #[test]
6051    fn test_retry_config_default() {
6052        let _guard = test_guard!();
6053        let config = RetryConfig::default();
6054        assert_eq!(config.max_attempts, 3);
6055        assert_eq!(config.base_delay_ms, 100);
6056        assert_eq!(config.max_delay_ms, 5000);
6057        assert_eq!(config.jitter_factor, 0.1);
6058        assert_eq!(config.total_timeout_ms, 30000);
6059    }
6060
6061    #[test]
6062    fn test_retry_config_no_retry() {
6063        let _guard = test_guard!();
6064        let config = RetryConfig::no_retry();
6065        assert_eq!(config.max_attempts, 1);
6066    }
6067
6068    #[test]
6069    fn test_retry_config_delay_for_attempt_zero() {
6070        let _guard = test_guard!();
6071        let config = RetryConfig::default();
6072        assert_eq!(config.delay_for_attempt(0), std::time::Duration::ZERO);
6073    }
6074
6075    #[test]
6076    fn test_retry_config_delay_for_attempt_exponential() {
6077        let _guard = test_guard!();
6078        let config = RetryConfig {
6079            base_delay_ms: 100,
6080            max_delay_ms: 10000,
6081            jitter_factor: 0.0, // No jitter for deterministic test
6082            ..Default::default()
6083        };
6084
6085        // attempt 1: 100 * 2^0 = 100ms
6086        assert_eq!(
6087            config.delay_for_attempt(1),
6088            std::time::Duration::from_millis(100)
6089        );
6090        // attempt 2: 100 * 2^1 = 200ms
6091        assert_eq!(
6092            config.delay_for_attempt(2),
6093            std::time::Duration::from_millis(200)
6094        );
6095        // attempt 3: 100 * 2^2 = 400ms
6096        assert_eq!(
6097            config.delay_for_attempt(3),
6098            std::time::Duration::from_millis(400)
6099        );
6100    }
6101
6102    #[test]
6103    fn test_retry_config_delay_capped_at_max() {
6104        let _guard = test_guard!();
6105        let config = RetryConfig {
6106            base_delay_ms: 1000,
6107            max_delay_ms: 2000,
6108            jitter_factor: 0.0,
6109            ..Default::default()
6110        };
6111
6112        // attempt 2: 1000 * 2^1 = 2000ms (at cap)
6113        assert_eq!(
6114            config.delay_for_attempt(2),
6115            std::time::Duration::from_millis(2000)
6116        );
6117        // attempt 3: 1000 * 2^2 = 4000ms, capped to 2000ms
6118        assert_eq!(
6119            config.delay_for_attempt(3),
6120            std::time::Duration::from_millis(2000)
6121        );
6122    }
6123
6124    #[test]
6125    fn test_retry_config_random_jitter_stays_within_bounds() {
6126        let _guard = test_guard!();
6127        let config = RetryConfig {
6128            base_delay_ms: 100,
6129            max_delay_ms: 200,
6130            jitter_factor: 0.25,
6131            ..Default::default()
6132        };
6133
6134        // Jitter applies after the exponential delay is capped. Exercise the
6135        // real RNG without requiring any particular random value or sequence.
6136        for (attempt, minimum_ms, maximum_ms) in [(1, 75, 125), (3, 150, 250)] {
6137            for _ in 0..128 {
6138                let delay = config.delay_for_attempt(attempt);
6139                assert!(delay >= std::time::Duration::from_millis(minimum_ms));
6140                assert!(delay <= std::time::Duration::from_millis(maximum_ms));
6141            }
6142        }
6143    }
6144
6145    #[test]
6146    fn test_retry_config_should_retry() {
6147        let _guard = test_guard!();
6148        let config = RetryConfig {
6149            max_attempts: 3,
6150            total_timeout_ms: 1000,
6151            ..Default::default()
6152        };
6153
6154        // Before max attempts and within timeout
6155        assert!(config.should_retry(0, std::time::Duration::from_millis(0)));
6156        assert!(config.should_retry(1, std::time::Duration::from_millis(500)));
6157        assert!(config.should_retry(2, std::time::Duration::from_millis(900)));
6158
6159        // At max attempts
6160        assert!(!config.should_retry(3, std::time::Duration::from_millis(0)));
6161
6162        // Past timeout
6163        assert!(!config.should_retry(1, std::time::Duration::from_millis(1001)));
6164    }
6165
6166    #[test]
6167    fn test_retry_config_serde_roundtrip() {
6168        let _guard = test_guard!();
6169        let config = RetryConfig {
6170            max_attempts: 5,
6171            base_delay_ms: 200,
6172            max_delay_ms: 8000,
6173            jitter_factor: 0.2,
6174            total_timeout_ms: 60000,
6175        };
6176        let json = serde_json::to_string(&config).unwrap();
6177        let parsed: RetryConfig = serde_json::from_str(&json).unwrap();
6178        assert_eq!(parsed.max_attempts, 5);
6179        assert_eq!(parsed.base_delay_ms, 200);
6180        assert_eq!(parsed.max_delay_ms, 8000);
6181        assert_eq!(parsed.jitter_factor, 0.2);
6182        assert_eq!(parsed.total_timeout_ms, 60000);
6183    }
6184
6185    #[test]
6186    fn test_transfer_config_includes_retry() {
6187        let _guard = test_guard!();
6188        let config = TransferConfig::default();
6189        assert_eq!(config.retry.max_attempts, 3);
6190    }
6191
6192    // =========================================================================
6193    // ExecutionConfig Tests (bd-785w)
6194    // =========================================================================
6195
6196    #[test]
6197    fn test_execution_config_default_allowlist() {
6198        let _guard = test_guard!();
6199        let config = ExecutionConfig::default();
6200        // All supported compilers should be in the default allowlist
6201        assert!(config.is_allowed("cargo"));
6202        assert!(config.is_allowed("rustc"));
6203        assert!(config.is_allowed("gcc"));
6204        assert!(config.is_allowed("g++"));
6205        assert!(config.is_allowed("clang"));
6206        assert!(config.is_allowed("clang++"));
6207        assert!(config.is_allowed("make"));
6208        assert!(config.is_allowed("cmake"));
6209        assert!(config.is_allowed("ninja"));
6210        assert!(config.is_allowed("meson"));
6211        assert!(config.is_allowed("bun"));
6212        assert!(config.is_allowed("nextest"));
6213        assert!(config.is_allowed("cc"));
6214        assert!(config.is_allowed("c++"));
6215    }
6216
6217    #[test]
6218    fn test_execution_config_is_allowed_case_insensitive() {
6219        let _guard = test_guard!();
6220        let config = ExecutionConfig::default();
6221        // Should be case-insensitive
6222        assert!(config.is_allowed("CARGO"));
6223        assert!(config.is_allowed("Cargo"));
6224        assert!(config.is_allowed("GCC"));
6225        assert!(config.is_allowed("Gcc"));
6226    }
6227
6228    #[test]
6229    fn test_execution_config_is_not_allowed() {
6230        let _guard = test_guard!();
6231        let config = ExecutionConfig::default();
6232        // Unknown commands should not be allowed
6233        assert!(!config.is_allowed("python"));
6234        assert!(!config.is_allowed("npm"));
6235        assert!(!config.is_allowed(""));
6236        // `go` and `tsc` ARE allowed now that Go/TypeScript are offload targets.
6237        // If they were missing from the allowlist the hook would fail open to
6238        // LOCAL execution — classification would look correct while every build
6239        // silently ran on the orchestrator.
6240        assert!(config.is_allowed("go"));
6241        assert!(config.is_allowed("tsc"));
6242    }
6243
6244    #[test]
6245    fn test_execution_config_empty_allowlist() {
6246        let _guard = test_guard!();
6247        let config = ExecutionConfig { allowlist: vec![] };
6248        // Empty allowlist should block everything
6249        assert!(!config.is_allowed("cargo"));
6250        assert!(!config.is_allowed("gcc"));
6251    }
6252
6253    #[test]
6254    fn test_execution_config_custom_allowlist() {
6255        let _guard = test_guard!();
6256        let config = ExecutionConfig {
6257            allowlist: vec!["cargo".to_string(), "custom_tool".to_string()],
6258        };
6259        assert!(config.is_allowed("cargo"));
6260        assert!(config.is_allowed("custom_tool"));
6261        assert!(!config.is_allowed("gcc"));
6262    }
6263
6264    #[test]
6265    fn test_execution_config_serde() {
6266        let _guard = test_guard!();
6267        let config = ExecutionConfig {
6268            allowlist: vec!["cargo".to_string(), "rustc".to_string()],
6269        };
6270        let json = serde_json::to_string(&config).unwrap();
6271        let parsed: ExecutionConfig = serde_json::from_str(&json).unwrap();
6272        assert_eq!(parsed.allowlist, config.allowlist);
6273    }
6274
6275    #[test]
6276    fn test_rch_config_includes_execution() {
6277        let _guard = test_guard!();
6278        let config = RchConfig::default();
6279        // RchConfig should include ExecutionConfig with default allowlist
6280        assert!(config.execution.is_allowed("cargo"));
6281    }
6282
6283    // ========================================================================
6284    // Adaptive Compression Tests (bd-243w)
6285    // ========================================================================
6286
6287    #[test]
6288    fn test_adaptive_compression_disabled_by_default() {
6289        let _guard = test_guard!();
6290        let config = TransferConfig::default();
6291        assert!(!config.adaptive_compression);
6292        // Should use fixed level when disabled
6293        assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6294        assert_eq!(config.select_compression_level(Some(500_000_000)), 3);
6295    }
6296
6297    #[test]
6298    fn test_adaptive_compression_small_payload() {
6299        let _guard = test_guard!();
6300        let config = TransferConfig {
6301            adaptive_compression: true,
6302            ..Default::default()
6303        };
6304        // < 10MB should use level 1
6305        assert_eq!(config.select_compression_level(Some(0)), 1);
6306        assert_eq!(config.select_compression_level(Some(1_000_000)), 1); // 1MB
6307        assert_eq!(config.select_compression_level(Some(9_999_999)), 1); // ~10MB
6308    }
6309
6310    #[test]
6311    fn test_adaptive_compression_medium_payload() {
6312        let _guard = test_guard!();
6313        let config = TransferConfig {
6314            adaptive_compression: true,
6315            ..Default::default()
6316        };
6317        // 10-200MB should use level 3
6318        assert_eq!(config.select_compression_level(Some(10_000_000)), 3); // 10MB
6319        assert_eq!(config.select_compression_level(Some(100_000_000)), 3); // 100MB
6320        assert_eq!(config.select_compression_level(Some(199_999_999)), 3); // ~200MB
6321    }
6322
6323    #[test]
6324    fn test_adaptive_compression_large_payload() {
6325        let _guard = test_guard!();
6326        let config = TransferConfig {
6327            adaptive_compression: true,
6328            ..Default::default()
6329        };
6330        // > 200MB should use level 7
6331        assert_eq!(config.select_compression_level(Some(200_000_000)), 7); // 200MB
6332        assert_eq!(config.select_compression_level(Some(500_000_000)), 7); // 500MB
6333        assert_eq!(config.select_compression_level(Some(1_000_000_000)), 7); // 1GB
6334    }
6335
6336    #[test]
6337    fn test_adaptive_compression_no_estimate() {
6338        let _guard = test_guard!();
6339        let config = TransferConfig {
6340            adaptive_compression: true,
6341            compression_level: 5,
6342            ..Default::default()
6343        };
6344        // No estimate should fallback to default level
6345        assert_eq!(config.select_compression_level(None), 5);
6346    }
6347
6348    #[test]
6349    fn test_adaptive_compression_respects_min_level() {
6350        let _guard = test_guard!();
6351        let config = TransferConfig {
6352            adaptive_compression: true,
6353            min_compression_level: 3,
6354            ..Default::default()
6355        };
6356        // Small payload would want level 1, but min is 3
6357        assert_eq!(config.select_compression_level(Some(1_000_000)), 3);
6358    }
6359
6360    #[test]
6361    fn test_adaptive_compression_respects_max_level() {
6362        let _guard = test_guard!();
6363        let config = TransferConfig {
6364            adaptive_compression: true,
6365            max_compression_level: 5,
6366            ..Default::default()
6367        };
6368        // Large payload would want level 7, but max is 5
6369        assert_eq!(config.select_compression_level(Some(500_000_000)), 5);
6370    }
6371
6372    #[test]
6373    fn test_adaptive_compression_serde_roundtrip() {
6374        let _guard = test_guard!();
6375        let config = TransferConfig {
6376            adaptive_compression: true,
6377            min_compression_level: 2,
6378            max_compression_level: 8,
6379            ..Default::default()
6380        };
6381        let json = serde_json::to_string(&config).unwrap();
6382        let parsed: TransferConfig = serde_json::from_str(&json).unwrap();
6383        assert!(parsed.adaptive_compression);
6384        assert_eq!(parsed.min_compression_level, 2);
6385        assert_eq!(parsed.max_compression_level, 8);
6386    }
6387
6388    // -------------------------------------------------------------------------
6389    // Self-Healing Config Tests (bd-59kg)
6390    // -------------------------------------------------------------------------
6391
6392    #[test]
6393    fn test_self_healing_config_defaults() {
6394        let _guard = test_guard!();
6395        // TEST START: SelfHealingConfig::default() returns expected values
6396        let config = SelfHealingConfig::default();
6397
6398        // Both self-healing behaviors enabled by default
6399        assert!(
6400            config.hook_starts_daemon,
6401            "hook_starts_daemon should be true by default"
6402        );
6403        assert!(
6404            config.daemon_installs_hooks,
6405            "daemon_installs_hooks should be true by default"
6406        );
6407
6408        // Timing defaults
6409        assert_eq!(
6410            config.auto_start_cooldown_secs, 30,
6411            "auto_start_cooldown_secs should default to 30"
6412        );
6413        assert_eq!(
6414            config.auto_start_timeout_secs, 3,
6415            "auto_start_timeout_secs should default to 3"
6416        );
6417        // TEST PASS: SelfHealingConfig defaults
6418    }
6419
6420    #[test]
6421    fn test_self_healing_config_serde_full() {
6422        let _guard = test_guard!();
6423        // TEST START: Full SelfHealingConfig serialization/deserialization
6424        let config = SelfHealingConfig {
6425            hook_starts_daemon: false,
6426            daemon_installs_hooks: false,
6427            auto_start_cooldown_secs: 60,
6428            auto_start_timeout_secs: 10,
6429            self_healing_log_level: SelfHealingLogLevel::Debug,
6430        };
6431
6432        let json = serde_json::to_string(&config).unwrap();
6433        assert!(json.contains("\"hook_starts_daemon\":false"));
6434        assert!(json.contains("\"daemon_installs_hooks\":false"));
6435        assert!(json.contains("\"auto_start_cooldown_secs\":60"));
6436        assert!(json.contains("\"auto_start_timeout_secs\":10"));
6437        assert!(json.contains("\"self_healing_log_level\":\"debug\""));
6438
6439        let parsed: SelfHealingConfig = serde_json::from_str(&json).unwrap();
6440        assert!(!parsed.hook_starts_daemon);
6441        assert!(!parsed.daemon_installs_hooks);
6442        assert_eq!(parsed.auto_start_cooldown_secs, 60);
6443        assert_eq!(parsed.auto_start_timeout_secs, 10);
6444        assert_eq!(parsed.self_healing_log_level, SelfHealingLogLevel::Debug);
6445        // TEST PASS: Full SelfHealingConfig serde
6446    }
6447
6448    // br-4zf3p: Tests for the self_healing_log_level field.
6449
6450    #[test]
6451    fn test_self_healing_log_level_default_is_info() {
6452        // TEST START: SelfHealingLogLevel::default() == Info
6453        let level = SelfHealingLogLevel::default();
6454        assert_eq!(level, SelfHealingLogLevel::Info);
6455        // Also verify the SelfHealingConfig default surfaces it.
6456        let config = SelfHealingConfig::default();
6457        assert_eq!(
6458            config.self_healing_log_level,
6459            SelfHealingLogLevel::Info,
6460            "config default should be Info"
6461        );
6462        // TEST PASS
6463    }
6464
6465    #[test]
6466    fn test_self_healing_log_level_serializes_lowercase() {
6467        // TEST START: serde uses lowercase variants
6468        let json = serde_json::to_string(&SelfHealingLogLevel::Warn).unwrap();
6469        assert_eq!(json, "\"warn\"");
6470        let parsed: SelfHealingLogLevel = serde_json::from_str("\"error\"").unwrap();
6471        assert_eq!(parsed, SelfHealingLogLevel::Error);
6472        // TEST PASS
6473    }
6474
6475    #[test]
6476    fn test_self_healing_log_level_from_env_str_accepts_known() {
6477        // TEST START: from_env_str parses each level (case-insensitive) and aliases
6478        assert_eq!(
6479            SelfHealingLogLevel::from_env_str("debug"),
6480            Some(SelfHealingLogLevel::Debug)
6481        );
6482        assert_eq!(
6483            SelfHealingLogLevel::from_env_str("DEBUG"),
6484            Some(SelfHealingLogLevel::Debug)
6485        );
6486        assert_eq!(
6487            SelfHealingLogLevel::from_env_str("info"),
6488            Some(SelfHealingLogLevel::Info)
6489        );
6490        assert_eq!(
6491            SelfHealingLogLevel::from_env_str("warn"),
6492            Some(SelfHealingLogLevel::Warn)
6493        );
6494        assert_eq!(
6495            SelfHealingLogLevel::from_env_str("warning"),
6496            Some(SelfHealingLogLevel::Warn),
6497            "warning should map to Warn"
6498        );
6499        assert_eq!(
6500            SelfHealingLogLevel::from_env_str("error"),
6501            Some(SelfHealingLogLevel::Error)
6502        );
6503        // Whitespace tolerance
6504        assert_eq!(
6505            SelfHealingLogLevel::from_env_str("  debug  "),
6506            Some(SelfHealingLogLevel::Debug)
6507        );
6508        // TEST PASS
6509    }
6510
6511    #[test]
6512    fn test_self_healing_log_level_from_env_str_rejects_unknown() {
6513        // TEST START: unknown values return None (caller falls back to default)
6514        assert!(SelfHealingLogLevel::from_env_str("banana").is_none());
6515        assert!(SelfHealingLogLevel::from_env_str("").is_none());
6516        assert!(SelfHealingLogLevel::from_env_str("trace").is_none());
6517        // TEST PASS
6518    }
6519
6520    #[test]
6521    fn test_self_healing_config_serde_partial_uses_defaults() {
6522        let _guard = test_guard!();
6523        // TEST START: Partial TOML/JSON uses defaults for missing fields
6524        let json = r#"{"hook_starts_daemon": false}"#;
6525        let config: SelfHealingConfig = serde_json::from_str(json).unwrap();
6526
6527        assert!(
6528            !config.hook_starts_daemon,
6529            "Explicit false should be parsed"
6530        );
6531        assert!(
6532            config.daemon_installs_hooks,
6533            "Missing field should use default (true)"
6534        );
6535        assert_eq!(
6536            config.auto_start_cooldown_secs, 30,
6537            "Missing field should use default (30)"
6538        );
6539        assert_eq!(
6540            config.auto_start_timeout_secs, 3,
6541            "Missing field should use default (3)"
6542        );
6543        // TEST PASS: Partial SelfHealingConfig uses defaults
6544    }
6545
6546    #[test]
6547    fn test_self_healing_config_toml_with_alias() {
6548        let _guard = test_guard!();
6549        // TEST START: daemon_start_timeout alias works for auto_start_timeout_secs
6550        let toml_str = r#"
6551            hook_starts_daemon = true
6552            daemon_installs_hooks = false
6553            auto_start_cooldown_secs = 45
6554            daemon_start_timeout = 7
6555        "#;
6556
6557        let config: SelfHealingConfig = toml::from_str(toml_str).unwrap();
6558        assert!(config.hook_starts_daemon);
6559        assert!(!config.daemon_installs_hooks);
6560        assert_eq!(config.auto_start_cooldown_secs, 45);
6561        assert_eq!(
6562            config.auto_start_timeout_secs, 7,
6563            "daemon_start_timeout alias should set auto_start_timeout_secs"
6564        );
6565        // TEST PASS: TOML alias works
6566    }
6567
6568    #[allow(unsafe_code)]
6569    mod self_healing_env_override_tests {
6570        use super::*;
6571        use crate::config::env_test_lock;
6572
6573        fn env_guard() -> std::sync::MutexGuard<'static, ()> {
6574            env_test_lock()
6575        }
6576
6577        fn set_env(key: &str, value: &str) {
6578            // SAFETY: Tests are serialized with env_guard().
6579            unsafe { std::env::set_var(key, value) };
6580        }
6581
6582        fn remove_env(key: &str) {
6583            // SAFETY: Tests are serialized with env_guard().
6584            unsafe { std::env::remove_var(key) };
6585        }
6586
6587        struct EnvVarGuard {
6588            key: &'static str,
6589            old: Option<String>,
6590        }
6591
6592        impl EnvVarGuard {
6593            fn set(key: &'static str, value: &str) -> Self {
6594                let old = std::env::var(key).ok();
6595                set_env(key, value);
6596                Self { key, old }
6597            }
6598        }
6599
6600        impl Drop for EnvVarGuard {
6601            fn drop(&mut self) {
6602                if let Some(old) = &self.old {
6603                    set_env(self.key, old);
6604                } else {
6605                    remove_env(self.key);
6606                }
6607            }
6608        }
6609
6610        #[test]
6611        fn test_self_healing_config_with_env_overrides_master_disable() {
6612            let _guard = test_guard!();
6613            let _guard = env_guard();
6614
6615            let _no_self_healing = EnvVarGuard::set("RCH_NO_SELF_HEALING", "1");
6616            let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "1");
6617            let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "1");
6618            let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "99");
6619            let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "99");
6620
6621            let config = SelfHealingConfig::default().with_env_overrides();
6622            assert!(
6623                !config.hook_starts_daemon,
6624                "RCH_NO_SELF_HEALING should disable hook auto-start"
6625            );
6626            assert!(
6627                !config.daemon_installs_hooks,
6628                "RCH_NO_SELF_HEALING should disable daemon hook installation"
6629            );
6630
6631            // Master disable returns early; numeric overrides should not apply.
6632            assert_eq!(config.auto_start_cooldown_secs, 30);
6633            assert_eq!(config.auto_start_timeout_secs, 3);
6634        }
6635
6636        #[test]
6637        fn test_self_healing_config_with_env_overrides_toggles_and_numbers() {
6638            let _guard = test_guard!();
6639            let _guard = env_guard();
6640
6641            let _hook_starts_daemon = EnvVarGuard::set("RCH_HOOK_STARTS_DAEMON", "0");
6642            let _daemon_installs_hooks = EnvVarGuard::set("RCH_DAEMON_INSTALLS_HOOKS", "false");
6643            let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "45");
6644            let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "7");
6645
6646            let config = SelfHealingConfig::default().with_env_overrides();
6647            assert!(!config.hook_starts_daemon);
6648            assert!(!config.daemon_installs_hooks);
6649            assert_eq!(config.auto_start_cooldown_secs, 45);
6650            assert_eq!(config.auto_start_timeout_secs, 7);
6651        }
6652
6653        #[test]
6654        fn test_self_healing_config_with_env_overrides_invalid_numbers_ignored() {
6655            let _guard = test_guard!();
6656            let _guard = env_guard();
6657
6658            let _cooldown = EnvVarGuard::set("RCH_AUTO_START_COOLDOWN_SECS", "not-a-number");
6659            let _timeout = EnvVarGuard::set("RCH_AUTO_START_TIMEOUT_SECS", "nope");
6660
6661            let config = SelfHealingConfig::default().with_env_overrides();
6662            assert_eq!(config.auto_start_cooldown_secs, 30);
6663            assert_eq!(config.auto_start_timeout_secs, 3);
6664        }
6665    }
6666
6667    // -------------------------------------------------------------------------
6668    // Preflight Health Guard Tests (bd-3eaa)
6669    // -------------------------------------------------------------------------
6670
6671    #[test]
6672    fn test_load_per_core_calculation() {
6673        let _guard = test_guard!();
6674        let mut caps = WorkerCapabilities::new();
6675        // No metrics -> None
6676        assert!(caps.load_per_core().is_none());
6677
6678        // Only load, no cpus -> None
6679        caps.load_avg_1 = Some(4.0);
6680        assert!(caps.load_per_core().is_none());
6681
6682        // Both available
6683        caps.num_cpus = Some(4);
6684        assert_eq!(caps.load_per_core(), Some(1.0)); // 4.0 / 4 = 1.0
6685
6686        // High load
6687        caps.load_avg_1 = Some(16.0);
6688        assert_eq!(caps.load_per_core(), Some(4.0)); // 16.0 / 4 = 4.0
6689    }
6690
6691    #[test]
6692    fn test_is_high_load() {
6693        let _guard = test_guard!();
6694        let mut caps = WorkerCapabilities::new();
6695        caps.load_avg_1 = Some(8.0);
6696        caps.num_cpus = Some(4);
6697        // load_per_core = 2.0
6698
6699        // Below threshold
6700        assert_eq!(caps.is_high_load(3.0), Some(false));
6701        // At threshold
6702        assert_eq!(caps.is_high_load(2.0), Some(false)); // equal is OK
6703        // Above threshold
6704        assert_eq!(caps.is_high_load(1.5), Some(true));
6705
6706        // No metrics -> None (fail-open)
6707        caps.load_avg_1 = None;
6708        assert!(caps.is_high_load(1.0).is_none());
6709    }
6710
6711    #[test]
6712    fn test_is_low_disk() {
6713        let _guard = test_guard!();
6714        let mut caps = WorkerCapabilities::new();
6715        caps.disk_free_gb = Some(15.0);
6716
6717        // Above threshold
6718        assert_eq!(caps.is_low_disk(10.0), Some(false));
6719        // At threshold
6720        assert_eq!(caps.is_low_disk(15.0), Some(false)); // equal is OK
6721        // Below threshold
6722        assert_eq!(caps.is_low_disk(20.0), Some(true));
6723
6724        // No metrics -> None (fail-open)
6725        caps.disk_free_gb = None;
6726        assert!(caps.is_low_disk(10.0).is_none());
6727    }
6728
6729    #[test]
6730    fn low_disk_judges_the_build_disk_not_a_small_tmpfs() {
6731        let _guard = test_guard!();
6732        // GH #78: a 16 GB tmpfs /tmp is the tightest mount by ratio, but the
6733        // build trees live on a roomy data disk.
6734        let mut caps = WorkerCapabilities::new();
6735        caps.disk_free_gb = Some(8.0);
6736        caps.disk_total_gb = Some(15.5);
6737        caps.build_disk_free_gb = Some(1700.0);
6738        caps.build_disk_total_gb = Some(1900.0);
6739        assert_eq!(caps.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6740        assert_eq!(caps.is_low_disk(10.0), Some(false));
6741
6742        // A full build disk is low even when /tmp is roomy.
6743        caps.build_disk_free_gb = Some(4.0);
6744        assert_eq!(caps.is_low_disk(10.0), Some(true));
6745
6746        // An older rch-wkr without the build sample keeps the old behaviour.
6747        caps.build_disk_free_gb = None;
6748        caps.build_disk_total_gb = None;
6749        assert_eq!(caps.build_disk_gb(), (Some(8.0), Some(15.5)));
6750        assert_eq!(caps.is_low_disk(10.0), Some(true));
6751    }
6752
6753    #[test]
6754    fn build_disk_sample_is_optional_on_the_wire() {
6755        let _guard = test_guard!();
6756        let old: WorkerCapabilities =
6757            serde_json::from_str(r#"{"disk_free_gb":13.9,"disk_total_gb":15.5}"#).unwrap();
6758        assert_eq!(old.build_disk_free_gb, None);
6759        assert_eq!(old.build_disk_gb(), (Some(13.9), Some(15.5)));
6760
6761        let new: WorkerCapabilities = serde_json::from_str(
6762            r#"{"disk_free_gb":13.9,"disk_total_gb":15.5,"build_disk_free_gb":1700.0,"build_disk_total_gb":1900.0}"#,
6763        )
6764        .unwrap();
6765        assert_eq!(new.build_disk_gb(), (Some(1700.0), Some(1900.0)));
6766        let json = serde_json::to_string(&new).unwrap();
6767        assert!(json.contains("\"build_disk_free_gb\":1700.0"), "{json}");
6768    }
6769
6770    #[test]
6771    fn test_selection_config_preflight_defaults() {
6772        let _guard = test_guard!();
6773        let config = SelectionConfig::default();
6774        // Default thresholds
6775        assert_eq!(config.max_load_per_core, Some(2.0));
6776        assert_eq!(config.min_free_gb, Some(10.0));
6777        assert_eq!(config.disk_gb_per_slot, 10.0);
6778    }
6779
6780    #[test]
6781    fn test_selection_config_preflight_serde() {
6782        let _guard = test_guard!();
6783        let config = SelectionConfig {
6784            max_load_per_core: Some(3.5),
6785            min_free_gb: Some(25.0),
6786            disk_gb_per_slot: 0.83,
6787            ..Default::default()
6788        };
6789        let json = serde_json::to_string(&config).unwrap();
6790        let parsed: SelectionConfig = serde_json::from_str(&json).unwrap();
6791        assert_eq!(parsed.max_load_per_core, Some(3.5));
6792        assert_eq!(parsed.min_free_gb, Some(25.0));
6793        assert_eq!(parsed.disk_gb_per_slot, 0.83);
6794    }
6795
6796    #[test]
6797    fn test_selection_disk_slot_budget_validation() {
6798        let _guard = test_guard!();
6799        for value in ["0.0", "-1.0", "nan", "inf", "-inf"] {
6800            let result = toml::from_str::<SelectionConfig>(&format!("disk_gb_per_slot = {value}"));
6801            assert!(result.is_err(), "invalid disk slot budget {value}");
6802        }
6803        let default: SelectionConfig = toml::from_str("").unwrap();
6804        assert_eq!(default.disk_gb_per_slot, 10.0);
6805    }
6806
6807    #[test]
6808    fn test_selection_disk_weight_validation_and_roundtrip() {
6809        for value in ["-0.1", "1.1", "nan", "inf", "-inf"] {
6810            assert!(toml::from_str::<SelectionWeightConfig>(&format!("disk = {value}")).is_err());
6811        }
6812        for value in [0.0, 0.2, 1.0] {
6813            let weights: SelectionWeightConfig =
6814                toml::from_str(&format!("disk = {value}")).unwrap();
6815            assert_eq!(weights.disk, value);
6816            let roundtrip: SelectionWeightConfig =
6817                serde_json::from_str(&serde_json::to_string(&weights).unwrap()).unwrap();
6818            assert_eq!(roundtrip.disk, value);
6819        }
6820        let defaults: SelectionWeightConfig = toml::from_str("").unwrap();
6821        assert_eq!(defaults.disk, 0.2);
6822    }
6823
6824    #[test]
6825    fn test_worker_capabilities_health_metrics_serde() {
6826        let _guard = test_guard!();
6827        let caps = WorkerCapabilities {
6828            num_cpus: Some(8),
6829            load_avg_1: Some(1.5),
6830            load_avg_5: Some(2.0),
6831            load_avg_15: Some(1.8),
6832            disk_free_gb: Some(50.5),
6833            disk_total_gb: Some(100.0),
6834            ..Default::default()
6835        };
6836        let json = serde_json::to_string(&caps).unwrap();
6837        let parsed: WorkerCapabilities = serde_json::from_str(&json).unwrap();
6838        assert_eq!(parsed.num_cpus, Some(8));
6839        assert_eq!(parsed.load_avg_1, Some(1.5));
6840        assert_eq!(parsed.disk_free_gb, Some(50.5));
6841    }
6842
6843    #[test]
6844    fn microarch_level_classifies_ivy_bridge_as_v2() {
6845        // Abbreviated real flag set from ovh-b (Xeon E3-1245 V2, Ivy Bridge):
6846        // AVX present, but no avx2/bmi/fma — the bd-68hon SIGILL class.
6847        let ivy = "fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov \
6848                   pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx rdtscp lm \
6849                   constant_tsc pni pclmulqdq ssse3 cx16 sse4_1 sse4_2 x2apic \
6850                   popcnt tsc_deadline_timer aes xsave avx f16c rdrand lahf_lm";
6851        assert_eq!(x86_64_microarch_level_from_flags(ivy), 2);
6852    }
6853
6854    #[test]
6855    fn microarch_level_classifies_v3_and_v4() {
6856        let v3 = "cx16 lahf_lm popcnt sse4_1 sse4_2 ssse3 abm avx avx2 bmi1 bmi2 \
6857                  f16c fma movbe xsave";
6858        assert_eq!(x86_64_microarch_level_from_flags(v3), 3);
6859
6860        let v4 = format!("{v3} avx512f avx512bw avx512cd avx512dq avx512vl");
6861        assert_eq!(x86_64_microarch_level_from_flags(&v4), 4);
6862    }
6863
6864    #[test]
6865    fn microarch_level_v1_for_ancient_or_empty_flags() {
6866        assert_eq!(x86_64_microarch_level_from_flags(""), 1);
6867        assert_eq!(x86_64_microarch_level_from_flags("fpu mmx sse sse2"), 1);
6868    }
6869
6870    #[test]
6871    fn pre_v3_predicate_only_fires_on_affirmative_evidence() {
6872        let mut caps = WorkerCapabilities::new();
6873        assert!(!caps.is_pre_v3_x86(), "unknown must not deprioritize");
6874        caps.cpu_microarch_level = Some(2);
6875        assert!(caps.is_pre_v3_x86());
6876        caps.cpu_microarch_level = Some(3);
6877        assert!(!caps.is_pre_v3_x86());
6878    }
6879}