agtrace_providers/codex/
io.rs

1use anyhow::{Context, Result};
2use std::io::{BufRead, BufReader};
3use std::path::Path;
4
5use super::parser::normalize_codex_session;
6use super::schema::CodexRecord;
7
8/// Parse Codex JSONL file and normalize to AgentEvent
9pub fn normalize_codex_file(path: &Path) -> Result<Vec<agtrace_types::AgentEvent>> {
10    let text = std::fs::read_to_string(path)
11        .with_context(|| format!("Failed to read Codex file: {}", path.display()))?;
12
13    let mut records: Vec<CodexRecord> = Vec::new();
14    let mut session_id_from_meta: Option<String> = None;
15
16    for line in text.lines() {
17        let line = line.trim();
18        if line.is_empty() {
19            continue;
20        }
21        let record: CodexRecord = serde_json::from_str(line)
22            .with_context(|| format!("Failed to parse JSON line: {}", line))?;
23
24        // Extract session_id from session_meta record
25        if let CodexRecord::SessionMeta(ref meta) = record {
26            session_id_from_meta = Some(meta.payload.id.clone());
27        }
28
29        records.push(record);
30    }
31
32    // session_id should be extracted from file content, fallback to "unknown-session"
33    let session_id = session_id_from_meta.unwrap_or_else(|| "unknown-session".to_string());
34
35    Ok(normalize_codex_session(records, &session_id))
36}
37
38/// Extract cwd from a Codex session file by reading the first few records
39pub fn extract_cwd_from_codex_file(path: &Path) -> Option<String> {
40    let file = std::fs::File::open(path).ok()?;
41    let reader = BufReader::new(file);
42
43    for line in reader.lines().take(10).flatten() {
44        if let Ok(record) = serde_json::from_str::<CodexRecord>(&line) {
45            match record {
46                CodexRecord::SessionMeta(meta) => {
47                    return Some(meta.payload.cwd.clone());
48                }
49                CodexRecord::TurnContext(turn) => {
50                    return Some(turn.payload.cwd.clone());
51                }
52                _ => continue,
53            }
54        }
55    }
56    None
57}
58
59#[derive(Debug)]
60pub struct CodexHeader {
61    pub session_id: Option<String>,
62    pub cwd: Option<String>,
63    pub timestamp: Option<String>,
64    pub snippet: Option<String>,
65}
66
67/// Extract header information from Codex file (for scanning)
68pub fn extract_codex_header(path: &Path) -> Result<CodexHeader> {
69    let file = std::fs::File::open(path)
70        .with_context(|| format!("Failed to open file: {}", path.display()))?;
71    let reader = BufReader::new(file);
72
73    let mut session_id = None;
74    let mut cwd = None;
75    let mut timestamp = None;
76    let mut snippet = None;
77
78    for line in reader.lines().take(20).flatten() {
79        if let Ok(record) = serde_json::from_str::<CodexRecord>(&line) {
80            match &record {
81                CodexRecord::SessionMeta(meta) => {
82                    if session_id.is_none() {
83                        session_id = Some(meta.payload.id.clone());
84                    }
85                    if cwd.is_none() {
86                        cwd = Some(meta.payload.cwd.clone());
87                    }
88                    if timestamp.is_none() {
89                        timestamp = Some(meta.timestamp.clone());
90                    }
91                }
92                CodexRecord::TurnContext(turn) => {
93                    if cwd.is_none() {
94                        cwd = Some(turn.payload.cwd.clone());
95                    }
96                    if timestamp.is_none() {
97                        timestamp = Some(turn.timestamp.clone());
98                    }
99                }
100                CodexRecord::EventMsg(event) => {
101                    if timestamp.is_none() {
102                        timestamp = Some(event.timestamp.clone());
103                    }
104                    if snippet.is_none()
105                        && let super::schema::EventMsgPayload::UserMessage(msg) = &event.payload
106                    {
107                        snippet = Some(msg.message.clone());
108                    }
109                }
110                CodexRecord::ResponseItem(response) => {
111                    if timestamp.is_none() {
112                        timestamp = Some(response.timestamp.clone());
113                    }
114                    if snippet.is_none()
115                        && let super::schema::ResponseItemPayload::Message(msg) = &response.payload
116                        && msg.role == "user"
117                    {
118                        let text = msg.content.iter().find_map(|c| match c {
119                            super::schema::MessageContent::InputText { text } => Some(text.clone()),
120                            super::schema::MessageContent::OutputText { text } => {
121                                Some(text.clone())
122                            }
123                            _ => None,
124                        });
125                        if let Some(t) = &text
126                            && !t.contains("<environment_context>")
127                        {
128                            snippet = text;
129                        }
130                    }
131                }
132                _ => {}
133            }
134
135            if session_id.is_some() && cwd.is_some() && timestamp.is_some() && snippet.is_some() {
136                break;
137            }
138        }
139    }
140
141    Ok(CodexHeader {
142        session_id,
143        cwd,
144        timestamp,
145        snippet,
146    })
147}
148
149/// Check if a Codex session file is empty or incomplete
150pub fn is_empty_codex_session(path: &Path) -> bool {
151    let Ok(file) = std::fs::File::open(path) else {
152        return true;
153    };
154    let reader = BufReader::new(file);
155
156    let mut line_count = 0;
157    let mut has_event = false;
158
159    for line in reader.lines().take(20).flatten() {
160        line_count += 1;
161        if let Ok(record) = serde_json::from_str::<CodexRecord>(&line) {
162            match record {
163                CodexRecord::SessionMeta(_) | CodexRecord::TurnContext(_) => {
164                    has_event = true;
165                    break;
166                }
167                CodexRecord::EventMsg(_) | CodexRecord::ResponseItem(_) => {
168                    has_event = true;
169                    break;
170                }
171                _ => {}
172            }
173        }
174    }
175
176    line_count <= 2 && !has_event
177}