Skip to main content

mermaid_runtime/storage/
mod.rs

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