use super::types::*;
use crate::commands::ingest_claude::ExtractionResult;
use crate::errors::AppError;
use std::io::Write;
use std::path::Path;
use std::process::Stdio;
pub(crate) fn write_schema_tempfile() -> Result<tempfile::NamedTempFile, AppError> {
let mut f = tempfile::NamedTempFile::new().map_err(AppError::Io)?;
std::io::Write::write_all(&mut f, EXTRACTION_SCHEMA_CODEX.as_bytes()).map_err(AppError::Io)?;
std::io::Write::flush(&mut f).map_err(AppError::Io)?;
Ok(f)
}
pub(crate) fn extract_with_codex(
binary: &Path,
file_content: &[u8],
model: Option<&str>,
timeout_secs: u64,
schema_file: &Path,
) -> Result<(ExtractionResult, Option<CodexUsage>), AppError> {
use wait_timeout::ChildExt;
let _ = timeout_secs; let _ = file_content; let _ = schema_file; let prompt = String::new(); let mut cmd = crate::commands::codex_spawn::build_codex_command(
&crate::commands::codex_spawn::CodexSpawnArgs {
binary,
prompt: &prompt,
json_schema: "", input_text: "",
model,
timeout_secs,
schema_path: schema_file.to_path_buf(),
},
)?;
let _ = std::fs::write(schema_file, EXTRACTION_SCHEMA_CODEX);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child =
crate::commands::claude_runner::spawn_with_memory_limit(&mut cmd).map_err(|e| {
AppError::Io(std::io::Error::new(
e.kind(),
format!("failed to spawn codex: {e}"),
))
})?;
let file_utf8 = String::from_utf8(file_content.to_vec())
.map_err(|e| AppError::Validation(crate::i18n::validation::file_not_utf8(&e)))?;
let stdin_payload = format!("{EXTRACTION_PROMPT}\n\n---\n\nDocument content:\n\n{file_utf8}");
let stdin_bytes = stdin_payload.into_bytes();
let mut child_stdin = child.stdin.take().ok_or_else(|| {
AppError::Validation(crate::i18n::validation::failed_to_open_codex_stdin())
})?;
let stdin_thread = std::thread::spawn(move || -> Result<(), std::io::Error> {
child_stdin.write_all(&stdin_bytes)?;
drop(child_stdin);
Ok(())
});
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(timeout_secs);
let status = child.wait_timeout(timeout).map_err(AppError::Io)?;
match status {
Some(exit_status) => {
stdin_thread
.join()
.map_err(
|_| AppError::Validation(crate::i18n::validation::stdin_thread_panicked()),
)?
.map_err(AppError::Io)?;
tracing::debug!(
target: "process",
exit_code = ?exit_status.code(),
elapsed_ms = start.elapsed().as_millis() as u64,
"external process completed"
);
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
if let Some(mut out) = child.stdout.take() {
std::io::Read::read_to_end(&mut out, &mut stdout_buf).map_err(AppError::Io)?;
}
if let Some(mut err) = child.stderr.take() {
std::io::Read::read_to_end(&mut err, &mut stderr_buf).map_err(AppError::Io)?;
}
if !exit_status.success() {
let stderr_str = String::from_utf8_lossy(&stderr_buf);
let stdout_str = String::from_utf8_lossy(&stdout_buf);
if let Ok((result, usage)) = parse_codex_output(&stdout_str) {
return Ok((result, usage));
}
if stderr_str.contains("401")
|| stderr_str.contains("Unauthorized")
|| stderr_str.contains("auth")
{
tracing::warn!(
target: "ingest",
"Codex CLI authentication expired. Re-authenticate with: codex auth login"
);
}
return Err(AppError::Validation(
crate::i18n::validation::process_exited(
"codex exec",
exit_status.code(),
stderr_str.trim(),
),
));
}
let stdout = String::from_utf8(stdout_buf).map_err(|_| {
AppError::Validation(crate::i18n::validation::codex_exec_stdout_not_utf8())
})?;
parse_codex_output(&stdout)
}
None => {
tracing::warn!(target: "ingest", timeout_secs, "codex exec timed out, killing process");
let _ = child.kill();
let _ = child.wait();
let _ = stdin_thread.join();
Err(AppError::Validation(
crate::i18n::validation::process_timed_out("codex exec", timeout_secs),
))
}
}
}
pub(crate) fn parse_codex_output(
stdout: &str,
) -> Result<(ExtractionResult, Option<CodexUsage>), AppError> {
let mut last_agent_text: Option<String> = None;
let mut usage: Option<CodexUsage> = None;
let mut rate_limited = false;
let mut schema_error = false;
let mut turn_failed = false;
let mut failed_message = String::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let event: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
tracing::warn!(target: "ingest", line, "codex output: skipping malformed JSONL line");
continue;
}
};
let event_type = match event.get("type").and_then(|t| t.as_str()) {
Some(t) => t,
None => continue,
};
match event_type {
"item.completed" => {
if let Some(item) = event.get("item") {
if item.get("type").and_then(|t| t.as_str()) == Some("agent_message") {
if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
last_agent_text = Some(text.to_string());
}
}
}
}
"turn.completed" => {
if let Some(u) = event.get("usage") {
if let Ok(parsed) = serde_json::from_value::<CodexUsage>(u.clone()) {
usage = Some(parsed);
}
}
}
"turn.failed" => {
turn_failed = true;
if let Some(err) = event.get("error") {
let msg = err
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
failed_message = msg.to_string();
if msg.contains("rate_limit")
|| msg.contains("429")
|| msg.contains("Too Many Requests")
{
rate_limited = true;
}
}
}
"error" => {
if let Some(msg) = event.get("message").and_then(|m| m.as_str()) {
if msg.contains("invalid_json_schema") || msg.contains("schema") {
schema_error = true;
}
tracing::warn!(target: "ingest", error_msg = msg, "codex error event received");
}
}
_ => {
}
}
}
if rate_limited {
return Err(AppError::RateLimited {
detail: failed_message,
});
}
if schema_error {
return Err(AppError::Validation(
"codex rejected the output schema (invalid_json_schema)".to_string(),
));
}
if turn_failed {
return Err(AppError::Validation(
crate::i18n::validation::codex_turn_failed(&failed_message),
));
}
let text = last_agent_text
.ok_or_else(|| AppError::Validation(crate::i18n::validation::codex_no_agent_message()))?;
let extraction: ExtractionResult = serde_json::from_str(&text).map_err(|e| {
AppError::Validation(
crate::i18n::validation::failed_to_parse_codex_agent_message(&e, &text),
)
})?;
Ok((extraction, usage))
}