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