Skip to main content

mecha10_core/
node.rs

1// Mecha10 Node Trait and Lifecycle Management
2//
3// This module defines the core Node trait and lifecycle management for Mecha10 nodes.
4// Every Mecha10 node implements the NodeImpl trait to define its behavior.
5
6use crate::{Context, HealthStatus, NodeState, Result};
7use async_trait::async_trait;
8use schemars::JsonSchema;
9use serde::de::DeserializeOwned;
10use serde::{Deserialize, Serialize};
11use std::fmt::Debug;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::sync::Mutex;
15use tracing::{debug, error, info, warn};
16
17// ============================================================================
18// Health Priority
19// ============================================================================
20
21/// Node health reporting priority
22///
23/// Determines heartbeat frequency and timeout thresholds based on
24/// node criticality. Safety-critical nodes report more frequently.
25///
26/// # Priority Levels
27///
28/// | Priority | Interval | TTL | Timeout | Use Case |
29/// |----------|----------|-----|---------|----------|
30/// | Critical | 1s | 5s | 3s | motor-controller, estop |
31/// | Normal | 5s | 60s | 15s | sensors, AI nodes |
32/// | Background | 30s | 120s | 90s | logging, diagnostics |
33///
34/// # Example
35///
36/// ```rust
37/// use mecha10_core::prelude::*;
38///
39/// // Create config with critical priority
40/// let config = HealthReportingConfig::critical();
41///
42/// // Or specify priority explicitly
43/// let config = HealthReportingConfig {
44///     priority: HealthPriority::Critical,
45///     ..Default::default()
46/// };
47/// ```
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
49#[serde(rename_all = "lowercase")]
50pub enum HealthPriority {
51    /// Safety-critical nodes that require immediate failure detection.
52    ///
53    /// Examples: motor-controller, estop, safety-monitor
54    ///
55    /// - Interval: 1 second
56    /// - TTL: 5 seconds
57    /// - Timeout: 3 seconds
58    Critical,
59
60    /// Standard operational nodes (default).
61    ///
62    /// Examples: camera, sensors, simulator
63    ///
64    /// - Interval: 5 seconds
65    /// - TTL: 60 seconds
66    /// - Timeout: 15 seconds
67    #[default]
68    Normal,
69
70    /// Background/auxiliary nodes where delayed detection is acceptable.
71    ///
72    /// Examples: logging, telemetry, diagnostics
73    ///
74    /// - Interval: 30 seconds
75    /// - TTL: 120 seconds
76    /// - Timeout: 90 seconds
77    Background,
78}
79
80impl HealthPriority {
81    /// Get the heartbeat interval for this priority level
82    pub const fn interval(&self) -> Duration {
83        match self {
84            Self::Critical => Duration::from_secs(1),
85            Self::Normal => Duration::from_secs(5),
86            Self::Background => Duration::from_secs(30),
87        }
88    }
89
90    /// Get the Redis TTL (time-to-live) in seconds for this priority level
91    ///
92    /// The TTL should be longer than the interval to allow for some jitter,
93    /// but short enough that stale data expires quickly.
94    pub const fn ttl_seconds(&self) -> u64 {
95        match self {
96            Self::Critical => 5,
97            Self::Normal => 60,
98            Self::Background => 120,
99        }
100    }
101
102    /// Get the timeout threshold for monitoring
103    ///
104    /// A node is considered unresponsive if no health report is received
105    /// within this duration.
106    pub const fn timeout(&self) -> Duration {
107        match self {
108            Self::Critical => Duration::from_secs(3),
109            Self::Normal => Duration::from_secs(15),
110            Self::Background => Duration::from_secs(90),
111        }
112    }
113
114    /// Get the timeout in microseconds (for comparison with timestamps)
115    pub const fn timeout_micros(&self) -> u64 {
116        match self {
117            Self::Critical => 3_000_000,
118            Self::Normal => 15_000_000,
119            Self::Background => 90_000_000,
120        }
121    }
122}
123
124impl std::fmt::Display for HealthPriority {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            Self::Critical => write!(f, "critical"),
128            Self::Normal => write!(f, "normal"),
129            Self::Background => write!(f, "background"),
130        }
131    }
132}
133
134// ============================================================================
135// Configuration
136// ============================================================================
137
138/// Configuration for node health reporting
139///
140/// # Example
141///
142/// ```rust
143/// use mecha10_core::prelude::*;
144///
145/// // Default config (Normal priority, 5s interval)
146/// let config = HealthReportingConfig::default();
147///
148/// // Critical priority (1s interval)
149/// let config = HealthReportingConfig::critical();
150///
151/// // Background priority (30s interval)
152/// let config = HealthReportingConfig::background();
153///
154/// // Custom config
155/// let config = HealthReportingConfig {
156///     enabled: true,
157///     priority: HealthPriority::Normal,
158///     store_history: false, // Disable history for high-frequency nodes
159/// };
160/// ```
161#[derive(Debug, Clone)]
162pub struct HealthReportingConfig {
163    /// Whether health reporting is enabled (default: true)
164    pub enabled: bool,
165
166    /// Priority level determines interval and timeout (default: Normal)
167    pub priority: HealthPriority,
168
169    /// Whether to store health history in Redis (default: true)
170    ///
171    /// Disable for high-frequency critical nodes to reduce Redis load.
172    /// History is stored as a sorted set with 24-hour retention.
173    pub store_history: bool,
174}
175
176impl Default for HealthReportingConfig {
177    fn default() -> Self {
178        Self {
179            enabled: true,
180            priority: HealthPriority::Normal,
181            store_history: true,
182        }
183    }
184}
185
186impl HealthReportingConfig {
187    /// Create config for critical priority nodes (1s interval)
188    pub fn critical() -> Self {
189        Self {
190            enabled: true,
191            priority: HealthPriority::Critical,
192            store_history: false, // Disable history for critical nodes to reduce load
193        }
194    }
195
196    /// Create config for normal priority nodes (5s interval)
197    pub fn normal() -> Self {
198        Self::default()
199    }
200
201    /// Create config for background priority nodes (30s interval)
202    pub fn background() -> Self {
203        Self {
204            enabled: true,
205            priority: HealthPriority::Background,
206            store_history: true,
207        }
208    }
209
210    /// Get the effective interval based on priority
211    pub fn interval(&self) -> Duration {
212        self.priority.interval()
213    }
214
215    /// Get the effective TTL based on priority
216    pub fn ttl_seconds(&self) -> u64 {
217        self.priority.ttl_seconds()
218    }
219}
220
221/// Trait for node configurations that specify an update rate
222///
223/// This trait provides a standard interface for nodes that publish at a fixed rate.
224/// Implement this trait on your node's config struct to enable convenient rate-based
225/// interval creation.
226///
227/// # Example
228///
229/// ```rust
230/// use mecha10_core::prelude::*;
231///
232/// #[derive(Debug, Clone, Serialize, Deserialize)]
233/// pub struct CameraConfig {
234///     pub fps: f32,
235///     pub width: u32,
236///     pub height: u32,
237/// }
238///
239/// impl RateConfig for CameraConfig {
240///     fn rate_hz(&self) -> f32 {
241///         self.fps
242///     }
243/// }
244///
245/// // Now you can use the config to create intervals easily:
246/// # async fn example(config: CameraConfig) {
247/// let mut interval = config.create_interval();
248/// loop {
249///     interval.tick().await;
250///     // Publish at configured rate
251/// }
252/// # }
253/// ```
254pub trait RateConfig {
255    /// Get the update rate in Hertz (cycles per second)
256    fn rate_hz(&self) -> f32;
257
258    /// Create a tokio interval from the configured rate
259    ///
260    /// This is a convenience method that converts the rate to a Duration
261    /// and creates a tokio::time::Interval.
262    fn create_interval(&self) -> tokio::time::Interval {
263        let duration = Duration::from_secs_f32(1.0 / self.rate_hz());
264        tokio::time::interval(duration)
265    }
266
267    /// Get the period (time between updates) as a Duration
268    fn period(&self) -> Duration {
269        Duration::from_secs_f32(1.0 / self.rate_hz())
270    }
271}
272
273// ============================================================================
274// Health Status Helper
275// ============================================================================
276
277/// Helper methods for creating HealthStatus instances
278pub trait HealthStatusExt {
279    /// Create a healthy status
280    fn healthy(message: impl Into<String>) -> Self;
281    /// Create an unhealthy status
282    fn unhealthy(message: impl Into<String>) -> Self;
283}
284
285impl HealthStatusExt for HealthStatus {
286    fn healthy(message: impl Into<String>) -> Self {
287        Self {
288            healthy: true,
289            last_check: crate::prelude::now_micros(),
290            failure_count: 0,
291            message: Some(message.into()),
292        }
293    }
294
295    fn unhealthy(message: impl Into<String>) -> Self {
296        Self {
297            healthy: false,
298            last_check: crate::prelude::now_micros(),
299            failure_count: 1,
300            message: Some(message.into()),
301        }
302    }
303}
304
305// ============================================================================
306// Node Type and Tags
307// ============================================================================
308
309/// Built-in node kind.
310///
311/// This is a deliberately *closed* set -- it drives 1:1 config-schema
312/// resolution in `mecha10-schema-registry` (a node has exactly one config
313/// struct), so it can't also double as an open-ended "what kind of thing is
314/// this" label for custom/project-local nodes. See [`NodeImpl::NODE_TYPE`]
315/// and the "Node type and tags" section of the Node Management doc
316/// (<https://linear.app/laboratory-one/document/node-management-1b7f4324db40>)
317/// for the full design rationale.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
319#[serde(rename_all = "kebab-case")]
320pub enum NodeType {
321    Imu,
322    Camera,
323    ImageClassifier,
324    ObjectDetector,
325    BehaviorExecutor,
326    LlmCommand,
327    Teleop,
328    Motor,
329    WebsocketBridge,
330    VideoStreamer,
331    Simulator,
332    Speaker,
333    Listener,
334}
335
336impl NodeType {
337    /// Every built-in node type, in declaration order.
338    ///
339    /// `mecha10-schema-registry`'s `BUILTIN_NODE_TYPES` derives from this
340    /// instead of maintaining its own parallel `&[&str]` list.
341    pub const ALL: &'static [NodeType] = &[
342        NodeType::Imu,
343        NodeType::Camera,
344        NodeType::ImageClassifier,
345        NodeType::ObjectDetector,
346        NodeType::BehaviorExecutor,
347        NodeType::LlmCommand,
348        NodeType::Teleop,
349        NodeType::Motor,
350        NodeType::WebsocketBridge,
351        NodeType::VideoStreamer,
352        NodeType::Simulator,
353        NodeType::Speaker,
354        NodeType::Listener,
355    ];
356
357    /// The kebab-case wire identifier for this type, e.g. `"image-classifier"`.
358    ///
359    /// This is exactly the string `serde` would produce via
360    /// `#[serde(rename_all = "kebab-case")]`, exposed as a plain `&str` for
361    /// callers (like `mecha10-schema-registry`) that work with node-type
362    /// identifiers as strings rather than JSON values.
363    pub fn as_str(&self) -> &'static str {
364        match self {
365            NodeType::Imu => "imu",
366            NodeType::Camera => "camera",
367            NodeType::ImageClassifier => "image-classifier",
368            NodeType::ObjectDetector => "object-detector",
369            NodeType::BehaviorExecutor => "behavior-executor",
370            NodeType::LlmCommand => "llm-command",
371            NodeType::Teleop => "teleop",
372            NodeType::Motor => "motor",
373            NodeType::WebsocketBridge => "websocket-bridge",
374            NodeType::VideoStreamer => "video-streamer",
375            NodeType::Simulator => "simulator",
376            NodeType::Speaker => "speaker",
377            NodeType::Listener => "listener",
378        }
379    }
380}
381
382impl std::fmt::Display for NodeType {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        write!(f, "{}", self.as_str())
385    }
386}
387
388/// Capability tag describing something specific a node *does*.
389///
390/// Unlike [`NodeType`], this is meant to be usable by custom/project-local
391/// nodes too -- see [`NodeImpl::NODE_TAGS`]. Each variant names a concrete
392/// capability (never a structural/meta label like "publishes messages",
393/// which is true of nearly every node and carries no routing information).
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
395#[serde(rename_all = "kebab-case")]
396pub enum NodeTag {
397    Sensor,
398    Inertial,
399    Capture,
400    Ai,
401    Classifier,
402    Detector,
403    Vision,
404    Planner,
405    Control,
406    Nlp,
407    Input,
408    Actuator,
409    Motion,
410    Relay,
411    Websocket,
412    Streamer,
413    Video,
414    Simulator,
415    Logger,
416}
417
418// ============================================================================
419// NodeImpl Trait
420// ============================================================================
421
422/// Core trait that all Mecha10 nodes must implement.
423///
424/// This trait defines the lifecycle of a node:
425/// 1. `init()` - Initialize the node with configuration
426/// 2. `run()` - Main execution loop
427/// 3. `shutdown()` - Graceful shutdown
428/// 4. `health_check()` - Report health status
429///
430/// # Example
431///
432/// ```rust
433/// use mecha10_core::prelude::*;
434///
435/// #[derive(Debug)]
436/// struct MyNode {
437///     config: MyConfig,
438/// }
439///
440/// #[derive(Debug, Deserialize)]
441/// struct MyConfig {
442///     rate: f32,
443/// }
444///
445/// #[async_trait]
446/// impl NodeImpl for MyNode {
447///     type Config = MyConfig;
448///
449///     async fn init(config: Self::Config) -> Result<Self> {
450///         info!("Initializing node with rate: {}", config.rate);
451///         Ok(Self { config })
452///     }
453///
454///     async fn run(&mut self, ctx: &Context) -> Result<()> {
455///         let mut images = ctx.subscribe(sensor::CAMERA_RGB).await?;
456///
457///         while let Some(image) = images.recv().await {
458///             // Process image
459///             info!("Received image: {}x{}", image.width, image.height);
460///         }
461///
462///         Ok(())
463///     }
464/// }
465/// ```
466#[async_trait]
467pub trait NodeImpl: Send + Sync + Debug + 'static {
468    /// Configuration type for this node
469    type Config: DeserializeOwned + Send + Sync + Debug;
470
471    /// Built-in node type, if this is one of the framework-provided node
472    /// kinds. `None` for custom/project-local nodes -- `NodeType` is
473    /// deliberately closed (it drives 1:1 config-schema resolution in
474    /// `mecha10-schema-registry`), so a custom node correctly stays `None`
475    /// here rather than being forced to misrepresent itself as one of the
476    /// built-in variants. This matches
477    /// `mecha10-schema-registry::resolve_node_type`'s existing `None` ->
478    /// generic-schema-fallback behavior for anything unrecognized.
479    const NODE_TYPE: Option<NodeType> = None;
480
481    /// Capability tags. Empty by default. Unlike `NODE_TYPE`,
482    /// custom/project-local nodes are encouraged to set this -- tagging e.g.
483    /// a custom lidar node `NodeTag::Sensor` lets it be picked up by any
484    /// dashboard widget that requires that tag, without needing an entry in
485    /// `mecha10-schema-registry` at all.
486    const NODE_TAGS: &'static [NodeTag] = &[];
487
488    /// Initialize the node with the given configuration.
489    ///
490    /// This is called once when the node starts. Use this to:
491    /// - Load configuration
492    /// - Initialize resources (models, connections, etc.)
493    /// - Validate prerequisites
494    ///
495    /// # Errors
496    ///
497    /// Return an error if initialization fails. The node will not start.
498    async fn init(config: Self::Config) -> Result<Self>
499    where
500        Self: Sized;
501
502    /// Main execution loop for the node.
503    ///
504    /// This is where your node's business logic lives. Typical patterns:
505    /// - Subscribe to topics and process messages
506    /// - Run periodic tasks
507    /// - Publish results
508    ///
509    /// This method should run indefinitely until:
510    /// - The context is cancelled (shutdown requested)
511    /// - An unrecoverable error occurs
512    ///
513    /// # Errors
514    ///
515    /// Return an error if the node encounters an unrecoverable error.
516    /// The node will transition to Error state and shutdown.
517    async fn run(&mut self, ctx: &Context) -> Result<()>;
518
519    /// Graceful shutdown hook.
520    ///
521    /// Called when the node is shutting down. Use this to:
522    /// - Close connections
523    /// - Flush buffers
524    /// - Save state
525    /// - Release resources
526    ///
527    /// The default implementation does nothing.
528    ///
529    /// # Errors
530    ///
531    /// Errors during shutdown are logged but don't prevent shutdown.
532    async fn shutdown(&mut self) -> Result<()> {
533        Ok(())
534    }
535
536    /// Health check for monitoring.
537    ///
538    /// Called periodically to check node health. Use this to:
539    /// - Report current state
540    /// - Check resource usage
541    /// - Validate connections
542    ///
543    /// The default implementation returns healthy status.
544    async fn health_check(&self) -> HealthStatus {
545        HealthStatus {
546            healthy: true,
547            last_check: crate::prelude::now_micros(),
548            failure_count: 0,
549            message: Some("Node is running".to_string()),
550        }
551    }
552
553    /// Node-specific pre-run content check.
554    ///
555    /// This is the "prerun" layer that catches a bad node config *before* the
556    /// node's main loop starts, rather than crashing deep inside `run()` -- e.g.
557    /// a camera device path that doesn't exist, a model file that was never
558    /// downloaded, or two GPIO pins wired to the same physical pin.
559    ///
560    /// Called once, after `init()` has already produced a fully-typed (and, per
561    /// [`crate::config::Validate`], schema/shape-validated) `Self`, and before
562    /// `run()` starts. Unlike `Validate::validate()` -- which only sees the raw
563    /// deserialized config and runs during config loading -- `preflight()` runs
564    /// on the initialized node, so it can also check state derived during
565    /// `init()` (e.g. a resolved file path) rather than just raw config fields.
566    ///
567    /// The default implementation does nothing, so opting in is non-breaking for
568    /// every existing `NodeImpl`.
569    ///
570    /// # Errors
571    ///
572    /// Return an error with an actionable message (see `node-runner`'s
573    /// `preflight.rs` for the established convention: a `❌` header naming what
574    /// failed, followed by numbered remediation steps) if the node should not
575    /// start. The node will not run.
576    fn preflight(&self) -> Result<()> {
577        Ok(())
578    }
579}
580
581// ============================================================================
582// Node Runner
583// ============================================================================
584
585/// Wrapper that manages the lifecycle of a NodeImpl.
586///
587/// This provides:
588/// - State management
589/// - Lifecycle orchestration
590/// - Error handling
591/// - Health monitoring
592#[derive(Clone)]
593pub struct Node<T: NodeImpl> {
594    node: Arc<Mutex<T>>,
595    state: Arc<Mutex<NodeState>>,
596}
597
598impl<T: NodeImpl> Node<T> {
599    /// Create a new node from a configuration.
600    ///
601    /// This calls `NodeImpl::init()` and wraps the result in a Node.
602    ///
603    /// # Errors
604    ///
605    /// Returns an error if initialization fails.
606    pub async fn from_config(config: T::Config) -> Result<Self> {
607        info!("Initializing node");
608
609        let node = T::init(config).await?;
610
611        Ok(Self {
612            node: Arc::new(Mutex::new(node)),
613            state: Arc::new(Mutex::new(NodeState::Starting)),
614        })
615    }
616
617    /// Start the node with the given context.
618    ///
619    /// This orchestrates the full lifecycle:
620    /// 1. Run `preflight()` node-content checks
621    /// 2. Transition to Running state
622    /// 3. Call `run()`
623    /// 4. Call `shutdown()` on completion or error
624    /// 5. Transition to final state
625    ///
626    /// # Errors
627    ///
628    /// Returns an error if `preflight()` rejects the node's config, or if the
629    /// node fails during execution.
630    pub async fn start(&self, ctx: Context) -> Result<()> {
631        // Node-specific pre-run content check (LAB-1773) - runs after `init()`
632        // (so it can see fully-typed, already-validated config/state) and
633        // before `run()`, so a bad config fails fast instead of crashing deep
634        // in the node's main loop.
635        {
636            let node = self.node.lock().await;
637            node.preflight()?;
638        }
639
640        // Transition to running
641        {
642            let mut state = self.state.lock().await;
643            *state = NodeState::Running;
644            info!("Node transitioned to Running state");
645        }
646
647        // Run the node
648        let result = {
649            let mut node = self.node.lock().await;
650            debug!("Starting node execution");
651            node.run(&ctx).await
652        };
653
654        // Handle result
655        match result {
656            Ok(()) => {
657                info!("Node completed successfully");
658                self.shutdown().await?;
659            }
660            Err(e) => {
661                error!("Node failed with error: {}", e);
662                {
663                    let mut state = self.state.lock().await;
664                    *state = NodeState::Crashed;
665                }
666                self.shutdown().await?;
667                return Err(e);
668            }
669        }
670
671        Ok(())
672    }
673
674    /// Gracefully shutdown the node.
675    ///
676    /// This calls `shutdown()` and transitions to Stopped state.
677    pub async fn shutdown(&self) -> Result<()> {
678        info!("Shutting down node");
679
680        // Transition to shutting down
681        {
682            let mut state = self.state.lock().await;
683            *state = NodeState::Stopping;
684        }
685
686        // Call shutdown hook
687        let result = {
688            let mut node = self.node.lock().await;
689            node.shutdown().await
690        };
691
692        // Transition to stopped
693        {
694            let mut state = self.state.lock().await;
695            *state = NodeState::Stopped;
696            info!("Node stopped");
697        }
698
699        if let Err(e) = result {
700            warn!("Error during shutdown: {}", e);
701            return Err(e);
702        }
703
704        Ok(())
705    }
706
707    /// Get the current state of the node.
708    pub async fn state(&self) -> NodeState {
709        self.state.lock().await.clone()
710    }
711
712    /// Perform a health check on the node.
713    pub async fn health_check(&self) -> HealthStatus {
714        let node = self.node.lock().await;
715        node.health_check().await
716    }
717
718    /// Check if the node is healthy.
719    pub async fn is_healthy(&self) -> bool {
720        matches!(self.state().await, NodeState::Running | NodeState::Starting)
721    }
722}
723
724// ============================================================================
725// Helper Functions
726// ============================================================================
727
728/// Run a node with the given configuration and context.
729///
730/// This is a convenience function that:
731/// 1. Initializes the node from config
732/// 2. Starts the node with the context
733/// 3. Handles errors
734///
735/// # Arguments
736///
737/// * `config` - Node-specific configuration
738/// * `ctx` - Execution context
739/// * `health_config` - Health reporting configuration
740///
741/// # Example
742///
743/// ```rust
744/// use mecha10_core::prelude::*;
745///
746/// #[tokio::main]
747/// async fn main() -> Result<()> {
748///     let config = MyConfig { rate: 10.0 };
749///     let ctx = Context::new("my_node");
750///
751///     // Use default health reporting (Normal priority, 5s interval)
752///     run_node::<MyNode>(config, ctx, HealthReportingConfig::default()).await?;
753///
754///     // Critical priority for safety-critical nodes (1s interval)
755///     run_node::<MyNode>(config, ctx, HealthReportingConfig::critical()).await?;
756///
757///     // Background priority for auxiliary nodes (30s interval)
758///     run_node::<MyNode>(config, ctx, HealthReportingConfig::background()).await?;
759///
760///     Ok(())
761/// }
762/// ```
763pub async fn run_node<T: NodeImpl>(
764    config: T::Config,
765    ctx: Context,
766    health_config: HealthReportingConfig,
767) -> Result<()> {
768    // Initialize logging (safe to call multiple times - see
769    // `prelude::init_logging`'s doc comment for why the Redis-forwarding
770    // layer it attaches has to be feature-gated there, not here).
771    crate::prelude::init_logging();
772
773    // Wire the (now-attached, if "messaging" is enabled) Redis log layer up
774    // to this node's Context so captured log events actually get published
775    // to `/system/log` - without this, node logs only ever reached local
776    // stdout/journald, and a connected robot's logs never reached the
777    // platform's `/logs/stream` subscribers no matter what the dashboard or
778    // aggregator side did. See `crate::log_publisher`.
779    #[cfg(feature = "messaging")]
780    crate::log_publisher::attach(ctx.node_id(), ctx.clone());
781
782    let node = Node::<T>::from_config(config).await?;
783
784    // Start automatic health reporting (requires messaging feature)
785    #[cfg(feature = "messaging")]
786    {
787        if health_config.enabled {
788            // Create clones for the health reporting task
789            let node_for_health = node.node.clone();
790            let ctx_for_health = ctx.clone();
791            let priority = health_config.priority;
792            let report_interval = health_config.interval();
793            let ttl_seconds = health_config.ttl_seconds();
794            let store_history = health_config.store_history;
795
796            tokio::spawn(async move {
797                use crate::health::HealthReporter;
798
799                // Try to create health reporter with priority config, tagged
800                // with this node's built-in type/tags (if any).
801                let reporter = match HealthReporter::with_config_and_type_tags(
802                    &ctx_for_health,
803                    priority,
804                    ttl_seconds,
805                    store_history,
806                    T::NODE_TYPE,
807                    T::NODE_TAGS,
808                )
809                .await
810                {
811                    Ok(r) => r,
812                    Err(e) => {
813                        warn!("Health reporting disabled: {}", e);
814                        return;
815                    }
816                };
817
818                info!(
819                    "Health reporting enabled: priority={}, interval={:?}, ttl={}s, history={}",
820                    priority, report_interval, ttl_seconds, store_history
821                );
822
823                let mut interval = tokio::time::interval(report_interval);
824                info!("Health reporting loop starting");
825
826                loop {
827                    interval.tick().await;
828                    debug!("Health tick - checking node health");
829
830                    // Try to get health status from node without blocking
831                    // If the node is busy (lock held by run()), report as healthy but busy
832                    let msg_status = match node_for_health.try_lock() {
833                        Ok(node) => {
834                            let status = node.health_check().await;
835                            crate::messages::system::HealthStatus {
836                                timestamp: status.last_check,
837                                level: if status.healthy {
838                                    crate::messages::system::HealthLevel::Ok
839                                } else {
840                                    crate::messages::system::HealthLevel::Error
841                                },
842                                message: status.message.unwrap_or_else(|| "No message".to_string()),
843                                diagnostics: None,
844                            }
845                        }
846                        Err(_) => {
847                            // Node is busy (lock held by run()) - report as healthy
848                            // This is normal for long-running nodes
849                            debug!("Node busy, reporting as healthy");
850                            crate::messages::system::HealthStatus {
851                                timestamp: crate::prelude::now_micros(),
852                                level: crate::messages::system::HealthLevel::Ok,
853                                message: "Node running (busy)".to_string(),
854                                diagnostics: None,
855                            }
856                        }
857                    };
858
859                    // Report to centralized monitoring
860                    if let Err(e) = reporter.report(msg_status).await {
861                        warn!("Failed to report health: {}", e);
862                    } else {
863                        tracing::trace!("Health report sent successfully");
864                    }
865                }
866            });
867        } else {
868            info!("Health reporting disabled");
869        }
870    }
871    #[cfg(not(feature = "messaging"))]
872    let _ = &health_config;
873
874    node.start(ctx).await
875}
876
877// `Context::new()` only avoids a real Redis connection attempt when the
878// `messaging` feature is off (see `Context::new`'s `#[cfg(feature =
879// "messaging")]` branch) - with it on, constructing a `Context` needs a live
880// broker, which isn't available in a unit test. This whole module exercises
881// the real `Node::start()` -> `preflight()` -> `run()` wiring end-to-end in
882// the (also real, supported) no-messaging configuration; `NodeImpl::preflight()`'s
883// default-no-op and per-node override behavior itself is feature-independent
884// and covered directly in each node crate's own `config.rs` tests (e.g.
885// `mecha10-nodes-camera`, `mecha10-nodes-motor`).
886#[cfg(all(test, not(feature = "messaging")))]
887mod tests {
888    use super::*;
889    use crate::error::Mecha10Error;
890    use std::sync::atomic::{AtomicBool, Ordering};
891    use std::sync::Arc as StdArc;
892
893    #[derive(Debug, Default, serde::Deserialize)]
894    struct FakeConfig;
895
896    #[derive(Debug)]
897    struct OkNode {
898        ran: StdArc<AtomicBool>,
899    }
900
901    #[async_trait]
902    impl NodeImpl for OkNode {
903        type Config = FakeConfig;
904
905        async fn init(_config: Self::Config) -> Result<Self> {
906            Ok(Self {
907                ran: StdArc::new(AtomicBool::new(false)),
908            })
909        }
910
911        async fn run(&mut self, _ctx: &Context) -> Result<()> {
912            self.ran.store(true, Ordering::SeqCst);
913            Ok(())
914        }
915    }
916
917    #[derive(Debug)]
918    struct RejectingPreflightNode {
919        ran: StdArc<AtomicBool>,
920    }
921
922    #[async_trait]
923    impl NodeImpl for RejectingPreflightNode {
924        type Config = FakeConfig;
925
926        async fn init(_config: Self::Config) -> Result<Self> {
927            Ok(Self {
928                ran: StdArc::new(AtomicBool::new(false)),
929            })
930        }
931
932        async fn run(&mut self, _ctx: &Context) -> Result<()> {
933            self.ran.store(true, Ordering::SeqCst);
934            Ok(())
935        }
936
937        fn preflight(&self) -> Result<()> {
938            Err(Mecha10Error::Configuration("boom: bad node content".to_string()))
939        }
940    }
941
942    #[tokio::test]
943    async fn default_preflight_is_a_noop_and_run_still_executes() {
944        let node = Node::<OkNode>::from_config(FakeConfig).await.unwrap();
945        let ctx = Context::new("test-node-ok").await.unwrap();
946
947        let ran_handle = { node.node.lock().await.ran.clone() };
948
949        node.start(ctx).await.unwrap();
950
951        assert!(ran_handle.load(Ordering::SeqCst), "run() should have executed");
952    }
953
954    #[tokio::test]
955    async fn start_fails_fast_when_preflight_rejects_config_and_never_calls_run() {
956        let node = Node::<RejectingPreflightNode>::from_config(FakeConfig).await.unwrap();
957        let ctx = Context::new("test-node-rejecting").await.unwrap();
958
959        let ran_handle = { node.node.lock().await.ran.clone() };
960
961        let result = node.start(ctx).await;
962
963        assert!(result.is_err());
964        assert!(result.unwrap_err().to_string().contains("boom: bad node content"));
965        assert!(
966            !ran_handle.load(Ordering::SeqCst),
967            "run() must not execute when preflight() rejects the node"
968        );
969    }
970
971    #[test]
972    fn node_type_kebab_case_matches_existing_builtin_node_types_strings() {
973        let expected: &[(NodeType, &str)] = &[
974            (NodeType::Imu, "imu"),
975            (NodeType::Camera, "camera"),
976            (NodeType::ImageClassifier, "image-classifier"),
977            (NodeType::ObjectDetector, "object-detector"),
978            (NodeType::BehaviorExecutor, "behavior-executor"),
979            (NodeType::LlmCommand, "llm-command"),
980            (NodeType::Teleop, "teleop"),
981            (NodeType::Motor, "motor"),
982            (NodeType::WebsocketBridge, "websocket-bridge"),
983            (NodeType::VideoStreamer, "video-streamer"),
984            (NodeType::Simulator, "simulator"),
985            (NodeType::Speaker, "speaker"),
986            (NodeType::Listener, "listener"),
987        ];
988
989        assert_eq!(expected.len(), NodeType::ALL.len());
990
991        for (variant, kebab) in expected {
992            let json = serde_json::to_string(variant).unwrap();
993            assert_eq!(json, format!("\"{}\"", kebab));
994            assert_eq!(variant.as_str(), *kebab);
995
996            let round_tripped: NodeType = serde_json::from_str(&json).unwrap();
997            assert_eq!(round_tripped, *variant);
998        }
999    }
1000
1001    #[test]
1002    fn node_tag_kebab_case_matches_expected_strings() {
1003        let expected: &[(NodeTag, &str)] = &[
1004            (NodeTag::Sensor, "sensor"),
1005            (NodeTag::Inertial, "inertial"),
1006            (NodeTag::Capture, "capture"),
1007            (NodeTag::Ai, "ai"),
1008            (NodeTag::Classifier, "classifier"),
1009            (NodeTag::Detector, "detector"),
1010            (NodeTag::Vision, "vision"),
1011            (NodeTag::Planner, "planner"),
1012            (NodeTag::Control, "control"),
1013            (NodeTag::Nlp, "nlp"),
1014            (NodeTag::Input, "input"),
1015            (NodeTag::Actuator, "actuator"),
1016            (NodeTag::Motion, "motion"),
1017            (NodeTag::Relay, "relay"),
1018            (NodeTag::Websocket, "websocket"),
1019            (NodeTag::Streamer, "streamer"),
1020            (NodeTag::Video, "video"),
1021            (NodeTag::Simulator, "simulator"),
1022            (NodeTag::Logger, "logger"),
1023        ];
1024
1025        for (variant, kebab) in expected {
1026            let json = serde_json::to_string(variant).unwrap();
1027            assert_eq!(json, format!("\"{}\"", kebab));
1028
1029            let round_tripped: NodeTag = serde_json::from_str(&json).unwrap();
1030            assert_eq!(round_tripped, *variant);
1031        }
1032    }
1033
1034    #[test]
1035    fn node_impl_defaults_node_type_to_none_and_tags_to_empty() {
1036        assert_eq!(OkNode::NODE_TYPE, None);
1037        assert!(OkNode::NODE_TAGS.is_empty());
1038    }
1039}