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