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 checkpoint.
23///
24/// **Goal 284**: With on-demand checkpoints (no automatic pre/post
25/// per turn), the schema is:
26/// - `turn` — which turn the agent was in when it saved.
27/// - `id` — the checkpoint id (was `post`).
28/// - `message` — agent-supplied label (optional).
29/// - `touched_files` — files touched up to this point.
30/// - `touched_via` — attribution method.
31/// - `saved_at` — Unix timestamp of the save.
32///
33/// **Backwards compatibility**: old `pre`/`post`/`started_at`/
34/// `finished_at` fields are accepted on deserialization but ignored
35/// in favour of the new fields. New records always use the new names.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct CheckpointRecord {
38    /// 0-indexed turn within the session.
39    pub turn: usize,
40    /// Deprecated (auto-snapshot era). Present on old records;
41    /// ignored when `id` is available.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub pre: Option<CheckpointId>,
44    /// Checkpoint id (was `post` in auto-snapshot era).
45    #[serde(alias = "post")]
46    pub id: CheckpointId,
47    /// Agent-supplied label for this checkpoint. Defaults to empty
48    /// for auto-snapshot records that lack it.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub message: Option<String>,
51    /// Workspace-relative paths the agent touched this turn.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub touched_files: Vec<String>,
54    /// "structured" if all touched files came from typed tool args;
55    /// "shell-diff" if at least one file was attributed via fallback
56    /// pre/post tree diff after a `run_shell` call.
57    pub touched_via: TouchedVia,
58    /// Deprecated (auto-snapshot era). Present on old records.
59    #[serde(default, skip_serializing_if = "is_zero_i64")]
60    pub started_at: i64,
61    /// Deprecated (auto-snapshot era). Present on old records.
62    #[serde(default, skip_serializing_if = "is_zero_i64")]
63    pub finished_at: i64,
64    /// Unix timestamp of when this checkpoint was saved.
65    #[serde(default, skip_serializing_if = "is_zero_i64")]
66    pub saved_at: i64,
67}
68
69fn is_zero_i64(v: &i64) -> bool {
70    *v == 0
71}
72
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "kebab-case")]
75pub enum TouchedVia {
76    Structured,
77    ShellDiff,
78}
79
80/// Append-only writer.
81#[derive(Debug, Clone)]
82pub struct CheckpointLogWriter {
83    path: PathBuf,
84}
85
86impl CheckpointLogWriter {
87    /// Open or create the log at `path`. Parent directory must exist.
88    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
89        let path = path.into();
90        if let Some(parent) = path.parent() {
91            std::fs::create_dir_all(parent).map_err(Error::Io)?;
92        }
93        // Touch the file if missing.
94        let _ = OpenOptions::new()
95            .append(true)
96            .create(true)
97            .open(&path)
98            .map_err(Error::Io)?;
99        Ok(Self { path })
100    }
101
102    /// Append a record. Each call performs an O_APPEND fsync-less write;
103    /// records are durable on flush.
104    pub fn append(&self, rec: &CheckpointRecord) -> Result<()> {
105        let mut f = OpenOptions::new()
106            .append(true)
107            .create(true)
108            .open(&self.path)
109            .map_err(Error::Io)?;
110        let mut w = BufWriter::new(&mut f);
111        serde_json::to_writer(&mut w, rec).map_err(Error::Json)?;
112        w.write_all(b"\n").map_err(Error::Io)?;
113        w.flush().map_err(Error::Io)?;
114        Ok(())
115    }
116}
117
118/// Read all records from `path`. Empty / missing file → empty vec.
119pub fn read_log(path: &Path) -> Result<Vec<CheckpointRecord>> {
120    if !path.exists() {
121        return Ok(vec![]);
122    }
123    let f = File::open(path).map_err(Error::Io)?;
124    let r = BufReader::new(f);
125    let mut out = Vec::new();
126    for (i, line) in r.lines().enumerate() {
127        let line = line.map_err(Error::Io)?;
128        if line.trim().is_empty() {
129            continue;
130        }
131        let rec: CheckpointRecord = serde_json::from_str(&line).map_err(|e| Error::Tool {
132            name: "checkpoint-log".into(),
133            call_id: None,
134            message: format!("malformed log line {}: {e}", i + 1),
135        })?;
136        out.push(rec);
137    }
138    Ok(out)
139}
140
141/// Truncate the log to records with `turn < cutoff`. Atomic via temp
142/// file + rename.
143pub fn truncate_to_turn(path: &Path, cutoff: usize) -> Result<()> {
144    let recs = read_log(path)?;
145    let kept: Vec<&CheckpointRecord> = recs.iter().filter(|r| r.turn < cutoff).collect();
146
147    let tmp = path.with_extension("jsonl.tmp");
148    {
149        let f = File::create(&tmp).map_err(Error::Io)?;
150        let mut w = BufWriter::new(f);
151        for r in &kept {
152            serde_json::to_writer(&mut w, r).map_err(Error::Json)?;
153            w.write_all(b"\n").map_err(Error::Io)?;
154        }
155        w.flush().map_err(Error::Io)?;
156    }
157    std::fs::rename(&tmp, path).map_err(Error::Io)?;
158    Ok(())
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn rec(turn: usize, id: &str) -> CheckpointRecord {
166        CheckpointRecord {
167            turn,
168            pre: None,
169            id: CheckpointId(id.to_string()),
170            message: None,
171            touched_files: vec!["a.txt".into()],
172            touched_via: TouchedVia::Structured,
173            started_at: 0,
174            finished_at: 0,
175            saved_at: 0,
176        }
177    }
178
179    #[test]
180    fn write_then_read_round_trip() {
181        let dir = tempfile::tempdir().unwrap();
182        let path = dir.path().join("checkpoints.jsonl");
183        let w = CheckpointLogWriter::open(&path).unwrap();
184        w.append(&rec(0, "aaa")).unwrap();
185        w.append(&rec(1, "bbb")).unwrap();
186        let read = read_log(&path).unwrap();
187        assert_eq!(read.len(), 2);
188        assert_eq!(read[0].turn, 0);
189        assert_eq!(read[1].id.0, "bbb");
190    }
191
192    #[test]
193    fn read_missing_file_returns_empty() {
194        let dir = tempfile::tempdir().unwrap();
195        let path = dir.path().join("does-not-exist.jsonl");
196        assert!(read_log(&path).unwrap().is_empty());
197    }
198
199    #[test]
200    fn truncate_drops_later_turns() {
201        let dir = tempfile::tempdir().unwrap();
202        let path = dir.path().join("c.jsonl");
203        let w = CheckpointLogWriter::open(&path).unwrap();
204        for t in 0..5 {
205            w.append(&rec(t, &format!("p{t}"))).unwrap();
206        }
207        truncate_to_turn(&path, 3).unwrap();
208        let kept = read_log(&path).unwrap();
209        assert_eq!(kept.len(), 3);
210        assert!(kept.iter().all(|r| r.turn < 3));
211    }
212
213    #[test]
214    fn truncate_to_zero_empties_file() {
215        let dir = tempfile::tempdir().unwrap();
216        let path = dir.path().join("c.jsonl");
217        let w = CheckpointLogWriter::open(&path).unwrap();
218        w.append(&rec(0, "x")).unwrap();
219        truncate_to_turn(&path, 0).unwrap();
220        assert!(read_log(&path).unwrap().is_empty());
221    }
222
223    #[test]
224    fn is_zero_i64_returns_true_for_zero() {
225        assert!(is_zero_i64(&0_i64));
226    }
227
228    #[test]
229    fn is_zero_i64_returns_false_for_positive() {
230        assert!(!is_zero_i64(&1_i64));
231        assert!(!is_zero_i64(&1000_i64));
232    }
233
234    #[test]
235    fn is_zero_i64_returns_false_for_negative() {
236        assert!(!is_zero_i64(&-1_i64));
237    }
238
239    #[test]
240    fn nonzero_timestamps_survive_round_trip() {
241        let dir = tempfile::tempdir().unwrap();
242        let path = dir.path().join("ts.jsonl");
243        let w = CheckpointLogWriter::open(&path).unwrap();
244        let mut r = rec(0, "ts-test");
245        r.started_at = 1_700_000_000;
246        r.finished_at = 1_700_000_001;
247        r.saved_at = 1_700_000_002;
248        w.append(&r).unwrap();
249        let rows = read_log(&path).unwrap();
250        assert_eq!(rows[0].started_at, 1_700_000_000);
251        assert_eq!(rows[0].finished_at, 1_700_000_001);
252        assert_eq!(rows[0].saved_at, 1_700_000_002);
253    }
254
255    #[test]
256    fn zero_timestamps_are_skipped_in_serialization() {
257        let r = rec(0, "z");
258        let json = serde_json::to_string(&r).unwrap();
259        assert!(
260            !json.contains("started_at"),
261            "zero started_at should be skipped"
262        );
263        assert!(
264            !json.contains("finished_at"),
265            "zero finished_at should be skipped"
266        );
267        assert!(
268            !json.contains("saved_at"),
269            "zero saved_at should be skipped"
270        );
271    }
272
273    #[test]
274    fn nonzero_timestamps_are_included_in_serialization() {
275        // kills `replace is_zero_i64 -> bool with true` and `replace == with !=` mutations.
276        // If is_zero_i64 always returns true, non-zero timestamps would be omitted too.
277        let mut r = rec(0, "z");
278        r.started_at = 1_700_000_000;
279        r.finished_at = 1_700_001_000;
280        r.saved_at = 1_700_002_000;
281        let json = serde_json::to_string(&r).unwrap();
282        assert!(
283            json.contains("started_at"),
284            "non-zero started_at must appear in serialization; got: {json}"
285        );
286        assert!(
287            json.contains("finished_at"),
288            "non-zero finished_at must appear in serialization; got: {json}"
289        );
290        assert!(
291            json.contains("saved_at"),
292            "non-zero saved_at must appear in serialization; got: {json}"
293        );
294    }
295
296    #[test]
297    fn read_log_skips_blank_lines() {
298        // kills the `if line.trim().is_empty() { continue }` guard removal mutation
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("ck.jsonl");
301        let writer = CheckpointLogWriter::open(&path).unwrap();
302        writer.append(&rec(1, "a")).unwrap();
303
304        // Manually insert a blank line between records
305        use std::io::Write;
306        let mut f = std::fs::OpenOptions::new()
307            .append(true)
308            .open(&path)
309            .unwrap();
310        writeln!(f).unwrap(); // blank line
311        drop(f);
312
313        writer.append(&rec(2, "b")).unwrap();
314
315        let recs = read_log(&path).unwrap();
316        assert_eq!(recs.len(), 2, "blank line must be silently skipped");
317        assert_eq!(recs[0].turn, 1);
318        assert_eq!(recs[1].turn, 2);
319    }
320
321    #[test]
322    fn read_log_returns_error_for_malformed_json() {
323        // kills function-level replacement of the parse error path
324        let dir = tempfile::tempdir().unwrap();
325        let path = dir.path().join("bad.jsonl");
326        std::fs::write(&path, "not-valid-json\n").unwrap();
327        let result = read_log(&path);
328        assert!(result.is_err(), "malformed JSON must return an Err");
329    }
330
331    #[test]
332    fn truncate_keeps_records_below_cutoff_exactly() {
333        // kills off-by-one mutations in `r.turn < cutoff`
334        let dir = tempfile::tempdir().unwrap();
335        let path = dir.path().join("ck.jsonl");
336        let writer = CheckpointLogWriter::open(&path).unwrap();
337        for turn in 0..5 {
338            writer.append(&rec(turn, "x")).unwrap();
339        }
340        // cutoff=3 => keep turns 0,1,2; drop turns 3,4
341        truncate_to_turn(&path, 3).unwrap();
342        let recs = read_log(&path).unwrap();
343        assert_eq!(recs.len(), 3);
344        assert_eq!(recs[2].turn, 2, "turn 2 must be kept (< 3)");
345    }
346}