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}