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//!   current_json       TEXT,               -- JSON object: slot -> `Assignee`, NULL when no slot is held
23//!   next_generation    INTEGER NOT NULL DEFAULT 0, -- the model's `G`
24//!   result_ref_json    TEXT,               -- JSON-encoded `serde_json::Value`, NULL when unset
25//!   input_json         TEXT,               -- opaque launch-input snapshot for resume, NULL when unset
26//!   created_at         INTEGER NOT NULL,
27//!   updated_at         INTEGER NOT NULL
28//! );
29//! CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);
30//! ```
31//!
32//! `degradations_json` (GH #32), `input_json` (the resume launch-input
33//! snapshot) and the assignment pair `current_json` / `next_generation`
34//! were all added after the initial release; each migration is applied
35//! idempotently on open via a `PRAGMA table_info(runs)` existence check
36//! followed by the matching `ALTER TABLE runs ADD COLUMN …` when missing,
37//! so pre-existing database files pick up the columns without a manual
38//! migration step. `input_json` and `current_json` are nullable `TEXT` (no
39//! default) — rows written before those features read back `None`
40//! (`current_json` `NULL` = no slot held); `next_generation` carries
41//! `DEFAULT 0` so a back-filled row starts at the launch value of `G`.
42//!
43//! `current_json` holds the whole `slot -> Assignee` map as one JSON
44//! object, not one row per slot: the map is read and rewritten whole on
45//! every assignment event anyway (the event has to bump the sibling
46//! `next_generation` in the same transaction), and nothing queries a Run
47//! *by* who holds one of its slots. A map that has gone empty is stored
48//! back as SQL `NULL`, so "no slot held" has exactly one representation on
49//! disk.
50//!
51//! `acquire_assignee`/`vacate_assignee` bump `next_generation` and rewrite
52//! `current_json` as a read-modify-write inside one `Immediate`
53//! transaction — the same shape as `append_step_entry`, and for the same
54//! reason: the increment-and-stamp spans two columns, which a conditional
55//! `UPDATE` (the `try_transition` compare-and-set) cannot express. Two
56//! acquires naming *different* slots take the same path, so the map merge
57//! is serialized too and neither can drop the other's entry.
58//!
59//! The two events have separate bodies rather than one parameterized one,
60//! because they no longer share a shape: an `Assign` always writes
61//! (**A8** — no precondition on the incumbent), while a `Vacant` first
62//! compares the seat's generation against the one its caller observed and
63//! writes nothing when they differ. Putting the comparison inside the same
64//! transaction as the removal is the whole point of the verb — see
65//! [`VacateOutcome`] — so it cannot be hoisted into a shared prologue.
66
67use super::{
68    Assignee, DegradationEntry, RunId, RunListFilter, RunRecord, RunStatus, RunStore,
69    RunStoreError, StepEntry, TaskId, VacateOutcome,
70};
71use async_trait::async_trait;
72use rusqlite::{params, OptionalExtension};
73use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
74use std::collections::BTreeMap;
75use std::path::Path;
76
77const SCHEMA_SQL: &str = "\
78CREATE TABLE IF NOT EXISTS runs (\
79  id                 TEXT PRIMARY KEY, \
80  task_id            TEXT NOT NULL, \
81  status             TEXT NOT NULL, \
82  step_entries_json  TEXT NOT NULL, \
83  degradations_json  TEXT NOT NULL DEFAULT '[]', \
84  operator_sid       TEXT, \
85  current_json       TEXT, \
86  next_generation    INTEGER NOT NULL DEFAULT 0, \
87  result_ref_json    TEXT, \
88  input_json         TEXT, \
89  created_at         INTEGER NOT NULL, \
90  updated_at         INTEGER NOT NULL\
91);\
92CREATE INDEX IF NOT EXISTS ix_runs_task_id ON runs(task_id, created_at);\
93";
94
95/// Idempotently ensures a nullable column named `column` exists on `runs`,
96/// adding it via `ALTER TABLE … ADD COLUMN <column> <decl>` when a
97/// pre-existing database file was created before the column was introduced.
98/// Fresh databases get every column from [`SCHEMA_SQL`] directly; this only
99/// fires the `ALTER TABLE` on older files missing it.
100fn migrate_add_column_if_missing(
101    conn: &rusqlite::Connection,
102    column: &str,
103    decl: &str,
104) -> rusqlite::Result<()> {
105    let mut stmt = conn.prepare("PRAGMA table_info(runs)")?;
106    let has_column = stmt
107        .query_map([], |row| row.get::<_, String>(1))?
108        .collect::<Result<Vec<String>, _>>()?
109        .iter()
110        .any(|name| name == column);
111    if !has_column {
112        conn.execute_batch(&format!("ALTER TABLE runs ADD COLUMN {column} {decl};"))?;
113    }
114    Ok(())
115}
116
117/// SQLite-backed persistent [`RunStore`].
118///
119/// Open with [`SqliteRunStore::open`] (file path) or
120/// [`SqliteRunStore::open_in_memory`] (tests). Both return the store plus
121/// an [`AsyncIsleDriver`] the caller must `shutdown().await` when done —
122/// dropping the driver without a shutdown call leaves the SQLite thread
123/// as-is until the process exits.
124pub struct SqliteRunStore {
125    isle: AsyncIsle,
126}
127
128impl SqliteRunStore {
129    /// Open (or create) a SQLite database file and run the schema
130    /// migrations.
131    pub async fn open(path: impl AsRef<Path>) -> Result<(Self, AsyncIsleDriver), RunStoreError> {
132        let (isle, driver) = AsyncIsle::spawn(path.as_ref().to_path_buf(), |conn| {
133            // The trace store (`SqliteRunTraceStore`) shares this file
134            // from its own confined connection; a short busy wait
135            // absorbs its write transactions instead of surfacing
136            // SQLITE_BUSY here.
137            conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
138            conn.execute_batch(SCHEMA_SQL)?;
139            migrate_add_column_if_missing(conn, "degradations_json", "TEXT NOT NULL DEFAULT '[]'")?;
140            migrate_add_column_if_missing(conn, "input_json", "TEXT")?;
141            migrate_add_column_if_missing(conn, "current_json", "TEXT")?;
142            migrate_add_column_if_missing(conn, "next_generation", "INTEGER NOT NULL DEFAULT 0")
143        })
144        .await
145        .map_err(map_isle_err)?;
146        Ok((Self { isle }, driver))
147    }
148
149    /// Open an ephemeral in-memory database (tests, doctests).
150    pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), RunStoreError> {
151        let (isle, driver) = AsyncIsle::open_in_memory(|conn| {
152            conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
153            conn.execute_batch(SCHEMA_SQL)?;
154            migrate_add_column_if_missing(conn, "degradations_json", "TEXT NOT NULL DEFAULT '[]'")?;
155            migrate_add_column_if_missing(conn, "input_json", "TEXT")?;
156            migrate_add_column_if_missing(conn, "current_json", "TEXT")?;
157            migrate_add_column_if_missing(conn, "next_generation", "INTEGER NOT NULL DEFAULT 0")
158        })
159        .await
160        .map_err(map_isle_err)?;
161        Ok((Self { isle }, driver))
162    }
163
164    /// The `Assign` half of the model's assignment event (§4.3 **A4**),
165    /// scoped to one slot: read `current_json` + `next_generation`, bump
166    /// the counter, stamp a fresh [`Assignee`] onto `slot`, write both
167    /// columns back. The other slots' entries are re-encoded exactly as
168    /// they were read.
169    ///
170    /// **A8**: there is no precondition on the incumbent — this always
171    /// writes. The conditional sibling is
172    /// [`Self::record_conditional_vacate`].
173    ///
174    /// Returns `Ok(None)` when no row matched, which the caller lifts to
175    /// [`RunStoreError::NotFound`].
176    async fn record_assign(
177        &self,
178        id: &RunId,
179        slot: &str,
180        op: String,
181        desc: String,
182    ) -> Result<Option<(u64, Option<Assignee>)>, RunStoreError> {
183        let id_str = id.to_string();
184        let slot = slot.to_string();
185        let updated_at = crate::types::now_unix() as i64;
186
187        self.isle
188            .call(move |conn| {
189                // Immediate — see `create`'s comment for the shared-file
190                // busy-wait rationale. Beyond that, the read-increment-
191                // stamp-write below MUST be one critical section: two
192                // acquires that read the same `next_generation` would hand
193                // out the same generation twice. A conditional UPDATE (the
194                // `try_transition` compare-and-set) cannot express this,
195                // because the new `current_json` depends on the value read
196                // from the sibling column.
197                let tx =
198                    conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
199                let Some((current_json, generation)) = read_assignment_columns(&tx, &id_str)?
200                else {
201                    return Ok(None);
202                };
203                let mut current = decode_current(current_json)?;
204                // A4: unconditional — the counter counts events, so an
205                // Assign to the incumbent still advances it. It is one
206                // counter for the whole Run: this bump is the same one an
207                // event on any other slot would make.
208                let generation = generation as u64 + 1;
209                // Q3: a brand-new instance carries the new generation;
210                // `previous` — the entry this slot held — is handed back to
211                // the caller untouched.
212                let previous = current.insert(
213                    slot.clone(),
214                    Assignee {
215                        op,
216                        desc,
217                        gen: generation,
218                    },
219                );
220                write_assignment_columns(&tx, &id_str, &current, generation, updated_at)?;
221                tx.commit()?;
222                Ok(Some((generation, previous)))
223            })
224            .await
225            .map_err(map_isle_err)
226    }
227
228    /// The `Vacant` half, conditional on `expected_gen` (§4.3 **A4** /
229    /// **A7** / **O8**): read the seat, and release it only while it still
230    /// holds the generation the caller observed.
231    ///
232    /// The comparison lives *inside* the same `Immediate` transaction as
233    /// the removal, which is the only place it can be atomic — the new
234    /// `current_json` is derived from the value read in that same
235    /// transaction, so a `WHERE` clause on the UPDATE could not express it
236    /// (the same reason `record_assign` is a read-modify-write). A
237    /// mismatch commits nothing at all: no removal, no counter bump, no
238    /// `updated_at`.
239    ///
240    /// Returns `Ok(None)` when no row matched, which the caller lifts to
241    /// [`RunStoreError::NotFound`].
242    async fn record_conditional_vacate(
243        &self,
244        id: &RunId,
245        slot: &str,
246        expected_gen: u64,
247    ) -> Result<Option<VacateOutcome>, RunStoreError> {
248        let id_str = id.to_string();
249        let slot = slot.to_string();
250        let updated_at = crate::types::now_unix() as i64;
251
252        self.isle
253            .call(move |conn| {
254                let tx =
255                    conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
256                let Some((current_json, generation)) = read_assignment_columns(&tx, &id_str)?
257                else {
258                    return Ok(None);
259                };
260                let mut current = decode_current(current_json)?;
261                match current.get(&slot) {
262                    Some(held) if held.gen == expected_gen => {}
263                    other => {
264                        // Nothing written, so the transaction is dropped
265                        // rather than committed — a refused release is not
266                        // an assignment event and must not advance `G`.
267                        return Ok(Some(VacateOutcome::Stale {
268                            current: other.cloned(),
269                        }));
270                    }
271                }
272                let Some(released) = current.remove(&slot) else {
273                    return Ok(Some(VacateOutcome::Stale { current: None }));
274                };
275                // A4: a release that happens advances the Run-wide counter
276                // exactly like an Assign does; it just mints no Assignee.
277                let generation = generation as u64 + 1;
278                write_assignment_columns(&tx, &id_str, &current, generation, updated_at)?;
279                tx.commit()?;
280                Ok(Some(VacateOutcome::Released {
281                    generation,
282                    released,
283                }))
284            })
285            .await
286            .map_err(map_isle_err)
287    }
288}
289
290/// `SELECT current_json, next_generation` for one Run inside an open
291/// transaction. `Ok(None)` = no such row.
292fn read_assignment_columns(
293    tx: &rusqlite::Transaction<'_>,
294    id_str: &str,
295) -> rusqlite::Result<Option<(Option<String>, i64)>> {
296    tx.query_row(
297        "SELECT current_json, next_generation FROM runs WHERE id = ?1",
298        params![id_str],
299        |row| Ok((row.get(0)?, row.get(1)?)),
300    )
301    .optional()
302}
303
304/// Decode the `slot -> Assignee` map. SQL `NULL` is the empty map — the one
305/// on-disk shape for "no slot held".
306fn decode_current(current_json: Option<String>) -> rusqlite::Result<BTreeMap<String, Assignee>> {
307    match current_json {
308        Some(text) => serde_json::from_str(&text)
309            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))),
310        None => Ok(BTreeMap::new()),
311    }
312}
313
314/// Write both assignment columns back. An emptied map goes back as SQL
315/// NULL, matching both the pre-assignment rows and a Run that has never
316/// been assigned.
317fn write_assignment_columns(
318    tx: &rusqlite::Transaction<'_>,
319    id_str: &str,
320    current: &BTreeMap<String, Assignee>,
321    generation: u64,
322    updated_at: i64,
323) -> rusqlite::Result<()> {
324    let next_json = if current.is_empty() {
325        None
326    } else {
327        Some(
328            serde_json::to_string(current)
329                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
330        )
331    };
332    tx.execute(
333        "UPDATE runs SET current_json = ?1, next_generation = ?2, updated_at = ?3 WHERE id = ?4",
334        params![next_json, generation as i64, updated_at, id_str],
335    )?;
336    Ok(())
337}
338
339fn map_isle_err(e: IsleError) -> RunStoreError {
340    RunStoreError::Other(format!("sqlite: {e}"))
341}
342
343/// One `runs` SELECT row in column order: id, task_id, status,
344/// step_entries_json, degradations_json, operator_sid, current_json,
345/// next_generation, result_ref_json, input_json, created_at, updated_at.
346///
347/// Position-coupled with [`RUN_SELECT_COLUMNS`], [`row_to_record`] and
348/// every `query_map` closure below — all of them move together.
349type RunRow = (
350    String,
351    String,
352    String,
353    String,
354    String,
355    Option<String>,
356    Option<String>,
357    i64,
358    Option<String>,
359    Option<String>,
360    i64,
361    i64,
362);
363
364const RUN_SELECT_COLUMNS: &str = "id, task_id, status, step_entries_json, degradations_json, \
365     operator_sid, current_json, next_generation, result_ref_json, input_json, created_at, \
366     updated_at";
367
368/// Read one `runs` row into [`RunRow`] positionally. Every `SELECT
369/// {RUN_SELECT_COLUMNS}` in this file goes through here, so the column
370/// order lives in exactly two places (the const and this function) instead
371/// of once per query.
372fn read_run_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<RunRow> {
373    Ok((
374        row.get::<_, String>(0)?,
375        row.get::<_, String>(1)?,
376        row.get::<_, String>(2)?,
377        row.get::<_, String>(3)?,
378        row.get::<_, String>(4)?,
379        row.get::<_, Option<String>>(5)?,
380        row.get::<_, Option<String>>(6)?,
381        row.get::<_, i64>(7)?,
382        row.get::<_, Option<String>>(8)?,
383        row.get::<_, Option<String>>(9)?,
384        row.get::<_, i64>(10)?,
385        row.get::<_, i64>(11)?,
386    ))
387}
388
389fn row_to_record(row: RunRow) -> Result<RunRecord, RunStoreError> {
390    let (
391        id,
392        task_id,
393        status_json,
394        step_entries_json,
395        degradations_json,
396        operator_sid,
397        current_json,
398        next_generation,
399        result_ref_json,
400        input_json,
401        created_at,
402        updated_at,
403    ) = row;
404    let status: RunStatus = serde_json::from_str(&status_json)
405        .map_err(|e| RunStoreError::Other(format!("decode status: {e}")))?;
406    let step_entries: Vec<StepEntry> = serde_json::from_str(&step_entries_json)
407        .map_err(|e| RunStoreError::Other(format!("decode step_entries: {e}")))?;
408    let degradations: Vec<DegradationEntry> = serde_json::from_str(&degradations_json)
409        .map_err(|e| RunStoreError::Other(format!("decode degradations: {e}")))?;
410    // A NULL `current_json` is a Run with no slot held — a legitimate
411    // state, not a decode failure. A non-NULL value that is not a
412    // `slot -> Assignee` object IS a failure and is surfaced: silently
413    // reading it back as "no slot held" would turn a corrupt (or
414    // wrong-shaped) column into an apparently unassigned Run, and a
415    // dispatch would then be routed nowhere with no explanation.
416    let current: BTreeMap<String, Assignee> = match current_json {
417        Some(text) => serde_json::from_str(&text)
418            .map_err(|e| RunStoreError::Other(format!("decode current: {e}")))?,
419        None => BTreeMap::new(),
420    };
421    let result_ref: Option<serde_json::Value> = match result_ref_json {
422        Some(text) => Some(
423            serde_json::from_str(&text)
424                .map_err(|e| RunStoreError::Other(format!("decode result_ref: {e}")))?,
425        ),
426        None => None,
427    };
428    // Ids were minted by us before landing in the table; a prefix mismatch
429    // here means the row predates the issue #13 prefix reconciliation or
430    // the file was written by something else — fail loud either way.
431    let id = RunId::parse(id).map_err(|e| RunStoreError::Other(format!("decode id: {e}")))?;
432    let task_id =
433        TaskId::parse(task_id).map_err(|e| RunStoreError::Other(format!("decode task_id: {e}")))?;
434    Ok(RunRecord {
435        id,
436        task_id,
437        status,
438        step_entries,
439        degradations,
440        operator_sid,
441        current,
442        next_generation: next_generation as u64,
443        result_ref,
444        input_json,
445        created_at: created_at as u64,
446        updated_at: updated_at as u64,
447    })
448}
449
450#[async_trait]
451impl RunStore for SqliteRunStore {
452    fn name(&self) -> &str {
453        "sqlite"
454    }
455
456    async fn create(&self, record: RunRecord) -> Result<(), RunStoreError> {
457        // **A2** on the way in — see `RunStore::create`. Checked before any
458        // encoding so a rejected record never reaches the connection.
459        record.validate_assignment_generations()?;
460        let id = record.id.to_string();
461        let id_for_conflict = record.id.clone();
462        let task_id = record.task_id.to_string();
463        let status_json = serde_json::to_string(&record.status)
464            .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
465        let step_entries_json = serde_json::to_string(&record.step_entries)
466            .map_err(|e| RunStoreError::Other(format!("encode step_entries: {e}")))?;
467        let degradations_json = serde_json::to_string(&record.degradations)
468            .map_err(|e| RunStoreError::Other(format!("encode degradations: {e}")))?;
469        let operator_sid = record.operator_sid.clone();
470        // An empty map (no slot held) persists as SQL NULL rather than the
471        // JSON literal `{}`, so `row_to_record` can read absence straight
472        // off the column type and a never-assigned Run looks identical to a
473        // pre-assignment row.
474        let current_json = if record.current.is_empty() {
475            None
476        } else {
477            Some(
478                serde_json::to_string(&record.current)
479                    .map_err(|e| RunStoreError::Other(format!("encode current: {e}")))?,
480            )
481        };
482        let next_generation = record.next_generation as i64;
483        let result_ref_json = record
484            .result_ref
485            .as_ref()
486            .map(serde_json::to_string)
487            .transpose()
488            .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
489        let input_json = record.input_json.clone();
490        let created_at = record.created_at as i64;
491        let updated_at = record.updated_at as i64;
492
493        self.isle
494            .call(move |conn| {
495                // Immediate: the trace store shares this file from its own
496                // connection; RESERVED-up-front keeps the busy wait
497                // effective (a DEFERRED read-then-upgrade racing it gets
498                // an instant SQLITE_BUSY instead).
499                let tx =
500                    conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
501                let exists: i64 = tx.query_row(
502                    "SELECT COUNT(*) FROM runs WHERE id = ?1",
503                    params![id],
504                    |row| row.get(0),
505                )?;
506                if exists > 0 {
507                    return Err(rusqlite::Error::SqliteFailure(
508                        rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
509                        Some(format!("__mlua_swarm_duplicate:{id}")),
510                    ));
511                }
512                tx.execute(
513                    "INSERT INTO runs (id, task_id, status, step_entries_json, \
514                     degradations_json, operator_sid, current_json, next_generation, \
515                     result_ref_json, input_json, created_at, updated_at) \
516                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
517                    params![
518                        id,
519                        task_id,
520                        status_json,
521                        step_entries_json,
522                        degradations_json,
523                        operator_sid,
524                        current_json,
525                        next_generation,
526                        result_ref_json,
527                        input_json,
528                        created_at,
529                        updated_at,
530                    ],
531                )?;
532                tx.commit()?;
533                Ok(())
534            })
535            .await
536            .map_err(|e| match &e {
537                IsleError::Sqlite(rusqlite::Error::SqliteFailure(_, Some(msg)))
538                    if msg.starts_with("__mlua_swarm_duplicate:") =>
539                {
540                    RunStoreError::Duplicate(id_for_conflict.clone())
541                }
542                _ => map_isle_err(e),
543            })
544    }
545
546    async fn get(&self, id: &RunId) -> Result<RunRecord, RunStoreError> {
547        let id_str = id.to_string();
548        let id_for_notfound = id.clone();
549        let row = self
550            .isle
551            .call(move |conn| {
552                conn.query_row(
553                    &format!("SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE id = ?1"),
554                    params![id_str],
555                    read_run_row,
556                )
557                .optional()
558            })
559            .await
560            .map_err(map_isle_err)?;
561        match row {
562            Some(row) => row_to_record(row),
563            None => Err(RunStoreError::NotFound(id_for_notfound)),
564        }
565    }
566
567    async fn list_by_task(&self, task_id: &TaskId) -> Result<Vec<RunRecord>, RunStoreError> {
568        let task_id_str = task_id.to_string();
569        let rows = self
570            .isle
571            .call(move |conn| {
572                let mut stmt = conn.prepare(&format!(
573                    "SELECT {RUN_SELECT_COLUMNS} FROM runs \
574                     WHERE task_id = ?1 ORDER BY created_at ASC"
575                ))?;
576                let iter = stmt.query_map(params![task_id_str], read_run_row)?;
577                let mut out = Vec::new();
578                for r in iter {
579                    out.push(r?);
580                }
581                Ok(out)
582            })
583            .await
584            .map_err(map_isle_err)?;
585        rows.into_iter().map(row_to_record).collect()
586    }
587
588    async fn append_step_entry(&self, id: &RunId, entry: StepEntry) -> Result<(), RunStoreError> {
589        let id_str = id.to_string();
590        let id_for_notfound = id.clone();
591        let updated_at = crate::types::now_unix() as i64;
592
593        let updated = self
594            .isle
595            .call(move |conn| {
596                // Immediate — see `create`'s comment (shared-file busy-wait
597                // effectiveness).
598                let tx =
599                    conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
600                let existing: Option<String> = tx
601                    .query_row(
602                        "SELECT step_entries_json FROM runs WHERE id = ?1",
603                        params![id_str],
604                        |row| row.get(0),
605                    )
606                    .optional()?;
607                let Some(existing_json) = existing else {
608                    return Ok(false);
609                };
610                let mut entries: Vec<StepEntry> = serde_json::from_str(&existing_json)
611                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
612                entries.push(entry);
613                let new_json = serde_json::to_string(&entries)
614                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
615                tx.execute(
616                    "UPDATE runs SET step_entries_json = ?1, updated_at = ?2 WHERE id = ?3",
617                    params![new_json, updated_at, id_str],
618                )?;
619                tx.commit()?;
620                Ok(true)
621            })
622            .await
623            .map_err(map_isle_err)?;
624
625        if updated {
626            Ok(())
627        } else {
628            Err(RunStoreError::NotFound(id_for_notfound))
629        }
630    }
631
632    async fn append_degradation(
633        &self,
634        id: &RunId,
635        entry: DegradationEntry,
636    ) -> Result<(), RunStoreError> {
637        let id_str = id.to_string();
638        let id_for_notfound = id.clone();
639        let updated_at = crate::types::now_unix() as i64;
640
641        let updated = self
642            .isle
643            .call(move |conn| {
644                // Immediate — see `create`'s comment (shared-file busy-wait
645                // effectiveness).
646                let tx =
647                    conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
648                let existing: Option<String> = tx
649                    .query_row(
650                        "SELECT degradations_json FROM runs WHERE id = ?1",
651                        params![id_str],
652                        |row| row.get(0),
653                    )
654                    .optional()?;
655                let Some(existing_json) = existing else {
656                    return Ok(false);
657                };
658                let mut entries: Vec<DegradationEntry> = serde_json::from_str(&existing_json)
659                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
660                entries.push(entry);
661                let new_json = serde_json::to_string(&entries)
662                    .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
663                tx.execute(
664                    "UPDATE runs SET degradations_json = ?1, updated_at = ?2 WHERE id = ?3",
665                    params![new_json, updated_at, id_str],
666                )?;
667                tx.commit()?;
668                Ok(true)
669            })
670            .await
671            .map_err(map_isle_err)?;
672
673        if updated {
674            Ok(())
675        } else {
676            Err(RunStoreError::NotFound(id_for_notfound))
677        }
678    }
679
680    async fn update_status(&self, id: &RunId, status: RunStatus) -> Result<(), RunStoreError> {
681        let id_str = id.to_string();
682        let id_for_notfound = id.clone();
683        let status_json = serde_json::to_string(&status)
684            .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
685        let updated_at = crate::types::now_unix() as i64;
686        let n = self
687            .isle
688            .call(move |conn| {
689                conn.execute(
690                    "UPDATE runs SET status = ?1, updated_at = ?2 WHERE id = ?3",
691                    params![status_json, updated_at, id_str],
692                )
693            })
694            .await
695            .map_err(map_isle_err)?;
696        if n == 0 {
697            Err(RunStoreError::NotFound(id_for_notfound))
698        } else {
699            Ok(())
700        }
701    }
702
703    async fn try_transition(
704        &self,
705        id: &RunId,
706        from: RunStatus,
707        to: RunStatus,
708    ) -> Result<bool, RunStoreError> {
709        let id_str = id.to_string();
710        let from_json = serde_json::to_string(&from)
711            .map_err(|e| RunStoreError::Other(format!("encode from status: {e}")))?;
712        let to_json = serde_json::to_string(&to)
713            .map_err(|e| RunStoreError::Other(format!("encode to status: {e}")))?;
714        let updated_at = crate::types::now_unix() as i64;
715        // A single conditional UPDATE is the compare-and-set: the `AND
716        // status = ?from` predicate makes the read+set atomic at the SQLite
717        // level, so two concurrent resumes cannot both flip the same row.
718        // `rows_affected == 1` = we won the transition; `0` = the row was
719        // absent or no longer `from` (a racing transition already won).
720        let n = self
721            .isle
722            .call(move |conn| {
723                conn.execute(
724                    "UPDATE runs SET status = ?1, updated_at = ?2 WHERE id = ?3 AND status = ?4",
725                    params![to_json, updated_at, id_str, from_json],
726                )
727            })
728            .await
729            .map_err(map_isle_err)?;
730        Ok(n == 1)
731    }
732
733    async fn acquire_assignee(
734        &self,
735        id: &RunId,
736        slot: &str,
737        op: &str,
738        desc: &str,
739    ) -> Result<(u64, Option<Assignee>), RunStoreError> {
740        // Refuse before touching the row — a Run must never come to hold an
741        // unnamed assignment (A9) or one filed under no slot, and a
742        // rejected acquire must not have burned a generation.
743        if slot.is_empty() {
744            return Err(RunStoreError::AssigneeSlotRequired);
745        }
746        if desc.trim().is_empty() {
747            return Err(RunStoreError::AssigneeDescRequired);
748        }
749        // A8: no precondition on the slot's incumbent — whoever asks, wins.
750        self.record_assign(id, slot, op.to_string(), desc.to_string())
751            .await?
752            .ok_or_else(|| RunStoreError::NotFound(id.clone()))
753    }
754
755    async fn vacate_assignee(
756        &self,
757        id: &RunId,
758        slot: &str,
759        expected_gen: u64,
760    ) -> Result<VacateOutcome, RunStoreError> {
761        if slot.is_empty() {
762            return Err(RunStoreError::AssigneeSlotRequired);
763        }
764        self.record_conditional_vacate(id, slot, expected_gen)
765            .await?
766            .ok_or_else(|| RunStoreError::NotFound(id.clone()))
767    }
768
769    async fn set_result(
770        &self,
771        id: &RunId,
772        result_ref: serde_json::Value,
773    ) -> Result<(), RunStoreError> {
774        let id_str = id.to_string();
775        let id_for_notfound = id.clone();
776        let result_ref_json = serde_json::to_string(&result_ref)
777            .map_err(|e| RunStoreError::Other(format!("encode result_ref: {e}")))?;
778        let updated_at = crate::types::now_unix() as i64;
779        let n = self
780            .isle
781            .call(move |conn| {
782                conn.execute(
783                    "UPDATE runs SET result_ref_json = ?1, updated_at = ?2 WHERE id = ?3",
784                    params![result_ref_json, updated_at, id_str],
785                )
786            })
787            .await
788            .map_err(map_isle_err)?;
789        if n == 0 {
790            Err(RunStoreError::NotFound(id_for_notfound))
791        } else {
792            Ok(())
793        }
794    }
795
796    async fn set_input_json(&self, id: &RunId, input_json: String) -> Result<(), RunStoreError> {
797        let id_str = id.to_string();
798        let id_for_notfound = id.clone();
799        let updated_at = crate::types::now_unix() as i64;
800        let n = self
801            .isle
802            .call(move |conn| {
803                conn.execute(
804                    "UPDATE runs SET input_json = ?1, updated_at = ?2 WHERE id = ?3",
805                    params![input_json, updated_at, id_str],
806                )
807            })
808            .await
809            .map_err(map_isle_err)?;
810        if n == 0 {
811            Err(RunStoreError::NotFound(id_for_notfound))
812        } else {
813            Ok(())
814        }
815    }
816
817    async fn list_running(&self) -> Result<Vec<RunRecord>, RunStoreError> {
818        let status_json = serde_json::to_string(&RunStatus::Running)
819            .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
820        let rows = self
821            .isle
822            .call(move |conn| {
823                let mut stmt = conn.prepare(&format!(
824                    "SELECT {RUN_SELECT_COLUMNS} FROM runs WHERE status = ?1"
825                ))?;
826                let iter = stmt.query_map(params![status_json], read_run_row)?;
827                let mut out = Vec::new();
828                for r in iter {
829                    out.push(r?);
830                }
831                Ok(out)
832            })
833            .await
834            .map_err(map_isle_err)?;
835        rows.into_iter().map(row_to_record).collect()
836    }
837
838    async fn list(&self, filter: &RunListFilter) -> Result<Vec<RunRecord>, RunStoreError> {
839        let task_id = filter.task_id.as_ref().map(|t| t.to_string());
840        let status_json = filter
841            .status
842            .map(|s| serde_json::to_string(&s))
843            .transpose()
844            .map_err(|e| RunStoreError::Other(format!("encode status: {e}")))?;
845        let limit = filter.limit.map(|l| l as i64).unwrap_or(-1);
846        let offset = filter.offset.map(|o| o as i64).unwrap_or(0);
847        let rows = self
848            .isle
849            .call(move |conn| {
850                // `?1 IS NULL OR …` folds each optional filter into one
851                // statement; `LIMIT -1` is SQLite's "no cap". `rowid`
852                // breaks `created_at` ties newest-insertion-first.
853                let mut stmt = conn.prepare(&format!(
854                    "SELECT {RUN_SELECT_COLUMNS} FROM runs \
855                     WHERE (?1 IS NULL OR task_id = ?1) \
856                       AND (?2 IS NULL OR status = ?2) \
857                     ORDER BY created_at DESC, rowid DESC \
858                     LIMIT ?3 OFFSET ?4"
859                ))?;
860                let iter =
861                    stmt.query_map(params![task_id, status_json, limit, offset], read_run_row)?;
862                let mut out = Vec::new();
863                for r in iter {
864                    out.push(r?);
865                }
866                Ok(out)
867            })
868            .await
869            .map_err(map_isle_err)?;
870        rows.into_iter().map(row_to_record).collect()
871    }
872
873    async fn delete(&self, id: &RunId) -> Result<(), RunStoreError> {
874        let id_str = id.to_string();
875        let id_for_notfound = id.clone();
876        let n = self
877            .isle
878            .call(move |conn| conn.execute("DELETE FROM runs WHERE id = ?1", params![id_str]))
879            .await
880            .map_err(map_isle_err)?;
881        if n == 0 {
882            Err(RunStoreError::NotFound(id_for_notfound))
883        } else {
884            Ok(())
885        }
886    }
887}
888
889// ──────────────────────────────────────────────────────────────────────────
890// tests
891// ──────────────────────────────────────────────────────────────────────────
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use serde_json::json;
897
898    fn mk(id: &str, task_id: &str, created_at: u64) -> RunRecord {
899        RunRecord {
900            id: RunId::parse(id).unwrap(),
901            task_id: TaskId::parse(task_id).unwrap(),
902            status: RunStatus::Pending,
903            step_entries: vec![],
904            degradations: vec![],
905            operator_sid: None,
906            current: Default::default(),
907            next_generation: 0,
908            result_ref: None,
909            input_json: None,
910            created_at,
911            updated_at: created_at,
912        }
913    }
914
915    fn mk_degradation(tool: &str, at: u64) -> DegradationEntry {
916        DegradationEntry {
917            tool: tool.to_string(),
918            error: "boom".to_string(),
919            fallback: "cached-default".to_string(),
920            note: None,
921            step_ref: Some("worker".to_string()),
922            attempt: Some(1),
923            at,
924        }
925    }
926
927    #[tokio::test]
928    async fn create_then_get() {
929        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
930        s.create(mk("R-1", "T-1", 100)).await.unwrap();
931        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
932        assert_eq!(got.task_id, TaskId::parse("T-1").unwrap());
933        assert_eq!(got.status, RunStatus::Pending);
934        assert!(got.step_entries.is_empty());
935        assert_eq!(got.result_ref, None);
936        drop(s);
937        driver.shutdown().await.unwrap();
938    }
939
940    /// **A2** at `create`, in parity with `InMemoryRunStore` — the check
941    /// belongs to the trait contract, not to one backend, and it runs
942    /// before any encoding so the connection never sees the row.
943    #[tokio::test]
944    async fn create_rejects_a_holder_generation_above_the_counter() {
945        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
946        let mut record = mk("R-1", "T-1", 100);
947        record.current.insert(
948            SLOT_A.to_string(),
949            Assignee {
950                op: "S-seeded".to_string(),
951                desc: "seeded straight into the record".to_string(),
952                gen: 99,
953            },
954        );
955
956        let err = s.create(record).await.unwrap_err();
957        assert!(
958            matches!(
959                err,
960                RunStoreError::AssigneeGenerationAhead {
961                    gen: 99,
962                    next_generation: 0,
963                    ..
964                }
965            ),
966            "got: {err:?}"
967        );
968        assert!(
969            matches!(
970                s.get(&RunId::parse("R-1").unwrap()).await.unwrap_err(),
971                RunStoreError::NotFound(_)
972            ),
973            "a refused create must leave no row behind"
974        );
975        drop(s);
976        driver.shutdown().await.unwrap();
977    }
978
979    #[tokio::test]
980    async fn duplicate_create_rejected() {
981        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
982        s.create(mk("R-1", "T-1", 100)).await.unwrap();
983        let err = s.create(mk("R-1", "T-1", 200)).await.unwrap_err();
984        assert!(matches!(err, RunStoreError::Duplicate(_)), "got: {err:?}");
985        drop(s);
986        driver.shutdown().await.unwrap();
987    }
988
989    #[tokio::test]
990    async fn get_missing_returns_not_found() {
991        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
992        let err = s.get(&RunId::parse("R-nope").unwrap()).await.unwrap_err();
993        assert!(matches!(err, RunStoreError::NotFound(_)));
994        drop(s);
995        driver.shutdown().await.unwrap();
996    }
997
998    #[tokio::test]
999    async fn list_by_task_filters_and_orders_ascending() {
1000        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1001        s.create(mk("R-1", "T-1", 300)).await.unwrap();
1002        s.create(mk("R-2", "T-2", 50)).await.unwrap();
1003        s.create(mk("R-3", "T-1", 100)).await.unwrap();
1004        let list = s
1005            .list_by_task(&TaskId::parse("T-1").unwrap())
1006            .await
1007            .unwrap();
1008        let ids: Vec<_> = list.iter().map(|r| r.id.to_string()).collect();
1009        assert_eq!(ids, vec!["R-3", "R-1"]);
1010        drop(s);
1011        driver.shutdown().await.unwrap();
1012    }
1013
1014    #[tokio::test]
1015    async fn append_step_entry_accumulates_in_order() {
1016        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1017        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1018        s.append_step_entry(
1019            &RunId::parse("R-1").unwrap(),
1020            StepEntry::basic(
1021                crate::types::StepId::parse("ST-1").unwrap(),
1022                Some("step-a".into()),
1023                Some("dispatched".into()),
1024                None,
1025                101,
1026            ),
1027        )
1028        .await
1029        .unwrap();
1030        s.append_step_entry(
1031            &RunId::parse("R-1").unwrap(),
1032            StepEntry::basic(
1033                crate::types::StepId::parse("ST-2").unwrap(),
1034                Some("step-b".into()),
1035                Some("passed".into()),
1036                None,
1037                102,
1038            ),
1039        )
1040        .await
1041        .unwrap();
1042        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1043        assert_eq!(got.step_entries.len(), 2);
1044        assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
1045        assert_eq!(got.step_entries[1].step_ref, Some("step-b".into()));
1046        drop(s);
1047        driver.shutdown().await.unwrap();
1048    }
1049
1050    #[tokio::test]
1051    async fn append_step_entry_unknown_run_fails() {
1052        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1053        let err = s
1054            .append_step_entry(
1055                &RunId::parse("R-nope").unwrap(),
1056                StepEntry::basic(
1057                    crate::types::StepId::parse("ST-1").unwrap(),
1058                    None,
1059                    None,
1060                    None,
1061                    1,
1062                ),
1063            )
1064            .await
1065            .unwrap_err();
1066        assert!(matches!(err, RunStoreError::NotFound(_)));
1067        drop(s);
1068        driver.shutdown().await.unwrap();
1069    }
1070
1071    #[tokio::test]
1072    async fn append_degradation_accumulates_in_order() {
1073        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1074        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1075        s.append_degradation(
1076            &RunId::parse("R-1").unwrap(),
1077            mk_degradation("web_search", 101),
1078        )
1079        .await
1080        .unwrap();
1081        s.append_degradation(
1082            &RunId::parse("R-1").unwrap(),
1083            mk_degradation("code_exec", 102),
1084        )
1085        .await
1086        .unwrap();
1087        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1088        assert_eq!(got.degradations.len(), 2);
1089        assert_eq!(got.degradations[0].tool, "web_search");
1090        assert_eq!(got.degradations[1].tool, "code_exec");
1091        drop(s);
1092        driver.shutdown().await.unwrap();
1093    }
1094
1095    #[tokio::test]
1096    async fn append_degradation_unknown_run_fails() {
1097        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1098        let err = s
1099            .append_degradation(
1100                &RunId::parse("R-nope").unwrap(),
1101                mk_degradation("web_search", 1),
1102            )
1103            .await
1104            .unwrap_err();
1105        assert!(matches!(err, RunStoreError::NotFound(_)));
1106        drop(s);
1107        driver.shutdown().await.unwrap();
1108    }
1109
1110    #[tokio::test]
1111    async fn append_degradation_bumps_updated_at() {
1112        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1113        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1114        s.append_degradation(
1115            &RunId::parse("R-1").unwrap(),
1116            mk_degradation("web_search", 200),
1117        )
1118        .await
1119        .unwrap();
1120        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1121        assert!(got.updated_at > 100);
1122        drop(s);
1123        driver.shutdown().await.unwrap();
1124    }
1125
1126    #[tokio::test]
1127    async fn update_status_persists() {
1128        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1129        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1130        s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Done)
1131            .await
1132            .unwrap();
1133        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1134        assert_eq!(got.status, RunStatus::Done);
1135        drop(s);
1136        driver.shutdown().await.unwrap();
1137    }
1138
1139    #[tokio::test]
1140    async fn set_result_persists() {
1141        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1142        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1143        s.set_result(&RunId::parse("R-1").unwrap(), json!({"ok": true}))
1144            .await
1145            .unwrap();
1146        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1147        assert_eq!(got.result_ref, Some(json!({"ok": true})));
1148        drop(s);
1149        driver.shutdown().await.unwrap();
1150    }
1151
1152    #[tokio::test]
1153    async fn persists_across_reopen() {
1154        let dir = tempfile::tempdir().unwrap();
1155        let path = dir.path().join("runs.db");
1156
1157        {
1158            let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1159            s.create(mk("R-keep", "T-keep", 42)).await.unwrap();
1160            s.append_step_entry(
1161                &RunId::parse("R-keep").unwrap(),
1162                StepEntry::basic(
1163                    crate::types::StepId::parse("ST-1").unwrap(),
1164                    Some("step-a".into()),
1165                    Some("dispatched".into()),
1166                    None,
1167                    43,
1168                ),
1169            )
1170            .await
1171            .unwrap();
1172            drop(s);
1173            driver.shutdown().await.unwrap();
1174        }
1175
1176        let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1177        let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
1178        assert_eq!(got.task_id, TaskId::parse("T-keep").unwrap());
1179        assert_eq!(got.step_entries.len(), 1);
1180        assert_eq!(got.step_entries[0].step_ref, Some("step-a".into()));
1181        drop(s);
1182        driver.shutdown().await.unwrap();
1183    }
1184
1185    #[tokio::test]
1186    async fn list_running_filters_by_status() {
1187        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1188        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1189        s.create(mk("R-2", "T-2", 200)).await.unwrap();
1190        s.create(mk("R-3", "T-3", 300)).await.unwrap();
1191        s.update_status(&RunId::parse("R-2").unwrap(), RunStatus::Running)
1192            .await
1193            .unwrap();
1194        s.update_status(&RunId::parse("R-3").unwrap(), RunStatus::Done)
1195            .await
1196            .unwrap();
1197        let running = s.list_running().await.unwrap();
1198        assert_eq!(running.len(), 1);
1199        assert_eq!(running[0].id, RunId::parse("R-2").unwrap());
1200        assert_eq!(running[0].status, RunStatus::Running);
1201        drop(s);
1202        driver.shutdown().await.unwrap();
1203    }
1204
1205    #[tokio::test]
1206    async fn try_transition_is_atomic_compare_and_set() {
1207        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1208        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1209        s.update_status(&RunId::parse("R-1").unwrap(), RunStatus::Interrupted)
1210            .await
1211            .unwrap();
1212
1213        let first = s
1214            .try_transition(
1215                &RunId::parse("R-1").unwrap(),
1216                RunStatus::Interrupted,
1217                RunStatus::Running,
1218            )
1219            .await
1220            .unwrap();
1221        assert!(first, "first CAS must flip Interrupted -> Running");
1222        assert_eq!(
1223            s.get(&RunId::parse("R-1").unwrap()).await.unwrap().status,
1224            RunStatus::Running
1225        );
1226
1227        let second = s
1228            .try_transition(
1229                &RunId::parse("R-1").unwrap(),
1230                RunStatus::Interrupted,
1231                RunStatus::Running,
1232            )
1233            .await
1234            .unwrap();
1235        assert!(
1236            !second,
1237            "a racing second CAS must not flip a now-Running row"
1238        );
1239
1240        let absent = s
1241            .try_transition(
1242                &RunId::parse("R-nope").unwrap(),
1243                RunStatus::Interrupted,
1244                RunStatus::Running,
1245            )
1246            .await
1247            .unwrap();
1248        assert!(!absent, "an absent Run must report false, not error");
1249        drop(s);
1250        driver.shutdown().await.unwrap();
1251    }
1252
1253    // ── assignment axis (model §4.3) ──────────────────────────────────
1254
1255    /// The two slots (Blueprint-declared Operator seats) the tests below
1256    /// assign to — the shipped per-lane alias shape, where a Blueprint
1257    /// declares one seat per phase.
1258    const SLOT_A: &str = "phase-a-op";
1259    const SLOT_B: &str = "phase-b-op";
1260
1261    /// A4: a launched Run starts with every slot Vacant and `G == 0`, and
1262    /// both columns round-trip through the row decode.
1263    #[tokio::test]
1264    async fn launch_starts_vacant_at_generation_zero() {
1265        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1266        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1267        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1268        assert!(got.current.is_empty(), "no slot is held at launch");
1269        assert_eq!(got.next_generation, 0);
1270        drop(s);
1271        driver.shutdown().await.unwrap();
1272    }
1273
1274    /// A4: every event advances `G` by one and the FIRST Assign lands on
1275    /// `1`. A8: re-acquiring for the incumbent still advances it.
1276    #[tokio::test]
1277    async fn acquire_advances_generation_even_for_the_same_op() {
1278        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1279        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1280        let id = RunId::parse("R-1").unwrap();
1281
1282        let (gen, previous) = s
1283            .acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1284            .await
1285            .unwrap();
1286        assert_eq!(gen, 1, "the first Assign stamps generation 1");
1287        assert_eq!(previous, None);
1288
1289        let (gen, previous) = s
1290            .acquire_assignee(&id, SLOT_A, "S-a1", "same holder, new event")
1291            .await
1292            .unwrap();
1293        assert_eq!(gen, 2, "A4: a repeat Assign for the same op still bumps");
1294        assert_eq!(previous.expect("displaced holder").gen, 1);
1295
1296        let got = s.get(&id).await.unwrap();
1297        assert_eq!(got.next_generation, 2);
1298        assert_eq!(
1299            got.current.len(),
1300            1,
1301            "A1: re-assigning a slot leaves it with exactly one holder"
1302        );
1303        let holder = &got.current[SLOT_A];
1304        assert_eq!(holder.gen, 2);
1305        assert_eq!(holder.desc, "same holder, new event");
1306        drop(s);
1307        driver.shutdown().await.unwrap();
1308    }
1309
1310    /// The slots are independent through the column: writing one slot
1311    /// re-encodes the other's entry untouched instead of replacing the map.
1312    #[tokio::test]
1313    async fn assigning_one_slot_leaves_the_others_intact() {
1314        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1315        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1316        let id = RunId::parse("R-1").unwrap();
1317
1318        s.acquire_assignee(&id, SLOT_A, "S-a1", "holds phase a")
1319            .await
1320            .unwrap();
1321        let got = s.get(&id).await.unwrap();
1322        assert!(
1323            !got.current.contains_key(SLOT_B),
1324            "an unassigned slot has no entry — that absence IS its Vacant"
1325        );
1326
1327        s.acquire_assignee(&id, SLOT_B, "S-b2", "holds phase b")
1328            .await
1329            .unwrap();
1330        let got = s.get(&id).await.unwrap();
1331        assert_eq!(got.current[SLOT_A].op, "S-a1", "the first seat survived");
1332        assert_eq!(got.current[SLOT_B].op, "S-b2");
1333        drop(s);
1334        driver.shutdown().await.unwrap();
1335    }
1336
1337    /// A4 is Run-wide, not per slot: interleaved assignments to two slots
1338    /// walk ONE counter, so any two holders can be ordered by `gen`.
1339    #[tokio::test]
1340    async fn the_generation_counter_is_shared_across_slots() {
1341        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1342        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1343        let id = RunId::parse("R-1").unwrap();
1344
1345        let (first, _) = s
1346            .acquire_assignee(&id, SLOT_A, "S-a1", "holds phase a")
1347            .await
1348            .unwrap();
1349        let (second, _) = s
1350            .acquire_assignee(&id, SLOT_B, "S-b2", "holds phase b")
1351            .await
1352            .unwrap();
1353        let (third, _) = s
1354            .acquire_assignee(&id, SLOT_A, "S-a3", "takes over phase a")
1355            .await
1356            .unwrap();
1357
1358        assert_eq!(
1359            (first, second, third),
1360            (1, 2, 3),
1361            "a second slot does not start its own counter at 1"
1362        );
1363
1364        let got = s.get(&id).await.unwrap();
1365        assert_eq!(got.next_generation, 3);
1366        assert_eq!(got.current[SLOT_A].gen, 3);
1367        assert_eq!(got.current[SLOT_B].gen, 2);
1368        drop(s);
1369        driver.shutdown().await.unwrap();
1370    }
1371
1372    /// A4 (Vacant side): releasing bumps `G` too; the next Assign continues
1373    /// from the bumped value.
1374    #[tokio::test]
1375    async fn vacate_advances_generation_and_clears_the_holder() {
1376        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1377        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1378        let id = RunId::parse("R-1").unwrap();
1379
1380        s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1381            .await
1382            .unwrap();
1383        let outcome = s.vacate_assignee(&id, SLOT_A, 1).await.unwrap();
1384        assert_eq!(
1385            outcome,
1386            VacateOutcome::Released {
1387                generation: 2,
1388                released: Assignee {
1389                    op: "S-a1".into(),
1390                    desc: "first hold".into(),
1391                    gen: 1,
1392                },
1393            },
1394            "A4: a Vacant that happens is an event and advances G"
1395        );
1396
1397        let got = s.get(&id).await.unwrap();
1398        assert!(
1399            !got.current.contains_key(SLOT_A),
1400            "R2: the Run stays, the holder does not"
1401        );
1402        assert_eq!(got.next_generation, 2);
1403
1404        let (gen, previous) = s
1405            .acquire_assignee(&id, SLOT_A, "S-b2", "after release")
1406            .await
1407            .unwrap();
1408        assert_eq!(gen, 3, "the next Assign continues from the bumped counter");
1409        assert_eq!(
1410            previous, None,
1411            "nothing was displaced — the slot was Vacant"
1412        );
1413        drop(s);
1414        driver.shutdown().await.unwrap();
1415    }
1416
1417    /// A Vacant applies to the named slot only — the other seats keep the
1418    /// holders they had, across the column round-trip.
1419    #[tokio::test]
1420    async fn vacate_releases_only_the_named_slot() {
1421        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1422        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1423        let id = RunId::parse("R-1").unwrap();
1424
1425        s.acquire_assignee(&id, SLOT_A, "S-a1", "holds phase a")
1426            .await
1427            .unwrap();
1428        s.acquire_assignee(&id, SLOT_B, "S-b2", "holds phase b")
1429            .await
1430            .unwrap();
1431
1432        let outcome = s.vacate_assignee(&id, SLOT_A, 1).await.unwrap();
1433        assert!(
1434            matches!(&outcome, VacateOutcome::Released { released, .. } if released.op == "S-a1"),
1435            "got: {outcome:?}"
1436        );
1437
1438        let got = s.get(&id).await.unwrap();
1439        assert!(!got.current.contains_key(SLOT_A));
1440        assert_eq!(
1441            got.current[SLOT_B].op, "S-b2",
1442            "vacating one seat must not empty another"
1443        );
1444        drop(s);
1445        driver.shutdown().await.unwrap();
1446    }
1447
1448    /// The defect this verb exists for, across the column round-trip: a
1449    /// release naming a generation the seat no longer holds must leave the
1450    /// current holder — and the counter — untouched.
1451    #[tokio::test]
1452    async fn a_stale_release_does_not_disturb_the_current_holder() {
1453        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1454        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1455        let id = RunId::parse("R-1").unwrap();
1456
1457        // What the releasing caller read.
1458        let (observed_gen, _) = s
1459            .acquire_assignee(&id, SLOT_A, "S-away", "the holder that went quiet")
1460            .await
1461            .unwrap();
1462        // What landed while it was deciding (A8: acquire never excludes).
1463        s.acquire_assignee(&id, SLOT_A, "S-fresh", "took the seat mid-decision")
1464            .await
1465            .unwrap();
1466
1467        let outcome = s.vacate_assignee(&id, SLOT_A, observed_gen).await.unwrap();
1468        assert_eq!(
1469            outcome,
1470            VacateOutcome::Stale {
1471                current: Some(Assignee {
1472                    op: "S-fresh".into(),
1473                    desc: "took the seat mid-decision".into(),
1474                    gen: 2,
1475                }),
1476            },
1477            "the stale reader is told who holds the seat now, and nothing is released"
1478        );
1479
1480        let got = s.get(&id).await.unwrap();
1481        assert_eq!(
1482            got.current[SLOT_A].op, "S-fresh",
1483            "the newer holder stands — A8 already decided this contest"
1484        );
1485        assert_eq!(
1486            got.next_generation, 2,
1487            "the refused release burned no generation"
1488        );
1489        drop(s);
1490        driver.shutdown().await.unwrap();
1491    }
1492
1493    /// An already-Vacant slot holds no generation, so nothing can match it:
1494    /// stale, and no write at all.
1495    #[tokio::test]
1496    async fn vacate_on_a_vacant_run_is_stale_and_writes_nothing() {
1497        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1498        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1499        let id = RunId::parse("R-1").unwrap();
1500
1501        let outcome = s.vacate_assignee(&id, SLOT_A, 1).await.unwrap();
1502        assert_eq!(outcome, VacateOutcome::Stale { current: None });
1503        assert_eq!(
1504            s.get(&id).await.unwrap().next_generation,
1505            0,
1506            "a release that did not release is not an assignment event"
1507        );
1508        drop(s);
1509        driver.shutdown().await.unwrap();
1510    }
1511
1512    /// A3 / Q3: an acquire mints a NEW `Assignee` and returns the displaced
1513    /// one with its original stamp intact.
1514    #[tokio::test]
1515    async fn acquire_never_rewrites_the_incumbent_assignee() {
1516        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1517        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1518        let id = RunId::parse("R-1").unwrap();
1519
1520        s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1521            .await
1522            .unwrap();
1523        let held_before = s.get(&id).await.unwrap().current[SLOT_A].clone();
1524        assert_eq!(held_before.gen, 1);
1525
1526        let (_, displaced) = s
1527            .acquire_assignee(&id, SLOT_A, "S-b2", "takeover")
1528            .await
1529            .unwrap();
1530
1531        assert_eq!(
1532            held_before.gen, 1,
1533            "A3: gen is immutable for the lifetime of an instance"
1534        );
1535        assert_eq!(
1536            displaced.expect("displaced holder"),
1537            held_before,
1538            "Q3: the displaced instance is returned as-is, not mutated"
1539        );
1540        drop(s);
1541        driver.shutdown().await.unwrap();
1542    }
1543
1544    /// A8: acquire has no precondition on the slot's incumbent — last
1545    /// writer wins.
1546    #[tokio::test]
1547    async fn acquire_displaces_a_live_holder() {
1548        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1549        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1550        let id = RunId::parse("R-1").unwrap();
1551
1552        s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1553            .await
1554            .unwrap();
1555        let (gen, displaced) = s
1556            .acquire_assignee(&id, SLOT_A, "S-b2", "takeover")
1557            .await
1558            .unwrap();
1559
1560        assert_eq!(gen, 2);
1561        assert_eq!(displaced.expect("displaced holder").op, "S-a1");
1562        let got = s.get(&id).await.unwrap();
1563        assert_eq!(got.current[SLOT_A].op, "S-b2");
1564        assert_eq!(got.current.len(), 1, "A1: still one holder for that slot");
1565        drop(s);
1566        driver.shutdown().await.unwrap();
1567    }
1568
1569    /// A9: `desc` is mandatory, and so is the slot; a refused event leaves
1570    /// the row alone.
1571    #[tokio::test]
1572    async fn acquire_rejects_a_missing_desc_without_side_effects() {
1573        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1574        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1575        let id = RunId::parse("R-1").unwrap();
1576        s.acquire_assignee(&id, SLOT_A, "S-a1", "first hold")
1577            .await
1578            .unwrap();
1579
1580        for blank in ["", "   "] {
1581            let err = s
1582                .acquire_assignee(&id, SLOT_A, "S-b2", blank)
1583                .await
1584                .unwrap_err();
1585            assert!(
1586                matches!(err, RunStoreError::AssigneeDescRequired),
1587                "got: {err:?}"
1588            );
1589        }
1590
1591        let err = s
1592            .acquire_assignee(&id, "", "S-b2", "no slot named")
1593            .await
1594            .unwrap_err();
1595        assert!(
1596            matches!(err, RunStoreError::AssigneeSlotRequired),
1597            "got: {err:?}"
1598        );
1599        let err = s.vacate_assignee(&id, "", 1).await.unwrap_err();
1600        assert!(
1601            matches!(err, RunStoreError::AssigneeSlotRequired),
1602            "got: {err:?}"
1603        );
1604
1605        let got = s.get(&id).await.unwrap();
1606        assert_eq!(got.next_generation, 1, "a refused event is not an event");
1607        assert_eq!(got.current[SLOT_A].op, "S-a1");
1608        drop(s);
1609        driver.shutdown().await.unwrap();
1610    }
1611
1612    /// Concurrent acquires run as separate `Immediate` transactions and
1613    /// must never read the same `G` — nor drop each other's slot entry,
1614    /// since each rewrites the whole map.
1615    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1616    async fn concurrent_acquires_hand_out_distinct_generations() {
1617        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1618        let s = std::sync::Arc::new(s);
1619        s.create(mk("R-1", "T-1", 100)).await.unwrap();
1620
1621        let mut handles = Vec::new();
1622        for i in 0..8u32 {
1623            let s = s.clone();
1624            let slot = if i % 2 == 0 { SLOT_A } else { SLOT_B };
1625            handles.push(tokio::spawn(async move {
1626                s.acquire_assignee(
1627                    &RunId::parse("R-1").unwrap(),
1628                    slot,
1629                    &format!("S-{i}"),
1630                    "concurrent hold",
1631                )
1632                .await
1633                .unwrap()
1634                .0
1635            }));
1636        }
1637        let mut generations = Vec::new();
1638        for h in handles {
1639            generations.push(h.await.unwrap());
1640        }
1641        generations.sort_unstable();
1642        assert_eq!(generations, (1..=8).collect::<Vec<u64>>());
1643        let got = s.get(&RunId::parse("R-1").unwrap()).await.unwrap();
1644        assert_eq!(got.next_generation, 8);
1645        assert_eq!(
1646            got.current.len(),
1647            2,
1648            "both slots ended up held — no writer clobbered the other's entry"
1649        );
1650        drop(s);
1651        driver.shutdown().await.unwrap();
1652    }
1653
1654    #[tokio::test]
1655    async fn assignment_on_an_unknown_run_fails() {
1656        let (s, driver) = SqliteRunStore::open_in_memory().await.unwrap();
1657        let missing = RunId::parse("R-nope").unwrap();
1658        let err = s
1659            .acquire_assignee(&missing, SLOT_A, "S-a1", "hold")
1660            .await
1661            .unwrap_err();
1662        assert!(matches!(err, RunStoreError::NotFound(_)), "got: {err:?}");
1663        let err = s.vacate_assignee(&missing, SLOT_A, 1).await.unwrap_err();
1664        assert!(matches!(err, RunStoreError::NotFound(_)), "got: {err:?}");
1665        drop(s);
1666        driver.shutdown().await.unwrap();
1667    }
1668
1669    /// R6: a restart does not drop the assignments — every slot's holder
1670    /// and `G` come back with the Run.
1671    #[tokio::test]
1672    async fn assignment_survives_reopen() {
1673        let dir = tempfile::tempdir().unwrap();
1674        let path = dir.path().join("runs.db");
1675
1676        {
1677            let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1678            s.create(mk("R-keep", "T-keep", 42)).await.unwrap();
1679            let id = RunId::parse("R-keep").unwrap();
1680            s.acquire_assignee(&id, SLOT_A, "main-ai", "held at restart")
1681                .await
1682                .unwrap();
1683            s.acquire_assignee(&id, SLOT_B, "S-b2", "also held at restart")
1684                .await
1685                .unwrap();
1686            drop(s);
1687            driver.shutdown().await.unwrap();
1688        }
1689
1690        let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1691        let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
1692        let holder = &got.current[SLOT_A];
1693        assert_eq!(holder.op, "main-ai");
1694        assert_eq!(holder.desc, "held at restart");
1695        assert_eq!(holder.gen, 1);
1696        assert_eq!(
1697            got.current[SLOT_B].gen, 2,
1698            "the second seat survives with its own stamp"
1699        );
1700        assert_eq!(got.next_generation, 2);
1701        drop(s);
1702        driver.shutdown().await.unwrap();
1703    }
1704
1705    /// The pre-correction `current_json` shape — a bare single `Assignee`
1706    /// object, from before `current` became a per-slot map — is refused
1707    /// loudly rather than read back as "no slot held".
1708    ///
1709    /// No released build ever wrote that shape (the single-holder form was
1710    /// never committed), so this is not a migration path; it is the
1711    /// assertion that a `current_json` the decoder does not understand
1712    /// surfaces as an error instead of silently unassigning a Run.
1713    #[tokio::test]
1714    async fn a_pre_slot_current_json_fails_loud_rather_than_reading_vacant() {
1715        let dir = tempfile::tempdir().unwrap();
1716        let path = dir.path().join("runs.db");
1717
1718        {
1719            let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1720            s.create(mk("R-legacy", "T-legacy", 7)).await.unwrap();
1721            drop(s);
1722            driver.shutdown().await.unwrap();
1723        }
1724        {
1725            let conn = rusqlite::Connection::open(&path).unwrap();
1726            conn.execute(
1727                "UPDATE runs SET current_json = ?1, next_generation = 1 WHERE id = 'R-legacy'",
1728                params![r#"{"op":"main-ai","desc":"held","gen":1}"#],
1729            )
1730            .unwrap();
1731        }
1732
1733        let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1734        let err = s.get(&RunId::parse("R-legacy").unwrap()).await.unwrap_err();
1735        assert!(
1736            matches!(&err, RunStoreError::Other(msg) if msg.contains("decode current")),
1737            "got: {err:?}"
1738        );
1739        drop(s);
1740        driver.shutdown().await.unwrap();
1741    }
1742
1743    /// A database file written before the assignment columns existed opens
1744    /// cleanly: the migration adds both, and the pre-existing row reads
1745    /// back as Vacant at generation 0.
1746    #[tokio::test]
1747    async fn legacy_db_without_assignment_columns_migrates() {
1748        let dir = tempfile::tempdir().unwrap();
1749        let path = dir.path().join("runs.db");
1750
1751        // The `runs` shape as of the release before this axis landed.
1752        {
1753            let conn = rusqlite::Connection::open(&path).unwrap();
1754            conn.execute_batch(
1755                "CREATE TABLE runs (\
1756                   id                 TEXT PRIMARY KEY, \
1757                   task_id            TEXT NOT NULL, \
1758                   status             TEXT NOT NULL, \
1759                   step_entries_json  TEXT NOT NULL, \
1760                   degradations_json  TEXT NOT NULL DEFAULT '[]', \
1761                   operator_sid       TEXT, \
1762                   result_ref_json    TEXT, \
1763                   input_json         TEXT, \
1764                   created_at         INTEGER NOT NULL, \
1765                   updated_at         INTEGER NOT NULL\
1766                 );",
1767            )
1768            .unwrap();
1769            conn.execute(
1770                "INSERT INTO runs (id, task_id, status, step_entries_json, degradations_json, \
1771                 operator_sid, result_ref_json, input_json, created_at, updated_at) \
1772                 VALUES ('R-old', 'T-old', '\"pending\"', '[]', '[]', NULL, NULL, NULL, 7, 7)",
1773                [],
1774            )
1775            .unwrap();
1776        }
1777
1778        let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1779        let got = s.get(&RunId::parse("R-old").unwrap()).await.unwrap();
1780        assert!(
1781            got.current.is_empty(),
1782            "a pre-existing row reads back with no slot held"
1783        );
1784        assert_eq!(
1785            got.next_generation, 0,
1786            "and starts at the launch value of G"
1787        );
1788        assert_eq!(got.created_at, 7);
1789
1790        // The migrated columns are writable, not just readable.
1791        let (gen, _) = s
1792            .acquire_assignee(
1793                &RunId::parse("R-old").unwrap(),
1794                SLOT_A,
1795                "S-a1",
1796                "after migration",
1797            )
1798            .await
1799            .unwrap();
1800        assert_eq!(gen, 1);
1801        drop(s);
1802        driver.shutdown().await.unwrap();
1803    }
1804
1805    #[tokio::test]
1806    async fn input_json_roundtrips_across_reopen() {
1807        let dir = tempfile::tempdir().unwrap();
1808        let path = dir.path().join("runs.db");
1809        let snapshot = r#"{"blueprint":"snapshot","init_ctx":{}}"#;
1810
1811        {
1812            let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1813            let mut rec = mk("R-keep", "T-keep", 42);
1814            rec.input_json = Some(snapshot.to_string());
1815            s.create(rec).await.unwrap();
1816            drop(s);
1817            driver.shutdown().await.unwrap();
1818        }
1819
1820        let (s, driver) = SqliteRunStore::open(&path).await.unwrap();
1821        let got = s.get(&RunId::parse("R-keep").unwrap()).await.unwrap();
1822        assert_eq!(got.input_json.as_deref(), Some(snapshot));
1823        drop(s);
1824        driver.shutdown().await.unwrap();
1825    }
1826}