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 set_input_json(&self, id: &RunId, input_json: String) -> Result<(), RunStoreError> {
499 let id_str = id.to_string();
500 let id_for_notfound = id.clone();
501 let updated_at = crate::types::now_unix() as i64;
502 let n = self
503 .isle
504 .call(move |conn| {
505 conn.execute(
506 "UPDATE runs SET input_json = ?1, updated_at = ?2 WHERE id = ?3",
507 params![input_json, updated_at, id_str],
508 )
509 })
510 .await
511 .map_err(map_isle_err)?;
512 if n == 0 {
513 Err(RunStoreError::NotFound(id_for_notfound))
514 } else {
515 Ok(())
516 }
517 }
518
519 async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError> {
520 let status_json = serde_json::to_string(&RunStatus::Running)
521 .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
522 let rows = self
523 .isle
524 .call(move |conn| {
525 let mut stmt = conn.prepare(&format!(
526 "SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE status = ?1"
527 ))?;
528 let iter = stmt.query_map(params![status_json], |row| {
529 Ok((
530 row.get::<_, String>(0)?,
531 row.get::<_, String>(1)?,
532 row.get::<_, String>(2)?,
533 row.get::<_, String>(3)?,
534 row.get::<_, String>(4)?,
535 row.get::<_, Option<String>>(5)?,
536 row.get::<_, Option<String>>(6)?,
537 row.get::<_, Option<String>>(7)?,
538 row.get::<_, i64>(8)?,
539 row.get::<_, i64>(9)?,
540 ))
541 })?;
542 let mut out = Vec::new();
543 for r in iter {
544 out.push(r?);
545 }
546 Ok(out)
547 })
548 .await
549 .map_err(map_isle_err)?;
550 rows.into_iter().map(row_to_record).collect()
551 }
552}
553
554#[cfg(test)]
559mod tests {
560 use super::*;
561 use serde_json::json;
562
563 fn mk(id: &str, task_id: &str, created_at: u64) -> RunRecord {
564 RunRecord {
565 id: RunId::parse(id).unwrap(),
566 task_id: TaskId::parse(task_id).unwrap(),
567 status: RunStatus::Pending,
568 step_entries: vec![],
569 degradations: vec![],
570 operator_sid: None,
571 result_ref: None,
572 input_json: None,
573 created_at,
574 updated_at: created_at,
575 }
576 }
577
578 fn mk_degradation(tool: &str, at: u64) -> DegradationEntry {
579 DegradationEntry {
580 tool: tool.to_string(),
581 error: "boom".to_string(),
582 fallback: "cached-default".to_string(),
583 note: None,
584 step_ref: Some("worker".to_string()),
585 attempt: Some(1),
586 at,
587 }
588 }
589
590 #[tokio::test]
591 async fn create_then_get() {
592 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
593 s.create(mk("R-1", "T-1", 100)).await.unwrap();
594 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
595 assert_eq!(got.task_id, TaskId::parse("T-1").unwrap());
596 assert_eq!(got.status, RunStatus::Pending);
597 assert!(got.step_entries.is_empty());
598 assert_eq!(got.result_ref, None);
599 drop(s);
600 driver.shutdown().await.unwrap();
601 }
602
603 #[tokio::test]
604 async fn duplicate_create_rejected() {
605 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
606 s.create(mk("R-1", "T-1", 100)).await.unwrap();
607 let err = s.create(mk("R-1", "T-1", 200)).await.unwrap_err();
608 assert!(matches!(err, RunStoreError::Duplicate(_)), "got: {err:?}");
609 drop(s);
610 driver.shutdown().await.unwrap();
611 }
612
613 #[tokio::test]
614 async fn get_missing_returns_not_found() {
615 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
616 let err = s.get(&RunId::parse("R-nope").unwrap()).await.unwrap_err();
617 assert!(matches!(err, RunStoreError::NotFound(_)));
618 drop(s);
619 driver.shutdown().await.unwrap();
620 }
621
622 #[tokio::test]
623 async fn list_by_task_filters_and_orders_ascending() {
624 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
625 s.create(mk("R-1", "T-1", 300)).await.unwrap();
626 s.create(mk("R-2", "T-2", 50)).await.unwrap();
627 s.create(mk("R-3", "T-1", 100)).await.unwrap();
628 let list = s
629 .list_by_task(&TaskId::parse("T-1").unwrap())
630 .await
631 .unwrap();
632 let ids: Vec<_> = list.iter().map(|r| r.id.to_string()).collect();
633 assert_eq!(ids, vec!["R-3", "R-1"]);
634 drop(s);
635 driver.shutdown().await.unwrap();
636 }
637
638 #[tokio::test]
639 async fn append_step_entry_accumulates_in_order() {
640 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
641 s.create(mk("R-1", "T-1", 100)).await.unwrap();
642 s.append_step_entry(
643 &RunId::parse("R-1").unwrap(),
644 StepEntry {
645 step_id: crate::types::StepId::parse("ST-1").unwrap(),
646 step_ref: Some("step-a".into()),
647 status: Some("dispatched".into()),
648 binding_digest: None,
649 at: 101,
650 },
651 )
652 .await
653 .unwrap();
654 s.append_step_entry(
655 &RunId::parse("R-1").unwrap(),
656 StepEntry {
657 step_id: crate::types::StepId::parse("ST-2").unwrap(),
658 step_ref: Some("step-b".into()),
659 status: Some("passed".into()),
660 binding_digest: None,
661 at: 102,
662 },
663 )
664 .await
665 .unwrap();
666 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
667 assert_eq!(got.step_entries.len(), 2);
668 assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
669 assert_eq!(got.step_entries[1].step_ref, Some("step-b".into()));
670 drop(s);
671 driver.shutdown().await.unwrap();
672 }
673
674 #[tokio::test]
675 async fn append_step_entry_unknown_run_fails() {
676 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
677 let err = s
678 .append_step_entry(
679 &RunId::parse("R-nope").unwrap(),
680 StepEntry {
681 step_id: crate::types::StepId::parse("ST-1").unwrap(),
682 step_ref: None,
683 status: None,
684 binding_digest: None,
685 at: 1,
686 },
687 )
688 .await
689 .unwrap_err();
690 assert!(matches!(err, RunStoreError::NotFound(_)));
691 drop(s);
692 driver.shutdown().await.unwrap();
693 }
694
695 #[tokio::test]
696 async fn append_degradation_accumulates_in_order() {
697 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
698 s.create(mk("R-1", "T-1", 100)).await.unwrap();
699 s.append_degradation(
700 &RunId::parse("R-1").unwrap(),
701 mk_degradation("web_search", 101),
702 )
703 .await
704 .unwrap();
705 s.append_degradation(
706 &RunId::parse("R-1").unwrap(),
707 mk_degradation("code_exec", 102),
708 )
709 .await
710 .unwrap();
711 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
712 assert_eq!(got.degradations.len(), 2);
713 assert_eq!(got.degradations[0].tool, "web_search");
714 assert_eq!(got.degradations[1].tool, "code_exec");
715 drop(s);
716 driver.shutdown().await.unwrap();
717 }
718
719 #[tokio::test]
720 async fn append_degradation_unknown_run_fails() {
721 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
722 let err = s
723 .append_degradation(
724 &RunId::parse("R-nope").unwrap(),
725 mk_degradation("web_search", 1),
726 )
727 .await
728 .unwrap_err();
729 assert!(matches!(err, RunStoreError::NotFound(_)));
730 drop(s);
731 driver.shutdown().await.unwrap();
732 }
733
734 #[tokio::test]
735 async fn append_degradation_bumps_updated_at() {
736 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
737 s.create(mk("R-1", "T-1", 100)).await.unwrap();
738 s.append_degradation(
739 &RunId::parse("R-1").unwrap(),
740 mk_degradation("web_search", 200),
741 )
742 .await
743 .unwrap();
744 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
745 assert!(got.updated_at > 100);
746 drop(s);
747 driver.shutdown().await.unwrap();
748 }
749
750 #[tokio::test]
751 async fn update_status_persists() {
752 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
753 s.create(mk("R-1", "T-1", 100)).await.unwrap();
754 s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Done)
755 .await
756 .unwrap();
757 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
758 assert_eq!(got.status, RunStatus::Done);
759 drop(s);
760 driver.shutdown().await.unwrap();
761 }
762
763 #[tokio::test]
764 async fn set_result_persists() {
765 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
766 s.create(mk("R-1", "T-1", 100)).await.unwrap();
767 s.set_result(&RunId::parse("R-1").unwrap(), json!({"ok": true}))
768 .await
769 .unwrap();
770 let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
771 assert_eq!(got.result_ref, Some(json!({"ok": true})));
772 drop(s);
773 driver.shutdown().await.unwrap();
774 }
775
776 #[tokio::test]
777 async fn persists_across_reopen() {
778 let dir = tempfile::tempdir().unwrap();
779 let path = dir.path().join("runs.db");
780
781 {
782 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
783 s.create(mk("R-keep", "T-keep", 42)).await.unwrap();
784 s.append_step_entry(
785 &RunId::parse("R-keep").unwrap(),
786 StepEntry {
787 step_id: crate::types::StepId::parse("ST-1").unwrap(),
788 step_ref: Some("step-a".into()),
789 status: Some("dispatched".into()),
790 binding_digest: None,
791 at: 43,
792 },
793 )
794 .await
795 .unwrap();
796 drop(s);
797 driver.shutdown().await.unwrap();
798 }
799
800 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
801 let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
802 assert_eq!(got.task_id, TaskId::parse("T-keep").unwrap());
803 assert_eq!(got.step_entries.len(), 1);
804 assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
805 drop(s);
806 driver.shutdown().await.unwrap();
807 }
808
809 #[tokio::test]
810 async fn list_running_filters_by_status() {
811 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
812 s.create(mk("R-1", "T-1", 100)).await.unwrap();
813 s.create(mk("R-2", "T-2", 200)).await.unwrap();
814 s.create(mk("R-3", "T-3", 300)).await.unwrap();
815 s.update_status(&RunId::parse("R-2").unwrap(), RunStatus::Running)
816 .await
817 .unwrap();
818 s.update_status(&RunId::parse("R-3").unwrap(), RunStatus::Done)
819 .await
820 .unwrap();
821 let running = s.list_running().await.unwrap();
822 assert_eq!(running.len(), 1);
823 assert_eq!(running[0].id, RunId::parse("R-2").unwrap());
824 assert_eq!(running[0].status, RunStatus::Running);
825 drop(s);
826 driver.shutdown().await.unwrap();
827 }
828
829 #[tokio::test]
830 async fn try_transition_is_atomic_compare_and_set() {
831 let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
832 s.create(mk("R-1", "T-1", 100)).await.unwrap();
833 s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Interrupted)
834 .await
835 .unwrap();
836
837 let first = s
838 .try_transition(
839 &RunId::parse("R-1").unwrap(),
840 RunStatus::Interrupted,
841 RunStatus::Running,
842 )
843 .await
844 .unwrap();
845 assert!(first, "first CAS must flip Interrupted -> Running");
846 assert_eq!(
847 s.get(&RunId::parse("R-1").unwrap()).await.unwrap().status,
848 RunStatus::Running
849 );
850
851 let second = s
852 .try_transition(
853 &RunId::parse("R-1").unwrap(),
854 RunStatus::Interrupted,
855 RunStatus::Running,
856 )
857 .await
858 .unwrap();
859 assert!(
860 !second,
861 "a racing second CAS must not flip a now-Running row"
862 );
863
864 let absent = s
865 .try_transition(
866 &RunId::parse("R-nope").unwrap(),
867 RunStatus::Interrupted,
868 RunStatus::Running,
869 )
870 .await
871 .unwrap();
872 assert!(!absent, "an absent Run must report false, not error");
873 drop(s);
874 driver.shutdown().await.unwrap();
875 }
876
877 #[tokio::test]
878 async fn input_json_roundtrips_across_reopen() {
879 let dir = tempfile::tempdir().unwrap();
880 let path = dir.path().join("runs.db");
881 let snapshot = r#"{"blueprint":"snapshot","init_ctx":{}}"#;
882
883 {
884 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
885 let mut rec = mk("R-keep", "T-keep", 42);
886 rec.input_json = Some(snapshot.to_string());
887 s.create(rec).await.unwrap();
888 drop(s);
889 driver.shutdown().await.unwrap();
890 }
891
892 let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
893 let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
894 assert_eq!(got.input_json.as_deref(), Some(snapshot));
895 drop(s);
896 driver.shutdown().await.unwrap();
897 }
898}