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