1use std::collections::HashMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::sync::Mutex;
32
33use crate::tenant::TenantContext;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum RunStatus {
43 Running,
44 AwaitingInput,
47 Succeeded,
48 Failed,
49}
50
51#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
60pub struct GraphRunRecord {
61 pub run_id: String,
62 pub graph_json: String,
64 pub cursor: String,
66 pub state_json: String,
68 pub status: RunStatus,
69 pub visits_json: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub frontier_json: Option<String>,
80}
81
82#[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#[derive(Debug, Clone, PartialEq)]
105pub enum NodeVisitOutcome {
106 Recorded,
107 Replayed(serde_json::Value),
108}
109
110type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
115
116pub trait CheckpointStore: Send + Sync {
121 fn load<'a>(
123 &'a self,
124 tenant: &'a TenantContext,
125 run_id: &'a str,
126 ) -> BoxFut<'a, Result<Option<GraphRunRecord>, CheckpointError>>;
127
128 fn save<'a>(
130 &'a self,
131 tenant: &'a TenantContext,
132 rec: &'a GraphRunRecord,
133 ) -> BoxFut<'a, Result<(), CheckpointError>>;
134
135 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 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
159pub(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#[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#[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 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}