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