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        if let Some(project_vars) = project_env {
64            tracing::debug!(
65                "🔀 spawn_node_direct for '{}': merging {} project vars",
66                node_name,
67                project_vars.len()
68            );
69            for (key, value) in &project_vars {
70                tracing::debug!(
71                    "  Adding: {} = {}",
72                    key,
73                    if key == "ROBOT_API_KEY" { "<redacted>" } else { value }
74                );
75            }
76            env.extend(project_vars);
77        } else {
78            tracing::warn!("⚠️  spawn_node_direct for '{}': No project_env provided!", node_name);
79        }
80
81        tracing::debug!(
82            "🏁 spawn_node_direct for '{}': final env has {} vars, is_local: {}",
83            node_name,
84            env.len(),
85            is_local_node
86        );
87
88        if is_local_node {
89            self.spawn_local_node_with_identifier(node_identifier, node_name, env)
90        } else {
91            self.spawn_framework_node(node_identifier, node_name, env)
92        }
93    }
94
95    /// Spawn a framework node using BinaryManager for binary resolution: checks the local
96    /// cache (~/.mecha10/bin/) first, then downloads via `mecha10-api` if needed.
97    fn spawn_framework_node(
98        &mut self,
99        node_identifier: &str,
100        node_name: &str,
101        env: HashMap<String, String>,
102    ) -> Result<u32> {
103        use super::super::binary_manager::BinaryManager;
104
105        let (base_identifier, _instance_id, _camera_id) = split_wire_identifier(node_identifier);
106        let version = self.get_node_version_from_config(base_identifier);
107
108        let binary_manager = BinaryManager::new()?;
109
110        let binary_path = tokio::task::block_in_place(|| {
111            tokio::runtime::Handle::current()
112                .block_on(binary_manager.resolve_with_version(node_name, version.as_deref()))
113        })?;
114
115        tracing::info!(
116            "🚀 Spawning framework node '{}' from {}",
117            node_name,
118            binary_path.display()
119        );
120
121        self.spawn_with_env(node_identifier, &binary_path.to_string_lossy(), &[], env)
122    }
123
124    /// Spawn a local project node with full identifier for tracking: builds the node with
125    /// cargo and runs the binary directly.
126    fn spawn_local_node_with_identifier(
127        &mut self,
128        node_identifier: &str,
129        node_name: &str,
130        env: HashMap<String, String>,
131    ) -> Result<u32> {
132        use std::process::Command;
133
134        tracing::info!("🔨 Building local node: {}", node_name);
135
136        let build_output = Command::new("cargo")
137            .args(["build", "-p", node_name])
138            .output()
139            .context("Failed to run cargo build")?;
140
141        if !build_output.status.success() {
142            let stderr = String::from_utf8_lossy(&build_output.stderr);
143            anyhow::bail!("Failed to build node '{}': {}", node_name, stderr);
144        }
145
146        tracing::info!("✅ Built local node: {}", node_name);
147
148        let binary_path = paths::target_path("debug", node_name);
149
150        if !std::path::Path::new(&binary_path).exists() {
151            anyhow::bail!(
152                "Binary not found at '{}'. Build may have failed or the package name differs from node name.",
153                binary_path
154            );
155        }
156
157        self.spawn_with_env(node_identifier, &binary_path, &[], env)
158    }
159}
160
161/// Build the argument list passed to `mecha10-node-runner` for a given node.
162///
163/// `base_identifier` is passed through unchanged (no scope stripping): that's `node-runner`'s
164/// own responsibility when it needs the bare name.
165fn node_runner_args(
166    project_name: &str,
167    base_identifier: &str,
168    instance_id: Option<&str>,
169    camera_id: Option<&str>,
170) -> Vec<String> {
171    let mut args = vec![
172        "--project".to_string(),
173        project_name.to_string(),
174        base_identifier.to_string(),
175    ];
176    if let Some(instance_id) = instance_id {
177        args.push("--instance-id".to_string());
178        args.push(instance_id.to_string());
179    }
180    if let Some(camera_id) = camera_id {
181        args.push("--camera-id".to_string());
182        args.push(camera_id.to_string());
183    }
184    args
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn node_runner_args_passes_the_scoped_identifier_through_unchanged() {
193        let args = node_runner_args("rover-robot", "@mecha10/simulator", None, None);
194        assert_eq!(args, ["--project", "rover-robot", "@mecha10/simulator"]);
195    }
196
197    #[test]
198    fn node_runner_args_leaves_unscoped_node_name_untouched() {
199        let args = node_runner_args("rover-robot", "my-node", None, None);
200        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
201    }
202
203    /// `LAB-4131`: an instance id appends `--instance-id <id>` for node-runner to forward on
204    /// to the actual node binary's environment.
205    #[test]
206    fn node_runner_args_appends_instance_id_flag_when_present() {
207        let args = node_runner_args("rover-robot", "@mecha10/camera", Some("real"), None);
208        assert_eq!(
209            args,
210            ["--project", "rover-robot", "@mecha10/camera", "--instance-id", "real"]
211        );
212    }
213
214    /// `LAB-4747`: a `camera_id` appends `--camera-id <id>` independently of `instance_id`.
215    #[test]
216    fn node_runner_args_appends_camera_id_flag_when_present() {
217        let args = node_runner_args("rover-robot", "@mecha10/video-streamer", None, Some("camera"));
218        assert_eq!(
219            args,
220            [
221                "--project",
222                "rover-robot",
223                "@mecha10/video-streamer",
224                "--camera-id",
225                "camera"
226            ]
227        );
228    }
229
230    #[test]
231    fn node_runner_args_appends_both_flags_when_both_present() {
232        let args = node_runner_args(
233            "rover-robot",
234            "@mecha10/video-streamer",
235            Some("sim-streamer"),
236            Some("simulation_camera"),
237        );
238        assert_eq!(
239            args,
240            [
241                "--project",
242                "rover-robot",
243                "@mecha10/video-streamer",
244                "--instance-id",
245                "sim-streamer",
246                "--camera-id",
247                "simulation_camera"
248            ]
249        );
250    }
251}