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}