Skip to main content

mermaid_runtime/storage/
mod.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use rusqlite::{Connection, params};
5
6// Bumped to 5 for the additive `tasks.prompt` column (the daemon scheduler
7// executes queued tasks later, so the full prompt must be persisted at enqueue
8// time — `title` is truncated at 80 chars). Additive, but the bump lets a DB
9// already at v4 re-run the migration once to pick it up. The bump is
10// load-bearing alongside the F17 early-return in `init_schema`: a DB at an
11// older version still runs the migration (the idempotent baseline plus any
12// per-version step dispatched by `migrate_within_txn`) exactly once, while an
13// already-current DB skips the write lock entirely.
14//
15// History: v2 added the additive `tasks.owner_kind` column (F18/RC-E); v3 added
16// the F75 covering indexes; v4 added the `outcomes` table.
17
18use std::sync::{Mutex, PoisonError};
19
20/// The one store handle this process shares. Before this existed every call
21/// site ran `RuntimeStore::open_default()` -- ~40 sites, each paying the
22/// open + ACL check + migration probe per call and racing the others on
23/// first-open migration. One handle per process removes the churn; SQLite's
24/// WAL already serializes actual writes, so the mutex adds no meaningful
25/// contention (every op here is a short read or insert).
26///
27/// The store is deliberately MULTI-PROCESS: a live `mermaid` session and a
28/// running `mermaidd` share the database by design (WAL + the owner-kind
29/// column keep them out of each other's rows -- see `OWNER_KIND_DAEMON`).
30/// This helper unifies access WITHIN a process, not across processes.
31static SHARED_STORE: Mutex<Option<RuntimeStore>> = Mutex::new(None);
32
33/// Run one operation against the process-wide shared store, opening it on
34/// first use. An open failure is returned and NOT cached, so a transient
35/// failure (the data dir's drive dropping off the bus) self-heals on the
36/// next call. An operation error evicts the cached handle for the same
37/// reason: before this helper, every call re-opened fresh and a dead
38/// connection could not outlive its call -- eviction preserves that
39/// self-healing at the cost of one re-open after a genuine error.
40///
41/// # Errors
42///
43/// Whatever `RuntimeStore::open_default` or the operation itself returns.
44pub fn with_shared_store<T>(op: impl FnOnce(&RuntimeStore) -> Result<T>) -> Result<T> {
45    let mut guard = SHARED_STORE.lock().unwrap_or_else(PoisonError::into_inner);
46    let store = match guard.as_mut() {
47        Some(store) => store,
48        None => guard.insert(RuntimeStore::open_default()?),
49    };
50    let result = op(store);
51    if result.is_err() {
52        *guard = None;
53    }
54    result
55}
56
57pub mod repos;
58pub mod rows;
59
60pub use mermaid_model::records::*;
61pub use repos::*;
62pub use rows::*;
63
64// Bumped to 5 for the additive `tasks.prompt` column (the daemon scheduler
65// executes queued tasks later, so the full prompt must be persisted at enqueue
66// time — `title` is truncated at 80 chars). Additive, but the bump lets a DB
67// already at v4 re-run the migration once to pick it up. The bump is
68// load-bearing alongside the F17 early-return in `init_schema`: a DB at an
69// older version still runs the migration (the idempotent baseline plus any
70// per-version step dispatched by `migrate_within_txn`) exactly once, while an
71// already-current DB skips the write lock entirely.
72//
73// History: v2 added the additive `tasks.owner_kind` column (F18/RC-E); v3 added
74// the F75 covering indexes; v4 added the `outcomes` table.
75pub(crate) const SCHEMA_VERSION: i32 = 7;
76
77/// Windows ACL hardening for the data directory, and the repair path for
78/// machines an earlier version locked out of their own database.
79///
80/// # What went wrong
81///
82/// The original hardening was one command:
83///
84/// ```text
85/// icacls <dir> /inheritance:r /grant:r <user>:(OI)(CI)F /T
86/// ```
87///
88/// `(OI)(CI)` are *inheritance* flags: they describe what the children of a
89/// container inherit. `/T` applies the ACE string verbatim to every existing
90/// item underneath, and on a leaf file those flags grant nothing — so
91/// `/inheritance:r` stripped the file's inherited ACE and the replacement put
92/// nothing back. Measured on a fresh directory:
93///
94/// ```text
95/// <dir>            ACEs=1 [FullControl]     <- correct
96/// <dir>\db.sqlite3 ACEs=0 []                <- locked out
97/// <dir>\sub        ACEs=1 [FullControl]     <- correct
98/// <dir>\sub\x.txt  ACEs=0 []                <- locked out
99/// ```
100///
101/// SQLite then returns `SQLITE_CANTOPEN` (14) forever, and the sentinel — written
102/// on `icacls` exit 0, which it earned — stopped the block ever running again.
103///
104/// # What replaces it
105///
106/// Harden the **directory only** and let Windows do the propagating. Removing
107/// inheritance on the parent recomputes every child's inherited ACEs from the
108/// new parent DACL, so existing files end up with exactly the intended access
109/// and subdirectories keep their `(OI)(CI)` flags, which is what makes files
110/// created *later* inherit correctly too.
111///
112/// The obvious-looking alternative — a `/T` pass granting plain `<user>:F`,
113/// then re-flagging the directory — was measured and rejected: it leaves every
114/// subdirectory with an unflagged ACE, reintroducing the same bug one level
115/// down for every file created afterwards.
116#[cfg(windows)]
117mod windows_acl {
118    use std::path::Path;
119    use std::process::{Command, Stdio};
120
121    fn icacls(args: &[&std::ffi::OsStr]) -> bool {
122        Command::new("icacls")
123            .args(args)
124            .stdout(Stdio::null())
125            .stderr(Stdio::null())
126            .status()
127            .is_ok_and(|status| status.success())
128    }
129
130    /// Restrict `dir` to the current user, and only `dir`. Children follow by
131    /// inheritance. Best-effort: a machine with no `USERNAME`, or an `icacls`
132    /// that fails, leaves the directory as it was rather than failing the open.
133    pub(super) fn harden_data_dir(dir: &Path) -> bool {
134        let Ok(user) = std::env::var("USERNAME") else {
135            return false;
136        };
137        if user.is_empty() {
138            return false;
139        }
140        icacls(&[
141            dir.as_os_str(),
142            "/inheritance:r".as_ref(),
143            "/grant:r".as_ref(),
144            format!("{user}:(OI)(CI)F").as_ref(),
145        ])
146    }
147
148    /// Give the current user access back to a single file whose DACL came out
149    /// empty. An owner always retains `WRITE_DAC`, so this succeeds on exactly
150    /// the machines the bug created and fails harmlessly everywhere else.
151    ///
152    /// `/grant` and not `/grant:r`: this is a repair, and it must add the
153    /// missing ACE without discarding whatever else is legitimately there.
154    pub(super) fn restore_owner_access(path: &Path) -> bool {
155        let Ok(user) = std::env::var("USERNAME") else {
156            return false;
157        };
158        if user.is_empty() {
159            return false;
160        }
161        icacls(&[
162            path.as_os_str(),
163            "/grant".as_ref(),
164            format!("{user}:(F)").as_ref(),
165        ])
166    }
167
168    /// Can SQLite actually open this path? The only question the sentinel is
169    /// allowed to be written on the answer to.
170    pub(super) fn sqlite_opens(path: &Path) -> bool {
171        rusqlite::Connection::open(path).is_ok()
172    }
173}
174
175/// Open the connection, repairing a Windows ACL lockout once before giving up.
176///
177/// Scoped to `SQLITE_CANTOPEN` on a file that exists: any other failure, or a
178/// missing file, means something the ACL cannot explain and must surface
179/// unchanged.
180#[cfg(windows)]
181fn open_connection(path: &Path) -> Result<Connection> {
182    let err = match Connection::open(path) {
183        Ok(conn) => return Ok(conn),
184        Err(err) => err,
185    };
186    let cannot_open = matches!(
187        err,
188        rusqlite::Error::SqliteFailure(
189            rusqlite::ffi::Error {
190                code: rusqlite::ErrorCode::CannotOpen,
191                ..
192            },
193            _
194        )
195    );
196    if !cannot_open || !path.is_file() || !windows_acl::restore_owner_access(path) {
197        return Err(err).with_context(|| format!("failed to open runtime DB {}", path.display()));
198    }
199    tracing::warn!(
200        path = %path.display(),
201        "runtime DB was unreadable (an earlier Mermaid left its ACL empty); \
202         restored owner access and retried"
203    );
204    Connection::open(path).with_context(|| {
205        format!(
206            "failed to open runtime DB {} even after restoring owner access",
207            path.display()
208        )
209    })
210}
211
212#[cfg(not(windows))]
213fn open_connection(path: &Path) -> Result<Connection> {
214    Connection::open(path).with_context(|| format!("failed to open runtime DB {}", path.display()))
215}
216
217/// SQLite-backed durable runtime state.
218pub struct RuntimeStore {
219    conn: Connection,
220    path: PathBuf,
221}
222
223impl RuntimeStore {
224    /// Open the store at its default location under the app data dir,
225    /// creating and (best-effort) locking down that dir first.
226    ///
227    /// # Errors
228    ///
229    /// Resolving the data dir, creating it, and everything [`Self::open`]
230    /// reports. Tightening permissions is best-effort on both platforms and
231    /// never fails the open, so an `Ok` store is not proof the data dir is
232    /// owner-only.
233    pub fn open_default() -> Result<Self> {
234        let dir = data_dir()?;
235        std::fs::create_dir_all(&dir)
236            .with_context(|| format!("failed to create Mermaid data dir {}", dir.display()))?;
237        // The data dir holds the daemon control socket, pairing tokens, and
238        // session/memory state. Restrict it to the owning user (0700) so no
239        // other local UID can reach the socket or read the DB.
240        #[cfg(unix)]
241        {
242            use std::os::unix::fs::PermissionsExt;
243            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
244        }
245        // Windows has no mode bits, so the DB (token hashes, transcripts) would
246        // otherwise inherit the parent's default ACL. Lock it to the current
247        // user via `icacls`. A sentinel makes this run once (first open, or
248        // first open after upgrade for an already-loose dir) rather than on
249        // every store open — the daemon opens the store per request. Best-effort
250        // like the Unix branch: never fail the store open on an ACL hiccup.
251        #[cfg(windows)]
252        {
253            let sentinel = dir.join(".acl-hardened");
254            let db = dir.join("runtime.sqlite3");
255            // The sentinel is written only after the DB is confirmed openable,
256            // NOT on `icacls` exit 0. Those are different claims, and the gap
257            // between them is what shipped: the old command exited 0 having
258            // done exactly what it was told, and what it was told left the
259            // database unreadable. See `windows_acl` for the mechanism.
260            if !sentinel.exists()
261                && windows_acl::harden_data_dir(&dir)
262                && windows_acl::sqlite_opens(&db)
263            {
264                let _ = std::fs::write(&sentinel, b"1");
265            }
266        }
267        Self::open(dir.join("runtime.sqlite3"))
268    }
269
270    /// Open (creating if needed) the SQLite store at `path`, then apply the
271    /// connection pragmas and run the schema migration.
272    ///
273    /// # Errors
274    ///
275    /// Creating the parent directory, opening the database — a corrupt file or
276    /// a directory the process cannot write — setting the connection pragmas,
277    /// and the schema migration. A concurrent opener is not among them: WAL
278    /// plus the busy timeout is exactly what keeps a second process from
279    /// failing here with `SQLITE_BUSY`.
280    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
281        let path = path.as_ref().to_path_buf();
282        if let Some(parent) = path.parent() {
283            std::fs::create_dir_all(parent).with_context(|| {
284                format!("failed to create SQLite parent dir {}", parent.display())
285            })?;
286        }
287        let conn = open_connection(&path)?;
288        // The daemon, CLI, and per-turn effect tasks each open their own
289        // connection (often in separate processes). Without WAL + a busy
290        // timeout, a writer holding the DB makes a concurrent write fail
291        // immediately with SQLITE_BUSY (lost task/tool/approval updates).
292        // WAL allows concurrent readers with a single writer; busy_timeout
293        // serializes writers gracefully.
294        conn.busy_timeout(std::time::Duration::from_secs(5))
295            .context("failed to set SQLite busy_timeout")?;
296        // `foreign_keys` is connection-scoped and can only be toggled in
297        // autocommit mode, so it lives here (per connection) rather than inside
298        // the now-transactional `init_schema` migration, where a PRAGMA
299        // foreign_keys would be a silent no-op.
300        conn.execute_batch(
301            "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
302        )
303        .context("failed to set SQLite connection PRAGMAs")?;
304        let store = Self { conn, path };
305        store.init_schema()?;
306        Ok(store)
307    }
308
309    pub fn path(&self) -> &Path {
310        &self.path
311    }
312
313    pub fn sessions(&self) -> SessionsRepo<'_> {
314        SessionsRepo { conn: &self.conn }
315    }
316
317    pub fn tasks(&self) -> TasksRepo<'_> {
318        TasksRepo { conn: &self.conn }
319    }
320
321    pub fn tool_runs(&self) -> ToolRunsRepo<'_> {
322        ToolRunsRepo { conn: &self.conn }
323    }
324
325    pub fn approvals(&self) -> ApprovalsRepo<'_> {
326        ApprovalsRepo { conn: &self.conn }
327    }
328
329    pub fn processes(&self) -> ProcessesRepo<'_> {
330        ProcessesRepo { conn: &self.conn }
331    }
332
333    pub fn checkpoints(&self) -> CheckpointsRepo<'_> {
334        CheckpointsRepo { conn: &self.conn }
335    }
336
337    pub fn compactions(&self) -> CompactionsRepo<'_> {
338        CompactionsRepo { conn: &self.conn }
339    }
340
341    pub fn plugins(&self) -> PluginsRepo<'_> {
342        PluginsRepo { conn: &self.conn }
343    }
344
345    pub fn provider_probes(&self) -> ProviderProbesRepo<'_> {
346        ProviderProbesRepo { conn: &self.conn }
347    }
348
349    pub fn pairing_tokens(&self) -> PairingTokensRepo<'_> {
350        PairingTokensRepo { conn: &self.conn }
351    }
352
353    pub fn outcomes(&self) -> OutcomesRepo<'_> {
354        OutcomesRepo { conn: &self.conn }
355    }
356
357    /// Recover state stranded by a previous daemon's crash/stop (#120, #118).
358    /// A `Running` task's worker died with the daemon, so it can never finish —
359    /// mark it `failed` with an event. An approval left in the transient
360    /// `approving` claim state (a replay that crashed mid-effect, #118) is reset
361    /// to undecided so it reappears as pending and stays re-runnable. Call once
362    /// on daemon startup, before serving. Returns `(tasks_reset, claims_released)`.
363    ///
364    /// F18 (RC-E): only **daemon-owned** running tasks are reset. The store is
365    /// shared with interactive `mermaid` CLI runs; their tasks are created with a
366    /// `NULL` `owner_kind` and are LEFT RUNNING here, so a live CLI session isn't
367    /// wrongly flipped to `failed` (with a spurious "interrupted" event) just
368    /// because the daemon restarted. The daemon tags the tasks it runs in-process
369    /// via [`NewTask::daemon_owned`].
370    /// # Errors
371    ///
372    /// Taking the `BEGIN IMMEDIATE` write lock — which waits out a concurrent
373    /// writer for `busy_timeout` before giving up — and any statement in the
374    /// pass. Every failure rolls the transaction back, so recovery is
375    /// all-or-nothing: no task is left flipped to `failed` without its
376    /// `interrupted` event, and no claim is released without the tasks beside
377    /// it. The caller may simply run it again.
378    pub fn reconcile_after_restart(&self) -> Result<(usize, usize)> {
379        let now = now_rfc3339();
380        // Take the write lock up front with BEGIN IMMEDIATE rather than a DEFERRED
381        // transaction that SELECTs and then upgrades to a write on the first
382        // UPDATE: SQLite fails a read→write lock upgrade with SQLITE_BUSY
383        // *immediately* (busy_timeout does not retry upgrades), so a CLI holding
384        // the write lock at daemon startup would abort recovery. IMMEDIATE instead
385        // waits on busy_timeout for the lock (#F21). Mirrors `init_schema`.
386        self.conn.execute_batch("BEGIN IMMEDIATE;")?;
387        let result = (|| -> Result<(usize, usize)> {
388            let running: Vec<String> = {
389                let mut stmt = self
390                    .conn
391                    .prepare("SELECT id FROM tasks WHERE status = 'running' AND owner_kind = ?1")?;
392                let ids = stmt.query_map([OWNER_KIND_DAEMON], |row| row.get::<_, String>(0))?;
393                ids.collect::<rusqlite::Result<Vec<_>>>()?
394            };
395            for id in &running {
396                self.conn.execute(
397                    "UPDATE tasks SET status = 'failed', updated_at = ?2 WHERE id = ?1",
398                    params![id, now],
399                )?;
400                self.conn.execute(
401                    "INSERT INTO task_events (task_id, kind, message, created_at)
402                     VALUES (?1, ?2, ?3, ?4)",
403                    params![
404                        id,
405                        "interrupted",
406                        "task was running when the daemon restarted; marked failed",
407                        now
408                    ],
409                )?;
410            }
411            let claims_released = self.conn.execute(
412                "UPDATE approvals SET user_decision = NULL WHERE user_decision = 'approving'",
413                [],
414            )?;
415            Ok((running.len(), claims_released))
416        })();
417        match result {
418            Ok(v) => {
419                self.conn.execute_batch("COMMIT;")?;
420                Ok(v)
421            },
422            Err(e) => {
423                let _ = self.conn.execute_batch("ROLLBACK;");
424                Err(e)
425            },
426        }
427    }
428
429    /// Best-effort retention GC (#130, F22/RC-F): prune archived
430    /// approvals/checkpoints, the events of long-finished tasks, terminal tasks,
431    /// and the high-churn / old rows of the remaining tables, all older than
432    /// `retention_days`. The append-only `outcomes` reward table — the
433    /// self-improving-loop training corpus — is pruned on its own, longer
434    /// `outcomes_retention_days` window so a large training history survives the
435    /// shorter task/session window. Deletes only archived, finished, or
436    /// terminal-and-old rows — **active data is never touched** (a running task,
437    /// a still-open tool run, a live process, or a recently-updated session all
438    /// survive). Returns the number of rows removed.
439    /// # Errors
440    ///
441    /// Opening the transaction and any `DELETE` in it. The whole pass is one
442    /// transaction, so a failure prunes nothing and the returned count is
443    /// never partial. Having nothing to prune is `Ok(0)`.
444    pub fn gc(&self, retention_days: i64, outcomes_retention_days: i64) -> Result<u64> {
445        let now = chrono::Utc::now();
446        let cutoff = (now - chrono::Duration::days(retention_days)).to_rfc3339();
447        let outcomes_cutoff = (now - chrono::Duration::days(outcomes_retention_days)).to_rfc3339();
448        let tx = self.conn.unchecked_transaction()?;
449        let mut removed = 0u64;
450        removed += tx.execute(
451            "DELETE FROM approvals WHERE archived_at IS NOT NULL AND archived_at < ?1",
452            params![cutoff],
453        )? as u64;
454        removed += tx.execute(
455            "DELETE FROM checkpoints WHERE archived_at IS NOT NULL AND archived_at < ?1",
456            params![cutoff],
457        )? as u64;
458        removed += tx.execute(
459            "DELETE FROM task_events
460             WHERE created_at < ?1
461               AND task_id IN (
462                   SELECT id FROM tasks
463                   WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1
464               )",
465            params![cutoff],
466        )? as u64;
467        // F22 (RC-F): the high-churn growers. `tool_runs` is the fastest — one row
468        // per tool call — so prune FINISHED runs past the window (a still-running
469        // run has a NULL `finished_at` and is kept).
470        removed += tx.execute(
471            "DELETE FROM tool_runs WHERE finished_at IS NOT NULL AND finished_at < ?1",
472            params![cutoff],
473        )? as u64;
474        // Exited processes past the window (a live `running`/`unknown` process is
475        // kept so the dashboard and `stop`/`restart` still see it).
476        removed += tx.execute(
477            "DELETE FROM processes WHERE status = 'exited' AND updated_at < ?1",
478            params![cutoff],
479        )? as u64;
480        // Old compaction history — immutable bookkeeping rows, safe to drop once
481        // past the window.
482        removed += tx.execute(
483            "DELETE FROM compactions WHERE created_at < ?1",
484            params![cutoff],
485        )? as u64;
486        // Sessions untouched for the whole window are treated as finished.
487        // Their transcripts live in the project's conversation files, not
488        // here, so pruning the index row is the whole job.
489        removed += tx.execute(
490            "DELETE FROM sessions WHERE updated_at < ?1",
491            params![cutoff],
492        )? as u64;
493        // The append-only `outcomes` reward table is the training corpus for the
494        // self-improving loop, so it is pruned on its own, deliberately longer
495        // window. Prune it BEFORE the terminal-tasks delete below: an outcome's
496        // `task_id` is `ON DELETE SET NULL`, so a task pruned on the shorter
497        // window nulls the link on any still-retained outcome — the denormalized
498        // `detail_json` (captured at task-terminal time) preserves the training
499        // context regardless.
500        removed += tx.execute(
501            "DELETE FROM outcomes WHERE created_at < ?1",
502            params![outcomes_cutoff],
503        )? as u64;
504        // Terminal tasks past the window — the #148 durable queue would otherwise
505        // keep every finished task (with its full `prompt`) forever. `task_events`
506        // is `ON DELETE CASCADE`, so a pruned task's events go with it (the
507        // explicit task_events prune above already cleared most). A queued /
508        // running / waiting task is never terminal, so live work survives.
509        removed += tx.execute(
510            "DELETE FROM tasks
511             WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1",
512            params![cutoff],
513        )? as u64;
514        tx.commit()?;
515        Ok(removed)
516    }
517
518    pub(crate) fn init_schema(&self) -> Result<()> {
519        let conn = &self.conn;
520        // Forward-compat gate: read the stored schema version BEFORE writing
521        // anything. A DB written by a newer mermaid (higher `user_version`)
522        // must be refused, not silently down-labeled. The old code stamped
523        // `PRAGMA user_version = 1` inside the CREATE script — before this
524        // check — so the guard was dead and an older binary would happily
525        // operate (and corrupt) a newer DB.
526        let current: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
527        anyhow::ensure!(
528            current <= SCHEMA_VERSION,
529            "runtime DB schema version {current} is newer than this build supports ({SCHEMA_VERSION}); upgrade mermaid"
530        );
531
532        // F17 (RC-E): the overwhelmingly common case is an already-current DB.
533        // The daemon opens a fresh store per request, and the old code ran
534        // `BEGIN IMMEDIATE` (the write lock) + the full migration + an
535        // unconditional `PRAGMA user_version` write on EVERY open — so even
536        // read-only requests serialized on a single writer and grew the WAL. Once
537        // the stored version already matches, the schema is in place and there is
538        // nothing to migrate or stamp: return before taking any write lock so
539        // concurrent readers never contend. The newer-than-supported gate above
540        // still runs first, so a newer DB is refused, not skipped.
541        if current == SCHEMA_VERSION {
542            return Ok(());
543        }
544
545        // Older (or fresh, version 0) DB only past this point.
546        // Create tables + run column migrations exactly once, even when the
547        // daemon and CLI open the DB concurrently: BEGIN IMMEDIATE takes the
548        // write lock up front, so a racing process blocks on `busy_timeout`
549        // and, once we commit, sees the schema already in place instead of
550        // double-running an ALTER and failing the open (the old check-then-
551        // ALTER `ensure_column` race).
552        conn.execute_batch("BEGIN IMMEDIATE;")?;
553        if let Err(error) = self.migrate_within_txn(current) {
554            let _ = conn.execute_batch("ROLLBACK;");
555            return Err(error);
556        }
557        conn.execute_batch("COMMIT;")?;
558
559        // Stamp the version only after a successful migration — never before
560        // the gate above.
561        conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
562        let version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
563        anyhow::ensure!(
564            version == SCHEMA_VERSION,
565            "unsupported runtime DB schema version {version} (expected {SCHEMA_VERSION})"
566        );
567        Ok(())
568    }
569
570    /// Schema creation + column migrations, run inside the `init_schema`
571    /// transaction for a DB upgrading from `from_version`. Idempotent:
572    /// `CREATE TABLE IF NOT EXISTS` plus the duplicate-tolerant `ensure_column`
573    /// make a re-run a no-op, so a second concurrent opener that wins the lock
574    /// after us does no harm.
575    #[expect(
576        clippy::too_many_lines,
577        reason = "predates the lint; see .github/baselines/expect_budget.txt"
578    )]
579    pub(crate) fn migrate_within_txn(&self, from_version: i32) -> Result<()> {
580        self.conn.execute_batch(
581            r#"
582            CREATE TABLE IF NOT EXISTS sessions (
583                id TEXT PRIMARY KEY,
584                project_path TEXT NOT NULL,
585                model_id TEXT NOT NULL,
586                title TEXT,
587                conversation_path TEXT,
588                created_at TEXT NOT NULL,
589                updated_at TEXT NOT NULL,
590                total_tokens INTEGER
591            );
592
593            CREATE TABLE IF NOT EXISTS tasks (
594                id TEXT PRIMARY KEY,
595                title TEXT NOT NULL,
596                status TEXT NOT NULL,
597                priority TEXT NOT NULL,
598                project_path TEXT NOT NULL,
599                model_id TEXT NOT NULL,
600                conversation_id TEXT,
601                created_at TEXT NOT NULL,
602                updated_at TEXT NOT NULL,
603                final_report TEXT,
604                owner_kind TEXT
605            );
606            CREATE INDEX IF NOT EXISTS idx_tasks_project_status
607                ON tasks(project_path, status, updated_at);
608            -- `idx_tasks_status_owner` is NOT here. It indexes `owner_kind`,
609            -- which the `ensure_column` below adds, and on a pre-v2 DB the
610            -- `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
611            -- that has no such column. See the ordered block after the
612            -- `ensure_column` calls.
613
614            CREATE TABLE IF NOT EXISTS task_events (
615                id INTEGER PRIMARY KEY AUTOINCREMENT,
616                task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
617                kind TEXT NOT NULL,
618                message TEXT NOT NULL,
619                created_at TEXT NOT NULL
620            );
621            CREATE INDEX IF NOT EXISTS idx_task_events_task_id
622                ON task_events(task_id, id);
623
624            CREATE TABLE IF NOT EXISTS tool_runs (
625                id TEXT PRIMARY KEY,
626                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
627                turn_id TEXT,
628                call_id TEXT,
629                tool_name TEXT NOT NULL,
630                status TEXT NOT NULL,
631                args_json TEXT,
632                output_json TEXT,
633                started_at TEXT NOT NULL,
634                finished_at TEXT
635            );
636            CREATE INDEX IF NOT EXISTS idx_tool_runs_task_id ON tool_runs(task_id);
637
638            CREATE TABLE IF NOT EXISTS approvals (
639                id TEXT PRIMARY KEY,
640                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
641                proposed_action TEXT NOT NULL,
642                risk_classification TEXT NOT NULL,
643                policy_decision TEXT NOT NULL,
644                user_decision TEXT,
645                args_summary TEXT,
646                checkpoint_id TEXT,
647                pending_action_json TEXT,
648                created_at TEXT NOT NULL,
649                decided_at TEXT,
650                archived_at TEXT,
651                archive_reason TEXT
652            );
653            CREATE INDEX IF NOT EXISTS idx_approvals_task_id ON approvals(task_id);
654            -- F75: `list_pending` scans `user_decision IS NULL ORDER BY
655            -- created_at`. A partial index over only the pending rows stays tiny
656            -- and serves both the filter and the ordering.
657            CREATE INDEX IF NOT EXISTS idx_approvals_pending
658                ON approvals(created_at)
659                WHERE user_decision IS NULL;
660
661            CREATE TABLE IF NOT EXISTS processes (
662                id TEXT PRIMARY KEY,
663                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
664                pid INTEGER NOT NULL,
665                command TEXT NOT NULL,
666                cwd TEXT,
667                log_path TEXT,
668                detected_url TEXT,
669                status TEXT NOT NULL,
670                health TEXT,
671                created_at TEXT NOT NULL,
672                updated_at TEXT NOT NULL
673            );
674            CREATE INDEX IF NOT EXISTS idx_processes_task_id ON processes(task_id);
675            CREATE INDEX IF NOT EXISTS idx_processes_pid ON processes(pid);
676
677            CREATE TABLE IF NOT EXISTS checkpoints (
678                id TEXT PRIMARY KEY,
679                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
680                project_path TEXT NOT NULL,
681                snapshot_path TEXT NOT NULL,
682                changed_files_json TEXT NOT NULL,
683                pending_action_json TEXT,
684                approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL,
685                created_at TEXT NOT NULL,
686                archived_at TEXT,
687                archive_reason TEXT,
688                session_id TEXT,
689                message_index INTEGER
690            );
691
692            CREATE TABLE IF NOT EXISTS compactions (
693                id TEXT PRIMARY KEY,
694                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
695                session_id TEXT,
696                source_token_estimate INTEGER,
697                summary_token_count INTEGER,
698                preserved_turns INTEGER,
699                archive_path TEXT,
700                verification_status TEXT,
701                created_at TEXT NOT NULL
702            );
703
704            CREATE TABLE IF NOT EXISTS provider_probes (
705                provider TEXT NOT NULL,
706                model_id TEXT NOT NULL,
707                capability_key TEXT NOT NULL,
708                capability_value TEXT NOT NULL,
709                confidence TEXT NOT NULL,
710                error TEXT,
711                probed_at TEXT NOT NULL,
712                PRIMARY KEY (provider, model_id, capability_key)
713            );
714
715            CREATE TABLE IF NOT EXISTS plugin_installs (
716                id TEXT PRIMARY KEY,
717                name TEXT NOT NULL,
718                source TEXT NOT NULL,
719                version TEXT,
720                enabled INTEGER NOT NULL DEFAULT 1,
721                manifest_json TEXT NOT NULL,
722                installed_at TEXT NOT NULL,
723                updated_at TEXT NOT NULL
724            );
725
726            CREATE TABLE IF NOT EXISTS pairing_tokens (
727                id TEXT PRIMARY KEY,
728                token_hash TEXT NOT NULL,
729                label TEXT,
730                enabled INTEGER NOT NULL DEFAULT 1,
731                created_at TEXT NOT NULL,
732                last_used_at TEXT,
733                expires_at TEXT
734            );
735            CREATE INDEX IF NOT EXISTS idx_pairing_tokens_enabled
736                ON pairing_tokens(enabled, created_at);
737
738            CREATE TABLE IF NOT EXISTS outcomes (
739                id TEXT PRIMARY KEY,
740                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
741                tool_run_id TEXT REFERENCES tool_runs(id) ON DELETE SET NULL,
742                kind TEXT NOT NULL,
743                label TEXT NOT NULL,
744                reward REAL,
745                source TEXT NOT NULL,
746                detail_json TEXT,
747                created_at TEXT NOT NULL
748            );
749            CREATE INDEX IF NOT EXISTS idx_outcomes_task_id ON outcomes(task_id);
750            CREATE INDEX IF NOT EXISTS idx_outcomes_kind ON outcomes(kind, created_at);
751            "#,
752        )?;
753
754        ensure_column(&self.conn, "approvals", "pending_action_json", "TEXT")?;
755        ensure_column(&self.conn, "approvals", "archived_at", "TEXT")?;
756        ensure_column(&self.conn, "approvals", "archive_reason", "TEXT")?;
757        ensure_column(&self.conn, "checkpoints", "archived_at", "TEXT")?;
758        ensure_column(&self.conn, "checkpoints", "archive_reason", "TEXT")?;
759        // v6: conversation anchoring for rewind/fork. Nullable + no backfill —
760        // pre-existing checkpoints simply have no anchor and are excluded from
761        // fork notices.
762        ensure_column(&self.conn, "checkpoints", "session_id", "TEXT")?;
763        ensure_column(&self.conn, "checkpoints", "message_index", "INTEGER")?;
764        // Index AFTER the ensure_columns: on an upgraded DB the columns only
765        // exist once the lines above ran (fresh DBs have them from CREATE).
766        self.conn.execute_batch(
767            "CREATE INDEX IF NOT EXISTS idx_checkpoints_session
768                 ON checkpoints(session_id, message_index);",
769        )?;
770        // F18 (RC-E): task ownership. Nullable + no backfill — existing rows stay
771        // `NULL` (treated as un-owned, so reconcile leaves them alone), and only
772        // tasks the daemon explicitly marks `daemon` are reset on restart.
773        ensure_column(&self.conn, "tasks", "owner_kind", "TEXT")?;
774        // v5: full prompt for scheduler-executed tasks. Nullable — only tasks
775        // enqueued for deferred daemon execution set it; the claim query treats
776        // a NULL prompt as "metadata-only task, never claim".
777        ensure_column(&self.conn, "tasks", "prompt", "TEXT")?;
778        // F75: `reconcile_after_restart` filters `status = 'running' AND
779        // owner_kind = ?`, which the (project_path, ...) index cannot serve
780        // (wrong leading column). This covering index does.
781        //
782        // AFTER the `ensure_column` above, for the same reason
783        // `idx_checkpoints_session` sits after its columns. It used to live in
784        // the baseline batch, which made every v1 DB unopenable: `CREATE TABLE
785        // IF NOT EXISTS tasks` is a no-op when the table already exists, so on
786        // a pre-v2 DB the index was created against a table with no
787        // `owner_kind` and failed with "no such column". `IF NOT EXISTS` does
788        // not help — it guards the index NAME, and SQLite still parses the
789        // column list. The failure landed inside the migration transaction, so
790        // it rolled back and `user_version` was never stamped, and the next
791        // open failed identically. Forever.
792        self.conn.execute_batch(
793            "CREATE INDEX IF NOT EXISTS idx_tasks_status_owner
794                 ON tasks(status, owner_kind);",
795        )?;
796        // Pairing-token TTL. When the column is first added to an existing DB,
797        // backfill live tokens with a 30-day grace window from now rather than
798        // expiring them instantly on upgrade. Fresh DBs already have the column
799        // (so no backfill) and only tokens minted with `--ttl-days 0` keep a
800        // NULL (never-expires) value going forward.
801        if ensure_column(&self.conn, "pairing_tokens", "expires_at", "TEXT")? {
802            let grace = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
803            self.conn.execute(
804                "UPDATE pairing_tokens SET expires_at = ?1 WHERE expires_at IS NULL",
805                params![grace],
806            )?;
807        }
808
809        // F76: structured per-version migration dispatch. Everything above is the
810        // idempotent ADDITIVE baseline (`CREATE ... IF NOT EXISTS` + `ensure_column`),
811        // always safe to re-run. This loop is the home for FUTURE NON-ADDITIVE
812        // steps — dropping/renaming/transforming a column, rebuilding a table —
813        // that the baseline cannot express: each target version's step runs once,
814        // only when upgrading PAST it, inside this same transaction. Today every
815        // shipped step is additive, so the arms are documented (near-)no-ops, but a
816        // future v4 now has an ordered, versioned place to live instead of
817        // overloading `IF NOT EXISTS`.
818        for target in (from_version + 1)..=SCHEMA_VERSION {
819            match target {
820                // v2 added `tasks.owner_kind` — additive, applied by the baseline.
821                2 => {},
822                // v3: F75 covering indexes — additive, created by the baseline
823                // above; this call is the concrete template for the first real
824                // non-additive change.
825                3 => self.migrate_to_v3()?,
826                // v4: additive `outcomes` table — created by the idempotent
827                // baseline above; this arm is its versioned home if a
828                // non-additive change to that schema is ever needed.
829                4 => self.migrate_to_v4()?,
830                // v5: additive `tasks.prompt` column — applied by `ensure_column`
831                // in the baseline above.
832                5 => self.migrate_to_v5()?,
833                // v6: additive `checkpoints.session_id`/`message_index` columns
834                // + covering index — applied by the idempotent baseline above.
835                6 => {},
836                // v7: the first genuinely NON-additive step — see below.
837                7 => self.migrate_to_v7()?,
838                // A future v8+ adds its non-additive step here.
839                _ => {},
840            }
841        }
842        Ok(())
843    }
844
845    /// Non-additive migration steps introduced at schema v3. Today v3 only adds
846    /// covering indexes (additive — applied by the idempotent baseline in
847    /// [`Self::migrate_within_txn`]), so this is intentionally a no-op. It exists
848    /// as the concrete template for the first real non-additive change: a step
849    /// that, for example, drops or transforms a column, which
850    /// `CREATE ... IF NOT EXISTS` and `ensure_column` cannot express. Runs inside
851    /// the `init_schema` transaction, exactly once, when a DB upgrades past v2.
852    pub(crate) fn migrate_to_v3(&self) -> Result<()> {
853        Ok(())
854    }
855
856    /// Non-additive migration steps introduced at schema v4. Today v4 only adds
857    /// the additive `outcomes` table (applied by the idempotent baseline in
858    /// [`Self::migrate_within_txn`]), so this is intentionally a no-op — the
859    /// versioned home for a future non-additive change to the outcomes schema.
860    pub(crate) fn migrate_to_v4(&self) -> Result<()> {
861        Ok(())
862    }
863
864    /// Non-additive migration steps introduced at schema v5. Today v5 only adds
865    /// the additive `tasks.prompt` column (applied by `ensure_column` in the
866    /// baseline), so this is intentionally a no-op.
867    pub(crate) fn migrate_to_v5(&self) -> Result<()> {
868        Ok(())
869    }
870
871    /// v7: drop the `messages` table — the structure's first real use.
872    ///
873    /// It never had a production writer: only tests ever inserted into it,
874    /// while three read paths served its permanently empty contents as if
875    /// they were session history. Those readers now go to the conversation
876    /// files, so the table is dead weight with a foreign key into
877    /// `sessions`. Dropping it is exactly what the idempotent baseline
878    /// cannot express, which is what the versioned dispatch is for.
879    ///
880    /// Safe in both directions: an older mermaid re-creates the (empty)
881    /// table from its own baseline on the next open, so a rollback loses
882    /// nothing that was ever there.
883    pub(crate) fn migrate_to_v7(&self) -> Result<()> {
884        self.conn.execute_batch(
885            "DROP INDEX IF EXISTS idx_messages_session_id; DROP TABLE IF EXISTS messages;",
886        )?;
887        Ok(())
888    }
889}
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894
895    #[test]
896    pub(crate) fn open_enables_wal_and_busy_timeout() {
897        // H19: every connection must use WAL so daemon/CLI/effect writers
898        // don't hit a hard SQLITE_BUSY.
899        let path = temp_db("wal_check");
900        let store = RuntimeStore::open(&path).expect("open");
901        let mode: String = store
902            .conn
903            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
904            .expect("journal_mode pragma");
905        assert_eq!(mode.to_lowercase(), "wal");
906    }
907
908    pub(crate) fn temp_db(name: &str) -> PathBuf {
909        let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{name}"));
910        let _ = std::fs::remove_dir_all(&dir);
911        std::fs::create_dir_all(&dir).expect("create temp dir");
912        dir.join("runtime.sqlite3")
913    }
914
915    /// Hardening must not lock the owner out of the database it is protecting.
916    ///
917    /// The shipped version did exactly that, and nothing caught it because
918    /// every existing assertion looked at the *directory* — which was always
919    /// correct. The broken half was the file inside it, and no test opened one
920    /// after hardening. This does, and it opens the DB the way production
921    /// does rather than checking an ACE count, so it stays true whatever the
922    /// implementation is.
923    #[cfg(windows)]
924    #[test]
925    fn hardening_leaves_the_database_and_its_subdirectories_usable() {
926        let path = temp_db("acl_hardening");
927        let dir = path.parent().expect("temp dir").to_path_buf();
928
929        // A DB, a subdirectory, and a file inside it: the real data dir has
930        // `checkpoints/`, `memory/`, `projects/` and friends, and a fix that
931        // only rescues top-level files would pass a shallower test.
932        drop(RuntimeStore::open(&path).expect("seed the DB before hardening"));
933        let sub = dir.join("checkpoints");
934        std::fs::create_dir_all(&sub).expect("create subdir");
935        std::fs::write(sub.join("existing.json"), b"{}").expect("seed a nested file");
936
937        assert!(
938            super::windows_acl::harden_data_dir(&dir),
939            "icacls hardening did not run; the rest of this test would be vacuous"
940        );
941
942        // `sqlite_opens` and not `RuntimeStore::open`: the latter now repairs
943        // an empty DACL, so it would paper over broken hardening and this
944        // assertion would hold for the wrong reason. The claim here is that
945        // hardening never needs the repair.
946        assert!(
947            super::windows_acl::sqlite_opens(&path),
948            "hardening locked the owner out of the database it was protecting"
949        );
950        std::fs::read(sub.join("existing.json")).expect("a nested file must stay readable");
951
952        // Files created AFTER hardening inherit from the directory. This is
953        // the half the rejected two-pass fix broke: it left subdirectories
954        // with an unflagged ACE, so anything written into `checkpoints/`
955        // later came out unreadable.
956        std::fs::write(sub.join("created-after.json"), b"{}").expect("write a new nested file");
957        std::fs::read(sub.join("created-after.json"))
958            .expect("a file created after hardening must be readable");
959    }
960
961    /// The repair path, driven from the state the bug actually produces.
962    #[cfg(windows)]
963    #[test]
964    fn an_empty_dacl_is_repaired_on_open_rather_than_surfaced() {
965        let path = temp_db("acl_repair");
966        drop(RuntimeStore::open(&path).expect("seed the DB"));
967
968        // `/inheritance:r` with no `/grant` removes every inherited ACE and
969        // adds nothing, which reaches the bug's end state — an empty DACL —
970        // directly. The first version of this test re-ran the shipped
971        // `(OI)(CI)F` command instead and depended on that quirk producing a
972        // lockout, which is one platform behavior more than the test needs.
973        let stripped = std::process::Command::new("icacls")
974            .arg(&path)
975            .arg("/inheritance:r")
976            .stdout(std::process::Stdio::null())
977            .stderr(std::process::Stdio::null())
978            .status()
979            .expect("run icacls");
980        assert!(stripped.success(), "icacls must strip the DACL");
981
982        if std::fs::read(&path).is_ok() {
983            // Not every Windows can stage this. A caller holding
984            // SeBackupPrivilege — every elevated GitHub Actions runner — reads
985            // straight through an empty DACL, so there is no lockout here to
986            // repair. Assert what is still true rather than assert something
987            // false; the machines this bug actually reaches are unprivileged
988            // desktops, where the branch below runs.
989            println!(
990                "note: this environment reads through an empty DACL; \
991                 asserting the repair grant only"
992            );
993            assert!(
994                super::windows_acl::restore_owner_access(&path),
995                "the repair must still be able to grant"
996            );
997            RuntimeStore::open(&path).expect("open must succeed");
998            return;
999        }
1000
1001        RuntimeStore::open(&path).expect("open must repair the ACL and succeed");
1002    }
1003
1004    #[test]
1005    pub(crate) fn outcomes_round_trip_and_list_for_task() {
1006        let path = temp_db("outcomes");
1007        let store = RuntimeStore::open(&path).expect("open store");
1008        let task = store
1009            .tasks()
1010            .create(NewTask::new("t", "/tmp/p", "m"))
1011            .expect("create task");
1012
1013        let first = store
1014            .outcomes()
1015            .record(NewOutcome {
1016                id: None,
1017                task_id: Some(task.id.clone()),
1018                tool_run_id: None,
1019                kind: "task_terminal".to_string(),
1020                label: OUTCOME_LABEL_SUCCESS.to_string(),
1021                reward: Some(1.0),
1022                source: OUTCOME_SOURCE_SYSTEM.to_string(),
1023                detail_json: None,
1024            })
1025            .expect("record first");
1026        let second = store
1027            .outcomes()
1028            .record(NewOutcome {
1029                id: None,
1030                task_id: Some(task.id.clone()),
1031                tool_run_id: None,
1032                kind: "preference".to_string(),
1033                label: OUTCOME_LABEL_ACCEPTED.to_string(),
1034                reward: None,
1035                source: OUTCOME_SOURCE_USER.to_string(),
1036                detail_json: Some("{\"chosen\":\"a\",\"rejected\":\"b\"}".to_string()),
1037            })
1038            .expect("record second");
1039
1040        // get() round-trips every field, including the nullable reward and the
1041        // structured detail payload.
1042        assert_eq!(
1043            store.outcomes().get(&first.id).expect("get").as_ref(),
1044            Some(&first)
1045        );
1046        assert_eq!(first.reward, Some(1.0));
1047        assert_eq!(second.reward, None);
1048        assert_eq!(second.source, OUTCOME_SOURCE_USER);
1049        assert!(second.detail_json.as_deref().unwrap().contains("chosen"));
1050
1051        // Both attach to the task. Assert as a set — two records created within
1052        // the same coarse clock tick can share a `created_at`, so the ASC order
1053        // between them isn't something to pin a test on.
1054        let for_task = store
1055            .outcomes()
1056            .list_for_task(&task.id)
1057            .expect("list_for_task");
1058        assert_eq!(for_task.len(), 2);
1059        let ids: std::collections::HashSet<&str> = for_task.iter().map(|o| o.id.as_str()).collect();
1060        assert!(ids.contains(first.id.as_str()));
1061        assert!(ids.contains(second.id.as_str()));
1062
1063        // The global list sees them too.
1064        assert_eq!(store.outcomes().list(10).expect("list").len(), 2);
1065        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1066    }
1067
1068    #[test]
1069    pub(crate) fn claim_next_queued_orders_by_priority_then_fifo_and_skips_unclaimable() {
1070        let path = temp_db("claim_queue");
1071        let store = RuntimeStore::open(&path).expect("open store");
1072
1073        // Unclaimable rows: not daemon-owned; daemon-owned but prompt-less
1074        // (metadata-only); daemon-owned with prompt but already running.
1075        store
1076            .tasks()
1077            .create(NewTask::new("cli", "/p", "m").with_prompt("x"))
1078            .expect("cli task");
1079        store
1080            .tasks()
1081            .create(NewTask::new("meta", "/p", "m").daemon_owned())
1082            .expect("meta task");
1083        let busy = store
1084            .tasks()
1085            .create(
1086                NewTask::new("busy", "/p", "m")
1087                    .daemon_owned()
1088                    .with_prompt("x"),
1089            )
1090            .expect("busy task");
1091        store
1092            .tasks()
1093            .update_status(&busy.id, TaskStatus::Running, None)
1094            .expect("mark busy running");
1095
1096        let normal_first = store
1097            .tasks()
1098            .create(
1099                NewTask::new("n1", "/p", "m")
1100                    .daemon_owned()
1101                    .with_prompt("p1"),
1102            )
1103            .expect("n1");
1104        let low = store
1105            .tasks()
1106            .create(
1107                NewTask::new("l1", "/p", "m")
1108                    .daemon_owned()
1109                    .with_prompt("p2")
1110                    .with_priority(TaskPriority::Low),
1111            )
1112            .expect("l1");
1113        let high = store
1114            .tasks()
1115            .create(
1116                NewTask::new("h1", "/p", "m")
1117                    .daemon_owned()
1118                    .with_prompt("p-high")
1119                    .with_priority(TaskPriority::High),
1120            )
1121            .expect("h1");
1122        let normal_second = store
1123            .tasks()
1124            .create(
1125                NewTask::new("n2", "/p", "m")
1126                    .daemon_owned()
1127                    .with_prompt("p3"),
1128            )
1129            .expect("n2");
1130
1131        // High first (despite being enqueued after the normals), then the two
1132        // normals FIFO, then low; each claim flips the row to Running and
1133        // returns the persisted prompt.
1134        let c1 = store.tasks().claim_next_queued().expect("claim 1").unwrap();
1135        assert_eq!(c1.id, high.id);
1136        assert_eq!(c1.status, TaskStatus::Running);
1137        assert_eq!(c1.prompt.as_deref(), Some("p-high"));
1138        let c2 = store.tasks().claim_next_queued().expect("claim 2").unwrap();
1139        assert_eq!(c2.id, normal_first.id);
1140        let c3 = store.tasks().claim_next_queued().expect("claim 3").unwrap();
1141        assert_eq!(c3.id, normal_second.id);
1142        let c4 = store.tasks().claim_next_queued().expect("claim 4").unwrap();
1143        assert_eq!(c4.id, low.id);
1144        // Queue drained: nothing claimable remains (the unclaimable trio stays).
1145        assert!(
1146            store
1147                .tasks()
1148                .claim_next_queued()
1149                .expect("claim 5")
1150                .is_none()
1151        );
1152        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1153    }
1154
1155    #[test]
1156    pub(crate) fn outcome_allows_null_task_and_tool_run() {
1157        // A free-floating outcome (no task/tool_run) is valid — task_id is
1158        // nullable with ON DELETE SET NULL, so the loop never loses a signal to
1159        // a deleted subject.
1160        let path = temp_db("outcomes_null");
1161        let store = RuntimeStore::open(&path).expect("open store");
1162        let rec = store
1163            .outcomes()
1164            .record(NewOutcome {
1165                id: None,
1166                task_id: None,
1167                tool_run_id: None,
1168                kind: "build".to_string(),
1169                label: OUTCOME_LABEL_FAILURE.to_string(),
1170                reward: Some(-1.0),
1171                source: OUTCOME_SOURCE_VERIFIER.to_string(),
1172                detail_json: None,
1173            })
1174            .expect("record");
1175        assert_eq!(rec.task_id, None);
1176        assert_eq!(rec.tool_run_id, None);
1177        assert_eq!(store.outcomes().list(10).expect("list").len(), 1);
1178        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1179    }
1180
1181    #[test]
1182    pub(crate) fn initializes_runtime_schema() {
1183        let path = temp_db("schema");
1184        let store = RuntimeStore::open(&path).expect("open store");
1185        assert_eq!(store.path(), path.as_path());
1186        let version: i32 = store
1187            .conn
1188            .query_row("PRAGMA user_version", [], |row| row.get(0))
1189            .unwrap();
1190        assert_eq!(version, SCHEMA_VERSION);
1191        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1192    }
1193
1194    #[test]
1195    pub(crate) fn rejects_newer_schema_version() {
1196        // Forward-compat gate: a DB stamped with a newer schema must be
1197        // refused, not silently down-labeled and operated on (RC-5).
1198        let path = temp_db("newer_schema");
1199        {
1200            let store = RuntimeStore::open(&path).expect("first open");
1201            store
1202                .conn
1203                .execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
1204                .expect("bump version");
1205        }
1206        // `RuntimeStore` isn't `Debug`, so match rather than `expect_err`.
1207        let err = match RuntimeStore::open(&path) {
1208            Ok(_) => panic!("must refuse a newer DB"),
1209            Err(e) => e,
1210        };
1211        assert!(
1212            err.to_string().contains("newer than this build"),
1213            "unexpected error: {err}"
1214        );
1215        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1216    }
1217
1218    #[test]
1219    pub(crate) fn checkpoint_anchor_round_trips_and_list_for_session_is_strict() {
1220        let path = temp_db("checkpoint_anchor");
1221        let store = RuntimeStore::open(&path).expect("open store");
1222        for (id, idx) in [("cp-a", 3_i64), ("cp-b", 5), ("cp-c", 9)] {
1223            store
1224                .checkpoints()
1225                .create(NewCheckpoint {
1226                    id: Some(id.to_string()),
1227                    task_id: None,
1228                    project_path: "/tmp/p".to_string(),
1229                    snapshot_path: format!("/data/checkpoints/{id}"),
1230                    changed_files_json: "[]".to_string(),
1231                    pending_action_json: None,
1232                    approval_id: None,
1233                    session_id: Some("sess-1".to_string()),
1234                    message_index: Some(idx),
1235                })
1236                .expect("create checkpoint");
1237        }
1238        // Unanchored + other-session rows never surface.
1239        store
1240            .checkpoints()
1241            .create(NewCheckpoint {
1242                id: Some("cp-unanchored".to_string()),
1243                task_id: None,
1244                project_path: "/tmp/p".to_string(),
1245                snapshot_path: "/x".to_string(),
1246                changed_files_json: "[]".to_string(),
1247                pending_action_json: None,
1248                approval_id: None,
1249                session_id: None,
1250                message_index: None,
1251            })
1252            .expect("create unanchored");
1253
1254        let got = store.checkpoints().get("cp-a").unwrap().unwrap();
1255        assert_eq!(got.session_id.as_deref(), Some("sess-1"));
1256        assert_eq!(got.message_index, Some(3));
1257
1258        // STRICT boundary: fork at k=3 keeps messages[..3]; cp-a (index 3)
1259        // snapshotted state from BEFORE user message 3 existed — kept prefix.
1260        let past = store
1261            .checkpoints()
1262            .list_for_session("sess-1", 3)
1263            .expect("list_for_session");
1264        let ids: Vec<&str> = past.iter().map(|c| c.id.as_str()).collect();
1265        assert_eq!(ids, vec!["cp-b", "cp-c"], "strict > and oldest-first");
1266
1267        assert!(
1268            store
1269                .checkpoints()
1270                .list_for_session("sess-other", 0)
1271                .unwrap()
1272                .is_empty()
1273        );
1274        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1275    }
1276
1277    #[test]
1278    pub(crate) fn v7_drops_the_messages_table_and_keeps_its_session() {
1279        // The first genuinely non-additive migration. A v6 DB carrying the
1280        // table (with rows, as only tests ever produced) must open, lose the
1281        // table, and keep the session row that outlived it.
1282        let path = temp_db("v7_drop_messages");
1283        {
1284            let conn = Connection::open(&path).expect("raw open");
1285            conn.execute_batch(
1286                r"
1287                CREATE TABLE sessions (
1288                    id TEXT PRIMARY KEY,
1289                    project_path TEXT NOT NULL,
1290                    model_id TEXT NOT NULL,
1291                    title TEXT,
1292                    conversation_path TEXT,
1293                    created_at TEXT NOT NULL,
1294                    updated_at TEXT NOT NULL,
1295                    total_tokens INTEGER
1296                );
1297                CREATE TABLE messages (
1298                    id INTEGER PRIMARY KEY AUTOINCREMENT,
1299                    session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
1300                    role TEXT NOT NULL,
1301                    content_json TEXT NOT NULL,
1302                    created_at TEXT NOT NULL
1303                );
1304                CREATE INDEX idx_messages_session_id ON messages(session_id);
1305                INSERT INTO sessions (id, project_path, model_id, created_at, updated_at)
1306                    VALUES ('s-old', '/repo', 'm', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z');
1307                INSERT INTO messages (session_id, role, content_json, created_at)
1308                    VALUES ('s-old', 'user', '{}', '2026-01-01T00:00:00Z');
1309                PRAGMA user_version = 6;
1310                ",
1311            )
1312            .expect("seed a v6 schema");
1313        }
1314
1315        let store = RuntimeStore::open(&path).expect("upgrade open");
1316        let version: i32 = store
1317            .conn
1318            .query_row("PRAGMA user_version", [], |r| r.get(0))
1319            .unwrap();
1320        assert_eq!(version, SCHEMA_VERSION);
1321        let table_count: i64 = store
1322            .conn
1323            .query_row(
1324                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'messages'",
1325                [],
1326                |r| r.get(0),
1327            )
1328            .unwrap();
1329        assert_eq!(table_count, 0, "the messages table must be dropped");
1330        assert!(
1331            store.sessions().get("s-old").unwrap().is_some(),
1332            "the session it referenced must survive"
1333        );
1334        // And the baseline must not resurrect it on the next open.
1335        drop(store);
1336        let store = RuntimeStore::open(&path).expect("reopen");
1337        let table_count: i64 = store
1338            .conn
1339            .query_row(
1340                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'messages'",
1341                [],
1342                |r| r.get(0),
1343            )
1344            .unwrap();
1345        assert_eq!(table_count, 0, "a reopen must not re-create it");
1346        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1347    }
1348
1349    #[test]
1350    pub(crate) fn v5_database_upgrades_with_null_checkpoint_anchors() {
1351        // A DB created by the previous build (schema v5, no anchor columns)
1352        // must open cleanly, gain the columns, and load old rows as None.
1353        let path = temp_db("v5_upgrade");
1354        {
1355            let conn = Connection::open(&path).expect("raw open");
1356            conn.execute_batch(
1357                r#"
1358                CREATE TABLE checkpoints (
1359                    id TEXT PRIMARY KEY,
1360                    task_id TEXT,
1361                    project_path TEXT NOT NULL,
1362                    snapshot_path TEXT NOT NULL,
1363                    changed_files_json TEXT NOT NULL,
1364                    pending_action_json TEXT,
1365                    approval_id TEXT,
1366                    created_at TEXT NOT NULL,
1367                    archived_at TEXT,
1368                    archive_reason TEXT
1369                );
1370                INSERT INTO checkpoints
1371                    (id, task_id, project_path, snapshot_path, changed_files_json, created_at)
1372                    VALUES ('old-cp', NULL, '/tmp/p', '/snap', '[]', '2026-01-01T00:00:00Z');
1373                PRAGMA user_version = 5;
1374                "#,
1375            )
1376            .expect("seed v5 schema");
1377        }
1378        let store = RuntimeStore::open(&path).expect("upgrade open");
1379        let old = store.checkpoints().get("old-cp").unwrap().unwrap();
1380        assert_eq!(old.session_id, None);
1381        assert_eq!(old.message_index, None);
1382        let version: i32 = store
1383            .conn
1384            .query_row("PRAGMA user_version", [], |r| r.get(0))
1385            .unwrap();
1386        assert_eq!(version, SCHEMA_VERSION);
1387        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1388    }
1389
1390    #[test]
1391    pub(crate) fn init_schema_is_idempotent_across_opens() {
1392        // Re-opening the same DB re-runs `init_schema`; it must succeed (the
1393        // create script and `ensure_column` are idempotent) and keep the
1394        // version stamped.
1395        let path = temp_db("idempotent_schema");
1396        let _ = RuntimeStore::open(&path).expect("first open");
1397        let store = RuntimeStore::open(&path).expect("second open must succeed");
1398        let version: i32 = store
1399            .conn
1400            .query_row("PRAGMA user_version", [], |r| r.get(0))
1401            .unwrap();
1402        assert_eq!(version, SCHEMA_VERSION);
1403        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1404    }
1405
1406    pub(crate) fn explain_query_plan(conn: &Connection, sql: &str) -> String {
1407        let mut stmt = conn
1408            .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
1409            .expect("prepare EXPLAIN QUERY PLAN");
1410        // Column 3 of an EQP row is the human-readable `detail` (e.g.
1411        // "SEARCH approvals USING INDEX idx_approvals_pending ...").
1412        let rows = stmt
1413            .query_map([], |row| row.get::<_, String>(3))
1414            .expect("eqp query")
1415            .collect::<rusqlite::Result<Vec<String>>>()
1416            .expect("eqp rows");
1417        rows.join("\n")
1418    }
1419
1420    #[test]
1421    pub(crate) fn pending_and_reconcile_scans_use_indexes() {
1422        // F75: the pending-approval scan and the reconcile scan must hit their
1423        // covering indexes rather than full-table scans.
1424        let path = temp_db("scan_indexes");
1425        let store = RuntimeStore::open(&path).expect("open");
1426
1427        let index_count: i64 = store
1428            .conn
1429            .query_row(
1430                "SELECT COUNT(*) FROM sqlite_master
1431                 WHERE type = 'index'
1432                   AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
1433                [],
1434                |r| r.get(0),
1435            )
1436            .unwrap();
1437        assert_eq!(index_count, 2, "F75 indexes must be created");
1438
1439        // `list_pending`'s scan must use the partial pending index (it also serves
1440        // the ORDER BY created_at, so no separate sort).
1441        let plan = explain_query_plan(
1442            &store.conn,
1443            "SELECT id FROM approvals WHERE user_decision IS NULL ORDER BY created_at DESC",
1444        );
1445        assert!(
1446            plan.contains("idx_approvals_pending"),
1447            "pending scan must use idx_approvals_pending; plan was:\n{plan}"
1448        );
1449
1450        // `reconcile_after_restart`'s scan must use the (status, owner_kind) index.
1451        let plan = explain_query_plan(
1452            &store.conn,
1453            "SELECT id FROM tasks WHERE status = 'running' AND owner_kind = 'daemon'",
1454        );
1455        assert!(
1456            plan.contains("idx_tasks_status_owner"),
1457            "reconcile scan must use idx_tasks_status_owner; plan was:\n{plan}"
1458        );
1459
1460        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1461    }
1462
1463    #[test]
1464    pub(crate) fn upgrades_from_v2_to_current_and_adds_indexes() {
1465        // F75/F76: a DB stamped at the previous schema version must migrate forward
1466        // on the next open — re-run the idempotent baseline, pick up the F75
1467        // indexes, and stamp the current version — exercising the per-version
1468        // dispatch (`from_version = 2` runs the v3 step).
1469        let path = temp_db("upgrade_v2");
1470        {
1471            let store = RuntimeStore::open(&path).expect("first open");
1472            // Simulate an older v2 DB: drop the new indexes and roll the stamp back.
1473            store
1474                .conn
1475                .execute_batch(
1476                    "DROP INDEX IF EXISTS idx_approvals_pending;
1477                     DROP INDEX IF EXISTS idx_tasks_status_owner;
1478                     PRAGMA user_version = 2;",
1479                )
1480                .expect("downgrade to v2");
1481        }
1482        let store = RuntimeStore::open(&path).expect("reopen must migrate forward");
1483        let version: i32 = store
1484            .conn
1485            .query_row("PRAGMA user_version", [], |r| r.get(0))
1486            .unwrap();
1487        assert_eq!(version, SCHEMA_VERSION);
1488        let index_count: i64 = store
1489            .conn
1490            .query_row(
1491                "SELECT COUNT(*) FROM sqlite_master
1492                 WHERE type = 'index'
1493                   AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
1494                [],
1495                |r| r.get(0),
1496            )
1497            .unwrap();
1498        assert_eq!(
1499            index_count, 2,
1500            "forward migration must recreate the F75 indexes"
1501        );
1502        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1503    }
1504
1505    /// Strip a current DB back to the SHAPE version `version` really had —
1506    /// dropping the columns and tables added after it — rather than only
1507    /// rolling `user_version` back.
1508    ///
1509    /// The distinction is the whole point. `upgrades_from_v2_to_current_and_
1510    /// adds_indexes` drops two indexes and restamps, so the table it migrates
1511    /// still has every column a current table has. A v1 `tasks` table does not
1512    /// have `owner_kind`, and that is exactly what broke.
1513    fn downgrade_schema_to(conn: &Connection, version: i32) {
1514        // Indexes before the columns they cover: SQLite refuses to drop an
1515        // indexed column.
1516        if version < 6 {
1517            conn.execute_batch(
1518                "DROP INDEX IF EXISTS idx_checkpoints_session;
1519                 ALTER TABLE checkpoints DROP COLUMN session_id;
1520                 ALTER TABLE checkpoints DROP COLUMN message_index;",
1521            )
1522            .expect("undo v6");
1523        }
1524        if version < 5 {
1525            conn.execute_batch("ALTER TABLE tasks DROP COLUMN prompt;")
1526                .expect("undo v5");
1527        }
1528        if version < 4 {
1529            conn.execute_batch("DROP TABLE IF EXISTS outcomes;")
1530                .expect("undo v4");
1531        }
1532        if version < 3 {
1533            conn.execute_batch(
1534                "DROP INDEX IF EXISTS idx_approvals_pending;
1535                 DROP INDEX IF EXISTS idx_tasks_status_owner;",
1536            )
1537            .expect("undo v3");
1538        }
1539        if version < 2 {
1540            conn.execute_batch(
1541                "DROP INDEX IF EXISTS idx_tasks_status_owner;
1542                 ALTER TABLE tasks DROP COLUMN owner_kind;",
1543            )
1544            .expect("undo v2");
1545        }
1546        conn.execute_batch(&format!("PRAGMA user_version = {version};"))
1547            .expect("restamp");
1548    }
1549
1550    /// Every version `init_schema` claims to accept must actually upgrade.
1551    ///
1552    /// A v1 database could not. The F75 covering index was created in the
1553    /// idempotent baseline, which runs before the `ensure_column` that adds
1554    /// the column it indexes — so the migration threw "no such column:
1555    /// `owner_kind`", rolled back inside its own transaction, left
1556    /// `user_version` unstamped, and failed the same way on every open after.
1557    /// Tasks, approvals, checkpoints, processes and the daemon were all
1558    /// unreachable, permanently, with no way forward.
1559    ///
1560    /// It survived because the two existing migration tests start at v2 and
1561    /// v5 — the versions that were convenient to construct. This covers the
1562    /// whole accepted range, including 0, which `init_schema` also routes
1563    /// through the migration branch.
1564    #[test]
1565    pub(crate) fn every_supported_older_version_upgrades_to_current() {
1566        for version in 0..SCHEMA_VERSION {
1567            let path = temp_db(&format!("upgrade_from_v{version}"));
1568            {
1569                let store = RuntimeStore::open(&path).expect("first open");
1570                downgrade_schema_to(&store.conn, version);
1571            }
1572
1573            let store = RuntimeStore::open(&path)
1574                .unwrap_or_else(|e| panic!("a v{version} DB must upgrade, but: {e:#}"));
1575
1576            let stamped: i32 = store
1577                .conn
1578                .query_row("PRAGMA user_version", [], |r| r.get(0))
1579                .expect("read user_version");
1580            assert_eq!(
1581                stamped, SCHEMA_VERSION,
1582                "v{version} upgraded without stamping the current version"
1583            );
1584
1585            // The column and its index are the specific pair that broke, so
1586            // assert the end state rather than just "open returned Ok".
1587            let indexes: i64 = store
1588                .conn
1589                .query_row(
1590                    "SELECT COUNT(*) FROM sqlite_master
1591                     WHERE type = 'index'
1592                       AND name IN ('idx_tasks_status_owner', 'idx_approvals_pending',
1593                                    'idx_checkpoints_session')",
1594                    [],
1595                    |r| r.get(0),
1596                )
1597                .expect("count indexes");
1598            assert_eq!(indexes, 3, "v{version} upgrade left indexes missing");
1599
1600            // A write proves the migrated table is usable, not merely present.
1601            // `owner_kind` is set deliberately: it is the column the broken
1602            // migration never added, so a row carrying it is the end-to-end
1603            // claim rather than a schema inspection.
1604            store
1605                .tasks()
1606                .create(NewTask {
1607                    title: "migrated".to_string(),
1608                    project_path: "/p".to_string(),
1609                    model_id: "m".to_string(),
1610                    priority: TaskPriority::Normal,
1611                    conversation_id: None,
1612                    owner_kind: Some("daemon".to_string()),
1613                    prompt: None,
1614                })
1615                .unwrap_or_else(|e| panic!("v{version} upgraded DB must accept writes: {e:#}"));
1616
1617            let _ = std::fs::remove_dir_all(path.parent().expect("temp dir"));
1618        }
1619    }
1620
1621    #[test]
1622    pub(crate) fn task_create_commits_task_and_event_atomically() {
1623        // The task row and its `task_created` event commit in one transaction.
1624        let path = temp_db("task_txn");
1625        let store = RuntimeStore::open(&path).expect("open");
1626        let task = store
1627            .tasks()
1628            .create(NewTask::new("do a thing", "/repo", "anthropic/claude"))
1629            .expect("create task");
1630        let events = store.tasks().events(&task.id).expect("events");
1631        assert!(
1632            events.iter().any(|e| e.kind == "task_created"),
1633            "the task_created event must commit with the task row"
1634        );
1635        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1636    }
1637
1638    #[test]
1639    pub(crate) fn task_lifecycle_round_trips() {
1640        let path = temp_db("task");
1641        let store = RuntimeStore::open(&path).expect("open store");
1642        let session = store
1643            .sessions()
1644            .upsert(NewSession {
1645                id: Some("session-1".to_string()),
1646                project_path: "/repo".to_string(),
1647                model_id: "anthropic/claude".to_string(),
1648                title: Some("Run tests".to_string()),
1649                conversation_path: Some("/repo/.mermaid/session.json".to_string()),
1650                total_tokens: Some(42),
1651            })
1652            .expect("upsert session");
1653        assert_eq!(session.id, "session-1");
1654
1655        let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
1656        new.priority = TaskPriority::High;
1657        let task = store.tasks().create(new).expect("create task");
1658
1659        assert_eq!(task.status, TaskStatus::Queued);
1660        assert_eq!(task.priority, TaskPriority::High);
1661
1662        store
1663            .tasks()
1664            .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
1665            .expect("update task");
1666        let loaded = store.tasks().get(&task.id).unwrap().unwrap();
1667        assert_eq!(loaded.status, TaskStatus::Completed);
1668        assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
1669
1670        let events = store.tasks().events(&task.id).expect("events");
1671        assert_eq!(events.len(), 2);
1672        assert_eq!(events[0].kind, "task_created");
1673        assert_eq!(events[1].kind, "status_changed");
1674        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1675    }
1676
1677    #[test]
1678    pub(crate) fn approval_and_process_records_round_trip() {
1679        let path = temp_db("approval_process");
1680        let store = RuntimeStore::open(&path).expect("open store");
1681        let task = store
1682            .tasks()
1683            .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
1684            .expect("create task");
1685
1686        let approval = store
1687            .approvals()
1688            .create(NewApproval {
1689                task_id: Some(task.id.clone()),
1690                proposed_action: "write_file src/lib.rs".to_string(),
1691                risk_classification: "file_mutation".to_string(),
1692                policy_decision: "ask".to_string(),
1693                args_summary: Some("src/lib.rs".to_string()),
1694                checkpoint_id: Some("checkpoint-1".to_string()),
1695                pending_action_json: Some(
1696                    "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
1697                ),
1698            })
1699            .expect("create approval");
1700        store
1701            .approvals()
1702            .decide(&approval.id, "approved")
1703            .expect("decide approval");
1704        let approval = store.approvals().get(&approval.id).unwrap().unwrap();
1705        assert_eq!(approval.user_decision.as_deref(), Some("approved"));
1706        assert!(approval.pending_action_json.is_some());
1707
1708        let tool_run = store
1709            .tool_runs()
1710            .start(NewToolRun {
1711                id: Some("toolrun-1".to_string()),
1712                task_id: Some(task.id.clone()),
1713                turn_id: Some("turn-1".to_string()),
1714                call_id: Some("call-1".to_string()),
1715                tool_name: "write_file".to_string(),
1716                args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
1717            })
1718            .expect("start tool run");
1719        assert_eq!(tool_run.status, "running");
1720        store
1721            .tool_runs()
1722            .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
1723            .expect("finish tool run");
1724        let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
1725        assert_eq!(tool_run.status, "success");
1726        assert!(tool_run.finished_at.is_some());
1727
1728        let process = store
1729            .processes()
1730            .upsert(NewProcess {
1731                id: Some("proc-1".to_string()),
1732                task_id: Some(task.id),
1733                pid: 123,
1734                command: "npm run dev".to_string(),
1735                cwd: Some("/repo".to_string()),
1736                log_path: Some("/tmp/mermaid.log".to_string()),
1737                detected_url: Some("http://127.0.0.1:5173".to_string()),
1738                status: ProcessStatus::Running,
1739                health: Some("ready".to_string()),
1740            })
1741            .expect("upsert process");
1742        assert_eq!(process.status, ProcessStatus::Running);
1743        assert_eq!(store.processes().list(10).unwrap().len(), 1);
1744        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1745    }
1746
1747    #[test]
1748    pub(crate) fn approval_decide_is_single_shot() {
1749        let path = temp_db("approval_decide_guard");
1750        let store = RuntimeStore::open(&path).expect("open store");
1751        let make = |action: &str| {
1752            store
1753                .approvals()
1754                .create(NewApproval {
1755                    task_id: None,
1756                    proposed_action: action.to_string(),
1757                    risk_classification: "file_mutation".to_string(),
1758                    policy_decision: "ask".to_string(),
1759                    args_summary: None,
1760                    checkpoint_id: None,
1761                    pending_action_json: None,
1762                })
1763                .expect("create approval")
1764        };
1765
1766        // A second decision on an already-decided approval is rejected — this
1767        // is what stops a stored action from being replayed N times.
1768        let a = make("write_file a");
1769        store
1770            .approvals()
1771            .decide(&a.id, "approved")
1772            .expect("first decide");
1773        assert!(
1774            store.approvals().decide(&a.id, "approved").is_err(),
1775            "re-approving an approved approval must be rejected"
1776        );
1777
1778        // A denied approval cannot be resurrected as approved.
1779        let b = make("write_file b");
1780        store.approvals().decide(&b.id, "denied").expect("deny");
1781        assert!(
1782            store.approvals().decide(&b.id, "approved").is_err(),
1783            "a denied approval must not be re-decidable as approved"
1784        );
1785        let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
1786        assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
1787
1788        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1789    }
1790
1791    #[test]
1792    pub(crate) fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
1793        let path = temp_db("archive_visibility");
1794        let store = RuntimeStore::open(&path).expect("open store");
1795
1796        let approval = store
1797            .approvals()
1798            .create(NewApproval {
1799                task_id: None,
1800                proposed_action: "restore replay: write_file".to_string(),
1801                risk_classification: "restored_action".to_string(),
1802                policy_decision: "ask".to_string(),
1803                args_summary: None,
1804                checkpoint_id: Some("checkpoint-1".to_string()),
1805                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1806            })
1807            .expect("create approval");
1808        let checkpoint = store
1809            .checkpoints()
1810            .create(NewCheckpoint {
1811                id: Some("checkpoint-1".to_string()),
1812                task_id: None,
1813                project_path: "/tmp/mermaid_checkpoint_test".to_string(),
1814                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
1815                changed_files_json: "[]".to_string(),
1816                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1817                approval_id: Some(approval.id.clone()),
1818                session_id: None,
1819                message_index: None,
1820            })
1821            .expect("create checkpoint");
1822
1823        assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
1824        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
1825        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
1826        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
1827        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
1828
1829        assert_eq!(
1830            store
1831                .approvals()
1832                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
1833                .unwrap(),
1834            1
1835        );
1836        assert_eq!(
1837            store
1838                .checkpoints()
1839                .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
1840                .unwrap(),
1841            1
1842        );
1843        assert_eq!(
1844            store
1845                .approvals()
1846                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
1847                .unwrap(),
1848            0
1849        );
1850        assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
1851        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
1852        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
1853        assert_eq!(store.approvals().count_archived().unwrap(), 1);
1854        assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
1855        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
1856        assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
1857
1858        let archived = store.approvals().get(&approval.id).unwrap().unwrap();
1859        assert!(archived.archived_at.is_some());
1860        assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
1861        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1862    }
1863
1864    #[test]
1865    pub(crate) fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
1866        let path = temp_db("everything_else");
1867        let store = RuntimeStore::open(&path).expect("open store");
1868
1869        let checkpoint = store
1870            .checkpoints()
1871            .create(NewCheckpoint {
1872                id: Some("checkpoint-1".to_string()),
1873                task_id: None,
1874                project_path: "/repo".to_string(),
1875                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
1876                changed_files_json: "[\"src/lib.rs\"]".to_string(),
1877                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1878                approval_id: None,
1879                session_id: None,
1880                message_index: None,
1881            })
1882            .expect("create checkpoint");
1883        assert_eq!(checkpoint.id, "checkpoint-1");
1884        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
1885
1886        let compaction = store
1887            .compactions()
1888            .create(NewCompaction {
1889                id: Some("compaction-1".to_string()),
1890                task_id: None,
1891                session_id: Some("session-1".to_string()),
1892                source_token_estimate: Some(10_000),
1893                summary_token_count: Some(800),
1894                preserved_turns: Some(6),
1895                archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
1896                verification_status: Some("verified".to_string()),
1897            })
1898            .expect("create compaction");
1899        assert_eq!(compaction.summary_token_count, Some(800));
1900        assert_eq!(store.compactions().list(10).unwrap().len(), 1);
1901
1902        let plugin = store
1903            .plugins()
1904            .install(NewPluginInstall {
1905                id: Some("plugin-1".to_string()),
1906                name: "example".to_string(),
1907                source: "local".to_string(),
1908                version: Some("0.1.0".to_string()),
1909                enabled: true,
1910                manifest_json: "{\"name\":\"example\"}".to_string(),
1911            })
1912            .expect("install plugin");
1913        assert!(plugin.enabled);
1914        store.plugins().set_enabled("plugin-1", false).unwrap();
1915        assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
1916
1917        let probe = store
1918            .provider_probes()
1919            .upsert(NewProviderProbe {
1920                provider: "cerebras".to_string(),
1921                model_id: "gpt-oss-120b".to_string(),
1922                capability_key: "parallel_tool_calls".to_string(),
1923                capability_value: "false".to_string(),
1924                confidence: "static".to_string(),
1925                error: None,
1926            })
1927            .expect("probe");
1928        assert_eq!(probe.confidence, "static");
1929        assert_eq!(
1930            store
1931                .provider_probes()
1932                .list(Some("cerebras"), Some("gpt-oss-120b"))
1933                .unwrap()
1934                .len(),
1935            1
1936        );
1937
1938        let pairing = store
1939            .pairing_tokens()
1940            .create("hash", Some("phone"), None)
1941            .expect("pairing");
1942        store.pairing_tokens().mark_used(&pairing.id).unwrap();
1943        assert!(
1944            store
1945                .pairing_tokens()
1946                .get(&pairing.id)
1947                .unwrap()
1948                .unwrap()
1949                .last_used_at
1950                .is_some()
1951        );
1952        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1953    }
1954
1955    #[test]
1956    pub(crate) fn pairing_token_expiry_and_revoke() {
1957        let path = temp_db("pairing_ttl");
1958        let store = RuntimeStore::open(&path).expect("open store");
1959        let tokens = store.pairing_tokens();
1960
1961        // A never-expiring token verifies.
1962        let live = tokens
1963            .create("live_hash", Some("a"), None)
1964            .expect("create live");
1965        assert!(tokens.verify_token("live_hash").unwrap().is_some());
1966
1967        // A future expiry still verifies; a past expiry does not.
1968        let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
1969        tokens
1970            .create("future_hash", None, Some(&future))
1971            .expect("create future");
1972        assert!(tokens.verify_token("future_hash").unwrap().is_some());
1973
1974        let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
1975        tokens
1976            .create("past_hash", None, Some(&past))
1977            .expect("create past");
1978        assert!(
1979            tokens.verify_token("past_hash").unwrap().is_none(),
1980            "an expired token must not verify"
1981        );
1982
1983        // A future expiry rendered with a non-UTC offset still verifies, even
1984        // though its RFC3339 string sorts lexically *before* `now_rfc3339()` —
1985        // this would wrongly read as expired under the old SQL string compare (#64).
1986        let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
1987            .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
1988            .to_rfc3339();
1989        tokens
1990            .create("skew_hash", None, Some(&skewed))
1991            .expect("create skewed");
1992        assert!(
1993            tokens.verify_token("skew_hash").unwrap().is_some(),
1994            "a future token in a non-UTC offset must verify (parsed-instant compare)"
1995        );
1996
1997        // A present-but-unparseable expiry fails closed (treated as expired).
1998        tokens
1999            .create("garbage_hash", None, Some("not-a-timestamp"))
2000            .expect("create garbage");
2001        assert!(
2002            tokens.verify_token("garbage_hash").unwrap().is_none(),
2003            "an unparseable expiry must fail closed"
2004        );
2005
2006        // Revoking disables the token.
2007        assert!(tokens.revoke(&live.id).unwrap());
2008        assert!(tokens.verify_token("live_hash").unwrap().is_none());
2009        assert!(
2010            !tokens.revoke(&live.id).unwrap(),
2011            "double revoke is a no-op"
2012        );
2013
2014        // A non-matching hash never verifies.
2015        assert!(tokens.verify_token("nope").unwrap().is_none());
2016
2017        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2018    }
2019
2020    #[test]
2021    pub(crate) fn ct_eq_matches_only_identical_bytes() {
2022        assert!(ct_eq(b"abc", b"abc"));
2023        assert!(!ct_eq(b"abc", b"abd"));
2024        assert!(!ct_eq(b"abc", b"ab"));
2025        assert!(!ct_eq(b"", b"x"));
2026        assert!(ct_eq(b"", b""));
2027    }
2028
2029    #[test]
2030    pub(crate) fn fresh_id_is_collision_free_in_tight_loop() {
2031        // The #61 stress: ids minted back-to-back (same nanosecond on a coarse
2032        // clock) must all be distinct and keep the `prefix-` shape.
2033        let mut seen = std::collections::HashSet::new();
2034        for _ in 0..10_000 {
2035            let id = fresh_id("process");
2036            assert!(id.starts_with("process-"), "id must keep prefix: {id}");
2037            assert!(seen.insert(id), "fresh_id produced a duplicate");
2038        }
2039    }
2040
2041    #[test]
2042    pub(crate) fn tool_run_repository_redacts_arguments_and_outcomes() {
2043        let path = temp_db("persistence_redaction");
2044        let store = RuntimeStore::open(&path).expect("open store");
2045        let run = store
2046            .tool_runs()
2047            .start(NewToolRun {
2048                id: Some("toolrun-redacted".to_string()),
2049                task_id: None,
2050                turn_id: None,
2051                call_id: None,
2052                tool_name: "web_fetch".to_string(),
2053                args_json: Some(
2054                    serde_json::json!({
2055                        "url": "https://user:password@example.test/a?X-Goog-Credential=opaque-id&X-Goog-Signature=opaque-signature#fragment",
2056                        "password": "abc",
2057                        "token": 12345,
2058                        "nested": { "client_secret": true }
2059                    })
2060                    .to_string(),
2061                ),
2062            })
2063            .expect("start tool run");
2064        store
2065            .tool_runs()
2066            .finish(
2067                &run.id,
2068                "success",
2069                Some(
2070                    &serde_json::json!({
2071                        "model_content": "OPENAI_API_KEY=sk-abcdefghijklmnop1234\npassword=abc\nAuthorization: Bearer xyz\nAuthorization: Basic dXNlcjphYmM=\nhttps://example.test/download/sk-zyxwvutsrqponmlk9876\n-----BEGIN PRIVATE KEY-----\ncHJpdmF0ZS1tYXRlcmlhbA==\n-----END PRIVATE KEY-----"
2072                    })
2073                    .to_string(),
2074                ),
2075            )
2076            .expect("finish tool run");
2077        let persisted = store.tool_runs().get(&run.id).unwrap().unwrap();
2078        let args: serde_json::Value =
2079            serde_json::from_str(persisted.args_json.as_deref().unwrap()).unwrap();
2080        assert_eq!(args["password"], "[REDACTED]");
2081        assert_eq!(args["token"], "[REDACTED]");
2082        assert_eq!(args["nested"]["client_secret"], "[REDACTED]");
2083        let combined = format!("{:?}{:?}", persisted.args_json, persisted.output_json);
2084        for secret in [
2085            "user",
2086            "opaque-signature",
2087            "opaque-id",
2088            "fragment",
2089            "sk-abcdefghijklmnop1234",
2090            "password=abc",
2091            "Bearer xyz",
2092            "dXNlcjphYmM=",
2093            "sk-zyxwvutsrqponmlk9876",
2094            "cHJpdmF0ZS1tYXRlcmlhbA==",
2095            "-----END PRIVATE KEY-----",
2096            "12345",
2097        ] {
2098            assert!(
2099                !combined.contains(secret),
2100                "tool run leaked {secret}: {combined}"
2101            );
2102        }
2103
2104        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2105    }
2106
2107    #[test]
2108    pub(crate) fn ensure_column_rejects_non_identifier() {
2109        let path = temp_db("ensure_col");
2110        let store = RuntimeStore::open(&path).expect("open store");
2111        assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
2112        assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
2113        assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
2114        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2115    }
2116
2117    #[test]
2118    pub(crate) fn clamp_limit_never_binds_negative() {
2119        // #128: a huge `limit` must clamp, not wrap to a negative i64 (which
2120        // SQLite reads as unbounded).
2121        assert_eq!(clamp_limit(10), 10);
2122        assert_eq!(clamp_limit(usize::MAX), MAX_QUERY_LIMIT as i64);
2123        assert!(clamp_limit(usize::MAX) > 0);
2124    }
2125
2126    pub(crate) fn make_approval(store: &RuntimeStore, action: &str) -> ApprovalRecord {
2127        store
2128            .approvals()
2129            .create(NewApproval {
2130                task_id: None,
2131                proposed_action: action.to_string(),
2132                risk_classification: "shell_mutation".to_string(),
2133                policy_decision: "ask".to_string(),
2134                args_summary: None,
2135                checkpoint_id: None,
2136                pending_action_json: None,
2137            })
2138            .expect("create approval")
2139    }
2140
2141    #[test]
2142    pub(crate) fn approval_claim_is_single_winner_releasable_and_finalizable() {
2143        // #118: exactly one concurrent claim wins; a released claim re-claims; a
2144        // finalized one is decided and unclaimable.
2145        let path = temp_db("approval_claim");
2146        let store = RuntimeStore::open(&path).expect("open store");
2147        let a = make_approval(&store, "write_file a");
2148
2149        assert!(store.approvals().claim(&a.id).unwrap(), "first claim wins");
2150        assert!(
2151            !store.approvals().claim(&a.id).unwrap(),
2152            "second claim loses"
2153        );
2154
2155        store.approvals().release_claim(&a.id).unwrap();
2156        assert!(
2157            store.approvals().claim(&a.id).unwrap(),
2158            "a released claim is re-claimable (effect-failed path)"
2159        );
2160
2161        store
2162            .approvals()
2163            .finalize_claimed(&a.id, "approved")
2164            .unwrap();
2165        assert_eq!(
2166            store
2167                .approvals()
2168                .get(&a.id)
2169                .unwrap()
2170                .unwrap()
2171                .user_decision
2172                .as_deref(),
2173            Some("approved")
2174        );
2175        assert!(
2176            !store.approvals().claim(&a.id).unwrap(),
2177            "a decided approval cannot be claimed"
2178        );
2179        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2180    }
2181
2182    #[test]
2183    pub(crate) fn reconcile_after_restart_recovers_running_tasks_and_claims() {
2184        // #120/#118: a daemon-owned Running task and an 'approving' claim left by a
2185        // crashed daemon are recovered on the next startup.
2186        let path = temp_db("reconcile");
2187        let store = RuntimeStore::open(&path).expect("open store");
2188        let task = store
2189            .tasks()
2190            .create(NewTask::new("t", "/repo", "m").daemon_owned())
2191            .expect("create task");
2192        store
2193            .tasks()
2194            .update_status(&task.id, TaskStatus::Running, None)
2195            .expect("mark running");
2196        let appr = make_approval(&store, "git push");
2197        assert!(store.approvals().claim(&appr.id).unwrap());
2198
2199        let (tasks, claims) = store.reconcile_after_restart().expect("reconcile");
2200        assert_eq!((tasks, claims), (1, 1));
2201        assert_eq!(
2202            store.tasks().get(&task.id).unwrap().unwrap().status,
2203            TaskStatus::Failed
2204        );
2205        assert!(
2206            store
2207                .approvals()
2208                .get(&appr.id)
2209                .unwrap()
2210                .unwrap()
2211                .user_decision
2212                .is_none(),
2213            "a released claim is undecided and re-runnable"
2214        );
2215        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2216    }
2217
2218    #[test]
2219    pub(crate) fn reconcile_after_restart_spares_non_daemon_running_tasks() {
2220        // F18 (RC-E): a Running task NOT owned by the daemon (an interactive CLI
2221        // run sharing the store, owner_kind = NULL) must survive a daemon restart
2222        // — not be flipped to Failed with a spurious "interrupted" event.
2223        let path = temp_db("reconcile_spare_cli");
2224        let store = RuntimeStore::open(&path).expect("open store");
2225
2226        let cli = store
2227            .tasks()
2228            .create(NewTask::new("cli run", "/repo", "m")) // no .daemon_owned()
2229            .expect("create cli task");
2230        store
2231            .tasks()
2232            .update_status(&cli.id, TaskStatus::Running, None)
2233            .expect("mark cli running");
2234        let daemon = store
2235            .tasks()
2236            .create(NewTask::new("daemon run", "/repo", "m").daemon_owned())
2237            .expect("create daemon task");
2238        store
2239            .tasks()
2240            .update_status(&daemon.id, TaskStatus::Running, None)
2241            .expect("mark daemon running");
2242
2243        let (tasks, _claims) = store.reconcile_after_restart().expect("reconcile");
2244        assert_eq!(tasks, 1, "only the daemon-owned task is reset");
2245        assert_eq!(
2246            store.tasks().get(&cli.id).unwrap().unwrap().status,
2247            TaskStatus::Running,
2248            "a live CLI task must NOT be clobbered by the daemon's reconcile"
2249        );
2250        assert_eq!(
2251            store.tasks().get(&daemon.id).unwrap().unwrap().status,
2252            TaskStatus::Failed,
2253            "a stranded daemon task is still recovered"
2254        );
2255        // The spared CLI task gets no "interrupted" event.
2256        assert!(
2257            !store
2258                .tasks()
2259                .events(&cli.id)
2260                .unwrap()
2261                .iter()
2262                .any(|e| e.kind == "interrupted"),
2263            "the spared task must not receive a spurious interrupted event"
2264        );
2265        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2266    }
2267
2268    #[test]
2269    pub(crate) fn gc_prunes_old_archived_but_keeps_active() {
2270        // #130: GC removes archived rows past the retention window, never active
2271        // ones.
2272        let path = temp_db("gc");
2273        let store = RuntimeStore::open(&path).expect("open store");
2274        let keep = make_approval(&store, "active");
2275        let gone = make_approval(&store, "old archived");
2276        store
2277            .approvals()
2278            .archive(std::slice::from_ref(&gone.id), "test")
2279            .expect("archive");
2280        // Backdate the archive far past the window.
2281        store
2282            .conn
2283            .execute(
2284                "UPDATE approvals SET archived_at = ?2 WHERE id = ?1",
2285                params![gone.id, "2000-01-01T00:00:00+00:00"],
2286            )
2287            .unwrap();
2288
2289        let removed = store.gc(30, 180).expect("gc");
2290        assert!(removed >= 1, "the old archived approval should be pruned");
2291        assert!(
2292            store.approvals().get(&gone.id).unwrap().is_none(),
2293            "old archived row removed"
2294        );
2295        assert!(
2296            store.approvals().get(&keep.id).unwrap().is_some(),
2297            "active row kept"
2298        );
2299        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2300    }
2301
2302    #[test]
2303    pub(crate) fn gc_prunes_outcomes_and_terminal_tasks_on_their_windows() {
2304        // R1: `gc` prunes terminal tasks past the task window and `outcomes` past
2305        // their own (longer) window, never touching a live task or a recent
2306        // outcome. When a task is pruned while its outcome survives, the outcome
2307        // stays with a NULL `task_id` (ON DELETE SET NULL) — the denormalized
2308        // `detail_json` is what keeps it usable for training after the link dies.
2309        let path = temp_db("gc_outcomes");
2310        let store = RuntimeStore::open(&path).expect("open store");
2311        let old = "2000-01-01T00:00:00+00:00"; // far past both windows
2312
2313        // A live (queued) task must survive.
2314        let live = store
2315            .tasks()
2316            .create(NewTask::new("live", "/repo", "m"))
2317            .expect("live task");
2318
2319        // An old terminal task must be pruned.
2320        let done = store
2321            .tasks()
2322            .create(NewTask::new("done", "/repo", "m"))
2323            .expect("done task");
2324        store
2325            .tasks()
2326            .update_status(&done.id, TaskStatus::Completed, Some("ok"))
2327            .expect("finish task");
2328        store
2329            .conn
2330            .execute(
2331                "UPDATE tasks SET updated_at = ?2 WHERE id = ?1",
2332                params![done.id, old],
2333            )
2334            .unwrap();
2335
2336        // An outcome for that pruned task, still inside the (longer) outcomes
2337        // window: it must survive, with its link nulled and its context intact.
2338        let kept_outcome = store
2339            .outcomes()
2340            .record(NewOutcome {
2341                id: None,
2342                task_id: Some(done.id.clone()),
2343                tool_run_id: None,
2344                kind: "task_terminal".to_string(),
2345                label: OUTCOME_LABEL_SUCCESS.to_string(),
2346                reward: Some(1.0),
2347                source: OUTCOME_SOURCE_SYSTEM.to_string(),
2348                detail_json: Some("{\"prompt\":\"do the thing\"}".to_string()),
2349            })
2350            .expect("record kept outcome");
2351
2352        // An ancient outcome, past the outcomes window: it must be pruned.
2353        let gone_outcome = store
2354            .outcomes()
2355            .record(NewOutcome {
2356                id: None,
2357                task_id: None,
2358                tool_run_id: None,
2359                kind: "task_terminal".to_string(),
2360                label: OUTCOME_LABEL_FAILURE.to_string(),
2361                reward: Some(-1.0),
2362                source: OUTCOME_SOURCE_SYSTEM.to_string(),
2363                detail_json: None,
2364            })
2365            .expect("record gone outcome");
2366        store
2367            .conn
2368            .execute(
2369                "UPDATE outcomes SET created_at = ?2 WHERE id = ?1",
2370                params![gone_outcome.id, old],
2371            )
2372            .unwrap();
2373
2374        store.gc(30, 180).expect("gc");
2375
2376        assert!(
2377            store.tasks().get(&live.id).unwrap().is_some(),
2378            "a live (queued) task must survive gc"
2379        );
2380        assert!(
2381            store.tasks().get(&done.id).unwrap().is_none(),
2382            "an old terminal task must be pruned"
2383        );
2384        let kept = store
2385            .outcomes()
2386            .get(&kept_outcome.id)
2387            .unwrap()
2388            .expect("the recent outcome must survive gc");
2389        assert!(
2390            kept.task_id.is_none(),
2391            "the pruned task's link is nulled (ON DELETE SET NULL)"
2392        );
2393        assert_eq!(
2394            kept.detail_json.as_deref(),
2395            Some("{\"prompt\":\"do the thing\"}"),
2396            "the denormalized training context must survive the task prune"
2397        );
2398        assert!(
2399            store.outcomes().get(&gone_outcome.id).unwrap().is_none(),
2400            "an outcome past the outcomes window must be pruned"
2401        );
2402
2403        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2404    }
2405
2406    #[test]
2407    #[expect(
2408        clippy::too_many_lines,
2409        reason = "predates the lint; see .github/baselines/expect_budget.txt"
2410    )]
2411    pub(crate) fn gc_prunes_high_churn_and_old_terminal_rows_but_keeps_active() {
2412        // F22 (RC-F): GC prunes finished tool_runs, exited processes, old
2413        // compactions, and stale sessions/messages past the window — never active
2414        // data (a running tool_run, a live process, a fresh session).
2415        let path = temp_db("gc_high_churn");
2416        let store = RuntimeStore::open(&path).expect("open store");
2417        let old = "2000-01-01T00:00:00+00:00";
2418
2419        // Stale session + message (deleted) vs active session + message (kept).
2420        let stale_session = store
2421            .sessions()
2422            .upsert(NewSession {
2423                id: Some("stale".to_string()),
2424                project_path: "/repo".to_string(),
2425                model_id: "m".to_string(),
2426                title: None,
2427                conversation_path: None,
2428                total_tokens: None,
2429            })
2430            .expect("stale session");
2431        let active_session = store
2432            .sessions()
2433            .upsert(NewSession {
2434                id: Some("active".to_string()),
2435                project_path: "/repo".to_string(),
2436                model_id: "m".to_string(),
2437                title: None,
2438                conversation_path: None,
2439                total_tokens: None,
2440            })
2441            .expect("active session");
2442        store
2443            .conn
2444            .execute(
2445                "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
2446                params![stale_session.id, old],
2447            )
2448            .unwrap();
2449
2450        // Finished (old) tool_run deleted; running tool_run kept.
2451        store
2452            .tool_runs()
2453            .start(NewToolRun {
2454                id: Some("tr-finished".to_string()),
2455                task_id: None,
2456                turn_id: None,
2457                call_id: None,
2458                tool_name: "x".to_string(),
2459                args_json: None,
2460            })
2461            .expect("start finished tr");
2462        store
2463            .tool_runs()
2464            .finish("tr-finished", "success", None)
2465            .expect("finish tr");
2466        store
2467            .conn
2468            .execute(
2469                "UPDATE tool_runs SET finished_at = ?2 WHERE id = ?1",
2470                params!["tr-finished", old],
2471            )
2472            .unwrap();
2473        store
2474            .tool_runs()
2475            .start(NewToolRun {
2476                id: Some("tr-running".to_string()),
2477                task_id: None,
2478                turn_id: None,
2479                call_id: None,
2480                tool_name: "x".to_string(),
2481                args_json: None,
2482            })
2483            .expect("start running tr");
2484
2485        // Exited (old) process deleted; running process kept.
2486        let exited = store
2487            .processes()
2488            .upsert(NewProcess {
2489                id: Some("p-exited".to_string()),
2490                task_id: None,
2491                pid: 1,
2492                command: "c".to_string(),
2493                cwd: None,
2494                log_path: None,
2495                detected_url: None,
2496                status: ProcessStatus::Exited,
2497                health: None,
2498            })
2499            .expect("exited process");
2500        store
2501            .conn
2502            .execute(
2503                "UPDATE processes SET updated_at = ?2 WHERE id = ?1",
2504                params![exited.id, old],
2505            )
2506            .unwrap();
2507        let running_proc = store
2508            .processes()
2509            .upsert(NewProcess {
2510                id: Some("p-running".to_string()),
2511                task_id: None,
2512                pid: 2,
2513                command: "c".to_string(),
2514                cwd: None,
2515                log_path: None,
2516                detected_url: None,
2517                status: ProcessStatus::Running,
2518                health: None,
2519            })
2520            .expect("running process");
2521
2522        // Old compaction deleted.
2523        let comp = store
2524            .compactions()
2525            .create(NewCompaction {
2526                id: Some("comp-old".to_string()),
2527                task_id: None,
2528                session_id: None,
2529                source_token_estimate: None,
2530                summary_token_count: None,
2531                preserved_turns: None,
2532                archive_path: None,
2533                verification_status: None,
2534            })
2535            .expect("compaction");
2536        store
2537            .conn
2538            .execute(
2539                "UPDATE compactions SET created_at = ?2 WHERE id = ?1",
2540                params![comp.id, old],
2541            )
2542            .unwrap();
2543
2544        let removed = store.gc(30, 180).expect("gc");
2545        // Four, not five: the message row this used to count went away with
2546        // the table at schema v7.
2547        assert!(removed >= 4, "stale rows pruned (got {removed})");
2548        assert!(
2549            store.sessions().get(&stale_session.id).unwrap().is_none(),
2550            "stale session gone"
2551        );
2552        assert!(
2553            store.sessions().get(&active_session.id).unwrap().is_some(),
2554            "active session kept"
2555        );
2556        assert!(
2557            store.tool_runs().get("tr-finished").unwrap().is_none(),
2558            "old finished tool_run gone"
2559        );
2560        assert!(
2561            store.tool_runs().get("tr-running").unwrap().is_some(),
2562            "running tool_run kept"
2563        );
2564        assert!(
2565            store.processes().get(&exited.id).unwrap().is_none(),
2566            "old exited process gone"
2567        );
2568        assert!(
2569            store.processes().get(&running_proc.id).unwrap().is_some(),
2570            "running process kept"
2571        );
2572        assert!(
2573            store.compactions().get(&comp.id).unwrap().is_none(),
2574            "old compaction gone"
2575        );
2576        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2577    }
2578
2579    #[test]
2580    pub(crate) fn task_list_skips_undecodable_status_row() {
2581        // F19 (RC-E): a task row whose status enum this build can't decode (a
2582        // different binary wrote it) is skipped, not allowed to blank the list.
2583        let path = temp_db("poison_task");
2584        let store = RuntimeStore::open(&path).expect("open store");
2585        let good = store
2586            .tasks()
2587            .create(NewTask::new("good", "/repo", "m"))
2588            .expect("create good task");
2589        store
2590            .conn
2591            .execute(
2592                "INSERT INTO tasks
2593                 (id, title, status, priority, project_path, model_id, created_at, updated_at)
2594                 VALUES ('poison', 't', 'from_the_future', 'normal', '/repo', 'm', ?1, ?1)",
2595                params![now_rfc3339()],
2596            )
2597            .unwrap();
2598        let listed = store.tasks().list(50).expect("list");
2599        assert_eq!(
2600            listed.len(),
2601            1,
2602            "the poison row is skipped, the good row remains"
2603        );
2604        assert_eq!(listed[0].id, good.id);
2605        // The strict get() path still surfaces the poison row as an error.
2606        assert!(store.tasks().get("poison").is_err(), "get() stays strict");
2607        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2608    }
2609
2610    #[test]
2611    pub(crate) fn checkpoint_delete_removes_row() {
2612        // F23 (RC-F): the on-disk dir GC drops a checkpoint's DB row so list()
2613        // and the on-disk dirs stay in agreement.
2614        let path = temp_db("ckpt_delete");
2615        let store = RuntimeStore::open(&path).expect("open store");
2616        let ckpt = store
2617            .checkpoints()
2618            .create(NewCheckpoint {
2619                id: Some("ckpt-1".to_string()),
2620                task_id: None,
2621                project_path: "/repo".to_string(),
2622                snapshot_path: "/data/checkpoints/ckpt-1".to_string(),
2623                changed_files_json: "[]".to_string(),
2624                pending_action_json: None,
2625                approval_id: None,
2626                session_id: None,
2627                message_index: None,
2628            })
2629            .expect("create checkpoint");
2630        assert!(store.checkpoints().get(&ckpt.id).unwrap().is_some());
2631        assert!(store.checkpoints().delete(&ckpt.id).unwrap(), "row deleted");
2632        assert!(
2633            store.checkpoints().get(&ckpt.id).unwrap().is_none(),
2634            "row gone"
2635        );
2636        assert!(
2637            !store.checkpoints().delete(&ckpt.id).unwrap(),
2638            "second delete is a no-op"
2639        );
2640        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2641    }
2642}