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//!   input_json         TEXT,               -- opaque launch-input snapshot for resume, NULL when unset
24//!   created_at         INTEGER NOT NULL,
25//!   updated_at         INTEGER NOT NULL
26//! );
27//! CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);
28//! ```
29//!
30//! `degradations_json` (GH #32) and `input_json` (the resume launch-input
31//! snapshot) were both added after the initial release; each migration is
32//! applied idempotently on open via a `PRAGMA table_info(runs)` existence
33//! check followed by the matching `ALTER TABLE runs ADD COLUMN …` when
34//! missing, so pre-existing database files pick up the columns without a
35//! manual migration step. `input_json` is a nullable `TEXT` (no default) —
36//! rows written before resume support simply read back `None`.
37
38use 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
62/// Idempotently ensures a nullable column named `column` exists on `runs`,
63/// adding it via `ALTER TABLE … ADD COLUMN <column> <decl>` when a
64/// pre-existing database file was created before the column was introduced.
65/// Fresh databases get every column from [`SCHEMA_SQL`] directly; this only
66/// fires the `ALTER TABLE` on older files missing it.
67fn 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
84/// SQLite-backed persistent [`RunStore`].
85///
86/// Open with [`SqliteRunStore::open`] (file path) or
87/// [`SqliteRunStore::open_in_memory`] (tests). Both return the store plus
88/// an [`AsyncIsleDriver`] the caller must `shutdown().await` when done —
89/// dropping the driver without a shutdown call leaves the SQLite thread
90/// as-is until the process exits.
91pub struct SqliteRunStore {
92    isle: AsyncIsle,
93}
94
95impl SqliteRunStore {
96    /// Open (or create) a SQLite database file and run the schema
97    /// migrations.
98    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    /// Open an ephemeral in-memory database (tests, doctests).
110    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
126/// One `runs` SELECT row in column order: id, task_id, status,
127/// step_entries_json, degradations_json, operator_sid, result_ref_json,
128/// input_json, created_at, updated_at.
129type 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(&degradations_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    // Ids were minted by us before landing in the table; a prefix mismatch
172    // here means the row predates the issue #13 prefix reconciliation or
173    // the file was written by something else — fail loud either way.
174    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        // A single conditional UPDATE is the compare-and-set: the `AND
454        // status = ?from` predicate makes the read+set atomic at the SQLite
455        // level, so two concurrent resumes cannot both flip the same row.
456        // `rows_affected == 1` = we won the transition; `0` = the row was
457        // absent or no longer `from` (a racing transition already won).
458        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// ──────────────────────────────────────────────────────────────────────────
534// tests
535// ──────────────────────────────────────────────────────────────────────────
536
537#[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}