Skip to main content

supercode_harness/
checkpoint.rs

1//! §2 module 20 `checkpoint` (COMPOSABLE-HARNESS-DESIGN.md line 470): file
2//! checkpointing / shadow-git; D4-adjacent revert; D3 turn-diff tracking.
3//! Line 1504: "restores FILES, not context — pairs with, never replaces,
4//! the reduction sidecar."
5//!
6//! **Not to be confused with** [`crate::Agent::checkpoint`]/[`crate::Agent::rewind_to`]
7//! (an in-memory CONVERSATION-position marker, an unrelated pre-existing
8//! facility) or [`supercode_interchange::session_tree`] (module 21, tree-addressable
9//! sessions). This module only ever touches files on disk.
10//!
11//! # The write-path interception seam (D-5, shared with `formatters`/P5-11)
12//! [`crate::tools::WriteObserver`] (defined in `crate::tools`, the seam's
13//! natural owner since [`crate::tools::ToolContext`] carries it) is called
14//! by `write_file`/`edit_file`/`apply_patch` around their mutation.
15//! [`CheckpointObserver`] is this module's implementation: `before_write`
16//! captures a pre-image; `after_write` is a no-op (reserved for
17//! `formatters`).
18//!
19//! # Storage — never the user's real git
20//! [`CheckpointStore`] is a content-addressed, per-project shadow store at
21//! a caller-supplied `root` (the same dependency-injection shape as
22//! [`crate::store::SessionStore::open`]) — blobs keyed by
23//! [`crate::reduce::content_hash`] under `<root>/objects/`, one JSON
24//! manifest per checkpoint under `<root>/checkpoints/<id>.json`. This
25//! module never shells out to `git` and never touches the project's own
26//! `.git` — there is no `GIT_DIR` to get wrong because none is ever used.
27//!
28//! # C8 (design line 1819) compliance
29//! A [`CheckpointId`] is an opaque `String` — the ONLY thing a caller
30//! (session record, CLI) ever threads around. It is never the file
31//! contents, and it never reaches the model's context window: restoring
32//! files is a disk operation, never a conversation-history mutation.
33
34use std::collections::HashSet;
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Mutex;
38
39use serde::{Deserialize, Serialize};
40
41use crate::error::{Error, Result};
42use crate::tools::WriteObserver;
43
44/// Default number of checkpoints retained per project before the oldest are
45/// pruned (bounded-disk requirement) — overridable via
46/// `[capabilities.checkpoint].retain`.
47pub const DEFAULT_RETAIN: usize = 50;
48
49/// An opaque, lexically-sortable (ascending = chronological, since it's
50/// zero-padded-millis-prefixed) checkpoint identifier. This is ALL a
51/// session record ever carries for C8 — never file contents.
52pub type CheckpointId = String;
53
54fn now_ms() -> u128 {
55    std::time::SystemTime::now()
56        .duration_since(std::time::UNIX_EPOCH)
57        .map(|d| d.as_millis())
58        .unwrap_or(0)
59}
60
61/// Mint a fresh id: zero-padded millis + an 8-hex-digit salt derived from
62/// (pid, millis, a monotonic instant) — collision-free in practice without
63/// pulling in a UUID/random dependency (matches this crate's existing
64/// `shell_sentinel` precedent in `tools/builtins.rs`).
65fn mint_id() -> CheckpointId {
66    use std::hash::BuildHasher;
67    let millis = now_ms();
68    let salt = std::collections::hash_map::RandomState::new().hash_one((
69        std::process::id(),
70        millis,
71        std::time::Instant::now(),
72    ));
73    format!("{millis:020}-{:08x}", salt as u32)
74}
75
76/// One file's entry in a [`CheckpointManifest`] — the turn-diff (D3) is
77/// simply this manifest's `files` list.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct CheckpointFileEntry {
80    /// Path relative to the project root, `/`-separated, never absolute and
81    /// never containing a `..` component in what [`CheckpointObserver`]
82    /// itself writes (restore independently re-validates this — see
83    /// [`CheckpointStore::restore`] — rather than trusting it).
84    pub path: String,
85    /// blake3 hex digest ([`crate::reduce::content_hash`]) of the pre-image
86    /// content, or `None` if the file did not exist before this
87    /// checkpoint's turn began (i.e. the write that follows is a create —
88    /// restoring deletes it again).
89    pub blob: Option<String>,
90}
91
92/// A checkpoint's full manifest — one JSON file per checkpoint.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CheckpointManifest {
95    /// This checkpoint's id.
96    pub id: CheckpointId,
97    /// Unix-epoch milliseconds when this checkpoint was minted.
98    pub created_at_ms: u128,
99    /// A short, human-readable label (e.g. the turn's prompt excerpt) —
100    /// display only, never re-parsed.
101    pub label: String,
102    /// Every file this turn captured a pre-image for — the D3 turn-diff.
103    pub files: Vec<CheckpointFileEntry>,
104}
105
106/// Lightweight listing entry ([`CheckpointStore::list`]) — the manifest
107/// without the (potentially long) file list.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CheckpointMeta {
110    /// This checkpoint's id.
111    pub id: CheckpointId,
112    /// Unix-epoch milliseconds when this checkpoint was minted.
113    pub created_at_ms: u128,
114    /// A short, human-readable label — display only.
115    pub label: String,
116    /// How many files this checkpoint's manifest lists.
117    pub file_count: usize,
118}
119
120/// The outcome of a [`CheckpointStore::restore`] call.
121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122pub struct RestoreReport {
123    /// Files successfully restored (project-relative paths).
124    pub restored: Vec<String>,
125    /// Files refused, with the reason — e.g. an out-of-root or
126    /// protected-path target. Non-empty means the restore was PARTIAL; a
127    /// caller must surface this, never silently swallow it.
128    pub refused: Vec<(String, String)>,
129}
130
131/// A directory-backed, content-addressed shadow store rooted at `root` —
132/// see the module doc comment. Never touches anything outside `root` except
133/// (during [`Self::restore`]) the project files a manifest names, which are
134/// re-validated against `project_root` independently of how the manifest
135/// was produced.
136#[derive(Debug)]
137pub struct CheckpointStore {
138    root: PathBuf,
139}
140
141impl CheckpointStore {
142    /// Open (creating if needed) a store at `root`. `Err` if `root` can't
143    /// be created (e.g. an unwritable state dir) — callers (see
144    /// [`observer_for_config`]) treat that as "disable checkpoint, warn
145    /// once", never a crash.
146    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
147        let root = root.into();
148        std::fs::create_dir_all(root.join("objects"))?;
149        std::fs::create_dir_all(root.join("checkpoints"))?;
150        Ok(CheckpointStore { root })
151    }
152
153    fn objects_dir(&self) -> PathBuf {
154        self.root.join("objects")
155    }
156    fn checkpoints_dir(&self) -> PathBuf {
157        self.root.join("checkpoints")
158    }
159    fn manifest_path(&self, id: &str) -> PathBuf {
160        self.checkpoints_dir().join(format!("{id}.json"))
161    }
162    fn blob_path(&self, hash: &str) -> PathBuf {
163        let prefix = &hash[..hash.len().min(2)];
164        self.objects_dir().join(prefix).join(hash)
165    }
166
167    /// Reject an `id` that could escape the store root when joined into a
168    /// path — the exact same defensive shape as
169    /// `crate::store::SessionStore::validate_name`, since a `checkpoint
170    /// restore <id>`/`checkpoint diff <id>` CLI argument is untrusted user
171    /// input by the time it reaches here.
172    fn validate_id(id: &str) -> Result<()> {
173        let bad = id.is_empty()
174            || id.contains('/')
175            || id.contains('\\')
176            || id.contains('\0')
177            || id.split(['/', '\\']).any(|c| c == ".." || c == ".")
178            || Path::new(id).is_absolute()
179            || id.trim() != id;
180        if bad {
181            return Err(Error::Other(format!("invalid checkpoint id: `{id}`")));
182        }
183        Ok(())
184    }
185
186    /// Content-address `content`, writing it only if not already present
187    /// (dedup — the same content written by two different checkpoints costs
188    /// one blob). Atomic (write-to-tmp, rename) so a concurrent reader
189    /// never observes a partial blob; a lost race against another writer
190    /// racing the SAME hash is harmless (identical content either way).
191    fn write_blob(&self, content: &[u8]) -> Result<String> {
192        let hash = crate::reduce::content_hash(content);
193        let dest = self.blob_path(&hash);
194        if dest.exists() {
195            return Ok(hash);
196        }
197        let Some(parent) = dest.parent() else {
198            return Err(Error::Other("blob path has no parent".to_string()));
199        };
200        std::fs::create_dir_all(parent)?;
201        let tmp = parent.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
202        std::fs::write(&tmp, content)?;
203        match std::fs::rename(&tmp, &dest) {
204            Ok(()) => {}
205            Err(e) if dest.exists() => {
206                // Another writer won the race with identical content.
207                let _ = std::fs::remove_file(&tmp);
208                let _ = e;
209            }
210            Err(e) => return Err(e.into()),
211        }
212        Ok(hash)
213    }
214
215    fn read_blob(&self, hash: &str) -> Result<Vec<u8>> {
216        std::fs::read(self.blob_path(hash)).map_err(Into::into)
217    }
218
219    fn write_manifest(&self, m: &CheckpointManifest) -> Result<()> {
220        Self::validate_id(&m.id)?;
221        let dest = self.manifest_path(&m.id);
222        let json = serde_json::to_vec_pretty(m).map_err(|e| Error::Other(e.to_string()))?;
223        let dir = self.checkpoints_dir();
224        std::fs::create_dir_all(&dir)?;
225        let tmp = dir.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
226        std::fs::write(&tmp, &json)?;
227        std::fs::rename(&tmp, &dest)?;
228        Ok(())
229    }
230
231    /// Mint a fresh checkpoint (a new turn) with an empty file list. `label`
232    /// is display-only.
233    pub fn create_checkpoint(&self, label: &str) -> Result<CheckpointId> {
234        let id = mint_id();
235        let manifest = CheckpointManifest {
236            id: id.clone(),
237            created_at_ms: now_ms(),
238            label: label.to_string(),
239            files: Vec::new(),
240        };
241        self.write_manifest(&manifest)?;
242        Ok(id)
243    }
244
245    /// Idempotently record `rel`'s pre-image under checkpoint `id` — a
246    /// SECOND call for the same `(id, rel)` pair is a no-op (the manifest
247    /// always keeps the EARLIEST pre-image seen this turn, which is the one
248    /// a revert needs). `content: None` means the file did not exist yet.
249    pub fn record_pre_image(&self, id: &str, rel: &str, content: Option<Vec<u8>>) -> Result<()> {
250        let mut manifest = self.manifest(id)?;
251        if manifest.files.iter().any(|f| f.path == rel) {
252            return Ok(());
253        }
254        let blob = match content {
255            Some(bytes) => Some(self.write_blob(&bytes)?),
256            None => None,
257        };
258        manifest.files.push(CheckpointFileEntry {
259            path: rel.to_string(),
260            blob,
261        });
262        self.write_manifest(&manifest)
263    }
264
265    /// Read one checkpoint's full manifest.
266    pub fn manifest(&self, id: &str) -> Result<CheckpointManifest> {
267        Self::validate_id(id)?;
268        let text = std::fs::read_to_string(self.manifest_path(id))
269            .map_err(|e| Error::Other(format!("checkpoint `{id}` not found: {e}")))?;
270        serde_json::from_str(&text)
271            .map_err(|e| Error::Other(format!("checkpoint `{id}` manifest is corrupt: {e}")))
272    }
273
274    /// Every checkpoint in the store, newest first (ids are millis-prefixed
275    /// so lexical descending order IS chronological descending order). A
276    /// corrupt individual manifest is skipped (resilience — one bad file
277    /// never hides every other checkpoint), not a hard error.
278    pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
279        let dir = self.checkpoints_dir();
280        let mut metas = Vec::new();
281        if !dir.exists() {
282            return Ok(metas);
283        }
284        for entry in std::fs::read_dir(&dir)? {
285            let entry = entry?;
286            let path = entry.path();
287            if path.extension().and_then(|e| e.to_str()) != Some("json") {
288                continue;
289            }
290            let Ok(text) = std::fs::read_to_string(&path) else {
291                continue;
292            };
293            if let Ok(m) = serde_json::from_str::<CheckpointManifest>(&text) {
294                metas.push(CheckpointMeta {
295                    id: m.id,
296                    created_at_ms: m.created_at_ms,
297                    label: m.label,
298                    file_count: m.files.len(),
299                });
300            }
301        }
302        metas.sort_by(|a, b| b.id.cmp(&a.id));
303        Ok(metas)
304    }
305
306    /// D3 turn-diff: the set of project-relative paths this checkpoint's
307    /// turn touched — exactly the manifest's file list (every entry exists
308    /// BECAUSE a write-tool call captured a pre-image for it this turn).
309    pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
310        Ok(self
311            .manifest(id)?
312            .files
313            .into_iter()
314            .map(|f| f.path)
315            .collect())
316    }
317
318    /// BP-7 (catalog §4a "Turn diff tracking": "cumulative file-diff of
319    /// the turn"): the turn's cumulative UNIFIED DIFF, not just the file
320    /// list [`Self::turn_diff`] returns.
321    ///
322    /// Each manifest entry is a pre-image (`None` = the file did not exist
323    /// before the turn); the post-image is whatever is on disk under
324    /// `project_root` NOW (missing = the turn deleted it). So this is the
325    /// net effect of the whole turn — a file written three times in one
326    /// turn shows one diff from where it started to where it ended, which
327    /// is exactly what "cumulative" means and what cc's `/diff` and cx's
328    /// `turn_diff_tracker` show.
329    ///
330    /// Non-UTF-8 content on either side is reported as
331    /// `Binary files a/<path> and b/<path> differ` rather than being
332    /// diffed byte-wise into garbage; a file whose content is unchanged
333    /// contributes nothing. Entries are emitted in sorted path order so
334    /// the output is stable across runs.
335    pub fn turn_patch(&self, id: &str, project_root: &Path) -> Result<String> {
336        let mut entries = self.manifest(id)?.files;
337        entries.sort_by(|a, b| a.path.cmp(&b.path));
338        let mut out = String::new();
339        for entry in entries {
340            let before: Option<Vec<u8>> = match &entry.blob {
341                Some(blob) => Some(self.read_blob(blob)?),
342                None => None,
343            };
344            let after = std::fs::read(project_root.join(&entry.path)).ok();
345            if before == after {
346                continue;
347            }
348            let (Some(before_text), Some(after_text)) = (
349                decode_side(before.as_deref()),
350                decode_side(after.as_deref()),
351            ) else {
352                out.push_str(&format!(
353                    "Binary files a/{0} and b/{0} differ
354",
355                    entry.path
356                ));
357                continue;
358            };
359            let patch = diffy::create_patch(&before_text, &after_text);
360            out.push_str(&format!(
361                "--- a/{0}
362+++ b/{0}
363",
364                entry.path
365            ));
366            // `diffy` emits its own `---`/`+++` header lines with no file
367            // names; the project-relative pair above replaces them, so the
368            // output reads like an ordinary `git diff`.
369            for line in patch.to_string().lines().skip(2) {
370                out.push_str(line);
371                out.push('\n');
372            }
373        }
374        Ok(out)
375    }
376
377    /// D4-adjacent revert: restore `project_root`'s working files to
378    /// checkpoint `id`. For each manifest entry: `Some(blob)` rewrites the
379    /// file to that pre-image; `None` (didn't exist before the turn)
380    /// deletes it if present now (undoing a create). SECURITY: every
381    /// target is independently re-validated (never trusts the manifest was
382    /// produced honestly) against `project_root` containment (no symlink
383    /// escape, no `..` traversal) AND `protected_globs` (plus an
384    /// unconditional `.git/**` floor) checked against BOTH the LEXICAL
385    /// normalized path and the symlink-RESOLVED path — a lexical-only
386    /// check would miss a manifest entry like `foo/config` where `foo` is
387    /// a pre-existing symlink into `.git`: lexically it's clean, but it
388    /// resolves inside `root` (so containment alone accepts it too) and
389    /// lands on the real `.git/config`. A refused entry is recorded in
390    /// [`RestoreReport::refused`], never silently applied AND never aborts
391    /// the rest of the restore (partial-success, fully reported).
392    pub fn restore(
393        &self,
394        id: &str,
395        project_root: &Path,
396        protected_globs: &[String],
397    ) -> Result<RestoreReport> {
398        let manifest = self.manifest(id)?;
399        let mut report = RestoreReport::default();
400        for entry in &manifest.files {
401            // (1) Up-front rejection: a legitimately-captured entry (see
402            // `CheckpointFileEntry::path`'s doc comment) is always a clean
403            // relative path — `record_pre_image` never produces an absolute
404            // path or a `..` component. An entry that has one is by
405            // definition hostile or corrupt (traversal-injected, or a
406            // corrupted/copied/shared manifest — exactly this module's
407            // stated threat model) and must never reach a raw-string
408            // pattern match at all.
409            if let Some(reason) = reject_unsafe_manifest_path(&entry.path) {
410                report.refused.push((entry.path.clone(), reason));
411                continue;
412            }
413            let target = project_root.join(&entry.path);
414            // (2) Normalize-then-protect: compute the SAME lexically-
415            // normalized project-relative path `contained()` uses
416            // internally, ONCE, and check `is_protected` against THAT
417            // (not the raw manifest string) — so a `.git/**`-floor or
418            // `protected_globs` bypass via `x/../.git/config` can no
419            // longer disagree between the two checks (the root cause of
420            // the bug this replaces: `is_protected` matched the raw
421            // string while `contained` matched the normalized path).
422            let Some(normalized_rel) = normalized_project_rel(project_root, &target) else {
423                report.refused.push((
424                    entry.path.clone(),
425                    "refused: escapes the project root".to_string(),
426                ));
427                continue;
428            };
429            if is_protected(&normalized_rel, protected_globs) {
430                report
431                    .refused
432                    .push((entry.path.clone(), "refused: protected path".to_string()));
433                continue;
434            }
435            // Belt-and-suspenders: the existing symlink-safe containment
436            // check (canonicalizes the longest existing ancestor, refusing
437            // any symlink escape) still runs unconditionally.
438            if !contained(project_root, &target) {
439                report.refused.push((
440                    entry.path.clone(),
441                    "refused: escapes the project root".to_string(),
442                ));
443                continue;
444            }
445            // (3) Resolved-then-protect: `normalized_rel` above is a purely
446            // LEXICAL collapse — it never resolves symlinks — while
447            // `contained` (just above) DOES resolve symlinks when it
448            // canonicalizes the longest existing ancestor. Those two views
449            // of the path can disagree exactly when a component of `target`
450            // is a symlink: e.g. a pre-existing `foo -> .git` inside
451            // `project_root` plus a manifest entry `foo/config` lexically
452            // normalizes to `foo/config` (not protected — no literal
453            // `.git/` prefix) yet resolves to `<root>/.git/config` (still
454            // "contained" under `project_root`, so the escape check above
455            // doesn't catch it either — it never leaves the root, it just
456            // lands somewhere the lexical path didn't say). Re-run
457            // `is_protected` against the RESOLVED, symlink-followed
458            // project-relative path too, so this can't slip through: the
459            // `.git`/`protected_globs` floor now sees BOTH the lexical and
460            // the resolved view, and refuses if EITHER is protected.
461            if let Some(resolved_rel) = resolved_project_rel(project_root, &target) {
462                if is_protected(&resolved_rel, protected_globs) {
463                    report
464                        .refused
465                        .push((entry.path.clone(), "refused: protected path".to_string()));
466                    continue;
467                }
468            }
469            match &entry.blob {
470                Some(hash) => {
471                    let bytes = match self.read_blob(hash) {
472                        Ok(b) => b,
473                        Err(e) => {
474                            report
475                                .refused
476                                .push((entry.path.clone(), format!("blob unreadable: {e}")));
477                            continue;
478                        }
479                    };
480                    if let Some(parent) = target.parent() {
481                        let _ = std::fs::create_dir_all(parent);
482                    }
483                    if let Err(e) = std::fs::write(&target, &bytes) {
484                        report
485                            .refused
486                            .push((entry.path.clone(), format!("write failed: {e}")));
487                        continue;
488                    }
489                }
490                None if target.exists() => {
491                    if let Err(e) = std::fs::remove_file(&target) {
492                        report
493                            .refused
494                            .push((entry.path.clone(), format!("delete failed: {e}")));
495                        continue;
496                    }
497                }
498                None => {}
499            }
500            report.restored.push(entry.path.clone());
501        }
502        Ok(report)
503    }
504
505    /// Bounded-disk requirement: keep only the `keep` newest checkpoints,
506    /// deleting the rest, then GC any blob no longer referenced by a
507    /// surviving manifest. Returns the number of checkpoints removed.
508    pub fn prune(&self, keep: usize) -> Result<usize> {
509        let mut metas = self.list()?; // newest first
510        if metas.len() <= keep {
511            return Ok(0);
512        }
513        let stale = metas.split_off(keep);
514        let removed = stale.len();
515        for m in stale {
516            let _ = std::fs::remove_file(self.manifest_path(&m.id));
517        }
518        self.gc_unreferenced_blobs()?;
519        Ok(removed)
520    }
521
522    /// Delete every blob under `objects/` not referenced by ANY surviving
523    /// manifest — a full scan, not a refcount (simplest correct form; the
524    /// retention bound keeps this cheap in practice — see [`Self::prune`]'s
525    /// doc comment).
526    fn gc_unreferenced_blobs(&self) -> Result<()> {
527        let mut referenced: HashSet<String> = HashSet::new();
528        for meta in self.list()? {
529            if let Ok(m) = self.manifest(&meta.id) {
530                for f in m.files {
531                    if let Some(b) = f.blob {
532                        referenced.insert(b);
533                    }
534                }
535            }
536        }
537        let objects = self.objects_dir();
538        if !objects.exists() {
539            return Ok(());
540        }
541        for entry in std::fs::read_dir(&objects)? {
542            let entry = entry?;
543            if !entry.file_type()?.is_dir() {
544                continue;
545            }
546            for inner in std::fs::read_dir(entry.path())? {
547                let inner = inner?;
548                let name = inner.file_name();
549                let Some(name) = name.to_str() else {
550                    continue;
551                };
552                if name.starts_with(".tmp-") {
553                    continue; // an in-flight write, not ours to reap
554                }
555                if !referenced.contains(name) {
556                    let _ = std::fs::remove_file(inner.path());
557                }
558            }
559        }
560        Ok(())
561    }
562}
563
564// SECURITY (safe-path consolidation, CRITICAL fix): every primitive below
565// used to be defined HERE, locally — this module's own P5-9 fix for the
566// `.git`-clobber bug class. It is now `crate::safe_path`'s canonical
567// implementation instead, with checkpoint DELEGATING to it (thin wrappers,
568// same names, same signatures, same behavior) so `crate::permissions`'s
569// gate and `crate::tools`'s sandbox containment reuse this exact proven
570// logic rather than each re-implementing (and, in the permissions gate's
571// case, getting wrong) their own. See `crate::safe_path`'s module doc
572// comment for the full story and the two-views-of-a-path explanation every
573// doc comment below used to carry inline.
574//
575// Every one of this module's 23 tests (see `tests` below) still exercises
576// these names directly and is UNCHANGED — that is the proof this extraction
577// is behavior-preserving.
578
579/// Is `rel` (a project-relative, `/`-separated path) a hard floor this
580/// module refuses to snapshot INTO or restore OVER, regardless of config?
581/// `.git` (and everything under it) is unconditional — checkpoint must
582/// never touch the user's real git repo even if `permissions.protected_paths`
583/// (module 13) is off. `extra_globs` layers `Config::permissions_protected_paths`
584/// on top when the caller has one (restore only — capture-time protection
585/// is already covered because [`CheckpointObserver::before_write`] only
586/// ever fires for a write the P5-1 permission gate already approved, which
587/// already folds `protected_paths` in — see that method's own doc comment).
588/// See `crate::safe_path::is_protected` for the implementation.
589fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
590    crate::safe_path::is_protected(rel, extra_globs)
591}
592
593/// Up-front rejection for a manifest-declared path that could never have
594/// come from a legitimate capture: `record_pre_image` only ever stores a
595/// clean, `/`-separated, project-relative path (see
596/// [`CheckpointFileEntry::path`]'s doc comment), so an absolute path or one
597/// containing a `..` (`ParentDir`), root, or Windows-prefix component is by
598/// definition hostile or corrupt. Returns the refusal reason, or `None` if
599/// `rel` is clean. Called BEFORE any raw-string pattern match (e.g.
600/// [`is_protected`]) so a traversal entry like `x/../.git/config` — which
601/// does not literally string-match the `.git/` floor — is refused before
602/// it can ever be compared against anything. See
603/// `crate::safe_path::reject_unsafe_rel_path` for the implementation.
604fn reject_unsafe_manifest_path(rel: &str) -> Option<String> {
605    crate::safe_path::reject_unsafe_rel_path(rel)
606}
607
608/// Lexically normalize `path` (expected to be `root.join(rel)` for some
609/// manifest-declared `rel`) via the SAME collapse [`contained`] uses, then —
610/// if the normalized form is still under `root` — return its
611/// project-relative, `/`-separated tail. Callers (currently only
612/// [`CheckpointStore::restore`]) compute this ONCE per entry and feed the
613/// single result to [`is_protected`], so the protected-path floor sees
614/// EXACTLY the same normalized path [`contained`]'s containment check
615/// computes. See `crate::safe_path::normalized_project_rel` for the
616/// implementation.
617fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
618    crate::safe_path::normalized_project_rel(root, path)
619}
620
621/// Symlink- and traversal-safe containment check: is `path` (absolute,
622/// possibly not-yet-existing) confined under `root`? Fails closed (`false`)
623/// on any resolution error. See `crate::safe_path::contained` for the
624/// implementation.
625fn contained(root: &Path, path: &Path) -> bool {
626    crate::safe_path::contained(root, path)
627}
628
629/// Resolved (symlink-following) counterpart to [`normalized_project_rel`]:
630/// canonicalizes `path`'s longest existing ancestor (resolving any symlink
631/// along the way — see `crate::safe_path::resolve_real`) and returns the
632/// resulting absolute path's tail relative to `root`'s own canonical form, as a
633/// `/`-separated string — or `None` if resolution fails, or the resolved
634/// path lands outside `root` entirely (that case is already refused by
635/// [`contained`]; this function only needs to report a rel path when
636/// there IS one). [`CheckpointStore::restore`] feeds this to
637/// [`is_protected`] IN ADDITION TO the lexical [`normalized_project_rel`]
638/// result, closing the gap where a symlink resolves into `.git` (or a
639/// `protected_globs` match) even though its LEXICAL path never mentions
640/// `.git` at all and it never leaves `root` (so `contained` alone would
641/// accept it) — see the call site's doc comment for the exact repro this
642/// closes. See `crate::safe_path::resolved_project_rel` for the
643/// implementation.
644fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
645    crate::safe_path::resolved_project_rel(root, path)
646}
647
648#[derive(Debug, Default)]
649struct ObserverState {
650    /// The checkpoint currently open for the in-flight turn.
651    current: Option<CheckpointId>,
652    /// Project-relative paths already captured under `current` — first
653    /// write per turn wins the pre-image; every write after the first is a
654    /// no-op capture (the manifest already holds the earliest state).
655    captured: HashSet<String>,
656}
657
658/// [`WriteObserver`] implementation backing `[capabilities.checkpoint]`.
659/// One instance per [`crate::Agent`] (installed on its [`crate::tools::ToolContext`]
660/// AND held directly so `crate::Agent::run_loop` can call
661/// [`Self::begin_turn`] once per user turn — see that method's call site's
662/// doc comment).
663#[derive(Debug)]
664pub struct CheckpointObserver {
665    store: CheckpointStore,
666    project_root: PathBuf,
667    retain: usize,
668    protected: Vec<String>,
669    /// BP-7 (§3.1 `capabilities.checkpoint.restore`): see
670    /// [`Self::restore_enabled`].
671    restore_enabled: bool,
672    state: Mutex<ObserverState>,
673    /// Set once on any I/O failure — graceful degrade (§ "git-absent /
674    /// unwritable state dir ⇒ warn + disable, never crash"), not a panic
675    /// and not a blocked tool call. `Relaxed` throughout: this is a
676    /// best-effort circuit breaker, not a correctness-critical ordering.
677    disabled: AtomicBool,
678}
679
680impl CheckpointObserver {
681    /// `protected` layers extra glob patterns (typically
682    /// `Config::permissions_protected_paths`, when module 13 is active) on
683    /// top of the unconditional `.git/**` floor — consulted by
684    /// [`Self::restore`].
685    pub fn new(
686        store: CheckpointStore,
687        project_root: PathBuf,
688        retain: usize,
689        protected: Vec<String>,
690    ) -> Self {
691        Self::with_restore(store, project_root, retain, protected, true)
692    }
693
694    /// BP-7: [`Self::new`] with the restore half explicitly gated — see
695    /// [`Self::restore_enabled`].
696    pub fn with_restore(
697        store: CheckpointStore,
698        project_root: PathBuf,
699        retain: usize,
700        protected: Vec<String>,
701        restore_enabled: bool,
702    ) -> Self {
703        CheckpointObserver {
704            store,
705            project_root,
706            retain: retain.max(1),
707            protected,
708            restore_enabled,
709            state: Mutex::new(ObserverState::default()),
710            disabled: AtomicBool::new(false),
711        }
712    }
713
714    /// Read-only access to the underlying store (e.g. so a caller can
715    /// `list`/`turn_diff`/`restore` without re-deriving the root path).
716    pub fn store(&self) -> &CheckpointStore {
717        &self.store
718    }
719
720    /// List every checkpoint, newest first — see [`CheckpointStore::list`].
721    pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
722        self.store.list()
723    }
724
725    /// D3 turn-diff for one checkpoint — see [`CheckpointStore::turn_diff`].
726    pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
727        self.store.turn_diff(id)
728    }
729
730    /// BP-7: the turn's cumulative unified diff against THIS observer's own
731    /// project root — see [`CheckpointStore::turn_patch`].
732    pub fn turn_patch(&self, id: &str) -> Result<String> {
733        self.store.turn_patch(id, &self.project_root)
734    }
735
736    /// BP-7 (§3.1 `capabilities.checkpoint.restore`): whether this
737    /// observer may put files back. `false` is the turn-diff-only posture
738    /// cx-parity takes — Codex tracks each turn's diff but has no code
739    /// restore at all (its `ghost_snapshot` is a stripped legacy no-op), so
740    /// a preset that transcribes Codex must be able to arm the tracking
741    /// half without the restoring half.
742    pub fn restore_enabled(&self) -> bool {
743        self.restore_enabled
744    }
745
746    /// D4-adjacent revert: restore this project's working files to
747    /// checkpoint `id`, honoring THIS observer's own `project_root` and
748    /// `protected` globs (the fields set at construction) — see
749    /// [`CheckpointStore::restore`] for the full security contract.
750    pub fn restore(&self, id: &str) -> Result<RestoreReport> {
751        if !self.restore_enabled {
752            return Err(Error::Other(
753                "checkpoint restore is off for this harness                  (`[capabilities.checkpoint] restore = false`) — this preset tracks each                  turn's diff but has no code-restore surface"
754                    .to_string(),
755            ));
756        }
757        self.store.restore(id, &self.project_root, &self.protected)
758    }
759
760    /// Whether this observer has disabled itself after an I/O failure.
761    pub fn is_disabled(&self) -> bool {
762        self.disabled.load(Ordering::Relaxed)
763    }
764
765    /// Open a fresh checkpoint for a new turn — called once at the top of
766    /// `crate::Agent::run_loop` (i.e. once per `Agent::send`/
767    /// `send_with_files`/`send_with_images` call, cc's "per-prompt
768    /// file-history-snapshot"). `label` is a short excerpt of the turn's
769    /// prompt, display-only. Also prunes past the retention bound here
770    /// (once per turn, not once per write) — see [`CheckpointStore::prune`].
771    /// Returns `None` when disabled (config-off is never routed here at
772    /// all — see [`observer_for_config`] — so `None` here specifically
773    /// means an I/O failure already tripped the breaker).
774    pub fn begin_turn(&self, label: &str) -> Option<CheckpointId> {
775        if self.disabled.load(Ordering::Relaxed) {
776            return None;
777        }
778        let short: String = label.chars().take(120).collect();
779        match self.store.create_checkpoint(&short) {
780            Ok(id) => {
781                if let Ok(mut st) = self.state.lock() {
782                    st.current = Some(id.clone());
783                    st.captured.clear();
784                }
785                if let Err(e) = self.store.prune(self.retain) {
786                    eprintln!("warning: checkpoint: prune failed: {e}");
787                }
788                Some(id)
789            }
790            Err(e) => {
791                eprintln!(
792                    "warning: checkpoint disabled for the rest of this session — \
793                     failed to open a new checkpoint: {e}"
794                );
795                self.disabled.store(true, Ordering::Relaxed);
796                None
797            }
798        }
799    }
800
801    /// The checkpoint currently open for the in-flight turn, if any.
802    pub fn current(&self) -> Option<CheckpointId> {
803        self.state.lock().ok().and_then(|s| s.current.clone())
804    }
805}
806
807#[async_trait::async_trait]
808impl WriteObserver for CheckpointObserver {
809    async fn before_write(&self, path: &Path) {
810        if self.disabled.load(Ordering::Relaxed) {
811            return;
812        }
813        if !contained(&self.project_root, path) {
814            // Outside the project root — out of this module's scope (§ "an
815            // honest gap", never a crash or a wrong snapshot).
816            return;
817        }
818        let Some(normalized) = crate::tools::normalize(path) else {
819            return;
820        };
821        let Some(root_normalized) = crate::tools::normalize(&self.project_root) else {
822            return;
823        };
824        let Ok(rel_path) = normalized.strip_prefix(&root_normalized) else {
825            return;
826        };
827        let rel = rel_path.to_string_lossy().replace('\\', "/");
828        if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
829            return;
830        }
831
832        // The ENTIRE check-read-record sequence runs under one lock, so two
833        // concurrent writes to the SAME path (e.g. a `run_tools_concurrently`
834        // batch, or a P5-6 background job racing a foreground write) can
835        // never both read-and-lose a torn pre-image: the second one to
836        // arrive here always sees `captured` already contains `rel` and
837        // skips entirely, never re-reading a post-first-write state.
838        let mut st = match self.state.lock() {
839            Ok(g) => g,
840            Err(poisoned) => poisoned.into_inner(),
841        };
842        if st.current.is_none() {
843            // A write reached this seam with no open turn (e.g. a caller
844            // driving the tool registry directly, outside `Agent::send`) —
845            // self-heal with an ad-hoc checkpoint rather than silently
846            // dropping the capture. `begin_turn` takes this same lock, so
847            // it must be called with `st` released first.
848            drop(st);
849            self.begin_turn("untracked");
850            st = match self.state.lock() {
851                Ok(g) => g,
852                Err(poisoned) => poisoned.into_inner(),
853            };
854        }
855        if st.captured.contains(&rel) {
856            return;
857        }
858        let Some(id) = st.current.clone() else {
859            return; // begin_turn's own failure already warned + disabled
860        };
861        let content = std::fs::read(path).ok(); // None => doesn't exist yet (a create)
862        match self.store.record_pre_image(&id, &rel, content) {
863            Ok(()) => {
864                st.captured.insert(rel);
865            }
866            Err(e) => {
867                eprintln!(
868                    "warning: checkpoint disabled for the rest of this session — \
869                     failed to record a snapshot: {e}"
870                );
871                self.disabled.store(true, Ordering::Relaxed);
872            }
873        }
874    }
875
876    async fn after_write(&self, _path: &Path) -> Option<String> {
877        // P5-11: `formatters`/`lsp` now occupy this hook (via
878        // `crate::tools::WriteObserverChain`, installed AFTER this observer
879        // in `crate::agent::build_tool_context`'s chain) — checkpoint itself
880        // still has nothing to do after a write completes, and returning
881        // `None` keeps the tool-result text this hook contributes
882        // byte-identical to before P5-11 whenever checkpoint is the only
883        // observer installed.
884        None
885    }
886}
887
888/// A stable per-project-directory tag — the same hash-of-canonicalized-cwd
889/// idea `crates/cli/src/main.rs::cwd_tag` uses for session naming, kept
890/// separately here (a `core`-crate concern, and `cli` depends on `core` not
891/// the reverse) so two different projects never share one shadow store even
892/// though they'd otherwise both resolve to the same `$SUPERCODE_HOME`-
893/// derived parent directory.
894///
895/// BP-10: `pub(crate)` because the persisted approval cache
896/// (`crate::permissions::approval`) keys its own per-project store the same
897/// way — the SAME tag function, not a second hash with the same idea, so
898/// one project's checkpoints and its remembered approvals can never
899/// disagree about which project they belong to.
900pub(crate) fn project_tag(cwd: &Path) -> String {
901    use std::hash::{Hash, Hasher};
902    let canon = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
903    let mut h = std::collections::hash_map::DefaultHasher::new();
904    canon.hash(&mut h);
905    format!("{:016x}", h.finish())
906}
907
908/// The DEFAULT shadow-store root for `config.cwd` when
909/// [`crate::Config::checkpoint_dir`] is unset: `$SUPERCODE_HOME/checkpoints/<project_tag>`
910/// (`crate::agent::global_instructions_dir` is the same `$SUPERCODE_HOME`
911/// resolver the global instruction tier and `checkpoint` both use).
912fn default_shadow_root(cwd: &Path) -> PathBuf {
913    crate::agent::global_instructions_dir()
914        .join("checkpoints")
915        .join(project_tag(cwd))
916}
917
918/// Build the [`CheckpointObserver`] a fresh [`crate::Agent`] should install,
919/// given a resolved [`crate::Config`] — called once, from
920/// `crate::agent::build_tool_context`. `Config::checkpoint_enabled` is the
921/// ONE gate: `false` (the default) returns `None` WITHOUT touching the
922/// filesystem at all (no `CheckpointStore::open`, no directory created) —
923/// the default-off byte-identity guarantee. `true` opens (creating if
924/// needed) the shadow store at `Config::checkpoint_dir`, or
925/// `default_shadow_root` when that's unset; an I/O failure (unwritable
926/// state dir) is reported via a one-time `eprintln!` warning and returns
927/// `None` — graceful degrade, never a crash, never a blocked `Agent::new`.
928/// BP-7: one side of a [`CheckpointStore::turn_patch`] entry as text.
929/// `None` (absent file) decodes to the empty string — a create diffs
930/// against nothing and a delete diffs to nothing, which is what a unified
931/// diff of those cases should show. Non-UTF-8 bytes return `None`, which
932/// the caller renders as a "binary files differ" line rather than diffing
933/// them into garbage.
934fn decode_side(bytes: Option<&[u8]>) -> Option<String> {
935    match bytes {
936        None => Some(String::new()),
937        Some(b) => String::from_utf8(b.to_vec()).ok(),
938    }
939}
940
941pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<CheckpointObserver>> {
942    if !config.checkpoint_enabled {
943        return None;
944    }
945    let root = config
946        .checkpoint_dir
947        .clone()
948        .unwrap_or_else(|| default_shadow_root(&config.cwd));
949    match CheckpointStore::open(&root) {
950        Ok(store) => Some(std::sync::Arc::new(CheckpointObserver::with_restore(
951            store,
952            config.cwd.clone(),
953            config.checkpoint_retain,
954            config.permissions_protected_paths.clone(),
955            config.checkpoint_restore,
956        ))),
957        Err(e) => {
958            eprintln!(
959                "warning: [capabilities.checkpoint] is enabled but the shadow store at \
960                 {} could not be opened — checkpoint is disabled for this session: {e}",
961                root.display()
962            );
963            None
964        }
965    }
966}