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 /// # use mecha10_core::Context;
29 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
30 /// let ctx = Context::new("my-node").await?;
31 /// # Ok(())
32 /// # }
33 /// ```
34 pub async fn new(node_id: &str) -> Result<Self> {
35 let node_id = std::env::var("NODE_NAME").unwrap_or_else(|_| node_id.to_string());
36
37 // Multi-instance support (LAB-4131): when node-runner spawns a node with an
38 // `instance_id` (from `mecha10.json`'s `{"name": ..., "instance_id": "real"}` node-list
39 // entry shape), it sets `NODE_INSTANCE_ID` on the child process's environment instead of
40 // baking the instance into `NODE_NAME` itself, so this applies uniformly to every node
41 // via `Context::new` - no per-node opt-in required. This has the same effect as an
42 // explicit `.with_instance(..)` call (see below): `node_id` becomes
43 // "{node_id}/{instance}" and `Self::instance` is set, so config loading
44 // (`load_node_config`) and health reporting both pick it up automatically.
45 let instance = std::env::var("NODE_INSTANCE_ID").ok().filter(|s| !s.is_empty());
46 let node_id = match &instance {
47 Some(instance) => format!("{}/{}", node_id, instance),
48 None => node_id,
49 };
50 #[cfg(feature = "messaging")]
51 let (message_bus, control_plane_bus, redis_bridge) = {
52 let config = Self::load_infrastructure_config()?;
53
54 let redis_url = Self::get_redis_url_from_config(&config)?;
55 let local_bus = mecha10_messaging::MessageBus::connect(&redis_url, &node_id)
56 .await
57 .with_context(|| {
58 format!(
59 "Failed to connect local message bus for node '{}' to Redis at '{}'",
60 node_id, redis_url
61 )
62 })
63 .map_err(|e| Mecha10Error::MessagingError {
64 message: format!("{:#}", e),
65 suggestion: "Ensure local Redis is running at the configured URL".to_string(),
66 })?;
67
68 let cp_bus = if let Some(cp_config) = &config.control_plane_redis {
69 let cp_url = &cp_config.url;
70 match mecha10_messaging::MessageBus::connect(cp_url, &node_id).await {
71 Ok(bus) => {
72 tracing::info!(
73 "Connected to control plane Redis at '{}' for node '{}'",
74 cp_url,
75 node_id
76 );
77 Some(bus)
78 }
79 Err(e) => {
80 tracing::warn!(
81 "Failed to connect to control plane Redis at '{}': {}. Continuing with local Redis only.",
82 cp_url,
83 e
84 );
85 None
86 }
87 }
88 } else {
89 None
90 };
91
92 let bridge = if let Some(_cp_bus_ref) = &cp_bus {
93 let bridge_config = config.bridge.unwrap_or_default();
94
95 if bridge_config.enabled {
96 let bridge_local_bus = mecha10_messaging::MessageBus::connect(&redis_url, &node_id)
97 .await
98 .map_err(|e| Mecha10Error::MessagingError {
99 message: format!("Failed to create bridge connection to local Redis: {}", e),
100 suggestion: "Check local Redis connection".to_string(),
101 })?;
102
103 let cp_url = config
104 .control_plane_redis
105 .as_ref()
106 .map(|c| c.url.clone())
107 .unwrap_or_default();
108 let bridge_cp_bus = mecha10_messaging::MessageBus::connect(&cp_url, &node_id)
109 .await
110 .map_err(|e| Mecha10Error::MessagingError {
111 message: format!("Failed to create bridge connection to control plane Redis: {}", e),
112 suggestion: "Check control plane Redis connection".to_string(),
113 })?;
114
115 let redis_bridge_config = crate::redis_bridge::BridgeConfig {
116 enabled: bridge_config.enabled,
117 local_topics: bridge_config.local_topics,
118 bridged_topics: bridge_config.bridged_topics,
119 robot_id: bridge_config.robot_id,
120 };
121
122 let bridge =
123 crate::redis_bridge::RedisBridge::new(redis_bridge_config, bridge_local_bus, bridge_cp_bus);
124
125 if let Err(e) = bridge.start().await {
126 tracing::warn!("Failed to start Redis bridge: {}. Continuing without bridge.", e);
127 None
128 } else {
129 tracing::info!("Redis bridge started successfully");
130 Some(bridge)
131 }
132 } else {
133 tracing::debug!("Redis bridge is disabled in configuration");
134 None
135 }
136 } else {
137 None
138 };
139
140 (local_bus, cp_bus, bridge)
141 };
142
143 Ok(Self {
144 node_id,
145 instance,
146 #[cfg(feature = "messaging")]
147 message_bus: Arc::new(RwLock::new(message_bus)),
148 #[cfg(feature = "messaging")]
149 control_plane_bus: Arc::new(RwLock::new(control_plane_bus)),
150 #[cfg(feature = "messaging")]
151 redis_bridge: Arc::new(RwLock::new(redis_bridge)),
152 shutdown: Arc::new(RwLock::new(false)),
153 state_manager: Arc::new(RwLock::new(None)),
154 blackboard: crate::blackboard::Blackboard::new(),
155 })
156 }
157
158 /// Set an instance name for this context (builder pattern)
159 ///
160 /// This allows the same node implementation to run multiple instances
161 /// with different names. The node_id becomes "{node_name}/{instance}".
162 ///
163 /// # Example
164 ///
165 /// ```rust
166 /// # use mecha10_core::Context;
167 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
168 /// // Single instance (no instance name)
169 /// let ctx = Context::new("camera_fake").await?;
170 /// // node_id: "camera_fake"
171 ///
172 /// // Multi-instance (with instance names)
173 /// let left_ctx = Context::new("camera_fake").await?.with_instance("left");
174 /// // node_id: "camera_fake/left"
175 ///
176 /// let right_ctx = Context::new("camera_fake").await?.with_instance("right");
177 /// // node_id: "camera_fake/right"
178 /// # Ok(())
179 /// # }
180 /// ```
181 pub fn with_instance(mut self, instance: &str) -> Self {
182 self.instance = Some(instance.to_string());
183 self.node_id = format!("{}/{}", self.node_id, instance);
184 self
185 }
186
187 /// Get the instance name if set
188 ///
189 /// Returns `None` for single-instance nodes, or `Some(instance)` for
190 /// multi-instance nodes.
191 pub fn instance(&self) -> Option<&str> {
192 self.instance.as_deref()
193 }
194
195 /// Get the node ID
196 pub fn node_id(&self) -> &str {
197 &self.node_id
198 }
199
200 /// Check if control plane Redis connection is available
201 #[cfg(feature = "messaging")]
202 pub async fn has_control_plane(&self) -> bool {
203 self.control_plane_bus.read().await.is_some()
204 }
205
206 /// Check if shutdown has been requested
207 pub async fn is_shutdown(&self) -> bool {
208 *self.shutdown.read().await
209 }
210
211 /// Request shutdown
212 pub async fn shutdown(&self) {
213 *self.shutdown.write().await = true;
214
215 #[cfg(feature = "messaging")]
216 {
217 let bridge_opt = self.redis_bridge.read().await;
218 if let Some(bridge) = bridge_opt.as_ref() {
219 if let Err(e) = bridge.stop().await {
220 tracing::warn!("Failed to stop Redis bridge during shutdown: {}", e);
221 }
222 }
223 }
224 }
225
226 /// Wait for shutdown signal
227 pub async fn wait_for_shutdown(&self) {
228 loop {
229 if self.is_shutdown().await {
230 break;
231 }
232 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
233 }
234 }
235
236 /// Enter a logging span with this node's context
237 ///
238 /// This returns a guard that, when entered, adds the node_id to all log messages.
239 /// The span is automatically exited when the guard is dropped.
240 ///
241 /// # Example
242 ///
243 /// ```rust
244 /// use mecha10_core::prelude::*;
245 ///
246 /// # async fn example(ctx: &Context) -> Result<()> {
247 /// let _guard = ctx.logging_span().entered();
248 /// info!("This message includes node_id automatically");
249 /// debug!("So does this one");
250 /// # Ok(())
251 /// # }
252 /// ```
253 pub fn logging_span(&self) -> tracing::Span {
254 tracing::info_span!("node", node_id = %self.node_id)
255 }
256}