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
17    ///
18    /// This is the simplified spawning method for Phase 2+ of Node Lifecycle Architecture.
19    /// It delegates all complexity (binary resolution, model pulling, env setup) to node-runner.
20    ///
21    /// # Returns
22    ///
23    /// Process ID of the spawned node-runner instance
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if the node-runner cannot be spawned
28    ///
29    /// # Configuration
30    ///
31    /// The node-runner reads configuration from the node's config file (e.g., `configs/nodes/{node_name}.json`)
32    /// and supports the following runtime settings:
33    ///
34    /// ```json
35    /// {
36    ///   "runtime": {
37    ///     "restart_policy": "on-failure",  // never, on-failure, always
38    ///     "max_retries": 3,
39    ///     "backoff_secs": 1
40    ///   },
41    ///   "depends_on": ["camera", "lidar"],
42    ///   "startup_timeout_secs": 30
43    /// }
44    /// ```
45    ///
46    /// To enable dependency checking, use: `mecha10-node-runner --wait-for-deps <node-name>`
47    ///
48    /// # Arguments
49    ///
50    /// * `node_identifier` - Full node identifier (e.g., `@mecha10/simulator`, `my-node`)
51    /// * `project_name` - Name of the current project, forwarded to node-runner as the
52    ///   required `--project` flag, used to find the monorepo project binary at
53    ///   `target/{release,debug}/{project_name}`.
54    pub fn spawn_node_runner(
55        &mut self,
56        node_identifier: &str,
57        project_name: &str,
58        project_env: Option<HashMap<String, String>>,
59    ) -> Result<u32> {
60        let runner_path = Self::resolve_node_runner_path();
61
62        let env = project_env.unwrap_or_default();
63
64        let (base_identifier, instance_id, camera_id) = split_wire_identifier(node_identifier);
65
66        let args = node_runner_args(project_name, base_identifier, instance_id, camera_id);
67        let args: Vec<&str> = args.iter().map(String::as_str).collect();
68        self.spawn_with_env(node_identifier, &runner_path, &args, env)
69    }
70
71    /// Spawn a node directly (standalone mode)
72    ///
73    /// This is used when mecha10-node-runner is not available (standalone projects
74    /// installed from crates.io). Handles both framework nodes (via BinaryManager)
75    /// and local project nodes (built and run directly).
76    ///
77    /// Resolution strategy for framework nodes:
78    /// 1. Check ~/.mecha10/bin/ for a pre-downloaded binary
79    /// 2. If not found, download it via `mecha10-api`
80    ///
81    /// There is no further fallback: an unsupported platform or a failed download is a
82    /// hard error (see `BinaryManager::resolve_with_version`).
83    ///
84    /// # Arguments
85    ///
86    /// * `node_name` - Name of the node to run
87    /// * `project_name` - Name of the project (used to find the binary)
88    /// * `project_env` - Optional additional environment variables from project config
89    ///
90    /// # Returns
91    ///
92    /// Process ID of the spawned node process
93    pub fn spawn_node_direct(
94        &mut self,
95        node_identifier: &str,
96        _project_name: &str,
97        project_env: Option<HashMap<String, String>>,
98    ) -> Result<u32> {
99        let (base_identifier, instance_id, camera_id) = split_wire_identifier(node_identifier);
100        let node_name = strip_node_scope(base_identifier);
101
102        let is_local_node = self.is_local_project_node(node_name);
103
104        let mut env = HashMap::new();
105        env.insert("NODE_NAME".to_string(), base_identifier.to_string());
106        if let Some(instance_id) = instance_id {
107            env.insert("NODE_INSTANCE_ID".to_string(), instance_id.to_string());
108        }
109        if let Some(camera_id) = camera_id {
110            env.insert("MECHA10_CAMERA_ID".to_string(), camera_id.to_string());
111        }
112        if let Ok(rust_log) = std::env::var("RUST_LOG") {
113            env.insert("RUST_LOG".to_string(), rust_log);
114        }
115
116        if let Some(project_vars) = project_env {
117            tracing::debug!(
118                "🔀 spawn_node_direct for '{}': merging {} project vars",
119                node_name,
120                project_vars.len()
121            );
122            for (key, value) in &project_vars {
123                tracing::debug!(
124                    "  Adding: {} = {}",
125                    key,
126                    if key == "ROBOT_API_KEY" { "<redacted>" } else { value }
127                );
128            }
129            env.extend(project_vars);
130        } else {
131            tracing::warn!("⚠️  spawn_node_direct for '{}': No project_env provided!", node_name);
132        }
133
134        tracing::debug!(
135            "🏁 spawn_node_direct for '{}': final env has {} vars, is_local: {}",
136            node_name,
137            env.len(),
138            is_local_node
139        );
140
141        if is_local_node {
142            self.spawn_local_node_with_identifier(node_identifier, node_name, env)
143        } else {
144            self.spawn_framework_node(node_identifier, node_name, env)
145        }
146    }
147
148    /// Spawn a framework node using BinaryManager for binary resolution
149    ///
150    /// This method resolves the binary path using BinaryManager, which:
151    /// 1. Checks the local cache (~/.mecha10/bin/)
152    /// 2. Downloads via `mecha10-api` if needed
153    ///
154    /// An unsupported platform or a failed download errors immediately — there is no
155    /// cargo-install fallback.
156    fn spawn_framework_node(
157        &mut self,
158        node_identifier: &str,
159        node_name: &str,
160        env: HashMap<String, String>,
161    ) -> Result<u32> {
162        use super::super::binary_manager::BinaryManager;
163
164        let (base_identifier, _instance_id, _camera_id) = split_wire_identifier(node_identifier);
165        let version = self.get_node_version_from_config(base_identifier);
166
167        let binary_manager = BinaryManager::new()?;
168
169        let binary_path = tokio::task::block_in_place(|| {
170            tokio::runtime::Handle::current()
171                .block_on(binary_manager.resolve_with_version(node_name, version.as_deref()))
172        })?;
173
174        tracing::info!(
175            "🚀 Spawning framework node '{}' from {}",
176            node_name,
177            binary_path.display()
178        );
179
180        self.spawn_with_env(node_identifier, &binary_path.to_string_lossy(), &[], env)
181    }
182
183    /// Spawn a local project node with full identifier for tracking
184    ///
185    /// Builds the node with cargo and runs the binary directly.
186    /// Registers the process with the full identifier (e.g., my-node) for consistent tracking.
187    fn spawn_local_node_with_identifier(
188        &mut self,
189        node_identifier: &str,
190        node_name: &str,
191        env: HashMap<String, String>,
192    ) -> Result<u32> {
193        use std::process::Command;
194
195        tracing::info!("🔨 Building local node: {}", node_name);
196
197        let build_output = Command::new("cargo")
198            .args(["build", "-p", node_name])
199            .output()
200            .context("Failed to run cargo build")?;
201
202        if !build_output.status.success() {
203            let stderr = String::from_utf8_lossy(&build_output.stderr);
204            anyhow::bail!("Failed to build node '{}': {}", node_name, stderr);
205        }
206
207        tracing::info!("✅ Built local node: {}", node_name);
208
209        let binary_path = paths::target_path("debug", node_name);
210
211        if !std::path::Path::new(&binary_path).exists() {
212            anyhow::bail!(
213                "Binary not found at '{}'. Build may have failed or the package name differs from node name.",
214                binary_path
215            );
216        }
217
218        self.spawn_with_env(node_identifier, &binary_path, &[], env)
219    }
220}
221
222/// Build the argument list passed to `mecha10-node-runner` for a given node.
223///
224/// `base_identifier` is passed through unchanged (no scope stripping): `node-runner` has no
225/// upfront "is this a framework node" classification to derive - it just tries a local
226/// project/monorepo binary first, then falls back to resolving (and downloading if needed) a
227/// standalone binary from `mecha10-api`, stripping any `@scope/` namespace itself only where
228/// that lookup needs the bare name.
229///
230/// Split out from [`ProcessService::spawn_node_runner`] as a pure function so the
231/// required `--project`/`--instance-id`/`--camera-id` wiring is unit testable without spawning a
232/// real subprocess.
233fn node_runner_args(
234    project_name: &str,
235    base_identifier: &str,
236    instance_id: Option<&str>,
237    camera_id: Option<&str>,
238) -> Vec<String> {
239    let mut args = vec![
240        "--project".to_string(),
241        project_name.to_string(),
242        base_identifier.to_string(),
243    ];
244    if let Some(instance_id) = instance_id {
245        args.push("--instance-id".to_string());
246        args.push(instance_id.to_string());
247    }
248    if let Some(camera_id) = camera_id {
249        args.push("--camera-id".to_string());
250        args.push(camera_id.to_string());
251    }
252    args
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn node_runner_args_passes_the_scoped_identifier_through_unchanged() {
261        let args = node_runner_args("rover-robot", "@mecha10/simulator", None, None);
262        assert_eq!(args, ["--project", "rover-robot", "@mecha10/simulator"]);
263    }
264
265    #[test]
266    fn node_runner_args_leaves_unscoped_node_name_untouched() {
267        let args = node_runner_args("rover-robot", "my-node", None, None);
268        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
269    }
270
271    /// `LAB-4131`: an instance id appends `--instance-id <id>` for node-runner to forward on
272    /// to the actual node binary's environment.
273    #[test]
274    fn node_runner_args_appends_instance_id_flag_when_present() {
275        let args = node_runner_args("rover-robot", "@mecha10/camera", Some("real"), None);
276        assert_eq!(
277            args,
278            ["--project", "rover-robot", "@mecha10/camera", "--instance-id", "real"]
279        );
280    }
281
282    /// `LAB-4747`: a `camera_id` appends `--camera-id <id>` independently of `instance_id`.
283    #[test]
284    fn node_runner_args_appends_camera_id_flag_when_present() {
285        let args = node_runner_args("rover-robot", "@mecha10/video-streamer", None, Some("camera"));
286        assert_eq!(
287            args,
288            [
289                "--project",
290                "rover-robot",
291                "@mecha10/video-streamer",
292                "--camera-id",
293                "camera"
294            ]
295        );
296    }
297
298    #[test]
299    fn node_runner_args_appends_both_flags_when_both_present() {
300        let args = node_runner_args(
301            "rover-robot",
302            "@mecha10/video-streamer",
303            Some("sim-streamer"),
304            Some("simulation_camera"),
305        );
306        assert_eq!(
307            args,
308            [
309                "--project",
310                "rover-robot",
311                "@mecha10/video-streamer",
312                "--instance-id",
313                "sim-streamer",
314                "--camera-id",
315                "simulation_camera"
316            ]
317        );
318    }
319}