Skip to main content

clawft_kernel/
health.rs

1//! Health monitoring subsystem.
2//!
3//! The [`HealthSystem`] aggregates health checks from all registered
4//! services and produces an overall [`OverallHealth`] status.
5
6use std::sync::Arc;
7
8use serde::{Deserialize, Serialize};
9use tracing::{debug, warn};
10
11use crate::service::ServiceRegistry;
12
13/// Health status for a single service.
14#[non_exhaustive]
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub enum HealthStatus {
17    /// Service is operating normally.
18    Healthy,
19    /// Service is operational but degraded.
20    Degraded(String),
21    /// Service is not operational.
22    Unhealthy(String),
23    /// Health status could not be determined (e.g. timeout).
24    Unknown,
25}
26
27impl std::fmt::Display for HealthStatus {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            HealthStatus::Healthy => write!(f, "healthy"),
31            HealthStatus::Degraded(msg) => write!(f, "degraded: {msg}"),
32            HealthStatus::Unhealthy(msg) => write!(f, "unhealthy: {msg}"),
33            HealthStatus::Unknown => write!(f, "unknown"),
34        }
35    }
36}
37
38/// Aggregated health status for the entire kernel.
39#[non_exhaustive]
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub enum OverallHealth {
42    /// All services are healthy.
43    Healthy,
44    /// Some services are degraded or unhealthy.
45    Degraded {
46        /// Services that are not fully healthy.
47        unhealthy_services: Vec<String>,
48    },
49    /// All services are unhealthy or no services registered.
50    Down,
51}
52
53impl std::fmt::Display for OverallHealth {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            OverallHealth::Healthy => write!(f, "healthy"),
57            OverallHealth::Degraded { unhealthy_services } => {
58                write!(f, "degraded ({})", unhealthy_services.join(", "))
59            }
60            OverallHealth::Down => write!(f, "down"),
61        }
62    }
63}
64
65/// Health monitoring system.
66///
67/// Periodically checks all registered services and aggregates their
68/// health into an overall status.
69pub struct HealthSystem {
70    check_interval_secs: u64,
71}
72
73impl HealthSystem {
74    /// Create a new health system with the given check interval.
75    pub fn new(check_interval_secs: u64) -> Self {
76        Self {
77            check_interval_secs,
78        }
79    }
80
81    /// Get the configured check interval in seconds.
82    pub fn check_interval_secs(&self) -> u64 {
83        self.check_interval_secs
84    }
85
86    /// Run a single health check cycle against all services.
87    pub async fn aggregate(
88        &self,
89        registry: &Arc<ServiceRegistry>,
90    ) -> (OverallHealth, Vec<(String, HealthStatus)>) {
91        let results = registry.health_all().await;
92
93        if results.is_empty() {
94            return (OverallHealth::Down, results);
95        }
96
97        let mut unhealthy = Vec::new();
98        let mut all_unhealthy = true;
99
100        for (name, status) in &results {
101            match status {
102                HealthStatus::Healthy => {
103                    debug!(service = %name, "health check: healthy");
104                    all_unhealthy = false;
105                }
106                HealthStatus::Degraded(msg) => {
107                    warn!(service = %name, reason = %msg, "health check: degraded");
108                    unhealthy.push(name.clone());
109                    all_unhealthy = false;
110                }
111                HealthStatus::Unhealthy(msg) => {
112                    warn!(service = %name, reason = %msg, "health check: unhealthy");
113                    unhealthy.push(name.clone());
114                }
115                HealthStatus::Unknown => {
116                    warn!(service = %name, "health check: unknown");
117                    unhealthy.push(name.clone());
118                }
119            }
120        }
121
122        let overall = if unhealthy.is_empty() {
123            OverallHealth::Healthy
124        } else if all_unhealthy {
125            OverallHealth::Down
126        } else {
127            OverallHealth::Degraded {
128                unhealthy_services: unhealthy,
129            }
130        };
131
132        (overall, results)
133    }
134}
135
136// ── K2b-G2: Liveness and readiness probes (os-patterns) ─────────
137
138/// Result of a liveness or readiness probe.
139#[non_exhaustive]
140#[cfg(feature = "os-patterns")]
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub enum ProbeResult {
143    /// Service is live (responding to probes).
144    Live,
145    /// Service is not live.
146    NotLive { reason: String },
147    /// Service is ready to accept traffic.
148    Ready,
149    /// Service is not ready.
150    NotReady { reason: String },
151}
152
153/// Configuration for liveness and readiness probes.
154#[cfg(feature = "os-patterns")]
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ProbeConfig {
157    /// How often to check liveness (default: 10s).
158    pub liveness_interval_secs: u64,
159    /// How often to check readiness (default: 5s).
160    pub readiness_interval_secs: u64,
161    /// Number of consecutive failures before marking as failed.
162    pub failure_threshold: u32,
163    /// Number of consecutive successes before marking as recovered.
164    pub success_threshold: u32,
165}
166
167#[cfg(feature = "os-patterns")]
168impl Default for ProbeConfig {
169    fn default() -> Self {
170        Self {
171            liveness_interval_secs: 10,
172            readiness_interval_secs: 5,
173            failure_threshold: 3,
174            success_threshold: 1,
175        }
176    }
177}
178
179#[cfg(feature = "os-patterns")]
180impl ProbeConfig {
181    /// Derive a [`ProbeConfig`] using an optional learned
182    /// [`HealthThresholdModel`](crate::eml_kernel::HealthThresholdModel).
183    ///
184    /// The model's two output heads `(degraded, failed)` map onto
185    /// `(success_threshold, failure_threshold)` respectively: the
186    /// `failed` head sets the number of consecutive probe failures
187    /// before the service is marked failed, and the `degraded` head
188    /// is treated as the number of consecutive successes required to
189    /// mark recovery (a higher value makes recovery more conservative).
190    ///
191    /// When `model` is `None` or untrained, returns
192    /// [`Self::default`] — i.e. `failure_threshold=3`,
193    /// `success_threshold=1` — exactly reproducing today's behaviour.
194    ///
195    /// `service_type`, `history_depth`, and `recent_latency_ms` feed
196    /// the EML model when it is trained; callers without those
197    /// signals can pass `(0, 0, 0.0)`.
198    ///
199    /// NOTE(eml-swap): wired — Finding #5 (HealthThresholdModel).
200    pub fn from_model(
201        model: Option<&crate::eml_kernel::HealthThresholdModel>,
202        service_type: u32,
203        history_depth: u32,
204        recent_latency_ms: f64,
205    ) -> Self {
206        match model {
207            Some(m) if m.is_trained() => {
208                let (degraded, failed) = m.predict(service_type, history_depth, recent_latency_ms);
209                Self {
210                    failure_threshold: failed,
211                    success_threshold: degraded.max(1),
212                    ..Self::default()
213                }
214            }
215            _ => Self::default(),
216        }
217    }
218}
219
220/// Tracks consecutive probe results for threshold-based decisions.
221#[cfg(feature = "os-patterns")]
222#[derive(Debug, Clone)]
223pub struct ProbeState {
224    /// Consecutive liveness failures.
225    pub liveness_failures: u32,
226    /// Consecutive readiness failures.
227    pub readiness_failures: u32,
228    /// Consecutive readiness successes (for recovery).
229    pub readiness_successes: u32,
230    /// Whether the service is currently considered live.
231    pub is_live: bool,
232    /// Whether the service is currently considered ready.
233    pub is_ready: bool,
234}
235
236#[cfg(feature = "os-patterns")]
237impl Default for ProbeState {
238    fn default() -> Self {
239        Self {
240            liveness_failures: 0,
241            readiness_failures: 0,
242            readiness_successes: 0,
243            is_live: true,
244            is_ready: true,
245        }
246    }
247}
248
249#[cfg(feature = "os-patterns")]
250impl ProbeState {
251    /// Record a liveness probe result.
252    ///
253    /// Returns `true` if the service should be restarted (failures >= threshold).
254    pub fn record_liveness(&mut self, result: &ProbeResult, config: &ProbeConfig) -> bool {
255        match result {
256            ProbeResult::Live => {
257                self.liveness_failures = 0;
258                self.is_live = true;
259                false
260            }
261            ProbeResult::NotLive { .. } => {
262                self.liveness_failures += 1;
263                if self.liveness_failures >= config.failure_threshold {
264                    self.is_live = false;
265                    true
266                } else {
267                    false
268                }
269            }
270            _ => false, // readiness results ignored here
271        }
272    }
273
274    /// Record a readiness probe result.
275    ///
276    /// Returns the readiness state change:
277    /// - `Some(false)` if service should be removed from registry
278    /// - `Some(true)` if service should be re-added (recovered)
279    /// - `None` if no state change
280    pub fn record_readiness(&mut self, result: &ProbeResult, config: &ProbeConfig) -> Option<bool> {
281        match result {
282            ProbeResult::Ready => {
283                self.readiness_failures = 0;
284                self.readiness_successes += 1;
285                if !self.is_ready && self.readiness_successes >= config.success_threshold {
286                    self.is_ready = true;
287                    Some(true) // recovered
288                } else {
289                    None
290                }
291            }
292            ProbeResult::NotReady { .. } => {
293                self.readiness_successes = 0;
294                self.readiness_failures += 1;
295                if self.is_ready && self.readiness_failures >= config.failure_threshold {
296                    self.is_ready = false;
297                    Some(false) // became unready
298                } else {
299                    None
300                }
301            }
302            _ => None, // liveness results ignored here
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::service::{ServiceType, SystemService};
311    use async_trait::async_trait;
312
313    struct HealthyService;
314
315    #[async_trait]
316    impl SystemService for HealthyService {
317        fn name(&self) -> &str {
318            "healthy-svc"
319        }
320        fn service_type(&self) -> ServiceType {
321            ServiceType::Core
322        }
323        async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
324            Ok(())
325        }
326        async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
327            Ok(())
328        }
329        async fn health_check(&self) -> HealthStatus {
330            HealthStatus::Healthy
331        }
332    }
333
334    struct UnhealthyService;
335
336    #[async_trait]
337    impl SystemService for UnhealthyService {
338        fn name(&self) -> &str {
339            "unhealthy-svc"
340        }
341        fn service_type(&self) -> ServiceType {
342            ServiceType::Core
343        }
344        async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
345            Ok(())
346        }
347        async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
348            Ok(())
349        }
350        async fn health_check(&self) -> HealthStatus {
351            HealthStatus::Unhealthy("test failure".into())
352        }
353    }
354
355    #[tokio::test]
356    async fn aggregate_all_healthy() {
357        let registry = Arc::new(ServiceRegistry::new());
358        registry.register(Arc::new(HealthyService)).unwrap();
359
360        let health = HealthSystem::new(30);
361        let (overall, results) = health.aggregate(&registry).await;
362
363        assert!(matches!(overall, OverallHealth::Healthy));
364        assert_eq!(results.len(), 1);
365    }
366
367    #[tokio::test]
368    async fn aggregate_mixed() {
369        let registry = Arc::new(ServiceRegistry::new());
370        registry.register(Arc::new(HealthyService)).unwrap();
371        registry.register(Arc::new(UnhealthyService)).unwrap();
372
373        let health = HealthSystem::new(30);
374        let (overall, results) = health.aggregate(&registry).await;
375
376        assert!(matches!(overall, OverallHealth::Degraded { .. }));
377        assert_eq!(results.len(), 2);
378    }
379
380    #[tokio::test]
381    async fn aggregate_all_unhealthy() {
382        let registry = Arc::new(ServiceRegistry::new());
383        registry.register(Arc::new(UnhealthyService)).unwrap();
384
385        let health = HealthSystem::new(30);
386        let (overall, _) = health.aggregate(&registry).await;
387
388        assert!(matches!(overall, OverallHealth::Down));
389    }
390
391    #[tokio::test]
392    async fn aggregate_empty_registry() {
393        let registry = Arc::new(ServiceRegistry::new());
394        let health = HealthSystem::new(30);
395        let (overall, results) = health.aggregate(&registry).await;
396
397        assert!(matches!(overall, OverallHealth::Down));
398        assert!(results.is_empty());
399    }
400
401    #[test]
402    fn health_status_display() {
403        assert_eq!(HealthStatus::Healthy.to_string(), "healthy");
404        assert_eq!(
405            HealthStatus::Degraded("slow".into()).to_string(),
406            "degraded: slow"
407        );
408        assert_eq!(
409            HealthStatus::Unhealthy("crash".into()).to_string(),
410            "unhealthy: crash"
411        );
412        assert_eq!(HealthStatus::Unknown.to_string(), "unknown");
413    }
414
415    #[test]
416    fn overall_health_display() {
417        assert_eq!(OverallHealth::Healthy.to_string(), "healthy");
418        assert_eq!(OverallHealth::Down.to_string(), "down");
419        assert_eq!(
420            OverallHealth::Degraded {
421                unhealthy_services: vec!["svc-a".into(), "svc-b".into()]
422            }
423            .to_string(),
424            "degraded (svc-a, svc-b)"
425        );
426    }
427
428    #[test]
429    fn check_interval() {
430        let health = HealthSystem::new(15);
431        assert_eq!(health.check_interval_secs(), 15);
432    }
433
434    // ── K2b-G2: Probe tests (os-patterns) ────────────────────────
435
436    #[cfg(feature = "os-patterns")]
437    mod probe_tests {
438        use super::super::*;
439
440        #[test]
441        fn probe_config_default() {
442            let config = ProbeConfig::default();
443            assert_eq!(config.liveness_interval_secs, 10);
444            assert_eq!(config.readiness_interval_secs, 5);
445            assert_eq!(config.failure_threshold, 3);
446            assert_eq!(config.success_threshold, 1);
447        }
448
449        #[test]
450        fn probe_config_from_model_untrained_matches_default() {
451            // Finding #5: untrained HealthThresholdModel must reproduce
452            // the hardcoded defaults exactly.
453            let model = crate::eml_kernel::HealthThresholdModel::new();
454            assert!(!model.is_trained());
455
456            let from_none = ProbeConfig::from_model(None, 0, 0, 0.0);
457            let from_untrained = ProbeConfig::from_model(Some(&model), 0, 100, 50.0);
458            let defaults = ProbeConfig::default();
459
460            assert_eq!(from_none.failure_threshold, defaults.failure_threshold);
461            assert_eq!(from_none.success_threshold, defaults.success_threshold);
462            assert_eq!(from_untrained.failure_threshold, defaults.failure_threshold);
463            assert_eq!(from_untrained.success_threshold, defaults.success_threshold);
464        }
465
466        #[test]
467        fn probe_config_from_trained_model_uses_prediction() {
468            // Finding #5: with a trained HealthThresholdModel, the
469            // config thresholds are sourced from the model.
470            let model = crate::eml_kernel::HealthThresholdModel::new();
471            let mut json = serde_json::to_value(&model).unwrap();
472            if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
473                inner.insert("trained".into(), serde_json::Value::Bool(true));
474            }
475            let forced: crate::eml_kernel::HealthThresholdModel =
476                serde_json::from_value(json).unwrap();
477            assert!(forced.is_trained());
478
479            let cfg = ProbeConfig::from_model(Some(&forced), 0, 100, 50.0);
480            // Thresholds are clamped to [1,20] inside the model.
481            assert!((1..=20).contains(&cfg.failure_threshold));
482            assert!((1..=20).contains(&cfg.success_threshold));
483            // failed >= degraded (enforced by HealthThresholdModel).
484            assert!(cfg.failure_threshold >= cfg.success_threshold);
485        }
486
487        #[test]
488        fn probe_config_serde_roundtrip() {
489            let config = ProbeConfig {
490                liveness_interval_secs: 15,
491                readiness_interval_secs: 10,
492                failure_threshold: 5,
493                success_threshold: 2,
494            };
495            let json = serde_json::to_string(&config).unwrap();
496            let restored: ProbeConfig = serde_json::from_str(&json).unwrap();
497            assert_eq!(restored.liveness_interval_secs, 15);
498            assert_eq!(restored.failure_threshold, 5);
499        }
500
501        #[test]
502        fn probe_result_serde_roundtrip() {
503            let results = vec![
504                ProbeResult::Live,
505                ProbeResult::NotLive {
506                    reason: "oom".into(),
507                },
508                ProbeResult::Ready,
509                ProbeResult::NotReady {
510                    reason: "init".into(),
511                },
512            ];
513            for result in results {
514                let json = serde_json::to_string(&result).unwrap();
515                let restored: ProbeResult = serde_json::from_str(&json).unwrap();
516                assert_eq!(restored, result);
517            }
518        }
519
520        #[test]
521        fn probe_state_default_is_live_and_ready() {
522            let state = ProbeState::default();
523            assert!(state.is_live);
524            assert!(state.is_ready);
525            assert_eq!(state.liveness_failures, 0);
526            assert_eq!(state.readiness_failures, 0);
527        }
528
529        #[test]
530        fn liveness_resets_on_success() {
531            let config = ProbeConfig {
532                failure_threshold: 3,
533                ..Default::default()
534            };
535            let mut state = ProbeState::default();
536            state.liveness_failures = 2;
537
538            let restart = state.record_liveness(&ProbeResult::Live, &config);
539            assert!(!restart);
540            assert_eq!(state.liveness_failures, 0);
541            assert!(state.is_live);
542        }
543
544        #[test]
545        fn liveness_triggers_restart_at_threshold() {
546            let config = ProbeConfig {
547                failure_threshold: 3,
548                ..Default::default()
549            };
550            let mut state = ProbeState::default();
551
552            // 2 failures: no restart
553            assert!(!state.record_liveness(
554                &ProbeResult::NotLive {
555                    reason: "hang".into()
556                },
557                &config
558            ));
559            assert!(!state.record_liveness(
560                &ProbeResult::NotLive {
561                    reason: "hang".into()
562                },
563                &config
564            ));
565            assert!(state.is_live);
566
567            // 3rd failure: restart
568            assert!(state.record_liveness(
569                &ProbeResult::NotLive {
570                    reason: "hang".into()
571                },
572                &config
573            ));
574            assert!(!state.is_live);
575        }
576
577        #[test]
578        fn readiness_removes_at_threshold() {
579            let config = ProbeConfig {
580                failure_threshold: 2,
581                ..Default::default()
582            };
583            let mut state = ProbeState::default();
584
585            assert!(
586                state
587                    .record_readiness(
588                        &ProbeResult::NotReady {
589                            reason: "init".into()
590                        },
591                        &config
592                    )
593                    .is_none()
594            );
595            let change = state.record_readiness(
596                &ProbeResult::NotReady {
597                    reason: "init".into(),
598                },
599                &config,
600            );
601            assert_eq!(change, Some(false)); // should be removed
602            assert!(!state.is_ready);
603        }
604
605        #[test]
606        fn readiness_recovery_re_adds() {
607            let config = ProbeConfig {
608                failure_threshold: 1,
609                success_threshold: 1,
610                ..Default::default()
611            };
612            let mut state = ProbeState::default();
613
614            // Make it unready
615            state.record_readiness(
616                &ProbeResult::NotReady {
617                    reason: "init".into(),
618                },
619                &config,
620            );
621            assert!(!state.is_ready);
622
623            // Recover
624            let change = state.record_readiness(&ProbeResult::Ready, &config);
625            assert_eq!(change, Some(true));
626            assert!(state.is_ready);
627        }
628
629        #[test]
630        fn threshold_prevents_flapping() {
631            let config = ProbeConfig {
632                failure_threshold: 3,
633                success_threshold: 2,
634                ..Default::default()
635            };
636            let mut state = ProbeState::default();
637
638            // One failure shouldn't change anything
639            assert!(
640                state
641                    .record_readiness(&ProbeResult::NotReady { reason: "x".into() }, &config)
642                    .is_none()
643            );
644            assert!(state.is_ready);
645
646            // One success resets failures
647            assert!(
648                state
649                    .record_readiness(&ProbeResult::Ready, &config)
650                    .is_none()
651            );
652            assert!(state.is_ready);
653        }
654
655        #[test]
656        fn default_probe_returns_live_ready() {
657            // Default liveness/readiness should return Live/Ready
658            let live = ProbeResult::Live;
659            let ready = ProbeResult::Ready;
660            assert_eq!(live, ProbeResult::Live);
661            assert_eq!(ready, ProbeResult::Ready);
662        }
663    }
664}