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