mecha10_cli/services/process/
spawn_node.rs1use super::ProcessService;
9use crate::paths;
10use anyhow::{Context, Result};
11use mecha10_cli_core::utils::strip_node_scope;
12use mecha10_core::wire_identifier::split_wire_identifier;
13use std::collections::HashMap;
14
15impl ProcessService {
16 pub fn spawn_node_runner(
20 &mut self,
21 node_identifier: &str,
22 project_name: &str,
23 project_env: Option<HashMap<String, String>>,
24 ) -> Result<u32> {
25 let runner_path = Self::resolve_node_runner_path();
26
27 let env = project_env.unwrap_or_default();
28
29 let (base_identifier, instance_id, camera_id) = split_wire_identifier(node_identifier);
30
31 let args = node_runner_args(project_name, base_identifier, instance_id, camera_id);
32 let args: Vec<&str> = args.iter().map(String::as_str).collect();
33 self.spawn_with_env(node_identifier, &runner_path, &args, env)
34 }
35
36 pub fn spawn_node_direct(
41 &mut self,
42 node_identifier: &str,
43 _project_name: &str,
44 project_env: Option<HashMap<String, String>>,
45 ) -> Result<u32> {
46 let (base_identifier, instance_id, camera_id) = split_wire_identifier(node_identifier);
47 let node_name = strip_node_scope(base_identifier);
48
49 let is_local_node = self.is_local_project_node(node_name);
50
51 let mut env = HashMap::new();
52 env.insert("NODE_NAME".to_string(), base_identifier.to_string());
53 if let Some(instance_id) = instance_id {
54 env.insert("NODE_INSTANCE_ID".to_string(), instance_id.to_string());
55 }
56 if let Some(camera_id) = camera_id {
57 env.insert("MECHA10_CAMERA_ID".to_string(), camera_id.to_string());
58 }
59 if let Ok(rust_log) = std::env::var("RUST_LOG") {
60 env.insert("RUST_LOG".to_string(), rust_log);
61 }
62
63 insert_ort_dylib_path(node_name, &mut env)?;
64
65 if let Some(project_vars) = project_env {
66 tracing::debug!(
67 "🔀 spawn_node_direct for '{}': merging {} project vars",
68 node_name,
69 project_vars.len()
70 );
71 for (key, value) in &project_vars {
72 tracing::debug!(
73 " Adding: {} = {}",
74 key,
75 if key == "ROBOT_API_KEY" { "<redacted>" } else { value }
76 );
77 }
78 env.extend(project_vars);
79 } else {
80 tracing::warn!("⚠️ spawn_node_direct for '{}': No project_env provided!", node_name);
81 }
82
83 tracing::debug!(
84 "🏁 spawn_node_direct for '{}': final env has {} vars, is_local: {}",
85 node_name,
86 env.len(),
87 is_local_node
88 );
89
90 if is_local_node {
91 self.spawn_local_node_with_identifier(node_identifier, node_name, env)
92 } else {
93 self.spawn_framework_node(node_identifier, node_name, env)
94 }
95 }
96
97 fn spawn_framework_node(
100 &mut self,
101 node_identifier: &str,
102 node_name: &str,
103 mut env: HashMap<String, String>,
104 ) -> Result<u32> {
105 use super::super::binary_manager::BinaryManager;
106
107 insert_ort_dylib_path(node_name, &mut env)?;
108
109 let (base_identifier, _instance_id, _camera_id) = split_wire_identifier(node_identifier);
110 let version = self.get_node_version_from_config(base_identifier);
111
112 let binary_manager = BinaryManager::new()?;
113
114 let binary_path = tokio::task::block_in_place(|| {
115 tokio::runtime::Handle::current()
116 .block_on(binary_manager.resolve_with_version(node_name, version.as_deref()))
117 })?;
118
119 tracing::info!(
120 "🚀 Spawning framework node '{}' from {}",
121 node_name,
122 binary_path.display()
123 );
124
125 self.spawn_with_env(node_identifier, &binary_path.to_string_lossy(), &[], env)
126 }
127
128 fn spawn_local_node_with_identifier(
131 &mut self,
132 node_identifier: &str,
133 node_name: &str,
134 env: HashMap<String, String>,
135 ) -> Result<u32> {
136 use std::process::Command;
137
138 tracing::info!("🔨 Building local node: {}", node_name);
139
140 let build_output = Command::new("cargo")
141 .args(["build", "-p", node_name])
142 .output()
143 .context("Failed to run cargo build")?;
144
145 if !build_output.status.success() {
146 let stderr = String::from_utf8_lossy(&build_output.stderr);
147 anyhow::bail!("Failed to build node '{}': {}", node_name, stderr);
148 }
149
150 tracing::info!("✅ Built local node: {}", node_name);
151
152 let binary_path = paths::target_path("debug", node_name);
153
154 if !std::path::Path::new(&binary_path).exists() {
155 anyhow::bail!(
156 "Binary not found at '{}'. Build may have failed or the package name differs from node name.",
157 binary_path
158 );
159 }
160
161 self.spawn_with_env(node_identifier, &binary_path, &[], env)
162 }
163}
164
165fn node_runner_args(
170 project_name: &str,
171 base_identifier: &str,
172 instance_id: Option<&str>,
173 camera_id: Option<&str>,
174) -> Vec<String> {
175 let mut args = vec![
176 "--project".to_string(),
177 project_name.to_string(),
178 base_identifier.to_string(),
179 ];
180 if let Some(instance_id) = instance_id {
181 args.push("--instance-id".to_string());
182 args.push(instance_id.to_string());
183 }
184 if let Some(camera_id) = camera_id {
185 args.push("--camera-id".to_string());
186 args.push(camera_id.to_string());
187 }
188 args
189}
190
191const ONNX_DEPENDENT_NODES: [&str; 4] = ["image-classifier", "instance-track", "object-detector", "rl"];
196
197fn insert_ort_dylib_path(node_name: &str, env: &mut HashMap<String, String>) -> Result<()> {
206 if !cfg!(target_os = "macos") {
207 return Ok(());
208 }
209
210 let dylib_path = paths::user::onnxruntime_dylib_path();
211 if dylib_path.exists() {
212 env.insert("ORT_DYLIB_PATH".to_string(), dylib_path.to_string_lossy().into_owned());
213 return Ok(());
214 }
215
216 if is_onnx_dependent_node(node_name) {
217 anyhow::bail!(
218 "ONNX Runtime not found (needed by node '{}') - run `mecha10 setup` first",
219 node_name
220 );
221 }
222
223 Ok(())
224}
225
226fn is_onnx_dependent_node(node_name: &str) -> bool {
229 ONNX_DEPENDENT_NODES.contains(&node_name)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn node_runner_args_passes_the_scoped_identifier_through_unchanged() {
238 let args = node_runner_args("rover-robot", "@mecha10/simulator", None, None);
239 assert_eq!(args, ["--project", "rover-robot", "@mecha10/simulator"]);
240 }
241
242 #[test]
243 fn node_runner_args_leaves_unscoped_node_name_untouched() {
244 let args = node_runner_args("rover-robot", "my-node", None, None);
245 assert_eq!(args, ["--project", "rover-robot", "my-node"]);
246 }
247
248 #[test]
251 fn node_runner_args_appends_instance_id_flag_when_present() {
252 let args = node_runner_args("rover-robot", "@mecha10/camera", Some("real"), None);
253 assert_eq!(
254 args,
255 ["--project", "rover-robot", "@mecha10/camera", "--instance-id", "real"]
256 );
257 }
258
259 #[test]
261 fn node_runner_args_appends_camera_id_flag_when_present() {
262 let args = node_runner_args("rover-robot", "@mecha10/video-streamer", None, Some("camera"));
263 assert_eq!(
264 args,
265 [
266 "--project",
267 "rover-robot",
268 "@mecha10/video-streamer",
269 "--camera-id",
270 "camera"
271 ]
272 );
273 }
274
275 #[test]
276 fn is_onnx_dependent_node_matches_only_the_four_onnx_backed_nodes() {
277 assert!(is_onnx_dependent_node("image-classifier"));
278 assert!(is_onnx_dependent_node("instance-track"));
279 assert!(is_onnx_dependent_node("object-detector"));
280 assert!(is_onnx_dependent_node("rl"));
281
282 assert!(!is_onnx_dependent_node("simulator"));
283 assert!(!is_onnx_dependent_node("video-streamer"));
284 assert!(!is_onnx_dependent_node(""));
285 }
286
287 #[cfg(not(target_os = "macos"))]
291 #[test]
292 fn insert_ort_dylib_path_is_a_complete_no_op_off_macos() {
293 let mut env = HashMap::new();
294 let result = insert_ort_dylib_path("image-classifier", &mut env);
295
296 assert!(result.is_ok());
297 assert!(env.is_empty());
298 }
299
300 #[test]
301 fn node_runner_args_appends_both_flags_when_both_present() {
302 let args = node_runner_args(
303 "rover-robot",
304 "@mecha10/video-streamer",
305 Some("sim-streamer"),
306 Some("simulation_camera"),
307 );
308 assert_eq!(
309 args,
310 [
311 "--project",
312 "rover-robot",
313 "@mecha10/video-streamer",
314 "--instance-id",
315 "sim-streamer",
316 "--camera-id",
317 "simulation_camera"
318 ]
319 );
320 }
321}