Skip to main content

mecha10_cli/services/process/
spawn_node.rs

1//! Node-oriented spawning for `ProcessService`
2//!
3//! Higher-level spawning that resolves what a "node" actually is and how to
4//! run it: via `mecha10-node-runner` (framework dev mode), or directly in
5//! standalone mode (framework nodes via `BinaryManager`, or local project
6//! nodes built and run in-place).
7
8use 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    /// Spawn a node using mecha10-node-runner, which handles binary resolution, model pulling,
17    /// and env setup. Returns the process ID of the spawned node-runner instance, or an error if
18    /// it cannot be spawned.
19    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    /// Spawn a node directly (standalone mode)
37    ///
38    /// This is used when mecha10-node-runner is not available. Handles both framework nodes
39    /// (via BinaryManager) and local project nodes (built and run directly).
40    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    /// Spawn a framework node using BinaryManager for binary resolution: checks the local
98    /// cache (~/.mecha10/bin/) first, then downloads via `mecha10-api` if needed.
99    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    /// Spawn a local project node with full identifier for tracking: builds the node with
129    /// cargo and runs the binary directly.
130    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
165/// Build the argument list passed to `mecha10-node-runner` for a given node.
166///
167/// `base_identifier` is passed through unchanged (no scope stripping): that's `node-runner`'s
168/// own responsibility when it needs the bare name.
169fn 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
191/// The four `mecha10-nodes` crates that build `ort` with the `load-dynamic` feature (which
192/// skips `ort-sys`'s build-time download+static-link step and defers to a runtime
193/// `ORT_DYLIB_PATH` lookup in `ort::init()` instead). Every other node name is unaffected by
194/// [`insert_ort_dylib_path`].
195const ONNX_DEPENDENT_NODES: [&str; 4] = ["image-classifier", "instance-track", "object-detector", "rl"];
196
197/// On macOS, point the four ONNX-dependent nodes at the `libonnxruntime.dylib` cached by
198/// `mecha10 setup` (`handlers/setup.rs`'s ONNX Runtime step) by setting `ORT_DYLIB_PATH` in
199/// `env` - `ort::init()`'s default env-var lookup then picks it up with no code change needed
200/// in `mecha10-nodes` itself.
201///
202/// Non-ONNX nodes are untouched. For the four ONNX-dependent nodes, a missing dylib is a hard
203/// error rather than a silent skip: without this, the child process panics opaquely with a raw
204/// `dlopen` failure (see this ticket's repro) instead of a message telling the user what to run.
205fn 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
226/// Whether `node_name` is one of the four `mecha10-nodes` crates that need
227/// [`ONNX_DEPENDENT_NODES`]'s `ORT_DYLIB_PATH` wiring to run on macOS.
228fn 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    /// `LAB-4131`: an instance id appends `--instance-id <id>` for node-runner to forward on
249    /// to the actual node binary's environment.
250    #[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    /// `LAB-4747`: a `camera_id` appends `--camera-id <id>` independently of `instance_id`.
260    #[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    /// On every platform `mecha10 dev`/`sim`/`run` actually ships for other than macOS, ONNX
288    /// node spawning must behave exactly as it did before this change - no `ORT_DYLIB_PATH`,
289    /// no error, regardless of whether the dylib happens to be cached.
290    #[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}