Skip to main content

aft/
checkpoint.rs

1use std::collections::HashMap;
2use std::path::{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                    crate::slog_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            crate::slog_info!("checkpoint created: {} ({} files)", name, file_count);
123        } else {
124            crate::slog_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        let mut paths = checkpoint.file_contents.keys().cloned().collect::<Vec<_>>();
144        paths.sort();
145
146        restore_paths_atomically(checkpoint, &paths)?;
147
148        crate::slog_info!("checkpoint restored: {}", name);
149
150        Ok(CheckpointInfo {
151            name: checkpoint.name.clone(),
152            file_count: checkpoint.file_contents.len(),
153            created_at: checkpoint.created_at,
154            skipped: Vec::new(),
155        })
156    }
157
158    /// Restore a checkpoint using a caller-validated path list.
159    pub fn restore_validated(
160        &self,
161        session: &str,
162        name: &str,
163        validated_paths: &[PathBuf],
164    ) -> Result<CheckpointInfo, AftError> {
165        let checkpoint = self.get(session, name)?;
166
167        for path in validated_paths {
168            checkpoint
169                .file_contents
170                .get(path)
171                .ok_or_else(|| AftError::FileNotFound {
172                    path: path.display().to_string(),
173                })?;
174        }
175        restore_paths_atomically(checkpoint, validated_paths)?;
176
177        crate::slog_info!("checkpoint restored: {}", name);
178
179        Ok(CheckpointInfo {
180            name: checkpoint.name.clone(),
181            file_count: checkpoint.file_contents.len(),
182            created_at: checkpoint.created_at,
183            skipped: Vec::new(),
184        })
185    }
186
187    /// Return the file paths stored for a checkpoint.
188    pub fn file_paths(&self, session: &str, name: &str) -> Result<Vec<PathBuf>, AftError> {
189        let checkpoint = self.get(session, name)?;
190        Ok(checkpoint.file_contents.keys().cloned().collect())
191    }
192
193    /// Delete a checkpoint from a session. Returns true when a checkpoint was removed.
194    pub fn delete(&mut self, session: &str, name: &str) -> bool {
195        let Some(session_checkpoints) = self.checkpoints.get_mut(session) else {
196            return false;
197        };
198        let removed = session_checkpoints.remove(name).is_some();
199        if session_checkpoints.is_empty() {
200            self.checkpoints.remove(session);
201        }
202        removed
203    }
204
205    /// List all checkpoints for this session with metadata.
206    pub fn list(&self, session: &str) -> Vec<CheckpointInfo> {
207        self.checkpoints
208            .get(session)
209            .map(|s| {
210                s.values()
211                    .map(|cp| CheckpointInfo {
212                        name: cp.name.clone(),
213                        file_count: cp.file_contents.len(),
214                        created_at: cp.created_at,
215                        skipped: Vec::new(),
216                    })
217                    .collect()
218            })
219            .unwrap_or_default()
220    }
221
222    /// Total checkpoint count across all sessions (for `/aft-status`).
223    pub fn total_count(&self) -> usize {
224        self.checkpoints.values().map(|s| s.len()).sum()
225    }
226
227    /// Remove checkpoints older than `ttl_hours` across all sessions.
228    /// Empty session entries are pruned after cleanup.
229    pub fn cleanup(&mut self, ttl_hours: u32) {
230        let now = current_timestamp();
231        let ttl_secs = ttl_hours as u64 * 3600;
232        self.checkpoints.retain(|_, session_cps| {
233            session_cps.retain(|_, cp| now.saturating_sub(cp.created_at) < ttl_secs);
234            !session_cps.is_empty()
235        });
236    }
237
238    fn get(&self, session: &str, name: &str) -> Result<&Checkpoint, AftError> {
239        self.checkpoints
240            .get(session)
241            .and_then(|s| s.get(name))
242            .ok_or_else(|| AftError::CheckpointNotFound {
243                name: name.to_string(),
244            })
245    }
246}
247
248fn restore_paths_atomically(checkpoint: &Checkpoint, paths: &[PathBuf]) -> Result<(), AftError> {
249    let mut pre_restore_snapshot: HashMap<PathBuf, Option<String>> = HashMap::new();
250    for path in paths {
251        let current = if path.exists() {
252            Some(
253                std::fs::read_to_string(path).map_err(|_| AftError::FileNotFound {
254                    path: path.display().to_string(),
255                })?,
256            )
257        } else {
258            None
259        };
260        pre_restore_snapshot.insert(path.clone(), current);
261    }
262
263    let mut restored_paths: Vec<PathBuf> = Vec::new();
264    for path in paths {
265        let content = checkpoint
266            .file_contents
267            .get(path)
268            .ok_or_else(|| AftError::FileNotFound {
269                path: path.display().to_string(),
270            })?;
271        if let Err(e) = write_restored_file(path, content) {
272            for restored_path in restored_paths.iter().rev() {
273                if let Some(snapshot) = pre_restore_snapshot.get(restored_path) {
274                    let _ = restore_snapshot_file(restored_path, snapshot.as_deref());
275                }
276            }
277            return Err(e);
278        }
279        restored_paths.push(path.clone());
280    }
281
282    Ok(())
283}
284
285fn restore_snapshot_file(path: &Path, content: Option<&str>) -> Result<(), AftError> {
286    match content {
287        Some(content) => write_restored_file(path, content),
288        None => match std::fs::remove_file(path) {
289            Ok(()) => Ok(()),
290            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
291            Err(_) => Err(AftError::FileNotFound {
292                path: path.display().to_string(),
293            }),
294        },
295    }
296}
297
298fn write_restored_file(path: &Path, content: &str) -> Result<(), AftError> {
299    if let Some(parent) = path.parent() {
300        std::fs::create_dir_all(parent).map_err(|_| AftError::FileNotFound {
301            path: path.display().to_string(),
302        })?;
303    }
304    std::fs::write(path, content).map_err(|_| AftError::FileNotFound {
305        path: path.display().to_string(),
306    })
307}
308
309fn current_timestamp() -> u64 {
310    std::time::SystemTime::now()
311        .duration_since(std::time::UNIX_EPOCH)
312        .unwrap_or_default()
313        .as_secs()
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::protocol::DEFAULT_SESSION_ID;
320    use std::fs;
321
322    fn temp_file(name: &str, content: &str) -> PathBuf {
323        let dir = std::env::temp_dir().join("aft_checkpoint_tests");
324        fs::create_dir_all(&dir).unwrap();
325        let path = dir.join(name);
326        fs::write(&path, content).unwrap();
327        path
328    }
329
330    #[test]
331    fn create_and_restore_round_trip() {
332        let path1 = temp_file("cp_rt1.txt", "hello");
333        let path2 = temp_file("cp_rt2.txt", "world");
334
335        let backup_store = BackupStore::new();
336        let mut store = CheckpointStore::new();
337
338        let info = store
339            .create(
340                DEFAULT_SESSION_ID,
341                "snap1",
342                vec![path1.clone(), path2.clone()],
343                &backup_store,
344            )
345            .unwrap();
346        assert_eq!(info.name, "snap1");
347        assert_eq!(info.file_count, 2);
348
349        // Modify files
350        fs::write(&path1, "changed1").unwrap();
351        fs::write(&path2, "changed2").unwrap();
352
353        // Restore
354        let info = store.restore(DEFAULT_SESSION_ID, "snap1").unwrap();
355        assert_eq!(info.file_count, 2);
356        assert_eq!(fs::read_to_string(&path1).unwrap(), "hello");
357        assert_eq!(fs::read_to_string(&path2).unwrap(), "world");
358    }
359
360    #[test]
361    fn overwrite_existing_name() {
362        let path = temp_file("cp_overwrite.txt", "v1");
363        let backup_store = BackupStore::new();
364        let mut store = CheckpointStore::new();
365
366        store
367            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
368            .unwrap();
369        fs::write(&path, "v2").unwrap();
370        store
371            .create(DEFAULT_SESSION_ID, "dup", vec![path.clone()], &backup_store)
372            .unwrap();
373
374        // Restore should give v2 (the overwritten checkpoint)
375        fs::write(&path, "v3").unwrap();
376        store.restore(DEFAULT_SESSION_ID, "dup").unwrap();
377        assert_eq!(fs::read_to_string(&path).unwrap(), "v2");
378    }
379
380    #[test]
381    fn list_returns_metadata_scoped_to_session() {
382        let path = temp_file("cp_list.txt", "data");
383        let backup_store = BackupStore::new();
384        let mut store = CheckpointStore::new();
385
386        store
387            .create(DEFAULT_SESSION_ID, "a", vec![path.clone()], &backup_store)
388            .unwrap();
389        store
390            .create(DEFAULT_SESSION_ID, "b", vec![path.clone()], &backup_store)
391            .unwrap();
392        store
393            .create("other_session", "c", vec![path.clone()], &backup_store)
394            .unwrap();
395
396        let default_list = store.list(DEFAULT_SESSION_ID);
397        assert_eq!(default_list.len(), 2);
398        let names: Vec<&str> = default_list.iter().map(|i| i.name.as_str()).collect();
399        assert!(names.contains(&"a"));
400        assert!(names.contains(&"b"));
401
402        let other_list = store.list("other_session");
403        assert_eq!(other_list.len(), 1);
404        assert_eq!(other_list[0].name, "c");
405    }
406
407    #[test]
408    fn sessions_isolate_checkpoint_names() {
409        // Same checkpoint name in two sessions does not collide on restore.
410        let path_a = temp_file("cp_isolated_a.txt", "a-original");
411        let path_b = temp_file("cp_isolated_b.txt", "b-original");
412        let backup_store = BackupStore::new();
413        let mut store = CheckpointStore::new();
414
415        // Both sessions create a checkpoint with the same name but different files.
416        store
417            .create("session_a", "snap", vec![path_a.clone()], &backup_store)
418            .unwrap();
419        store
420            .create("session_b", "snap", vec![path_b.clone()], &backup_store)
421            .unwrap();
422
423        fs::write(&path_a, "a-modified").unwrap();
424        fs::write(&path_b, "b-modified").unwrap();
425
426        // Restoring session A's "snap" only touches path_a.
427        store.restore("session_a", "snap").unwrap();
428        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-original");
429        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-modified");
430
431        // Restoring session B's "snap" only touches path_b.
432        fs::write(&path_a, "a-modified").unwrap();
433        store.restore("session_b", "snap").unwrap();
434        assert_eq!(fs::read_to_string(&path_a).unwrap(), "a-modified");
435        assert_eq!(fs::read_to_string(&path_b).unwrap(), "b-original");
436    }
437
438    #[test]
439    fn cleanup_removes_expired_across_sessions() {
440        let path = temp_file("cp_cleanup.txt", "data");
441        let backup_store = BackupStore::new();
442        let mut store = CheckpointStore::new();
443
444        store
445            .create(
446                DEFAULT_SESSION_ID,
447                "recent",
448                vec![path.clone()],
449                &backup_store,
450            )
451            .unwrap();
452
453        // Manually insert an expired checkpoint in another session.
454        store
455            .checkpoints
456            .entry("other".to_string())
457            .or_default()
458            .insert(
459                "old".to_string(),
460                Checkpoint {
461                    name: "old".to_string(),
462                    file_contents: HashMap::new(),
463                    created_at: 1000, // far in the past
464                },
465            );
466
467        assert_eq!(store.total_count(), 2);
468        store.cleanup(24); // 24 hours
469        assert_eq!(store.total_count(), 1);
470        assert_eq!(store.list(DEFAULT_SESSION_ID)[0].name, "recent");
471        assert!(store.list("other").is_empty());
472    }
473
474    #[test]
475    fn restore_nonexistent_returns_error() {
476        let store = CheckpointStore::new();
477        let result = store.restore(DEFAULT_SESSION_ID, "nope");
478        assert!(result.is_err());
479        match result.unwrap_err() {
480            AftError::CheckpointNotFound { name } => {
481                assert_eq!(name, "nope");
482            }
483            other => panic!("expected CheckpointNotFound, got: {:?}", other),
484        }
485    }
486
487    #[test]
488    fn restore_nonexistent_in_other_session_returns_error() {
489        // A "snap" that exists in session A must NOT be visible from session B.
490        let path = temp_file("cp_cross_session.txt", "data");
491        let backup_store = BackupStore::new();
492        let mut store = CheckpointStore::new();
493        store
494            .create("session_a", "only_a", vec![path], &backup_store)
495            .unwrap();
496        assert!(store.restore("session_b", "only_a").is_err());
497    }
498
499    #[test]
500    fn create_skips_missing_files_from_backup_tracked_set() {
501        // Simulate the reported issue #15-follow-up: an agent deletes a
502        // previously-edited file, then calls checkpoint with no explicit
503        // file list. Before the fix, the stale backup-tracked entry caused
504        // the whole checkpoint to fail on the missing path. Now the checkpoint
505        // succeeds with the readable file and reports the skipped one.
506        let readable = temp_file("cp_skip_readable.txt", "still_here");
507        let deleted = temp_file("cp_skip_deleted.txt", "about_to_vanish");
508
509        // Backup store canonicalizes keys, so the skipped path in the
510        // checkpoint result is the canonical form, not the raw temp path.
511        let deleted_canonical = fs::canonicalize(&deleted).unwrap();
512
513        let mut backup_store = BackupStore::new();
514        backup_store
515            .snapshot(DEFAULT_SESSION_ID, &readable, "auto")
516            .unwrap();
517        backup_store
518            .snapshot(DEFAULT_SESSION_ID, &deleted, "auto")
519            .unwrap();
520
521        fs::remove_file(&deleted).unwrap();
522
523        let mut store = CheckpointStore::new();
524        let info = store
525            .create(DEFAULT_SESSION_ID, "partial", vec![], &backup_store)
526            .expect("checkpoint should succeed despite one missing file");
527        assert_eq!(info.file_count, 1);
528        assert_eq!(info.skipped.len(), 1);
529        assert_eq!(info.skipped[0].0, deleted_canonical);
530        assert!(!info.skipped[0].1.is_empty());
531    }
532
533    #[test]
534    fn create_with_explicit_single_missing_file_errors() {
535        // When the caller names a single file explicitly and it can't be read,
536        // fail loudly — an empty checkpoint isn't what the caller asked for.
537        let missing = std::env::temp_dir()
538            .join("aft_checkpoint_tests/cp_explicit_missing_does_not_exist.txt");
539        let _ = fs::remove_file(&missing);
540
541        let backup_store = BackupStore::new();
542        let mut store = CheckpointStore::new();
543        let result = store.create(
544            DEFAULT_SESSION_ID,
545            "explicit",
546            vec![missing.clone()],
547            &backup_store,
548        );
549
550        assert!(result.is_err());
551        match result.unwrap_err() {
552            AftError::FileNotFound { path } => {
553                assert!(path.contains(&missing.display().to_string()));
554            }
555            other => panic!("expected FileNotFound, got: {:?}", other),
556        }
557    }
558
559    #[test]
560    fn create_with_explicit_mixed_files_keeps_readable_and_reports_skipped() {
561        // Explicit file list with one readable + one missing: keep the
562        // readable one in the checkpoint, report the missing one under
563        // `skipped` instead of failing outright.
564        let good = temp_file("cp_mixed_good.txt", "ok");
565        let missing = std::env::temp_dir().join("aft_checkpoint_tests/cp_mixed_missing.txt");
566        let _ = fs::remove_file(&missing);
567
568        let backup_store = BackupStore::new();
569        let mut store = CheckpointStore::new();
570        let info = store
571            .create(
572                DEFAULT_SESSION_ID,
573                "mixed",
574                vec![good.clone(), missing.clone()],
575                &backup_store,
576            )
577            .expect("mixed checkpoint should succeed when any file is readable");
578        assert_eq!(info.file_count, 1);
579        assert_eq!(info.skipped.len(), 1);
580        assert_eq!(info.skipped[0].0, missing);
581    }
582
583    #[test]
584    fn create_with_empty_files_uses_backup_tracked() {
585        let path = temp_file("cp_tracked.txt", "tracked_content");
586        let mut backup_store = BackupStore::new();
587        backup_store
588            .snapshot(DEFAULT_SESSION_ID, &path, "auto")
589            .unwrap();
590
591        let mut store = CheckpointStore::new();
592        let info = store
593            .create(DEFAULT_SESSION_ID, "from_tracked", vec![], &backup_store)
594            .unwrap();
595        assert!(info.file_count >= 1);
596
597        // Modify and restore
598        fs::write(&path, "modified").unwrap();
599        store.restore(DEFAULT_SESSION_ID, "from_tracked").unwrap();
600        assert_eq!(fs::read_to_string(&path).unwrap(), "tracked_content");
601    }
602
603    #[test]
604    fn restore_recreates_missing_parent_directories() {
605        let dir = tempfile::tempdir().unwrap();
606        let path = dir.path().join("nested").join("deeper").join("file.txt");
607        fs::create_dir_all(path.parent().unwrap()).unwrap();
608        fs::write(&path, "original nested content").unwrap();
609
610        let backup_store = BackupStore::new();
611        let mut store = CheckpointStore::new();
612        store
613            .create(
614                DEFAULT_SESSION_ID,
615                "nested",
616                vec![path.clone()],
617                &backup_store,
618            )
619            .unwrap();
620
621        fs::remove_dir_all(dir.path().join("nested")).unwrap();
622
623        store.restore(DEFAULT_SESSION_ID, "nested").unwrap();
624        assert_eq!(
625            fs::read_to_string(&path).unwrap(),
626            "original nested content"
627        );
628    }
629
630    #[cfg(unix)]
631    #[test]
632    fn checkpoint_restore_rolls_back_on_partial_failure() {
633        use std::os::unix::fs::PermissionsExt;
634
635        let dir = tempfile::tempdir().unwrap();
636        let path_a = dir.path().join("a.txt");
637        let path_b = dir.path().join("b.txt");
638        fs::write(&path_a, "checkpoint-a").unwrap();
639        fs::write(&path_b, "checkpoint-b").unwrap();
640
641        let backup_store = BackupStore::new();
642        let mut store = CheckpointStore::new();
643        store
644            .create(
645                DEFAULT_SESSION_ID,
646                "partial_failure",
647                vec![path_a.clone(), path_b.clone()],
648                &backup_store,
649            )
650            .unwrap();
651
652        fs::write(&path_a, "pre-restore-a").unwrap();
653        fs::write(&path_b, "pre-restore-b").unwrap();
654        let mut readonly = fs::metadata(&path_b).unwrap().permissions();
655        readonly.set_mode(0o444);
656        fs::set_permissions(&path_b, readonly).unwrap();
657
658        let result = store.restore(DEFAULT_SESSION_ID, "partial_failure");
659        let mut writable = fs::metadata(&path_b).unwrap().permissions();
660        writable.set_mode(0o644);
661        fs::set_permissions(&path_b, writable).unwrap();
662
663        assert!(result.is_err(), "restore should surface write failure");
664        assert_eq!(fs::read_to_string(&path_a).unwrap(), "pre-restore-a");
665        assert_eq!(fs::read_to_string(&path_b).unwrap(), "pre-restore-b");
666    }
667}