Skip to main content

mermaid_runtime/
storage.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result};
8use directories::ProjectDirs;
9use rusqlite::types::Type;
10use rusqlite::{Connection, OptionalExtension, params};
11use serde::{Deserialize, Serialize};
12
13// Bumped to 3 for the F75 covering indexes (pending-approval and reconcile
14// scans). They are additive, but the bump lets a DB already at v2 re-run the
15// migration once to pick them up. The bump is load-bearing alongside the F17
16// early-return in `init_schema`: a DB at an older version still runs the
17// migration (the idempotent baseline plus any per-version step dispatched by
18// `migrate_within_txn`) exactly once, while an already-current DB skips the
19// write lock entirely.
20//
21// History: v2 added the additive `tasks.owner_kind` column (F18/RC-E).
22const SCHEMA_VERSION: i32 = 3;
23
24/// `tasks.owner_kind` value for a task the daemon runs in-process. Only these are
25/// reset by `reconcile_after_restart`; a `NULL` owner (an interactive CLI run, or
26/// any other creator) is left alone so a live `mermaid` session that shares the
27/// store isn't wrongly failed on daemon startup (F18/RC-E).
28const OWNER_KIND_DAEMON: &str = "daemon";
29
30/// Durable task state. A task is the daemon-level work unit; a chat
31/// transcript is just one artifact linked to it.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum TaskStatus {
35    Queued,
36    Running,
37    WaitingForApproval,
38    Blocked,
39    Completed,
40    Failed,
41    Cancelled,
42}
43
44impl TaskStatus {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            TaskStatus::Queued => "queued",
48            TaskStatus::Running => "running",
49            TaskStatus::WaitingForApproval => "waiting_for_approval",
50            TaskStatus::Blocked => "blocked",
51            TaskStatus::Completed => "completed",
52            TaskStatus::Failed => "failed",
53            TaskStatus::Cancelled => "cancelled",
54        }
55    }
56
57    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
58        match value {
59            "queued" => Ok(TaskStatus::Queued),
60            "running" => Ok(TaskStatus::Running),
61            "waiting_for_approval" => Ok(TaskStatus::WaitingForApproval),
62            "blocked" => Ok(TaskStatus::Blocked),
63            "completed" => Ok(TaskStatus::Completed),
64            "failed" => Ok(TaskStatus::Failed),
65            "cancelled" => Ok(TaskStatus::Cancelled),
66            other => Err(UnknownRuntimeEnum::new("task status", other)),
67        }
68    }
69}
70
71impl fmt::Display for TaskStatus {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(self.as_str())
74    }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum TaskPriority {
80    Low,
81    Normal,
82    High,
83}
84
85impl TaskPriority {
86    pub fn as_str(self) -> &'static str {
87        match self {
88            TaskPriority::Low => "low",
89            TaskPriority::Normal => "normal",
90            TaskPriority::High => "high",
91        }
92    }
93
94    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
95        match value {
96            "low" => Ok(TaskPriority::Low),
97            "normal" => Ok(TaskPriority::Normal),
98            "high" => Ok(TaskPriority::High),
99            other => Err(UnknownRuntimeEnum::new("task priority", other)),
100        }
101    }
102}
103
104impl fmt::Display for TaskPriority {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.write_str(self.as_str())
107    }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ProcessStatus {
113    Running,
114    Exited,
115    Unknown,
116}
117
118impl ProcessStatus {
119    pub fn as_str(self) -> &'static str {
120        match self {
121            ProcessStatus::Running => "running",
122            ProcessStatus::Exited => "exited",
123            ProcessStatus::Unknown => "unknown",
124        }
125    }
126
127    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
128        match value {
129            "running" => Ok(ProcessStatus::Running),
130            "exited" => Ok(ProcessStatus::Exited),
131            "unknown" => Ok(ProcessStatus::Unknown),
132            other => Err(UnknownRuntimeEnum::new("process status", other)),
133        }
134    }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct TaskRecord {
139    pub id: String,
140    pub title: String,
141    pub status: TaskStatus,
142    pub priority: TaskPriority,
143    pub project_path: String,
144    pub model_id: String,
145    pub conversation_id: Option<String>,
146    pub created_at: String,
147    pub updated_at: String,
148    pub final_report: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct TaskTimelineEvent {
153    pub id: i64,
154    pub task_id: String,
155    pub kind: String,
156    pub message: String,
157    pub created_at: String,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct SessionRecord {
162    pub id: String,
163    pub project_path: String,
164    pub model_id: String,
165    pub title: Option<String>,
166    pub conversation_path: Option<String>,
167    pub created_at: String,
168    pub updated_at: String,
169    pub total_tokens: Option<i64>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct NewSession {
174    pub id: Option<String>,
175    pub project_path: String,
176    pub model_id: String,
177    pub title: Option<String>,
178    pub conversation_path: Option<String>,
179    pub total_tokens: Option<i64>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct MessageRecord {
184    pub id: i64,
185    pub session_id: String,
186    pub role: String,
187    pub content_json: String,
188    pub created_at: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct NewMessage {
193    pub session_id: String,
194    pub role: String,
195    pub content_json: String,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct NewTask {
200    pub title: String,
201    pub project_path: String,
202    pub model_id: String,
203    pub priority: TaskPriority,
204    pub conversation_id: Option<String>,
205    /// Which kind of process owns this task. `Some("daemon")` (set via
206    /// [`Self::daemon_owned`]) marks a task the daemon runs in-process, so the
207    /// startup reconcile may fail it if a crash left it `Running`. `None` — the
208    /// default, used by interactive CLI runs and any other creator — is left
209    /// untouched by reconcile so a live session isn't clobbered (F18/RC-E).
210    pub owner_kind: Option<String>,
211}
212
213impl NewTask {
214    pub fn new(
215        title: impl Into<String>,
216        project_path: impl Into<String>,
217        model_id: impl Into<String>,
218    ) -> Self {
219        Self {
220            title: title.into(),
221            project_path: project_path.into(),
222            model_id: model_id.into(),
223            priority: TaskPriority::Normal,
224            conversation_id: None,
225            owner_kind: None,
226        }
227    }
228
229    /// Mark this task as daemon-owned (run in the daemon process). Only such
230    /// tasks are reset by [`RuntimeStore::reconcile_after_restart`]; omit it for
231    /// interactive CLI runs so they survive a daemon restart.
232    pub fn daemon_owned(mut self) -> Self {
233        self.owner_kind = Some(OWNER_KIND_DAEMON.to_string());
234        self
235    }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ApprovalRecord {
240    pub id: String,
241    pub task_id: Option<String>,
242    pub proposed_action: String,
243    pub risk_classification: String,
244    pub policy_decision: String,
245    pub user_decision: Option<String>,
246    pub args_summary: Option<String>,
247    pub checkpoint_id: Option<String>,
248    pub pending_action_json: Option<String>,
249    pub created_at: String,
250    pub decided_at: Option<String>,
251    pub archived_at: Option<String>,
252    pub archive_reason: Option<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct NewApproval {
257    pub task_id: Option<String>,
258    pub proposed_action: String,
259    pub risk_classification: String,
260    pub policy_decision: String,
261    pub args_summary: Option<String>,
262    pub checkpoint_id: Option<String>,
263    pub pending_action_json: Option<String>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct ToolRunRecord {
268    pub id: String,
269    pub task_id: Option<String>,
270    pub turn_id: Option<String>,
271    pub call_id: Option<String>,
272    pub tool_name: String,
273    pub status: String,
274    pub args_json: Option<String>,
275    pub output_json: Option<String>,
276    pub started_at: String,
277    pub finished_at: Option<String>,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct NewToolRun {
282    pub id: Option<String>,
283    pub task_id: Option<String>,
284    pub turn_id: Option<String>,
285    pub call_id: Option<String>,
286    pub tool_name: String,
287    pub args_json: Option<String>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct ProcessRecord {
292    pub id: String,
293    pub task_id: Option<String>,
294    pub pid: u32,
295    pub command: String,
296    pub cwd: Option<String>,
297    pub log_path: Option<String>,
298    pub detected_url: Option<String>,
299    pub status: ProcessStatus,
300    pub health: Option<String>,
301    pub created_at: String,
302    pub updated_at: String,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct NewProcess {
307    pub id: Option<String>,
308    pub task_id: Option<String>,
309    pub pid: u32,
310    pub command: String,
311    pub cwd: Option<String>,
312    pub log_path: Option<String>,
313    pub detected_url: Option<String>,
314    pub status: ProcessStatus,
315    pub health: Option<String>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct CheckpointRecord {
320    pub id: String,
321    pub task_id: Option<String>,
322    pub project_path: String,
323    pub snapshot_path: String,
324    pub changed_files_json: String,
325    pub pending_action_json: Option<String>,
326    pub approval_id: Option<String>,
327    pub created_at: String,
328    pub archived_at: Option<String>,
329    pub archive_reason: Option<String>,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct NewCheckpoint {
334    pub id: Option<String>,
335    pub task_id: Option<String>,
336    pub project_path: String,
337    pub snapshot_path: String,
338    pub changed_files_json: String,
339    pub pending_action_json: Option<String>,
340    pub approval_id: Option<String>,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct CompactionRecord {
345    pub id: String,
346    pub task_id: Option<String>,
347    pub session_id: Option<String>,
348    pub source_token_estimate: Option<i64>,
349    pub summary_token_count: Option<i64>,
350    pub preserved_turns: Option<i64>,
351    pub archive_path: Option<String>,
352    pub verification_status: Option<String>,
353    pub created_at: String,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct NewCompaction {
358    pub id: Option<String>,
359    pub task_id: Option<String>,
360    pub session_id: Option<String>,
361    pub source_token_estimate: Option<i64>,
362    pub summary_token_count: Option<i64>,
363    pub preserved_turns: Option<i64>,
364    pub archive_path: Option<String>,
365    pub verification_status: Option<String>,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct PluginInstallRecord {
370    pub id: String,
371    pub name: String,
372    pub source: String,
373    pub version: Option<String>,
374    pub enabled: bool,
375    pub manifest_json: String,
376    pub installed_at: String,
377    pub updated_at: String,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct NewPluginInstall {
382    pub id: Option<String>,
383    pub name: String,
384    pub source: String,
385    pub version: Option<String>,
386    pub enabled: bool,
387    pub manifest_json: String,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct ProviderProbeRecord {
392    pub provider: String,
393    pub model_id: String,
394    pub capability_key: String,
395    pub capability_value: String,
396    pub confidence: String,
397    pub error: Option<String>,
398    pub probed_at: String,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct NewProviderProbe {
403    pub provider: String,
404    pub model_id: String,
405    pub capability_key: String,
406    pub capability_value: String,
407    pub confidence: String,
408    pub error: Option<String>,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412pub struct PairingTokenRecord {
413    pub id: String,
414    pub token_hash: String,
415    pub label: Option<String>,
416    pub enabled: bool,
417    pub created_at: String,
418    pub last_used_at: Option<String>,
419    /// RFC3339 expiry. `None` = never expires (opt-in via `--ttl-days 0`).
420    pub expires_at: Option<String>,
421}
422
423/// SQLite-backed durable runtime state.
424pub struct RuntimeStore {
425    conn: Connection,
426    path: PathBuf,
427}
428
429impl RuntimeStore {
430    pub fn open_default() -> Result<Self> {
431        let dir = data_dir()?;
432        std::fs::create_dir_all(&dir)
433            .with_context(|| format!("failed to create Mermaid data dir {}", dir.display()))?;
434        // The data dir holds the daemon control socket, pairing tokens, and
435        // session/memory state. Restrict it to the owning user (0700) so no
436        // other local UID can reach the socket or read the DB.
437        #[cfg(unix)]
438        {
439            use std::os::unix::fs::PermissionsExt;
440            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
441        }
442        // Windows has no mode bits, so the DB (token hashes, transcripts) would
443        // otherwise inherit the parent's default ACL. Lock it to the current
444        // user via `icacls`. A sentinel makes this run once (first open, or
445        // first open after upgrade for an already-loose dir) rather than on
446        // every store open — the daemon opens the store per request. Best-effort
447        // like the Unix branch: never fail the store open on an ACL hiccup.
448        #[cfg(windows)]
449        {
450            let sentinel = dir.join(".acl-hardened");
451            if !sentinel.exists()
452                && let Ok(user) = std::env::var("USERNAME")
453                && !user.is_empty()
454            {
455                let hardened = std::process::Command::new("icacls")
456                    .arg(&dir)
457                    .arg("/inheritance:r")
458                    .arg("/grant:r")
459                    .arg(format!("{user}:(OI)(CI)F"))
460                    .arg("/T")
461                    .stdout(std::process::Stdio::null())
462                    .stderr(std::process::Stdio::null())
463                    .status()
464                    .map(|status| status.success())
465                    .unwrap_or(false);
466                if hardened {
467                    let _ = std::fs::write(&sentinel, b"1");
468                }
469            }
470        }
471        Self::open(dir.join("runtime.sqlite3"))
472    }
473
474    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
475        let path = path.as_ref().to_path_buf();
476        if let Some(parent) = path.parent() {
477            std::fs::create_dir_all(parent).with_context(|| {
478                format!("failed to create SQLite parent dir {}", parent.display())
479            })?;
480        }
481        let conn = Connection::open(&path)
482            .with_context(|| format!("failed to open runtime DB {}", path.display()))?;
483        // The daemon, CLI, and per-turn effect tasks each open their own
484        // connection (often in separate processes). Without WAL + a busy
485        // timeout, a writer holding the DB makes a concurrent write fail
486        // immediately with SQLITE_BUSY (lost task/tool/approval updates).
487        // WAL allows concurrent readers with a single writer; busy_timeout
488        // serializes writers gracefully.
489        conn.busy_timeout(std::time::Duration::from_secs(5))
490            .context("failed to set SQLite busy_timeout")?;
491        // `foreign_keys` is connection-scoped and can only be toggled in
492        // autocommit mode, so it lives here (per connection) rather than inside
493        // the now-transactional `init_schema` migration, where a PRAGMA
494        // foreign_keys would be a silent no-op.
495        conn.execute_batch(
496            "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
497        )
498        .context("failed to set SQLite connection PRAGMAs")?;
499        let store = Self { conn, path };
500        store.init_schema()?;
501        Ok(store)
502    }
503
504    pub fn path(&self) -> &Path {
505        &self.path
506    }
507
508    pub fn sessions(&self) -> SessionsRepo<'_> {
509        SessionsRepo { conn: &self.conn }
510    }
511
512    pub fn messages(&self) -> MessagesRepo<'_> {
513        MessagesRepo { conn: &self.conn }
514    }
515
516    pub fn tasks(&self) -> TasksRepo<'_> {
517        TasksRepo { conn: &self.conn }
518    }
519
520    pub fn tool_runs(&self) -> ToolRunsRepo<'_> {
521        ToolRunsRepo { conn: &self.conn }
522    }
523
524    pub fn approvals(&self) -> ApprovalsRepo<'_> {
525        ApprovalsRepo { conn: &self.conn }
526    }
527
528    pub fn processes(&self) -> ProcessesRepo<'_> {
529        ProcessesRepo { conn: &self.conn }
530    }
531
532    pub fn checkpoints(&self) -> CheckpointsRepo<'_> {
533        CheckpointsRepo { conn: &self.conn }
534    }
535
536    pub fn compactions(&self) -> CompactionsRepo<'_> {
537        CompactionsRepo { conn: &self.conn }
538    }
539
540    pub fn plugins(&self) -> PluginsRepo<'_> {
541        PluginsRepo { conn: &self.conn }
542    }
543
544    pub fn provider_probes(&self) -> ProviderProbesRepo<'_> {
545        ProviderProbesRepo { conn: &self.conn }
546    }
547
548    pub fn pairing_tokens(&self) -> PairingTokensRepo<'_> {
549        PairingTokensRepo { conn: &self.conn }
550    }
551
552    /// Recover state stranded by a previous daemon's crash/stop (#120, #118).
553    /// A `Running` task's worker died with the daemon, so it can never finish —
554    /// mark it `failed` with an event. An approval left in the transient
555    /// `approving` claim state (a replay that crashed mid-effect, #118) is reset
556    /// to undecided so it reappears as pending and stays re-runnable. Call once
557    /// on daemon startup, before serving. Returns `(tasks_reset, claims_released)`.
558    ///
559    /// F18 (RC-E): only **daemon-owned** running tasks are reset. The store is
560    /// shared with interactive `mermaid` CLI runs; their tasks are created with a
561    /// `NULL` `owner_kind` and are LEFT RUNNING here, so a live CLI session isn't
562    /// wrongly flipped to `failed` (with a spurious "interrupted" event) just
563    /// because the daemon restarted. The daemon tags the tasks it runs in-process
564    /// via [`NewTask::daemon_owned`].
565    pub fn reconcile_after_restart(&self) -> Result<(usize, usize)> {
566        let now = now_rfc3339();
567        // Take the write lock up front with BEGIN IMMEDIATE rather than a DEFERRED
568        // transaction that SELECTs and then upgrades to a write on the first
569        // UPDATE: SQLite fails a read→write lock upgrade with SQLITE_BUSY
570        // *immediately* (busy_timeout does not retry upgrades), so a CLI holding
571        // the write lock at daemon startup would abort recovery. IMMEDIATE instead
572        // waits on busy_timeout for the lock (#F21). Mirrors `init_schema`.
573        self.conn.execute_batch("BEGIN IMMEDIATE;")?;
574        let result = (|| -> Result<(usize, usize)> {
575            let running: Vec<String> = {
576                let mut stmt = self
577                    .conn
578                    .prepare("SELECT id FROM tasks WHERE status = 'running' AND owner_kind = ?1")?;
579                let ids = stmt.query_map([OWNER_KIND_DAEMON], |row| row.get::<_, String>(0))?;
580                ids.collect::<rusqlite::Result<Vec<_>>>()?
581            };
582            for id in &running {
583                self.conn.execute(
584                    "UPDATE tasks SET status = 'failed', updated_at = ?2 WHERE id = ?1",
585                    params![id, now],
586                )?;
587                self.conn.execute(
588                    "INSERT INTO task_events (task_id, kind, message, created_at)
589                     VALUES (?1, ?2, ?3, ?4)",
590                    params![
591                        id,
592                        "interrupted",
593                        "task was running when the daemon restarted; marked failed",
594                        now
595                    ],
596                )?;
597            }
598            let claims_released = self.conn.execute(
599                "UPDATE approvals SET user_decision = NULL WHERE user_decision = 'approving'",
600                [],
601            )?;
602            Ok((running.len(), claims_released))
603        })();
604        match result {
605            Ok(v) => {
606                self.conn.execute_batch("COMMIT;")?;
607                Ok(v)
608            },
609            Err(e) => {
610                let _ = self.conn.execute_batch("ROLLBACK;");
611                Err(e)
612            },
613        }
614    }
615
616    /// Best-effort retention GC (#130, F22/RC-F): prune archived
617    /// approvals/checkpoints, the events of long-finished tasks, and the
618    /// high-churn / old terminal rows of the remaining tables, all older than
619    /// `retention_days`. Deletes only archived, finished, or terminal-and-old
620    /// rows — **active data is never touched** (a running task, a still-open tool
621    /// run, a live process, or a recently-updated session all survive). Returns
622    /// the number of rows removed.
623    pub fn gc(&self, retention_days: i64) -> Result<u64> {
624        let cutoff = (chrono::Utc::now() - chrono::Duration::days(retention_days)).to_rfc3339();
625        let tx = self.conn.unchecked_transaction()?;
626        let mut removed = 0u64;
627        removed += tx.execute(
628            "DELETE FROM approvals WHERE archived_at IS NOT NULL AND archived_at < ?1",
629            params![cutoff],
630        )? as u64;
631        removed += tx.execute(
632            "DELETE FROM checkpoints WHERE archived_at IS NOT NULL AND archived_at < ?1",
633            params![cutoff],
634        )? as u64;
635        removed += tx.execute(
636            "DELETE FROM task_events
637             WHERE created_at < ?1
638               AND task_id IN (
639                   SELECT id FROM tasks
640                   WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1
641               )",
642            params![cutoff],
643        )? as u64;
644        // F22 (RC-F): the high-churn growers. `tool_runs` is the fastest — one row
645        // per tool call — so prune FINISHED runs past the window (a still-running
646        // run has a NULL `finished_at` and is kept).
647        removed += tx.execute(
648            "DELETE FROM tool_runs WHERE finished_at IS NOT NULL AND finished_at < ?1",
649            params![cutoff],
650        )? as u64;
651        // Exited processes past the window (a live `running`/`unknown` process is
652        // kept so the dashboard and `stop`/`restart` still see it).
653        removed += tx.execute(
654            "DELETE FROM processes WHERE status = 'exited' AND updated_at < ?1",
655            params![cutoff],
656        )? as u64;
657        // Old compaction history — immutable bookkeeping rows, safe to drop once
658        // past the window.
659        removed += tx.execute(
660            "DELETE FROM compactions WHERE created_at < ?1",
661            params![cutoff],
662        )? as u64;
663        // Sessions untouched for the whole window are treated as finished. Delete
664        // their messages first (so the freed rows are counted) — the FK cascade
665        // would remove them anyway — then the sessions themselves. A session
666        // updated within the window is active and is kept along with all its
667        // messages.
668        removed += tx.execute(
669            "DELETE FROM messages
670             WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ?1)",
671            params![cutoff],
672        )? as u64;
673        removed += tx.execute(
674            "DELETE FROM sessions WHERE updated_at < ?1",
675            params![cutoff],
676        )? as u64;
677        tx.commit()?;
678        Ok(removed)
679    }
680
681    fn init_schema(&self) -> Result<()> {
682        let conn = &self.conn;
683        // Forward-compat gate: read the stored schema version BEFORE writing
684        // anything. A DB written by a newer mermaid (higher `user_version`)
685        // must be refused, not silently down-labeled. The old code stamped
686        // `PRAGMA user_version = 1` inside the CREATE script — before this
687        // check — so the guard was dead and an older binary would happily
688        // operate (and corrupt) a newer DB.
689        let current: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
690        anyhow::ensure!(
691            current <= SCHEMA_VERSION,
692            "runtime DB schema version {} is newer than this build supports ({}); upgrade mermaid",
693            current,
694            SCHEMA_VERSION
695        );
696
697        // F17 (RC-E): the overwhelmingly common case is an already-current DB.
698        // The daemon opens a fresh store per request, and the old code ran
699        // `BEGIN IMMEDIATE` (the write lock) + the full migration + an
700        // unconditional `PRAGMA user_version` write on EVERY open — so even
701        // read-only requests serialized on a single writer and grew the WAL. Once
702        // the stored version already matches, the schema is in place and there is
703        // nothing to migrate or stamp: return before taking any write lock so
704        // concurrent readers never contend. The newer-than-supported gate above
705        // still runs first, so a newer DB is refused, not skipped.
706        if current == SCHEMA_VERSION {
707            return Ok(());
708        }
709
710        // Older (or fresh, version 0) DB only past this point.
711        // Create tables + run column migrations exactly once, even when the
712        // daemon and CLI open the DB concurrently: BEGIN IMMEDIATE takes the
713        // write lock up front, so a racing process blocks on `busy_timeout`
714        // and, once we commit, sees the schema already in place instead of
715        // double-running an ALTER and failing the open (the old check-then-
716        // ALTER `ensure_column` race).
717        conn.execute_batch("BEGIN IMMEDIATE;")?;
718        if let Err(error) = self.migrate_within_txn(current) {
719            let _ = conn.execute_batch("ROLLBACK;");
720            return Err(error);
721        }
722        conn.execute_batch("COMMIT;")?;
723
724        // Stamp the version only after a successful migration — never before
725        // the gate above.
726        conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
727        let version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
728        anyhow::ensure!(
729            version == SCHEMA_VERSION,
730            "unsupported runtime DB schema version {} (expected {})",
731            version,
732            SCHEMA_VERSION
733        );
734        Ok(())
735    }
736
737    /// Schema creation + column migrations, run inside the `init_schema`
738    /// transaction for a DB upgrading from `from_version`. Idempotent:
739    /// `CREATE TABLE IF NOT EXISTS` plus the duplicate-tolerant `ensure_column`
740    /// make a re-run a no-op, so a second concurrent opener that wins the lock
741    /// after us does no harm.
742    fn migrate_within_txn(&self, from_version: i32) -> Result<()> {
743        self.conn.execute_batch(
744            r#"
745            CREATE TABLE IF NOT EXISTS sessions (
746                id TEXT PRIMARY KEY,
747                project_path TEXT NOT NULL,
748                model_id TEXT NOT NULL,
749                title TEXT,
750                conversation_path TEXT,
751                created_at TEXT NOT NULL,
752                updated_at TEXT NOT NULL,
753                total_tokens INTEGER
754            );
755
756            CREATE TABLE IF NOT EXISTS messages (
757                id INTEGER PRIMARY KEY AUTOINCREMENT,
758                session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
759                role TEXT NOT NULL,
760                content_json TEXT NOT NULL,
761                created_at TEXT NOT NULL
762            );
763            CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
764
765            CREATE TABLE IF NOT EXISTS tasks (
766                id TEXT PRIMARY KEY,
767                title TEXT NOT NULL,
768                status TEXT NOT NULL,
769                priority TEXT NOT NULL,
770                project_path TEXT NOT NULL,
771                model_id TEXT NOT NULL,
772                conversation_id TEXT,
773                created_at TEXT NOT NULL,
774                updated_at TEXT NOT NULL,
775                final_report TEXT,
776                owner_kind TEXT
777            );
778            CREATE INDEX IF NOT EXISTS idx_tasks_project_status
779                ON tasks(project_path, status, updated_at);
780            -- F75: `reconcile_after_restart` filters `status = 'running' AND
781            -- owner_kind = ?`, which the (project_path, ...) index above cannot
782            -- serve (wrong leading column). This covering index does.
783            CREATE INDEX IF NOT EXISTS idx_tasks_status_owner
784                ON tasks(status, owner_kind);
785
786            CREATE TABLE IF NOT EXISTS task_events (
787                id INTEGER PRIMARY KEY AUTOINCREMENT,
788                task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
789                kind TEXT NOT NULL,
790                message TEXT NOT NULL,
791                created_at TEXT NOT NULL
792            );
793            CREATE INDEX IF NOT EXISTS idx_task_events_task_id
794                ON task_events(task_id, id);
795
796            CREATE TABLE IF NOT EXISTS tool_runs (
797                id TEXT PRIMARY KEY,
798                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
799                turn_id TEXT,
800                call_id TEXT,
801                tool_name TEXT NOT NULL,
802                status TEXT NOT NULL,
803                args_json TEXT,
804                output_json TEXT,
805                started_at TEXT NOT NULL,
806                finished_at TEXT
807            );
808            CREATE INDEX IF NOT EXISTS idx_tool_runs_task_id ON tool_runs(task_id);
809
810            CREATE TABLE IF NOT EXISTS approvals (
811                id TEXT PRIMARY KEY,
812                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
813                proposed_action TEXT NOT NULL,
814                risk_classification TEXT NOT NULL,
815                policy_decision TEXT NOT NULL,
816                user_decision TEXT,
817                args_summary TEXT,
818                checkpoint_id TEXT,
819                pending_action_json TEXT,
820                created_at TEXT NOT NULL,
821                decided_at TEXT,
822                archived_at TEXT,
823                archive_reason TEXT
824            );
825            CREATE INDEX IF NOT EXISTS idx_approvals_task_id ON approvals(task_id);
826            -- F75: `list_pending` scans `user_decision IS NULL ORDER BY
827            -- created_at`. A partial index over only the pending rows stays tiny
828            -- and serves both the filter and the ordering.
829            CREATE INDEX IF NOT EXISTS idx_approvals_pending
830                ON approvals(created_at)
831                WHERE user_decision IS NULL;
832
833            CREATE TABLE IF NOT EXISTS processes (
834                id TEXT PRIMARY KEY,
835                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
836                pid INTEGER NOT NULL,
837                command TEXT NOT NULL,
838                cwd TEXT,
839                log_path TEXT,
840                detected_url TEXT,
841                status TEXT NOT NULL,
842                health TEXT,
843                created_at TEXT NOT NULL,
844                updated_at TEXT NOT NULL
845            );
846            CREATE INDEX IF NOT EXISTS idx_processes_task_id ON processes(task_id);
847            CREATE INDEX IF NOT EXISTS idx_processes_pid ON processes(pid);
848
849            CREATE TABLE IF NOT EXISTS checkpoints (
850                id TEXT PRIMARY KEY,
851                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
852                project_path TEXT NOT NULL,
853                snapshot_path TEXT NOT NULL,
854                changed_files_json TEXT NOT NULL,
855                pending_action_json TEXT,
856                approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL,
857                created_at TEXT NOT NULL,
858                archived_at TEXT,
859                archive_reason TEXT
860            );
861
862            CREATE TABLE IF NOT EXISTS compactions (
863                id TEXT PRIMARY KEY,
864                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
865                session_id TEXT,
866                source_token_estimate INTEGER,
867                summary_token_count INTEGER,
868                preserved_turns INTEGER,
869                archive_path TEXT,
870                verification_status TEXT,
871                created_at TEXT NOT NULL
872            );
873
874            CREATE TABLE IF NOT EXISTS provider_probes (
875                provider TEXT NOT NULL,
876                model_id TEXT NOT NULL,
877                capability_key TEXT NOT NULL,
878                capability_value TEXT NOT NULL,
879                confidence TEXT NOT NULL,
880                error TEXT,
881                probed_at TEXT NOT NULL,
882                PRIMARY KEY (provider, model_id, capability_key)
883            );
884
885            CREATE TABLE IF NOT EXISTS plugin_installs (
886                id TEXT PRIMARY KEY,
887                name TEXT NOT NULL,
888                source TEXT NOT NULL,
889                version TEXT,
890                enabled INTEGER NOT NULL DEFAULT 1,
891                manifest_json TEXT NOT NULL,
892                installed_at TEXT NOT NULL,
893                updated_at TEXT NOT NULL
894            );
895
896            CREATE TABLE IF NOT EXISTS pairing_tokens (
897                id TEXT PRIMARY KEY,
898                token_hash TEXT NOT NULL,
899                label TEXT,
900                enabled INTEGER NOT NULL DEFAULT 1,
901                created_at TEXT NOT NULL,
902                last_used_at TEXT,
903                expires_at TEXT
904            );
905            CREATE INDEX IF NOT EXISTS idx_pairing_tokens_enabled
906                ON pairing_tokens(enabled, created_at);
907            "#,
908        )?;
909
910        ensure_column(&self.conn, "approvals", "pending_action_json", "TEXT")?;
911        ensure_column(&self.conn, "approvals", "archived_at", "TEXT")?;
912        ensure_column(&self.conn, "approvals", "archive_reason", "TEXT")?;
913        ensure_column(&self.conn, "checkpoints", "archived_at", "TEXT")?;
914        ensure_column(&self.conn, "checkpoints", "archive_reason", "TEXT")?;
915        // F18 (RC-E): task ownership. Nullable + no backfill — existing rows stay
916        // `NULL` (treated as un-owned, so reconcile leaves them alone), and only
917        // tasks the daemon explicitly marks `daemon` are reset on restart.
918        ensure_column(&self.conn, "tasks", "owner_kind", "TEXT")?;
919        // Pairing-token TTL. When the column is first added to an existing DB,
920        // backfill live tokens with a 30-day grace window from now rather than
921        // expiring them instantly on upgrade. Fresh DBs already have the column
922        // (so no backfill) and only tokens minted with `--ttl-days 0` keep a
923        // NULL (never-expires) value going forward.
924        if ensure_column(&self.conn, "pairing_tokens", "expires_at", "TEXT")? {
925            let grace = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
926            self.conn.execute(
927                "UPDATE pairing_tokens SET expires_at = ?1 WHERE expires_at IS NULL",
928                params![grace],
929            )?;
930        }
931
932        // F76: structured per-version migration dispatch. Everything above is the
933        // idempotent ADDITIVE baseline (`CREATE ... IF NOT EXISTS` + `ensure_column`),
934        // always safe to re-run. This loop is the home for FUTURE NON-ADDITIVE
935        // steps — dropping/renaming/transforming a column, rebuilding a table —
936        // that the baseline cannot express: each target version's step runs once,
937        // only when upgrading PAST it, inside this same transaction. Today every
938        // shipped step is additive, so the arms are documented (near-)no-ops, but a
939        // future v4 now has an ordered, versioned place to live instead of
940        // overloading `IF NOT EXISTS`.
941        for target in (from_version + 1)..=SCHEMA_VERSION {
942            match target {
943                // v2 added `tasks.owner_kind` — additive, applied by the baseline.
944                2 => {},
945                // v3: F75 covering indexes — additive, created by the baseline
946                // above; this call is the concrete template for the first real
947                // non-additive change.
948                3 => self.migrate_to_v3()?,
949                // A future v4+ adds its non-additive step here.
950                _ => {},
951            }
952        }
953        Ok(())
954    }
955
956    /// Non-additive migration steps introduced at schema v3. Today v3 only adds
957    /// covering indexes (additive — applied by the idempotent baseline in
958    /// [`Self::migrate_within_txn`]), so this is intentionally a no-op. It exists
959    /// as the concrete template for the first real non-additive change: a step
960    /// that, for example, drops or transforms a column, which
961    /// `CREATE ... IF NOT EXISTS` and `ensure_column` cannot express. Runs inside
962    /// the `init_schema` transaction, exactly once, when a DB upgrades past v2.
963    fn migrate_to_v3(&self) -> Result<()> {
964        Ok(())
965    }
966}
967
968pub struct TasksRepo<'a> {
969    conn: &'a Connection,
970}
971
972pub struct SessionsRepo<'a> {
973    conn: &'a Connection,
974}
975
976impl SessionsRepo<'_> {
977    pub fn upsert(&self, new: NewSession) -> Result<SessionRecord> {
978        let now = now_rfc3339();
979        let id = new.id.unwrap_or_else(|| fresh_id("session"));
980        self.conn.execute(
981            "INSERT INTO sessions
982             (id, project_path, model_id, title, conversation_path, created_at, updated_at, total_tokens)
983             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
984             ON CONFLICT(id) DO UPDATE SET
985                project_path = excluded.project_path,
986                model_id = excluded.model_id,
987                title = excluded.title,
988                conversation_path = excluded.conversation_path,
989                updated_at = excluded.updated_at,
990                total_tokens = excluded.total_tokens",
991            params![
992                id,
993                new.project_path,
994                new.model_id,
995                new.title,
996                new.conversation_path,
997                now,
998                now,
999                new.total_tokens,
1000            ],
1001        )?;
1002        self.get(&id)?
1003            .context("session was upserted but could not be reloaded")
1004    }
1005
1006    pub fn get(&self, id: &str) -> Result<Option<SessionRecord>> {
1007        self.conn
1008            .query_row(
1009                "SELECT id, project_path, model_id, title, conversation_path,
1010                        created_at, updated_at, total_tokens
1011                 FROM sessions WHERE id = ?1",
1012                [id],
1013                session_from_row,
1014            )
1015            .optional()
1016            .map_err(Into::into)
1017    }
1018
1019    pub fn list(&self, limit: usize) -> Result<Vec<SessionRecord>> {
1020        let mut stmt = self.conn.prepare(
1021            "SELECT id, project_path, model_id, title, conversation_path,
1022                    created_at, updated_at, total_tokens
1023             FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1024        )?;
1025        let rows = stmt.query_map([clamp_limit(limit)], session_from_row)?;
1026        rows.collect::<rusqlite::Result<Vec<_>>>()
1027            .map_err(Into::into)
1028    }
1029}
1030
1031pub struct MessagesRepo<'a> {
1032    conn: &'a Connection,
1033}
1034
1035impl MessagesRepo<'_> {
1036    pub fn add(&self, new: NewMessage) -> Result<MessageRecord> {
1037        self.conn.execute(
1038            "INSERT INTO messages (session_id, role, content_json, created_at)
1039             VALUES (?1, ?2, ?3, ?4)",
1040            params![new.session_id, new.role, new.content_json, now_rfc3339()],
1041        )?;
1042        let id = self.conn.last_insert_rowid();
1043        self.get(id)?
1044            .context("message was inserted but could not be reloaded")
1045    }
1046
1047    pub fn get(&self, id: i64) -> Result<Option<MessageRecord>> {
1048        self.conn
1049            .query_row(
1050                "SELECT id, session_id, role, content_json, created_at
1051                 FROM messages WHERE id = ?1",
1052                [id],
1053                message_from_row,
1054            )
1055            .optional()
1056            .map_err(Into::into)
1057    }
1058
1059    /// Load a session's messages in chronological order, capped at
1060    /// [`MAX_SESSION_MESSAGES`] (F24/RC-F).
1061    ///
1062    /// A session transcript is otherwise unbounded, and the daemon
1063    /// `session_messages` path loads it whole into RAM — a pathological session
1064    /// could OOM the daemon. We return the **most recent** `MAX_SESSION_MESSAGES`
1065    /// (newest activity is what a viewer wants) but still in ascending `id`
1066    /// order, by taking the tail in a subquery and re-sorting it ascending.
1067    pub fn list_for_session(&self, session_id: &str) -> Result<Vec<MessageRecord>> {
1068        let mut stmt = self.conn.prepare(
1069            "SELECT id, session_id, role, content_json, created_at FROM (
1070                 SELECT id, session_id, role, content_json, created_at
1071                 FROM messages WHERE session_id = ?1
1072                 ORDER BY id DESC LIMIT ?2
1073             ) ORDER BY id ASC",
1074        )?;
1075        let rows = stmt.query_map(params![session_id, MAX_SESSION_MESSAGES], message_from_row)?;
1076        rows.collect::<rusqlite::Result<Vec<_>>>()
1077            .map_err(Into::into)
1078    }
1079}
1080
1081impl TasksRepo<'_> {
1082    pub fn create(&self, new: NewTask) -> Result<TaskRecord> {
1083        let now = now_rfc3339();
1084        // Owner tag isn't part of the public `TaskRecord`; move it out before the
1085        // record consumes the rest of `new`, then persist it on its own column.
1086        let owner_kind = new.owner_kind;
1087        let record = TaskRecord {
1088            id: fresh_id("task"),
1089            title: new.title,
1090            status: TaskStatus::Queued,
1091            priority: new.priority,
1092            project_path: new.project_path,
1093            model_id: new.model_id,
1094            conversation_id: new.conversation_id,
1095            created_at: now.clone(),
1096            updated_at: now.clone(),
1097            final_report: None,
1098        };
1099        // The task row and its initial event are one logical write — commit
1100        // them atomically so a crash between can't leave an event-less task.
1101        let tx = self.conn.unchecked_transaction()?;
1102        tx.execute(
1103            "INSERT INTO tasks
1104             (id, title, status, priority, project_path, model_id, conversation_id, created_at, updated_at, final_report, owner_kind)
1105             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1106            params![
1107                record.id,
1108                record.title,
1109                record.status.as_str(),
1110                record.priority.as_str(),
1111                record.project_path,
1112                record.model_id,
1113                record.conversation_id,
1114                record.created_at,
1115                record.updated_at,
1116                record.final_report,
1117                owner_kind,
1118            ],
1119        )?;
1120        tx.execute(
1121            "INSERT INTO task_events (task_id, kind, message, created_at)
1122             VALUES (?1, ?2, ?3, ?4)",
1123            params![record.id, "task_created", "task created", now],
1124        )?;
1125        tx.commit()?;
1126        self.get(&record.id)?
1127            .context("task was inserted but could not be reloaded")
1128    }
1129
1130    pub fn get(&self, id: &str) -> Result<Option<TaskRecord>> {
1131        self.conn
1132            .query_row(
1133                "SELECT id, title, status, priority, project_path, model_id, conversation_id,
1134                        created_at, updated_at, final_report
1135                 FROM tasks WHERE id = ?1",
1136                [id],
1137                task_from_row,
1138            )
1139            .optional()
1140            .map_err(Into::into)
1141    }
1142
1143    pub fn list(&self, limit: usize) -> Result<Vec<TaskRecord>> {
1144        let mut stmt = self.conn.prepare(
1145            "SELECT id, title, status, priority, project_path, model_id, conversation_id,
1146                    created_at, updated_at, final_report
1147             FROM tasks
1148             ORDER BY updated_at DESC
1149             LIMIT ?1",
1150        )?;
1151        // F19 (RC-E): skip-and-warn a single undecodable row (e.g. a status enum
1152        // a different binary wrote) instead of `collect`ing a `Result` that would
1153        // blank the WHOLE tasks panel on one poison row.
1154        let rows = stmt.query_map([clamp_limit(limit)], task_from_row_opt)?;
1155        collect_tolerant(rows)
1156    }
1157
1158    pub fn update_status(
1159        &self,
1160        id: &str,
1161        status: TaskStatus,
1162        final_report: Option<&str>,
1163    ) -> Result<()> {
1164        let now = now_rfc3339();
1165        // Status update + its event are one logical write.
1166        let tx = self.conn.unchecked_transaction()?;
1167        tx.execute(
1168            "UPDATE tasks
1169             SET status = ?2, updated_at = ?3, final_report = COALESCE(?4, final_report)
1170             WHERE id = ?1",
1171            params![id, status.as_str(), now, final_report],
1172        )?;
1173        tx.execute(
1174            "INSERT INTO task_events (task_id, kind, message, created_at)
1175             VALUES (?1, ?2, ?3, ?4)",
1176            params![
1177                id,
1178                "status_changed",
1179                format!("status changed to {status}"),
1180                now
1181            ],
1182        )?;
1183        tx.commit()?;
1184        Ok(())
1185    }
1186
1187    pub fn add_event(&self, task_id: &str, kind: &str, message: &str) -> Result<()> {
1188        self.conn.execute(
1189            "INSERT INTO task_events (task_id, kind, message, created_at)
1190             VALUES (?1, ?2, ?3, ?4)",
1191            params![task_id, kind, message, now_rfc3339()],
1192        )?;
1193        Ok(())
1194    }
1195
1196    pub fn events(&self, task_id: &str) -> Result<Vec<TaskTimelineEvent>> {
1197        let mut stmt = self.conn.prepare(
1198            "SELECT id, task_id, kind, message, created_at
1199             FROM task_events
1200             WHERE task_id = ?1
1201             ORDER BY id ASC",
1202        )?;
1203        // F19 (RC-E): one undecodable event row must not blank the whole timeline.
1204        let rows = stmt.query_map([task_id], task_event_from_row_opt)?;
1205        collect_tolerant(rows)
1206    }
1207}
1208
1209pub struct ToolRunsRepo<'a> {
1210    conn: &'a Connection,
1211}
1212
1213impl ToolRunsRepo<'_> {
1214    pub fn start(&self, new: NewToolRun) -> Result<ToolRunRecord> {
1215        let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
1216        self.conn.execute(
1217            "INSERT INTO tool_runs
1218             (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
1219             VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
1220            params![
1221                id,
1222                new.task_id,
1223                new.turn_id,
1224                new.call_id,
1225                new.tool_name,
1226                new.args_json,
1227                now_rfc3339(),
1228            ],
1229        )?;
1230        self.get(&id)?
1231            .context("tool run was inserted but could not be reloaded")
1232    }
1233
1234    pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
1235        let changed = self.conn.execute(
1236            "UPDATE tool_runs
1237             SET status = ?2, output_json = ?3, finished_at = ?4
1238             WHERE id = ?1",
1239            params![id, status, output_json, now_rfc3339()],
1240        )?;
1241        anyhow::ensure!(changed > 0, "tool run not found: {}", id);
1242        Ok(())
1243    }
1244
1245    pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
1246        self.conn
1247            .query_row(
1248                "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1249                        output_json, started_at, finished_at
1250                 FROM tool_runs WHERE id = ?1",
1251                [id],
1252                tool_run_from_row,
1253            )
1254            .optional()
1255            .map_err(Into::into)
1256    }
1257
1258    pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1259        let mut stmt = self.conn.prepare(
1260            "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1261                    output_json, started_at, finished_at
1262             FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
1263        )?;
1264        let rows = stmt.query_map([clamp_limit(limit)], tool_run_from_row)?;
1265        rows.collect::<rusqlite::Result<Vec<_>>>()
1266            .map_err(Into::into)
1267    }
1268}
1269
1270pub struct ApprovalsRepo<'a> {
1271    conn: &'a Connection,
1272}
1273
1274impl ApprovalsRepo<'_> {
1275    pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
1276        let record = ApprovalRecord {
1277            id: fresh_id("approval"),
1278            task_id: new.task_id,
1279            proposed_action: new.proposed_action,
1280            risk_classification: new.risk_classification,
1281            policy_decision: new.policy_decision,
1282            user_decision: None,
1283            args_summary: new.args_summary,
1284            checkpoint_id: new.checkpoint_id,
1285            pending_action_json: new.pending_action_json,
1286            created_at: now_rfc3339(),
1287            decided_at: None,
1288            archived_at: None,
1289            archive_reason: None,
1290        };
1291        self.conn.execute(
1292            "INSERT INTO approvals
1293             (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
1294              args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
1295             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1296            params![
1297                record.id,
1298                record.task_id,
1299                record.proposed_action,
1300                record.risk_classification,
1301                record.policy_decision,
1302                record.user_decision,
1303                record.args_summary,
1304                record.checkpoint_id,
1305                record.pending_action_json,
1306                record.created_at,
1307                record.decided_at,
1308            ],
1309        )?;
1310        self.get(&record.id)?
1311            .context("approval was inserted but could not be reloaded")
1312    }
1313
1314    pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
1315        self.conn
1316            .query_row(
1317                "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1318                        user_decision, args_summary, checkpoint_id, pending_action_json,
1319                        created_at, decided_at, archived_at, archive_reason
1320                 FROM approvals WHERE id = ?1",
1321                [id],
1322                approval_from_row,
1323            )
1324            .optional()
1325            .map_err(Into::into)
1326    }
1327
1328    pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
1329        // Single-shot decision: only an undecided, un-archived approval can be
1330        // decided, so a denied approval cannot be resurrected as "approved".
1331        // `approval::approve_and_replay` runs the (un-rollback-able) replay
1332        // effect *before* calling `decide`, so the "approved" mark lands only
1333        // after the action ran: a crash mid-replay leaves the row undecided and
1334        // safely re-runnable, never "approved but never applied" (#62). Mirrors
1335        // the `archive` `WHERE archived_at IS NULL` idempotency pattern below.
1336        let changed = self.conn.execute(
1337            "UPDATE approvals
1338             SET user_decision = ?2, decided_at = ?3
1339             WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1340            params![id, user_decision, now_rfc3339()],
1341        )?;
1342        anyhow::ensure!(
1343            changed > 0,
1344            "approval {} cannot be decided (already decided, archived, or not found)",
1345            id
1346        );
1347        Ok(())
1348    }
1349
1350    /// Atomically claim an undecided approval for replay (#118). Sets
1351    /// `user_decision='approving'` only when it is currently NULL and
1352    /// un-archived, and reports whether THIS caller won the claim. Two concurrent
1353    /// `approve <id>` calls race this single UPDATE; exactly one sees
1354    /// `rows_affected == 1` and runs the un-rollback-able effect, the other sees
1355    /// `false` and bails — so the effect can't fire twice. A claim that crashes
1356    /// before finalizing is reset to NULL by the daemon's startup reconcile.
1357    pub fn claim(&self, id: &str) -> Result<bool> {
1358        let changed = self.conn.execute(
1359            "UPDATE approvals
1360             SET user_decision = 'approving'
1361             WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1362            params![id],
1363        )?;
1364        Ok(changed == 1)
1365    }
1366
1367    /// Release a claim taken by [`Self::claim`] back to undecided, so the action
1368    /// stays re-runnable after the replay effect failed.
1369    pub fn release_claim(&self, id: &str) -> Result<()> {
1370        self.conn.execute(
1371            "UPDATE approvals SET user_decision = NULL
1372             WHERE id = ?1 AND user_decision = 'approving'",
1373            params![id],
1374        )?;
1375        Ok(())
1376    }
1377
1378    /// Finalize a claimed approval's decision (the `approving` → terminal-value
1379    /// transition that [`Self::decide`]'s `WHERE user_decision IS NULL` can't make).
1380    pub fn finalize_claimed(&self, id: &str, user_decision: &str) -> Result<()> {
1381        let changed = self.conn.execute(
1382            "UPDATE approvals
1383             SET user_decision = ?2, decided_at = ?3
1384             WHERE id = ?1 AND user_decision = 'approving'",
1385            params![id, user_decision, now_rfc3339()],
1386        )?;
1387        anyhow::ensure!(changed > 0, "approval {} was not in the claimed state", id);
1388        Ok(())
1389    }
1390
1391    pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
1392        self.list_pending_with_archived(false)
1393    }
1394
1395    pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
1396        self.list_pending_with_archived(true)
1397    }
1398
1399    pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
1400        let mut stmt = self.conn.prepare(
1401            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1402                    user_decision, args_summary, checkpoint_id, pending_action_json,
1403                    created_at, decided_at, archived_at, archive_reason
1404             FROM approvals
1405             ORDER BY created_at DESC
1406             LIMIT ?1",
1407        )?;
1408        let rows = stmt.query_map([clamp_limit(limit)], approval_from_row)?;
1409        rows.collect::<rusqlite::Result<Vec<_>>>()
1410            .map_err(Into::into)
1411    }
1412
1413    fn list_pending_with_archived(&self, include_archived: bool) -> Result<Vec<ApprovalRecord>> {
1414        let archived_filter = if include_archived {
1415            ""
1416        } else {
1417            " AND archived_at IS NULL"
1418        };
1419        let mut stmt = self.conn.prepare(&format!(
1420            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1421                    user_decision, args_summary, checkpoint_id, pending_action_json,
1422                    created_at, decided_at, archived_at, archive_reason
1423             FROM approvals
1424             WHERE user_decision IS NULL{archived_filter}
1425             ORDER BY created_at DESC"
1426        ))?;
1427        let rows = stmt.query_map([], approval_from_row)?;
1428        rows.collect::<rusqlite::Result<Vec<_>>>()
1429            .map_err(Into::into)
1430    }
1431
1432    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1433        let archived_at = now_rfc3339();
1434        let mut changed = 0;
1435        for id in ids {
1436            changed += self.conn.execute(
1437                "UPDATE approvals
1438                 SET archived_at = COALESCE(archived_at, ?2),
1439                     archive_reason = COALESCE(archive_reason, ?3)
1440                 WHERE id = ?1 AND archived_at IS NULL",
1441                params![id, archived_at, reason],
1442            )?;
1443        }
1444        Ok(changed)
1445    }
1446
1447    pub fn count_archived(&self) -> Result<usize> {
1448        self.conn
1449            .query_row(
1450                "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
1451                [],
1452                |row| row.get::<_, i64>(0),
1453            )
1454            .map(|count| count as usize)
1455            .map_err(Into::into)
1456    }
1457}
1458
1459pub struct ProcessesRepo<'a> {
1460    conn: &'a Connection,
1461}
1462
1463impl ProcessesRepo<'_> {
1464    pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
1465        let now = now_rfc3339();
1466        let id = new.id.unwrap_or_else(|| fresh_id("process"));
1467        self.conn.execute(
1468            "INSERT INTO processes
1469             (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
1470             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1471             ON CONFLICT(id) DO UPDATE SET
1472                task_id = excluded.task_id,
1473                pid = excluded.pid,
1474                command = excluded.command,
1475                cwd = excluded.cwd,
1476                log_path = excluded.log_path,
1477                detected_url = excluded.detected_url,
1478                status = excluded.status,
1479                health = excluded.health,
1480                updated_at = excluded.updated_at",
1481            params![
1482                id,
1483                new.task_id,
1484                new.pid,
1485                new.command,
1486                new.cwd,
1487                new.log_path,
1488                new.detected_url,
1489                new.status.as_str(),
1490                new.health,
1491                now,
1492                now,
1493            ],
1494        )?;
1495        self.get(&id)?
1496            .context("process was upserted but could not be reloaded")
1497    }
1498
1499    pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
1500        self.conn
1501            .query_row(
1502                "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1503                        created_at, updated_at
1504                 FROM processes WHERE id = ?1",
1505                [id],
1506                process_from_row,
1507            )
1508            .optional()
1509            .map_err(Into::into)
1510    }
1511
1512    pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1513        let mut stmt = self.conn.prepare(
1514            "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1515                    created_at, updated_at
1516             FROM processes
1517             ORDER BY updated_at DESC
1518             LIMIT ?1",
1519        )?;
1520        // F19 (RC-E): skip-and-warn an undecodable row (e.g. a status enum a
1521        // different binary wrote) rather than blanking the whole processes panel.
1522        let rows = stmt.query_map([clamp_limit(limit)], process_from_row_opt)?;
1523        collect_tolerant(rows)
1524    }
1525}
1526
1527pub struct CheckpointsRepo<'a> {
1528    conn: &'a Connection,
1529}
1530
1531impl CheckpointsRepo<'_> {
1532    pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
1533        let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
1534        self.conn.execute(
1535            "INSERT INTO checkpoints
1536             (id, task_id, project_path, snapshot_path, changed_files_json,
1537              pending_action_json, approval_id, created_at)
1538             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1539            params![
1540                id,
1541                new.task_id,
1542                new.project_path,
1543                new.snapshot_path,
1544                new.changed_files_json,
1545                new.pending_action_json,
1546                new.approval_id,
1547                now_rfc3339(),
1548            ],
1549        )?;
1550        self.get(&id)?
1551            .context("checkpoint was inserted but could not be reloaded")
1552    }
1553
1554    pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
1555        self.conn
1556            .query_row(
1557                "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1558                        pending_action_json, approval_id, created_at, archived_at, archive_reason
1559                 FROM checkpoints WHERE id = ?1",
1560                [id],
1561                checkpoint_from_row,
1562            )
1563            .optional()
1564            .map_err(Into::into)
1565    }
1566
1567    pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
1568        let changed = self.conn.execute(
1569            "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
1570            params![id, approval_id],
1571        )?;
1572        anyhow::ensure!(changed > 0, "checkpoint not found: {}", id);
1573        Ok(())
1574    }
1575
1576    /// Delete a checkpoint row outright. Returns whether a row was removed.
1577    ///
1578    /// F23 (RC-F): coordinates the on-disk checkpoint-dir GC
1579    /// ([`crate::checkpoint::gc_old_checkpoint_dirs`]) with the DB. The dir GC
1580    /// prunes by mtime regardless of archive state, while storage [`Self`] /
1581    /// `gc()` only removes ARCHIVED checkpoint rows — so a never-archived old
1582    /// checkpoint would lose its directory while its row survived, and a later
1583    /// `restore_checkpoint` would fail on the missing manifest. The dir GC now
1584    /// calls this so `list()` and the on-disk dirs stay in agreement.
1585    pub fn delete(&self, id: &str) -> Result<bool> {
1586        let changed = self
1587            .conn
1588            .execute("DELETE FROM checkpoints WHERE id = ?1", params![id])?;
1589        Ok(changed > 0)
1590    }
1591
1592    pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1593        self.list_with_archived(limit, false)
1594    }
1595
1596    pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1597        self.list_with_archived(limit, true)
1598    }
1599
1600    fn list_with_archived(
1601        &self,
1602        limit: usize,
1603        include_archived: bool,
1604    ) -> Result<Vec<CheckpointRecord>> {
1605        let archived_filter = if include_archived {
1606            ""
1607        } else {
1608            "WHERE archived_at IS NULL"
1609        };
1610        let mut stmt = self.conn.prepare(&format!(
1611            "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1612                    pending_action_json, approval_id, created_at, archived_at, archive_reason
1613             FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
1614        ))?;
1615        let rows = stmt.query_map([clamp_limit(limit)], checkpoint_from_row)?;
1616        rows.collect::<rusqlite::Result<Vec<_>>>()
1617            .map_err(Into::into)
1618    }
1619
1620    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1621        let archived_at = now_rfc3339();
1622        let mut changed = 0;
1623        for id in ids {
1624            changed += self.conn.execute(
1625                "UPDATE checkpoints
1626                 SET archived_at = COALESCE(archived_at, ?2),
1627                     archive_reason = COALESCE(archive_reason, ?3)
1628                 WHERE id = ?1 AND archived_at IS NULL",
1629                params![id, archived_at, reason],
1630            )?;
1631        }
1632        Ok(changed)
1633    }
1634
1635    pub fn count_archived(&self) -> Result<usize> {
1636        self.conn
1637            .query_row(
1638                "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1639                [],
1640                |row| row.get::<_, i64>(0),
1641            )
1642            .map(|count| count as usize)
1643            .map_err(Into::into)
1644    }
1645}
1646
1647pub struct CompactionsRepo<'a> {
1648    conn: &'a Connection,
1649}
1650
1651impl CompactionsRepo<'_> {
1652    pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
1653        let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
1654        self.conn.execute(
1655            "INSERT INTO compactions
1656             (id, task_id, session_id, source_token_estimate, summary_token_count,
1657              preserved_turns, archive_path, verification_status, created_at)
1658             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1659             ON CONFLICT(id) DO UPDATE SET
1660                task_id = excluded.task_id,
1661                session_id = excluded.session_id,
1662                source_token_estimate = excluded.source_token_estimate,
1663                summary_token_count = excluded.summary_token_count,
1664                preserved_turns = excluded.preserved_turns,
1665                archive_path = excluded.archive_path,
1666                verification_status = excluded.verification_status",
1667            params![
1668                id,
1669                new.task_id,
1670                new.session_id,
1671                new.source_token_estimate,
1672                new.summary_token_count,
1673                new.preserved_turns,
1674                new.archive_path,
1675                new.verification_status,
1676                now_rfc3339(),
1677            ],
1678        )?;
1679        self.get(&id)?
1680            .context("compaction was inserted but could not be reloaded")
1681    }
1682
1683    pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
1684        self.conn
1685            .query_row(
1686                "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1687                        preserved_turns, archive_path, verification_status, created_at
1688                 FROM compactions WHERE id = ?1",
1689                [id],
1690                compaction_from_row,
1691            )
1692            .optional()
1693            .map_err(Into::into)
1694    }
1695
1696    pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
1697        let mut stmt = self.conn.prepare(
1698            "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1699                    preserved_turns, archive_path, verification_status, created_at
1700             FROM compactions ORDER BY created_at DESC LIMIT ?1",
1701        )?;
1702        let rows = stmt.query_map([clamp_limit(limit)], compaction_from_row)?;
1703        rows.collect::<rusqlite::Result<Vec<_>>>()
1704            .map_err(Into::into)
1705    }
1706}
1707
1708pub struct PluginsRepo<'a> {
1709    conn: &'a Connection,
1710}
1711
1712impl PluginsRepo<'_> {
1713    pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
1714        let now = now_rfc3339();
1715        let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
1716        self.conn.execute(
1717            "INSERT INTO plugin_installs
1718             (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
1719             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1720             ON CONFLICT(id) DO UPDATE SET
1721                name = excluded.name,
1722                source = excluded.source,
1723                version = excluded.version,
1724                enabled = excluded.enabled,
1725                manifest_json = excluded.manifest_json,
1726                updated_at = excluded.updated_at",
1727            params![
1728                id,
1729                new.name,
1730                new.source,
1731                new.version,
1732                if new.enabled { 1 } else { 0 },
1733                new.manifest_json,
1734                now,
1735                now,
1736            ],
1737        )?;
1738        self.get(&id)?
1739            .context("plugin install was inserted but could not be reloaded")
1740    }
1741
1742    pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
1743        self.conn
1744            .query_row(
1745                "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1746                 FROM plugin_installs WHERE id = ?1",
1747                [id],
1748                plugin_from_row,
1749            )
1750            .optional()
1751            .map_err(Into::into)
1752    }
1753
1754    pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
1755        let mut stmt = self.conn.prepare(
1756            "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1757             FROM plugin_installs ORDER BY name ASC",
1758        )?;
1759        let rows = stmt.query_map([], plugin_from_row)?;
1760        rows.collect::<rusqlite::Result<Vec<_>>>()
1761            .map_err(Into::into)
1762    }
1763
1764    pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1765        self.conn.execute(
1766            "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
1767            params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
1768        )?;
1769        Ok(())
1770    }
1771}
1772
1773pub struct ProviderProbesRepo<'a> {
1774    conn: &'a Connection,
1775}
1776
1777impl ProviderProbesRepo<'_> {
1778    pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
1779        let now = now_rfc3339();
1780        let provider = new.provider;
1781        let model_id = new.model_id;
1782        let capability_key = new.capability_key;
1783        self.conn.execute(
1784            "INSERT INTO provider_probes
1785             (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
1786             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1787             ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
1788                capability_value = excluded.capability_value,
1789                confidence = excluded.confidence,
1790                error = excluded.error,
1791                probed_at = excluded.probed_at",
1792            params![
1793                &provider,
1794                &model_id,
1795                &capability_key,
1796                new.capability_value,
1797                new.confidence,
1798                new.error,
1799                now,
1800            ],
1801        )?;
1802        self.get(&provider, &model_id, &capability_key)?
1803            .context("provider probe was inserted but could not be reloaded")
1804    }
1805
1806    pub fn get(
1807        &self,
1808        provider: &str,
1809        model_id: &str,
1810        capability_key: &str,
1811    ) -> Result<Option<ProviderProbeRecord>> {
1812        self.conn
1813            .query_row(
1814                "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1815                 FROM provider_probes
1816                 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
1817                params![provider, model_id, capability_key],
1818                provider_probe_from_row,
1819            )
1820            .optional()
1821            .map_err(Into::into)
1822    }
1823
1824    pub fn list(
1825        &self,
1826        provider: Option<&str>,
1827        model_id: Option<&str>,
1828    ) -> Result<Vec<ProviderProbeRecord>> {
1829        let mut stmt = self.conn.prepare(
1830            "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1831             FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
1832        )?;
1833        let rows = stmt.query_map([], provider_probe_from_row)?;
1834        let mut out = Vec::new();
1835        for row in rows {
1836            let probe = row?;
1837            if provider.is_some_and(|p| probe.provider != p) {
1838                continue;
1839            }
1840            if model_id.is_some_and(|m| probe.model_id != m) {
1841                continue;
1842            }
1843            out.push(probe);
1844        }
1845        Ok(out)
1846    }
1847}
1848
1849pub struct PairingTokensRepo<'a> {
1850    conn: &'a Connection,
1851}
1852
1853impl PairingTokensRepo<'_> {
1854    pub fn create(
1855        &self,
1856        token_hash: &str,
1857        label: Option<&str>,
1858        expires_at: Option<&str>,
1859    ) -> Result<PairingTokenRecord> {
1860        let id = fresh_id("pairing");
1861        self.conn.execute(
1862            "INSERT INTO pairing_tokens
1863                 (id, token_hash, label, enabled, created_at, last_used_at, expires_at)
1864             VALUES (?1, ?2, ?3, 1, ?4, NULL, ?5)",
1865            params![id, token_hash, label, now_rfc3339(), expires_at],
1866        )?;
1867        self.get(&id)?
1868            .context("pairing token was inserted but could not be reloaded")
1869    }
1870
1871    pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
1872        self.conn
1873            .query_row(
1874                "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1875                 FROM pairing_tokens WHERE id = ?1",
1876                [id],
1877                pairing_from_row,
1878            )
1879            .optional()
1880            .map_err(Into::into)
1881    }
1882
1883    /// Look up an enabled, unexpired pairing token by hash.
1884    ///
1885    /// The hash is **not** matched in SQL (`WHERE token_hash = ?`) — that is a
1886    /// DB-level equality on the secret and a theoretical timing channel.
1887    /// Instead we fetch the enabled, unexpired candidates (neither predicate is
1888    /// secret) and compare each hash in constant time. The candidate count is
1889    /// tiny and not secret. All candidates are scanned without early exit so the
1890    /// timing doesn't reveal which (if any) token matched.
1891    pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
1892        // Expiry is evaluated in Rust as a parsed instant (see `is_expired`),
1893        // not via a SQL `expires_at > ?` string compare. The skipped-because-
1894        // expired branch is on non-secret data; the hash itself is still matched
1895        // in constant time over every non-expired candidate with no early exit.
1896        let now = chrono::Utc::now();
1897        let mut stmt = self.conn.prepare(
1898            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1899             FROM pairing_tokens
1900             WHERE enabled = 1",
1901        )?;
1902        let candidates = stmt
1903            .query_map([], pairing_from_row)?
1904            .collect::<rusqlite::Result<Vec<_>>>()?;
1905        let mut found = None;
1906        for record in candidates {
1907            if is_expired(record.expires_at.as_deref(), now) {
1908                continue;
1909            }
1910            if ct_eq(record.token_hash.as_bytes(), token_hash.as_bytes()) {
1911                found = Some(record);
1912            }
1913        }
1914        Ok(found)
1915    }
1916
1917    pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
1918        let mut stmt = self.conn.prepare(
1919            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1920             FROM pairing_tokens ORDER BY created_at DESC",
1921        )?;
1922        let rows = stmt.query_map([], pairing_from_row)?;
1923        rows.collect::<rusqlite::Result<Vec<_>>>()
1924            .map_err(Into::into)
1925    }
1926
1927    /// Like [`list`](Self::list), but with `token_hash` blanked. Use for any
1928    /// surface that crosses a trust boundary — e.g. the daemon snapshot served
1929    /// over the local socket to same-UID processes. The hash is
1930    /// secret-equivalent (it's all `verify_token` compares against) and must
1931    /// not leave the store.
1932    pub fn list_redacted(&self) -> Result<Vec<PairingTokenRecord>> {
1933        Ok(self
1934            .list()?
1935            .into_iter()
1936            .map(|mut record| {
1937                record.token_hash = String::new();
1938                record
1939            })
1940            .collect())
1941    }
1942
1943    pub fn mark_used(&self, id: &str) -> Result<()> {
1944        self.conn.execute(
1945            "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
1946            params![id, now_rfc3339()],
1947        )?;
1948        Ok(())
1949    }
1950
1951    /// Revoke a token by disabling it. Returns `true` if a live token was
1952    /// revoked, `false` if it was already disabled or unknown.
1953    pub fn revoke(&self, id: &str) -> Result<bool> {
1954        let changed = self.conn.execute(
1955            "UPDATE pairing_tokens SET enabled = 0 WHERE id = ?1 AND enabled = 1",
1956            params![id],
1957        )?;
1958        Ok(changed > 0)
1959    }
1960}
1961
1962/// Add `column` to `table` if it is missing. Returns `true` iff the column was
1963/// just created (so the caller can run a one-time backfill).
1964///
1965/// SQL identifiers cannot be bound as `?` parameters, so `table`/`column`/
1966/// `definition` are interpolated. All call sites pass compile-time constants
1967/// today; the validation below makes that a hard invariant rather than a latent
1968/// injection footgun if a future caller ever threads in dynamic input.
1969fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<bool> {
1970    fn is_sql_identifier(s: &str) -> bool {
1971        let mut chars = s.chars();
1972        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
1973            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1974    }
1975    const ALLOWED_DEFINITIONS: &[&str] = &["TEXT", "INTEGER", "REAL", "BLOB"];
1976    anyhow::ensure!(
1977        is_sql_identifier(table),
1978        "invalid table identifier: {table}"
1979    );
1980    anyhow::ensure!(
1981        is_sql_identifier(column),
1982        "invalid column identifier: {column}"
1983    );
1984    anyhow::ensure!(
1985        ALLOWED_DEFINITIONS.contains(&definition),
1986        "unsupported column definition: {definition}"
1987    );
1988
1989    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
1990    let mut rows = stmt.query([])?;
1991    while let Some(row) = rows.next()? {
1992        let name: String = row.get(1)?;
1993        if name == column {
1994            return Ok(false);
1995        }
1996    }
1997    // Tolerate a concurrent opener that added the column between our
1998    // `table_info` check and this ALTER. SQLite reports that as a "duplicate
1999    // column name" schema error (not SQLITE_BUSY, so `busy_timeout` can't retry
2000    // it); treat it as already-present rather than failing the whole store open.
2001    match conn.execute(
2002        &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
2003        [],
2004    ) {
2005        Ok(_) => Ok(true),
2006        Err(error) if error.to_string().contains("duplicate column") => Ok(false),
2007        Err(error) => Err(error.into()),
2008    }
2009}
2010
2011pub fn data_dir() -> Result<PathBuf> {
2012    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
2013        return Ok(proj_dirs.data_dir().to_path_buf());
2014    }
2015    let home = std::env::var("HOME")
2016        .or_else(|_| std::env::var("USERPROFILE"))
2017        .context("could not determine home directory")?;
2018    Ok(PathBuf::from(home).join(".local/share/mermaid"))
2019}
2020
2021fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
2022    Ok(SessionRecord {
2023        id: row.get("id")?,
2024        project_path: row.get("project_path")?,
2025        model_id: row.get("model_id")?,
2026        title: row.get("title")?,
2027        conversation_path: row.get("conversation_path")?,
2028        created_at: row.get("created_at")?,
2029        updated_at: row.get("updated_at")?,
2030        total_tokens: row.get("total_tokens")?,
2031    })
2032}
2033
2034fn message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRecord> {
2035    Ok(MessageRecord {
2036        id: row.get("id")?,
2037        session_id: row.get("session_id")?,
2038        role: row.get("role")?,
2039        content_json: row.get("content_json")?,
2040        created_at: row.get("created_at")?,
2041    })
2042}
2043
2044fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
2045    let status_raw: String = row.get("status")?;
2046    let priority_raw: String = row.get("priority")?;
2047    Ok(TaskRecord {
2048        id: row.get("id")?,
2049        title: row.get("title")?,
2050        status: TaskStatus::from_db(&status_raw)
2051            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2052        priority: TaskPriority::from_db(&priority_raw)
2053            .map_err(|e| enum_from_sql_error("priority", priority_raw, e))?,
2054        project_path: row.get("project_path")?,
2055        model_id: row.get("model_id")?,
2056        conversation_id: row.get("conversation_id")?,
2057        created_at: row.get("created_at")?,
2058        updated_at: row.get("updated_at")?,
2059        final_report: row.get("final_report")?,
2060    })
2061}
2062
2063fn process_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProcessRecord> {
2064    let status_raw: String = row.get("status")?;
2065    let pid: i64 = row.get("pid")?;
2066    Ok(ProcessRecord {
2067        id: row.get("id")?,
2068        task_id: row.get("task_id")?,
2069        pid: pid as u32,
2070        command: row.get("command")?,
2071        cwd: row.get("cwd")?,
2072        log_path: row.get("log_path")?,
2073        detected_url: row.get("detected_url")?,
2074        status: ProcessStatus::from_db(&status_raw)
2075            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2076        health: row.get("health")?,
2077        created_at: row.get("created_at")?,
2078        updated_at: row.get("updated_at")?,
2079    })
2080}
2081
2082fn tool_run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ToolRunRecord> {
2083    Ok(ToolRunRecord {
2084        id: row.get("id")?,
2085        task_id: row.get("task_id")?,
2086        turn_id: row.get("turn_id")?,
2087        call_id: row.get("call_id")?,
2088        tool_name: row.get("tool_name")?,
2089        status: row.get("status")?,
2090        args_json: row.get("args_json")?,
2091        output_json: row.get("output_json")?,
2092        started_at: row.get("started_at")?,
2093        finished_at: row.get("finished_at")?,
2094    })
2095}
2096
2097fn approval_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApprovalRecord> {
2098    Ok(ApprovalRecord {
2099        id: row.get("id")?,
2100        task_id: row.get("task_id")?,
2101        proposed_action: row.get("proposed_action")?,
2102        risk_classification: row.get("risk_classification")?,
2103        policy_decision: row.get("policy_decision")?,
2104        user_decision: row.get("user_decision")?,
2105        args_summary: row.get("args_summary")?,
2106        checkpoint_id: row.get("checkpoint_id")?,
2107        pending_action_json: row.get("pending_action_json")?,
2108        created_at: row.get("created_at")?,
2109        decided_at: row.get("decided_at")?,
2110        archived_at: row.get("archived_at")?,
2111        archive_reason: row.get("archive_reason")?,
2112    })
2113}
2114
2115fn checkpoint_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointRecord> {
2116    Ok(CheckpointRecord {
2117        id: row.get("id")?,
2118        task_id: row.get("task_id")?,
2119        project_path: row.get("project_path")?,
2120        snapshot_path: row.get("snapshot_path")?,
2121        changed_files_json: row.get("changed_files_json")?,
2122        pending_action_json: row.get("pending_action_json")?,
2123        approval_id: row.get("approval_id")?,
2124        created_at: row.get("created_at")?,
2125        archived_at: row.get("archived_at")?,
2126        archive_reason: row.get("archive_reason")?,
2127    })
2128}
2129
2130fn compaction_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CompactionRecord> {
2131    Ok(CompactionRecord {
2132        id: row.get("id")?,
2133        task_id: row.get("task_id")?,
2134        session_id: row.get("session_id")?,
2135        source_token_estimate: row.get("source_token_estimate")?,
2136        summary_token_count: row.get("summary_token_count")?,
2137        preserved_turns: row.get("preserved_turns")?,
2138        archive_path: row.get("archive_path")?,
2139        verification_status: row.get("verification_status")?,
2140        created_at: row.get("created_at")?,
2141    })
2142}
2143
2144fn plugin_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PluginInstallRecord> {
2145    let enabled: i64 = row.get("enabled")?;
2146    Ok(PluginInstallRecord {
2147        id: row.get("id")?,
2148        name: row.get("name")?,
2149        source: row.get("source")?,
2150        version: row.get("version")?,
2151        enabled: enabled != 0,
2152        manifest_json: row.get("manifest_json")?,
2153        installed_at: row.get("installed_at")?,
2154        updated_at: row.get("updated_at")?,
2155    })
2156}
2157
2158fn provider_probe_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProviderProbeRecord> {
2159    Ok(ProviderProbeRecord {
2160        provider: row.get("provider")?,
2161        model_id: row.get("model_id")?,
2162        capability_key: row.get("capability_key")?,
2163        capability_value: row.get("capability_value")?,
2164        confidence: row.get("confidence")?,
2165        error: row.get("error")?,
2166        probed_at: row.get("probed_at")?,
2167    })
2168}
2169
2170fn pairing_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PairingTokenRecord> {
2171    let enabled: i64 = row.get("enabled")?;
2172    Ok(PairingTokenRecord {
2173        id: row.get("id")?,
2174        token_hash: row.get("token_hash")?,
2175        label: row.get("label")?,
2176        enabled: enabled != 0,
2177        created_at: row.get("created_at")?,
2178        last_used_at: row.get("last_used_at")?,
2179        expires_at: row.get("expires_at")?,
2180    })
2181}
2182
2183fn task_event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskTimelineEvent> {
2184    Ok(TaskTimelineEvent {
2185        id: row.get("id")?,
2186        task_id: row.get("task_id")?,
2187        kind: row.get("kind")?,
2188        message: row.get("message")?,
2189        created_at: row.get("created_at")?,
2190    })
2191}
2192
2193/// Whether a row error is a per-row DECODE failure — a value a different binary
2194/// wrote that this build can't parse: an unknown enum
2195/// ([`rusqlite::Error::FromSqlConversionFailure`], how `task_from_row` /
2196/// `process_from_row` surface an unknown status) or a column type mismatch
2197/// ([`rusqlite::Error::InvalidColumnType`]). F19 (RC-E): the list/events paths
2198/// skip-and-warn on these so one poison row can't blank an entire panel, while a
2199/// genuine infrastructure error (a locked DB, a dropped column) still propagates.
2200fn is_row_decode_error(err: &rusqlite::Error) -> bool {
2201    matches!(
2202        err,
2203        rusqlite::Error::FromSqlConversionFailure(..) | rusqlite::Error::InvalidColumnType(..)
2204    )
2205}
2206
2207/// Tolerant [`task_from_row`]: `Ok(None)` (with a warning) for a row this build
2208/// can't decode, so [`TasksRepo::list`] skips it instead of failing the list.
2209fn task_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskRecord>> {
2210    match task_from_row(row) {
2211        Ok(record) => Ok(Some(record)),
2212        Err(err) if is_row_decode_error(&err) => {
2213            tracing::warn!(error = %err, "skipping task row this build can't decode (version skew?)");
2214            Ok(None)
2215        },
2216        Err(err) => Err(err),
2217    }
2218}
2219
2220/// Tolerant [`process_from_row`] — see [`task_from_row_opt`].
2221fn process_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<ProcessRecord>> {
2222    match process_from_row(row) {
2223        Ok(record) => Ok(Some(record)),
2224        Err(err) if is_row_decode_error(&err) => {
2225            tracing::warn!(error = %err, "skipping process row this build can't decode (version skew?)");
2226            Ok(None)
2227        },
2228        Err(err) => Err(err),
2229    }
2230}
2231
2232/// Tolerant [`task_event_from_row`] — see [`task_from_row_opt`].
2233fn task_event_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskTimelineEvent>> {
2234    match task_event_from_row(row) {
2235        Ok(record) => Ok(Some(record)),
2236        Err(err) if is_row_decode_error(&err) => {
2237            tracing::warn!(error = %err, "skipping task event row this build can't decode");
2238            Ok(None)
2239        },
2240        Err(err) => Err(err),
2241    }
2242}
2243
2244/// Collect rows from a tolerant decoder (one that yields `Ok(None)` for a
2245/// skipped poison row), dropping the `None`s and propagating any real error.
2246fn collect_tolerant<T>(rows: impl Iterator<Item = rusqlite::Result<Option<T>>>) -> Result<Vec<T>> {
2247    let mut out = Vec::new();
2248    for row in rows {
2249        if let Some(item) = row? {
2250            out.push(item);
2251        }
2252    }
2253    Ok(out)
2254}
2255
2256fn enum_from_sql_error(
2257    column: &'static str,
2258    value: String,
2259    source: UnknownRuntimeEnum,
2260) -> rusqlite::Error {
2261    let _ = value;
2262    rusqlite::Error::FromSqlConversionFailure(column_index(column), Type::Text, Box::new(source))
2263}
2264
2265fn column_index(column: &str) -> usize {
2266    match column {
2267        "status" => 2,
2268        "priority" => 3,
2269        _ => 0,
2270    }
2271}
2272
2273#[derive(Debug)]
2274struct UnknownRuntimeEnum {
2275    kind: &'static str,
2276    value: String,
2277}
2278
2279impl UnknownRuntimeEnum {
2280    fn new(kind: &'static str, value: &str) -> Self {
2281        Self {
2282            kind,
2283            value: value.to_string(),
2284        }
2285    }
2286}
2287
2288impl fmt::Display for UnknownRuntimeEnum {
2289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2290        write!(f, "unknown {} value `{}`", self.kind, self.value)
2291    }
2292}
2293
2294impl std::error::Error for UnknownRuntimeEnum {}
2295
2296fn now_rfc3339() -> String {
2297    chrono::Utc::now().to_rfc3339()
2298}
2299
2300/// Constant-time byte-slice equality. Unlike `==` (or a SQL `=`), it never
2301/// short-circuits on the first differing byte, so it leaks no timing signal
2302/// about how much of a secret matched. Lengths are compared first; the length
2303/// of a token hash is fixed and not secret.
2304fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2305    if a.len() != b.len() {
2306        return false;
2307    }
2308    let mut diff = 0u8;
2309    for (x, y) in a.iter().zip(b.iter()) {
2310        diff |= x ^ y;
2311    }
2312    diff == 0
2313}
2314
2315/// Whether a pairing token's `expires_at` is in the past relative to `now`.
2316///
2317/// `None` (SQL `NULL`) means "never expires" — the documented `--ttl-days 0`
2318/// opt-out. A present-but-unparseable value fails closed (treated as expired).
2319/// Expiry is compared as a parsed instant rather than via a SQL `expires_at > ?`
2320/// string compare, which only orders correctly while every stored value is the
2321/// canonical `now_rfc3339()` shape (#64).
2322fn is_expired(expires_at: Option<&str>, now: chrono::DateTime<chrono::Utc>) -> bool {
2323    match expires_at {
2324        None => false,
2325        Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) {
2326            Ok(dt) => dt <= now,
2327            Err(_) => true,
2328        },
2329    }
2330}
2331
2332/// Upper bound on any `LIMIT` we bind. A caller-supplied `limit` (e.g. a daemon
2333/// request body's `limit`) can be a huge `u64` that, cast straight to `i64`,
2334/// wraps negative — and SQLite reads a negative `LIMIT` as *unbounded*, so the
2335/// query returns every row (#128). Clamp at the `usize` level before the cast.
2336const MAX_QUERY_LIMIT: usize = 10_000;
2337
2338fn clamp_limit(limit: usize) -> i64 {
2339    limit.min(MAX_QUERY_LIMIT) as i64
2340}
2341
2342/// Upper bound on the rows [`MessagesRepo::list_for_session`] returns (F24/RC-F).
2343/// A session transcript is unbounded and the daemon `session_messages` path loads
2344/// it whole into RAM; this caps the worst-case load at the most recent N messages
2345/// so one pathological session can't OOM the daemon. 5000 turns is far beyond any
2346/// real interactive session yet bounds memory.
2347const MAX_SESSION_MESSAGES: i64 = 5_000;
2348
2349pub(crate) fn fresh_id(prefix: &str) -> String {
2350    // In-process monotonic counter: two ids minted in the same nanosecond (a
2351    // coarse clock, or a clock stepping backward) can never be equal, so the
2352    // `ON CONFLICT(id) DO UPDATE` upserts can't silently overwrite an unrelated
2353    // row (#61). A per-process random salt removes the clock dependence so ids
2354    // minted across a daemon restart don't collide either (getrandom is already
2355    // a dependency — see `daemon.rs`).
2356    static SEQ: AtomicU64 = AtomicU64::new(0);
2357    static SALT: OnceLock<u64> = OnceLock::new();
2358    let salt = *SALT.get_or_init(|| {
2359        let mut bytes = [0u8; 8];
2360        // The monotonic counter alone still guarantees in-process uniqueness if
2361        // the RNG ever fails, so a best-effort fill is fine here.
2362        let _ = getrandom::fill(&mut bytes);
2363        u64::from_le_bytes(bytes)
2364    });
2365    let nanos = SystemTime::now()
2366        .duration_since(UNIX_EPOCH)
2367        .map(|d| d.as_nanos())
2368        .unwrap_or_default();
2369    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
2370    format!("{prefix}-{nanos:x}-{salt:x}-{seq:x}")
2371}
2372
2373/// Acquire an exclusive, auto-released advisory lock on `path` — a process
2374/// singleton guard for the daemon (#131). Returns the held `File` on success
2375/// (keep it alive to hold the lock), or `None` if another process already holds
2376/// it. `flock` releases automatically when the file is dropped OR the process
2377/// exits/crashes, so a dead holder never wedges the lock the way an `O_EXCL`
2378/// pidfile would. Holding it across the socket probe → unlink → bind closes that
2379/// TOCTOU: two daemons can't both decide a stale socket is theirs to rebind.
2380///
2381/// Unix-only: it backs the `#[cfg(unix)]` daemon singleton and relies on
2382/// `flock`, which `rustix` exposes only on Unix targets.
2383#[cfg(unix)]
2384pub fn try_exclusive_lock(path: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
2385    use rustix::fs::{FlockOperation, flock};
2386    // A lockfile's content is irrelevant — only the flock matters — so don't
2387    // truncate (avoids a needless write and any truncate/lock ordering race).
2388    let file = std::fs::OpenOptions::new()
2389        .create(true)
2390        .write(true)
2391        .truncate(false)
2392        .open(path)?;
2393    match flock(&file, FlockOperation::NonBlockingLockExclusive) {
2394        Ok(()) => Ok(Some(file)),
2395        Err(rustix::io::Errno::WOULDBLOCK) => Ok(None),
2396        Err(e) => Err(e.into()),
2397    }
2398}
2399
2400#[cfg(test)]
2401mod tests {
2402    use super::*;
2403
2404    #[test]
2405    fn open_enables_wal_and_busy_timeout() {
2406        // H19: every connection must use WAL so daemon/CLI/effect writers
2407        // don't hit a hard SQLITE_BUSY.
2408        let path = temp_db("wal_check");
2409        let store = RuntimeStore::open(&path).expect("open");
2410        let mode: String = store
2411            .conn
2412            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2413            .expect("journal_mode pragma");
2414        assert_eq!(mode.to_lowercase(), "wal");
2415    }
2416
2417    fn temp_db(name: &str) -> PathBuf {
2418        let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{}", name));
2419        let _ = std::fs::remove_dir_all(&dir);
2420        std::fs::create_dir_all(&dir).expect("create temp dir");
2421        dir.join("runtime.sqlite3")
2422    }
2423
2424    #[test]
2425    fn initializes_runtime_schema() {
2426        let path = temp_db("schema");
2427        let store = RuntimeStore::open(&path).expect("open store");
2428        assert_eq!(store.path(), path.as_path());
2429        let version: i32 = store
2430            .conn
2431            .query_row("PRAGMA user_version", [], |row| row.get(0))
2432            .unwrap();
2433        assert_eq!(version, SCHEMA_VERSION);
2434        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2435    }
2436
2437    #[test]
2438    fn rejects_newer_schema_version() {
2439        // Forward-compat gate: a DB stamped with a newer schema must be
2440        // refused, not silently down-labeled and operated on (RC-5).
2441        let path = temp_db("newer_schema");
2442        {
2443            let store = RuntimeStore::open(&path).expect("first open");
2444            store
2445                .conn
2446                .execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
2447                .expect("bump version");
2448        }
2449        // `RuntimeStore` isn't `Debug`, so match rather than `expect_err`.
2450        let err = match RuntimeStore::open(&path) {
2451            Ok(_) => panic!("must refuse a newer DB"),
2452            Err(e) => e,
2453        };
2454        assert!(
2455            err.to_string().contains("newer than this build"),
2456            "unexpected error: {err}"
2457        );
2458        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2459    }
2460
2461    #[test]
2462    fn init_schema_is_idempotent_across_opens() {
2463        // Re-opening the same DB re-runs `init_schema`; it must succeed (the
2464        // create script and `ensure_column` are idempotent) and keep the
2465        // version stamped.
2466        let path = temp_db("idempotent_schema");
2467        let _ = RuntimeStore::open(&path).expect("first open");
2468        let store = RuntimeStore::open(&path).expect("second open must succeed");
2469        let version: i32 = store
2470            .conn
2471            .query_row("PRAGMA user_version", [], |r| r.get(0))
2472            .unwrap();
2473        assert_eq!(version, SCHEMA_VERSION);
2474        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2475    }
2476
2477    fn explain_query_plan(conn: &Connection, sql: &str) -> String {
2478        let mut stmt = conn
2479            .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
2480            .expect("prepare EXPLAIN QUERY PLAN");
2481        // Column 3 of an EQP row is the human-readable `detail` (e.g.
2482        // "SEARCH approvals USING INDEX idx_approvals_pending ...").
2483        let rows = stmt
2484            .query_map([], |row| row.get::<_, String>(3))
2485            .expect("eqp query")
2486            .collect::<rusqlite::Result<Vec<String>>>()
2487            .expect("eqp rows");
2488        rows.join("\n")
2489    }
2490
2491    #[test]
2492    fn pending_and_reconcile_scans_use_indexes() {
2493        // F75: the pending-approval scan and the reconcile scan must hit their
2494        // covering indexes rather than full-table scans.
2495        let path = temp_db("scan_indexes");
2496        let store = RuntimeStore::open(&path).expect("open");
2497
2498        let index_count: i64 = store
2499            .conn
2500            .query_row(
2501                "SELECT COUNT(*) FROM sqlite_master
2502                 WHERE type = 'index'
2503                   AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
2504                [],
2505                |r| r.get(0),
2506            )
2507            .unwrap();
2508        assert_eq!(index_count, 2, "F75 indexes must be created");
2509
2510        // `list_pending`'s scan must use the partial pending index (it also serves
2511        // the ORDER BY created_at, so no separate sort).
2512        let plan = explain_query_plan(
2513            &store.conn,
2514            "SELECT id FROM approvals WHERE user_decision IS NULL ORDER BY created_at DESC",
2515        );
2516        assert!(
2517            plan.contains("idx_approvals_pending"),
2518            "pending scan must use idx_approvals_pending; plan was:\n{plan}"
2519        );
2520
2521        // `reconcile_after_restart`'s scan must use the (status, owner_kind) index.
2522        let plan = explain_query_plan(
2523            &store.conn,
2524            "SELECT id FROM tasks WHERE status = 'running' AND owner_kind = 'daemon'",
2525        );
2526        assert!(
2527            plan.contains("idx_tasks_status_owner"),
2528            "reconcile scan must use idx_tasks_status_owner; plan was:\n{plan}"
2529        );
2530
2531        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2532    }
2533
2534    #[test]
2535    fn upgrades_from_v2_to_current_and_adds_indexes() {
2536        // F75/F76: a DB stamped at the previous schema version must migrate forward
2537        // on the next open — re-run the idempotent baseline, pick up the F75
2538        // indexes, and stamp the current version — exercising the per-version
2539        // dispatch (`from_version = 2` runs the v3 step).
2540        let path = temp_db("upgrade_v2");
2541        {
2542            let store = RuntimeStore::open(&path).expect("first open");
2543            // Simulate an older v2 DB: drop the new indexes and roll the stamp back.
2544            store
2545                .conn
2546                .execute_batch(
2547                    "DROP INDEX IF EXISTS idx_approvals_pending;
2548                     DROP INDEX IF EXISTS idx_tasks_status_owner;
2549                     PRAGMA user_version = 2;",
2550                )
2551                .expect("downgrade to v2");
2552        }
2553        let store = RuntimeStore::open(&path).expect("reopen must migrate forward");
2554        let version: i32 = store
2555            .conn
2556            .query_row("PRAGMA user_version", [], |r| r.get(0))
2557            .unwrap();
2558        assert_eq!(version, SCHEMA_VERSION);
2559        let index_count: i64 = store
2560            .conn
2561            .query_row(
2562                "SELECT COUNT(*) FROM sqlite_master
2563                 WHERE type = 'index'
2564                   AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
2565                [],
2566                |r| r.get(0),
2567            )
2568            .unwrap();
2569        assert_eq!(
2570            index_count, 2,
2571            "forward migration must recreate the F75 indexes"
2572        );
2573        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2574    }
2575
2576    #[test]
2577    fn task_create_commits_task_and_event_atomically() {
2578        // The task row and its `task_created` event commit in one transaction.
2579        let path = temp_db("task_txn");
2580        let store = RuntimeStore::open(&path).expect("open");
2581        let task = store
2582            .tasks()
2583            .create(NewTask::new("do a thing", "/repo", "anthropic/claude"))
2584            .expect("create task");
2585        let events = store.tasks().events(&task.id).expect("events");
2586        assert!(
2587            events.iter().any(|e| e.kind == "task_created"),
2588            "the task_created event must commit with the task row"
2589        );
2590        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2591    }
2592
2593    #[test]
2594    fn task_lifecycle_round_trips() {
2595        let path = temp_db("task");
2596        let store = RuntimeStore::open(&path).expect("open store");
2597        let session = store
2598            .sessions()
2599            .upsert(NewSession {
2600                id: Some("session-1".to_string()),
2601                project_path: "/repo".to_string(),
2602                model_id: "anthropic/claude".to_string(),
2603                title: Some("Run tests".to_string()),
2604                conversation_path: Some("/repo/.mermaid/session.json".to_string()),
2605                total_tokens: Some(42),
2606            })
2607            .expect("upsert session");
2608        assert_eq!(session.id, "session-1");
2609        let message = store
2610            .messages()
2611            .add(NewMessage {
2612                session_id: session.id.clone(),
2613                role: "user".to_string(),
2614                content_json: "{\"text\":\"hi\"}".to_string(),
2615            })
2616            .expect("add message");
2617        assert_eq!(message.role, "user");
2618        assert_eq!(
2619            store
2620                .messages()
2621                .list_for_session(&session.id)
2622                .unwrap()
2623                .len(),
2624            1
2625        );
2626
2627        let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
2628        new.priority = TaskPriority::High;
2629        let task = store.tasks().create(new).expect("create task");
2630
2631        assert_eq!(task.status, TaskStatus::Queued);
2632        assert_eq!(task.priority, TaskPriority::High);
2633
2634        store
2635            .tasks()
2636            .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
2637            .expect("update task");
2638        let loaded = store.tasks().get(&task.id).unwrap().unwrap();
2639        assert_eq!(loaded.status, TaskStatus::Completed);
2640        assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
2641
2642        let events = store.tasks().events(&task.id).expect("events");
2643        assert_eq!(events.len(), 2);
2644        assert_eq!(events[0].kind, "task_created");
2645        assert_eq!(events[1].kind, "status_changed");
2646        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2647    }
2648
2649    #[test]
2650    fn approval_and_process_records_round_trip() {
2651        let path = temp_db("approval_process");
2652        let store = RuntimeStore::open(&path).expect("open store");
2653        let task = store
2654            .tasks()
2655            .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
2656            .expect("create task");
2657
2658        let approval = store
2659            .approvals()
2660            .create(NewApproval {
2661                task_id: Some(task.id.clone()),
2662                proposed_action: "write_file src/lib.rs".to_string(),
2663                risk_classification: "file_mutation".to_string(),
2664                policy_decision: "ask".to_string(),
2665                args_summary: Some("src/lib.rs".to_string()),
2666                checkpoint_id: Some("checkpoint-1".to_string()),
2667                pending_action_json: Some(
2668                    "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
2669                ),
2670            })
2671            .expect("create approval");
2672        store
2673            .approvals()
2674            .decide(&approval.id, "approved")
2675            .expect("decide approval");
2676        let approval = store.approvals().get(&approval.id).unwrap().unwrap();
2677        assert_eq!(approval.user_decision.as_deref(), Some("approved"));
2678        assert!(approval.pending_action_json.is_some());
2679
2680        let tool_run = store
2681            .tool_runs()
2682            .start(NewToolRun {
2683                id: Some("toolrun-1".to_string()),
2684                task_id: Some(task.id.clone()),
2685                turn_id: Some("turn-1".to_string()),
2686                call_id: Some("call-1".to_string()),
2687                tool_name: "write_file".to_string(),
2688                args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
2689            })
2690            .expect("start tool run");
2691        assert_eq!(tool_run.status, "running");
2692        store
2693            .tool_runs()
2694            .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
2695            .expect("finish tool run");
2696        let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
2697        assert_eq!(tool_run.status, "success");
2698        assert!(tool_run.finished_at.is_some());
2699
2700        let process = store
2701            .processes()
2702            .upsert(NewProcess {
2703                id: Some("proc-1".to_string()),
2704                task_id: Some(task.id),
2705                pid: 123,
2706                command: "npm run dev".to_string(),
2707                cwd: Some("/repo".to_string()),
2708                log_path: Some("/tmp/mermaid.log".to_string()),
2709                detected_url: Some("http://127.0.0.1:5173".to_string()),
2710                status: ProcessStatus::Running,
2711                health: Some("ready".to_string()),
2712            })
2713            .expect("upsert process");
2714        assert_eq!(process.status, ProcessStatus::Running);
2715        assert_eq!(store.processes().list(10).unwrap().len(), 1);
2716        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2717    }
2718
2719    #[test]
2720    fn approval_decide_is_single_shot() {
2721        let path = temp_db("approval_decide_guard");
2722        let store = RuntimeStore::open(&path).expect("open store");
2723        let make = |action: &str| {
2724            store
2725                .approvals()
2726                .create(NewApproval {
2727                    task_id: None,
2728                    proposed_action: action.to_string(),
2729                    risk_classification: "file_mutation".to_string(),
2730                    policy_decision: "ask".to_string(),
2731                    args_summary: None,
2732                    checkpoint_id: None,
2733                    pending_action_json: None,
2734                })
2735                .expect("create approval")
2736        };
2737
2738        // A second decision on an already-decided approval is rejected — this
2739        // is what stops a stored action from being replayed N times.
2740        let a = make("write_file a");
2741        store
2742            .approvals()
2743            .decide(&a.id, "approved")
2744            .expect("first decide");
2745        assert!(
2746            store.approvals().decide(&a.id, "approved").is_err(),
2747            "re-approving an approved approval must be rejected"
2748        );
2749
2750        // A denied approval cannot be resurrected as approved.
2751        let b = make("write_file b");
2752        store.approvals().decide(&b.id, "denied").expect("deny");
2753        assert!(
2754            store.approvals().decide(&b.id, "approved").is_err(),
2755            "a denied approval must not be re-decidable as approved"
2756        );
2757        let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
2758        assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
2759
2760        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2761    }
2762
2763    #[test]
2764    fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
2765        let path = temp_db("archive_visibility");
2766        let store = RuntimeStore::open(&path).expect("open store");
2767
2768        let approval = store
2769            .approvals()
2770            .create(NewApproval {
2771                task_id: None,
2772                proposed_action: "restore replay: write_file".to_string(),
2773                risk_classification: "restored_action".to_string(),
2774                policy_decision: "ask".to_string(),
2775                args_summary: None,
2776                checkpoint_id: Some("checkpoint-1".to_string()),
2777                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2778            })
2779            .expect("create approval");
2780        let checkpoint = store
2781            .checkpoints()
2782            .create(NewCheckpoint {
2783                id: Some("checkpoint-1".to_string()),
2784                task_id: None,
2785                project_path: "/tmp/mermaid_checkpoint_test".to_string(),
2786                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
2787                changed_files_json: "[]".to_string(),
2788                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2789                approval_id: Some(approval.id.clone()),
2790            })
2791            .expect("create checkpoint");
2792
2793        assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
2794        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
2795        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
2796        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
2797        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
2798
2799        assert_eq!(
2800            store
2801                .approvals()
2802                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
2803                .unwrap(),
2804            1
2805        );
2806        assert_eq!(
2807            store
2808                .checkpoints()
2809                .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
2810                .unwrap(),
2811            1
2812        );
2813        assert_eq!(
2814            store
2815                .approvals()
2816                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
2817                .unwrap(),
2818            0
2819        );
2820        assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
2821        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
2822        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
2823        assert_eq!(store.approvals().count_archived().unwrap(), 1);
2824        assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
2825        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
2826        assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
2827
2828        let archived = store.approvals().get(&approval.id).unwrap().unwrap();
2829        assert!(archived.archived_at.is_some());
2830        assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
2831        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2832    }
2833
2834    #[test]
2835    fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
2836        let path = temp_db("everything_else");
2837        let store = RuntimeStore::open(&path).expect("open store");
2838
2839        let checkpoint = store
2840            .checkpoints()
2841            .create(NewCheckpoint {
2842                id: Some("checkpoint-1".to_string()),
2843                task_id: None,
2844                project_path: "/repo".to_string(),
2845                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
2846                changed_files_json: "[\"src/lib.rs\"]".to_string(),
2847                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2848                approval_id: None,
2849            })
2850            .expect("create checkpoint");
2851        assert_eq!(checkpoint.id, "checkpoint-1");
2852        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
2853
2854        let compaction = store
2855            .compactions()
2856            .create(NewCompaction {
2857                id: Some("compaction-1".to_string()),
2858                task_id: None,
2859                session_id: Some("session-1".to_string()),
2860                source_token_estimate: Some(10_000),
2861                summary_token_count: Some(800),
2862                preserved_turns: Some(6),
2863                archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
2864                verification_status: Some("verified".to_string()),
2865            })
2866            .expect("create compaction");
2867        assert_eq!(compaction.summary_token_count, Some(800));
2868        assert_eq!(store.compactions().list(10).unwrap().len(), 1);
2869
2870        let plugin = store
2871            .plugins()
2872            .install(NewPluginInstall {
2873                id: Some("plugin-1".to_string()),
2874                name: "example".to_string(),
2875                source: "local".to_string(),
2876                version: Some("0.1.0".to_string()),
2877                enabled: true,
2878                manifest_json: "{\"name\":\"example\"}".to_string(),
2879            })
2880            .expect("install plugin");
2881        assert!(plugin.enabled);
2882        store.plugins().set_enabled("plugin-1", false).unwrap();
2883        assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
2884
2885        let probe = store
2886            .provider_probes()
2887            .upsert(NewProviderProbe {
2888                provider: "cerebras".to_string(),
2889                model_id: "gpt-oss-120b".to_string(),
2890                capability_key: "parallel_tool_calls".to_string(),
2891                capability_value: "false".to_string(),
2892                confidence: "static".to_string(),
2893                error: None,
2894            })
2895            .expect("probe");
2896        assert_eq!(probe.confidence, "static");
2897        assert_eq!(
2898            store
2899                .provider_probes()
2900                .list(Some("cerebras"), Some("gpt-oss-120b"))
2901                .unwrap()
2902                .len(),
2903            1
2904        );
2905
2906        let pairing = store
2907            .pairing_tokens()
2908            .create("hash", Some("phone"), None)
2909            .expect("pairing");
2910        store.pairing_tokens().mark_used(&pairing.id).unwrap();
2911        assert!(
2912            store
2913                .pairing_tokens()
2914                .get(&pairing.id)
2915                .unwrap()
2916                .unwrap()
2917                .last_used_at
2918                .is_some()
2919        );
2920        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2921    }
2922
2923    #[test]
2924    fn pairing_token_expiry_and_revoke() {
2925        let path = temp_db("pairing_ttl");
2926        let store = RuntimeStore::open(&path).expect("open store");
2927        let tokens = store.pairing_tokens();
2928
2929        // A never-expiring token verifies.
2930        let live = tokens
2931            .create("live_hash", Some("a"), None)
2932            .expect("create live");
2933        assert!(tokens.verify_token("live_hash").unwrap().is_some());
2934
2935        // A future expiry still verifies; a past expiry does not.
2936        let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
2937        tokens
2938            .create("future_hash", None, Some(&future))
2939            .expect("create future");
2940        assert!(tokens.verify_token("future_hash").unwrap().is_some());
2941
2942        let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
2943        tokens
2944            .create("past_hash", None, Some(&past))
2945            .expect("create past");
2946        assert!(
2947            tokens.verify_token("past_hash").unwrap().is_none(),
2948            "an expired token must not verify"
2949        );
2950
2951        // A future expiry rendered with a non-UTC offset still verifies, even
2952        // though its RFC3339 string sorts lexically *before* `now_rfc3339()` —
2953        // this would wrongly read as expired under the old SQL string compare (#64).
2954        let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
2955            .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
2956            .to_rfc3339();
2957        tokens
2958            .create("skew_hash", None, Some(&skewed))
2959            .expect("create skewed");
2960        assert!(
2961            tokens.verify_token("skew_hash").unwrap().is_some(),
2962            "a future token in a non-UTC offset must verify (parsed-instant compare)"
2963        );
2964
2965        // A present-but-unparseable expiry fails closed (treated as expired).
2966        tokens
2967            .create("garbage_hash", None, Some("not-a-timestamp"))
2968            .expect("create garbage");
2969        assert!(
2970            tokens.verify_token("garbage_hash").unwrap().is_none(),
2971            "an unparseable expiry must fail closed"
2972        );
2973
2974        // Revoking disables the token.
2975        assert!(tokens.revoke(&live.id).unwrap());
2976        assert!(tokens.verify_token("live_hash").unwrap().is_none());
2977        assert!(
2978            !tokens.revoke(&live.id).unwrap(),
2979            "double revoke is a no-op"
2980        );
2981
2982        // A non-matching hash never verifies.
2983        assert!(tokens.verify_token("nope").unwrap().is_none());
2984
2985        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2986    }
2987
2988    #[test]
2989    fn ct_eq_matches_only_identical_bytes() {
2990        assert!(ct_eq(b"abc", b"abc"));
2991        assert!(!ct_eq(b"abc", b"abd"));
2992        assert!(!ct_eq(b"abc", b"ab"));
2993        assert!(!ct_eq(b"", b"x"));
2994        assert!(ct_eq(b"", b""));
2995    }
2996
2997    #[test]
2998    fn fresh_id_is_collision_free_in_tight_loop() {
2999        // The #61 stress: ids minted back-to-back (same nanosecond on a coarse
3000        // clock) must all be distinct and keep the `prefix-` shape.
3001        let mut seen = std::collections::HashSet::new();
3002        for _ in 0..10_000 {
3003            let id = fresh_id("process");
3004            assert!(id.starts_with("process-"), "id must keep prefix: {id}");
3005            assert!(seen.insert(id), "fresh_id produced a duplicate");
3006        }
3007    }
3008
3009    #[test]
3010    fn ensure_column_rejects_non_identifier() {
3011        let path = temp_db("ensure_col");
3012        let store = RuntimeStore::open(&path).expect("open store");
3013        assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
3014        assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
3015        assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
3016        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3017    }
3018
3019    #[test]
3020    fn clamp_limit_never_binds_negative() {
3021        // #128: a huge `limit` must clamp, not wrap to a negative i64 (which
3022        // SQLite reads as unbounded).
3023        assert_eq!(clamp_limit(10), 10);
3024        assert_eq!(clamp_limit(usize::MAX), MAX_QUERY_LIMIT as i64);
3025        assert!(clamp_limit(usize::MAX) > 0);
3026    }
3027
3028    fn make_approval(store: &RuntimeStore, action: &str) -> ApprovalRecord {
3029        store
3030            .approvals()
3031            .create(NewApproval {
3032                task_id: None,
3033                proposed_action: action.to_string(),
3034                risk_classification: "shell_mutation".to_string(),
3035                policy_decision: "ask".to_string(),
3036                args_summary: None,
3037                checkpoint_id: None,
3038                pending_action_json: None,
3039            })
3040            .expect("create approval")
3041    }
3042
3043    #[test]
3044    fn approval_claim_is_single_winner_releasable_and_finalizable() {
3045        // #118: exactly one concurrent claim wins; a released claim re-claims; a
3046        // finalized one is decided and unclaimable.
3047        let path = temp_db("approval_claim");
3048        let store = RuntimeStore::open(&path).expect("open store");
3049        let a = make_approval(&store, "write_file a");
3050
3051        assert!(store.approvals().claim(&a.id).unwrap(), "first claim wins");
3052        assert!(
3053            !store.approvals().claim(&a.id).unwrap(),
3054            "second claim loses"
3055        );
3056
3057        store.approvals().release_claim(&a.id).unwrap();
3058        assert!(
3059            store.approvals().claim(&a.id).unwrap(),
3060            "a released claim is re-claimable (effect-failed path)"
3061        );
3062
3063        store
3064            .approvals()
3065            .finalize_claimed(&a.id, "approved")
3066            .unwrap();
3067        assert_eq!(
3068            store
3069                .approvals()
3070                .get(&a.id)
3071                .unwrap()
3072                .unwrap()
3073                .user_decision
3074                .as_deref(),
3075            Some("approved")
3076        );
3077        assert!(
3078            !store.approvals().claim(&a.id).unwrap(),
3079            "a decided approval cannot be claimed"
3080        );
3081        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3082    }
3083
3084    #[test]
3085    fn reconcile_after_restart_recovers_running_tasks_and_claims() {
3086        // #120/#118: a daemon-owned Running task and an 'approving' claim left by a
3087        // crashed daemon are recovered on the next startup.
3088        let path = temp_db("reconcile");
3089        let store = RuntimeStore::open(&path).expect("open store");
3090        let task = store
3091            .tasks()
3092            .create(NewTask::new("t", "/repo", "m").daemon_owned())
3093            .expect("create task");
3094        store
3095            .tasks()
3096            .update_status(&task.id, TaskStatus::Running, None)
3097            .expect("mark running");
3098        let appr = make_approval(&store, "git push");
3099        assert!(store.approvals().claim(&appr.id).unwrap());
3100
3101        let (tasks, claims) = store.reconcile_after_restart().expect("reconcile");
3102        assert_eq!((tasks, claims), (1, 1));
3103        assert_eq!(
3104            store.tasks().get(&task.id).unwrap().unwrap().status,
3105            TaskStatus::Failed
3106        );
3107        assert!(
3108            store
3109                .approvals()
3110                .get(&appr.id)
3111                .unwrap()
3112                .unwrap()
3113                .user_decision
3114                .is_none(),
3115            "a released claim is undecided and re-runnable"
3116        );
3117        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3118    }
3119
3120    #[test]
3121    fn reconcile_after_restart_spares_non_daemon_running_tasks() {
3122        // F18 (RC-E): a Running task NOT owned by the daemon (an interactive CLI
3123        // run sharing the store, owner_kind = NULL) must survive a daemon restart
3124        // — not be flipped to Failed with a spurious "interrupted" event.
3125        let path = temp_db("reconcile_spare_cli");
3126        let store = RuntimeStore::open(&path).expect("open store");
3127
3128        let cli = store
3129            .tasks()
3130            .create(NewTask::new("cli run", "/repo", "m")) // no .daemon_owned()
3131            .expect("create cli task");
3132        store
3133            .tasks()
3134            .update_status(&cli.id, TaskStatus::Running, None)
3135            .expect("mark cli running");
3136        let daemon = store
3137            .tasks()
3138            .create(NewTask::new("daemon run", "/repo", "m").daemon_owned())
3139            .expect("create daemon task");
3140        store
3141            .tasks()
3142            .update_status(&daemon.id, TaskStatus::Running, None)
3143            .expect("mark daemon running");
3144
3145        let (tasks, _claims) = store.reconcile_after_restart().expect("reconcile");
3146        assert_eq!(tasks, 1, "only the daemon-owned task is reset");
3147        assert_eq!(
3148            store.tasks().get(&cli.id).unwrap().unwrap().status,
3149            TaskStatus::Running,
3150            "a live CLI task must NOT be clobbered by the daemon's reconcile"
3151        );
3152        assert_eq!(
3153            store.tasks().get(&daemon.id).unwrap().unwrap().status,
3154            TaskStatus::Failed,
3155            "a stranded daemon task is still recovered"
3156        );
3157        // The spared CLI task gets no "interrupted" event.
3158        assert!(
3159            !store
3160                .tasks()
3161                .events(&cli.id)
3162                .unwrap()
3163                .iter()
3164                .any(|e| e.kind == "interrupted"),
3165            "the spared task must not receive a spurious interrupted event"
3166        );
3167        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3168    }
3169
3170    #[test]
3171    fn gc_prunes_old_archived_but_keeps_active() {
3172        // #130: GC removes archived rows past the retention window, never active
3173        // ones.
3174        let path = temp_db("gc");
3175        let store = RuntimeStore::open(&path).expect("open store");
3176        let keep = make_approval(&store, "active");
3177        let gone = make_approval(&store, "old archived");
3178        store
3179            .approvals()
3180            .archive(std::slice::from_ref(&gone.id), "test")
3181            .expect("archive");
3182        // Backdate the archive far past the window.
3183        store
3184            .conn
3185            .execute(
3186                "UPDATE approvals SET archived_at = ?2 WHERE id = ?1",
3187                params![gone.id, "2000-01-01T00:00:00+00:00"],
3188            )
3189            .unwrap();
3190
3191        let removed = store.gc(30).expect("gc");
3192        assert!(removed >= 1, "the old archived approval should be pruned");
3193        assert!(
3194            store.approvals().get(&gone.id).unwrap().is_none(),
3195            "old archived row removed"
3196        );
3197        assert!(
3198            store.approvals().get(&keep.id).unwrap().is_some(),
3199            "active row kept"
3200        );
3201        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3202    }
3203
3204    #[test]
3205    fn gc_prunes_high_churn_and_old_terminal_rows_but_keeps_active() {
3206        // F22 (RC-F): GC prunes finished tool_runs, exited processes, old
3207        // compactions, and stale sessions/messages past the window — never active
3208        // data (a running tool_run, a live process, a fresh session).
3209        let path = temp_db("gc_high_churn");
3210        let store = RuntimeStore::open(&path).expect("open store");
3211        let old = "2000-01-01T00:00:00+00:00";
3212
3213        // Stale session + message (deleted) vs active session + message (kept).
3214        let stale_session = store
3215            .sessions()
3216            .upsert(NewSession {
3217                id: Some("stale".to_string()),
3218                project_path: "/repo".to_string(),
3219                model_id: "m".to_string(),
3220                title: None,
3221                conversation_path: None,
3222                total_tokens: None,
3223            })
3224            .expect("stale session");
3225        store
3226            .messages()
3227            .add(NewMessage {
3228                session_id: stale_session.id.clone(),
3229                role: "user".to_string(),
3230                content_json: "{}".to_string(),
3231            })
3232            .expect("stale message");
3233        let active_session = store
3234            .sessions()
3235            .upsert(NewSession {
3236                id: Some("active".to_string()),
3237                project_path: "/repo".to_string(),
3238                model_id: "m".to_string(),
3239                title: None,
3240                conversation_path: None,
3241                total_tokens: None,
3242            })
3243            .expect("active session");
3244        store
3245            .messages()
3246            .add(NewMessage {
3247                session_id: active_session.id.clone(),
3248                role: "user".to_string(),
3249                content_json: "{}".to_string(),
3250            })
3251            .expect("active message");
3252        store
3253            .conn
3254            .execute(
3255                "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
3256                params![stale_session.id, old],
3257            )
3258            .unwrap();
3259
3260        // Finished (old) tool_run deleted; running tool_run kept.
3261        store
3262            .tool_runs()
3263            .start(NewToolRun {
3264                id: Some("tr-finished".to_string()),
3265                task_id: None,
3266                turn_id: None,
3267                call_id: None,
3268                tool_name: "x".to_string(),
3269                args_json: None,
3270            })
3271            .expect("start finished tr");
3272        store
3273            .tool_runs()
3274            .finish("tr-finished", "success", None)
3275            .expect("finish tr");
3276        store
3277            .conn
3278            .execute(
3279                "UPDATE tool_runs SET finished_at = ?2 WHERE id = ?1",
3280                params!["tr-finished", old],
3281            )
3282            .unwrap();
3283        store
3284            .tool_runs()
3285            .start(NewToolRun {
3286                id: Some("tr-running".to_string()),
3287                task_id: None,
3288                turn_id: None,
3289                call_id: None,
3290                tool_name: "x".to_string(),
3291                args_json: None,
3292            })
3293            .expect("start running tr");
3294
3295        // Exited (old) process deleted; running process kept.
3296        let exited = store
3297            .processes()
3298            .upsert(NewProcess {
3299                id: Some("p-exited".to_string()),
3300                task_id: None,
3301                pid: 1,
3302                command: "c".to_string(),
3303                cwd: None,
3304                log_path: None,
3305                detected_url: None,
3306                status: ProcessStatus::Exited,
3307                health: None,
3308            })
3309            .expect("exited process");
3310        store
3311            .conn
3312            .execute(
3313                "UPDATE processes SET updated_at = ?2 WHERE id = ?1",
3314                params![exited.id, old],
3315            )
3316            .unwrap();
3317        let running_proc = store
3318            .processes()
3319            .upsert(NewProcess {
3320                id: Some("p-running".to_string()),
3321                task_id: None,
3322                pid: 2,
3323                command: "c".to_string(),
3324                cwd: None,
3325                log_path: None,
3326                detected_url: None,
3327                status: ProcessStatus::Running,
3328                health: None,
3329            })
3330            .expect("running process");
3331
3332        // Old compaction deleted.
3333        let comp = store
3334            .compactions()
3335            .create(NewCompaction {
3336                id: Some("comp-old".to_string()),
3337                task_id: None,
3338                session_id: None,
3339                source_token_estimate: None,
3340                summary_token_count: None,
3341                preserved_turns: None,
3342                archive_path: None,
3343                verification_status: None,
3344            })
3345            .expect("compaction");
3346        store
3347            .conn
3348            .execute(
3349                "UPDATE compactions SET created_at = ?2 WHERE id = ?1",
3350                params![comp.id, old],
3351            )
3352            .unwrap();
3353
3354        let removed = store.gc(30).expect("gc");
3355        assert!(removed >= 5, "stale rows pruned (got {removed})");
3356        assert!(
3357            store.sessions().get(&stale_session.id).unwrap().is_none(),
3358            "stale session gone"
3359        );
3360        assert!(
3361            store
3362                .messages()
3363                .list_for_session(&stale_session.id)
3364                .unwrap()
3365                .is_empty(),
3366            "stale messages gone"
3367        );
3368        assert!(
3369            store.sessions().get(&active_session.id).unwrap().is_some(),
3370            "active session kept"
3371        );
3372        assert_eq!(
3373            store
3374                .messages()
3375                .list_for_session(&active_session.id)
3376                .unwrap()
3377                .len(),
3378            1,
3379            "active message kept"
3380        );
3381        assert!(
3382            store.tool_runs().get("tr-finished").unwrap().is_none(),
3383            "old finished tool_run gone"
3384        );
3385        assert!(
3386            store.tool_runs().get("tr-running").unwrap().is_some(),
3387            "running tool_run kept"
3388        );
3389        assert!(
3390            store.processes().get(&exited.id).unwrap().is_none(),
3391            "old exited process gone"
3392        );
3393        assert!(
3394            store.processes().get(&running_proc.id).unwrap().is_some(),
3395            "running process kept"
3396        );
3397        assert!(
3398            store.compactions().get(&comp.id).unwrap().is_none(),
3399            "old compaction gone"
3400        );
3401        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3402    }
3403
3404    #[test]
3405    fn task_list_skips_undecodable_status_row() {
3406        // F19 (RC-E): a task row whose status enum this build can't decode (a
3407        // different binary wrote it) is skipped, not allowed to blank the list.
3408        let path = temp_db("poison_task");
3409        let store = RuntimeStore::open(&path).expect("open store");
3410        let good = store
3411            .tasks()
3412            .create(NewTask::new("good", "/repo", "m"))
3413            .expect("create good task");
3414        store
3415            .conn
3416            .execute(
3417                "INSERT INTO tasks
3418                 (id, title, status, priority, project_path, model_id, created_at, updated_at)
3419                 VALUES ('poison', 't', 'from_the_future', 'normal', '/repo', 'm', ?1, ?1)",
3420                params![now_rfc3339()],
3421            )
3422            .unwrap();
3423        let listed = store.tasks().list(50).expect("list");
3424        assert_eq!(
3425            listed.len(),
3426            1,
3427            "the poison row is skipped, the good row remains"
3428        );
3429        assert_eq!(listed[0].id, good.id);
3430        // The strict get() path still surfaces the poison row as an error.
3431        assert!(store.tasks().get("poison").is_err(), "get() stays strict");
3432        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3433    }
3434
3435    #[test]
3436    fn checkpoint_delete_removes_row() {
3437        // F23 (RC-F): the on-disk dir GC drops a checkpoint's DB row so list()
3438        // and the on-disk dirs stay in agreement.
3439        let path = temp_db("ckpt_delete");
3440        let store = RuntimeStore::open(&path).expect("open store");
3441        let ckpt = store
3442            .checkpoints()
3443            .create(NewCheckpoint {
3444                id: Some("ckpt-1".to_string()),
3445                task_id: None,
3446                project_path: "/repo".to_string(),
3447                snapshot_path: "/data/checkpoints/ckpt-1".to_string(),
3448                changed_files_json: "[]".to_string(),
3449                pending_action_json: None,
3450                approval_id: None,
3451            })
3452            .expect("create checkpoint");
3453        assert!(store.checkpoints().get(&ckpt.id).unwrap().is_some());
3454        assert!(store.checkpoints().delete(&ckpt.id).unwrap(), "row deleted");
3455        assert!(
3456            store.checkpoints().get(&ckpt.id).unwrap().is_none(),
3457            "row gone"
3458        );
3459        assert!(
3460            !store.checkpoints().delete(&ckpt.id).unwrap(),
3461            "second delete is a no-op"
3462        );
3463        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3464    }
3465
3466    #[test]
3467    fn list_for_session_caps_at_max_and_keeps_ascending_order() {
3468        // F24 (RC-F): a huge session is bounded — list_for_session returns at most
3469        // MAX_SESSION_MESSAGES, the most recent ones, in ascending id order.
3470        let path = temp_db("session_cap");
3471        let store = RuntimeStore::open(&path).expect("open store");
3472        let session = store
3473            .sessions()
3474            .upsert(NewSession {
3475                id: Some("big".to_string()),
3476                project_path: "/repo".to_string(),
3477                model_id: "m".to_string(),
3478                title: None,
3479                conversation_path: None,
3480                total_tokens: None,
3481            })
3482            .expect("session");
3483        let total = MAX_SESSION_MESSAGES + 10;
3484        let now = now_rfc3339();
3485        let tx = store.conn.unchecked_transaction().unwrap();
3486        for i in 0..total {
3487            tx.execute(
3488                "INSERT INTO messages (session_id, role, content_json, created_at)
3489                 VALUES (?1, 'user', ?2, ?3)",
3490                params![session.id, format!("{{\"n\":{i}}}"), now],
3491            )
3492            .unwrap();
3493        }
3494        tx.commit().unwrap();
3495        let listed = store
3496            .messages()
3497            .list_for_session(&session.id)
3498            .expect("list");
3499        assert_eq!(
3500            listed.len() as i64,
3501            MAX_SESSION_MESSAGES,
3502            "capped at the max"
3503        );
3504        assert!(
3505            listed.windows(2).all(|w| w[0].id < w[1].id),
3506            "ascending id order preserved across the capped tail"
3507        );
3508        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3509    }
3510}