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