Skip to main content

agent_sdk_core/application/
checkpoint.rs

1//! Application-layer coordination over core primitives. Use these services to lower
2//! helpers, drive runs, validate output, coordinate tools, approvals, delivery,
3//! isolation, telemetry, and feature layers. Methods in this layer may call
4//! configured ports, mutate in-memory stores, append journals, or publish events as
5//! documented. This file contains the checkpoint portion of that contract.
6//!
7use 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
19/// Port or behavior contract for checkpoint store. Implementors should
20/// preserve policy, redaction, idempotency, and replay expectations
21/// from the surrounding module. Implementations may perform side
22/// effects only as described by the trait methods.
23pub trait CheckpointStore: Send + Sync {
24    /// Saves checkpoint accelerator data for a run at a journal sequence.
25    /// Implementations write or replace checkpoint accelerator data for the run without
26    /// changing journal truth.
27    fn save(
28        &self,
29        checkpoint: RunCheckpoint,
30        latest_journal_seq: u64,
31    ) -> Result<CheckpointSaveOutcome, AgentError>;
32
33    /// Loads the latest checkpoint accelerator data for a run.
34    /// Implementations read checkpoint storage for the requested run and return matching
35    /// checkpoint data without altering durable journal truth.
36    fn load_latest(&self, run_id: &RunId) -> Result<Option<RunCheckpoint>, AgentError>;
37
38    /// Loads checkpoint accelerator data at or before the requested journal
39    /// cursor.
40    /// Implementations read checkpoint storage for the requested run and return matching
41    /// checkpoint data without altering durable journal truth.
42    fn load_at_or_before(
43        &self,
44        run_id: &RunId,
45        cursor: &JournalCursor,
46    ) -> Result<Option<RunCheckpoint>, AgentError>;
47
48    /// Prunes checkpoint accelerator data according to retention policy.
49    /// Implementations remove checkpoint accelerator entries according to retention policy
50    /// without deleting journal truth.
51    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)]
59/// Holds checkpoint save outcome application-layer state or configuration.
60/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
61pub struct CheckpointSaveOutcome {
62    /// Typed checkpoint ref reference. Resolving or executing it is a
63    /// separate policy-gated step.
64    pub checkpoint_ref: String,
65    /// Covers journal seq used by this record or request.
66    pub covers_journal_seq: u64,
67    /// Whether terminal checkpoint is enabled.
68    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
69    pub terminal_checkpoint: bool,
70}
71
72#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
73/// Holds checkpoint prune policy application-layer state or configuration.
74/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
75pub struct CheckpointPrunePolicy {
76    /// Prune covered before used by this record or request.
77    pub prune_covered_before: u64,
78    /// Whether preserve latest terminal is enabled.
79    /// Policy, validation, or routing code uses this flag to choose the explicit behavior.
80    pub preserve_latest_terminal: bool,
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84/// Holds checkpoint prune report application-layer state or configuration.
85/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
86pub struct CheckpointPruneReport {
87    /// Run identifier used for lineage, filtering, replay, and dedupe.
88    pub run_id: RunId,
89    /// Count of pruned items observed or included in this record.
90    pub pruned_count: usize,
91    /// Count of retained items observed or included in this record.
92    pub retained_count: usize,
93    /// Terminal checkpoint retained even when pruning older checkpoint accelerators.
94    /// Use it to keep terminal replay shortcuts available without treating checkpoints as
95    /// durable truth.
96    pub preserved_terminal_checkpoint: Option<String>,
97}
98
99#[derive(Clone, Debug, Default)]
100/// Holds in memory checkpoint store application-layer state or configuration.
101/// Use it with the documented coordinator methods; run, journal, event, provider, or port effects are called out on those methods rather than on construction.
102pub struct InMemoryCheckpointStore {
103    checkpoints: Arc<Mutex<BTreeMap<RunId, Vec<RunCheckpoint>>>>,
104}
105
106impl InMemoryCheckpointStore {
107    /// Creates a new application::checkpoint value with explicit
108    /// caller-provided inputs. This constructor is data-only and
109    /// performs no I/O or external side effects.
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Save.
115    /// This stores checkpoint accelerator data in memory without changing journal truth.
116    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    /// Load latest.
125    /// This reads the in-memory checkpoint store for the requested run and does not write
126    /// checkpoints or journal records.
127    pub fn load_latest(&self, run_id: &RunId) -> Result<Option<RunCheckpoint>, AgentError> {
128        <Self as CheckpointStore>::load_latest(self, run_id)
129    }
130
131    /// Load at or before.
132    /// This reads the in-memory checkpoint store for the requested run and does not write
133    /// checkpoints or journal records.
134    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    /// Prune.
143    /// This prunes only the in-memory checkpoint accelerator and leaves durable journal records
144    /// untouched.
145    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    /// List.
154    /// This reads all in-memory checkpoint accelerator entries for one run.
155    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}