Skip to main content

aft/
checkpoint.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::backup::BackupStore;
5use crate::error::AftError;
6
7/// Metadata about a checkpoint, returned by list/create/restore.
8#[derive(Debug, Clone)]
9pub struct CheckpointInfo {
10    pub name: String,
11    pub file_count: usize,
12    pub created_at: u64,
13    /// Paths that could not be snapshotted (e.g. deleted since last edit),
14    /// paired with the OS-level error that stopped us from reading them.
15    /// Empty on successful round-trips. Populated only on `create()` — the
16    /// `list()` / `restore()` paths leave it empty.
17    pub skipped: Vec<(PathBuf, String)>,
18}
19
20/// A stored checkpoint: a snapshot of multiple file contents.
21#[derive(Debug, Clone)]
22struct Checkpoint {
23    name: String,
24    file_contents: HashMap<PathBuf, String>,
25    created_at: u64,
26}
27
28/// Workspace-wide, per-session checkpoint store.
29///
30/// Partitioned by session (issue #14): two OpenCode sessions sharing one bridge
31/// can both create checkpoints named `snap1` without collision, and restoring
32/// from one session does not leak the other's file set. Checkpoints are kept
33/// in memory only — a bridge crash drops all of them, which is a deliberate
34/// trade-off to keep this refactor bounded. Durable checkpoints are a possible
35/// follow-up.
36#[derive(Debug)]
37pub struct CheckpointStore {
38    /// session -> name -> checkpoint
39    checkpoints: HashMap<String, HashMap<String, Checkpoint>>,
40}
41
42impl CheckpointStore {
43    pub fn new() -> Self {
44        CheckpointStore {
45            checkpoints: HashMap::new(),
46        }
47    }
48
49    /// Create a checkpoint by reading the given files, scoped to `session`.
50    ///
51    /// If `files` is empty, snapshots all tracked files for **that session**
52    /// from the BackupStore (other sessions' tracked files are not visible).
53    /// Overwrites any existing checkpoint with the same name in this session.
54    ///
55    /// Unreadable paths (e.g. deleted since their last edit) are skipped with
56    /// a warning instead of failing the whole checkpoint. The paths and their
57    /// errors are returned via `CheckpointInfo::skipped` so callers can
58    /// surface them. A checkpoint is only rejected outright when *every*
59    /// requested path fails — that case still returns a `FileNotFound`
60    /// error so callers can distinguish "partial success" from "nothing
61    /// snapshotted at all".
62    pub fn create(
63        &mut self,
64        session: &str,
65        name: &str,
66        files: Vec<PathBuf>,
67        backup_store: &BackupStore,
68    ) -> Result<CheckpointInfo, AftError> {
69        let explicit_request = !files.is_empty();
70        let file_list = if files.is_empty() {
71            backup_store.tracked_files(session)
72        } else {
73            files
74        };
75
76        let mut file_contents = HashMap::new();
77        let mut skipped: Vec<(PathBuf, String)> = Vec::new();
78        for path in &file_list {
79            match std::fs::read_to_string(path) {
80                Ok(content) => {
81                    file_contents.insert(path.clone(), content);
82                }
83                Err(e) => {
84                    log::warn!(
85                        "checkpoint {}: skipping unreadable file {}: {}",
86                        name,
87                        path.display(),
88                        e
89                    );
90                    skipped.push((path.clone(), e.to_string()));
91                }
92            }
93        }
94
95        // If the caller explicitly named a single file and it was unreadable,
96        // that's a real error — surface it rather than silently returning an
97        // empty checkpoint. For empty `files` (tracked-file fallback) with no
98        // readable files at all, the empty-file checkpoint is a legitimate
99        // "nothing to snapshot" outcome and we keep it.
100        if explicit_request && file_contents.is_empty() && !skipped.is_empty() {
101            let (path, err) = &skipped[0];
102            return Err(AftError::FileNotFound {
103                path: format!("{}: {}", path.display(), err),
104            });
105        }
106
107        let created_at = current_timestamp();
108        let file_count = file_contents.len();
109
110        let checkpoint = Checkpoint {
111            name: name.to_string(),
112            file_contents,
113            created_at,
114        };
115
116        self.checkpoints
117            .entry(session.to_string())
118            .or_default()
119            .insert(name.to_string(), checkpoint);
120
121        if skipped.is_empty() {
122            log::info!("checkpoint created: {} ({} files)", name, file_count);
123        } else {
124            log::info!(
125                "checkpoint created: {} ({} files, {} skipped)",
126                name,
127                file_count,
128                skipped.len()
129            );
130        }
131
132        Ok(CheckpointInfo {
133            name: name.to_string(),
134            file_count,
135            created_at,
136            skipped,
137        })
138    }
139
140    /// Restore a checkpoint by overwriting files with stored content.
141    pub fn restore(&self, session: &str, name: &str) -> Result<CheckpointInfo, AftError> {
142        let checkpoint = self.get(session, name)?;
143
144        for (path, content) in &checkpoint.file_contents {
145            std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
146                path: path.display().to_string(),
147            })?;
148        }
149
150        log::info!("checkpoint restored: {}", name);
151
152        Ok(CheckpointInfo {
153            name: checkpoint.name.clone(),
154            file_count: checkpoint.file_contents.len(),
155            created_at: checkpoint.created_at,
156            skipped: Vec::new(),
157        })
158    }
159
160    /// Restore a checkpoint using a caller-validated path list.
161    pub fn restore_validated(
162        &self,
163        session: &str,
164        name: &str,
165        validated_paths: &[PathBuf],
166    ) -> Result<CheckpointInfo, AftError> {
167        let checkpoint = self.get(session, name)?;
168
169        for path in validated_paths {
170            let content =
171                checkpoint
172                    .file_contents
173                    .get(path)
174                    .ok_or_else(|| AftError::FileNotFound {
175                        path: path.display().to_string(),
176                    })?;
177            std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
178                path: path.display().to_string(),
179            })?;
180        }
181
182        log::info!("checkpoint restored: {}", name);
183
184        Ok(CheckpointInfo {
185            name: checkpoint.name.clone(),
186            file_count: checkpoint.file_contents.len(),
187            created_at: checkpoint.created_at,
188            skipped: Vec::new(),
189        })
190    }
191
192    /// Return the file paths stored for a checkpoint.
193    pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
194        let checkpoint = self.get(session, name)?;
195        Ok(checkpoint.file_contents.keys().cloned().collect())
196    }
197
198    /// List all checkpoints for this session with metadata.
199    pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
200        self.checkpoints
201            .get(session)
202            .map(|s| {
203                s.values()
204                    .map(|cp| CheckpointInfo {
205                        name: cp.name.clone(),
206                        file_count: cp.file_contents.len(),
207                        created_at: cp.created_at,
208                        skipped: Vec::new(),
209                    })
210                    .collect()
211            })
212            .unwrap_or_default()
213    }
214
215    /// Total checkpoint count across all sessions (for `/aft-status`).
216    pub fn total_count(&self) -> usize {
217        self.checkpoints.values().map(|s| s.len()).sum()
218    }
219
220    /// Remove checkpoints older than `ttl_hours` across all sessions.
221    /// Empty session entries are pruned after cleanup.
222    pub fn cleanup(&mut self, ttl_hours: u32) {
223        let now = current_timestamp();
224        let ttl_secs = ttl_hours as u64 * 3600;
225        self.checkpoints.retain(|_, session_cps| {
226            session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
227            !session_cps.is_empty()
228        });
229    }
230
231    fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
232        self.checkpoints
233            .get(session)
234            .and_then(|s| s.get(name))
235            .ok_or_else(|| AftError::CheckpointNotFound {
236                name: name.to_string(),
237            })
238    }
239}
240
241fn current_timestamp() -> u64 {
242    std::time::SystemTime::now()
243        .duration_since(std::time::UNIX_EPOCH)
244        .unwrap_or_default()
245        .as_secs()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::protocol::DEFAULT_SESSION_ID;
252    use std::fs;
253
254    fn temp_file(name: &str, content: &str) -> PathBuf {
255        let dir = std::env::temp_dir().join("aft_checkpoint_tests");
256        fs::create_dir_all(&dir).unwrap();
257        let path = dir.join(name);
258        fs::write(&path, content).unwrap();
259        path
260    }
261
262    #[test]
263    fn create_and_restore_round_trip() {
264        let path1 = temp_file("cp_rt1.txt", "hello");
265        let path2 = temp_file("cp_rt2.txt", "world");
266
267        let backup_store = BackupStore::new();
268        let mut store = CheckpointStore::new();
269
270        let info = store
271            .create(
272                DEFAULT_SESSION_ID,
273                "snap1",
274                vec![path1.clone(), path2.clone()],
275                &backup_store,
276            )
277            .unwrap();
278        assert_eq!(info.name, "snap1");
279        assert_eq!(info.file_count, 2);
280
281        // Modify files
282        fs::write(&path1, "changed1").unwrap();
283        fs::write(&path2, "changed2").unwrap();
284
285        // Restore
286        let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
287        assert_eq!(info.file_count, 2);
288        assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
289        assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
290    }
291
292    #[test]
293    fn overwrite_existing_name() {
294        let path = temp_file("cp_overwrite.txt", "v1");
295        let backup_store = BackupStore::new();
296        let mut store = CheckpointStore::new();
297
298        store
299            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
300            .unwrap();
301        fs::write(&path, "v2").unwrap();
302        store
303            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
304            .unwrap();
305
306        // Restore should give v2 (the overwritten checkpoint)
307        fs::write(&path, "v3").unwrap();
308        store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
309        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
310    }
311
312    #[test]
313    fn list_returns_metadata_scoped_to_session() {
314        let path = temp_file("cp_list.txt", "data");
315        let backup_store = BackupStore::new();
316        let mut store = CheckpointStore::new();
317
318        store
319            .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
320            .unwrap();
321        store
322            .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
323            .unwrap();
324        store
325            .create("other_session", "c", vec![path.clone()], &backup_store)
326            .unwrap();
327
328        let default_list = store.list(DEFAULT_SESSION_ID);
329        assert_eq!(default_list.len(), 2);
330        let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
331        assert!(names.contains(&"a"));
332        assert!(names.contains(&"b"));
333
334        let other_list = store.list("other_session");
335        assert_eq!(other_list.len(), 1);
336        assert_eq!(other_list[0].name, "c");
337    }
338
339    #[test]
340    fn sessions_isolate_checkpoint_names() {
341        // Same checkpoint name in two sessions does not collide on restore.
342        let path_a = temp_file("cp_isolated_a.txt", "a-original");
343        let path_b = temp_file("cp_isolated_b.txt", "b-original");
344        let backup_store = BackupStore::new();
345        let mut store = CheckpointStore::new();
346
347        // Both sessions create a checkpoint with the same name but different files.
348        store
349            .create("session_a", "snap", vec![path_a.clone()], &backup_store)
350            .unwrap();
351        store
352            .create("session_b", "snap", vec![path_b.clone()], &backup_store)
353            .unwrap();
354
355        fs::write(&path_a, "a-modified").unwrap();
356        fs::write(&path_b, "b-modified").unwrap();
357
358        // Restoring session A's "snap" only touches path_a.
359        store.restore("session_a", "snap").unwrap();
360        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
361        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
362
363        // Restoring session B's "snap" only touches path_b.
364        fs::write(&path_a, "a-modified").unwrap();
365        store.restore("session_b", "snap").unwrap();
366        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
367        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
368    }
369
370    #[test]
371    fn cleanup_removes_expired_across_sessions() {
372        let path = temp_file("cp_cleanup.txt", "data");
373        let backup_store = BackupStore::new();
374        let mut store = CheckpointStore::new();
375
376        store
377            .create(
378                DEFAULT_SESSION_ID,
379                "recent",
380                vec![path.clone()],
381                &backup_store,
382            )
383            .unwrap();
384
385        // Manually insert an expired checkpoint in another session.
386        store
387            .checkpoints
388            .entry("other".to_string())
389            .or_default()
390            .insert(
391                "old".to_string(),
392                Checkpoint {
393                    name: "old".to_string(),
394                    file_contents: HashMap::new(),
395                    created_at: 1000, // far in the past
396                },
397            );
398
399        assert_eq!(store.total_count(), 2);
400        store.cleanup(24); // 24 hours
401        assert_eq!(store.total_count(), 1);
402        assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
403        assert!(store.list("other").is_empty());
404    }
405
406    #[test]
407    fn restore_nonexistent_returns_error() {
408        let store = CheckpointStore::new();
409        let result = store.restore(DEFAULT_SESSION_ID, "nope");
410        assert!(result.is_err());
411        match result.unwrap_err() {
412            AftError::CheckpointNotFound { name } => {
413                assert_eq!(name, "nope");
414            }
415            other => panic!("expected CheckpointNotFound, got: {:?}", other),
416        }
417    }
418
419    #[test]
420    fn restore_nonexistent_in_other_session_returns_error() {
421        // A "snap" that exists in session A must NOT be visible from session B.
422        let path = temp_file("cp_cross_session.txt", "data");
423        let backup_store = BackupStore::new();
424        let mut store = CheckpointStore::new();
425        store
426            .create("session_a", "only_a", vec![path], &backup_store)
427            .unwrap();
428        assert!(store.restore("session_b", "only_a").is_err());
429    }
430
431    #[test]
432    fn create_skips_missing_files_from_backup_tracked_set() {
433        // Simulate the reported issue #15-follow-up: an agent deletes a
434        // previously-edited file, then calls checkpoint with no explicit
435        // file list. Before the fix, the stale backup-tracked entry caused
436        // the whole checkpoint to fail on the missing path. Now the checkpoint
437        // succeeds with the readable file and reports the skipped one.
438        let readable = temp_file("cp_skip_readable.txt", "still_here");
439        let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
440
441        // Backup store canonicalizes keys, so the skipped path in the
442        // checkpoint result is the canonical form, not the raw temp path.
443        let deleted_canonical = fs::canonicalize(&deleted).unwrap();
444
445        let mut backup_store = BackupStore::new();
446        backup_store
447            .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
448            .unwrap();
449        backup_store
450            .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
451            .unwrap();
452
453        fs::remove_file(&deleted).unwrap();
454
455        let mut store = CheckpointStore::new();
456        let info = store
457            .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
458            .expect("checkpoint should succeed despite one missing file");
459        assert_eq!(info.file_count, 1);
460        assert_eq!(info.skipped.len(), 1);
461        assert_eq!(info.skipped[0].0, deleted_canonical);
462        assert!(!info.skipped[0].1.is_empty());
463    }
464
465    #[test]
466    fn create_with_explicit_single_missing_file_errors() {
467        // When the caller names a single file explicitly and it can't be read,
468        // fail loudly — an empty checkpoint isn't what the caller asked for.
469        let missing = std::env::temp_dir()
470            .join("aft_checkpoint_tests/cp_explicit_missing_does_not_exist.txt");
471        let _ = fs::remove_file(&missing);
472
473        let backup_store = BackupStore::new();
474        let mut store = CheckpointStore::new();
475        let result = store.create(
476            DEFAULT_SESSION_ID,
477            "explicit",
478            vec![missing.clone()],
479            &backup_store,
480        );
481
482        assert!(result.is_err());
483        match result.unwrap_err() {
484            AftError::FileNotFound { path } => {
485                assert!(path.contains(&missing.display().to_string()));
486            }
487            other => panic!("expected FileNotFound, got: {:?}", other),
488        }
489    }
490
491    #[test]
492    fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
493        // Explicit file list with one readable + one missing: keep the
494        // readable one in the checkpoint, report the missing one under
495        // `skipped` instead of failing outright.
496        let good = temp_file("cp_mixed_good.txt", "ok");
497        let missing = std::env::temp_dir().join("aft_checkpoint_tests/cp_mixed_missing.txt");
498        let _ = fs::remove_file(&missing);
499
500        let backup_store = BackupStore::new();
501        let mut store = CheckpointStore::new();
502        let info = store
503            .create(
504                DEFAULT_SESSION_ID,
505                "mixed",
506                vec![good.clone(), missing.clone()],
507                &backup_store,
508            )
509            .expect("mixed checkpoint should succeed when any file is readable");
510        assert_eq!(info.file_count, 1);
511        assert_eq!(info.skipped.len(), 1);
512        assert_eq!(info.skipped[0].0, missing);
513    }
514
515    #[test]
516    fn create_with_empty_files_uses_backup_tracked() {
517        let path = temp_file("cp_tracked.txt", "tracked_content");
518        let mut backup_store = BackupStore::new();
519        backup_store
520            .snapshot(DEFAULT_SESSION_ID, &path, "auto")
521            .unwrap();
522
523        let mut store = CheckpointStore::new();
524        let info = store
525            .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
526            .unwrap();
527        assert!(info.file_count >= 1);
528
529        // Modify and restore
530        fs::write(&path, "modified").unwrap();
531        store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
532        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
533    }
534}