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}