Skip to main content

adk_managed/
checkpoint.rs

1//! Checkpoint management for resumable sessions.
2//!
3//! Checkpoints are held in memory. They support replay and resume **within a process**; they
4//! do not survive process loss, so a new process cannot resume a session started by another.
5//! "Atomic" below means a single assignment under a lock, not a transaction with a persistent
6//! store.
7//!
8//! The [`CheckpointManager`] provides atomic checkpoint persistence so that
9//! a crash cannot leave an event emitted but un-checkpointed (or vice versa).
10//! For the initial implementation, storage is in-memory (`Vec<SessionEvent>`).
11//! The real integration with `SessionService` for persistent storage is a
12//! platform concern.
13//!
14//! # Responsibilities
15//!
16//! 1. **Atomicity guarantee**: event + state saved together in one operation
17//! 2. **Load/resume interface**: retrieve all events and last run state
18//! 3. **Event log maintenance**: ordered log for replay
19
20use serde::{Deserialize, Serialize};
21
22use std::sync::Arc;
23
24use crate::state_store::{ManagedSessionState, ManagedStateStore};
25use crate::types::{RuntimeError, SessionEvent, SessionStatus};
26
27/// Run-state persisted with each checkpoint.
28///
29/// Contains everything needed to resume a session after a crash:
30/// the current sequence counter value, which tool calls are parked,
31/// and the session's lifecycle status.
32///
33/// # Example
34///
35/// ```rust
36/// use adk_managed::checkpoint::RunState;
37/// use adk_managed::types::SessionStatus;
38///
39/// let state = RunState {
40///     seq: 5,
41///     pending_tool_ids: vec!["ctu_001".to_string()],
42///     status: SessionStatus::Running,
43/// };
44/// assert_eq!(state.seq, 5);
45/// assert!(!state.pending_tool_ids.is_empty());
46/// ```
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub struct RunState {
49    /// Current sequence counter value.
50    pub seq: u64,
51    /// IDs of custom tool calls that are currently parked (awaiting client response).
52    pub pending_tool_ids: Vec<String>,
53    /// Current session status.
54    pub status: SessionStatus,
55}
56
57impl RunState {
58    /// Create a new initial run state (seq=0, no pending tools, queued status).
59    pub fn initial() -> Self {
60        Self { seq: 0, pending_tool_ids: Vec::new(), status: SessionStatus::Queued }
61    }
62}
63
64/// Manages in-process checkpoint state for resumable sessions.
65///
66/// Each checkpoint atomically stores an event and the updated run-state so that
67/// a crash cannot leave an event emitted but un-checkpointed (or vice versa).
68///
69/// # Example
70///
71/// ```rust
72/// use adk_managed::checkpoint::{CheckpointManager, RunState};
73/// use adk_managed::types::{SessionEvent, SessionStatus, ContentBlock};
74///
75/// let mut mgr = CheckpointManager::new("session_001".to_string());
76///
77/// let event = SessionEvent::StatusRunning { seq: 0 };
78/// let state = RunState { seq: 1, pending_tool_ids: vec![], status: SessionStatus::Running };
79/// mgr.checkpoint(event, state.clone());
80///
81/// assert_eq!(mgr.events().len(), 1);
82/// assert_eq!(mgr.run_state(), &state);
83/// ```
84pub struct CheckpointManager {
85    /// The session ID this manager is checkpointing for.
86    session_id: String,
87    /// The event log held by this manager.
88    events: Vec<SessionEvent>,
89    /// Current run state.
90    run_state: RunState,
91    /// Where [`CheckpointManager::flush`] writes, when a store is configured.
92    store: Option<Arc<dyn ManagedStateStore>>,
93}
94
95impl CheckpointManager {
96    /// Create a new checkpoint manager for the given session.
97    ///
98    /// Initializes with an empty event log and the initial run state
99    /// (seq=0, no pending tools, queued status).
100    pub fn new(session_id: String) -> Self {
101        Self { session_id, events: Vec::new(), run_state: RunState::initial(), store: None }
102    }
103
104    /// Writes flushed checkpoints to `store`.
105    ///
106    /// Check [`ManagedStateStore::durability`] to learn whether those writes survive process
107    /// loss. With the shipped [`InMemoryManagedStateStore`](crate::InMemoryManagedStateStore)
108    /// they do not.
109    ///
110    /// # Example
111    ///
112    /// ```rust
113    /// use adk_managed::{CheckpointManager, InMemoryManagedStateStore};
114    /// use std::sync::Arc;
115    ///
116    /// let manager = CheckpointManager::new("session-1".to_string())
117    ///     .with_store(Arc::new(InMemoryManagedStateStore::new()));
118    /// assert!(manager.store().is_some());
119    /// ```
120    pub fn with_store(mut self, store: Arc<dyn ManagedStateStore>) -> Self {
121        self.store = Some(store);
122        self
123    }
124
125    /// The configured store, if any.
126    pub fn store(&self) -> Option<&Arc<dyn ManagedStateStore>> {
127        self.store.as_ref()
128    }
129
130    /// Writes the current snapshot to the configured store.
131    ///
132    /// A no-op without a store. Separate from [`CheckpointManager::checkpoint`] because that
133    /// method is synchronous and a store write is not; a caller that needs the snapshot
134    /// externally visible must flush.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`RuntimeError`] when the store rejects the write.
139    pub async fn flush(&self) -> Result<(), RuntimeError> {
140        let Some(store) = &self.store else {
141            return Ok(());
142        };
143
144        store
145            .save(
146                &self.session_id,
147                ManagedSessionState {
148                    events: self.events.clone(),
149                    run_state: self.run_state.clone(),
150                },
151            )
152            .await
153    }
154
155    /// Rebuilds a manager for `session_id` from `store`.
156    ///
157    /// Returns a manager with the stored snapshot when one exists, and an empty one otherwise.
158    /// Whether anything is found across a restart depends entirely on the store's durability —
159    /// with the in-memory backend a new process finds nothing.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`RuntimeError`] when the store cannot be read.
164    pub async fn restore(
165        session_id: String,
166        store: Arc<dyn ManagedStateStore>,
167    ) -> Result<Self, RuntimeError> {
168        let restored = store.load(&session_id).await?;
169        let (events, run_state) = match restored {
170            Some(state) => (state.events, state.run_state),
171            None => (Vec::new(), RunState::initial()),
172        };
173
174        Ok(Self { session_id, events, run_state, store: Some(store) })
175    }
176
177    /// Records an event and the updated run state together.
178    ///
179    /// The pair is applied in one call, so replay never sees an event without its state. This
180    /// is a write to this manager's own fields, **not** a transaction with a persistent store:
181    /// it says nothing about surviving a crash. Call [`CheckpointManager::flush`] to write the
182    /// snapshot out, and check the store's durability to learn what that write guarantees.
183    pub fn checkpoint(&mut self, event: SessionEvent, run_state: RunState) {
184        self.events.push(event);
185        self.run_state = run_state;
186    }
187
188    /// The events and run state this manager holds, for resume within the process.
189    ///
190    /// Reconstructing a session in a *different* process requires a crash-durable
191    /// [`ManagedStateStore`] and [`CheckpointManager::restore`]; this method reads local
192    /// fields only.
193    pub fn load_checkpoint(&self) -> (Vec<SessionEvent>, RunState) {
194        (self.events.clone(), self.run_state.clone())
195    }
196
197    /// Get all events stored in the checkpoint log.
198    pub fn events(&self) -> &[SessionEvent] {
199        &self.events
200    }
201
202    /// Get current run state.
203    pub fn run_state(&self) -> &RunState {
204        &self.run_state
205    }
206
207    /// Get the session ID this manager is checkpointing for.
208    pub fn session_id(&self) -> &str {
209        &self.session_id
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::types::ContentBlock;
217    use serde_json::json;
218
219    #[test]
220    fn test_run_state_initial() {
221        let state = RunState::initial();
222        assert_eq!(state.seq, 0);
223        assert!(state.pending_tool_ids.is_empty());
224        assert_eq!(state.status, SessionStatus::Queued);
225    }
226
227    #[test]
228    fn test_run_state_serialization_round_trip() {
229        let state = RunState {
230            seq: 42,
231            pending_tool_ids: vec!["ctu_001".to_string(), "ctu_002".to_string()],
232            status: SessionStatus::Running,
233        };
234        let json = serde_json::to_string(&state).unwrap();
235        let deserialized: RunState = serde_json::from_str(&json).unwrap();
236        assert_eq!(state, deserialized);
237    }
238
239    #[test]
240    fn test_checkpoint_manager_new() {
241        let mgr = CheckpointManager::new("sess_123".to_string());
242        assert_eq!(mgr.session_id(), "sess_123");
243        assert!(mgr.events().is_empty());
244        assert_eq!(mgr.run_state(), &RunState::initial());
245    }
246
247    #[test]
248    fn test_checkpoint_stores_event_and_state_atomically() {
249        let mut mgr = CheckpointManager::new("sess_001".to_string());
250
251        let event = SessionEvent::StatusRunning { seq: 0 };
252        let state = RunState { seq: 1, pending_tool_ids: vec![], status: SessionStatus::Running };
253
254        mgr.checkpoint(event, state.clone());
255
256        // Both event and state should be stored together
257        assert_eq!(mgr.events().len(), 1);
258        assert_eq!(mgr.run_state(), &state);
259    }
260
261    #[test]
262    fn test_checkpoint_multiple_events() {
263        let mut mgr = CheckpointManager::new("sess_002".to_string());
264
265        // First checkpoint
266        let event1 = SessionEvent::StatusRunning { seq: 0 };
267        let state1 = RunState { seq: 1, pending_tool_ids: vec![], status: SessionStatus::Running };
268        mgr.checkpoint(event1, state1);
269
270        // Second checkpoint
271        let event2 = SessionEvent::Message {
272            content: vec![ContentBlock::Text { text: "Hello".to_string() }],
273            seq: 1,
274        };
275        let state2 = RunState { seq: 2, pending_tool_ids: vec![], status: SessionStatus::Running };
276        mgr.checkpoint(event2, state2.clone());
277
278        // Third checkpoint — idle with pending tool
279        let event3 = SessionEvent::CustomToolUse {
280            custom_tool_use_id: "ctu_001".to_string(),
281            name: "deploy".to_string(),
282            input: json!({"target": "staging"}),
283            seq: 2,
284        };
285        let state3 = RunState {
286            seq: 3,
287            pending_tool_ids: vec!["ctu_001".to_string()],
288            status: SessionStatus::Idle,
289        };
290        mgr.checkpoint(event3, state3.clone());
291
292        assert_eq!(mgr.events().len(), 3);
293        // Run state should reflect the LAST checkpoint
294        assert_eq!(mgr.run_state(), &state3);
295    }
296
297    #[test]
298    fn test_load_checkpoint_returns_all_events_and_current_state() {
299        let mut mgr = CheckpointManager::new("sess_003".to_string());
300
301        let event1 = SessionEvent::StatusRunning { seq: 0 };
302        let state1 = RunState { seq: 1, pending_tool_ids: vec![], status: SessionStatus::Running };
303        mgr.checkpoint(event1, state1);
304
305        let event2 = SessionEvent::StatusIdle { seq: 1, stop_reason: None, usage: None };
306        let state2 = RunState { seq: 2, pending_tool_ids: vec![], status: SessionStatus::Idle };
307        mgr.checkpoint(event2, state2.clone());
308
309        let (events, run_state) = mgr.load_checkpoint();
310        assert_eq!(events.len(), 2);
311        assert_eq!(run_state, state2);
312    }
313
314    #[test]
315    fn test_load_checkpoint_empty_manager() {
316        let mgr = CheckpointManager::new("sess_empty".to_string());
317        let (events, run_state) = mgr.load_checkpoint();
318        assert!(events.is_empty());
319        assert_eq!(run_state, RunState::initial());
320    }
321
322    #[test]
323    fn test_run_state_updates_atomically_with_event() {
324        let mut mgr = CheckpointManager::new("sess_atomic".to_string());
325
326        // Simulate a custom tool use that parks
327        let event = SessionEvent::CustomToolUse {
328            custom_tool_use_id: "ctu_park".to_string(),
329            name: "user_action".to_string(),
330            input: json!({}),
331            seq: 0,
332        };
333        let state = RunState {
334            seq: 1,
335            pending_tool_ids: vec!["ctu_park".to_string()],
336            status: SessionStatus::Idle,
337        };
338        mgr.checkpoint(event, state.clone());
339
340        // Verify the state reflects the parked tool
341        assert_eq!(mgr.run_state().pending_tool_ids, vec!["ctu_park"]);
342        assert_eq!(mgr.run_state().status, SessionStatus::Idle);
343
344        // Simulate the tool result arriving and session resuming
345        let event2 = SessionEvent::StatusRunning { seq: 1 };
346        let state2 = RunState { seq: 2, pending_tool_ids: vec![], status: SessionStatus::Running };
347        mgr.checkpoint(event2, state2.clone());
348
349        // Pending tools should be cleared
350        assert!(mgr.run_state().pending_tool_ids.is_empty());
351        assert_eq!(mgr.run_state().status, SessionStatus::Running);
352    }
353
354    #[test]
355    fn test_run_state_with_multiple_pending_tools() {
356        let state = RunState {
357            seq: 10,
358            pending_tool_ids: vec![
359                "ctu_001".to_string(),
360                "ctu_002".to_string(),
361                "ctu_003".to_string(),
362            ],
363            status: SessionStatus::Idle,
364        };
365
366        let json = serde_json::to_string(&state).unwrap();
367        let deserialized: RunState = serde_json::from_str(&json).unwrap();
368        assert_eq!(deserialized.pending_tool_ids.len(), 3);
369        assert_eq!(deserialized, state);
370    }
371}