1use super::{
39 DegradationEntry, RunId, RunRecord, RunStatus, RunStore, RunStoreError, StepEntry, TaskId,
40};
41use async_trait::async_trait;
42use rusqlite::{params, OptionalExtension};
43use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
44use std::path::Path;
45
46const SCHEMA_SQL: &str = "\
47CREATE TABLE IF NOT EXISTS runs (\
48 id TEXT PRIMARY KEY, \
49 task_id TEXT NOT NULL, \
50 status TEXT NOT NULL, \
51 step_entries_json TEXT NOT NULL, \
52 degradations_json TEXT NOT NULL DEFAULT '[]', \
53 operator_sid TEXT, \
54 result_ref_json TEXT, \
55 input_json TEXT, \
56 created_at INTEGER NOT NULL, \
57 updated_at INTEGER NOT NULL\
58);\
59CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);\
60";
61
62fn migrate_add_column_if_missing(
68 conn: &rusqlite::Connection,
69 column: &str,
70 decl: &str,
71) -> rusqlite::Result<()> {
72 let mut stmt = conn.prepare("PRAGMA table_info(runs)")?;
73 let has_column = stmt
74 .query_map([], |row| row.get::<_, String>(1))?
75 .collect::<Result<Vec<String>, _>>()?
76 .iter()
77 .any(|name| name == column);
78 if !has_column {
79 conn.execute_batch(&format!("ALTER TABLE runs ADD COLUMN {column} {decl};"))?;
80 }
81 Ok(())
82}
83
84pub struct SqliteRunStore {
92 isle: AsyncIsle,
93}
94
95impl SqliteRunStore {
96 pub async fn open(path: impl AsRef<Path>) -> Result<(Self, AsyncIsleDriver), RunStoreError> {
99 let (isle, driver) = AsyncIsle::spawn(path.as_ref().to_path_buf(), |conn| {
100 conn.execute_batch(SCHEMA_SQL)?;
101 migrate_add_column_if_missing(conn, "degradations_json", "TEXT NOT NULL DEFAULT '[]'")?;
102 migrate_add_column_if_missing(conn, "input_json", "TEXT")
103 })
104 .await
105 .map_err(map_isle_err)?;
106 Ok((Self { isle }, driver))
107 }
108
109 pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), RunStoreError> {
111 let (isle, driver) = AsyncIsle::open_in_memory(|conn| {
112 conn.execute_batch(SCHEMA_SQL)?;
113 migrate_add_column_if_missing(conn, "degradations_json", "TEXT NOT NULL DEFAULT '[]'")?;
114 migrate_add_column_if_missing(conn, "input_json", "TEXT")
115 })
116 .await
117 .map_err(map_isle_err)?;
118 Ok((Self { isle }, driver))
119 }
120}
121
122fn map_isle_err(e: IsleError) -> RunStoreError {
123 RunStoreError::Other(format!("sqlite: {e}"))
124}
125
126type RunRow = (
130 String,
131 String,
132 String,
133 String,
134 String,
135 Option<String>,
136 Option<String>,
137 Option<String>,
138 i64,
139 i64,
140);
141
142const RUN_SELECT_COLUMNS: &str = "id, task_id, status, step_entries_json, degradations_json, \
143 operator_sid, result_ref_json, input_json, created_at, updated_at";
144
145fn row_to_record(row: RunRow) -> Result<RunRecord, RunStoreError> {
146 let (
147 id,
148 task_id,
149 status_json,
150 step_entries_json,
151 degradations_json,
152 operator_sid,
153 result_ref_json,
154 input_json,
155 created_at,
156 updated_at,
157 ) = row;
158 let status: RunStatus = serde_json::from_str(&status_json)
159 .map_err(|e| RunStoreError::Other(format!("decode status: {e}")))?;
160 let step_entries: Vec<StepEntry> = serde_json::from_str(&step_entries_json)
161 .map_err(|e| RunStoreError::Other(format!("decode step_entries: {e}")))?;
162 let degradations: Vec<DegradationEntry> = serde_json::from_str(°radations_json)
163 .map_err(|e| RunStoreError::Other(format!("decode degradations: {e}")))?;
164 let result_ref: Option<serde_json::Value> = match result_ref_json {
165 Some(text) => Some(
166 serde_json::from_str(&text)
167 .map_err(|e| RunStoreError::Other(format!("decode result_ref: {e}")))?,
168 ),
169 None => None,
170 };
171 let id = RunId::parse(id).map_err(|e| RunStoreError::Other(format!("decode id: {e}")))?;
175 let task_id =
176 TaskId::parse(task_id).map_err(|e| RunStoreError::Other(format!("decode task_id: {e}")))?;
177 Ok(RunRecord {
178 id,
179 task_id,
180 status,
181 step_entries,
182 degradations,
183 operator_sid,
184 result_ref,
185 input_json,
186 created_at: created_at as u64,
187 updated_at: updated_at as u64,
188 })
189}
190
191#[async_trait]
192impl RunStore for SqliteRunStore {
193 fn name(&self) -> &str {
194 "sqlite"
195 }
196
197 async fn create(&self, record: RunRecord) -> Result<(), RunStoreError> {
198 let id = record.id.to_string();
199 let id_for_conflict = record.id.clone();
200 let task_id = record.task_id.to_string();
201 let status_json = serde_json::to_string(&record.status)
202 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
203 let step_entries_json = serde_json::to_string(&record.step_entries)
204 .map_err(|e| RunStoreError::Other(format!("encode step_entries: {e}")))?;
205 let degradations_json = serde_json::to_string(&record.degradations)
206 .map_err(|e| RunStoreError::Other(format!("encode degradations: {e}")))?;
207 let operator_sid = record.operator_sid.clone();
208 let result_ref_json = record
209 .result_ref
210 .as_ref()
211 .map(serde_json::to_string)
212 .transpose()
213 .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
214 let input_json = record.input_json.clone();
215 let created_at = record.created_at as i64;
216 let updated_at = record.updated_at as i64;
217
218 self.isle
219 .call(move |conn| {
220 let tx = conn.transaction()?;
221 let exists: i64 = tx.query_row(
222 "SELECT COUNT(*) FROM runs WHERE id = ?1",
223 params![id],
224 |row| row.get(0),
225 )?;
226 if exists > 0 {
227 return Err(rusqlite::Error::SqliteFailure(
228 rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
229 Some(format!("__mlua_swarm_duplicate:{id}")),
230 ));
231 }
232 tx.execute(
233 "INSERT INTO runs (id, task_id, status, step_entries_json, \
234 degradations_json, operator_sid, result_ref_json, input_json, \
235 created_at, updated_at) \
236 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
237 params![
238 id,
239 task_id,
240 status_json,
241 step_entries_json,
242 degradations_json,
243 operator_sid,
244 result_ref_json,
245 input_json,
246 created_at,
247 updated_at,
248 ],
249 )?;
250 tx.commit()?;
251 Ok(())
252 })
253 .await
254 .map_err(|e| match &e {
255 IsleError::Sqlite(rusqlite::Error::SqliteFailure(_, Some(msg)))
256 if msg.starts_with("__mlua_swarm_duplicate:") =>
257 {
258 RunStoreError::Duplicate(id_for_conflict.clone())
259 }
260 _ => map_isle_err(e),
261 })
262 }
263
264 async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError> {
265 let id_str = id.to_string();
266 let id_for_notfound = id.clone();
267 let row = self
268 .isle
269 .call(move |conn| {
270 conn.query_row(
271 &format!("SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE id = ?1"),
272 params![id_str],
273 |row| {
274 Ok((
275 row.get::<_, String>(0)?,
276 row.get::<_, String>(1)?,
277 row.get::<_, String>(2)?,
278 row.get::<_, String>(3)?,
279 row.get::<_, String>(4)?,
280 row.get::<_, Option<String>>(5)?,
281 row.get::<_, Option<String>>(6)?,
282 row.get::<_, Option<String>>(7)?,
283 row.get::<_, i64>(8)?,
284 row.get::<_, i64>(9)?,
285 ))
286 },
287 )
288 .optional()
289 })
290 .await
291 .map_err(map_isle_err)?;
292 match row {
293 Some(row) => row_to_record(row),
294 None => Err(RunStoreError::NotFound(id_for_notfound)),
295 }
296 }
297
298 async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError> {
299 let task_id_str = task_id.to_string();
300 let rows = self
301 .isle
302 .call(move |conn| {
303 let mut stmt = conn.prepare(&format!(
304 "SELECT {RUN_SELECT_COLUMNS} FROM runs \
305 WHERE task_id = ?1 ORDER BY created_at ASC"
306 ))?;
307 let iter = stmt.query_map(params![task_id_str], |row| {
308 Ok((
309 row.get::<_, String>(0)?,
310 row.get::<_, String>(1)?,
311 row.get::<_, String>(2)?,
312 row.get::<_, String>(3)?,
313 row.get::<_, String>(4)?,
314 row.get::<_, Option<String>>(5)?,
315 row.get::<_, Option<String>>(6)?,
316 row.get::<_, Option<String>>(7)?,
317 row.get::<_, i64>(8)?,
318 row.get::<_, i64>(9)?,
319 ))
320 })?;
321 let mut out = Vec::new();
322 for r in iter {
323 out.push(r?);
324 }
325 Ok(out)
326 })
327 .await
328 .map_err(map_isle_err)?;
329 rows.into_iter().map(row_to_record).collect()
330 }
331
332 async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError> {
333 let id_str = id.to_string();
334 let id_for_notfound = id.clone();
335 let updated_at = crate::types::now_unix() as i64;
336
337 let updated = self
338 .isle
339 .call(move |conn| {
340 let tx = conn.transaction()?;
341 let existing: Option<String> = tx
342 .query_row(
343 "SELECT step_entries_json FROM runs WHERE id = ?1",
344 params![id_str],
345 |row| row.get(0),
346 )
347 .optional()?;
348 let Some(existing_json) = existing else {
349 return Ok(false);
350 };
351 let mut entries: Vec<StepEntry> = serde_json::from_str(&existing_json)
352 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
353 entries.push(entry);
354 let new_json = serde_json::to_string(&entries)
355 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
356 tx.execute(
357 "UPDATE runs SET step_entries_json = ?1, updated_at = ?2 WHERE id = ?3",
358 params![new_json, updated_at, id_str],
359 )?;
360 tx.commit()?;
361 Ok(true)
362 })
363 .await
364 .map_err(map_isle_err)?;
365
366 if updated {
367 Ok(())
368 } else {
369 Err(RunStoreError::NotFound(id_for_notfound))
370 }
371 }
372
373 async fn append_degradation(
374 &self,
375 id: &RunId,
376 entry: DegradationEntry,
377 ) -> Result<(), RunStoreError> {
378 let id_str = id.to_string();
379 let id_for_notfound = id.clone();
380 let updated_at = crate::types::now_unix() as i64;
381
382 let updated = self
383 .isle
384 .call(move |conn| {
385 let tx = conn.transaction()?;
386 let existing: Option<String> = tx
387 .query_row(
388 "SELECT degradations_json FROM runs WHERE id = ?1",
389 params![id_str],
390 |row| row.get(0),
391 )
392 .optional()?;
393 let Some(existing_json) = existing else {
394 return Ok(false);
395 };
396 let mut entries: Vec<DegradationEntry> = serde_json::from_str(&existing_json)
397 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
398 entries.push(entry);
399 let new_json = serde_json::to_string(&entries)
400 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
401 tx.execute(
402 "UPDATE runs SET degradations_json = ?1, updated_at = ?2 WHERE id = ?3",
403 params![new_json, updated_at, id_str],
404 )?;
405 tx.commit()?;
406 Ok(true)
407 })
408 .await
409 .map_err(map_isle_err)?;
410
411 if updated {
412 Ok(())
413 } else {
414 Err(RunStoreError::NotFound(id_for_notfound))
415 }
416 }
417
418 async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError> {
419 let id_str = id.to_string();
420 let id_for_notfound = id.clone();
421 let status_json = serde_json::to_string(&status)
422 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
423 let updated_at = crate::types::now_unix() as i64;
424 let n = self
425 .isle
426 .call(move |conn| {
427 conn.execute(
428 "UPDATE runs SET status = ?1, updated_at = ?2 WHERE id = ?3",
429 params![status_json, updated_at, id_str],
430 )
431 })
432 .await
433 .map_err(map_isle_err)?;
434 if n == 0 {
435 Err(RunStoreError::NotFound(id_for_notfound))
436 } else {
437 Ok(())
438 }
439 }
440
441 async fn try_transition(
442 &self,
443 id: &RunId,
444 from: RunStatus,
445 to: RunStatus,
446 ) -> Result<bool, RunStoreError> {
447 let id_str = id.to_string();
448 let from_json = serde_json::to_string(&from)
449 .map_err(|e| RunStoreError::Other(format!("encode from status: {e}")))?;
450 let to_json = serde_json::to_string(&to)
451 .map_err(|e| RunStoreError::Other(format!("encode to status: {e}")))?;
452 let updated_at = crate::types::now_unix() as i64;
453 let n = self
459 .isle
460 .call(move |conn| {
461 conn.execute(
462 "UPDATE runs SET status = ?1, updated_at = ?2 WHERE id = ?3 AND status = ?4",
463 params![to_json, updated_at, id_str, from_json],
464 )
465 })
466 .await
467 .map_err(map_isle_err)?;
468 Ok(n == 1)
469 }
470
471 async fn set_result(
472 &self,
473 id: &RunId,
474 result_ref: serde_json::Value,
475 ) -> Result<(), RunStoreError> {
476 let id_str = id.to_string();
477 let id_for_notfound = id.clone();
478 let result_ref_json = serde_json::to_string(&result_ref)
479 .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
480 let updated_at = crate::types::now_unix() as i64;
481 let n = self
482 .isle
483 .call(move |conn| {
484 conn.execute(
485 "UPDATE runs SET result_ref_json = ?1, updated_at = ?2 WHERE id = ?3",
486 params![result_ref_json, updated_at, id_str],
487 )
488 })
489 .await
490 .map_err(map_isle_err)?;
491 if n == 0 {
492 Err(RunStoreError::NotFound(id_for_notfound))
493 } else {
494 Ok(())
495 }
496 }
497
498 async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError> {
499 let status_json = serde_json::to_string(&RunStatus::Running)
500 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
501 let rows = self
502 .isle
503 .call(move |conn| {
504 let mut stmt = conn.prepare(&format!(
505 "SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE status = ?1"
506 ))?;
507 let iter = stmt.query_map(params![status_json], |row| {
508 Ok((
509 row.get::<_, String>(0)?,
510 row.get::<_, String>(1)?,
511 row.get::<_, String>(2)?,
512 row.get::<_, String>(3)?,
513 row.get::<_, String>(4)?,
514 row.get::<_, Option<String>>(5)?,
515 row.get::<_, Option<String>>(6)?,
516 row.get::<_, Option<String>>(7)?,
517 row.get::<_, i64>(8)?,
518 row.get::<_, i64>(9)?,
519 ))
520 })?;
521 let mut out = Vec::new();
522 for r in iter {
523 out.push(r?);
524 }
525 Ok(out)
526 })
527 .await
528 .map_err(map_isle_err)?;
529 rows.into_iter().map(row_to_record).collect()
530 }
531}
532
533#[cfg(test)]
538mod tests {
539 use super::*;
540 use serde_json::json;
541
542 fn mk(id: &str, task_id: &str, created_at: u64) -> RunRecord {
543 RunRecord {
544 id: RunId::parse(id).unwrap(),
545 task_id: TaskId::parse(task_id).unwrap(),
546 status: RunStatus::Pending,
547 step_entries: vec![],
548 degradations: vec![],
549 operator_sid: None,
550 result_ref: None,
551 input_json: None,
552 created_at,
553 updated_at: created_at,
554 }
555 }
556
557 fn mk_degradation(tool: &str, at: u64) -> DegradationEntry {
558 DegradationEntry {
559 tool: tool.to_string(),
560 error: "boom".to_string(),
561 fallback: "cached-default".to_string(),
562 note: None,
563 step_ref: Some("worker".to_string()),
564 attempt: Some(1),
565 at,
566 }
567 }
568
569 #[tokio::test]
570 async fn create_then_get() {
571 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
572 s.create(mk("R-1", "T-1", 100)).await.unwrap();
573 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
574 assert_eq!(got.task_id, TaskId::parse("T-1").unwrap());
575 assert_eq!(got.status, RunStatus::Pending);
576 assert!(got.step_entries.is_empty());
577 assert_eq!(got.result_ref, None);
578 drop(s);
579 driver.shutdown().await.unwrap();
580 }
581
582 #[tokio::test]
583 async fn duplicate_create_rejected() {
584 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
585 s.create(mk("R-1", "T-1", 100)).await.unwrap();
586 let err = s.create(mk("R-1", "T-1", 200)).await.unwrap_err();
587 assert!(matches!(err, RunStoreError::Duplicate(_)), "got: {err:?}");
588 drop(s);
589 driver.shutdown().await.unwrap();
590 }
591
592 #[tokio::test]
593 async fn get_missing_returns_not_found() {
594 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
595 let err = s.get(&RunId::parse("R-nope").unwrap()).await.unwrap_err();
596 assert!(matches!(err, RunStoreError::NotFound(_)));
597 drop(s);
598 driver.shutdown().await.unwrap();
599 }
600
601 #[tokio::test]
602 async fn list_by_task_filters_and_orders_ascending() {
603 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
604 s.create(mk("R-1", "T-1", 300)).await.unwrap();
605 s.create(mk("R-2", "T-2", 50)).await.unwrap();
606 s.create(mk("R-3", "T-1", 100)).await.unwrap();
607 let list = s
608 .list_by_task(&TaskId::parse("T-1").unwrap())
609 .await
610 .unwrap();
611 let ids: Vec<_> = list.iter().map(|r| r.id.to_string()).collect();
612 assert_eq!(ids, vec!["R-3", "R-1"]);
613 drop(s);
614 driver.shutdown().await.unwrap();
615 }
616
617 #[tokio::test]
618 async fn append_step_entry_accumulates_in_order() {
619 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
620 s.create(mk("R-1", "T-1", 100)).await.unwrap();
621 s.append_step_entry(
622 &RunId::parse("R-1").unwrap(),
623 StepEntry {
624 step_id: crate::types::StepId::parse("ST-1").unwrap(),
625 step_ref: Some("step-a".into()),
626 status: Some("dispatched".into()),
627 at: 101,
628 },
629 )
630 .await
631 .unwrap();
632 s.append_step_entry(
633 &RunId::parse("R-1").unwrap(),
634 StepEntry {
635 step_id: crate::types::StepId::parse("ST-2").unwrap(),
636 step_ref: Some("step-b".into()),
637 status: Some("passed".into()),
638 at: 102,
639 },
640 )
641 .await
642 .unwrap();
643 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
644 assert_eq!(got.step_entries.len(), 2);
645 assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
646 assert_eq!(got.step_entries[1].step_ref, Some("step-b".into()));
647 drop(s);
648 driver.shutdown().await.unwrap();
649 }
650
651 #[tokio::test]
652 async fn append_step_entry_unknown_run_fails() {
653 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
654 let err = s
655 .append_step_entry(
656 &RunId::parse("R-nope").unwrap(),
657 StepEntry {
658 step_id: crate::types::StepId::parse("ST-1").unwrap(),
659 step_ref: None,
660 status: None,
661 at: 1,
662 },
663 )
664 .await
665 .unwrap_err();
666 assert!(matches!(err, RunStoreError::NotFound(_)));
667 drop(s);
668 driver.shutdown().await.unwrap();
669 }
670
671 #[tokio::test]
672 async fn append_degradation_accumulates_in_order() {
673 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
674 s.create(mk("R-1", "T-1", 100)).await.unwrap();
675 s.append_degradation(
676 &RunId::parse("R-1").unwrap(),
677 mk_degradation("web_search", 101),
678 )
679 .await
680 .unwrap();
681 s.append_degradation(
682 &RunId::parse("R-1").unwrap(),
683 mk_degradation("code_exec", 102),
684 )
685 .await
686 .unwrap();
687 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
688 assert_eq!(got.degradations.len(), 2);
689 assert_eq!(got.degradations[0].tool, "web_search");
690 assert_eq!(got.degradations[1].tool, "code_exec");
691 drop(s);
692 driver.shutdown().await.unwrap();
693 }
694
695 #[tokio::test]
696 async fn append_degradation_unknown_run_fails() {
697 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
698 let err = s
699 .append_degradation(
700 &RunId::parse("R-nope").unwrap(),
701 mk_degradation("web_search", 1),
702 )
703 .await
704 .unwrap_err();
705 assert!(matches!(err, RunStoreError::NotFound(_)));
706 drop(s);
707 driver.shutdown().await.unwrap();
708 }
709
710 #[tokio::test]
711 async fn append_degradation_bumps_updated_at() {
712 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
713 s.create(mk("R-1", "T-1", 100)).await.unwrap();
714 s.append_degradation(
715 &RunId::parse("R-1").unwrap(),
716 mk_degradation("web_search", 200),
717 )
718 .await
719 .unwrap();
720 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
721 assert!(got.updated_at > 100);
722 drop(s);
723 driver.shutdown().await.unwrap();
724 }
725
726 #[tokio::test]
727 async fn update_status_persists() {
728 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
729 s.create(mk("R-1", "T-1", 100)).await.unwrap();
730 s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Done)
731 .await
732 .unwrap();
733 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
734 assert_eq!(got.status, RunStatus::Done);
735 drop(s);
736 driver.shutdown().await.unwrap();
737 }
738
739 #[tokio::test]
740 async fn set_result_persists() {
741 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
742 s.create(mk("R-1", "T-1", 100)).await.unwrap();
743 s.set_result(&RunId::parse("R-1").unwrap(), json!({"ok": true}))
744 .await
745 .unwrap();
746 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
747 assert_eq!(got.result_ref, Some(json!({"ok": true})));
748 drop(s);
749 driver.shutdown().await.unwrap();
750 }
751
752 #[tokio::test]
753 async fn persists_across_reopen() {
754 let dir = tempfile::tempdir().unwrap();
755 let path = dir.path().join("runs.db");
756
757 {
758 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
759 s.create(mk("R-keep", "T-keep", 42)).await.unwrap();
760 s.append_step_entry(
761 &RunId::parse("R-keep").unwrap(),
762 StepEntry {
763 step_id: crate::types::StepId::parse("ST-1").unwrap(),
764 step_ref: Some("step-a".into()),
765 status: Some("dispatched".into()),
766 at: 43,
767 },
768 )
769 .await
770 .unwrap();
771 drop(s);
772 driver.shutdown().await.unwrap();
773 }
774
775 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
776 let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
777 assert_eq!(got.task_id, TaskId::parse("T-keep").unwrap());
778 assert_eq!(got.step_entries.len(), 1);
779 assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
780 drop(s);
781 driver.shutdown().await.unwrap();
782 }
783
784 #[tokio::test]
785 async fn list_running_filters_by_status() {
786 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
787 s.create(mk("R-1", "T-1", 100)).await.unwrap();
788 s.create(mk("R-2", "T-2", 200)).await.unwrap();
789 s.create(mk("R-3", "T-3", 300)).await.unwrap();
790 s.update_status(&RunId::parse("R-2").unwrap(), RunStatus::Running)
791 .await
792 .unwrap();
793 s.update_status(&RunId::parse("R-3").unwrap(), RunStatus::Done)
794 .await
795 .unwrap();
796 let running = s.list_running().await.unwrap();
797 assert_eq!(running.len(), 1);
798 assert_eq!(running[0].id, RunId::parse("R-2").unwrap());
799 assert_eq!(running[0].status, RunStatus::Running);
800 drop(s);
801 driver.shutdown().await.unwrap();
802 }
803
804 #[tokio::test]
805 async fn try_transition_is_atomic_compare_and_set() {
806 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
807 s.create(mk("R-1", "T-1", 100)).await.unwrap();
808 s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Interrupted)
809 .await
810 .unwrap();
811
812 let first = s
813 .try_transition(
814 &RunId::parse("R-1").unwrap(),
815 RunStatus::Interrupted,
816 RunStatus::Running,
817 )
818 .await
819 .unwrap();
820 assert!(first, "first CAS must flip Interrupted -> Running");
821 assert_eq!(
822 s.get(&RunId::parse("R-1").unwrap()).await.unwrap().status,
823 RunStatus::Running
824 );
825
826 let second = s
827 .try_transition(
828 &RunId::parse("R-1").unwrap(),
829 RunStatus::Interrupted,
830 RunStatus::Running,
831 )
832 .await
833 .unwrap();
834 assert!(
835 !second,
836 "a racing second CAS must not flip a now-Running row"
837 );
838
839 let absent = s
840 .try_transition(
841 &RunId::parse("R-nope").unwrap(),
842 RunStatus::Interrupted,
843 RunStatus::Running,
844 )
845 .await
846 .unwrap();
847 assert!(!absent, "an absent Run must report false, not error");
848 drop(s);
849 driver.shutdown().await.unwrap();
850 }
851
852 #[tokio::test]
853 async fn input_json_roundtrips_across_reopen() {
854 let dir = tempfile::tempdir().unwrap();
855 let path = dir.path().join("runs.db");
856 let snapshot = r#"{"blueprint":"snapshot","init_ctx":{}}"#;
857
858 {
859 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
860 let mut rec = mk("R-keep", "T-keep", 42);
861 rec.input_json = Some(snapshot.to_string());
862 s.create(rec).await.unwrap();
863 drop(s);
864 driver.shutdown().await.unwrap();
865 }
866
867 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
868 let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
869 assert_eq!(got.input_json.as_deref(), Some(snapshot));
870 drop(s);
871 driver.shutdown().await.unwrap();
872 }
873}