mecha10_cli/services/
process.rs

1#![allow(dead_code)]
2
3//! Process service for managing child processes
4//!
5//! This service provides process lifecycle management for CLI commands,
6//! particularly for the `dev` and `run` commands that need to manage
7//! multiple node processes.
8//!
9//! This is a thin wrapper around mecha10-runtime's ProcessManager,
10//! delegating core process management to the runtime layer.
11
12use crate::paths;
13use anyhow::{Context, Result};
14use mecha10_runtime::ProcessManager;
15use std::collections::HashMap;
16use std::path::Path;
17use std::process::{Command, Stdio};
18
19/// Process service for managing child processes
20///
21/// This is a thin wrapper around the runtime's ProcessManager,
22/// adding CLI-specific conveniences and delegating core functionality
23/// to the runtime layer.
24///
25/// # Examples
26///
27/// ```rust,ignore
28/// use mecha10_cli::services::ProcessService;
29///
30/// # async fn example() -> anyhow::Result<()> {
31/// let mut service = ProcessService::new();
32///
33/// // Spawn a node process
34/// service.spawn_node("camera_driver", "target/debug/camera_driver", &[])?;
35///
36/// // Check status
37/// let status = service.get_status();
38/// println!("Running processes: {:?}", status);
39///
40/// // Stop a specific process
41/// service.stop("camera_driver")?;
42///
43/// // Cleanup all processes
44/// service.cleanup();
45/// # Ok(())
46/// # }
47/// ```
48pub struct ProcessService {
49    /// Runtime's process manager (handles core lifecycle)
50    manager: ProcessManager,
51}
52
53impl ProcessService {
54    /// Create a new process service
55    pub fn new() -> Self {
56        Self {
57            manager: ProcessManager::new(),
58        }
59    }
60
61    /// Extract node name from a full identifier
62    ///
63    /// Handles both formats:
64    /// - Full identifier: `@mecha10/simulation-bridge` -> `simulation-bridge`
65    /// - Plain name: `simulation-bridge` -> `simulation-bridge`
66    fn extract_node_name(identifier: &str) -> String {
67        if identifier.starts_with('@') {
68            // Full identifier format: @scope/name
69            identifier.split('/').next_back().unwrap_or(identifier).to_string()
70        } else {
71            identifier.to_string()
72        }
73    }
74
75    /// Track dependency relationship for a node
76    ///
77    /// # Arguments
78    ///
79    /// * `node` - Name of the node
80    /// * `dependencies` - List of nodes this node depends on
81    pub fn track_dependency(&mut self, node: &str, dependencies: Vec<String>) {
82        for dep in dependencies {
83            self.manager.add_dependency(node.to_string(), dep);
84        }
85    }
86
87    /// Get shutdown order (reverse dependency order)
88    ///
89    /// Returns nodes in order they should be stopped:
90    /// - Nodes with dependents first (high-level nodes)
91    /// - Then their dependencies (low-level nodes)
92    ///
93    /// This ensures we don't stop a node while other nodes depend on it.
94    ///
95    /// Delegates to the runtime's ProcessManager.
96    pub fn get_shutdown_order(&self) -> Vec<String> {
97        self.manager.shutdown_order()
98    }
99
100    /// Check if we're in framework development mode
101    ///
102    /// Framework dev mode is detected by MECHA10_FRAMEWORK_PATH environment variable.
103    /// This is the only reliable indicator - .cargo/config.toml can exist in user projects too.
104    pub fn is_framework_dev_mode() -> bool {
105        std::env::var("MECHA10_FRAMEWORK_PATH").is_ok()
106    }
107
108    /// Find globally installed binary for a node
109    ///
110    /// Searches in:
111    /// 1. ~/.cargo/bin/{node_name}
112    /// 2. ~/.mecha10/bin/{node_name}
113    ///
114    /// # Arguments
115    ///
116    /// * `node_name` - Name of the node (e.g., "simulation-bridge")
117    ///
118    /// # Returns
119    ///
120    /// Path to the binary if found, None otherwise
121    pub fn find_global_binary(node_name: &str) -> Option<std::path::PathBuf> {
122        // Try ~/.cargo/bin/ first
123        let cargo_bin = paths::user::cargo_bin(node_name);
124        if cargo_bin.exists() && cargo_bin.is_file() {
125            return Some(cargo_bin);
126        }
127
128        // Try ~/.mecha10/bin/
129        let mecha10_bin = paths::user::bin(node_name);
130        if mecha10_bin.exists() && mecha10_bin.is_file() {
131            return Some(mecha10_bin);
132        }
133
134        None
135    }
136
137    /// Resolve binary path for a node with smart resolution
138    ///
139    /// Resolution strategy:
140    /// 1. If framework dev mode: use local build (target/debug or target/release)
141    /// 2. If global binary exists: use global binary
142    /// 3. Fallback: use local build path
143    ///
144    /// # Arguments
145    ///
146    /// * `node_name` - Name of the node
147    /// * `is_monorepo_node` - Whether this is a framework node
148    /// * `project_name` - Name of the project
149    ///
150    /// # Returns
151    ///
152    /// Path to the binary to execute
153    pub fn resolve_node_binary(node_name: &str, is_monorepo_node: bool, project_name: &str) -> String {
154        // Framework dev mode: always use local builds
155        if Self::is_framework_dev_mode() {
156            return Self::get_local_binary_path(node_name, is_monorepo_node, project_name);
157        }
158
159        // For monorepo (framework) nodes, check for global installation
160        if is_monorepo_node {
161            if let Some(global_path) = Self::find_global_binary(node_name) {
162                return global_path.to_string_lossy().to_string();
163            }
164        }
165
166        // Fallback to local build
167        Self::get_local_binary_path(node_name, is_monorepo_node, project_name)
168    }
169
170    /// Get local binary path (in target/ directory)
171    fn get_local_binary_path(node_name: &str, is_monorepo_node: bool, project_name: &str) -> String {
172        if is_monorepo_node {
173            // Monorepo nodes run via the project binary with 'node' subcommand
174            paths::target_path("release", project_name)
175        } else {
176            // Local nodes have their own binary
177            paths::target_path("release", node_name)
178        }
179    }
180
181    /// Resolve path to mecha10-node-runner binary
182    ///
183    /// Resolution strategy:
184    /// 1. Framework dev mode: $MECHA10_FRAMEWORK_PATH/target/release/mecha10-node-runner
185    /// 2. Global installation: ~/.cargo/bin/mecha10-node-runner
186    /// 3. Fallback: "mecha10-node-runner" (rely on PATH)
187    ///
188    /// # Returns
189    ///
190    /// Path to the mecha10-node-runner binary
191    pub fn resolve_node_runner_path() -> String {
192        // Framework dev mode: use framework's target directory
193        if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
194            let framework_binary =
195                std::path::PathBuf::from(&framework_path).join(paths::framework::NODE_RUNNER_RELEASE);
196
197            if framework_binary.exists() {
198                return framework_binary.to_string_lossy().to_string();
199            }
200
201            // Try debug build if release not available
202            let framework_binary_debug =
203                std::path::PathBuf::from(&framework_path).join(paths::framework::NODE_RUNNER_DEBUG);
204
205            if framework_binary_debug.exists() {
206                return framework_binary_debug.to_string_lossy().to_string();
207            }
208        }
209
210        // Try global installation
211        if let Some(global_path) = Self::find_global_binary("mecha10-node-runner") {
212            return global_path.to_string_lossy().to_string();
213        }
214
215        // Fallback: rely on PATH
216        "mecha10-node-runner".to_string()
217    }
218
219    /// Spawn a node process
220    ///
221    /// # Arguments
222    ///
223    /// * `name` - Name to identify the process
224    /// * `binary_path` - Path to the binary to execute
225    /// * `args` - Command-line arguments
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if the process cannot be spawned
230    pub fn spawn_node(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
231        let child = Command::new(binary_path)
232            .args(args)
233            .stdout(Stdio::inherit())
234            .stderr(Stdio::inherit())
235            .spawn()
236            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
237
238        let pid = child.id();
239        self.manager.track(name.to_string(), child);
240
241        Ok(pid)
242    }
243
244    /// Spawn a process with output capture
245    ///
246    /// # Arguments
247    ///
248    /// * `name` - Name to identify the process
249    /// * `binary_path` - Path to the binary to execute
250    /// * `args` - Command-line arguments
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if the process cannot be spawned
255    pub fn spawn_with_output(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
256        let child = Command::new(binary_path)
257            .args(args)
258            .stdout(Stdio::piped())
259            .stderr(Stdio::piped())
260            .spawn()
261            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
262
263        let pid = child.id();
264        self.manager.track(name.to_string(), child);
265
266        Ok(pid)
267    }
268
269    /// Spawn a process with custom environment variables
270    ///
271    /// # Arguments
272    ///
273    /// * `name` - Name to identify the process
274    /// * `binary_path` - Path to the binary to execute
275    /// * `args` - Command-line arguments
276    /// * `env` - Environment variables to set
277    pub fn spawn_with_env(
278        &mut self,
279        name: &str,
280        binary_path: &str,
281        args: &[&str],
282        env: HashMap<String, String>,
283    ) -> Result<u32> {
284        // Create logs directory if it doesn't exist
285        let logs_dir = std::path::PathBuf::from(paths::project::LOGS_DIR);
286        if !logs_dir.exists() {
287            std::fs::create_dir_all(&logs_dir)?;
288        }
289
290        // Create log file for this process
291        // Use short node name for log file (e.g., "@mecha10/listener" -> "listener.log")
292        let log_name = Self::extract_node_name(name);
293        let log_file_path = logs_dir.join(format!("{}.log", log_name));
294        let log_file = std::fs::OpenOptions::new()
295            .create(true)
296            .append(true)
297            .open(&log_file_path)
298            .with_context(|| format!("Failed to create log file: {}", log_file_path.display()))?;
299
300        // Clone for stderr
301        let log_file_stderr = log_file.try_clone().context("Failed to clone log file handle")?;
302
303        // Debug: Log environment being passed to process
304        tracing::debug!("🔧 spawn_with_env for '{}': received {} env vars", name, env.len());
305        if !env.is_empty() {
306            for (key, value) in &env {
307                if key == "ROBOT_API_KEY" {
308                    tracing::debug!("  {} = <redacted>", key);
309                } else {
310                    tracing::debug!("  {} = {}", key, value);
311                }
312            }
313        } else {
314            tracing::warn!("⚠️  No environment variables to inject!");
315        }
316
317        let mut cmd = Command::new(binary_path);
318        cmd.args(args)
319            .envs(&env)
320            .stdout(log_file) // Redirect stdout to log file
321            .stderr(log_file_stderr); // Redirect stderr to log file
322
323        // On Unix: Create new process group to prevent terminal signals from reaching child processes
324        // This ensures Ctrl+C in the terminal only affects the main CLI process, not node-runner
325        #[cfg(unix)]
326        {
327            use std::os::unix::process::CommandExt;
328            cmd.process_group(0); // 0 = create new process group with same ID as child PID
329        }
330
331        let child = cmd
332            .spawn()
333            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
334
335        let pid = child.id();
336        self.manager.track(name.to_string(), child);
337
338        Ok(pid)
339    }
340
341    /// Spawn a process in a specific working directory
342    ///
343    /// # Arguments
344    ///
345    /// * `name` - Name to identify the process
346    /// * `binary_path` - Path to the binary to execute
347    /// * `args` - Command-line arguments
348    /// * `working_dir` - Working directory for the process
349    pub fn spawn_in_dir(
350        &mut self,
351        name: &str,
352        binary_path: &str,
353        args: &[&str],
354        working_dir: impl AsRef<Path>,
355    ) -> Result<u32> {
356        let child = Command::new(binary_path)
357            .args(args)
358            .current_dir(working_dir)
359            .stdout(Stdio::inherit())
360            .stderr(Stdio::inherit())
361            .spawn()
362            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
363
364        let pid = child.id();
365        self.manager.track(name.to_string(), child);
366
367        Ok(pid)
368    }
369
370    /// Spawn multiple node processes from a list
371    ///
372    /// # Arguments
373    ///
374    /// * `nodes` - Vec of (name, binary_path, args) tuples
375    ///
376    /// # Returns
377    ///
378    /// HashMap of node names to PIDs
379    pub fn spawn_nodes(&mut self, nodes: Vec<(&str, &str, Vec<&str>)>) -> Result<HashMap<String, u32>> {
380        let mut pids = HashMap::new();
381
382        for (name, binary_path, args) in nodes {
383            match self.spawn_node(name, binary_path, &args) {
384                Ok(pid) => {
385                    pids.insert(name.to_string(), pid);
386                }
387                Err(e) => {
388                    eprintln!("Failed to spawn {}: {}", name, e);
389                }
390            }
391        }
392
393        Ok(pids)
394    }
395
396    /// Get status of all processes
397    ///
398    /// Returns a HashMap mapping process names to status strings
399    pub fn get_status(&mut self) -> HashMap<String, String> {
400        use mecha10_runtime::ProcessStatus;
401
402        self.manager
403            .status_all()
404            .into_iter()
405            .map(|(name, status)| {
406                let status_str = match status {
407                    ProcessStatus::Running => "running".to_string(),
408                    ProcessStatus::Exited(code) => format!("exited (code: {})", code),
409                    ProcessStatus::Error => "error".to_string(),
410                };
411                (name, status_str)
412            })
413            .collect()
414    }
415
416    /// Get the number of tracked processes
417    pub fn count(&self) -> usize {
418        self.manager.len()
419    }
420
421    /// Check if any processes are being tracked
422    pub fn is_empty(&self) -> bool {
423        self.manager.is_empty()
424    }
425
426    /// Stop a specific process by name
427    ///
428    /// # Arguments
429    ///
430    /// * `name` - Name of the process to stop
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if the process is not found
435    ///
436    /// Note: This uses a default 10-second timeout for graceful shutdown.
437    /// Use stop_with_timeout() for custom timeout.
438    pub fn stop(&mut self, name: &str) -> Result<()> {
439        self.manager.stop_graceful(name, std::time::Duration::from_secs(10))
440    }
441
442    /// Stop a process with timeout for graceful shutdown
443    ///
444    /// Tries graceful shutdown (SIGTERM on Unix), then force kills after timeout
445    ///
446    /// # Arguments
447    ///
448    /// * `name` - Name of the process to stop
449    /// * `timeout` - How long to wait for graceful shutdown
450    ///
451    /// # Errors
452    ///
453    /// Returns an error if process not found or cannot be stopped
454    pub fn stop_with_timeout(&mut self, name: &str, timeout: std::time::Duration) -> Result<()> {
455        self.manager.stop_graceful(name, timeout)
456    }
457
458    /// Force kill a process by name
459    ///
460    /// # Arguments
461    ///
462    /// * `name` - Name of the process to kill
463    pub fn force_kill(&mut self, name: &str) -> Result<()> {
464        self.manager.force_kill(name)
465    }
466
467    /// Stop all processes gracefully in dependency order
468    ///
469    /// This delegates to the runtime's ProcessManager which handles:
470    /// - Dependency-based shutdown ordering
471    /// - Graceful shutdown with timeout
472    /// - Force kill fallback
473    pub fn cleanup(&mut self) {
474        self.manager.shutdown_all();
475    }
476
477    /// Check if a process is running
478    ///
479    /// # Arguments
480    ///
481    /// * `name` - Name of the process to check
482    pub fn is_running(&mut self, name: &str) -> bool {
483        self.manager.is_running(name)
484    }
485
486    /// Get access to the underlying ProcessManager
487    ///
488    /// Useful for advanced operations or when migrating existing code.
489    /// Provides direct access to the runtime's ProcessManager.
490    pub fn manager(&mut self) -> &mut ProcessManager {
491        &mut self.manager
492    }
493
494    /// Build a node binary if needed
495    ///
496    /// Helper method to build a specific node package
497    ///
498    /// # Arguments
499    ///
500    /// * `node_name` - Name of the node to build
501    /// * `release` - Whether to build in release mode
502    pub fn build_node(&self, node_name: &str, release: bool) -> Result<()> {
503        let mut cmd = Command::new("cargo");
504        cmd.arg("build");
505
506        if release {
507            cmd.arg("--release");
508        }
509
510        cmd.arg("--bin").arg(node_name);
511
512        let output = cmd
513            .output()
514            .with_context(|| format!("Failed to build node: {}", node_name))?;
515
516        if !output.status.success() {
517            let stderr = String::from_utf8_lossy(&output.stderr);
518            return Err(anyhow::anyhow!("Build failed for {}: {}", node_name, stderr));
519        }
520
521        Ok(())
522    }
523
524    /// Build all nodes in the workspace
525    ///
526    /// # Arguments
527    ///
528    /// * `release` - Whether to build in release mode
529    pub fn build_all(&self, release: bool) -> Result<()> {
530        let mut cmd = Command::new("cargo");
531        cmd.arg("build");
532
533        if release {
534            cmd.arg("--release");
535        }
536
537        cmd.arg("--all");
538
539        let output = cmd.output().context("Failed to build workspace")?;
540
541        if !output.status.success() {
542            let stderr = String::from_utf8_lossy(&output.stderr);
543            return Err(anyhow::anyhow!("Build failed: {}", stderr));
544        }
545
546        Ok(())
547    }
548
549    /// Build a binary from the framework monorepo
550    ///
551    /// This builds a binary from the framework path (MECHA10_FRAMEWORK_PATH).
552    /// Used for binaries like `mecha10-node-runner` that exist in the monorepo
553    /// but need to be built when running from a generated project.
554    ///
555    /// # Arguments
556    ///
557    /// * `package_name` - Name of the package to build (e.g., "mecha10-node-runner")
558    /// * `release` - Whether to build in release mode
559    ///
560    /// # Returns
561    ///
562    /// Ok(()) on success, or error if build fails or framework path not set
563    pub fn build_from_framework(&self, package_name: &str, release: bool) -> Result<()> {
564        // Get framework path from environment
565        let framework_path = std::env::var("MECHA10_FRAMEWORK_PATH")
566            .context("MECHA10_FRAMEWORK_PATH not set - cannot build from framework")?;
567
568        let mut cmd = Command::new("cargo");
569        cmd.arg("build");
570
571        if release {
572            cmd.arg("--release");
573        }
574
575        cmd.arg("-p").arg(package_name);
576        cmd.current_dir(&framework_path);
577
578        let output = cmd
579            .output()
580            .with_context(|| format!("Failed to build package from framework: {}", package_name))?;
581
582        if !output.status.success() {
583            let stderr = String::from_utf8_lossy(&output.stderr);
584            return Err(anyhow::anyhow!(
585                "Build failed for {} (from framework): {}",
586                package_name,
587                stderr
588            ));
589        }
590
591        Ok(())
592    }
593
594    /// Build only packages needed by the current project (smart selective build)
595    ///
596    /// For generated projects, this just builds the project binary.
597    /// Cargo automatically builds only the dependencies actually used.
598    /// With .cargo/config.toml patches, this rebuilds framework packages from source.
599    ///
600    /// # Arguments
601    ///
602    /// * `release` - Whether to build in release mode
603    ///
604    /// # Returns
605    ///
606    /// Ok(()) on success, or error if build fails
607    pub fn build_project_packages(&self, release: bool) -> Result<()> {
608        use crate::types::ProjectConfig;
609
610        // Load project config
611        let config_path = std::path::Path::new(paths::PROJECT_CONFIG);
612        if !config_path.exists() {
613            // Fallback to build_all if no project config
614            return self.build_all(release);
615        }
616
617        // Parse config to get project name
618        let config_content = std::fs::read_to_string(config_path)?;
619        let config: ProjectConfig = serde_json::from_str(&config_content)?;
620
621        // Build just the project binary
622        // Cargo will automatically:
623        // 1. Resolve dependencies from Cargo.toml
624        // 2. Apply .cargo/config.toml patches (framework dev mode)
625        // 3. Build only the dependencies actually used
626        // 4. Use incremental compilation for unchanged code
627        let mut cmd = Command::new("cargo");
628        cmd.arg("build");
629
630        if release {
631            cmd.arg("--release");
632        }
633
634        // Build the project binary - Cargo handles the rest
635        cmd.arg("--bin").arg(&config.name);
636
637        let output = cmd.output().context("Failed to build project")?;
638
639        if !output.status.success() {
640            let stderr = String::from_utf8_lossy(&output.stderr);
641            return Err(anyhow::anyhow!("Build failed: {}", stderr));
642        }
643
644        Ok(())
645    }
646
647    /// Restart a specific process
648    ///
649    /// Stops the process if running and starts it again
650    ///
651    /// # Arguments
652    ///
653    /// * `name` - Name of the process
654    /// * `binary_path` - Path to the binary
655    /// * `args` - Command-line arguments
656    pub fn restart(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
657        // Stop if running
658        if self.is_running(name) {
659            self.stop(name)?;
660            // Give it a moment to shutdown
661            std::thread::sleep(std::time::Duration::from_millis(100));
662        }
663
664        // Start again
665        self.spawn_node(name, binary_path, args)
666    }
667
668    /// Restart all processes
669    ///
670    /// # Arguments
671    ///
672    /// * `nodes` - Vec of (name, binary_path, args) tuples
673    pub fn restart_all(&mut self, nodes: Vec<(&str, &str, Vec<&str>)>) -> Result<HashMap<String, u32>> {
674        // Stop all
675        self.cleanup();
676
677        // Small delay for cleanup
678        std::thread::sleep(std::time::Duration::from_millis(500));
679
680        // Start all
681        self.spawn_nodes(nodes)
682    }
683
684    /// Spawn a node using mecha10-node-runner
685    ///
686    /// This is the simplified spawning method for Phase 2+ of Node Lifecycle Architecture.
687    /// It delegates all complexity (binary resolution, model pulling, env setup) to node-runner.
688    ///
689    /// # Arguments
690    ///
691    /// * `node_name` - Name of the node to run
692    ///
693    /// # Returns
694    ///
695    /// Process ID of the spawned node-runner instance
696    ///
697    /// # Errors
698    ///
699    /// Returns an error if the node-runner cannot be spawned
700    ///
701    /// # Configuration
702    ///
703    /// The node-runner reads configuration from the node's config file (e.g., `configs/nodes/{node_name}.json`)
704    /// and supports the following runtime settings:
705    ///
706    /// ```json
707    /// {
708    ///   "runtime": {
709    ///     "restart_policy": "on-failure",  // never, on-failure, always
710    ///     "max_retries": 3,
711    ///     "backoff_secs": 1
712    ///   },
713    ///   "depends_on": ["camera", "lidar"],
714    ///   "startup_timeout_secs": 30
715    /// }
716    /// ```
717    ///
718    /// To enable dependency checking, use: `mecha10-node-runner --wait-for-deps <node-name>`
719    pub fn spawn_node_runner(
720        &mut self,
721        node_identifier: &str,
722        project_env: Option<HashMap<String, String>>,
723    ) -> Result<u32> {
724        // Extract node name from full identifier (e.g., @mecha10/listener -> listener)
725        let node_name = Self::extract_node_name(node_identifier);
726
727        // Simply spawn: mecha10-node-runner <node-name>
728        // The node-runner handles:
729        // - Binary path resolution (monorepo vs local)
730        // - Model pulling (if node needs AI models)
731        // - Environment setup
732        // - Log redirection
733        // - Restart policies (from config)
734        // - Health monitoring
735        // - Dependency checking (if --wait-for-deps enabled)
736        // - Actual node execution
737
738        // Resolve path to mecha10-node-runner binary
739        let runner_path = Self::resolve_node_runner_path();
740
741        // Build environment - include project vars for config substitution
742        let env = project_env.unwrap_or_default();
743
744        // Register with full identifier (e.g., @mecha10/listener) for consistent tracking
745        // The node-runner only needs the short name as an argument
746        self.spawn_with_env(node_identifier, &runner_path, &[&node_name], env)
747    }
748
749    /// Spawn a node directly (standalone mode)
750    ///
751    /// This is used when mecha10-node-runner is not available (standalone projects
752    /// installed from crates.io). Handles both framework nodes (via BinaryManager)
753    /// and local project nodes (built and run directly).
754    ///
755    /// Resolution strategy for framework nodes:
756    /// 1. Check ~/.mecha10/bin/ for pre-downloaded binary
757    /// 2. If not found, attempt to download from GitHub releases
758    /// 3. If download fails, fall back to cargo install
759    ///
760    /// # Arguments
761    ///
762    /// * `node_name` - Name of the node to run
763    /// * `project_name` - Name of the project (used to find the binary)
764    /// * `project_env` - Optional additional environment variables from project config
765    ///
766    /// # Returns
767    ///
768    /// Process ID of the spawned node process
769    pub fn spawn_node_direct(
770        &mut self,
771        node_identifier: &str,
772        _project_name: &str,
773        project_env: Option<HashMap<String, String>>,
774    ) -> Result<u32> {
775        // Extract node name from full identifier (e.g., @mecha10/listener -> listener)
776        let node_name = Self::extract_node_name(node_identifier);
777
778        // Check if this is a local project node by looking at mecha10.json
779        let is_local_node = self.is_local_project_node(&node_name);
780
781        let mut env = HashMap::new();
782        // Use full identifier for NODE_NAME so health reports and dashboard are consistent
783        env.insert("NODE_NAME".to_string(), node_identifier.to_string());
784        if let Ok(rust_log) = std::env::var("RUST_LOG") {
785            env.insert("RUST_LOG".to_string(), rust_log);
786        }
787
788        // Add project environment variables (REDIS_URL, control plane, robot info, etc.)
789        if let Some(project_vars) = project_env {
790            tracing::debug!(
791                "🔀 spawn_node_direct for '{}': merging {} project vars",
792                node_name,
793                project_vars.len()
794            );
795            for (key, value) in &project_vars {
796                tracing::debug!(
797                    "  Adding: {} = {}",
798                    key,
799                    if key == "ROBOT_API_KEY" { "<redacted>" } else { value }
800                );
801            }
802            env.extend(project_vars);
803        } else {
804            tracing::warn!("⚠️  spawn_node_direct for '{}': No project_env provided!", node_name);
805        }
806
807        tracing::debug!(
808            "🏁 spawn_node_direct for '{}': final env has {} vars, is_local: {}",
809            node_name,
810            env.len(),
811            is_local_node
812        );
813
814        if is_local_node {
815            // Local project node: build and run the binary directly
816            // Register with full identifier (e.g., @local/my-node) for consistent tracking
817            self.spawn_local_node_with_identifier(node_identifier, &node_name, env)
818        } else {
819            // Framework node: resolve binary via BinaryManager
820            // This will download from GitHub releases if not cached
821            self.spawn_framework_node(node_identifier, &node_name, env)
822        }
823    }
824
825    /// Spawn a framework node using BinaryManager for binary resolution
826    ///
827    /// This method resolves the binary path using BinaryManager, which:
828    /// 1. Checks the local cache (~/.mecha10/bin/)
829    /// 2. Downloads from GitHub releases if needed
830    /// 3. Falls back to cargo install for unsupported platforms
831    fn spawn_framework_node(
832        &mut self,
833        node_identifier: &str,
834        node_name: &str,
835        env: HashMap<String, String>,
836    ) -> Result<u32> {
837        use super::binary_manager::BinaryManager;
838
839        // Try to resolve binary via BinaryManager
840        let binary_manager = BinaryManager::new()?;
841
842        // Use block_in_place to run async code in sync context
843        let binary_path = tokio::task::block_in_place(|| {
844            tokio::runtime::Handle::current().block_on(binary_manager.resolve(node_name))
845        })?;
846
847        tracing::info!(
848            "🚀 Spawning framework node '{}' from {}",
849            node_name,
850            binary_path.display()
851        );
852
853        // Spawn the binary directly
854        self.spawn_with_env(node_identifier, &binary_path.to_string_lossy(), &[], env)
855    }
856
857    /// Check if a node is a local project node (defined in nodes/ directory)
858    fn is_local_project_node(&self, node_name: &str) -> bool {
859        use crate::types::{NodeSource, ProjectConfig};
860
861        let config_path = std::path::Path::new(paths::PROJECT_CONFIG);
862        if !config_path.exists() {
863            return false;
864        }
865
866        let config_content = match std::fs::read_to_string(config_path) {
867            Ok(c) => c,
868            Err(_) => return false,
869        };
870
871        let config: ProjectConfig = match serde_json::from_str(&config_content) {
872            Ok(c) => c,
873            Err(_) => return false,
874        };
875
876        // Check if node exists as a project node (nodes/<name>)
877        config
878            .nodes
879            .find_by_name(node_name)
880            .map(|spec| spec.source == NodeSource::Project)
881            .unwrap_or(false)
882    }
883
884    /// Spawn a local project node with full identifier for tracking
885    ///
886    /// Builds the node with cargo and runs the binary directly.
887    /// Registers the process with the full identifier (e.g., @local/my-node) for consistent tracking.
888    fn spawn_local_node_with_identifier(
889        &mut self,
890        node_identifier: &str,
891        node_name: &str,
892        env: HashMap<String, String>,
893    ) -> Result<u32> {
894        use std::process::Command;
895
896        // First, build the node
897        tracing::info!("🔨 Building local node: {}", node_name);
898
899        let build_output = Command::new("cargo")
900            .args(["build", "-p", node_name])
901            .output()
902            .context("Failed to run cargo build")?;
903
904        if !build_output.status.success() {
905            let stderr = String::from_utf8_lossy(&build_output.stderr);
906            anyhow::bail!("Failed to build node '{}': {}", node_name, stderr);
907        }
908
909        tracing::info!("✅ Built local node: {}", node_name);
910
911        // Run the binary from target/debug/<name>
912        let binary_path = paths::target_path("debug", node_name);
913
914        if !std::path::Path::new(&binary_path).exists() {
915            anyhow::bail!(
916                "Binary not found at '{}'. Build may have failed or the package name differs from node name.",
917                binary_path
918            );
919        }
920
921        // Register with full identifier for consistent tracking
922        self.spawn_with_env(node_identifier, &binary_path, &[], env)
923    }
924
925    /// Resolve path to mecha10 CLI binary
926    fn resolve_cli_binary() -> Result<String> {
927        // First try to find mecha10 in PATH
928        if let Some(path) = Self::find_global_binary("mecha10") {
929            return Ok(path.to_string_lossy().to_string());
930        }
931
932        // Try common install locations
933        let locations = [paths::user::local_bin("mecha10"), paths::user::cargo_bin("mecha10")];
934
935        for path in &locations {
936            if path.exists() {
937                return Ok(path.to_string_lossy().to_string());
938            }
939        }
940
941        // Fall back to current executable (we are mecha10!)
942        if let Ok(exe) = std::env::current_exe() {
943            return Ok(exe.to_string_lossy().to_string());
944        }
945
946        anyhow::bail!("Could not find mecha10 CLI binary. Ensure it's installed and in your PATH.")
947    }
948
949    /// Check if mecha10-node-runner is available
950    pub fn is_node_runner_available() -> bool {
951        // Check framework path first
952        if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
953            let release = std::path::PathBuf::from(&framework_path).join(paths::framework::NODE_RUNNER_RELEASE);
954            let debug = std::path::PathBuf::from(&framework_path).join(paths::framework::NODE_RUNNER_DEBUG);
955            if release.exists() || debug.exists() {
956                return true;
957            }
958        }
959
960        // Check global installation
961        Self::find_global_binary("mecha10-node-runner").is_some()
962    }
963}
964
965impl Default for ProcessService {
966    fn default() -> Self {
967        Self::new()
968    }
969}
970
971impl Drop for ProcessService {
972    fn drop(&mut self) {
973        // ProcessManager's Drop will handle graceful shutdown
974    }
975}