Skip to main content

mlua_swarm/store/run/
sqlite.rs

1//! `SqliteRunStore` — SQLite-backed [`RunStore`] using [`rusqlite-isle`].
2//!
3//! The `Connection` is confined to a dedicated OS thread by `AsyncIsle`;
4//! every call is a typed closure dispatched over a bounded channel.
5//! `step_entries`, `degradations`, and `result_ref` are stored as JSON
6//! blobs — the former two are pure trace/observability artifacts (not
7//! queried relationally), the latter is caller-defined payload shape.
8//! `append_step_entry`/`append_degradation` run as a read-modify-write
9//! inside a single transaction so concurrent appenders don't clobber each
10//! other's entries.
11//!
12//! ## Schema
13//!
14//! ```sql
15//! CREATE TABLE IF NOT EXISTS runs (
16//!   id                 TEXT PRIMARY KEY,
17//!   task_id            TEXT NOT NULL,
18//!   status             TEXT NOT NULL,      -- JSON-encoded `RunStatus`
19//!   step_entries_json  TEXT NOT NULL,      -- JSON-encoded `Vec<StepEntry>`
20//!   degradations_json  TEXT NOT NULL DEFAULT '[]', -- JSON-encoded `Vec<DegradationEntry>` (GH #32)
21//!   operator_sid       TEXT,
22//!   result_ref_json    TEXT,               -- JSON-encoded `serde_json::Value`, NULL when unset
23//!   created_at         INTEGER NOT NULL,
24//!   updated_at         INTEGER NOT NULL
25//! );
26//! CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);
27//! ```
28//!
29//! `degradations_json` was added after the initial release (GH #32); the
30//! migration is applied idempotently on open via a `PRAGMA table_info(runs)`
31//! existence check followed by `ALTER TABLE runs ADD COLUMN degradations_json
32//! TEXT NOT NULL DEFAULT '[]'` when missing, so pre-existing database files
33//! pick up the column without a manual migration step.
34
35use super::{
36    DegradationEntry, RunId, RunRecord, RunStatus, RunStore, RunStoreError, StepEntry, TaskId,
37};
38use async_trait::async_trait;
39use rusqlite::{params, OptionalExtension};
40use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
41use std::path::Path;
42
43const SCHEMA_SQL: &str = "\
44CREATE TABLE IF NOT EXISTS runs (\
45  id                 TEXT PRIMARY KEY, \
46  task_id            TEXT NOT NULL, \
47  status             TEXT NOT NULL, \
48  step_entries_json  TEXT NOT NULL, \
49  degradations_json  TEXT NOT NULL DEFAULT '[]', \
50  operator_sid       TEXT, \
51  result_ref_json    TEXT, \
52  created_at         INTEGER NOT NULL, \
53  updated_at         INTEGER NOT NULL\
54);\
55CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);\
56";
57
58/// Idempotently ensures `runs.degradations_json` exists — for database
59/// files created before GH #32 introduced the column. Fresh databases get
60/// the column from [`SCHEMA_SQL`] directly; this only fires the `ALTER
61/// TABLE` on pre-existing files where `runs` was created without it.
62fn migrate_degradations_column(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
63    let mut stmt = conn.prepare("PRAGMA table_info(runs)")?;
64    let has_column = stmt
65        .query_map([], |row| row.get::<_, String>(1))?
66        .collect::<Result<Vec<String>, _>>()?
67        .iter()
68        .any(|name| name == "degradations_json");
69    if !has_column {
70        conn.execute_batch(
71            "ALTER TABLE runs ADD COLUMN degradations_json TEXT NOT NULL DEFAULT '[]';",
72        )?;
73    }
74    Ok(())
75}
76
77/// SQLite-backed persistent [`RunStore`].
78///
79/// Open with [`SqliteRunStore::open`] (file path) or
80/// [`SqliteRunStore::open_in_memory`] (tests). Both return the store plus
81/// an [`AsyncIsleDriver`] the caller must `shutdown().await` when done —
82/// dropping the driver without a shutdown call leaves the SQLite thread
83/// as-is until the process exits.
84pub struct SqliteRunStore {
85    isle: AsyncIsle,
86}
87
88impl SqliteRunStore {
89    /// Open (or create) a SQLite database file and run the schema
90    /// migrations.
91    pub async fn open(path: impl AsRef<Path>) -> Result<(Self, AsyncIsleDriver), RunStoreError> {
92        let (isle, driver) = AsyncIsle::spawn(path.as_ref().to_path_buf(), |conn| {
93            conn.execute_batch(SCHEMA_SQL)?;
94            migrate_degradations_column(conn)
95        })
96        .await
97        .map_err(map_isle_err)?;
98        Ok((Self { isle }, driver))
99    }
100
101    /// Open an ephemeral in-memory database (tests, doctests).
102    pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), RunStoreError> {
103        let (isle, driver) = AsyncIsle::open_in_memory(|conn| {
104            conn.execute_batch(SCHEMA_SQL)?;
105            migrate_degradations_column(conn)
106        })
107        .await
108        .map_err(map_isle_err)?;
109        Ok((Self { isle }, driver))
110    }
111}
112
113fn map_isle_err(e: IsleError) -> RunStoreError {
114    RunStoreError::Other(format!("sqlite: {e}"))
115}
116
117/// One `runs` SELECT row in column order: id, task_id, status,
118/// step_entries_json, degradations_json, operator_sid, result_ref_json,
119/// created_at, updated_at.
120type RunRow = (
121    String,
122    String,
123    String,
124    String,
125    String,
126    Option<String>,
127    Option<String>,
128    i64,
129    i64,
130);
131
132const RUN_SELECT_COLUMNS: &str = "id, task_id, status, step_entries_json, degradations_json, \
133     operator_sid, result_ref_json, created_at, updated_at";
134
135fn row_to_record(row: RunRow) -> Result<RunRecord, RunStoreError> {
136    let (
137        id,
138        task_id,
139        status_json,
140        step_entries_json,
141        degradations_json,
142        operator_sid,
143        result_ref_json,
144        created_at,
145        updated_at,
146    ) = row;
147    let status: RunStatus = serde_json::from_str(&status_json)
148        .map_err(|e| RunStoreError::Other(format!("decode status: {e}")))?;
149    let step_entries: Vec<StepEntry> = serde_json::from_str(&step_entries_json)
150        .map_err(|e| RunStoreError::Other(format!("decode step_entries: {e}")))?;
151    let degradations: Vec<DegradationEntry> = serde_json::from_str(&degradations_json)
152        .map_err(|e| RunStoreError::Other(format!("decode degradations: {e}")))?;
153    let result_ref: Option<serde_json::Value> = match result_ref_json {
154        Some(text) => Some(
155            serde_json::from_str(&text)
156                .map_err(|e| RunStoreError::Other(format!("decode result_ref: {e}")))?,
157        ),
158        None => None,
159    };
160    // Ids were minted by us before landing in the table; a prefix mismatch
161    // here means the row predates the issue #13 prefix reconciliation or
162    // the file was written by something else — fail loud either way.
163    let id = RunId::parse(id).map_err(|e| RunStoreError::Other(format!("decode id: {e}")))?;
164    let task_id =
165        TaskId::parse(task_id).map_err(|e| RunStoreError::Other(format!("decode task_id: {e}")))?;
166    Ok(RunRecord {
167        id,
168        task_id,
169        status,
170        step_entries,
171        degradations,
172        operator_sid,
173        result_ref,
174        created_at: created_at as u64,
175        updated_at: updated_at as u64,
176    })
177}
178
179#[async_trait]
180impl RunStore for SqliteRunStore {
181    fn name(&self) -> &str {
182        "sqlite"
183    }
184
185    async fn create(&self, record: RunRecord) -> Result<(), RunStoreError> {
186        let id = record.id.to_string();
187        let id_for_conflict = record.id.clone();
188        let task_id = record.task_id.to_string();
189        let status_json = serde_json::to_string(&record.status)
190            .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
191        let step_entries_json = serde_json::to_string(&record.step_entries)
192            .map_err(|e| RunStoreError::Other(format!("encode step_entries: {e}")))?;
193        let degradations_json = serde_json::to_string(&record.degradations)
194            .map_err(|e| RunStoreError::Other(format!("encode degradations: {e}")))?;
195        let operator_sid = record.operator_sid.clone();
196        let result_ref_json = record
197            .result_ref
198            .as_ref()
199            .map(serde_json::to_string)
200            .transpose()
201            .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
202        let created_at = record.created_at as i64;
203        let updated_at = record.updated_at as i64;
204
205        self.isle
206            .call(move |conn| {
207                let tx = conn.transaction()?;
208                let exists: i64 = tx.query_row(
209                    "SELECT COUNT(*) FROM runs WHERE id = ?1",
210                    params![id],
211                    |row| row.get(0),
212                )?;
213                if exists > 0 {
214                    return Err(rusqlite::Error::SqliteFailure(
215                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
216                        Some(format!("__mlua_swarm_duplicate:{id}")),
217                    ));
218                }
219                tx.execute(
220                    "INSERT INTO runs (id, task_id, status, step_entries_json, \
221                     degradations_json, operator_sid, result_ref_json, created_at, updated_at) \
222                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
223                    params![
224                        id,
225                        task_id,
226                        status_json,
227                        step_entries_json,
228                        degradations_json,
229                        operator_sid,
230                        result_ref_json,
231                        created_at,
232                        updated_at,
233                    ],
234                )?;
235                tx.commit()?;
236                Ok(())
237            })
238            .await
239            .map_err(|e| match &e {
240                IsleError::Sqlite(rusqlite::Error::SqliteFailure(_, Some(msg)))
241                    if msg.starts_with("__mlua_swarm_duplicate:") =>
242                {
243                    RunStoreError::Duplicate(id_for_conflict.clone())
244                }
245                _ => map_isle_err(e),
246            })
247    }
248
249    async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError> {
250        let id_str = id.to_string();
251        let id_for_notfound = id.clone();
252        let row = self
253            .isle
254            .call(move |conn| {
255                conn.query_row(
256                    &format!("SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE id = ?1"),
257                    params![id_str],
258                    |row| {
259                        Ok((
260                            row.get::<_, String>(0)?,
261                            row.get::<_, String>(1)?,
262                            row.get::<_, String>(2)?,
263                            row.get::<_, String>(3)?,
264                            row.get::<_, String>(4)?,
265                            row.get::<_, Option<String>>(5)?,
266                            row.get::<_, Option<String>>(6)?,
267                            row.get::<_, i64>(7)?,
268                            row.get::<_, i64>(8)?,
269                        ))
270                    },
271                )
272                .optional()
273            })
274            .await
275            .map_err(map_isle_err)?;
276        match row {
277            Some(row) => row_to_record(row),
278            None => Err(RunStoreError::NotFound(id_for_notfound)),
279        }
280    }
281
282    async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError> {
283        let task_id_str = task_id.to_string();
284        let rows = self
285            .isle
286            .call(move |conn| {
287                let mut stmt = conn.prepare(&format!(
288                    "SELECT {RUN_SELECT_COLUMNS} FROM runs \
289                     WHERE task_id = ?1 ORDER BY created_at ASC"
290                ))?;
291                let iter = stmt.query_map(params![task_id_str], |row| {
292                    Ok((
293                        row.get::<_, String>(0)?,
294                        row.get::<_, String>(1)?,
295                        row.get::<_, String>(2)?,
296                        row.get::<_, String>(3)?,
297                        row.get::<_, String>(4)?,
298                        row.get::<_, Option<String>>(5)?,
299                        row.get::<_, Option<String>>(6)?,
300                        row.get::<_, i64>(7)?,
301                        row.get::<_, i64>(8)?,
302                    ))
303                })?;
304                let mut out = Vec::new();
305                for r in iter {
306                    out.push(r?);
307                }
308                Ok(out)
309            })
310            .await
311            .map_err(map_isle_err)?;
312        rows.into_iter().map(row_to_record).collect()
313    }
314
315    async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError> {
316        let id_str = id.to_string();
317        let id_for_notfound = id.clone();
318        let updated_at = crate::types::now_unix() as i64;
319
320        let updated = self
321            .isle
322            .call(move |conn| {
323                let tx = conn.transaction()?;
324                let existing: Option<String> = tx
325                    .query_row(
326                        "SELECT step_entries_json FROM runs WHERE id = ?1",
327                        params![id_str],
328                        |row| row.get(0),
329                    )
330                    .optional()?;
331                let Some(existing_json) = existing else {
332                    return Ok(false);
333                };
334                let mut entries: Vec<StepEntry> = serde_json::from_str(&existing_json)
335                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
336                entries.push(entry);
337                let new_json = serde_json::to_string(&entries)
338                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
339                tx.execute(
340                    "UPDATE runs SET step_entries_json = ?1, updated_at = ?2 WHERE id = ?3",
341                    params![new_json, updated_at, id_str],
342                )?;
343                tx.commit()?;
344                Ok(true)
345            })
346            .await
347            .map_err(map_isle_err)?;
348
349        if updated {
350            Ok(())
351        } else {
352            Err(RunStoreError::NotFound(id_for_notfound))
353        }
354    }
355
356    async fn append_degradation(
357        &self,
358        id: &RunId,
359        entry: DegradationEntry,
360    ) -> Result<(), RunStoreError> {
361        let id_str = id.to_string();
362        let id_for_notfound = id.clone();
363        let updated_at = crate::types::now_unix() as i64;
364
365        let updated = self
366            .isle
367            .call(move |conn| {
368                let tx = conn.transaction()?;
369                let existing: Option<String> = tx
370                    .query_row(
371                        "SELECT degradations_json FROM runs WHERE id = ?1",
372                        params![id_str],
373                        |row| row.get(0),
374                    )
375                    .optional()?;
376                let Some(existing_json) = existing else {
377                    return Ok(false);
378                };
379                let mut entries: Vec<DegradationEntry> = serde_json::from_str(&existing_json)
380                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
381                entries.push(entry);
382                let new_json = serde_json::to_string(&entries)
383                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
384                tx.execute(
385                    "UPDATE runs SET degradations_json = ?1, updated_at = ?2 WHERE id = ?3",
386                    params![new_json, updated_at, id_str],
387                )?;
388                tx.commit()?;
389                Ok(true)
390            })
391            .await
392            .map_err(map_isle_err)?;
393
394        if updated {
395            Ok(())
396        } else {
397            Err(RunStoreError::NotFound(id_for_notfound))
398        }
399    }
400
401    async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError> {
402        let id_str = id.to_string();
403        let id_for_notfound = id.clone();
404        let status_json = serde_json::to_string(&status)
405            .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
406        let updated_at = crate::types::now_unix() as i64;
407        let n = self
408            .isle
409            .call(move |conn| {
410                conn.execute(
411                    "UPDATE runs SET status = ?1, updated_at = ?2 WHERE id = ?3",
412                    params![status_json, updated_at, id_str],
413                )
414            })
415            .await
416            .map_err(map_isle_err)?;
417        if n == 0 {
418            Err(RunStoreError::NotFound(id_for_notfound))
419        } else {
420            Ok(())
421        }
422    }
423
424    async fn set_result(
425        &self,
426        id: &RunId,
427        result_ref: serde_json::Value,
428    ) -> Result<(), RunStoreError> {
429        let id_str = id.to_string();
430        let id_for_notfound = id.clone();
431        let result_ref_json = serde_json::to_string(&result_ref)
432            .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
433        let updated_at = crate::types::now_unix() as i64;
434        let n = self
435            .isle
436            .call(move |conn| {
437                conn.execute(
438                    "UPDATE runs SET result_ref_json = ?1, updated_at = ?2 WHERE id = ?3",
439                    params![result_ref_json, updated_at, id_str],
440                )
441            })
442            .await
443            .map_err(map_isle_err)?;
444        if n == 0 {
445            Err(RunStoreError::NotFound(id_for_notfound))
446        } else {
447            Ok(())
448        }
449    }
450
451    async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError> {
452        let status_json = serde_json::to_string(&RunStatus::Running)
453            .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
454        let rows = self
455            .isle
456            .call(move |conn| {
457                let mut stmt = conn.prepare(&format!(
458                    "SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE status = ?1"
459                ))?;
460                let iter = stmt.query_map(params![status_json], |row| {
461                    Ok((
462                        row.get::<_, String>(0)?,
463                        row.get::<_, String>(1)?,
464                        row.get::<_, String>(2)?,
465                        row.get::<_, String>(3)?,
466                        row.get::<_, String>(4)?,
467                        row.get::<_, Option<String>>(5)?,
468                        row.get::<_, Option<String>>(6)?,
469                        row.get::<_, i64>(7)?,
470                        row.get::<_, i64>(8)?,
471                    ))
472                })?;
473                let mut out = Vec::new();
474                for r in iter {
475                    out.push(r?);
476                }
477                Ok(out)
478            })
479            .await
480            .map_err(map_isle_err)?;
481        rows.into_iter().map(row_to_record).collect()
482    }
483}
484
485// ──────────────────────────────────────────────────────────────────────────
486// tests
487// ──────────────────────────────────────────────────────────────────────────
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use serde_json::json;
493
494    fn mk(id: &str, task_id: &str, created_at: u64) -> RunRecord {
495        RunRecord {
496            id: RunId::parse(id).unwrap(),
497            task_id: TaskId::parse(task_id).unwrap(),
498            status: RunStatus::Pending,
499            step_entries: vec![],
500            degradations: vec![],
501            operator_sid: None,
502            result_ref: None,
503            created_at,
504            updated_at: created_at,
505        }
506    }
507
508    fn mk_degradation(tool: &str, at: u64) -> DegradationEntry {
509        DegradationEntry {
510            tool: tool.to_string(),
511            error: "boom".to_string(),
512            fallback: "cached-default".to_string(),
513            note: None,
514            step_ref: Some("worker".to_string()),
515            attempt: Some(1),
516            at,
517        }
518    }
519
520    #[tokio::test]
521    async fn create_then_get() {
522        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
523        s.create(mk("R-1", "T-1", 100)).await.unwrap();
524        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
525        assert_eq!(got.task_id, TaskId::parse("T-1").unwrap());
526        assert_eq!(got.status, RunStatus::Pending);
527        assert!(got.step_entries.is_empty());
528        assert_eq!(got.result_ref, None);
529        drop(s);
530        driver.shutdown().await.unwrap();
531    }
532
533    #[tokio::test]
534    async fn duplicate_create_rejected() {
535        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
536        s.create(mk("R-1", "T-1", 100)).await.unwrap();
537        let err = s.create(mk("R-1", "T-1", 200)).await.unwrap_err();
538        assert!(matches!(err, RunStoreError::Duplicate(_)), "got: {err:?}");
539        drop(s);
540        driver.shutdown().await.unwrap();
541    }
542
543    #[tokio::test]
544    async fn get_missing_returns_not_found() {
545        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
546        let err = s.get(&RunId::parse("R-nope").unwrap()).await.unwrap_err();
547        assert!(matches!(err, RunStoreError::NotFound(_)));
548        drop(s);
549        driver.shutdown().await.unwrap();
550    }
551
552    #[tokio::test]
553    async fn list_by_task_filters_and_orders_ascending() {
554        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
555        s.create(mk("R-1", "T-1", 300)).await.unwrap();
556        s.create(mk("R-2", "T-2", 50)).await.unwrap();
557        s.create(mk("R-3", "T-1", 100)).await.unwrap();
558        let list = s
559            .list_by_task(&TaskId::parse("T-1").unwrap())
560            .await
561            .unwrap();
562        let ids: Vec<_> = list.iter().map(|r| r.id.to_string()).collect();
563        assert_eq!(ids, vec!["R-3", "R-1"]);
564        drop(s);
565        driver.shutdown().await.unwrap();
566    }
567
568    #[tokio::test]
569    async fn append_step_entry_accumulates_in_order() {
570        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
571        s.create(mk("R-1", "T-1", 100)).await.unwrap();
572        s.append_step_entry(
573            &RunId::parse("R-1").unwrap(),
574            StepEntry {
575                step_id: crate::types::StepId::parse("ST-1").unwrap(),
576                step_ref: Some("step-a".into()),
577                status: Some("dispatched".into()),
578                at: 101,
579            },
580        )
581        .await
582        .unwrap();
583        s.append_step_entry(
584            &RunId::parse("R-1").unwrap(),
585            StepEntry {
586                step_id: crate::types::StepId::parse("ST-2").unwrap(),
587                step_ref: Some("step-b".into()),
588                status: Some("passed".into()),
589                at: 102,
590            },
591        )
592        .await
593        .unwrap();
594        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
595        assert_eq!(got.step_entries.len(), 2);
596        assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
597        assert_eq!(got.step_entries[1].step_ref, Some("step-b".into()));
598        drop(s);
599        driver.shutdown().await.unwrap();
600    }
601
602    #[tokio::test]
603    async fn append_step_entry_unknown_run_fails() {
604        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
605        let err = s
606            .append_step_entry(
607                &RunId::parse("R-nope").unwrap(),
608                StepEntry {
609                    step_id: crate::types::StepId::parse("ST-1").unwrap(),
610                    step_ref: None,
611                    status: None,
612                    at: 1,
613                },
614            )
615            .await
616            .unwrap_err();
617        assert!(matches!(err, RunStoreError::NotFound(_)));
618        drop(s);
619        driver.shutdown().await.unwrap();
620    }
621
622    #[tokio::test]
623    async fn append_degradation_accumulates_in_order() {
624        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
625        s.create(mk("R-1", "T-1", 100)).await.unwrap();
626        s.append_degradation(
627            &RunId::parse("R-1").unwrap(),
628            mk_degradation("web_search", 101),
629        )
630        .await
631        .unwrap();
632        s.append_degradation(
633            &RunId::parse("R-1").unwrap(),
634            mk_degradation("code_exec", 102),
635        )
636        .await
637        .unwrap();
638        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
639        assert_eq!(got.degradations.len(), 2);
640        assert_eq!(got.degradations[0].tool, "web_search");
641        assert_eq!(got.degradations[1].tool, "code_exec");
642        drop(s);
643        driver.shutdown().await.unwrap();
644    }
645
646    #[tokio::test]
647    async fn append_degradation_unknown_run_fails() {
648        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
649        let err = s
650            .append_degradation(
651                &RunId::parse("R-nope").unwrap(),
652                mk_degradation("web_search", 1),
653            )
654            .await
655            .unwrap_err();
656        assert!(matches!(err, RunStoreError::NotFound(_)));
657        drop(s);
658        driver.shutdown().await.unwrap();
659    }
660
661    #[tokio::test]
662    async fn append_degradation_bumps_updated_at() {
663        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
664        s.create(mk("R-1", "T-1", 100)).await.unwrap();
665        s.append_degradation(
666            &RunId::parse("R-1").unwrap(),
667            mk_degradation("web_search", 200),
668        )
669        .await
670        .unwrap();
671        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
672        assert!(got.updated_at > 100);
673        drop(s);
674        driver.shutdown().await.unwrap();
675    }
676
677    #[tokio::test]
678    async fn update_status_persists() {
679        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
680        s.create(mk("R-1", "T-1", 100)).await.unwrap();
681        s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Done)
682            .await
683            .unwrap();
684        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
685        assert_eq!(got.status, RunStatus::Done);
686        drop(s);
687        driver.shutdown().await.unwrap();
688    }
689
690    #[tokio::test]
691    async fn set_result_persists() {
692        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
693        s.create(mk("R-1", "T-1", 100)).await.unwrap();
694        s.set_result(&RunId::parse("R-1").unwrap(), json!({"ok": true}))
695            .await
696            .unwrap();
697        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
698        assert_eq!(got.result_ref, Some(json!({"ok": true})));
699        drop(s);
700        driver.shutdown().await.unwrap();
701    }
702
703    #[tokio::test]
704    async fn persists_across_reopen() {
705        let dir = tempfile::tempdir().unwrap();
706        let path = dir.path().join("runs.db");
707
708        {
709            let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
710            s.create(mk("R-keep", "T-keep", 42)).await.unwrap();
711            s.append_step_entry(
712                &RunId::parse("R-keep").unwrap(),
713                StepEntry {
714                    step_id: crate::types::StepId::parse("ST-1").unwrap(),
715                    step_ref: Some("step-a".into()),
716                    status: Some("dispatched".into()),
717                    at: 43,
718                },
719            )
720            .await
721            .unwrap();
722            drop(s);
723            driver.shutdown().await.unwrap();
724        }
725
726        let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
727        let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
728        assert_eq!(got.task_id, TaskId::parse("T-keep").unwrap());
729        assert_eq!(got.step_entries.len(), 1);
730        assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
731        drop(s);
732        driver.shutdown().await.unwrap();
733    }
734
735    #[tokio::test]
736    async fn list_running_filters_by_status() {
737        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
738        s.create(mk("R-1", "T-1", 100)).await.unwrap();
739        s.create(mk("R-2", "T-2", 200)).await.unwrap();
740        s.create(mk("R-3", "T-3", 300)).await.unwrap();
741        s.update_status(&RunId::parse("R-2").unwrap(), RunStatus::Running)
742            .await
743            .unwrap();
744        s.update_status(&RunId::parse("R-3").unwrap(), RunStatus::Done)
745            .await
746            .unwrap();
747        let running = s.list_running().await.unwrap();
748        assert_eq!(running.len(), 1);
749        assert_eq!(running[0].id, RunId::parse("R-2").unwrap());
750        assert_eq!(running[0].status, RunStatus::Running);
751        drop(s);
752        driver.shutdown().await.unwrap();
753    }
754}