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