Skip to main content

greentic_aw_runtime/graph/
checkpoint.rs

1//! Durable-checkpoint abstraction for agent-graph runs.
2//!
3//! Provides:
4//! - [`GraphRunRecord`] — serialisable snapshot of one graph run.
5//! - [`CheckpointStore`] — object-safe async trait (manual `Pin<Box<dyn Future>>` returns,
6//!   same pattern as [`crate::state::AgentStateStore`]).
7//! - [`InMemoryCheckpointStore`] — std::sync::Mutex-backed implementation for tests
8//!   and designer swap. The Redis implementation ships in Task 6.
9//!
10//! # Key format and segment constraints
11//!
12//! Key segments (`tenant_id`, `env_id`, `run_id`, `node_id`) **MUST NOT contain `':'`**.
13//! Using `':'` as a delimiter makes keys unambiguous only when no segment itself contains
14//! the delimiter.  Run-id producers use `'__'` as their internal word separator to avoid
15//! conflicts.
16//!
17//! Key format for this in-memory implementation:
18//! - Run key:   `"{tenant_id}:{env_id}:{run_id}"`
19//! - Visit key: `"{tenant_id}:{env_id}:{run_id}:{node_id}:{attempt}"`
20//!
21//! The Redis implementation ([`crate::graph::RedisCheckpointStore`]) **additionally** prepends
22//! the crate's `aw:` namespace prefix, consistent with [`crate::state_redis::RedisAgentStateStore`]
23//! which uses [`crate::tenant::TenantContext::key_prefix()`] (returning `"aw:{tenant_id}:{env_id}"`),
24//! and suffixes a `graph` marker. This yields Redis keys of the form:
25//! - Run key:   `"aw:{tenant_id}:{env_id}:{run_id}:graph"`
26//! - Visit key: `"aw:{tenant_id}:{env_id}:{run_id}:graph:visit:{node_id}:{attempt}"`
27
28use std::collections::HashMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::Mutex;
32
33use crate::tenant::TenantContext;
34
35// ---------------------------------------------------------------------------
36// RunStatus
37// ---------------------------------------------------------------------------
38
39/// Current lifecycle state of a graph run.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum RunStatus {
43    Running,
44    /// Parked at a [`crate::graph::model::NodeKind::Approval`] node, awaiting
45    /// a human decision via the host's `ApprovalFn` closure (Task C2).
46    AwaitingInput,
47    Succeeded,
48    Failed,
49}
50
51// ---------------------------------------------------------------------------
52// GraphRunRecord
53// ---------------------------------------------------------------------------
54
55/// Durable snapshot of one graph run.
56///
57/// `graph_json` is immutable for the run's lifetime — republishing a graph
58/// never mutates an in-flight run.
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct GraphRunRecord {
61    pub run_id: String,
62    /// Serialised [`crate::graph::GraphConfig`] captured at run creation.
63    pub graph_json: String,
64    /// The node id the executor should resume from on restart.
65    pub cursor: String,
66    /// Serialised [`crate::graph::GraphRunState`].
67    pub state_json: String,
68    pub status: RunStatus,
69    /// Serialised `HashMap<String, u32>` tracking per-node attempt counts.
70    pub visits_json: String,
71    /// Serialised `Vec<BranchCursor>` describing the in-flight parallel
72    /// frontier, or `None` when the run is not inside a parallel region.
73    ///
74    /// `#[serde(default)]` + `skip_serializing_if` keep the wire format
75    /// backward-compatible: v1 records (written before this field existed)
76    /// deserialise with `frontier_json: None`, and records outside a parallel
77    /// region omit the field entirely.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub frontier_json: Option<String>,
80}
81
82// ---------------------------------------------------------------------------
83// CheckpointError
84// ---------------------------------------------------------------------------
85
86/// Errors produced by [`CheckpointStore`] implementations.
87#[derive(Debug, thiserror::Error)]
88pub enum CheckpointError {
89    #[error("checkpoint backend error: {0}")]
90    Backend(String),
91    #[error("checkpoint serialization error: {0}")]
92    Serde(#[from] serde_json::Error),
93}
94
95// ---------------------------------------------------------------------------
96// NodeVisitOutcome
97// ---------------------------------------------------------------------------
98
99/// Outcome of [`CheckpointStore::record_node_visit`].
100///
101/// - `Recorded`  — first write won; the result was stored.
102/// - `Replayed`  — a result for this `(run, node, attempt)` triple already
103///   existed; returns the original value so the executor can skip re-running.
104#[derive(Debug, Clone, PartialEq)]
105pub enum NodeVisitOutcome {
106    Recorded,
107    Replayed(serde_json::Value),
108}
109
110// ---------------------------------------------------------------------------
111// CheckpointStore trait
112// ---------------------------------------------------------------------------
113
114type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
115
116/// Durable storage for graph-run snapshots and per-node visit records.
117///
118/// Dyn-safe: every method returns `Pin<Box<dyn Future…>>` (same pattern as
119/// [`crate::state::AgentStateStore`]).
120pub trait CheckpointStore: Send + Sync {
121    /// Load the run record for `run_id`, if any.
122    fn load<'a>(
123        &'a self,
124        tenant: &'a TenantContext,
125        run_id: &'a str,
126    ) -> BoxFut<'a, Result<Option<GraphRunRecord>, CheckpointError>>;
127
128    /// Persist (create or overwrite) the run record.
129    fn save<'a>(
130        &'a self,
131        tenant: &'a TenantContext,
132        rec: &'a GraphRunRecord,
133    ) -> BoxFut<'a, Result<(), CheckpointError>>;
134
135    /// Insert-if-absent a node visit result, keyed by `(run_id, node_id, attempt)`.
136    ///
137    /// Returns [`NodeVisitOutcome::Recorded`] on first write,
138    /// [`NodeVisitOutcome::Replayed`] with the original value on subsequent
139    /// calls for the same key (enabling idempotent resume).
140    fn record_node_visit<'a>(
141        &'a self,
142        tenant: &'a TenantContext,
143        run_id: &'a str,
144        node_id: &'a str,
145        attempt: u32,
146        result: &'a serde_json::Value,
147    ) -> BoxFut<'a, Result<NodeVisitOutcome, CheckpointError>>;
148
149    /// Load a previously recorded node visit result, or `None` if absent.
150    fn load_node_visit<'a>(
151        &'a self,
152        tenant: &'a TenantContext,
153        run_id: &'a str,
154        node_id: &'a str,
155        attempt: u32,
156    ) -> BoxFut<'a, Result<Option<serde_json::Value>, CheckpointError>>;
157}
158
159// ---------------------------------------------------------------------------
160// Key helpers
161// ---------------------------------------------------------------------------
162
163/// Reject any key segment that contains `':'`.
164///
165/// See module-level doc for the key-segment contract.
166pub(crate) fn check_segment(name: &str, value: &str) -> Result<(), CheckpointError> {
167    if value.contains(':') {
168        Err(CheckpointError::Backend(format!(
169            "invalid key segment: {name} '{value}' must not contain ':'"
170        )))
171    } else {
172        Ok(())
173    }
174}
175
176fn run_key(tenant: &TenantContext, run_id: &str) -> String {
177    format!("{}:{}:{}", tenant.tenant_id, tenant.env_id, run_id)
178}
179
180fn visit_key(tenant: &TenantContext, run_id: &str, node_id: &str, attempt: u32) -> String {
181    format!(
182        "{}:{}:{}:{}:{}",
183        tenant.tenant_id, tenant.env_id, run_id, node_id, attempt
184    )
185}
186
187// ---------------------------------------------------------------------------
188// InMemoryCheckpointStore
189// ---------------------------------------------------------------------------
190
191/// In-memory [`CheckpointStore`] backed by `std::sync::Mutex`.
192///
193/// Intended for tests and the designer development swap. Not suitable for
194/// multi-process deployments — use the Redis implementation for production.
195///
196/// Lock scopes are kept as short as possible (acquire → clone/insert → drop).
197/// Poisoned locks are surfaced as [`CheckpointError::Backend`] rather than
198/// unwrapped.
199#[derive(Debug, Default)]
200pub struct InMemoryCheckpointStore {
201    runs: Mutex<HashMap<String, GraphRunRecord>>,
202    visits: Mutex<HashMap<String, serde_json::Value>>,
203}
204
205impl CheckpointStore for InMemoryCheckpointStore {
206    fn load<'a>(
207        &'a self,
208        tenant: &'a TenantContext,
209        run_id: &'a str,
210    ) -> BoxFut<'a, Result<Option<GraphRunRecord>, CheckpointError>> {
211        Box::pin(async move {
212            let key = run_key(tenant, run_id);
213            let guard = self
214                .runs
215                .lock()
216                .map_err(|e| CheckpointError::Backend(format!("lock poisoned: {e}")))?;
217            Ok(guard.get(&key).cloned())
218        })
219    }
220
221    fn save<'a>(
222        &'a self,
223        tenant: &'a TenantContext,
224        rec: &'a GraphRunRecord,
225    ) -> BoxFut<'a, Result<(), CheckpointError>> {
226        Box::pin(async move {
227            check_segment("run_id", &rec.run_id)?;
228            let key = run_key(tenant, &rec.run_id);
229            let mut guard = self
230                .runs
231                .lock()
232                .map_err(|e| CheckpointError::Backend(format!("lock poisoned: {e}")))?;
233            guard.insert(key, rec.clone());
234            Ok(())
235        })
236    }
237
238    fn record_node_visit<'a>(
239        &'a self,
240        tenant: &'a TenantContext,
241        run_id: &'a str,
242        node_id: &'a str,
243        attempt: u32,
244        result: &'a serde_json::Value,
245    ) -> BoxFut<'a, Result<NodeVisitOutcome, CheckpointError>> {
246        Box::pin(async move {
247            check_segment("run_id", run_id)?;
248            check_segment("node_id", node_id)?;
249            let key = visit_key(tenant, run_id, node_id, attempt);
250            let mut guard = self
251                .visits
252                .lock()
253                .map_err(|e| CheckpointError::Backend(format!("lock poisoned: {e}")))?;
254            if let Some(existing) = guard.get(&key) {
255                Ok(NodeVisitOutcome::Replayed(existing.clone()))
256            } else {
257                guard.insert(key, result.clone());
258                Ok(NodeVisitOutcome::Recorded)
259            }
260        })
261    }
262
263    fn load_node_visit<'a>(
264        &'a self,
265        tenant: &'a TenantContext,
266        run_id: &'a str,
267        node_id: &'a str,
268        attempt: u32,
269    ) -> BoxFut<'a, Result<Option<serde_json::Value>, CheckpointError>> {
270        Box::pin(async move {
271            let key = visit_key(tenant, run_id, node_id, attempt);
272            let guard = self
273                .visits
274                .lock()
275                .map_err(|e| CheckpointError::Backend(format!("lock poisoned: {e}")))?;
276            Ok(guard.get(&key).cloned())
277        })
278    }
279}
280
281// ---------------------------------------------------------------------------
282// Tests
283// ---------------------------------------------------------------------------
284
285#[cfg(test)]
286#[allow(clippy::unwrap_used, clippy::expect_used)]
287mod tests {
288    use super::*;
289
290    fn make_record(run_id: &str) -> GraphRunRecord {
291        GraphRunRecord {
292            run_id: run_id.into(),
293            graph_json: "{}".into(),
294            cursor: "agent".into(),
295            state_json: "{}".into(),
296            status: RunStatus::Running,
297            visits_json: "{}".into(),
298            frontier_json: None,
299        }
300    }
301
302    #[tokio::test]
303    async fn record_node_visit_is_insert_if_absent() {
304        let store = InMemoryCheckpointStore::default();
305        let t = TenantContext::new("t1", "dev");
306        let first = store
307            .record_node_visit(&t, "r1", "agent", 1, &serde_json::json!({"reply": "a"}))
308            .await
309            .unwrap();
310        assert_eq!(first, NodeVisitOutcome::Recorded);
311
312        let second = store
313            .record_node_visit(
314                &t,
315                "r1",
316                "agent",
317                1,
318                &serde_json::json!({"reply": "DIFFERENT"}),
319            )
320            .await
321            .unwrap();
322        assert_eq!(
323            second,
324            NodeVisitOutcome::Replayed(serde_json::json!({"reply": "a"}))
325        );
326    }
327
328    #[tokio::test]
329    async fn save_then_load_round_trips() {
330        let store = InMemoryCheckpointStore::default();
331        let t = TenantContext::new("t1", "dev");
332
333        assert!(store.load(&t, "r1").await.unwrap().is_none());
334
335        let rec = make_record("r1");
336        store.save(&t, &rec).await.unwrap();
337
338        let loaded = store.load(&t, "r1").await.unwrap().unwrap();
339        assert_eq!(loaded.cursor, "agent");
340        assert_eq!(loaded.status, RunStatus::Running);
341    }
342
343    #[tokio::test]
344    async fn tenants_are_isolated() {
345        let store = InMemoryCheckpointStore::default();
346        let t1 = TenantContext::new("t1", "dev");
347        let t2 = TenantContext::new("t2", "dev");
348
349        let rec = make_record("r1");
350        store.save(&t1, &rec).await.unwrap();
351
352        assert!(store.load(&t2, "r1").await.unwrap().is_none());
353    }
354
355    #[tokio::test]
356    async fn load_node_visit_absent_returns_none() {
357        let store = InMemoryCheckpointStore::default();
358        let t = TenantContext::new("t1", "dev");
359        let result = store.load_node_visit(&t, "r1", "agent", 1).await.unwrap();
360        assert!(result.is_none());
361    }
362
363    #[tokio::test]
364    async fn load_node_visit_after_record_returns_value() {
365        let store = InMemoryCheckpointStore::default();
366        let t = TenantContext::new("t1", "dev");
367        let val = serde_json::json!({"answer": 42});
368
369        store
370            .record_node_visit(&t, "r1", "node_a", 0, &val)
371            .await
372            .unwrap();
373
374        let loaded = store
375            .load_node_visit(&t, "r1", "node_a", 0)
376            .await
377            .unwrap()
378            .unwrap();
379        assert_eq!(loaded, val);
380    }
381
382    #[tokio::test]
383    async fn node_visits_are_attempt_scoped() {
384        let store = InMemoryCheckpointStore::default();
385        let t = TenantContext::new("t1", "dev");
386
387        store
388            .record_node_visit(&t, "r1", "agent", 0, &serde_json::json!({"v": 0}))
389            .await
390            .unwrap();
391        store
392            .record_node_visit(&t, "r1", "agent", 1, &serde_json::json!({"v": 1}))
393            .await
394            .unwrap();
395
396        let v0 = store
397            .load_node_visit(&t, "r1", "agent", 0)
398            .await
399            .unwrap()
400            .unwrap();
401        let v1 = store
402            .load_node_visit(&t, "r1", "agent", 1)
403            .await
404            .unwrap()
405            .unwrap();
406        assert_eq!(v0["v"], 0);
407        assert_eq!(v1["v"], 1);
408    }
409
410    #[tokio::test]
411    async fn save_overwrites_existing_run() {
412        let store = InMemoryCheckpointStore::default();
413        let t = TenantContext::new("t1", "dev");
414
415        let rec1 = make_record("r1");
416        store.save(&t, &rec1).await.unwrap();
417
418        let rec2 = GraphRunRecord {
419            run_id: "r1".into(),
420            graph_json: "{}".into(),
421            cursor: "router".into(),
422            state_json: "{}".into(),
423            status: RunStatus::Succeeded,
424            visits_json: "{}".into(),
425            frontier_json: None,
426        };
427        store.save(&t, &rec2).await.unwrap();
428
429        let loaded = store.load(&t, "r1").await.unwrap().unwrap();
430        assert_eq!(loaded.cursor, "router");
431        assert_eq!(loaded.status, RunStatus::Succeeded);
432    }
433
434    #[tokio::test]
435    async fn run_status_serialises_lowercase() {
436        let json = serde_json::to_string(&RunStatus::Succeeded).unwrap();
437        assert_eq!(json, r#""succeeded""#);
438
439        let back: RunStatus = serde_json::from_str(&json).unwrap();
440        assert_eq!(back, RunStatus::Succeeded);
441    }
442
443    #[test]
444    fn awaiting_input_status_roundtrips() {
445        let j = serde_json::to_string(&RunStatus::AwaitingInput).unwrap();
446        assert_eq!(j, r#""awaitinginput""#);
447        assert_eq!(
448            serde_json::from_str::<RunStatus>(&j).unwrap(),
449            RunStatus::AwaitingInput
450        );
451    }
452
453    #[tokio::test]
454    async fn save_rejects_colon_in_run_id() {
455        let store = InMemoryCheckpointStore::default();
456        let t = TenantContext::new("t1", "dev");
457        let rec = make_record("a:b");
458        let err = store.save(&t, &rec).await.unwrap_err();
459        assert!(
460            matches!(err, CheckpointError::Backend(ref msg) if msg.contains("run_id")),
461            "expected Backend error mentioning run_id, got {err:?}"
462        );
463    }
464
465    #[tokio::test]
466    async fn v1_record_without_frontier_field_deserialises_to_none() {
467        // A v1-shaped record JSON predates `frontier_json`; serde(default)
468        // must fill it with None so in-flight v1 runs resume unchanged.
469        let json = r#"{
470            "run_id": "r1",
471            "graph_json": "{}",
472            "cursor": "agent",
473            "state_json": "{}",
474            "status": "running",
475            "visits_json": "{}"
476        }"#;
477        let rec: GraphRunRecord = serde_json::from_str(json).unwrap();
478        assert_eq!(rec.run_id, "r1");
479        assert_eq!(rec.frontier_json, None);
480    }
481
482    #[tokio::test]
483    async fn frontier_json_none_is_omitted_from_wire_format() {
484        let rec = make_record("r1");
485        let json = serde_json::to_string(&rec).unwrap();
486        assert!(
487            !json.contains("frontier_json"),
488            "None frontier must be skipped on the wire: {json}"
489        );
490    }
491
492    #[tokio::test]
493    async fn frontier_json_some_round_trips() {
494        let mut rec = make_record("r1");
495        rec.frontier_json = Some(r#"[{"branch":"a"}]"#.into());
496        let json = serde_json::to_string(&rec).unwrap();
497        assert!(json.contains("frontier_json"), "json: {json}");
498        let back: GraphRunRecord = serde_json::from_str(&json).unwrap();
499        assert_eq!(back.frontier_json.as_deref(), Some(r#"[{"branch":"a"}]"#));
500    }
501
502    #[tokio::test]
503    async fn record_node_visit_rejects_colon_in_node_id() {
504        let store = InMemoryCheckpointStore::default();
505        let t = TenantContext::new("t1", "dev");
506        let err = store
507            .record_node_visit(&t, "r1", "x:y", 0, &serde_json::json!({}))
508            .await
509            .unwrap_err();
510        assert!(
511            matches!(err, CheckpointError::Backend(ref msg) if msg.contains("node_id")),
512            "expected Backend error mentioning node_id, got {err:?}"
513        );
514    }
515}