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` (the process's own working directory when `None`), then track the resulting
18    /// child under `name` and return its PID.
19    ///
20    /// Shared by [`Self::spawn_node`], [`Self::spawn_with_output`], and [`Self::spawn_in_dir`] -
21    /// those three differ only in which `Stdio` mode they wire up and whether they set a working
22    /// directory, so this is the one place that actually builds and tracks the `Command`.
23    fn spawn_tracked(
24        &mut self,
25        name: &str,
26        binary_path: &str,
27        args: &[&str],
28        stdio: impl Fn() -> Stdio,
29        working_dir: Option<&Path>,
30    ) -> Result<u32> {
31        let mut command = Command::new(binary_path);
32        command.args(args).stdout(stdio()).stderr(stdio());
33        if let Some(dir) = working_dir {
34            command.current_dir(dir);
35        }
36
37        let child = command
38            .spawn()
39            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
40
41        let pid = child.id();
42        self.manager.track(name.to_string(), child);
43
44        Ok(pid)
45    }
46
47    /// Spawn a node process
48    ///
49    /// # Arguments
50    ///
51    /// * `name` - Name to identify the process
52    /// * `binary_path` - Path to the binary to execute
53    /// * `args` - Command-line arguments
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the process cannot be spawned
58    pub fn spawn_node(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
59        self.spawn_tracked(name, binary_path, args, Stdio::inherit, None)
60    }
61
62    /// Spawn a process with output capture
63    ///
64    /// # Arguments
65    ///
66    /// * `name` - Name to identify the process
67    /// * `binary_path` - Path to the binary to execute
68    /// * `args` - Command-line arguments
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the process cannot be spawned
73    pub fn spawn_with_output(&mut self, name: &str, binary_path: &str, args: &[&str]) -> Result<u32> {
74        self.spawn_tracked(name, binary_path, args, Stdio::piped, None)
75    }
76
77    /// Spawn a process with custom environment variables
78    ///
79    /// # Arguments
80    ///
81    /// * `name` - Name to identify the process
82    /// * `binary_path` - Path to the binary to execute
83    /// * `args` - Command-line arguments
84    /// * `env` - Environment variables to set
85    pub fn spawn_with_env(
86        &mut self,
87        name: &str,
88        binary_path: &str,
89        args: &[&str],
90        env: HashMap<String, String>,
91    ) -> Result<u32> {
92        let disk_logging_enabled = self.disk_logging_enabled();
93
94        tracing::debug!("🔧 spawn_with_env for '{}': received {} env vars", name, env.len());
95        if !env.is_empty() {
96            for (key, value) in &env {
97                if key == "ROBOT_API_KEY" {
98                    tracing::debug!("  {} = <redacted>", key);
99                } else {
100                    tracing::debug!("  {} = {}", key, value);
101                }
102            }
103        } else {
104            tracing::warn!("⚠️  No environment variables to inject!");
105        }
106
107        let mut cmd = Command::new(binary_path);
108        cmd.args(args).envs(&env);
109
110        cmd.env("MECHA10_DISK_LOGGING_ENABLED", disk_logging_enabled.to_string());
111
112        if disk_logging_enabled {
113            let logs_dir = std::path::PathBuf::from(paths::project::LOGS_DIR);
114            if !logs_dir.exists() {
115                std::fs::create_dir_all(&logs_dir)?;
116            }
117
118            let log_name = strip_node_scope(name);
119            let log_file_path = logs_dir.join(format!("{}.log", log_name));
120            let log_file = std::fs::OpenOptions::new()
121                .create(true)
122                .append(true)
123                .open(&log_file_path)
124                .with_context(|| format!("Failed to create log file: {}", log_file_path.display()))?;
125
126            let log_file_stderr = log_file.try_clone().context("Failed to clone log file handle")?;
127
128            cmd.stdout(log_file).stderr(log_file_stderr);
129        } else {
130            tracing::debug!(
131                "📴 Disk logging disabled via project config; discarding stdout/stderr for '{}'",
132                name
133            );
134            cmd.stdout(Stdio::null()).stderr(Stdio::null());
135        }
136
137        #[cfg(unix)]
138        {
139            use std::os::unix::process::CommandExt;
140            cmd.process_group(0);
141        }
142
143        let child = cmd
144            .spawn()
145            .with_context(|| format!("Failed to spawn process: {}", binary_path))?;
146
147        let pid = child.id();
148        self.manager.track(name.to_string(), child);
149
150        Ok(pid)
151    }
152
153    /// Spawn a process in a specific working directory
154    ///
155    /// # Arguments
156    ///
157    /// * `name` - Name to identify the process
158    /// * `binary_path` - Path to the binary to execute
159    /// * `args` - Command-line arguments
160    /// * `working_dir` - Working directory for the process
161    pub fn spawn_in_dir(
162        &mut self,
163        name: &str,
164        binary_path: &str,
165        args: &[&str],
166        working_dir: impl AsRef<Path>,
167    ) -> Result<u32> {
168        self.spawn_tracked(name, binary_path, args, Stdio::inherit, Some(working_dir.as_ref()))
169    }
170
171    /// Spawn multiple node processes from a list
172    ///
173    /// # Arguments
174    ///
175    /// * `nodes` - Vec of (name, binary_path, args) tuples
176    ///
177    /// # Returns
178    ///
179    /// HashMap of node names to PIDs
180    pub fn spawn_nodes(&mut self, nodes: Vec<(&str, &str, Vec<&str>)>) -> Result<HashMap<String, u32>> {
181        let mut pids = HashMap::new();
182
183        for (name, binary_path, args) in nodes {
184            match self.spawn_node(name, binary_path, &args) {
185                Ok(pid) => {
186                    pids.insert(name.to_string(), pid);
187                }
188                Err(e) => {
189                    eprintln!("Failed to spawn {}: {}", name, e);
190                }
191            }
192        }
193
194        Ok(pids)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn spawn_node_tracks_the_process_under_the_given_name() {
204        let mut service = ProcessService::new();
205
206        let pid = service.spawn_node("test_process", "sleep", &["1"]).unwrap();
207
208        assert!(pid > 0);
209        assert_eq!(service.count(), 1);
210
211        service.cleanup();
212    }
213
214    #[test]
215    fn spawn_with_output_tracks_the_process_under_the_given_name() {
216        let mut service = ProcessService::new();
217
218        let pid = service.spawn_with_output("test_output", "sleep", &["1"]).unwrap();
219
220        assert!(pid > 0);
221        assert_eq!(service.count(), 1);
222
223        service.cleanup();
224    }
225
226    #[test]
227    fn spawn_in_dir_tracks_the_process_and_runs_it_in_the_given_directory() {
228        let mut service = ProcessService::new();
229
230        let pid = service.spawn_in_dir("test_dir", "sleep", &["1"], "/tmp").unwrap();
231
232        assert!(pid > 0);
233        assert_eq!(service.count(), 1);
234
235        service.cleanup();
236    }
237
238    #[test]
239    fn spawn_node_and_spawn_with_output_use_different_stdio_modes() {
240        let mut inherited = ProcessService::new();
241        let inherited_pid = inherited
242            .spawn_node("echo_inherited", "sh", &["-c", "echo hi"])
243            .unwrap();
244        assert!(inherited_pid > 0);
245        inherited.cleanup();
246
247        let mut piped = ProcessService::new();
248        let piped_pid = piped.spawn_with_output("echo_piped", "sh", &["-c", "echo hi"]).unwrap();
249        assert!(piped_pid > 0);
250        piped.cleanup();
251    }
252
253    #[test]
254    fn spawn_nodes_returns_a_pid_per_successfully_spawned_node() {
255        let mut service = ProcessService::new();
256
257        let pids = service
258            .spawn_nodes(vec![("node1", "sleep", vec!["1"]), ("node2", "sleep", vec!["1"])])
259            .unwrap();
260
261        assert_eq!(pids.len(), 2);
262        assert!(pids.contains_key("node1"));
263        assert!(pids.contains_key("node2"));
264
265        service.cleanup();
266    }
267}