Skip to main content

codex_wrapper/
history.rs

1//! Read-side access to the CLI's on-disk session logs.
2//!
3//! Read-only. Mutation goes through the CLI, via
4//! [`ArchiveCommand`](crate::ArchiveCommand) and
5//! [`DeleteCommand`](crate::DeleteCommand).
6//!
7//! # On-disk layout
8//!
9//! ```text
10//! $CODEX_HOME/sessions/<YYYY>/<MM>/<DD>/rollout-<ISO8601>-<uuid>.jsonl
11//! ```
12//!
13//! The date partitioning is why [`SessionQuery::after`] and
14//! [`SessionQuery::before`] are cheap: they filter directories, without opening
15//! a file.
16//!
17//! # Two envelope generations
18//!
19//! A real machine holds sessions written by many CLI versions, and the line
20//! format changed. Both were found on one machine while writing this, 205
21//! files spanning both:
22//!
23//! **Modern**, from around 0.47 onward. Every line is an envelope:
24//!
25//! ```json
26//! {"timestamp":"...","type":"session_meta","payload":{"id":"...","cwd":"..."}}
27//! {"timestamp":"...","type":"response_item","payload":{"type":"message","role":"user"}}
28//! ```
29//!
30//! **Legacy**, older files. No envelope at all: the first line *is* the
31//! metadata, and later lines are bare records.
32//!
33//! ```json
34//! {"id":"...","timestamp":"...","git":{},"instructions":null}
35//! {"id":"...","type":"message","role":"user","content":[]}
36//! ```
37//!
38//! A parser written against only the modern shape returns nothing at all for
39//! the older half of a real history, silently. [`SessionEntry::entry_type`] is
40//! `None` for a legacy line, and its `payload` is the whole line.
41//!
42//! Field-level drift is handled the same way: every metadata field is
43//! optional, because older files carry no `cli_version`, no `cwd`, and a
44//! different `instructions` key. [`SessionMeta::raw`] keeps whatever this
45//! crate does not name.
46//!
47//! # Example
48//!
49//! ```no_run
50//! use codex_wrapper::history::{self, SessionQuery};
51//!
52//! # fn example() -> codex_wrapper::Result<()> {
53//! for session in history::list(&SessionQuery::new().after(2026, 8, 1))? {
54//!     let log = history::read(&session.path)?;
55//!     println!("{} in {:?}", session.id, log.meta.and_then(|m| m.cwd));
56//! }
57//! # Ok(())
58//! # }
59//! ```
60
61use std::path::{Path, PathBuf};
62
63use crate::error::{Error, Result};
64
65/// A rollout file on disk, identified without opening it.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct SessionFile {
68    /// Full path to the `.jsonl` file.
69    pub path: PathBuf,
70    /// The session id, taken from the filename.
71    ///
72    /// This is the `thread_id` a resume takes.
73    pub id: String,
74    /// The date directory this file sits in, as `(year, month, day)`.
75    pub date: (u16, u8, u8),
76}
77
78/// Git metadata recorded with a session.
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct GitMeta {
82    /// Commit the working tree was on.
83    pub commit_hash: Option<String>,
84    /// Branch name.
85    pub branch: Option<String>,
86    /// Remote URL.
87    pub repository_url: Option<String>,
88}
89
90/// The session's opening metadata.
91///
92/// Every field is optional. Older files carry a different and smaller set, and
93/// a reader that required any one of them would fail on a real history.
94#[derive(Debug, Clone, Default, PartialEq)]
95#[non_exhaustive]
96pub struct SessionMeta {
97    /// Session id as recorded inside the file.
98    pub id: Option<String>,
99    /// ISO 8601 start time.
100    pub timestamp: Option<String>,
101    /// Working directory the session ran in. Absent in legacy files.
102    pub cwd: Option<PathBuf>,
103    /// CLI version that wrote it. Absent in legacy files.
104    pub cli_version: Option<String>,
105    /// What started the session, for example `codex_cli_rs`.
106    pub originator: Option<String>,
107    /// Entry point, for example `cli`.
108    pub source: Option<String>,
109    /// Git metadata, when recorded.
110    pub git: Option<GitMeta>,
111    /// The metadata object as written, including keys not named above.
112    pub raw: serde_json::Value,
113}
114
115/// One line of a session log.
116#[derive(Debug, Clone, PartialEq)]
117pub struct SessionEntry {
118    /// Envelope timestamp, when the line has an envelope.
119    pub timestamp: Option<String>,
120    /// Envelope type: `event_msg`, `response_item`, `turn_context`, and so on.
121    ///
122    /// `None` for a legacy line, which has no envelope. In that case
123    /// `payload` is the whole line.
124    pub entry_type: Option<String>,
125    /// The payload, or the whole line for a legacy entry.
126    pub payload: serde_json::Value,
127}
128
129impl SessionEntry {
130    /// The payload's own `type`, which is the useful discriminator.
131    ///
132    /// For a modern `event_msg` this is `agent_message`, `token_count`, and
133    /// so on; for a `response_item`, `message` or `reasoning`.
134    #[must_use]
135    pub fn payload_type(&self) -> Option<&str> {
136        self.payload.get("type")?.as_str()
137    }
138}
139
140/// A parsed session log.
141#[derive(Debug, Clone, PartialEq)]
142pub struct SessionLog {
143    /// The file this came from.
144    pub path: PathBuf,
145    /// Opening metadata, if the file had any.
146    pub meta: Option<SessionMeta>,
147    /// Every line after the metadata, in order.
148    pub entries: Vec<SessionEntry>,
149}
150
151/// Which sessions to list.
152#[derive(Debug, Clone, Default)]
153pub struct SessionQuery {
154    after: Option<(u16, u8, u8)>,
155    before: Option<(u16, u8, u8)>,
156    cwd: Option<PathBuf>,
157}
158
159impl SessionQuery {
160    /// Every session.
161    #[must_use]
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// On or after this date. Filters directories, without opening files.
167    #[must_use]
168    pub fn after(mut self, year: u16, month: u8, day: u8) -> Self {
169        self.after = Some((year, month, day));
170        self
171    }
172
173    /// On or before this date. Filters directories, without opening files.
174    #[must_use]
175    pub fn before(mut self, year: u16, month: u8, day: u8) -> Self {
176        self.before = Some((year, month, day));
177        self
178    }
179
180    /// Only sessions recorded as running in this directory.
181    ///
182    /// Unlike the date filters this one has to open each candidate file to
183    /// read its metadata, and legacy files record no `cwd` so they never
184    /// match.
185    #[must_use]
186    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
187        self.cwd = Some(cwd.into());
188        self
189    }
190
191    fn matches_date(&self, date: (u16, u8, u8)) -> bool {
192        self.after.is_none_or(|after| date >= after)
193            && self.before.is_none_or(|before| date <= before)
194    }
195}
196
197/// List sessions for the current environment, newest first.
198///
199/// Honors `CODEX_HOME`, defaulting to `~/.codex`.
200pub fn list(query: &SessionQuery) -> Result<Vec<SessionFile>> {
201    let home = crate::codex_home::resolve(&|key| std::env::var(key).ok());
202    list_in(home, query)
203}
204
205/// [`list`], but against an explicit `CODEX_HOME`.
206///
207/// A missing `sessions` directory yields an empty list rather than an error:
208/// no sessions is a normal state.
209pub fn list_in(codex_home: impl AsRef<Path>, query: &SessionQuery) -> Result<Vec<SessionFile>> {
210    let root = codex_home.as_ref().join("sessions");
211    let mut found = Vec::new();
212
213    for (date, day_dir) in date_dirs(&root) {
214        if !query.matches_date(date) {
215            continue;
216        }
217        let Ok(entries) = std::fs::read_dir(&day_dir) else {
218            continue;
219        };
220        for entry in entries.filter_map(std::result::Result::ok) {
221            let path = entry.path();
222            let Some(id) = session_id_from_path(&path) else {
223                continue;
224            };
225            if let Some(wanted) = &query.cwd
226                && !session_ran_in(&path, wanted)
227            {
228                continue;
229            }
230            found.push(SessionFile { path, id, date });
231        }
232    }
233
234    // Newest first. The filename carries the timestamp, so it orders within a
235    // day without opening anything.
236    found.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| b.path.cmp(&a.path)));
237    Ok(found)
238}
239
240/// Read and parse one session log.
241pub fn read(path: impl AsRef<Path>) -> Result<SessionLog> {
242    let path = path.as_ref();
243    let contents = std::fs::read_to_string(path).map_err(|e| Error::Io {
244        message: format!("failed to read {}: {e}", path.display()),
245        source: e,
246        working_dir: None,
247    })?;
248
249    let mut meta = None;
250    let mut entries = Vec::new();
251
252    for line in contents.lines() {
253        let line = line.trim();
254        if line.is_empty() {
255            continue;
256        }
257        // A line that will not parse is skipped rather than failing the read.
258        // These files are appended to by a long-running process and a
259        // truncated tail should not cost the caller the rest of the session.
260        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
261            continue;
262        };
263
264        match envelope(&value) {
265            Some((timestamp, entry_type, payload)) => {
266                if entry_type == "session_meta" && meta.is_none() {
267                    meta = Some(parse_meta(payload));
268                    continue;
269                }
270                entries.push(SessionEntry {
271                    timestamp: timestamp.map(str::to_string),
272                    entry_type: Some(entry_type.to_string()),
273                    payload: payload.clone(),
274                });
275            }
276            None => {
277                // Legacy: no envelope. The first such line is the metadata,
278                // recognised by carrying an id and no record type of its own.
279                if meta.is_none() && value.get("id").is_some() && value.get("type").is_none() {
280                    meta = Some(parse_meta(&value));
281                    continue;
282                }
283                entries.push(SessionEntry {
284                    timestamp: value
285                        .get("timestamp")
286                        .and_then(|v| v.as_str())
287                        .map(str::to_string),
288                    entry_type: None,
289                    payload: value,
290                });
291            }
292        }
293    }
294
295    Ok(SessionLog {
296        path: path.to_path_buf(),
297        meta,
298        entries,
299    })
300}
301
302/// `(timestamp, type, payload)` for a modern envelope, `None` for a legacy
303/// line.
304fn envelope(value: &serde_json::Value) -> Option<(Option<&str>, &str, &serde_json::Value)> {
305    let payload = value.get("payload")?;
306    let entry_type = value.get("type")?.as_str()?;
307    Some((
308        value.get("timestamp").and_then(serde_json::Value::as_str),
309        entry_type,
310        payload,
311    ))
312}
313
314fn parse_meta(value: &serde_json::Value) -> SessionMeta {
315    let string = |key: &str| {
316        value
317            .get(key)
318            .and_then(serde_json::Value::as_str)
319            .map(str::to_string)
320    };
321    SessionMeta {
322        id: string("id").or_else(|| string("session_id")),
323        timestamp: string("timestamp"),
324        cwd: string("cwd").map(PathBuf::from),
325        cli_version: string("cli_version"),
326        originator: string("originator"),
327        source: string("source"),
328        git: value.get("git").and_then(|git| {
329            let get = |key: &str| {
330                git.get(key)
331                    .and_then(serde_json::Value::as_str)
332                    .map(str::to_string)
333            };
334            let meta = GitMeta {
335                commit_hash: get("commit_hash"),
336                branch: get("branch"),
337                repository_url: get("repository_url"),
338            };
339            (meta != GitMeta::default()).then_some(meta)
340        }),
341        raw: value.clone(),
342    }
343}
344
345/// `(year, month, day)` directories under the sessions root.
346fn date_dirs(root: &Path) -> Vec<((u16, u8, u8), PathBuf)> {
347    let mut out = Vec::new();
348    for year in numeric_children(root) {
349        for month in numeric_children(&year.1) {
350            for day in numeric_children(&month.1) {
351                out.push(((year.0 as u16, month.0 as u8, day.0 as u8), day.1));
352            }
353        }
354    }
355    out
356}
357
358/// Child directories whose names are numbers, with the parsed value.
359fn numeric_children(dir: &Path) -> Vec<(u32, PathBuf)> {
360    let Ok(entries) = std::fs::read_dir(dir) else {
361        return Vec::new();
362    };
363    entries
364        .filter_map(std::result::Result::ok)
365        .filter_map(|entry| {
366            let name = entry.file_name().into_string().ok()?;
367            let value = name.parse::<u32>().ok()?;
368            entry.path().is_dir().then(|| (value, entry.path()))
369        })
370        .collect()
371}
372
373/// The uuid tail of `rollout-<ISO8601>-<uuid>.jsonl`.
374///
375/// The timestamp itself contains dashes, so this takes the tail rather than
376/// splitting: a uuid is five dash-separated groups.
377fn session_id_from_path(path: &Path) -> Option<String> {
378    let name = path.file_name()?.to_str()?;
379    let stem = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
380    let parts: Vec<&str> = stem.split('-').collect();
381    if parts.len() < 5 {
382        return None;
383    }
384    Some(parts[parts.len() - 5..].join("-"))
385}
386
387/// Whether a session's metadata records it as having run in `wanted`.
388fn session_ran_in(path: &Path, wanted: &Path) -> bool {
389    let Ok(contents) = std::fs::read_to_string(path) else {
390        return false;
391    };
392    let Some(first) = contents.lines().find(|line| !line.trim().is_empty()) else {
393        return false;
394    };
395    let Ok(value) = serde_json::from_str::<serde_json::Value>(first) else {
396        return false;
397    };
398    let meta = value.get("payload").unwrap_or(&value);
399    meta.get("cwd").and_then(serde_json::Value::as_str) == wanted.to_str()
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    fn temp_home(label: &str) -> PathBuf {
407        let dir = std::env::temp_dir().join(format!(
408            "codex-wrapper-history-{}-{label}",
409            std::process::id()
410        ));
411        let _ = std::fs::remove_dir_all(&dir);
412        dir
413    }
414
415    fn write_session(home: &Path, date: (u16, u8, u8), name: &str, lines: &[&str]) -> PathBuf {
416        let dir = home
417            .join("sessions")
418            .join(format!("{:04}", date.0))
419            .join(format!("{:02}", date.1))
420            .join(format!("{:02}", date.2));
421        std::fs::create_dir_all(&dir).unwrap();
422        let path = dir.join(name);
423        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
424        path
425    }
426
427    /// Transcribed from a real 2026 rollout: envelope of timestamp, type and
428    /// payload on every line.
429    const MODERN: &[&str] = &[
430        r#"{"timestamp":"2026-08-06T10:11:24Z","type":"session_meta","payload":{"session_id":"019fd80e-eb27-70e3-ad2e-ed939930901a","id":"019fd80e-eb27-70e3-ad2e-ed939930901a","timestamp":"2026-08-06T10:11:24Z","cwd":"/repo","originator":"codex_cli_rs","cli_version":"0.145.0","source":"cli","git":{"commit_hash":"abc123","branch":"main","repository_url":"git@example.com:o/r.git"}}}"#,
431        r#"{"timestamp":"2026-08-06T10:11:25Z","type":"turn_context","payload":{"model":"gpt-5.6-sol","cwd":"/repo"}}"#,
432        r#"{"timestamp":"2026-08-06T10:11:30Z","type":"event_msg","payload":{"type":"agent_message","message":"hello"}}"#,
433        r#"{"timestamp":"2026-08-06T10:11:31Z","type":"response_item","payload":{"type":"message","role":"assistant"}}"#,
434    ];
435
436    /// Transcribed from a real 2025-09 rollout: no envelope at all.
437    const LEGACY: &[&str] = &[
438        r#"{"id":"7b332612-1b8e-424b-bbb4-a239a64377fb","timestamp":"2025-09-01T19:51:34Z","instructions":null,"git":{"commit_hash":"old123"}}"#,
439        r#"{"record_type":"state"}"#,
440        r#"{"id":"item_0","type":"message","role":"user","content":[]}"#,
441    ];
442
443    #[test]
444    fn a_missing_sessions_directory_is_empty_not_an_error() {
445        let home = temp_home("empty");
446        assert_eq!(list_in(&home, &SessionQuery::new()).unwrap(), vec![]);
447    }
448
449    #[test]
450    fn lists_sessions_newest_first() {
451        let home = temp_home("order");
452        write_session(
453            &home,
454            (2026, 8, 1),
455            "rollout-2026-08-01T10-00-00-aaaaaaaa-1111-2222-3333-444444444444.jsonl",
456            MODERN,
457        );
458        write_session(
459            &home,
460            (2026, 8, 6),
461            "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
462            MODERN,
463        );
464        write_session(
465            &home,
466            (2025, 9, 1),
467            "rollout-2025-09-01T19-51-34-cccccccc-1111-2222-3333-444444444444.jsonl",
468            LEGACY,
469        );
470
471        let found = list_in(&home, &SessionQuery::new()).unwrap();
472        let dates: Vec<_> = found.iter().map(|s| s.date).collect();
473        assert_eq!(dates, vec![(2026, 8, 6), (2026, 8, 1), (2025, 9, 1)]);
474        assert_eq!(found[0].id, "bbbbbbbb-1111-2222-3333-444444444444");
475    }
476
477    #[test]
478    fn date_filters_narrow_the_listing() {
479        let home = temp_home("dates");
480        write_session(
481            &home,
482            (2026, 8, 1),
483            "rollout-2026-08-01T10-00-00-aaaaaaaa-1111-2222-3333-444444444444.jsonl",
484            MODERN,
485        );
486        write_session(
487            &home,
488            (2026, 8, 6),
489            "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
490            MODERN,
491        );
492
493        let after = list_in(&home, &SessionQuery::new().after(2026, 8, 5)).unwrap();
494        assert_eq!(after.len(), 1);
495        assert_eq!(after[0].date, (2026, 8, 6));
496
497        let before = list_in(&home, &SessionQuery::new().before(2026, 8, 5)).unwrap();
498        assert_eq!(before.len(), 1);
499        assert_eq!(before[0].date, (2026, 8, 1));
500
501        let between = list_in(
502            &home,
503            &SessionQuery::new().after(2026, 8, 1).before(2026, 8, 6),
504        )
505        .unwrap();
506        assert_eq!(between.len(), 2);
507    }
508
509    #[test]
510    fn cwd_filter_matches_the_recorded_directory() {
511        let home = temp_home("cwd");
512        write_session(
513            &home,
514            (2026, 8, 6),
515            "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
516            MODERN,
517        );
518
519        assert_eq!(
520            list_in(&home, &SessionQuery::new().cwd("/repo"))
521                .unwrap()
522                .len(),
523            1
524        );
525        assert_eq!(
526            list_in(&home, &SessionQuery::new().cwd("/elsewhere"))
527                .unwrap()
528                .len(),
529            0
530        );
531    }
532
533    #[test]
534    fn reads_a_modern_session() {
535        let home = temp_home("modern");
536        let path = write_session(
537            &home,
538            (2026, 8, 6),
539            "rollout-2026-08-06T10-11-24-bbbbbbbb-1111-2222-3333-444444444444.jsonl",
540            MODERN,
541        );
542
543        let log = read(&path).unwrap();
544        let meta = log.meta.unwrap();
545        assert_eq!(meta.cwd, Some(PathBuf::from("/repo")));
546        assert_eq!(meta.cli_version.as_deref(), Some("0.145.0"));
547        assert_eq!(meta.git.unwrap().branch.as_deref(), Some("main"));
548
549        // The metadata line is not also an entry.
550        assert_eq!(log.entries.len(), 3);
551        assert_eq!(log.entries[0].entry_type.as_deref(), Some("turn_context"));
552        assert_eq!(log.entries[1].payload_type(), Some("agent_message"));
553    }
554
555    /// The whole point of the two-generation handling: a parser written for
556    /// the modern envelope returns nothing here, silently.
557    #[test]
558    fn reads_a_legacy_session_without_an_envelope() {
559        let home = temp_home("legacy");
560        let path = write_session(
561            &home,
562            (2025, 9, 1),
563            "rollout-2025-09-01T19-51-34-cccccccc-1111-2222-3333-444444444444.jsonl",
564            LEGACY,
565        );
566
567        let log = read(&path).unwrap();
568        let meta = log.meta.expect("the first line is the metadata");
569        assert_eq!(
570            meta.id.as_deref(),
571            Some("7b332612-1b8e-424b-bbb4-a239a64377fb")
572        );
573        assert_eq!(meta.cli_version, None, "legacy files record no version");
574        assert_eq!(meta.cwd, None, "legacy files record no cwd");
575        assert_eq!(meta.git.unwrap().commit_hash.as_deref(), Some("old123"));
576
577        assert_eq!(log.entries.len(), 2);
578        // No envelope, so the whole line is the payload.
579        assert_eq!(log.entries[0].entry_type, None);
580        assert_eq!(log.entries[1].payload_type(), Some("message"));
581    }
582
583    /// These files are appended to by a running process, so a truncated last
584    /// line must not cost the caller the rest of the session.
585    #[test]
586    fn a_truncated_tail_does_not_lose_the_rest() {
587        let home = temp_home("truncated");
588        let mut lines = MODERN.to_vec();
589        lines.push(r#"{"timestamp":"2026-08-06T10:11:32Z","type":"event_ms"#);
590        let path = write_session(
591            &home,
592            (2026, 8, 6),
593            "rollout-2026-08-06T10-11-24-dddddddd-1111-2222-3333-444444444444.jsonl",
594            &lines,
595        );
596
597        let log = read(&path).unwrap();
598        assert!(log.meta.is_some());
599        assert_eq!(log.entries.len(), 3, "the good lines survive");
600    }
601
602    /// The timestamp in the filename also contains dashes, so the id has to be
603    /// taken from the tail rather than by splitting on the first one.
604    #[test]
605    fn session_id_comes_from_the_uuid_tail() {
606        assert_eq!(
607            session_id_from_path(Path::new(
608                "/x/rollout-2026-08-06T10-11-24-019fd80e-eb27-70e3-ad2e-ed939930901a.jsonl"
609            )),
610            Some("019fd80e-eb27-70e3-ad2e-ed939930901a".to_string())
611        );
612        assert_eq!(session_id_from_path(Path::new("/x/notes.txt")), None);
613    }
614
615    #[test]
616    fn unknown_entry_types_are_kept_rather_than_dropped() {
617        let home = temp_home("unknown");
618        let path = write_session(
619            &home,
620            (2026, 8, 6),
621            "rollout-2026-08-06T10-11-24-eeeeeeee-1111-2222-3333-444444444444.jsonl",
622            &[
623                MODERN[0],
624                r#"{"timestamp":"2026-08-06T10:11:40Z","type":"something_new","payload":{"type":"unheard_of"}}"#,
625            ],
626        );
627
628        let log = read(&path).unwrap();
629        assert_eq!(log.entries.len(), 1);
630        assert_eq!(log.entries[0].entry_type.as_deref(), Some("something_new"));
631        assert_eq!(log.entries[0].payload_type(), Some("unheard_of"));
632    }
633}