Skip to main content

recursive/
checkpoint_log.rs

1//! Per-session log of turn-level checkpoint records.
2//!
3//! Each session writes a `checkpoints.jsonl` file alongside its
4//! transcript. One line = one turn, capturing the pre/post checkpoint
5//! ids and the set of files this turn touched (via structured tool
6//! calls and/or fallback shell-diff).
7//!
8//! This metadata is what `recursive sessions rewind` reads to:
9//!  1. find the right `pre` checkpoint to restore to,
10//!  2. compute the union of files touched in the rewound-away turns,
11//!  3. detect conflicts where the workspace's current state diverges
12//!     from this session's last known post-snapshot.
13
14use serde::{Deserialize, Serialize};
15use std::fs::{File, OpenOptions};
16use std::io::{BufRead, BufReader, BufWriter, Write};
17use std::path::{Path, PathBuf};
18
19use crate::checkpoint::CheckpointId;
20use crate::error::{Error, Result};
21
22/// One record per turn.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24pub struct CheckpointRecord {
25    /// 0-indexed turn within the session.
26    pub turn: usize,
27    /// Checkpoint id captured at the start of the turn (before LLM
28    /// + tools). May be absent for the very first turn of legacy
29    ///   sessions.
30    pub pre: Option<CheckpointId>,
31    /// Checkpoint id captured at the end of the turn.
32    pub post: CheckpointId,
33    /// Workspace-relative paths the agent touched this turn.
34    pub touched_files: Vec<String>,
35    /// "structured" if all touched files came from typed tool args;
36    /// "shell-diff" if at least one file was attributed via fallback
37    /// pre/post tree diff after a `run_shell` call.
38    pub touched_via: TouchedVia,
39    /// Unix seconds.
40    pub started_at: i64,
41    /// Unix seconds.
42    pub finished_at: i64,
43}
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "kebab-case")]
47pub enum TouchedVia {
48    Structured,
49    ShellDiff,
50}
51
52/// Append-only writer.
53#[derive(Debug)]
54pub struct CheckpointLogWriter {
55    path: PathBuf,
56}
57
58impl CheckpointLogWriter {
59    /// Open or create the log at `path`. Parent directory must exist.
60    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
61        let path = path.into();
62        if let Some(parent) = path.parent() {
63            std::fs::create_dir_all(parent).map_err(Error::Io)?;
64        }
65        // Touch the file if missing.
66        let _ = OpenOptions::new()
67            .append(true)
68            .create(true)
69            .open(&path)
70            .map_err(Error::Io)?;
71        Ok(Self { path })
72    }
73
74    /// Append a record. Each call performs an O_APPEND fsync-less write;
75    /// records are durable on flush.
76    pub fn append(&self, rec: &CheckpointRecord) -> Result<()> {
77        let mut f = OpenOptions::new()
78            .append(true)
79            .create(true)
80            .open(&self.path)
81            .map_err(Error::Io)?;
82        let mut w = BufWriter::new(&mut f);
83        serde_json::to_writer(&mut w, rec).map_err(Error::Json)?;
84        w.write_all(b"\n").map_err(Error::Io)?;
85        w.flush().map_err(Error::Io)?;
86        Ok(())
87    }
88}
89
90/// Read all records from `path`. Empty / missing file → empty vec.
91pub fn read_log(path: &Path) -> Result<Vec<CheckpointRecord>> {
92    if !path.exists() {
93        return Ok(vec![]);
94    }
95    let f = File::open(path).map_err(Error::Io)?;
96    let r = BufReader::new(f);
97    let mut out = Vec::new();
98    for (i, line) in r.lines().enumerate() {
99        let line = line.map_err(Error::Io)?;
100        if line.trim().is_empty() {
101            continue;
102        }
103        let rec: CheckpointRecord = serde_json::from_str(&line).map_err(|e| Error::Tool {
104            name: "checkpoint-log".into(),
105            message: format!("malformed log line {}: {e}", i + 1),
106        })?;
107        out.push(rec);
108    }
109    Ok(out)
110}
111
112/// Truncate the log to records with `turn < cutoff`. Atomic via temp
113/// file + rename.
114pub fn truncate_to_turn(path: &Path, cutoff: usize) -> Result<()> {
115    let recs = read_log(path)?;
116    let kept: Vec<&CheckpointRecord> = recs.iter().filter(|r| r.turn < cutoff).collect();
117
118    let tmp = path.with_extension("jsonl.tmp");
119    {
120        let f = File::create(&tmp).map_err(Error::Io)?;
121        let mut w = BufWriter::new(f);
122        for r in &kept {
123            serde_json::to_writer(&mut w, r).map_err(Error::Json)?;
124            w.write_all(b"\n").map_err(Error::Io)?;
125        }
126        w.flush().map_err(Error::Io)?;
127    }
128    std::fs::rename(&tmp, path).map_err(Error::Io)?;
129    Ok(())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn rec(turn: usize, post: &str) -> CheckpointRecord {
137        CheckpointRecord {
138            turn,
139            pre: None,
140            post: CheckpointId(post.to_string()),
141            touched_files: vec!["a.txt".into()],
142            touched_via: TouchedVia::Structured,
143            started_at: 0,
144            finished_at: 0,
145        }
146    }
147
148    #[test]
149    fn write_then_read_round_trip() {
150        let dir = tempfile::tempdir().unwrap();
151        let path = dir.path().join("checkpoints.jsonl");
152        let w = CheckpointLogWriter::open(&path).unwrap();
153        w.append(&rec(0, "aaa")).unwrap();
154        w.append(&rec(1, "bbb")).unwrap();
155        let read = read_log(&path).unwrap();
156        assert_eq!(read.len(), 2);
157        assert_eq!(read[0].turn, 0);
158        assert_eq!(read[1].post.0, "bbb");
159    }
160
161    #[test]
162    fn read_missing_file_returns_empty() {
163        let dir = tempfile::tempdir().unwrap();
164        let path = dir.path().join("does-not-exist.jsonl");
165        assert!(read_log(&path).unwrap().is_empty());
166    }
167
168    #[test]
169    fn truncate_drops_later_turns() {
170        let dir = tempfile::tempdir().unwrap();
171        let path = dir.path().join("c.jsonl");
172        let w = CheckpointLogWriter::open(&path).unwrap();
173        for t in 0..5 {
174            w.append(&rec(t, &format!("p{t}"))).unwrap();
175        }
176        truncate_to_turn(&path, 3).unwrap();
177        let kept = read_log(&path).unwrap();
178        assert_eq!(kept.len(), 3);
179        assert!(kept.iter().all(|r| r.turn < 3));
180    }
181
182    #[test]
183    fn truncate_to_zero_empties_file() {
184        let dir = tempfile::tempdir().unwrap();
185        let path = dir.path().join("c.jsonl");
186        let w = CheckpointLogWriter::open(&path).unwrap();
187        w.append(&rec(0, "x")).unwrap();
188        truncate_to_turn(&path, 0).unwrap();
189        assert!(read_log(&path).unwrap().is_empty());
190    }
191}