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}