Skip to main content

swink_agent_memory/
checkpoint_store.rs

1//! File-backed checkpoint persistence for [`swink_agent::CheckpointStore`].
2
3use std::io;
4use std::path::{Path, PathBuf};
5
6use swink_agent::atomic_fs::atomic_write;
7use swink_agent::{Checkpoint, CheckpointFuture, CheckpointStore};
8
9use crate::store_async::spawn_store_call;
10
11fn checkpoint_path(checkpoints_dir: &Path, id: &str) -> PathBuf {
12    checkpoints_dir.join(format!("{id}.json"))
13}
14
15fn validate_checkpoint_id(id: &str) -> io::Result<()> {
16    if id.is_empty() {
17        return Err(io::Error::new(
18            io::ErrorKind::InvalidInput,
19            "checkpoint ID must not be empty",
20        ));
21    }
22    if id.contains('/')
23        || id.contains('\\')
24        || id.contains("..")
25        || id.chars().any(|c| c == ':' || c.is_ascii_control())
26    {
27        return Err(io::Error::new(
28            io::ErrorKind::InvalidInput,
29            format!("checkpoint ID contains unsafe characters: {id:?}"),
30        ));
31    }
32    Ok(())
33}
34
35/// Durable checkpoint store that persists one checkpoint per JSON file.
36///
37/// Checkpoints are written atomically, so a failed save never leaves a partial
38/// or zero-length live file behind. Checkpoint IDs are validated before file
39/// access and therefore must not contain path separators, `..`, `:`, or ASCII
40/// control characters.
41///
42/// Retention is unbounded by default. Use
43/// [`with_max_checkpoints`](Self::with_max_checkpoints) to prune older
44/// checkpoints after each save.
45///
46/// All filesystem work runs on Tokio's blocking pool via
47/// [`tokio::task::spawn_blocking`], so the returned futures never stall an
48/// async worker thread and must be polled inside a Tokio runtime.
49pub struct FileCheckpointStore {
50    checkpoints_dir: PathBuf,
51    max_checkpoints: Option<usize>,
52}
53
54impl FileCheckpointStore {
55    /// Recommended bound for callers that want capped checkpoint retention.
56    ///
57    /// New stores are unbounded by default for backwards compatibility with
58    /// the public checkpoint policy contract.
59    pub const DEFAULT_MAX_CHECKPOINTS: usize = 20;
60
61    /// Create a new store rooted at the given directory.
62    ///
63    /// Creates the directory (and parents) if it does not already exist.
64    ///
65    /// The store keeps every checkpoint by default; see
66    /// [`Self::with_max_checkpoints`] to opt into bounded retention.
67    pub fn new(checkpoints_dir: PathBuf) -> io::Result<Self> {
68        std::fs::create_dir_all(&checkpoints_dir)?;
69        Ok(Self {
70            checkpoints_dir,
71            max_checkpoints: None,
72        })
73    }
74
75    /// Default checkpoints directory: `<config_dir>/swink-agent/checkpoints`.
76    pub fn default_dir() -> Option<PathBuf> {
77        dirs::config_dir().map(|dir| dir.join("swink-agent").join("checkpoints"))
78    }
79
80    /// Keep at most `n` checkpoints, pruning the oldest (by `created_at`)
81    /// after each save.
82    ///
83    /// The checkpoint just saved counts toward the limit, so `n` should be at
84    /// least 1. [`Self::DEFAULT_MAX_CHECKPOINTS`] is a reasonable preset for
85    /// callers that do not need every historical checkpoint.
86    ///
87    /// Pruning only considers files in the store directory that parse as
88    /// checkpoints; foreign or malformed files are never deleted. Pruning is
89    /// best-effort: failures are logged at warn level and never fail the save
90    /// that triggered them.
91    #[must_use]
92    pub fn with_max_checkpoints(mut self, n: usize) -> Self {
93        self.max_checkpoints = Some(n);
94        self
95    }
96
97    /// Disable retention pruning and keep every checkpoint.
98    ///
99    /// This is the default for new stores. It is also useful after a builder
100    /// chain has temporarily selected a bound. Disk usage then grows without
101    /// bound: a per-turn checkpoint policy leaves one growing file per turn,
102    /// so an N-turn session stores O(N²) bytes. Prefer a bound unless every
103    /// historical checkpoint is needed.
104    #[must_use]
105    pub fn unbounded(mut self) -> Self {
106        self.max_checkpoints = None;
107        self
108    }
109}
110
111/// Delete the oldest checkpoints (by `created_at`) beyond `keep`.
112///
113/// Only files that parse as [`Checkpoint`]s are candidates; unreadable,
114/// malformed, or non-`.json` files are skipped, never deleted.
115fn prune_to(checkpoints_dir: &Path, keep: usize) {
116    let entries = match std::fs::read_dir(checkpoints_dir) {
117        Ok(entries) => entries,
118        Err(error) => {
119            tracing::warn!(error = %error, "checkpoint retention: cannot read store dir");
120            return;
121        }
122    };
123
124    // (created_at, id, path) for every parseable checkpoint file.
125    let mut checkpoints = Vec::new();
126    for entry in entries.flatten() {
127        let path = entry.path();
128        if path.extension().and_then(|value| value.to_str()) != Some("json") {
129            continue;
130        }
131        let Ok(contents) = std::fs::read_to_string(&path) else {
132            continue;
133        };
134        if let Ok(checkpoint) = serde_json::from_str::<Checkpoint>(&contents) {
135            checkpoints.push((checkpoint.created_at, checkpoint.id, path));
136        }
137    }
138
139    if checkpoints.len() <= keep {
140        return;
141    }
142
143    // Newest first (ties broken by id, matching `list_checkpoints`);
144    // everything past `keep` is pruned.
145    checkpoints.sort_by(|left, right| (right.0, &right.1).cmp(&(left.0, &left.1)));
146    for (_, id, path) in checkpoints.drain(keep..) {
147        if let Err(error) = std::fs::remove_file(&path)
148            && error.kind() != io::ErrorKind::NotFound
149        {
150            tracing::warn!(
151                checkpoint_id = %id,
152                path = %path.display(),
153                error = %error,
154                "checkpoint retention: failed to prune checkpoint"
155            );
156        }
157    }
158}
159
160impl CheckpointStore for FileCheckpointStore {
161    fn save_checkpoint(&self, checkpoint: Checkpoint) -> CheckpointFuture<'_, ()> {
162        let checkpoints_dir = self.checkpoints_dir.clone();
163        let max_checkpoints = self.max_checkpoints;
164        spawn_store_call(move || {
165            validate_checkpoint_id(&checkpoint.id)?;
166            let path = checkpoint_path(&checkpoints_dir, &checkpoint.id);
167            atomic_write(&path, |writer| {
168                serde_json::to_writer_pretty(&mut *writer, &checkpoint).map_err(io::Error::other)
169            })?;
170
171            if let Some(keep) = max_checkpoints {
172                prune_to(&checkpoints_dir, keep);
173            }
174            Ok(())
175        })
176    }
177
178    fn load_checkpoint(&self, id: &str) -> CheckpointFuture<'_, Option<Checkpoint>> {
179        let checkpoints_dir = self.checkpoints_dir.clone();
180        let id = id.to_string();
181        spawn_store_call(move || {
182            validate_checkpoint_id(&id)?;
183            let path = checkpoint_path(&checkpoints_dir, &id);
184            if !path.exists() {
185                return Ok(None);
186            }
187
188            let contents = std::fs::read_to_string(path)?;
189            serde_json::from_str(&contents)
190                .map(Some)
191                .map_err(io::Error::other)
192        })
193    }
194
195    fn list_checkpoints(&self) -> CheckpointFuture<'_, Vec<String>> {
196        let checkpoints_dir = self.checkpoints_dir.clone();
197        spawn_store_call(move || {
198            let mut checkpoints = Vec::new();
199
200            for entry in std::fs::read_dir(&checkpoints_dir)? {
201                let entry = entry?;
202                let path = entry.path();
203                if path.extension().and_then(|value| value.to_str()) != Some("json") {
204                    continue;
205                }
206
207                let contents = match std::fs::read_to_string(&path) {
208                    Ok(contents) => contents,
209                    Err(error) => {
210                        tracing::warn!(
211                            path = %path.display(),
212                            error = %error,
213                            "skipping unreadable checkpoint file"
214                        );
215                        continue;
216                    }
217                };
218
219                match serde_json::from_str::<Checkpoint>(&contents) {
220                    Ok(checkpoint) => checkpoints.push((checkpoint.created_at, checkpoint.id)),
221                    Err(error) => {
222                        tracing::warn!(
223                            path = %path.display(),
224                            error = %error,
225                            "skipping invalid checkpoint file"
226                        );
227                    }
228                }
229            }
230
231            checkpoints.sort_by(|left, right| right.cmp(left));
232            Ok(checkpoints.into_iter().map(|(_, id)| id).collect())
233        })
234    }
235
236    fn delete_checkpoint(&self, id: &str) -> CheckpointFuture<'_, ()> {
237        let checkpoints_dir = self.checkpoints_dir.clone();
238        let id = id.to_string();
239        spawn_store_call(move || {
240            validate_checkpoint_id(&id)?;
241            let path = checkpoint_path(&checkpoints_dir, &id);
242            match std::fs::remove_file(path) {
243                Ok(()) => Ok(()),
244                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
245                Err(error) => Err(error),
246            }
247        })
248    }
249}
250
251#[cfg(test)]
252#[path = "checkpoint_store_tests.rs"]
253mod tests;