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