agtrace_providers/codex/
discovery.rs

1use crate::traits::{LogDiscovery, ProbeResult, SessionIndex};
2use anyhow::Result;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7use super::io::{extract_codex_header, is_empty_codex_session};
8
9pub struct CodexDiscovery;
10
11impl LogDiscovery for CodexDiscovery {
12    fn id(&self) -> &'static str {
13        "codex"
14    }
15
16    fn probe(&self, path: &Path) -> ProbeResult {
17        if !path.is_file() {
18            return ProbeResult::NoMatch;
19        }
20
21        if let Ok(metadata) = std::fs::metadata(path)
22            && metadata.len() == 0
23        {
24            return ProbeResult::NoMatch;
25        }
26
27        let is_jsonl = path.extension().is_some_and(|e| e == "jsonl");
28        let filename = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
29
30        if is_jsonl && filename.starts_with("rollout-") && !is_empty_codex_session(path) {
31            ProbeResult::match_high()
32        } else {
33            ProbeResult::NoMatch
34        }
35    }
36
37    fn resolve_log_root(&self, _project_root: &Path) -> Option<PathBuf> {
38        None
39    }
40
41    fn scan_sessions(&self, log_root: &Path) -> Result<Vec<SessionIndex>> {
42        let mut sessions: HashMap<String, SessionIndex> = HashMap::new();
43
44        if !log_root.exists() {
45            return Ok(Vec::new());
46        }
47
48        for entry in WalkDir::new(log_root).into_iter().filter_map(|e| e.ok()) {
49            let path = entry.path();
50
51            if self.probe(path) == ProbeResult::NoMatch {
52                continue;
53            }
54
55            let header = match extract_codex_header(path) {
56                Ok(h) => h,
57                Err(_) => continue,
58            };
59
60            let session_id = match header.session_id {
61                Some(id) => id,
62                None => continue,
63            };
64
65            sessions
66                .entry(session_id.clone())
67                .or_insert_with(|| SessionIndex {
68                    session_id: session_id.clone(),
69                    timestamp: header.timestamp.clone(),
70                    latest_mod_time: None, // Will be computed after all files are collected
71                    main_file: path.to_path_buf(),
72                    sidechain_files: Vec::new(),
73                    project_root: header.cwd.clone().map(PathBuf::from),
74                    snippet: header.snippet.clone(),
75                });
76        }
77
78        // NOTE: Compute latest_mod_time for each session after all files are collected
79        // This tracks when the session was last active (most recent file modification)
80        // Critical for watch mode to identify "most recently updated" vs "most recently created" sessions
81        for session in sessions.values_mut() {
82            let all_files = vec![session.main_file.as_path()];
83            session.latest_mod_time = crate::get_latest_mod_time_rfc3339(&all_files);
84        }
85
86        Ok(sessions.into_values().collect())
87    }
88
89    fn extract_session_id(&self, path: &Path) -> Result<String> {
90        let header = extract_codex_header(path)?;
91        header
92            .session_id
93            .ok_or_else(|| anyhow::anyhow!("No session_id in file: {}", path.display()))
94    }
95
96    fn extract_project_hash(&self, path: &Path) -> Result<Option<agtrace_types::ProjectHash>> {
97        let header = extract_codex_header(path)?;
98        Ok(header
99            .cwd
100            .map(|cwd| agtrace_types::project_hash_from_root(&cwd)))
101    }
102
103    fn find_session_files(&self, log_root: &Path, session_id: &str) -> Result<Vec<PathBuf>> {
104        let mut matching_files = Vec::new();
105
106        for entry in WalkDir::new(log_root).into_iter().filter_map(|e| e.ok()) {
107            let path = entry.path();
108
109            if self.probe(path) == ProbeResult::NoMatch {
110                continue;
111            }
112
113            if let Ok(header) = extract_codex_header(path)
114                && header.session_id.as_deref() == Some(session_id)
115            {
116                matching_files.push(path.to_path_buf());
117            }
118        }
119
120        Ok(matching_files)
121    }
122}