mecha10_core/context/lifecycle.rs
1//! Context construction, instance naming, shutdown signaling, and logging span.
2
3use super::Context;
4#[cfg(feature = "messaging")]
5use crate::error::Mecha10Error;
6use crate::error::Result;
7#[cfg(feature = "messaging")]
8use anyhow::Context as _;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12impl Context {
13 /// Create a new context
14 ///
15 /// # Arguments
16 ///
17 /// * `node_id` - Unique identifier for this node (fallback if NODE_NAME env var not set)
18 ///
19 /// # Environment Variables
20 ///
21 /// * `NODE_NAME` - If set, overrides the `node_id` parameter. This allows node-runner
22 /// to set full identifiers like `@mecha10/speaker` for consistent naming across
23 /// the CLI, dashboard, and health reporting.
24 ///
25 /// # Example
26 ///
27 /// ```rust
28 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
29 /// let ctx = Context::new("my-node").await?;
30 /// # Ok(())
31 /// # }
32 /// ```
33 pub async fn new(node_id: &str) -> Result<Self> {
34 // Use NODE_NAME from environment if set (for consistent naming with node-runner)
35 // This allows bundled nodes to report health with full identifiers like @mecha10/speaker
36 let node_id = std::env::var("NODE_NAME").unwrap_or_else(|_| node_id.to_string());
37 #[cfg(feature = "messaging")]
38 let (message_bus, control_plane_bus, redis_bridge) = {
39 // Load infrastructure config
40 let config = Self::load_infrastructure_config()?;
41
42 // Connect to local Redis
43 let redis_url = Self::get_redis_url_from_config(&config)?;
44 let local_bus = mecha10_messaging::MessageBus::connect(&redis_url, &node_id)
45 .await
46 .with_context(|| {
47 format!(
48 "Failed to connect local message bus for node '{}' to Redis at '{}'",
49 node_id, redis_url
50 )
51 })
52 .map_err(|e| Mecha10Error::MessagingError {
53 message: format!("{:#}", e),
54 suggestion: "Ensure local Redis is running at the configured URL".to_string(),
55 })?;
56
57 // Connect to control plane Redis if configured
58 let cp_bus = if let Some(cp_config) = &config.control_plane_redis {
59 let cp_url = &cp_config.url;
60 match mecha10_messaging::MessageBus::connect(cp_url, &node_id).await {
61 Ok(bus) => {
62 tracing::info!(
63 "Connected to control plane Redis at '{}' for node '{}'",
64 cp_url,
65 node_id
66 );
67 Some(bus)
68 }
69 Err(e) => {
70 tracing::warn!(
71 "Failed to connect to control plane Redis at '{}': {}. Continuing with local Redis only.",
72 cp_url,
73 e
74 );
75 None
76 }
77 }
78 } else {
79 None
80 };
81
82 // Initialize Redis bridge if both connections are available
83 let bridge = if let Some(_cp_bus_ref) = &cp_bus {
84 // Get bridge configuration (use default if not specified)
85 let bridge_config = config.bridge.unwrap_or_default();
86
87 if bridge_config.enabled {
88 // Create bridge with separate clones of the buses
89 let bridge_local_bus = mecha10_messaging::MessageBus::connect(&redis_url, &node_id)
90 .await
91 .map_err(|e| Mecha10Error::MessagingError {
92 message: format!("Failed to create bridge connection to local Redis: {}", e),
93 suggestion: "Check local Redis connection".to_string(),
94 })?;
95
96 let cp_url = config
97 .control_plane_redis
98 .as_ref()
99 .map(|c| c.url.clone())
100 .unwrap_or_default();
101 let bridge_cp_bus = mecha10_messaging::MessageBus::connect(&cp_url, &node_id)
102 .await
103 .map_err(|e| Mecha10Error::MessagingError {
104 message: format!("Failed to create bridge connection to control plane Redis: {}", e),
105 suggestion: "Check control plane Redis connection".to_string(),
106 })?;
107
108 // Convert from infra_config::BridgeConfig to redis_bridge::BridgeConfig
109 let redis_bridge_config = crate::redis_bridge::BridgeConfig {
110 enabled: bridge_config.enabled,
111 local_topics: bridge_config.local_topics,
112 bridged_topics: bridge_config.bridged_topics,
113 robot_id: bridge_config.robot_id,
114 };
115
116 let bridge =
117 crate::redis_bridge::RedisBridge::new(redis_bridge_config, bridge_local_bus, bridge_cp_bus);
118
119 // Start the bridge
120 if let Err(e) = bridge.start().await {
121 tracing::warn!("Failed to start Redis bridge: {}. Continuing without bridge.", e);
122 None
123 } else {
124 tracing::info!("Redis bridge started successfully");
125 Some(bridge)
126 }
127 } else {
128 tracing::debug!("Redis bridge is disabled in configuration");
129 None
130 }
131 } else {
132 None
133 };
134
135 (local_bus, cp_bus, bridge)
136 };
137
138 Ok(Self {
139 node_id,
140 instance: None,
141 #[cfg(feature = "messaging")]
142 message_bus: Arc::new(RwLock::new(message_bus)),
143 #[cfg(feature = "messaging")]
144 control_plane_bus: Arc::new(RwLock::new(control_plane_bus)),
145 #[cfg(feature = "messaging")]
146 redis_bridge: Arc::new(RwLock::new(redis_bridge)),
147 shutdown: Arc::new(RwLock::new(false)),
148 state_manager: Arc::new(RwLock::new(None)),
149 })
150 }
151
152 /// Set an instance name for this context (builder pattern)
153 ///
154 /// This allows the same node implementation to run multiple instances
155 /// with different names. The node_id becomes "{node_name}/{instance}".
156 ///
157 /// # Example
158 ///
159 /// ```rust
160 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
161 /// // Single instance (no instance name)
162 /// let ctx = Context::new("camera_fake").await?;
163 /// // node_id: "camera_fake"
164 ///
165 /// // Multi-instance (with instance names)
166 /// let left_ctx = Context::new("camera_fake").await?.with_instance("left");
167 /// // node_id: "camera_fake/left"
168 ///
169 /// let right_ctx = Context::new("camera_fake").await?.with_instance("right");
170 /// // node_id: "camera_fake/right"
171 /// # Ok(())
172 /// # }
173 /// ```
174 pub fn with_instance(mut self, instance: &str) -> Self {
175 self.instance = Some(instance.to_string());
176 self.node_id = format!("{}/{}", self.node_id, instance);
177 self
178 }
179
180 /// Get the instance name if set
181 ///
182 /// Returns `None` for single-instance nodes, or `Some(instance)` for
183 /// multi-instance nodes.
184 pub fn instance(&self) -> Option<&str> {
185 self.instance.as_deref()
186 }
187
188 /// Get the node ID
189 pub fn node_id(&self) -> &str {
190 &self.node_id
191 }
192
193 /// Check if control plane Redis connection is available
194 #[cfg(feature = "messaging")]
195 pub async fn has_control_plane(&self) -> bool {
196 self.control_plane_bus.read().await.is_some()
197 }
198
199 /// Check if shutdown has been requested
200 pub async fn is_shutdown(&self) -> bool {
201 *self.shutdown.read().await
202 }
203
204 /// Request shutdown
205 pub async fn shutdown(&self) {
206 *self.shutdown.write().await = true;
207
208 // Stop Redis bridge if running
209 #[cfg(feature = "messaging")]
210 {
211 let bridge_opt = self.redis_bridge.read().await;
212 if let Some(bridge) = bridge_opt.as_ref() {
213 if let Err(e) = bridge.stop().await {
214 tracing::warn!("Failed to stop Redis bridge during shutdown: {}", e);
215 }
216 }
217 }
218 }
219
220 /// Wait for shutdown signal
221 pub async fn wait_for_shutdown(&self) {
222 loop {
223 if self.is_shutdown().await {
224 break;
225 }
226 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
227 }
228 }
229
230 /// Enter a logging span with this node's context
231 ///
232 /// This returns a guard that, when entered, adds the node_id to all log messages.
233 /// The span is automatically exited when the guard is dropped.
234 ///
235 /// # Example
236 ///
237 /// ```rust
238 /// use mecha10::prelude::*;
239 ///
240 /// # async fn example(ctx: &Context) -> Result<()> {
241 /// let _guard = ctx.logging_span().entered();
242 /// info!("This message includes node_id automatically");
243 /// debug!("So does this one");
244 /// # Ok(())
245 /// # }
246 /// ```
247 pub fn logging_span(&self) -> tracing::Span {
248 tracing::info_span!("node", node_id = %self.node_id)
249 }
250}