Skip to main content

mecha10_cli/services/process/
spawn.rs

1//! Plain process spawning primitives for `ProcessService`
2//!
3//! Covers spawning with inherited/piped output, custom env, and a specific
4//! working directory. Higher-level node spawning (via `mecha10-node-runner`
5//! or standalone mode) lives in [`super::spawn_node`].
6
7use super::ProcessService;
8use crate::paths;
9use anyhow::{Context, Result};
10use mecha10_cli_core::utils::strip_node_scope;
11use std::collections::HashMap;
12use std::path::Path;
13use std::process::{Command, Stdio};
14
15impl ProcessService {
16    /// Spawn `binary_path` with `args`, wiring stdout/stderr to `stdio` and running it in
17    /// `working_dir`, then track the resulting child under `name` and return its PID.
18    fn spawn_tracked(
19        &mut self,
20        name: &str,
21        binary_path: &str,
22        args: &[&str],
23        stdio: impl Fn() -> Stdio,
24        working_dir: Option<&Path>,
25    ) -> Result<u32> {
26        let mut command = Command::new(binary_path);
27        command.args(args).stdout(stdio()).stderr(stdio());
28        if let Some(dir) = working_dir {
29            command.current_dir(dir);
30        }
31
32        let child = command
33            .spawn()
34            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
35
36        let pid = child.id();
37        self.manager.track(name.to_string(), child);
38
39        Ok(pid)
40    }
41
42    /// Spawn a node process, tracked under `name`. Returns an error if the process cannot be
43    /// spawned.
44    pub fn spawn_node(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
45        self.spawn_tracked(name, binary_path, args, Stdio::inherit, None)
46    }
47
48    /// Spawn a process with output capture, tracked under `name`.
49    pub fn spawn_with_output(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
50        self.spawn_tracked(name, binary_path, args, Stdio::piped, None)
51    }
52
53    /// Spawn a process with custom environment variables, tracked under `name`.
54    pub fn spawn_with_env(
55        &mut self,
56        name: &str,
57        binary_path: &str,
58        args: &[&str],
59        env: HashMap<String, String>,
60    ) -> Result<u32> {
61        let disk_logging_enabled = self.disk_logging_enabled();
62
63        tracing::debug!("🔧 spawn_with_env for '{}': received {} env vars", name, env.len());
64        if !env.is_empty() {
65            for (key, value) in &env {
66                if key == "ROBOT_API_KEY" {
67                    tracing::debug!("  {} = <redacted>", key);
68                } else {
69                    tracing::debug!("  {} = {}", key, value);
70                }
71            }
72        } else {
73            tracing::warn!("⚠️  No environment variables to inject!");
74        }
75
76        let mut cmd = Command::new(binary_path);
77        cmd.args(args).envs(&env);
78
79        cmd.env("MECHA10_DISK_LOGGING_ENABLED", disk_logging_enabled.to_string());
80
81        if disk_logging_enabled {
82            let logs_dir = std::path::PathBuf::from(paths::project::LOGS_DIR);
83            if !logs_dir.exists() {
84                std::fs::create_dir_all(&logs_dir)?;
85            }
86
87            let log_name = strip_node_scope(name);
88            let log_file_path = logs_dir.join(format!("{}.log", log_name));
89            let log_file = std::fs::OpenOptions::new()
90                .create(true)
91                .append(true)
92                .open(&log_file_path)
93                .with_context(|| format!("Failed to create log file: {}", log_file_path.display()))?;
94
95            let log_file_stderr = log_file.try_clone().context("Failed to clone log file handle")?;
96
97            cmd.stdout(log_file).stderr(log_file_stderr);
98        } else {
99            tracing::debug!(
100                "📴 Disk logging disabled via project config; discarding stdout/stderr for '{}'",
101                name
102            );
103            cmd.stdout(Stdio::null()).stderr(Stdio::null());
104        }
105
106        #[cfg(unix)]
107        {
108            use std::os::unix::process::CommandExt;
109            cmd.process_group(0);
110        }
111
112        let child = cmd
113            .spawn()
114            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
115
116        let pid = child.id();
117        self.manager.track(name.to_string(), child);
118
119        Ok(pid)
120    }
121
122    /// Spawn a process in a specific working directory, tracked under `name`.
123    pub fn spawn_in_dir(
124        &mut self,
125        name: &str,
126        binary_path: &str,
127        args: &[&str],
128        working_dir: impl AsRef<Path>,
129    ) -> Result<u32> {
130        self.spawn_tracked(name, binary_path, args, Stdio::inherit, Some(working_dir.as_ref()))
131    }
132
133    /// Spawn multiple node processes from a list of (name, binary_path, args) tuples, returning
134    /// a HashMap of node names to PIDs.
135    pub fn spawn_nodes(&mut self, nodes: Vec<(&str, &str, Vec<&str>)>) -> Result<HashMap<String, u32>> {
136        let mut pids = HashMap::new();
137
138        for (name, binary_path, args) in nodes {
139            match self.spawn_node(name, binary_path, &args) {
140                Ok(pid) => {
141                    pids.insert(name.to_string(), pid);
142                }
143                Err(e) => {
144                    eprintln!("Failed to spawn {}: {}", name, e);
145                }
146            }
147        }
148
149        Ok(pids)
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn spawn_node_tracks_the_process_under_the_given_name() {
159        let mut service = ProcessService::new();
160
161        let pid = service.spawn_node("test_process", "sleep", &["1"]).unwrap();
162
163        assert!(pid > 0);
164        assert_eq!(service.count(), 1);
165
166        service.cleanup();
167    }
168
169    #[test]
170    fn spawn_with_output_tracks_the_process_under_the_given_name() {
171        let mut service = ProcessService::new();
172
173        let pid = service.spawn_with_output("test_output", "sleep", &["1"]).unwrap();
174
175        assert!(pid > 0);
176        assert_eq!(service.count(), 1);
177
178        service.cleanup();
179    }
180
181    #[test]
182    fn spawn_in_dir_tracks_the_process_and_runs_it_in_the_given_directory() {
183        let mut service = ProcessService::new();
184
185        let pid = service.spawn_in_dir("test_dir", "sleep", &["1"], "/tmp").unwrap();
186
187        assert!(pid > 0);
188        assert_eq!(service.count(), 1);
189
190        service.cleanup();
191    }
192
193    #[test]
194    fn spawn_node_and_spawn_with_output_use_different_stdio_modes() {
195        let mut inherited = ProcessService::new();
196        let inherited_pid = inherited
197            .spawn_node("echo_inherited", "sh", &["-c", "echo hi"])
198            .unwrap();
199        assert!(inherited_pid > 0);
200        inherited.cleanup();
201
202        let mut piped = ProcessService::new();
203        let piped_pid = piped.spawn_with_output("echo_piped", "sh", &["-c", "echo hi"]).unwrap();
204        assert!(piped_pid > 0);
205        piped.cleanup();
206    }
207
208    #[test]
209    fn spawn_nodes_returns_a_pid_per_successfully_spawned_node() {
210        let mut service = ProcessService::new();
211
212        let pids = service
213            .spawn_nodes(vec![("node1", "sleep", vec!["1"]), ("node2", "sleep", vec!["1"])])
214            .unwrap();
215
216        assert_eq!(pids.len(), 2);
217        assert!(pids.contains_key("node1"));
218        assert!(pids.contains_key("node2"));
219
220        service.cleanup();
221    }
222}