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 std::collections::HashMap;
13
14impl ProcessService {
15    /// Spawn a node using mecha10-node-runner
16    ///
17    /// This is the simplified spawning method for Phase 2+ of Node Lifecycle Architecture.
18    /// It delegates all complexity (binary resolution, model pulling, env setup) to node-runner.
19    ///
20    /// # Returns
21    ///
22    /// Process ID of the spawned node-runner instance
23    ///
24    /// # Errors
25    ///
26    /// Returns an error if the node-runner cannot be spawned
27    ///
28    /// # Configuration
29    ///
30    /// The node-runner reads configuration from the node's config file (e.g., `configs/nodes/{node_name}.json`)
31    /// and supports the following runtime settings:
32    ///
33    /// ```json
34    /// {
35    ///   "runtime": {
36    ///     "restart_policy": "on-failure",  // never, on-failure, always
37    ///     "max_retries": 3,
38    ///     "backoff_secs": 1
39    ///   },
40    ///   "depends_on": ["camera", "lidar"],
41    ///   "startup_timeout_secs": 30
42    /// }
43    /// ```
44    ///
45    /// To enable dependency checking, use: `mecha10-node-runner --wait-for-deps <node-name>`
46    ///
47    /// # Arguments
48    ///
49    /// * `node_identifier` - Full node identifier (e.g., `@mecha10/simulator`, `my-node`)
50    /// * `project_name` - Name of the current project, forwarded to node-runner as `--project`.
51    ///   Without this, node-runner falls back to guessing the project name from its own
52    ///   current working directory's basename (see its `resolve_node_execution`) to find the
53    ///   monorepo project binary at `target/{release,debug}/{project_name}` - a guess that
54    ///   silently breaks whenever the working directory's name doesn't match the actual
55    ///   Cargo package/binary name (e.g. a checkout cloned into a differently-named directory).
56    ///   That misresolution is what made bundled framework nodes (`@mecha10/*`) appear to fail
57    ///   under node-runner: with no monorepo binary found under the guessed name, node-runner
58    ///   falls through to its bundled-node fallback, which shells out to the globally installed
59    ///   `mecha10` CLI as `mecha10 node <name>` - a subcommand the `mecha10` CLI binary
60    ///   (`packages/cli`) doesn't define, so that fallback always fails too. Passing the real
61    ///   project name explicitly removes the guess entirely.
62    pub fn spawn_node_runner(
63        &mut self,
64        node_identifier: &str,
65        project_name: &str,
66        project_env: Option<HashMap<String, String>>,
67    ) -> Result<u32> {
68        let runner_path = Self::resolve_node_runner_path();
69
70        let env = project_env.unwrap_or_default();
71
72        let args = node_runner_args(project_name, node_identifier);
73        self.spawn_with_env(node_identifier, &runner_path, &args, env)
74    }
75
76    /// Spawn a node directly (standalone mode)
77    ///
78    /// This is used when mecha10-node-runner is not available (standalone projects
79    /// installed from crates.io). Handles both framework nodes (via BinaryManager)
80    /// and local project nodes (built and run directly).
81    ///
82    /// Resolution strategy for framework nodes:
83    /// 1. Check ~/.mecha10/bin/ for pre-downloaded binary
84    /// 2. If not found, attempt to download from GitHub releases
85    /// 3. If download fails, fall back to cargo install
86    ///
87    /// # Arguments
88    ///
89    /// * `node_name` - Name of the node to run
90    /// * `project_name` - Name of the project (used to find the binary)
91    /// * `project_env` - Optional additional environment variables from project config
92    ///
93    /// # Returns
94    ///
95    /// Process ID of the spawned node process
96    pub fn spawn_node_direct(
97        &mut self,
98        node_identifier: &str,
99        _project_name: &str,
100        project_env: Option<HashMap<String, String>>,
101    ) -> Result<u32> {
102        let node_name = strip_node_scope(node_identifier);
103
104        let is_local_node = self.is_local_project_node(node_name);
105
106        let mut env = HashMap::new();
107        env.insert("NODE_NAME".to_string(), node_identifier.to_string());
108        if let Ok(rust_log) = std::env::var("RUST_LOG") {
109            env.insert("RUST_LOG".to_string(), rust_log);
110        }
111
112        if let Some(project_vars) = project_env {
113            tracing::debug!(
114                "🔀 spawn_node_direct for '{}': merging {} project vars",
115                node_name,
116                project_vars.len()
117            );
118            for (key, value) in &project_vars {
119                tracing::debug!(
120                    "  Adding: {} = {}",
121                    key,
122                    if key == "ROBOT_API_KEY" { "<redacted>" } else { value }
123                );
124            }
125            env.extend(project_vars);
126        } else {
127            tracing::warn!("⚠️  spawn_node_direct for '{}': No project_env provided!", node_name);
128        }
129
130        tracing::debug!(
131            "🏁 spawn_node_direct for '{}': final env has {} vars, is_local: {}",
132            node_name,
133            env.len(),
134            is_local_node
135        );
136
137        if is_local_node {
138            self.spawn_local_node_with_identifier(node_identifier, node_name, env)
139        } else {
140            self.spawn_framework_node(node_identifier, node_name, env)
141        }
142    }
143
144    /// Spawn a framework node using BinaryManager for binary resolution
145    ///
146    /// This method resolves the binary path using BinaryManager, which:
147    /// 1. Checks the local cache (~/.mecha10/bin/)
148    /// 2. Downloads from GitHub releases if needed
149    /// 3. Falls back to cargo install for unsupported platforms
150    fn spawn_framework_node(
151        &mut self,
152        node_identifier: &str,
153        node_name: &str,
154        env: HashMap<String, String>,
155    ) -> Result<u32> {
156        use super::super::binary_manager::BinaryManager;
157
158        let version = self.get_node_version_from_config(node_identifier);
159
160        let binary_manager = BinaryManager::new()?;
161
162        let binary_path = tokio::task::block_in_place(|| {
163            tokio::runtime::Handle::current()
164                .block_on(binary_manager.resolve_with_version(node_name, version.as_deref()))
165        })?;
166
167        tracing::info!(
168            "🚀 Spawning framework node '{}' from {}",
169            node_name,
170            binary_path.display()
171        );
172
173        self.spawn_with_env(node_identifier, &binary_path.to_string_lossy(), &[], env)
174    }
175
176    /// Spawn a local project node with full identifier for tracking
177    ///
178    /// Builds the node with cargo and runs the binary directly.
179    /// Registers the process with the full identifier (e.g., my-node) for consistent tracking.
180    fn spawn_local_node_with_identifier(
181        &mut self,
182        node_identifier: &str,
183        node_name: &str,
184        env: HashMap<String, String>,
185    ) -> Result<u32> {
186        use std::process::Command;
187
188        tracing::info!("🔨 Building local node: {}", node_name);
189
190        let build_output = Command::new("cargo")
191            .args(["build", "-p", node_name])
192            .output()
193            .context("Failed to run cargo build")?;
194
195        if !build_output.status.success() {
196            let stderr = String::from_utf8_lossy(&build_output.stderr);
197            anyhow::bail!("Failed to build node '{}': {}", node_name, stderr);
198        }
199
200        tracing::info!("✅ Built local node: {}", node_name);
201
202        let binary_path = paths::target_path("debug", node_name);
203
204        if !std::path::Path::new(&binary_path).exists() {
205            anyhow::bail!(
206                "Binary not found at '{}'. Build may have failed or the package name differs from node name.",
207                binary_path
208            );
209        }
210
211        self.spawn_with_env(node_identifier, &binary_path, &[], env)
212    }
213}
214
215/// Build the argument list passed to `mecha10-node-runner` for a given node.
216///
217/// Split out from [`ProcessService::spawn_node_runner`] as a pure function so the
218/// `--project` wiring (see that method's doc comment for why it matters - without it,
219/// node-runner silently misresolves bundled framework nodes) is unit testable without
220/// spawning a real subprocess.
221fn node_runner_args<'a>(project_name: &'a str, node_identifier: &'a str) -> [&'a str; 3] {
222    ["--project", project_name, strip_node_scope(node_identifier)]
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn node_runner_args_passes_project_name_and_strips_bundled_node_scope() {
231        let args = node_runner_args("rover-robot", "@mecha10/simulator");
232        assert_eq!(args, ["--project", "rover-robot", "simulator"]);
233    }
234
235    #[test]
236    fn node_runner_args_passes_project_name_and_strips_local_node_scope() {
237        let args = node_runner_args("rover-robot", "@local/my-node");
238        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
239    }
240
241    #[test]
242    fn node_runner_args_leaves_unscoped_node_name_untouched() {
243        let args = node_runner_args("rover-robot", "my-node");
244        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
245    }
246}