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 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// ──────────────────────────────────────────────────────────────────────────
555// tests
556// ──────────────────────────────────────────────────────────────────────────
557
558#[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}