swink_agent_memory/
checkpoint_store.rs1use 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
35pub struct FileCheckpointStore {
50 checkpoints_dir: PathBuf,
51 max_checkpoints: Option<usize>,
52}
53
54impl FileCheckpointStore {
55 pub const DEFAULT_MAX_CHECKPOINTS: usize = 20;
60
61 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 pub fn default_dir() -> Option<PathBuf> {
77 dirs::config_dir().map(|dir| dir.join("swink-agent").join("checkpoints"))
78 }
79
80 #[must_use]
92 pub fn with_max_checkpoints(mut self, n: usize) -> Self {
93 self.max_checkpoints = Some(n);
94 self
95 }
96
97 #[must_use]
105 pub fn unbounded(mut self) -> Self {
106 self.max_checkpoints = None;
107 self
108 }
109}
110
111fn 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 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 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;