Skip to main content

codex_wrapper/
exec.rs

1//! Process execution layer for spawning and communicating with the `codex`
2//! binary, including timeout and retry support.
3
4use std::fmt;
5use std::time::Duration;
6
7use tokio::process::Command;
8use tracing::debug;
9
10use crate::Codex;
11use crate::error::{Error, Result};
12
13/// Raw output from a Codex CLI invocation.
14///
15/// Contains captured stdout/stderr, the process exit code, and a convenience
16/// `success` flag.
17#[derive(Clone)]
18pub struct CommandOutput {
19    /// Standard output as a UTF-8 string.
20    pub stdout: String,
21    /// Standard error as a UTF-8 string.
22    pub stderr: String,
23    /// Process exit code (`-1` if the process was killed by a signal).
24    pub exit_code: i32,
25    /// `true` when the process exited with code 0.
26    pub success: bool,
27}
28
29const DEBUG_TRUNCATE_LEN: usize = 200;
30
31fn truncate_for_debug(s: &str) -> String {
32    if s.len() > DEBUG_TRUNCATE_LEN {
33        format!("{}... ({} bytes total)", &s[..DEBUG_TRUNCATE_LEN], s.len())
34    } else {
35        s.to_string()
36    }
37}
38
39impl fmt::Debug for CommandOutput {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.debug_struct("CommandOutput")
42            .field("stdout", &truncate_for_debug(&self.stdout))
43            .field("stderr", &truncate_for_debug(&self.stderr))
44            .field("exit_code", &self.exit_code)
45            .field("success", &self.success)
46            .finish()
47    }
48}
49
50/// Run a codex command with the given arguments.
51///
52/// If the [`Codex`] client has a retry policy set, transient errors will be
53/// retried according to that policy. A per-command retry policy can be passed
54/// to override the client default.
55pub async fn run_codex(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
56    run_codex_with_retry(codex, args, None).await
57}
58
59/// Run a codex command with an optional per-command retry policy override.
60pub async fn run_codex_with_retry(
61    codex: &Codex,
62    args: Vec<String>,
63    retry_override: Option<&crate::retry::RetryPolicy>,
64) -> Result<CommandOutput> {
65    let policy = retry_override.or(codex.retry_policy.as_ref());
66
67    match policy {
68        Some(policy) => {
69            crate::retry::with_retry(policy, || run_codex_once(codex, args.clone())).await
70        }
71        None => run_codex_once(codex, args).await,
72    }
73}
74
75async fn run_codex_once(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
76    let mut command_args = Vec::new();
77
78    // Global args first (before subcommand)
79    command_args.extend(codex.global_args.clone());
80
81    // Then command-specific args
82    command_args.extend(args);
83
84    debug!(binary = %codex.binary.display(), args = ?command_args, "executing codex command");
85
86    let output = if let Some(timeout) = codex.timeout {
87        run_with_timeout(
88            &codex.binary,
89            &command_args,
90            &codex.env,
91            codex.working_dir.as_deref(),
92            timeout,
93        )
94        .await?
95    } else {
96        run_internal(
97            &codex.binary,
98            &command_args,
99            &codex.env,
100            codex.working_dir.as_deref(),
101        )
102        .await?
103    };
104
105    Ok(output)
106}
107
108/// Run a codex command and allow specific non-zero exit codes.
109pub async fn run_codex_allow_exit_codes(
110    codex: &Codex,
111    args: Vec<String>,
112    allowed_codes: &[i32],
113) -> Result<CommandOutput> {
114    let output = run_codex(codex, args).await;
115
116    match output {
117        Err(Error::CommandFailed {
118            exit_code,
119            stdout,
120            stderr,
121            ..
122        }) if allowed_codes.contains(&exit_code) => Ok(CommandOutput {
123            stdout,
124            stderr,
125            exit_code,
126            success: false,
127        }),
128        other => other,
129    }
130}
131
132async fn run_internal(
133    binary: &std::path::Path,
134    args: &[String],
135    env: &std::collections::HashMap<String, String>,
136    working_dir: Option<&std::path::Path>,
137) -> Result<CommandOutput> {
138    let mut cmd = Command::new(binary);
139    cmd.args(args);
140
141    // Prevent child from inheriting/blocking on parent's stdin.
142    cmd.stdin(std::process::Stdio::null());
143
144    if let Some(dir) = working_dir {
145        cmd.current_dir(dir);
146    }
147
148    for (key, value) in env {
149        cmd.env(key, value);
150    }
151
152    let output = cmd.output().await.map_err(|e| Error::Io {
153        message: format!("failed to spawn codex: {e}"),
154        source: e,
155        working_dir: working_dir.map(|p| p.to_path_buf()),
156    })?;
157
158    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
159    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
160    let exit_code = output.status.code().unwrap_or(-1);
161
162    if !output.status.success() {
163        return Err(Error::CommandFailed {
164            command: format!("{} {}", binary.display(), args.join(" ")),
165            exit_code,
166            stdout,
167            stderr,
168            working_dir: working_dir.map(|p| p.to_path_buf()),
169        });
170    }
171
172    Ok(CommandOutput {
173        stdout,
174        stderr,
175        exit_code,
176        success: true,
177    })
178}
179
180async fn run_with_timeout(
181    binary: &std::path::Path,
182    args: &[String],
183    env: &std::collections::HashMap<String, String>,
184    working_dir: Option<&std::path::Path>,
185    timeout: Duration,
186) -> Result<CommandOutput> {
187    tokio::time::timeout(timeout, run_internal(binary, args, env, working_dir))
188        .await
189        .map_err(|_| Error::Timeout {
190            timeout_seconds: timeout.as_secs(),
191        })?
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn make_output(stdout: &str, stderr: &str) -> CommandOutput {
199        CommandOutput {
200            stdout: stdout.to_string(),
201            stderr: stderr.to_string(),
202            exit_code: 0,
203            success: true,
204        }
205    }
206
207    #[test]
208    fn debug_short_output_not_truncated() {
209        let output = make_output("hello", "world");
210        let debug = format!("{output:?}");
211        assert!(debug.contains("hello"));
212        assert!(debug.contains("world"));
213        assert!(!debug.contains("bytes total"));
214    }
215
216    #[test]
217    fn debug_long_output_truncated() {
218        let long = "x".repeat(300);
219        let output = make_output(&long, &long);
220        let debug = format!("{output:?}");
221        assert!(debug.contains("... (300 bytes total)"));
222        assert!(!debug.contains(&long));
223    }
224}