1use crate::ir::Task as IRTask;
7use std::collections::HashMap;
8use std::path::PathBuf;
9use std::process::Stdio;
10use thiserror::Error;
11use tokio::process::Command;
12
13#[derive(Debug, Error)]
15pub enum RunnerError {
16 #[error("Task '{task}' has empty command")]
18 EmptyCommand { task: String },
19
20 #[error("Failed to spawn task '{task}': {source}")]
22 SpawnFailed {
23 task: String,
24 #[source]
25 source: std::io::Error,
26 },
27
28 #[error("Task '{task}' execution failed: {source}")]
30 ExecutionFailed {
31 task: String,
32 #[source]
33 source: std::io::Error,
34 },
35}
36
37#[derive(Debug, Clone)]
39pub struct TaskOutput {
40 pub task_id: String,
42 pub exit_code: i32,
44 pub stdout: String,
46 pub stderr: String,
48 pub success: bool,
50 pub from_cache: bool,
52 pub duration_ms: u64,
54}
55
56impl TaskOutput {
57 #[must_use]
59 pub fn from_cache(task_id: String, duration_ms: u64) -> Self {
60 Self {
61 task_id,
62 exit_code: 0,
63 stdout: String::new(),
64 stderr: String::new(),
65 success: true,
66 from_cache: true,
67 duration_ms,
68 }
69 }
70
71 #[must_use]
73 pub fn dry_run(task_id: String) -> Self {
74 Self {
75 task_id,
76 exit_code: 0,
77 stdout: String::new(),
78 stderr: String::new(),
79 success: true,
80 from_cache: false,
81 duration_ms: 0,
82 }
83 }
84}
85
86pub const DEFAULT_SHELL: &str = "/bin/sh";
88
89pub struct IRTaskRunner {
91 project_root: PathBuf,
93 capture_output: bool,
95 shell_path: String,
97}
98
99impl IRTaskRunner {
100 #[must_use]
102 pub fn new(project_root: PathBuf, capture_output: bool) -> Self {
103 Self {
104 project_root,
105 capture_output,
106 shell_path: DEFAULT_SHELL.to_string(),
107 }
108 }
109
110 #[must_use]
112 pub fn with_shell(
113 project_root: PathBuf,
114 capture_output: bool,
115 shell_path: impl Into<String>,
116 ) -> Self {
117 Self {
118 project_root,
119 capture_output,
120 shell_path: shell_path.into(),
121 }
122 }
123
124 #[tracing::instrument(
133 name = "execute_task",
134 fields(task_id = %task.id, shell = task.shell),
135 skip(self, env)
136 )]
137 pub async fn execute(
138 &self,
139 task: &IRTask,
140 env: HashMap<String, String>,
141 ) -> Result<TaskOutput, RunnerError> {
142 if task.command.is_empty() {
143 return Err(RunnerError::EmptyCommand {
144 task: task.id.clone(),
145 });
146 }
147
148 let start = std::time::Instant::now();
149
150 let mut cmd = if task.shell {
152 let shell_cmd = task.command.join(" ");
154 tracing::debug!(shell_cmd = %shell_cmd, shell = %self.shell_path, "Running in shell mode");
155
156 let mut c = Command::new(&self.shell_path);
157 c.arg("-c");
158 c.arg(&shell_cmd);
159 c
160 } else {
161 tracing::debug!(cmd = ?task.command, "Running in direct mode");
163
164 let mut c = Command::new(&task.command[0]);
165 if task.command.len() > 1 {
166 c.args(&task.command[1..]);
167 }
168 c
169 };
170
171 cmd.current_dir(&self.project_root);
173
174 cmd.env_clear();
176 for (k, v) in &env {
177 cmd.env(k, v);
178 }
179
180 if let Ok(path) = std::env::var("PATH") {
182 cmd.env("PATH", path);
183 }
184 if let Ok(home) = std::env::var("HOME") {
185 cmd.env("HOME", home);
186 }
187
188 if self.capture_output {
190 cmd.stdout(Stdio::piped());
191 cmd.stderr(Stdio::piped());
192 } else {
193 cmd.stdout(Stdio::inherit());
194 cmd.stderr(Stdio::inherit());
195 }
196
197 tracing::info!(task = %task.id, "Starting task execution");
199
200 let output = cmd
201 .output()
202 .await
203 .map_err(|e| RunnerError::ExecutionFailed {
204 task: task.id.clone(),
205 source: e,
206 })?;
207
208 let duration = start.elapsed();
209 let exit_code = output.status.code().unwrap_or(-1);
210 let success = output.status.success();
211
212 let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
213 tracing::info!(
214 task = %task.id,
215 exit_code = exit_code,
216 success = success,
217 duration_ms,
218 "Task execution completed"
219 );
220
221 Ok(TaskOutput {
222 task_id: task.id.clone(),
223 exit_code,
224 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
225 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
226 success,
227 from_cache: false,
228 duration_ms,
229 })
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use crate::ir::CachePolicy;
237 use tempfile::TempDir;
238
239 fn make_task(id: &str, command: &[&str], shell: bool) -> IRTask {
240 IRTask {
241 id: id.to_string(),
242 runtime: None,
243 command: command.iter().map(|s| (*s).to_string()).collect(),
244 shell,
245 env: HashMap::new(),
246 secrets: HashMap::new(),
247 resources: None,
248 concurrency_group: None,
249 inputs: vec![],
250 outputs: vec![],
251 depends_on: vec![],
252 cache_policy: CachePolicy::Normal,
253 deployment: false,
254 manual_approval: false,
255 }
256 }
257
258 #[tokio::test]
259 async fn test_simple_command() {
260 let tmp = TempDir::new().unwrap();
261 let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
262 let task = make_task("test", &["echo", "hello"], false);
263
264 let result = runner.execute(&task, HashMap::new()).await.unwrap();
265
266 assert!(result.success);
267 assert_eq!(result.exit_code, 0);
268 assert!(result.stdout.contains("hello"));
269 assert!(!result.from_cache);
270 }
271
272 #[tokio::test]
273 async fn test_shell_mode() {
274 let tmp = TempDir::new().unwrap();
275 let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
276 let task = make_task("test", &["echo", "hello", "&&", "echo", "world"], true);
277
278 let result = runner.execute(&task, HashMap::new()).await.unwrap();
279
280 assert!(result.success);
281 assert!(result.stdout.contains("hello"));
282 assert!(result.stdout.contains("world"));
283 }
284
285 #[tokio::test]
286 async fn test_env_injection() {
287 let tmp = TempDir::new().unwrap();
288 let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
289 let task = make_task("test", &["printenv", "MY_VAR"], false);
290
291 let env = HashMap::from([("MY_VAR".to_string(), "test_value".to_string())]);
292 let result = runner.execute(&task, env).await.unwrap();
293
294 assert!(result.success);
295 assert!(result.stdout.contains("test_value"));
296 }
297
298 #[tokio::test]
299 async fn test_failing_command() {
300 let tmp = TempDir::new().unwrap();
301 let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
302 let task = make_task("test", &["false"], false);
303
304 let result = runner.execute(&task, HashMap::new()).await.unwrap();
305
306 assert!(!result.success);
307 assert_ne!(result.exit_code, 0);
308 }
309
310 #[tokio::test]
311 async fn test_empty_command_error() {
312 let tmp = TempDir::new().unwrap();
313 let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
314 let task = make_task("test", &[], false);
315
316 let result = runner.execute(&task, HashMap::new()).await;
317 assert!(matches!(result, Err(RunnerError::EmptyCommand { .. })));
318 }
319
320 #[test]
321 fn test_cached_output() {
322 let output = TaskOutput::from_cache("test".to_string(), 100);
323 assert!(output.success);
324 assert!(output.from_cache);
325 assert_eq!(output.duration_ms, 100);
326 }
327
328 #[test]
329 fn test_dry_run_output() {
330 let output = TaskOutput::dry_run("test".to_string());
331 assert!(output.success);
332 assert!(!output.from_cache);
333 assert_eq!(output.duration_ms, 0);
334 }
335
336 #[tokio::test]
337 #[ignore = "requires /bin/bash which may not exist in sandboxed builds"]
338 async fn test_custom_shell() {
339 let tmp = TempDir::new().unwrap();
340 let runner = IRTaskRunner::with_shell(tmp.path().to_path_buf(), true, "/bin/bash");
342 let task = make_task("test", &["echo", "$BASH_VERSION"], true);
343
344 let result = runner.execute(&task, HashMap::new()).await.unwrap();
345
346 assert!(result.success);
348 }
349
350 #[test]
351 fn test_runner_default_shell() {
352 let tmp = TempDir::new().unwrap();
353 let runner = IRTaskRunner::new(tmp.path().to_path_buf(), true);
354 assert_eq!(runner.shell_path, "/bin/sh");
355 }
356}