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