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