Skip to main content

mecha10_core/
node.rs

1use crate::{Context, HealthStatus, NodeState, Result};
2use async_trait::async_trait;
3use schemars::JsonSchema;
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6use std::fmt::Debug;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::sync::Mutex;
10use tracing::{debug, error, info, warn};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum HealthPriority {
15    Critical,
16
17    #[default]
18    Normal,
19
20    Background,
21}
22
23impl HealthPriority {
24    /// Per-variant health-monitoring profile: (heartbeat interval, Redis TTL in seconds,
25    /// unresponsive timeout). Previously `interval`/`ttl_seconds`/`timeout` each had their own
26    /// 3-arm match over `Self`, which cargo-dupes flagged as duplicate match shapes; the
27    /// per-variant values are only defined once here, and the public getters below are plain
28    /// field projections.
29    const fn profile(&self) -> (Duration, u64, Duration) {
30        match self {
31            Self::Critical => (Duration::from_secs(1), 5, Duration::from_secs(3)),
32            Self::Normal => (Duration::from_secs(5), 60, Duration::from_secs(15)),
33            Self::Background => (Duration::from_secs(30), 120, Duration::from_secs(90)),
34        }
35    }
36
37    /// Get the heartbeat interval for this priority level
38    pub const fn interval(&self) -> Duration {
39        self.profile().0
40    }
41
42    /// Get the Redis TTL (time-to-live) in seconds for this priority level
43    ///
44    /// The TTL should be longer than the interval to allow for some jitter,
45    /// but short enough that stale data expires quickly.
46    pub const fn ttl_seconds(&self) -> u64 {
47        self.profile().1
48    }
49
50    /// Get the timeout threshold for monitoring
51    ///
52    /// A node is considered unresponsive if no health report is received
53    /// within this duration.
54    pub const fn timeout(&self) -> Duration {
55        self.profile().2
56    }
57
58    /// Get the timeout in microseconds (for comparison with timestamps)
59    pub const fn timeout_micros(&self) -> u64 {
60        self.timeout().as_micros() as u64
61    }
62}
63
64impl std::fmt::Display for HealthPriority {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Critical => write!(f, "critical"),
68            Self::Normal => write!(f, "normal"),
69            Self::Background => write!(f, "background"),
70        }
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct HealthReportingConfig {
76    pub enabled: bool,
77
78    pub priority: HealthPriority,
79
80    pub store_history: bool,
81}
82
83impl Default for HealthReportingConfig {
84    fn default() -> Self {
85        Self {
86            enabled: true,
87            priority: HealthPriority::Normal,
88            store_history: true,
89        }
90    }
91}
92
93impl HealthReportingConfig {
94    /// Shared constructor behind the `critical`/`background` presets, which are always
95    /// enabled and only differ in priority and whether history is stored.
96    fn with_priority(priority: HealthPriority, store_history: bool) -> Self {
97        Self {
98            enabled: true,
99            priority,
100            store_history,
101        }
102    }
103
104    /// Create config for critical priority nodes (1s interval)
105    pub fn critical() -> Self {
106        Self::with_priority(HealthPriority::Critical, false)
107    }
108
109    /// Create config for normal priority nodes (5s interval)
110    pub fn normal() -> Self {
111        Self::default()
112    }
113
114    /// Create config for background priority nodes (30s interval)
115    pub fn background() -> Self {
116        Self::with_priority(HealthPriority::Background, true)
117    }
118
119    /// Get the effective interval based on priority
120    pub fn interval(&self) -> Duration {
121        self.priority.interval()
122    }
123
124    /// Get the effective TTL based on priority
125    pub fn ttl_seconds(&self) -> u64 {
126        self.priority.ttl_seconds()
127    }
128}
129
130pub trait RateConfig {
131    /// Get the update rate in Hertz (cycles per second)
132    fn rate_hz(&self) -> f32;
133
134    /// Create a tokio interval from the configured rate
135    ///
136    /// This is a convenience method that converts the rate to a Duration
137    /// and creates a tokio::time::Interval.
138    fn create_interval(&self) -> tokio::time::Interval {
139        let duration = Duration::from_secs_f32(1.0 / self.rate_hz());
140        tokio::time::interval(duration)
141    }
142
143    /// Get the period (time between updates) as a Duration
144    fn period(&self) -> Duration {
145        Duration::from_secs_f32(1.0 / self.rate_hz())
146    }
147}
148
149pub trait HealthStatusExt {
150    /// Create a healthy status
151    fn healthy(message: impl Into<String>) -> Self;
152    /// Create an unhealthy status
153    fn unhealthy(message: impl Into<String>) -> Self;
154}
155
156impl HealthStatus {
157    /// Shared constructor backing both [`HealthStatusExt::healthy`] and
158    /// [`HealthStatusExt::unhealthy`] — the two only ever differed in the `healthy` flag and
159    /// the default `failure_count` that goes with it.
160    fn with_check(healthy: bool, failure_count: u32, message: impl Into<String>) -> Self {
161        Self {
162            healthy,
163            last_check: crate::prelude::now_micros(),
164            failure_count,
165            message: Some(message.into()),
166        }
167    }
168}
169
170impl HealthStatusExt for HealthStatus {
171    fn healthy(message: impl Into<String>) -> Self {
172        Self::with_check(true, 0, message)
173    }
174
175    fn unhealthy(message: impl Into<String>) -> Self {
176        Self::with_check(false, 1, message)
177    }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181#[serde(rename_all = "kebab-case")]
182pub enum NodeType {
183    Imu,
184    Camera,
185    ImageClassifier,
186    ObjectDetector,
187    BehaviorExecutor,
188    LlmCommand,
189    Teleop,
190    Motor,
191    WebsocketBridge,
192    VideoStreamer,
193    Simulator,
194    Speaker,
195    Listener,
196    Rl,
197    InstanceTrack,
198    Diagnostics,
199    Servo,
200    PanTilt,
201}
202
203impl NodeType {
204    pub const ALL: &'static [NodeType] = &[
205        NodeType::Imu,
206        NodeType::Camera,
207        NodeType::ImageClassifier,
208        NodeType::ObjectDetector,
209        NodeType::BehaviorExecutor,
210        NodeType::LlmCommand,
211        NodeType::Teleop,
212        NodeType::Motor,
213        NodeType::WebsocketBridge,
214        NodeType::VideoStreamer,
215        NodeType::Simulator,
216        NodeType::Speaker,
217        NodeType::Listener,
218        NodeType::Rl,
219        NodeType::InstanceTrack,
220        NodeType::Diagnostics,
221        NodeType::Servo,
222        NodeType::PanTilt,
223    ];
224
225    /// The kebab-case wire identifier for this type, e.g. `"image-classifier"`.
226    ///
227    /// This is exactly the string `serde` would produce via
228    /// `#[serde(rename_all = "kebab-case")]`, exposed as a plain `&str` for
229    /// callers (like `mecha10-schema-registry`) that work with node-type
230    /// identifiers as strings rather than JSON values.
231    pub fn as_str(&self) -> &'static str {
232        match self {
233            NodeType::Imu => "imu",
234            NodeType::Camera => "camera",
235            NodeType::ImageClassifier => "image-classifier",
236            NodeType::ObjectDetector => "object-detector",
237            NodeType::BehaviorExecutor => "behavior-executor",
238            NodeType::LlmCommand => "llm-command",
239            NodeType::Teleop => "teleop",
240            NodeType::Motor => "motor",
241            NodeType::WebsocketBridge => "websocket-bridge",
242            NodeType::VideoStreamer => "video-streamer",
243            NodeType::Simulator => "simulator",
244            NodeType::Speaker => "speaker",
245            NodeType::Listener => "listener",
246            NodeType::Rl => "rl",
247            NodeType::InstanceTrack => "instance-track",
248            NodeType::Diagnostics => "diagnostics",
249            NodeType::Servo => "servo",
250            NodeType::PanTilt => "pan-tilt",
251        }
252    }
253}
254
255impl std::fmt::Display for NodeType {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        write!(f, "{}", self.as_str())
258    }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
262#[serde(rename_all = "kebab-case")]
263pub enum NodeTag {
264    Sensor,
265    Inertial,
266    Capture,
267    Ai,
268    Classifier,
269    Detector,
270    Vision,
271    Planner,
272    Control,
273    Nlp,
274    Input,
275    Actuator,
276    Motion,
277    Relay,
278    Websocket,
279    Streamer,
280    Video,
281    Simulator,
282    Logger,
283}
284
285#[async_trait]
286pub trait NodeImpl: Send + Sync + Debug + 'static {
287    type Config: DeserializeOwned + Send + Sync + Debug;
288
289    const NODE_TYPE: Option<NodeType> = None;
290
291    const NODE_TAGS: &'static [NodeTag] = &[];
292
293    /// Crate name as it appears in Cargo.toml (e.g. `"mecha10-nodes-camera"`).
294    ///
295    /// `None` (the default) means this node isn't meant to be scaffolded via `mecha10 add` --
296    /// e.g. test/example nodes, or any node that hasn't opted into registration yet. This is
297    /// also the gate [`crate::register_node!`] checks: a node whose `CRATE_NAME` is `None`
298    /// registers no [`crate::node_registry::NodeDescriptor`] at all, regardless of what its
299    /// other registration-related consts are set to.
300    const CRATE_NAME: Option<&'static str> = None;
301
302    /// Human-readable one-line description of what this node does, surfaced by a catalog
303    /// consumer (e.g. `mecha10 add <node>`'s listing). Empty by default.
304    const DESCRIPTION: &'static str = "";
305
306    /// Default `mecha10 add`/run target for this node (e.g. `"robot"` vs. a simulator-only
307    /// target). Defaults to `"robot"`.
308    const DEFAULT_RUN_TARGET: &'static str = "robot";
309
310    /// Free-form catalog search tags (e.g. `&["camera", "video", "streaming"]`), distinct from
311    /// `NODE_TAGS` (a `NodeTag` enum used for health-reporting capability tags). Surfaced by a
312    /// catalog consumer (e.g. `mecha10 add <node>`'s search/filtering). Empty by default.
313    const CATALOG_TAGS: &'static [&'static str] = &[];
314
315    /// Initialize the node with the given configuration.
316    ///
317    /// This is called once when the node starts. Use this to:
318    /// - Load configuration
319    /// - Initialize resources (models, connections, etc.)
320    /// - Validate prerequisites
321    ///
322    /// # Errors
323    ///
324    /// Return an error if initialization fails. The node will not start.
325    async fn init(config: Self::Config) -> Result<Self>
326    where
327        Self: Sized;
328
329    /// Main execution loop for the node.
330    ///
331    /// This is where your node's business logic lives. Typical patterns:
332    /// - Subscribe to topics and process messages
333    /// - Run periodic tasks
334    /// - Publish results
335    ///
336    /// This method should run indefinitely until:
337    /// - The context is cancelled (shutdown requested)
338    /// - An unrecoverable error occurs
339    ///
340    /// # Errors
341    ///
342    /// Return an error if the node encounters an unrecoverable error.
343    /// The node will transition to Error state and shutdown.
344    async fn run(&mut self, ctx: &Context) -> Result<()>;
345
346    /// Graceful shutdown hook.
347    ///
348    /// Called when the node is shutting down. Use this to:
349    /// - Close connections
350    /// - Flush buffers
351    /// - Save state
352    /// - Release resources
353    ///
354    /// The default implementation does nothing.
355    ///
356    /// # Errors
357    ///
358    /// Errors during shutdown are logged but don't prevent shutdown.
359    async fn shutdown(&mut self) -> Result<()> {
360        Ok(())
361    }
362
363    /// Health check for monitoring.
364    ///
365    /// Called periodically to check node health. Use this to:
366    /// - Report current state
367    /// - Check resource usage
368    /// - Validate connections
369    ///
370    /// The default implementation returns healthy status.
371    async fn health_check(&self) -> HealthStatus {
372        HealthStatus {
373            healthy: true,
374            last_check: crate::prelude::now_micros(),
375            failure_count: 0,
376            message: Some("Node is running".to_string()),
377        }
378    }
379
380    /// Extra diagnostic key/value pairs to attach to this node's health report (surfaced on
381    /// the wire as [`crate::messages::system::HealthStatus::diagnostics`]).
382    ///
383    /// Separate from `health_check()`'s `HealthStatus` (which has a fixed shape shared by every
384    /// node) so a node can report open-ended, node-specific data -- e.g. `video-streamer`
385    /// reports which `camera_id`(s) it's actually streaming, since that's a per-camera identity
386    /// distinct from this node's own `node_id`/`instance_id` and the dashboard needs it to pair
387    /// WebRTC signaling correctly (see `packages/nodes/video-streamer/src/lib.rs`'s impl).
388    ///
389    /// The default implementation reports nothing.
390    fn health_diagnostics(&self) -> Option<std::collections::HashMap<String, String>> {
391        None
392    }
393
394    /// Node-specific pre-run content check.
395    ///
396    /// This is the "prerun" layer that catches a bad node config *before* the
397    /// node's main loop starts, rather than crashing deep inside `run()` -- e.g.
398    /// a camera device path that doesn't exist, a model file that was never
399    /// downloaded, or two GPIO pins wired to the same physical pin.
400    ///
401    /// Called once, after `init()` has already produced a fully-typed (and, per
402    /// [`crate::config::Validate`], schema/shape-validated) `Self`, and before
403    /// `run()` starts. Unlike `Validate::validate()` -- which only sees the raw
404    /// deserialized config and runs during config loading -- `preflight()` runs
405    /// on the initialized node, so it can also check state derived during
406    /// `init()` (e.g. a resolved file path) rather than just raw config fields.
407    ///
408    /// The default implementation does nothing, so opting in is non-breaking for
409    /// every existing `NodeImpl`.
410    ///
411    /// # Errors
412    ///
413    /// Return an error with an actionable message (see `node-runner`'s
414    /// `preflight.rs` for the established convention: a `❌` header naming what
415    /// failed, followed by numbered remediation steps) if the node should not
416    /// start. The node will not run.
417    fn preflight(&self) -> Result<()> {
418        Ok(())
419    }
420}
421
422/// Formats a `Debug` impl for a node whose controller field is a trait
423/// object that can't derive `Debug` (e.g. `Box<dyn SomeController>`).
424///
425/// Shared by node types that expose `config`, a controller identified by
426/// `controller_name` (typically `self.controller.name()`), and an
427/// `update_counter` field.
428pub fn fmt_node_with_controller(
429    f: &mut std::fmt::Formatter<'_>,
430    struct_name: &str,
431    config: &impl Debug,
432    controller_name: &str,
433    update_counter: u64,
434) -> std::fmt::Result {
435    f.debug_struct(struct_name)
436        .field("config", config)
437        .field("controller", &controller_name)
438        .field("update_counter", &update_counter)
439        .finish()
440}
441
442#[derive(Clone)]
443pub struct Node<T: NodeImpl> {
444    node: Arc<Mutex<T>>,
445    state: Arc<Mutex<NodeState>>,
446    // Snapshot of `T::health_diagnostics()` taken once right after `init()`, before `run()`
447    // ever starts holding `node`'s lock for the rest of the process's life (see `start()` -
448    // it locks `node` and never releases it until shutdown, so the health-reporting task's
449    // `try_lock()` in `run_node` below always loses that race in practice and would otherwise
450    // report `diagnostics: None` forever). Every current `health_diagnostics()` override
451    // (currently just `VideoStreamerNode`'s, reporting its fixed `camera_ids`) only reflects
452    // state that's already final by the time `init()` returns - not live run-loop state - so a
453    // one-time post-init snapshot is exactly as accurate as a live read would be, without
454    // needing to contend with `run()`'s lock at all.
455    static_diagnostics: Arc<Option<std::collections::HashMap<String, String>>>,
456}
457
458impl<T: NodeImpl> Node<T> {
459    /// Create a new node from a configuration.
460    ///
461    /// This calls `NodeImpl::init()` and wraps the result in a Node.
462    ///
463    /// # Errors
464    ///
465    /// Returns an error if initialization fails.
466    pub async fn from_config(config: T::Config) -> Result<Self> {
467        info!("Initializing node");
468
469        let node = T::init(config).await?;
470        let static_diagnostics = node.health_diagnostics();
471
472        Ok(Self {
473            node: Arc::new(Mutex::new(node)),
474            state: Arc::new(Mutex::new(NodeState::Starting)),
475            static_diagnostics: Arc::new(static_diagnostics),
476        })
477    }
478
479    /// The post-init snapshot of `health_diagnostics()` - see the field doc comment on
480    /// `static_diagnostics` for why this is safe to read without locking `node`.
481    pub fn static_diagnostics(&self) -> Option<std::collections::HashMap<String, String>> {
482        (*self.static_diagnostics).clone()
483    }
484
485    /// Start the node with the given context.
486    ///
487    /// This orchestrates the full lifecycle:
488    /// 1. Run `preflight()` node-content checks
489    /// 2. Transition to Running state
490    /// 3. Call `run()`
491    /// 4. Call `shutdown()` on completion or error
492    /// 5. Transition to final state
493    ///
494    /// # Errors
495    ///
496    /// Returns an error if `preflight()` rejects the node's config, or if the
497    /// node fails during execution.
498    pub async fn start(&self, ctx: Context) -> Result<()> {
499        {
500            let node = self.node.lock().await;
501            node.preflight()?;
502        }
503
504        {
505            let mut state = self.state.lock().await;
506            *state = NodeState::Running;
507            info!("Node transitioned to Running state");
508        }
509
510        let result = {
511            let mut node = self.node.lock().await;
512            debug!("Starting node execution");
513            node.run(&ctx).await
514        };
515
516        match result {
517            Ok(()) => {
518                info!("Node completed successfully");
519                self.shutdown().await?;
520            }
521            Err(e) => {
522                error!("Node failed with error: {}", e);
523                {
524                    let mut state = self.state.lock().await;
525                    *state = NodeState::Crashed;
526                }
527                self.shutdown().await?;
528                return Err(e);
529            }
530        }
531
532        Ok(())
533    }
534
535    /// Gracefully shutdown the node.
536    ///
537    /// This calls `shutdown()` and transitions to Stopped state.
538    pub async fn shutdown(&self) -> Result<()> {
539        info!("Shutting down node");
540
541        {
542            let mut state = self.state.lock().await;
543            *state = NodeState::Stopping;
544        }
545
546        let result = {
547            let mut node = self.node.lock().await;
548            node.shutdown().await
549        };
550
551        {
552            let mut state = self.state.lock().await;
553            *state = NodeState::Stopped;
554            info!("Node stopped");
555        }
556
557        if let Err(e) = result {
558            warn!("Error during shutdown: {}", e);
559            return Err(e);
560        }
561
562        Ok(())
563    }
564
565    /// Get the current state of the node.
566    pub async fn state(&self) -> NodeState {
567        self.state.lock().await.clone()
568    }
569
570    /// Perform a health check on the node.
571    pub async fn health_check(&self) -> HealthStatus {
572        let node = self.node.lock().await;
573        node.health_check().await
574    }
575
576    /// Check if the node is healthy.
577    pub async fn is_healthy(&self) -> bool {
578        matches!(self.state().await, NodeState::Running | NodeState::Starting)
579    }
580}
581
582/// Run a node with the given configuration and context.
583///
584/// This is a convenience function that:
585/// 1. Initializes the node from config
586/// 2. Starts the node with the context
587/// 3. Handles errors
588///
589/// # Arguments
590///
591/// * `config` - Node-specific configuration
592/// * `ctx` - Execution context
593/// * `health_config` - Health reporting configuration
594///
595/// # Example
596///
597/// ```rust,no_run
598/// use mecha10_core::prelude::*;
599/// use mecha10_core::context::ContextBuilder;
600///
601/// # #[derive(Debug)]
602/// # struct MyNode;
603/// # #[derive(Debug, Deserialize)]
604/// # struct MyConfig { rate: f32 }
605/// # #[async_trait]
606/// # impl NodeImpl for MyNode {
607/// #     type Config = MyConfig;
608/// #     async fn init(_config: Self::Config) -> Result<Self> { Ok(Self) }
609/// #     async fn run(&mut self, _ctx: &Context) -> Result<()> { Ok(()) }
610/// # }
611/// #[tokio::main]
612/// async fn main() -> Result<()> {
613///     let config = MyConfig { rate: 10.0 };
614///
615///     // Use default health reporting (Normal priority, 5s interval)
616///     let ctx = ContextBuilder::new("my_node").build().await?;
617///     run_node::<MyNode>(config, ctx, HealthReportingConfig::default()).await?;
618///
619///     // Critical priority for safety-critical nodes (1s interval)
620///     let config = MyConfig { rate: 10.0 };
621///     let ctx = ContextBuilder::new("my_node").build().await?;
622///     run_node::<MyNode>(config, ctx, HealthReportingConfig::critical()).await?;
623///
624///     // Background priority for auxiliary nodes (30s interval)
625///     let config = MyConfig { rate: 10.0 };
626///     let ctx = ContextBuilder::new("my_node").build().await?;
627///     run_node::<MyNode>(config, ctx, HealthReportingConfig::background()).await?;
628///
629///     Ok(())
630/// }
631/// ```
632pub async fn run_node<T: NodeImpl>(
633    config: T::Config,
634    ctx: Context,
635    health_config: HealthReportingConfig,
636) -> Result<()> {
637    crate::prelude::init_logging();
638
639    #[cfg(feature = "messaging")]
640    crate::log_publisher::attach(ctx.node_id(), ctx.clone());
641
642    let node = Node::<T>::from_config(config).await?;
643
644    #[cfg(feature = "messaging")]
645    {
646        if health_config.enabled {
647            let node_for_health = node.node.clone();
648            let static_diagnostics_for_health = node.static_diagnostics();
649            let ctx_for_health = ctx.clone();
650            let priority = health_config.priority;
651            let report_interval = health_config.interval();
652            let ttl_seconds = health_config.ttl_seconds();
653            let store_history = health_config.store_history;
654
655            tokio::spawn(async move {
656                use crate::health::HealthReporter;
657
658                let reporter = match HealthReporter::with_config_and_type_tags(
659                    &ctx_for_health,
660                    priority,
661                    ttl_seconds,
662                    store_history,
663                    T::NODE_TYPE,
664                    T::NODE_TAGS,
665                )
666                .await
667                {
668                    Ok(r) => r,
669                    Err(e) => {
670                        warn!("Health reporting disabled: {}", e);
671                        return;
672                    }
673                };
674
675                debug!(
676                    "Health reporting enabled: priority={}, interval={:?}, ttl={}s, history={}",
677                    priority, report_interval, ttl_seconds, store_history
678                );
679
680                let mut interval = tokio::time::interval(report_interval);
681
682                loop {
683                    interval.tick().await;
684                    debug!("Health tick - checking node health");
685
686                    let msg_status = match node_for_health.try_lock() {
687                        Ok(node) => {
688                            let status = node.health_check().await;
689                            let diagnostics = node.health_diagnostics();
690                            crate::messages::system::HealthStatus {
691                                timestamp: status.last_check,
692                                level: if status.healthy {
693                                    crate::messages::system::HealthLevel::Ok
694                                } else {
695                                    crate::messages::system::HealthLevel::Error
696                                },
697                                message: status.message.unwrap_or_else(|| "No message".to_string()),
698                                diagnostics,
699                            }
700                        }
701                        Err(_) => {
702                            // `run()` holds `node_for_health`'s lock for the node's entire
703                            // life (see `Node::start`), so this branch is in practice taken
704                            // on every tick once the node is actually running - this isn't an
705                            // occasional contention fallback. Fall back to the post-init
706                            // `static_diagnostics` snapshot (see its field doc comment on
707                            // `Node<T>`) rather than `None`, so an override like
708                            // `VideoStreamerNode::health_diagnostics()` - whose `camera_ids`
709                            // never changes after `init()` anyway - still gets reported here
710                            // instead of silently disappearing the moment the node starts
711                            // running.
712                            debug!("Node busy, reporting as healthy");
713                            crate::messages::system::HealthStatus {
714                                timestamp: crate::prelude::now_micros(),
715                                level: crate::messages::system::HealthLevel::Ok,
716                                message: "Node running (busy)".to_string(),
717                                diagnostics: static_diagnostics_for_health.clone(),
718                            }
719                        }
720                    };
721
722                    if let Err(e) = reporter.report(msg_status).await {
723                        warn!("Failed to report health: {}", e);
724                    } else {
725                        tracing::trace!("Health report sent successfully");
726                    }
727                }
728            });
729        } else {
730            info!("Health reporting disabled");
731        }
732    }
733    #[cfg(not(feature = "messaging"))]
734    let _ = &health_config;
735
736    node.start(ctx).await
737}
738
739#[cfg(all(test, not(feature = "messaging")))]
740mod tests {
741    use super::*;
742    use crate::error::Mecha10Error;
743    use std::sync::atomic::{AtomicBool, Ordering};
744    use std::sync::Arc as StdArc;
745
746    #[derive(Debug, Default, serde::Deserialize)]
747    struct FakeConfig;
748
749    #[derive(Debug)]
750    struct OkNode {
751        ran: StdArc<AtomicBool>,
752    }
753
754    #[async_trait]
755    impl NodeImpl for OkNode {
756        type Config = FakeConfig;
757
758        async fn init(_config: Self::Config) -> Result<Self> {
759            Ok(Self {
760                ran: StdArc::new(AtomicBool::new(false)),
761            })
762        }
763
764        async fn run(&mut self, _ctx: &Context) -> Result<()> {
765            self.ran.store(true, Ordering::SeqCst);
766            Ok(())
767        }
768    }
769
770    #[derive(Debug)]
771    struct RejectingPreflightNode {
772        ran: StdArc<AtomicBool>,
773    }
774
775    #[async_trait]
776    impl NodeImpl for RejectingPreflightNode {
777        type Config = FakeConfig;
778
779        async fn init(_config: Self::Config) -> Result<Self> {
780            Ok(Self {
781                ran: StdArc::new(AtomicBool::new(false)),
782            })
783        }
784
785        async fn run(&mut self, _ctx: &Context) -> Result<()> {
786            self.ran.store(true, Ordering::SeqCst);
787            Ok(())
788        }
789
790        fn preflight(&self) -> Result<()> {
791            Err(Mecha10Error::Configuration("boom: bad node content".to_string()))
792        }
793    }
794
795    #[tokio::test]
796    async fn default_preflight_is_a_noop_and_run_still_executes() {
797        let node = Node::<OkNode>::from_config(FakeConfig).await.unwrap();
798        let ctx = Context::new("test-node-ok").await.unwrap();
799
800        let ran_handle = { node.node.lock().await.ran.clone() };
801
802        node.start(ctx).await.unwrap();
803
804        assert!(ran_handle.load(Ordering::SeqCst), "run() should have executed");
805    }
806
807    #[tokio::test]
808    async fn start_fails_fast_when_preflight_rejects_config_and_never_calls_run() {
809        let node = Node::<RejectingPreflightNode>::from_config(FakeConfig).await.unwrap();
810        let ctx = Context::new("test-node-rejecting").await.unwrap();
811
812        let ran_handle = { node.node.lock().await.ran.clone() };
813
814        let result = node.start(ctx).await;
815
816        assert!(result.is_err());
817        assert!(result.unwrap_err().to_string().contains("boom: bad node content"));
818        assert!(
819            !ran_handle.load(Ordering::SeqCst),
820            "run() must not execute when preflight() rejects the node"
821        );
822    }
823
824    #[test]
825    fn node_type_kebab_case_matches_existing_builtin_node_types_strings() {
826        let expected: &[(NodeType, &str)] = &[
827            (NodeType::Imu, "imu"),
828            (NodeType::Camera, "camera"),
829            (NodeType::ImageClassifier, "image-classifier"),
830            (NodeType::ObjectDetector, "object-detector"),
831            (NodeType::BehaviorExecutor, "behavior-executor"),
832            (NodeType::LlmCommand, "llm-command"),
833            (NodeType::Teleop, "teleop"),
834            (NodeType::Motor, "motor"),
835            (NodeType::WebsocketBridge, "websocket-bridge"),
836            (NodeType::VideoStreamer, "video-streamer"),
837            (NodeType::Simulator, "simulator"),
838            (NodeType::Speaker, "speaker"),
839            (NodeType::Listener, "listener"),
840            (NodeType::Rl, "rl"),
841            (NodeType::InstanceTrack, "instance-track"),
842            (NodeType::Diagnostics, "diagnostics"),
843        ];
844
845        assert_eq!(expected.len(), NodeType::ALL.len());
846
847        for (variant, kebab) in expected {
848            let json = serde_json::to_string(variant).unwrap();
849            assert_eq!(json, format!("\"{}\"", kebab));
850            assert_eq!(variant.as_str(), *kebab);
851
852            let round_tripped: NodeType = serde_json::from_str(&json).unwrap();
853            assert_eq!(round_tripped, *variant);
854        }
855    }
856
857    #[test]
858    fn node_tag_kebab_case_matches_expected_strings() {
859        let expected: &[(NodeTag, &str)] = &[
860            (NodeTag::Sensor, "sensor"),
861            (NodeTag::Inertial, "inertial"),
862            (NodeTag::Capture, "capture"),
863            (NodeTag::Ai, "ai"),
864            (NodeTag::Classifier, "classifier"),
865            (NodeTag::Detector, "detector"),
866            (NodeTag::Vision, "vision"),
867            (NodeTag::Planner, "planner"),
868            (NodeTag::Control, "control"),
869            (NodeTag::Nlp, "nlp"),
870            (NodeTag::Input, "input"),
871            (NodeTag::Actuator, "actuator"),
872            (NodeTag::Motion, "motion"),
873            (NodeTag::Relay, "relay"),
874            (NodeTag::Websocket, "websocket"),
875            (NodeTag::Streamer, "streamer"),
876            (NodeTag::Video, "video"),
877            (NodeTag::Simulator, "simulator"),
878            (NodeTag::Logger, "logger"),
879        ];
880
881        for (variant, kebab) in expected {
882            let json = serde_json::to_string(variant).unwrap();
883            assert_eq!(json, format!("\"{}\"", kebab));
884
885            let round_tripped: NodeTag = serde_json::from_str(&json).unwrap();
886            assert_eq!(round_tripped, *variant);
887        }
888    }
889
890    #[test]
891    fn node_impl_defaults_node_type_to_none_and_tags_to_empty() {
892        assert_eq!(OkNode::NODE_TYPE, None);
893        assert!(OkNode::NODE_TAGS.is_empty());
894    }
895
896    #[test]
897    fn node_impl_defaults_registration_consts() {
898        assert_eq!(OkNode::CRATE_NAME, None);
899        assert_eq!(OkNode::DESCRIPTION, "");
900        assert_eq!(OkNode::DEFAULT_RUN_TARGET, "robot");
901        assert!(OkNode::CATALOG_TAGS.is_empty());
902    }
903}