agent_sdk_core/application/
checkpoint.rs1use std::{
8 collections::BTreeMap,
9 sync::{Arc, Mutex},
10};
11
12use serde::{Deserialize, Serialize};
13
14use crate::{
15 domain::{AgentError, JournalCursor, RunId},
16 journal::RunCheckpoint,
17};
18
19pub trait CheckpointStore: Send + Sync {
24 fn save(
28 &self,
29 checkpoint: RunCheckpoint,
30 latest_journal_seq: u64,
31 ) -> Result<CheckpointSaveOutcome, AgentError>;
32
33 fn load_latest(&self, run_id: &RunId) -> Result<Option<RunCheckpoint>, AgentError>;
37
38 fn load_at_or_before(
43 &self,
44 run_id: &RunId,
45 cursor: &JournalCursor,
46 ) -> Result<Option<RunCheckpoint>, AgentError>;
47
48 fn prune(
52 &self,
53 run_id: &RunId,
54 policy: CheckpointPrunePolicy,
55 ) -> Result<CheckpointPruneReport, AgentError>;
56}
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59pub struct CheckpointSaveOutcome {
62 pub checkpoint_ref: String,
65 pub covers_journal_seq: u64,
67 pub terminal_checkpoint: bool,
70}
71
72#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
73pub struct CheckpointPrunePolicy {
76 pub prune_covered_before: u64,
78 pub preserve_latest_terminal: bool,
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct CheckpointPruneReport {
87 pub run_id: RunId,
89 pub pruned_count: usize,
91 pub retained_count: usize,
93 pub preserved_terminal_checkpoint: Option<String>,
97}
98
99#[derive(Clone, Debug, Default)]
100pub struct InMemoryCheckpointStore {
103 checkpoints: Arc<Mutex<BTreeMap<RunId, Vec<RunCheckpoint>>>>,
104}
105
106impl InMemoryCheckpointStore {
107 pub fn new() -> Self {
111 Self::default()
112 }
113
114 pub fn save(
117 &self,
118 checkpoint: RunCheckpoint,
119 latest_journal_seq: u64,
120 ) -> Result<CheckpointSaveOutcome, AgentError> {
121 <Self as CheckpointStore>::save(self, checkpoint, latest_journal_seq)
122 }
123
124 pub fn load_latest(&self, run_id: &RunId) -> Result<Option<RunCheckpoint>, AgentError> {
128 <Self as CheckpointStore>::load_latest(self, run_id)
129 }
130
131 pub fn load_at_or_before(
135 &self,
136 run_id: &RunId,
137 cursor: &JournalCursor,
138 ) -> Result<Option<RunCheckpoint>, AgentError> {
139 <Self as CheckpointStore>::load_at_or_before(self, run_id, cursor)
140 }
141
142 pub fn prune(
146 &self,
147 run_id: &RunId,
148 policy: CheckpointPrunePolicy,
149 ) -> Result<CheckpointPruneReport, AgentError> {
150 <Self as CheckpointStore>::prune(self, run_id, policy)
151 }
152
153 pub fn list(&self, run_id: &RunId) -> Result<Vec<RunCheckpoint>, AgentError> {
156 Ok(self
157 .checkpoints
158 .lock()
159 .map_err(|_| AgentError::contract_violation("checkpoint store lock poisoned"))?
160 .get(run_id)
161 .cloned()
162 .unwrap_or_default())
163 }
164}
165
166impl CheckpointStore for InMemoryCheckpointStore {
167 fn save(
168 &self,
169 checkpoint: RunCheckpoint,
170 latest_journal_seq: u64,
171 ) -> Result<CheckpointSaveOutcome, AgentError> {
172 checkpoint.validate_against_latest_seq(latest_journal_seq)?;
173 let terminal_checkpoint = is_terminal_checkpoint(&checkpoint);
174 let checkpoint_ref = checkpoint.checkpoint_id.clone();
175 let covers_journal_seq = checkpoint.covers_journal_seq;
176 let mut locked = self
177 .checkpoints
178 .lock()
179 .map_err(|_| AgentError::contract_violation("checkpoint store lock poisoned"))?;
180 let entries = locked.entry(checkpoint.run_id.clone()).or_default();
181 entries.retain(|existing| existing.checkpoint_id != checkpoint.checkpoint_id);
182 entries.push(checkpoint);
183 entries.sort_by_key(|checkpoint| {
184 (
185 checkpoint.covers_journal_seq,
186 checkpoint.checkpoint_seq,
187 checkpoint.created_at_millis,
188 )
189 });
190
191 Ok(CheckpointSaveOutcome {
192 checkpoint_ref,
193 covers_journal_seq,
194 terminal_checkpoint,
195 })
196 }
197
198 fn load_latest(&self, run_id: &RunId) -> Result<Option<RunCheckpoint>, AgentError> {
199 Ok(self
200 .checkpoints
201 .lock()
202 .map_err(|_| AgentError::contract_violation("checkpoint store lock poisoned"))?
203 .get(run_id)
204 .and_then(|checkpoints| checkpoints.iter().max_by_key(checkpoint_order).cloned()))
205 }
206
207 fn load_at_or_before(
208 &self,
209 run_id: &RunId,
210 cursor: &JournalCursor,
211 ) -> Result<Option<RunCheckpoint>, AgentError> {
212 let cursor_seq = journal_cursor_seq(cursor);
213 Ok(self
214 .checkpoints
215 .lock()
216 .map_err(|_| AgentError::contract_violation("checkpoint store lock poisoned"))?
217 .get(run_id)
218 .and_then(|checkpoints| {
219 checkpoints
220 .iter()
221 .filter(|checkpoint| checkpoint.covers_journal_seq <= cursor_seq)
222 .max_by_key(checkpoint_order)
223 .cloned()
224 }))
225 }
226
227 fn prune(
228 &self,
229 run_id: &RunId,
230 policy: CheckpointPrunePolicy,
231 ) -> Result<CheckpointPruneReport, AgentError> {
232 let mut locked = self
233 .checkpoints
234 .lock()
235 .map_err(|_| AgentError::contract_violation("checkpoint store lock poisoned"))?;
236 let Some(checkpoints) = locked.get_mut(run_id) else {
237 return Ok(CheckpointPruneReport {
238 run_id: run_id.clone(),
239 pruned_count: 0,
240 retained_count: 0,
241 preserved_terminal_checkpoint: None,
242 });
243 };
244 let terminal_to_preserve = policy
245 .preserve_latest_terminal
246 .then(|| {
247 checkpoints
248 .iter()
249 .filter(|checkpoint| is_terminal_checkpoint(checkpoint))
250 .max_by_key(checkpoint_order)
251 .map(|checkpoint| checkpoint.checkpoint_id.clone())
252 })
253 .flatten();
254
255 let before = checkpoints.len();
256 checkpoints.retain(|checkpoint| {
257 checkpoint.covers_journal_seq >= policy.prune_covered_before
258 || terminal_to_preserve
259 .as_ref()
260 .is_some_and(|checkpoint_id| checkpoint_id == &checkpoint.checkpoint_id)
261 });
262 let retained_count = checkpoints.len();
263
264 Ok(CheckpointPruneReport {
265 run_id: run_id.clone(),
266 pruned_count: before.saturating_sub(retained_count),
267 retained_count,
268 preserved_terminal_checkpoint: terminal_to_preserve,
269 })
270 }
271}
272
273fn checkpoint_order(checkpoint: &&RunCheckpoint) -> (u64, u64, u64) {
274 (
275 checkpoint.covers_journal_seq,
276 checkpoint.checkpoint_seq,
277 checkpoint.created_at_millis,
278 )
279}
280
281fn is_terminal_checkpoint(checkpoint: &RunCheckpoint) -> bool {
282 let state = checkpoint.loop_state.to_ascii_lowercase();
283 state.starts_with("terminal:")
284 || matches!(
285 state.as_str(),
286 "completed" | "failed" | "cancelled" | "terminal"
287 )
288}
289
290fn journal_cursor_seq(cursor: &JournalCursor) -> u64 {
291 cursor
292 .as_str()
293 .rsplit_once('.')
294 .and_then(|(_, seq)| seq.parse::<u64>().ok())
295 .unwrap_or(0)
296}