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        // Simply spawn: mecha10-node-runner --project <project_name> <node-name>
69        // The node-runner handles:
70        // - Binary path resolution (monorepo vs local)
71        // - Model pulling (if node needs AI models)
72        // - Environment setup
73        // - Log redirection
74        // - Restart policies (from config)
75        // - Health monitoring
76        // - Dependency checking (if --wait-for-deps enabled)
77        // - Actual node execution
78
79        // Resolve path to mecha10-node-runner binary
80        let runner_path = Self::resolve_node_runner_path();
81
82        // Build environment - include project vars for config substitution
83        let env = project_env.unwrap_or_default();
84
85        // Register with full identifier (e.g., @mecha10/listener) for consistent tracking
86        let args = node_runner_args(project_name, node_identifier);
87        self.spawn_with_env(node_identifier, &runner_path, &args, env)
88    }
89
90    /// Spawn a node directly (standalone mode)
91    ///
92    /// This is used when mecha10-node-runner is not available (standalone projects
93    /// installed from crates.io). Handles both framework nodes (via BinaryManager)
94    /// and local project nodes (built and run directly).
95    ///
96    /// Resolution strategy for framework nodes:
97    /// 1. Check ~/.mecha10/bin/ for pre-downloaded binary
98    /// 2. If not found, attempt to download from GitHub releases
99    /// 3. If download fails, fall back to cargo install
100    ///
101    /// # Arguments
102    ///
103    /// * `node_name` - Name of the node to run
104    /// * `project_name` - Name of the project (used to find the binary)
105    /// * `project_env` - Optional additional environment variables from project config
106    ///
107    /// # Returns
108    ///
109    /// Process ID of the spawned node process
110    pub fn spawn_node_direct(
111        &mut self,
112        node_identifier: &str,
113        _project_name: &str,
114        project_env: Option<HashMap<String, String>>,
115    ) -> Result<u32> {
116        // Extract node name from full identifier (e.g., @mecha10/listener -> listener)
117        let node_name = strip_node_scope(node_identifier);
118
119        // Check if this is a local project node by looking at mecha10.json
120        let is_local_node = self.is_local_project_node(node_name);
121
122        let mut env = HashMap::new();
123        // Use full identifier for NODE_NAME so health reports and dashboard are consistent
124        env.insert("NODE_NAME".to_string(), node_identifier.to_string());
125        if let Ok(rust_log) = std::env::var("RUST_LOG") {
126            env.insert("RUST_LOG".to_string(), rust_log);
127        }
128
129        // Add project environment variables (REDIS_URL, control plane, robot info, etc.)
130        if let Some(project_vars) = project_env {
131            tracing::debug!(
132                "🔀 spawn_node_direct for '{}': merging {} project vars",
133                node_name,
134                project_vars.len()
135            );
136            for (key, value) in &project_vars {
137                tracing::debug!(
138                    "  Adding: {} = {}",
139                    key,
140                    if key == "ROBOT_API_KEY" { "<redacted>" } else { value }
141                );
142            }
143            env.extend(project_vars);
144        } else {
145            tracing::warn!("⚠️  spawn_node_direct for '{}': No project_env provided!", node_name);
146        }
147
148        tracing::debug!(
149            "🏁 spawn_node_direct for '{}': final env has {} vars, is_local: {}",
150            node_name,
151            env.len(),
152            is_local_node
153        );
154
155        if is_local_node {
156            // Local project node: build and run the binary directly
157            // Register with full identifier (e.g., my-node) for consistent tracking
158            self.spawn_local_node_with_identifier(node_identifier, node_name, env)
159        } else {
160            // Framework node: resolve binary via BinaryManager
161            // This will download from GitHub releases if not cached
162            self.spawn_framework_node(node_identifier, node_name, env)
163        }
164    }
165
166    /// Spawn a framework node using BinaryManager for binary resolution
167    ///
168    /// This method resolves the binary path using BinaryManager, which:
169    /// 1. Checks the local cache (~/.mecha10/bin/)
170    /// 2. Downloads from GitHub releases if needed
171    /// 3. Falls back to cargo install for unsupported platforms
172    fn spawn_framework_node(
173        &mut self,
174        node_identifier: &str,
175        node_name: &str,
176        env: HashMap<String, String>,
177    ) -> Result<u32> {
178        use super::super::binary_manager::BinaryManager;
179
180        // Try to get version from project config
181        let version = self.get_node_version_from_config(node_identifier);
182
183        // Try to resolve binary via BinaryManager
184        let binary_manager = BinaryManager::new()?;
185
186        // Use block_in_place to run async code in sync context
187        let binary_path = tokio::task::block_in_place(|| {
188            tokio::runtime::Handle::current()
189                .block_on(binary_manager.resolve_with_version(node_name, version.as_deref()))
190        })?;
191
192        tracing::info!(
193            "🚀 Spawning framework node '{}' from {}",
194            node_name,
195            binary_path.display()
196        );
197
198        // Spawn the binary directly
199        self.spawn_with_env(node_identifier, &binary_path.to_string_lossy(), &[], env)
200    }
201
202    /// Spawn a local project node with full identifier for tracking
203    ///
204    /// Builds the node with cargo and runs the binary directly.
205    /// Registers the process with the full identifier (e.g., my-node) for consistent tracking.
206    fn spawn_local_node_with_identifier(
207        &mut self,
208        node_identifier: &str,
209        node_name: &str,
210        env: HashMap<String, String>,
211    ) -> Result<u32> {
212        use std::process::Command;
213
214        // First, build the node
215        tracing::info!("🔨 Building local node: {}", node_name);
216
217        let build_output = Command::new("cargo")
218            .args(["build", "-p", node_name])
219            .output()
220            .context("Failed to run cargo build")?;
221
222        if !build_output.status.success() {
223            let stderr = String::from_utf8_lossy(&build_output.stderr);
224            anyhow::bail!("Failed to build node '{}': {}", node_name, stderr);
225        }
226
227        tracing::info!("✅ Built local node: {}", node_name);
228
229        // Run the binary from target/debug/<name>
230        let binary_path = paths::target_path("debug", node_name);
231
232        if !std::path::Path::new(&binary_path).exists() {
233            anyhow::bail!(
234                "Binary not found at '{}'. Build may have failed or the package name differs from node name.",
235                binary_path
236            );
237        }
238
239        // Register with full identifier for consistent tracking
240        self.spawn_with_env(node_identifier, &binary_path, &[], env)
241    }
242}
243
244/// Build the argument list passed to `mecha10-node-runner` for a given node.
245///
246/// Split out from [`ProcessService::spawn_node_runner`] as a pure function so the
247/// `--project` wiring (see that method's doc comment for why it matters - without it,
248/// node-runner silently misresolves bundled framework nodes) is unit testable without
249/// spawning a real subprocess.
250fn node_runner_args<'a>(project_name: &'a str, node_identifier: &'a str) -> [&'a str; 3] {
251    // node-runner only needs the short name (e.g. `@mecha10/simulator` -> `simulator`) as
252    // its positional argument.
253    ["--project", project_name, strip_node_scope(node_identifier)]
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    // ---- node_runner_args --------------------------------------------------------------
261
262    #[test]
263    fn node_runner_args_passes_project_name_and_strips_bundled_node_scope() {
264        let args = node_runner_args("rover-robot", "@mecha10/simulator");
265        assert_eq!(args, ["--project", "rover-robot", "simulator"]);
266    }
267
268    #[test]
269    fn node_runner_args_passes_project_name_and_strips_local_node_scope() {
270        let args = node_runner_args("rover-robot", "@local/my-node");
271        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
272    }
273
274    #[test]
275    fn node_runner_args_leaves_unscoped_node_name_untouched() {
276        let args = node_runner_args("rover-robot", "my-node");
277        assert_eq!(args, ["--project", "rover-robot", "my-node"]);
278    }
279}