mecha10-core 0.8.0

Mecha10 core runtime and foundational types
Documentation
//! Lifecycle event schemas for mode-based node management
//!
//! This module defines the event types used for dynamic lifecycle management:
//! - Mode change requests and confirmations
//! - Node lifecycle events
//! - Node status reporting
//!
//! # Event Flow
//!
//! ```text
//! CLI → ModeRequest → Runtime Lifecycle → Supervisor → Nodes
//!                                       ↓
//!                                 ModeChanged
//! ```
//!
//! # Example
//!
//! ```rust,ignore
//! use mecha10_core::lifecycle::*;
//!
//! // Request mode change
//! context.publish(
//!     TOPIC_MODE_REQUEST,
//!     &ModeRequest {
//!         mode: "simulation".to_string(),
//!         requested_by: "cli".to_string(),
//!         timestamp: get_timestamp_ms(),
//!     }
//! ).await?;
//!
//! // Listen for mode changes
//! let mut sub = context.subscribe(TOPIC_MODE_CHANGED).await?;
//! while let Some(msg) = sub.next().await {
//!     let event: ModeChanged = msg.decode()?;
//!     println!("Mode changed to: {}", event.current_mode);
//! }
//! ```

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModeRequest {
    pub mode: String,

    pub requested_by: String,

    pub timestamp: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModeChanged {
    pub previous_mode: String,

    pub current_mode: String,

    pub nodes_started: Vec<String>,

    pub nodes_stopped: Vec<String>,

    pub timestamp: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModeError {
    pub mode: String,

    pub error: String,

    pub current_mode: String,

    pub timestamp: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeLifecycleEvent {
    pub node: String,

    pub action: NodeLifecycleAction,

    pub reason: String,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub target_mode: Option<String>,

    #[serde(default = "default_graceful_timeout")]
    pub graceful_timeout_ms: u64,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub config: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NodeLifecycleAction {
    Start,

    Stop,

    Kill,

    Restart,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeStatusEvent {
    pub node: String,

    pub status: NodeLifecycleStatus,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pid: Option<u32>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,

    pub timestamp: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NodeLifecycleStatus {
    Starting,

    Started,

    Running,

    Stopping,

    Stopped,

    Failed,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NodeTriggerEvent {
    pub node: String,

    pub timestamp: u64,
}

impl crate::messages::Message for NodeTriggerEvent {}

pub const TOPIC_MODE_REQUEST: &str = "system/lifecycle/mode/request";

pub const TOPIC_MODE_CHANGED: &str = "system/lifecycle/mode/changed";

pub const TOPIC_MODE_ERROR: &str = "system/lifecycle/mode/error";

/// Get topic for node lifecycle events (internal)
///
/// Format: `system/node/{node}/lifecycle`
///
/// Payload: [`NodeLifecycleEvent`]
///
/// Note: Primarily for internal runtime use
pub fn topic_node_lifecycle(node: &str) -> String {
    format!("system/node/{}/lifecycle", node)
}

/// Get topic for node status events
///
/// Format: `system/node/{node}/status`
///
/// Payload: [`NodeStatusEvent`]
pub fn topic_node_status(node: &str) -> String {
    format!("system/node/{}/status", node)
}

/// Get topic for one-shot dashboard trigger events
///
/// Format: `system/node/{node}/trigger`
///
/// Payload: [`NodeTriggerEvent`]
pub fn topic_node_trigger(node: &str) -> String {
    format!("system/node/{}/trigger", node)
}

/// Default graceful shutdown timeout (5 seconds)
fn default_graceful_timeout() -> u64 {
    5000
}

/// Get current timestamp in milliseconds since Unix epoch
pub fn get_timestamp_ms() -> u64 {
    crate::prelude::now_millis()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_mode_request_serialization() {
        let request = ModeRequest {
            mode: "simulation".to_string(),
            requested_by: "cli".to_string(),
            timestamp: 1234567890,
        };

        let json = serde_json::to_string(&request).unwrap();
        let deserialized: ModeRequest = serde_json::from_str(&json).unwrap();

        assert_eq!(request, deserialized);
    }

    #[test]
    fn test_mode_changed_serialization() {
        let changed = ModeChanged {
            previous_mode: "startup".to_string(),
            current_mode: "simulation".to_string(),
            nodes_started: vec!["sim-bridge".to_string(), "object-detector".to_string()],
            nodes_stopped: vec!["imu".to_string(), "motor".to_string()],
            timestamp: 1234567890,
        };

        let json = serde_json::to_string(&changed).unwrap();
        let deserialized: ModeChanged = serde_json::from_str(&json).unwrap();

        assert_eq!(changed, deserialized);
    }

    #[test]
    fn test_node_lifecycle_action_serialization() {
        let actions = vec![
            NodeLifecycleAction::Start,
            NodeLifecycleAction::Stop,
            NodeLifecycleAction::Kill,
            NodeLifecycleAction::Restart,
        ];

        for action in actions {
            let json = serde_json::to_string(&action).unwrap();
            let deserialized: NodeLifecycleAction = serde_json::from_str(&json).unwrap();
            assert_eq!(action, deserialized);
        }
    }

    #[test]
    fn test_node_lifecycle_status_serialization() {
        let statuses = vec![
            NodeLifecycleStatus::Starting,
            NodeLifecycleStatus::Started,
            NodeLifecycleStatus::Running,
            NodeLifecycleStatus::Stopping,
            NodeLifecycleStatus::Stopped,
            NodeLifecycleStatus::Failed,
        ];

        for status in statuses {
            let json = serde_json::to_string(&status).unwrap();
            let deserialized: NodeLifecycleStatus = serde_json::from_str(&json).unwrap();
            assert_eq!(status, deserialized);
        }
    }

    #[test]
    fn test_topic_helpers() {
        assert_eq!(topic_node_lifecycle("test-node"), "system/node/test-node/lifecycle");
        assert_eq!(topic_node_status("test-node"), "system/node/test-node/status");
        assert_eq!(topic_node_trigger("test-node"), "system/node/test-node/trigger");
    }

    #[test]
    fn test_node_trigger_event_serialization() {
        let event = NodeTriggerEvent {
            node: "image-classifier".to_string(),
            timestamp: 1234567890,
        };

        let json = serde_json::to_string(&event).unwrap();
        let deserialized: NodeTriggerEvent = serde_json::from_str(&json).unwrap();

        assert_eq!(event, deserialized);
    }

    #[test]
    fn test_default_graceful_timeout() {
        let event = NodeLifecycleEvent {
            node: "test".to_string(),
            action: NodeLifecycleAction::Stop,
            reason: "test".to_string(),
            target_mode: None,
            graceful_timeout_ms: default_graceful_timeout(),
            config: None,
        };

        assert_eq!(event.graceful_timeout_ms, 5000);
    }

    #[test]
    fn test_get_timestamp_ms() {
        let ts1 = get_timestamp_ms();
        std::thread::sleep(std::time::Duration::from_millis(10));
        let ts2 = get_timestamp_ms();

        assert!(ts2 > ts1);
        assert!(ts2 - ts1 >= 10);
    }

    #[test]
    fn test_node_status_event_optional_fields() {
        let event1 = NodeStatusEvent {
            node: "test".to_string(),
            status: NodeLifecycleStatus::Starting,
            pid: None,
            error: None,
            timestamp: 1234567890,
        };

        let json1 = serde_json::to_string(&event1).unwrap();
        assert!(!json1.contains("\"pid\""));
        assert!(!json1.contains("\"error\""));

        let event2 = NodeStatusEvent {
            node: "test".to_string(),
            status: NodeLifecycleStatus::Running,
            pid: Some(12345),
            error: Some("test error".to_string()),
            timestamp: 1234567890,
        };

        let json2 = serde_json::to_string(&event2).unwrap();
        assert!(json2.contains("\"pid\""));
        assert!(json2.contains("\"error\""));
    }
}