1use std::fs;
44use std::path::{Path, PathBuf};
45
46use crate::helpers::dirs_home;
47
48pub const CODEX_SESSION_TTL_SECS: u64 = 30 * 60;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum Lifecycle {
66 Working,
68 Idle,
72 #[default]
75 Unknown,
76}
77
78#[derive(Debug, Clone, Default)]
80pub struct RolloutSummary {
81 pub session_id: String,
83 pub cwd: String,
85 pub started_at_secs: u64,
87 pub last_event_secs: u64,
89 pub input_tokens: u64,
90 pub cached_input_tokens: u64,
91 pub output_tokens: u64,
92 pub total_tokens: u64,
95 pub context_window: Option<u64>,
97 pub model: Option<String>,
98 pub lifecycle: Lifecycle,
99 pub tool_calls: u32,
101 pub last_tool: Option<String>,
104 pub path: PathBuf,
106}
107
108pub fn sessions_root() -> PathBuf {
110 super::home_for(super::vocab::CODEX)
111 .unwrap_or_else(|| dirs_home().join(".codex"))
112 .join("sessions")
113}
114
115pub fn scan(now_secs: u64, ttl_secs: u64) -> Vec<RolloutSummary> {
121 let mut out = Vec::new();
122 for path in recent_rollout_paths(&sessions_root(), now_secs, ttl_secs) {
123 match fs::read_to_string(&path) {
124 Ok(content) => {
125 if let Some(mut summary) = parse_rollout(&content) {
126 summary.path = path;
127 out.push(summary);
128 }
129 }
130 Err(e) => crate::logger::log(
131 "DEBUG",
132 &format!("codex rollout read failed: {}: {e}", path.display()),
133 ),
134 }
135 }
136 out.sort_by_key(|s| std::cmp::Reverse(s.last_event_secs));
137 out
138}
139
140fn recent_rollout_paths(root: &Path, now_secs: u64, ttl_secs: u64) -> Vec<PathBuf> {
142 let mut found: Vec<(PathBuf, u64)> = Vec::new();
143 for year in subdirs(root) {
145 for month in subdirs(&year) {
146 for day in subdirs(&month) {
147 let Ok(entries) = fs::read_dir(&day) else {
148 continue;
149 };
150 for entry in entries.flatten() {
151 let path = entry.path();
152 if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
153 continue;
154 }
155 let Some(mtime) = mtime_secs(&entry) else {
156 continue;
157 };
158 if now_secs.saturating_sub(mtime) <= ttl_secs {
159 found.push((path, mtime));
160 }
161 }
162 }
163 }
164 }
165 found.sort_by_key(|(_, mtime)| std::cmp::Reverse(*mtime));
166 found.into_iter().map(|(p, _)| p).collect()
167}
168
169fn subdirs(dir: &Path) -> Vec<PathBuf> {
170 let Ok(entries) = fs::read_dir(dir) else {
171 return Vec::new();
172 };
173 entries
174 .flatten()
175 .map(|e| e.path())
176 .filter(|p| p.is_dir())
177 .collect()
178}
179
180fn mtime_secs(entry: &fs::DirEntry) -> Option<u64> {
181 entry
182 .metadata()
183 .ok()?
184 .modified()
185 .ok()?
186 .duration_since(std::time::UNIX_EPOCH)
187 .ok()
188 .map(|d| d.as_secs())
189}
190
191pub fn parse_rollout(content: &str) -> Option<RolloutSummary> {
199 let mut s = RolloutSummary::default();
200 let mut saw_meta = false;
201
202 for line in content.lines() {
203 let line = line.trim();
204 if line.is_empty() {
205 continue;
206 }
207 let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
208 continue;
209 };
210
211 if let Some(secs) = event
212 .get("timestamp")
213 .and_then(|v| v.as_str())
214 .and_then(crate::history::parse_timestamp_epoch)
215 {
216 if s.started_at_secs == 0 {
217 s.started_at_secs = secs;
218 }
219 s.last_event_secs = s.last_event_secs.max(secs);
220 }
221
222 let kind = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
223 let payload = event.get("payload");
224 let payload_kind = payload
225 .and_then(|p| p.get("type"))
226 .and_then(|v| v.as_str())
227 .unwrap_or("");
228
229 match (kind, payload_kind) {
230 ("session_meta", _) => {
231 saw_meta = true;
232 if let Some(p) = payload {
233 if let Some(id) = p
236 .get("session_id")
237 .or_else(|| p.get("id"))
238 .and_then(|v| v.as_str())
239 {
240 s.session_id = id.to_string();
241 }
242 if let Some(cwd) = p.get("cwd").and_then(|v| v.as_str()) {
243 s.cwd = cwd.to_string();
244 }
245 }
246 }
247 ("event_msg", "token_count") => {
248 if let Some(info) = payload.and_then(|p| p.get("info")) {
249 if let Some(total) = info.get("total_token_usage") {
250 s.input_tokens = u64_at(total, "input_tokens");
251 s.cached_input_tokens = u64_at(total, "cached_input_tokens");
252 s.output_tokens = u64_at(total, "output_tokens");
253 s.total_tokens = u64_at(total, "total_tokens");
254 }
255 if let Some(window) = info.get("model_context_window").and_then(|v| v.as_u64())
256 {
257 s.context_window = Some(window);
258 }
259 }
260 }
261 ("event_msg", "task_started") => s.lifecycle = Lifecycle::Working,
262 ("event_msg", "task_complete" | "turn_aborted") => s.lifecycle = Lifecycle::Idle,
263 ("turn_context", _) => {
264 if let Some(model) = payload
265 .and_then(|p| p.get("model"))
266 .and_then(|v| v.as_str())
267 {
268 s.model = Some(model.to_string());
269 }
270 }
271 ("response_item", "function_call" | "custom_tool_call") => {
272 s.tool_calls = s.tool_calls.saturating_add(1);
273 if let Some(p) = payload {
274 if let Some(name) = p.get("name").and_then(|v| v.as_str()) {
275 let namespace = p.get("namespace").and_then(|v| v.as_str());
276 let arg = p
277 .get("input")
278 .or_else(|| p.get("arguments"))
279 .and_then(|v| v.as_str());
280 s.last_tool = Some(super::vocab::canonical_tool(
281 super::vocab::CODEX,
282 namespace,
283 name,
284 arg,
285 ));
286 }
287 }
288 }
289 _ => {}
290 }
291 }
292
293 saw_meta.then_some(s)
294}
295
296fn u64_at(v: &serde_json::Value, key: &str) -> u64 {
297 v.get(key).and_then(|x| x.as_u64()).unwrap_or(0)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 fn rollout_fixture() -> String {
306 [
307 r#"{"timestamp":"2026-10-11T19:08:37.776Z","ordinal":0,"type":"session_meta","payload":{"session_id":"01a12c5d-36b7-7ec3-bead-f78afff85501","id":"01a12c5d-f5ba-7783-96fa-69029d38aec6","cwd":"/Users/dev/project","runtime_workspace_roots":["/Users/dev"]}}"#,
308 r#"{"timestamp":"2026-10-11T19:08:38.000Z","ordinal":1,"type":"turn_context","payload":{"model":"gpt-6-astra"}}"#,
309 r#"{"timestamp":"2026-10-11T19:08:39.000Z","ordinal":2,"type":"event_msg","payload":{"type":"task_started"}}"#,
310 r#"{"timestamp":"2026-10-11T19:08:40.000Z","ordinal":3,"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}","call_id":"c1"}}"#,
311 r#"{"timestamp":"2026-10-11T19:08:41.000Z","ordinal":4,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":33434,"cached_input_tokens":22912,"output_tokens":215,"total_tokens":33649},"model_context_window":272000}}}"#,
312 r#"{"timestamp":"2026-10-11T19:08:42.000Z","ordinal":5,"type":"event_msg","payload":{"type":"task_complete"}}"#,
313 ]
314 .join("\n")
315 }
316
317 #[test]
318 fn parses_identity_and_cwd() {
319 let s = parse_rollout(&rollout_fixture()).expect("fixture should parse");
320 assert_eq!(s.session_id, "01a12c5d-36b7-7ec3-bead-f78afff85501");
321 assert_eq!(s.cwd, "/Users/dev/project");
322 }
323
324 #[test]
328 fn prefers_the_thread_id_over_the_rollout_id() {
329 let line = r#"{"type":"session_meta","payload":{"session_id":"thread","id":"rollout","cwd":"/x"}}"#;
330 let s = parse_rollout(line).unwrap();
331 assert_eq!(s.session_id, "thread");
332 }
333
334 #[test]
336 fn falls_back_to_the_rollout_id() {
337 let line = r#"{"type":"session_meta","payload":{"id":"rollout-only","cwd":"/x"}}"#;
338 let s = parse_rollout(line).unwrap();
339 assert_eq!(s.session_id, "rollout-only");
340 }
341
342 #[test]
343 fn parses_usage_and_the_context_window() {
344 let s = parse_rollout(&rollout_fixture()).unwrap();
345 assert_eq!(s.input_tokens, 33434);
346 assert_eq!(s.cached_input_tokens, 22912);
347 assert_eq!(s.output_tokens, 215);
348 assert_eq!(s.total_tokens, 33649);
349 assert_eq!(
350 s.context_window,
351 Some(272_000),
352 "model_context_window is present, so context pressure needs no new signal"
353 );
354 }
355
356 #[test]
357 fn parses_model_from_turn_context() {
358 let s = parse_rollout(&rollout_fixture()).unwrap();
359 assert_eq!(s.model.as_deref(), Some("gpt-6-astra"));
360 }
361
362 #[test]
364 fn a_finished_turn_reads_as_idle() {
365 let s = parse_rollout(&rollout_fixture()).unwrap();
366 assert_eq!(s.lifecycle, Lifecycle::Idle);
367 }
368
369 #[test]
371 fn an_unfinished_turn_reads_as_working() {
372 let lines = [
373 r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#,
374 r#"{"type":"event_msg","payload":{"type":"task_started"}}"#,
375 ]
376 .join("\n");
377 assert_eq!(parse_rollout(&lines).unwrap().lifecycle, Lifecycle::Working);
378 }
379
380 #[test]
382 fn an_aborted_turn_reads_as_idle() {
383 let lines = [
384 r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#,
385 r#"{"type":"event_msg","payload":{"type":"task_started"}}"#,
386 r#"{"type":"event_msg","payload":{"type":"turn_aborted"}}"#,
387 ]
388 .join("\n");
389 assert_eq!(parse_rollout(&lines).unwrap().lifecycle, Lifecycle::Idle);
390 }
391
392 #[test]
393 fn a_session_with_no_turn_is_unknown() {
394 let line = r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#;
395 assert_eq!(parse_rollout(line).unwrap().lifecycle, Lifecycle::Unknown);
396 }
397
398 #[test]
401 fn the_last_tool_is_canonicalized() {
402 let s = parse_rollout(&rollout_fixture()).unwrap();
403 assert_eq!(
404 s.last_tool.as_deref(),
405 Some("Bash"),
406 "exec_command must surface as Bash, not as its Codex spelling"
407 );
408 assert_eq!(s.tool_calls, 1);
409 }
410
411 #[test]
414 fn apply_patch_uses_its_envelope() {
415 let lines = [
416 r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#,
417 r#"{"type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","input":"*** Begin Patch\n*** Add File: new.rs\n+x"}}"#,
418 ]
419 .join("\n");
420 assert_eq!(
421 parse_rollout(&lines).unwrap().last_tool.as_deref(),
422 Some("Write")
423 );
424 }
425
426 #[test]
427 fn timestamps_bracket_the_session() {
428 let s = parse_rollout(&rollout_fixture()).unwrap();
429 assert!(s.started_at_secs > 0);
430 assert!(
431 s.last_event_secs >= s.started_at_secs,
432 "last event must not precede the first"
433 );
434 }
435
436 #[test]
439 fn a_truncated_trailing_line_is_skipped() {
440 let content = format!("{}\n{{\"timestamp\":\"2026-10", rollout_fixture());
441 let s = parse_rollout(&content).expect("a partial last line must not fail the file");
442 assert_eq!(s.session_id, "01a12c5d-36b7-7ec3-bead-f78afff85501");
443 assert_eq!(s.total_tokens, 33649);
444 }
445
446 #[test]
448 fn a_file_without_session_meta_is_not_a_session() {
449 assert!(
450 parse_rollout(r#"{"type":"event_msg","payload":{"type":"task_started"}}"#).is_none()
451 );
452 assert!(parse_rollout("").is_none());
453 assert!(parse_rollout("not json at all").is_none());
454 }
455
456 #[test]
457 fn sessions_root_is_under_the_codex_home() {
458 let root = sessions_root();
459 assert!(root.ends_with("sessions"));
460 assert!(root.to_string_lossy().contains(".codex"));
461 }
462
463 #[test]
466 fn recent_paths_respect_the_ttl_window() {
467 use std::io::Write;
468 let dir = tempfile::tempdir().unwrap();
469 let day = dir.path().join("2026").join("10").join("11");
470 fs::create_dir_all(&day).unwrap();
471
472 let rollout = day.join("rollout-a.jsonl");
473 let mut f = fs::File::create(&rollout).unwrap();
474 writeln!(f, "{}", rollout_fixture()).unwrap();
475 fs::write(day.join("notes.txt"), "x").unwrap();
477
478 let mtime = mtime_of(&rollout);
479 let found = recent_rollout_paths(dir.path(), mtime, 600);
480 assert_eq!(found.len(), 1, "only the .jsonl should be picked up");
481 assert_eq!(found[0], rollout);
482
483 let stale = recent_rollout_paths(dir.path(), mtime + 10_000, 600);
485 assert!(
486 stale.is_empty(),
487 "past the window a rollout is a transcript, not a session"
488 );
489 }
490
491 #[test]
492 fn scanning_a_missing_root_is_empty_not_an_error() {
493 let dir = tempfile::tempdir().unwrap();
494 assert!(recent_rollout_paths(&dir.path().join("nope"), 0, 600).is_empty());
495 }
496
497 fn mtime_of(path: &Path) -> u64 {
498 fs::metadata(path)
499 .unwrap()
500 .modified()
501 .unwrap()
502 .duration_since(std::time::UNIX_EPOCH)
503 .unwrap()
504 .as_secs()
505 }
506}