1use bytes::Bytes;
9use klieo_core::{BusError, KvStore};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::sync::Arc;
13
14pub const RUNS_BUCKET: &str = "workflow_runs";
16
17const IDEM_PREFIX: &str = "idem-";
18const RUN_PREFIX: &str = "run-";
19const ACTIVE_PREFIX: &str = "active-";
24
25#[non_exhaustive]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum RunStatus {
30 Pending,
32 Running,
34 Succeeded,
36 Failed,
38 Aborted,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct RunRecordView {
45 pub run_id: String,
47 pub workflow_id: String,
49 pub status: RunStatus,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub result: Option<Value>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub error: Option<String>,
57 pub author: String,
59 pub created_at: String,
61}
62
63impl RunRecordView {
64 pub fn pending(
66 run_id: impl Into<String>,
67 workflow_id: impl Into<String>,
68 author: impl Into<String>,
69 created_at: impl Into<String>,
70 ) -> Self {
71 Self {
72 run_id: run_id.into(),
73 workflow_id: workflow_id.into(),
74 status: RunStatus::Pending,
75 result: None,
76 error: None,
77 author: author.into(),
78 created_at: created_at.into(),
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ClaimOutcome {
86 Created,
88 AlreadyExists(String),
90}
91
92#[non_exhaustive]
94#[derive(Debug, thiserror::Error)]
95pub enum StoreError {
96 #[error("kv store error: {0}")]
98 Kv(#[from] BusError),
99 #[error("run record serialisation error: {0}")]
101 Serde(#[from] serde_json::Error),
102 #[error("run record not found: {0}")]
104 NotFound(String),
105}
106
107#[derive(Clone)]
109pub struct RunStore {
110 kv: Arc<dyn KvStore>,
111}
112
113impl RunStore {
114 pub fn new(kv: Arc<dyn KvStore>) -> Self {
116 Self { kv }
117 }
118
119 pub async fn claim_idempotent(
124 &self,
125 idempotency_key: &str,
126 run_id: &str,
127 ) -> Result<ClaimOutcome, StoreError> {
128 let key = format!("{IDEM_PREFIX}{idempotency_key}");
129 let value = Bytes::from(run_id.to_string());
130 match self.kv.cas(RUNS_BUCKET, &key, value, None).await {
131 Ok(_) => Ok(ClaimOutcome::Created),
132 Err(BusError::CasConflict { .. }) => {
133 let winner = self
134 .kv
135 .get(RUNS_BUCKET, &key)
136 .await?
137 .map(|e| String::from_utf8_lossy(&e.value).into_owned())
138 .ok_or_else(|| StoreError::NotFound(key.clone()))?;
139 Ok(ClaimOutcome::AlreadyExists(winner))
140 }
141 Err(other) => Err(StoreError::Kv(other)),
142 }
143 }
144
145 pub async fn create(&self, view: &RunRecordView) -> Result<(), StoreError> {
153 self.put_active_marker(&view.run_id).await?;
154 self.put_view(view).await
155 }
156
157 pub async fn rollback_claim(
162 &self,
163 idempotency_key: &str,
164 run_id: &str,
165 ) -> Result<(), StoreError> {
166 self.kv
167 .delete(RUNS_BUCKET, &format!("{IDEM_PREFIX}{idempotency_key}"))
168 .await?;
169 self.kv
170 .delete(RUNS_BUCKET, &format!("{RUN_PREFIX}{run_id}"))
171 .await?;
172 self.delete_active_marker(run_id).await
173 }
174
175 pub async fn set_running(&self, run_id: &str) -> Result<(), StoreError> {
177 self.update(run_id, |v| v.status = RunStatus::Running).await
178 }
179
180 pub async fn set_succeeded(&self, run_id: &str, result: Value) -> Result<(), StoreError> {
183 self.update(run_id, move |v| {
184 v.status = RunStatus::Succeeded;
185 v.result = Some(result);
186 })
187 .await?;
188 self.delete_active_marker(run_id).await
189 }
190
191 pub async fn set_failed(&self, run_id: &str, error: String) -> Result<(), StoreError> {
193 self.update(run_id, move |v| {
194 v.status = RunStatus::Failed;
195 v.error = Some(error);
196 })
197 .await?;
198 self.delete_active_marker(run_id).await
199 }
200
201 pub async fn set_aborted(&self, run_id: &str, error: String) -> Result<(), StoreError> {
204 self.update(run_id, move |v| {
205 v.status = RunStatus::Aborted;
206 v.error = Some(error);
207 })
208 .await?;
209 self.delete_active_marker(run_id).await
210 }
211
212 pub async fn get(&self, run_id: &str) -> Result<Option<RunRecordView>, StoreError> {
214 let key = format!("{RUN_PREFIX}{run_id}");
215 match self.kv.get(RUNS_BUCKET, &key).await? {
216 Some(entry) => Ok(Some(serde_json::from_slice(&entry.value)?)),
217 None => Ok(None),
218 }
219 }
220
221 pub(crate) async fn list_active(&self) -> Result<Vec<RunRecordView>, StoreError> {
237 let keys = match self.kv.keys(RUNS_BUCKET).await {
238 Ok(keys) => keys,
239 Err(BusError::Unsupported(_)) => return Ok(Vec::new()),
240 Err(other) => return Err(StoreError::Kv(other)),
241 };
242 let mut runs = Vec::new();
243 for key in keys.iter().filter(|k| k.starts_with(ACTIVE_PREFIX)) {
244 let run_id = &key[ACTIVE_PREFIX.len()..];
245 if let Some(view) = self.get(run_id).await? {
246 runs.push(view);
247 }
248 }
249 Ok(runs)
250 }
251
252 async fn update(
261 &self,
262 run_id: &str,
263 mutate: impl FnOnce(&mut RunRecordView),
264 ) -> Result<(), StoreError> {
265 let mut view = self
266 .get(run_id)
267 .await?
268 .ok_or_else(|| StoreError::NotFound(run_id.to_string()))?;
269 mutate(&mut view);
270 self.put_view(&view).await
271 }
272
273 async fn put_view(&self, view: &RunRecordView) -> Result<(), StoreError> {
274 let key = format!("{RUN_PREFIX}{}", view.run_id);
275 let bytes = Bytes::from(serde_json::to_vec(view)?);
276 self.kv.put(RUNS_BUCKET, &key, bytes).await?;
277 Ok(())
278 }
279
280 async fn put_active_marker(&self, run_id: &str) -> Result<(), StoreError> {
281 let key = format!("{ACTIVE_PREFIX}{run_id}");
282 self.kv.put(RUNS_BUCKET, &key, Bytes::new()).await?;
283 Ok(())
284 }
285
286 async fn delete_active_marker(&self, run_id: &str) -> Result<(), StoreError> {
287 self.kv
288 .delete(RUNS_BUCKET, &format!("{ACTIVE_PREFIX}{run_id}"))
289 .await?;
290 Ok(())
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use klieo_bus_memory::MemoryBus;
298
299 fn store() -> RunStore {
300 RunStore::new(MemoryBus::new().kv)
301 }
302
303 #[tokio::test]
304 async fn claim_returns_created_once_then_already_exists() {
305 let store = store();
306 assert_eq!(
307 store.claim_idempotent("k", "run-1").await.unwrap(),
308 ClaimOutcome::Created
309 );
310 assert_eq!(
311 store.claim_idempotent("k", "run-2").await.unwrap(),
312 ClaimOutcome::AlreadyExists("run-1".to_string()),
313 );
314 }
315
316 #[tokio::test]
317 async fn status_transitions_persist() {
318 let store = store();
319 store
320 .create(&RunRecordView::pending(
321 "run-1",
322 "wf-1",
323 "alice",
324 "2026-07-13T00:00:00Z",
325 ))
326 .await
327 .unwrap();
328 store.set_running("run-1").await.unwrap();
329 assert_eq!(
330 store.get("run-1").await.unwrap().unwrap().status,
331 RunStatus::Running
332 );
333 store
334 .set_succeeded("run-1", serde_json::json!({"ok": true}))
335 .await
336 .unwrap();
337 let view = store.get("run-1").await.unwrap().unwrap();
338 assert_eq!(view.status, RunStatus::Succeeded);
339 assert_eq!(view.result, Some(serde_json::json!({"ok": true})));
340 assert_eq!(view.author, "alice");
341 }
342
343 #[tokio::test]
344 async fn missing_run_reads_none() {
345 assert!(store().get("nope").await.unwrap().is_none());
346 }
347
348 #[tokio::test]
349 async fn transition_on_missing_run_errors() {
350 let err = store().set_running("ghost").await.unwrap_err();
351 assert!(matches!(err, StoreError::NotFound(_)));
352 }
353
354 #[tokio::test]
358 async fn list_active_maps_unsupported_keys_to_empty() {
359 use async_trait::async_trait;
360 use klieo_core::bus::{KvEntry, Lease, Revision};
361 use std::time::Duration;
362
363 struct NoKeysKv;
364 #[async_trait]
365 impl KvStore for NoKeysKv {
366 async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
367 unreachable!("list_active() must not read values when keys() is unsupported")
368 }
369 async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
370 unreachable!()
371 }
372 async fn cas(
373 &self,
374 _: &str,
375 _: &str,
376 _: Bytes,
377 _: Option<Revision>,
378 ) -> Result<Revision, BusError> {
379 unreachable!()
380 }
381 async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
382 unreachable!()
383 }
384 async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
385 unreachable!()
386 }
387 }
389
390 let store = RunStore::new(std::sync::Arc::new(NoKeysKv));
391 assert!(store.list_active().await.unwrap().is_empty());
392 }
393
394 #[tokio::test]
395 async fn create_writes_active_marker() {
396 let store = store();
397 store
398 .create(&RunRecordView::pending(
399 "run-1",
400 "wf-1",
401 "alice",
402 "2026-07-13T00:00:00Z",
403 ))
404 .await
405 .unwrap();
406 let active = store.list_active().await.unwrap();
407 assert_eq!(active.len(), 1);
408 assert_eq!(active[0].run_id, "run-1");
409 }
410
411 #[tokio::test]
412 async fn set_succeeded_deletes_active_marker() {
413 let store = store();
414 store
415 .create(&RunRecordView::pending(
416 "run-1",
417 "wf-1",
418 "alice",
419 "2026-07-13T00:00:00Z",
420 ))
421 .await
422 .unwrap();
423 store
424 .set_succeeded("run-1", serde_json::json!({"ok": true}))
425 .await
426 .unwrap();
427 assert!(store.list_active().await.unwrap().is_empty());
428 assert_eq!(
430 store.get("run-1").await.unwrap().unwrap().status,
431 RunStatus::Succeeded
432 );
433 }
434
435 #[tokio::test]
436 async fn set_failed_deletes_active_marker() {
437 let store = store();
438 store
439 .create(&RunRecordView::pending(
440 "run-1",
441 "wf-1",
442 "alice",
443 "2026-07-13T00:00:00Z",
444 ))
445 .await
446 .unwrap();
447 store.set_failed("run-1", "boom".into()).await.unwrap();
448 assert!(store.list_active().await.unwrap().is_empty());
449 }
450
451 #[tokio::test]
452 async fn set_aborted_deletes_active_marker() {
453 let store = store();
454 store
455 .create(&RunRecordView::pending(
456 "run-1",
457 "wf-1",
458 "alice",
459 "2026-07-13T00:00:00Z",
460 ))
461 .await
462 .unwrap();
463 store
464 .set_aborted("run-1", "timed out".into())
465 .await
466 .unwrap();
467 assert!(store.list_active().await.unwrap().is_empty());
468 }
469
470 #[tokio::test]
471 async fn rollback_claim_deletes_active_marker() {
472 let store = store();
473 store
474 .create(&RunRecordView::pending(
475 "run-1",
476 "wf-1",
477 "alice",
478 "2026-07-13T00:00:00Z",
479 ))
480 .await
481 .unwrap();
482 store.rollback_claim("idem-key", "run-1").await.unwrap();
483 assert!(store.list_active().await.unwrap().is_empty());
484 assert!(store.get("run-1").await.unwrap().is_none());
485 }
486
487 #[tokio::test]
488 async fn list_active_excludes_terminal_runs() {
489 let store = store();
490 store
491 .create(&RunRecordView::pending(
492 "done",
493 "wf-1",
494 "alice",
495 "2026-07-13T00:00:00Z",
496 ))
497 .await
498 .unwrap();
499 store
500 .set_succeeded("done", serde_json::json!({}))
501 .await
502 .unwrap();
503 store
504 .create(&RunRecordView::pending(
505 "live",
506 "wf-1",
507 "alice",
508 "2026-07-13T00:00:00Z",
509 ))
510 .await
511 .unwrap();
512
513 let active = store.list_active().await.unwrap();
514 assert_eq!(active.len(), 1);
515 assert_eq!(active[0].run_id, "live");
516 }
517
518 #[tokio::test]
521 async fn list_active_tolerates_marker_without_record() {
522 let store = store();
523 store.put_active_marker("ghost").await.unwrap();
524 assert!(store.list_active().await.unwrap().is_empty());
525 }
526}