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}