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, new: NewToolRun) -> Result<ToolRunRecord> {
1443        let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
1444        self.conn.execute(
1445            "INSERT INTO tool_runs
1446             (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
1447             VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
1448            params![
1449                id,
1450                new.task_id,
1451                new.turn_id,
1452                new.call_id,
1453                new.tool_name,
1454                new.args_json,
1455                now_rfc3339(),
1456            ],
1457        )?;
1458        self.get(&id)?
1459            .context("tool run was inserted but could not be reloaded")
1460    }
1461
1462    pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
1463        let changed = self.conn.execute(
1464            "UPDATE tool_runs
1465             SET status = ?2, output_json = ?3, finished_at = ?4
1466             WHERE id = ?1",
1467            params![id, status, output_json, now_rfc3339()],
1468        )?;
1469        anyhow::ensure!(changed > 0, "tool run not found: {}", id);
1470        Ok(())
1471    }
1472
1473    pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
1474        self.conn
1475            .query_row(
1476                "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1477                        output_json, started_at, finished_at
1478                 FROM tool_runs WHERE id = ?1",
1479                [id],
1480                tool_run_from_row,
1481            )
1482            .optional()
1483            .map_err(Into::into)
1484    }
1485
1486    pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1487        let mut stmt = self.conn.prepare(
1488            "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1489                    output_json, started_at, finished_at
1490             FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
1491        )?;
1492        let rows = stmt.query_map([clamp_limit(limit)], tool_run_from_row)?;
1493        rows.collect::<rusqlite::Result<Vec<_>>>()
1494            .map_err(Into::into)
1495    }
1496}
1497
1498pub struct OutcomesRepo<'a> {
1499    conn: &'a Connection,
1500}
1501
1502impl OutcomesRepo<'_> {
1503    /// Record a verifiable outcome / reward signal for a trajectory. Append-only
1504    /// — the loop reads these; nothing mutates them after the fact.
1505    pub fn record(&self, new: NewOutcome) -> Result<OutcomeRecord> {
1506        let id = new.id.unwrap_or_else(|| fresh_id("outcome"));
1507        self.conn.execute(
1508            "INSERT INTO outcomes
1509             (id, task_id, tool_run_id, kind, label, reward, source, detail_json, created_at)
1510             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1511            params![
1512                id,
1513                new.task_id,
1514                new.tool_run_id,
1515                new.kind,
1516                new.label,
1517                new.reward,
1518                new.source,
1519                new.detail_json,
1520                now_rfc3339(),
1521            ],
1522        )?;
1523        self.get(&id)?
1524            .context("outcome was inserted but could not be reloaded")
1525    }
1526
1527    pub fn get(&self, id: &str) -> Result<Option<OutcomeRecord>> {
1528        self.conn
1529            .query_row(
1530                "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1531                        detail_json, created_at
1532                 FROM outcomes WHERE id = ?1",
1533                [id],
1534                outcome_from_row,
1535            )
1536            .optional()
1537            .map_err(Into::into)
1538    }
1539
1540    /// Every outcome recorded against one task, oldest first (the order the
1541    /// trajectory earned them).
1542    pub fn list_for_task(&self, task_id: &str) -> Result<Vec<OutcomeRecord>> {
1543        let mut stmt = self.conn.prepare(
1544            "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1545                    detail_json, created_at
1546             FROM outcomes WHERE task_id = ?1 ORDER BY created_at ASC",
1547        )?;
1548        let rows = stmt.query_map([task_id], outcome_from_row)?;
1549        rows.collect::<rusqlite::Result<Vec<_>>>()
1550            .map_err(Into::into)
1551    }
1552
1553    pub fn list(&self, limit: usize) -> Result<Vec<OutcomeRecord>> {
1554        let mut stmt = self.conn.prepare(
1555            "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1556                    detail_json, created_at
1557             FROM outcomes ORDER BY created_at DESC LIMIT ?1",
1558        )?;
1559        let rows = stmt.query_map([clamp_limit(limit)], outcome_from_row)?;
1560        rows.collect::<rusqlite::Result<Vec<_>>>()
1561            .map_err(Into::into)
1562    }
1563}
1564
1565fn outcome_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<OutcomeRecord> {
1566    Ok(OutcomeRecord {
1567        id: row.get(0)?,
1568        task_id: row.get(1)?,
1569        tool_run_id: row.get(2)?,
1570        kind: row.get(3)?,
1571        label: row.get(4)?,
1572        reward: row.get(5)?,
1573        source: row.get(6)?,
1574        detail_json: row.get(7)?,
1575        created_at: row.get(8)?,
1576    })
1577}
1578
1579pub struct ApprovalsRepo<'a> {
1580    conn: &'a Connection,
1581}
1582
1583impl ApprovalsRepo<'_> {
1584    pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
1585        let record = ApprovalRecord {
1586            id: fresh_id("approval"),
1587            task_id: new.task_id,
1588            proposed_action: new.proposed_action,
1589            risk_classification: new.risk_classification,
1590            policy_decision: new.policy_decision,
1591            user_decision: None,
1592            args_summary: new.args_summary,
1593            checkpoint_id: new.checkpoint_id,
1594            pending_action_json: new.pending_action_json,
1595            created_at: now_rfc3339(),
1596            decided_at: None,
1597            archived_at: None,
1598            archive_reason: None,
1599        };
1600        self.conn.execute(
1601            "INSERT INTO approvals
1602             (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
1603              args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
1604             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1605            params![
1606                record.id,
1607                record.task_id,
1608                record.proposed_action,
1609                record.risk_classification,
1610                record.policy_decision,
1611                record.user_decision,
1612                record.args_summary,
1613                record.checkpoint_id,
1614                record.pending_action_json,
1615                record.created_at,
1616                record.decided_at,
1617            ],
1618        )?;
1619        self.get(&record.id)?
1620            .context("approval was inserted but could not be reloaded")
1621    }
1622
1623    pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
1624        self.conn
1625            .query_row(
1626                "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1627                        user_decision, args_summary, checkpoint_id, pending_action_json,
1628                        created_at, decided_at, archived_at, archive_reason
1629                 FROM approvals WHERE id = ?1",
1630                [id],
1631                approval_from_row,
1632            )
1633            .optional()
1634            .map_err(Into::into)
1635    }
1636
1637    pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
1638        // Single-shot decision: only an undecided, un-archived approval can be
1639        // decided, so a denied approval cannot be resurrected as "approved".
1640        // `approval::approve_and_replay` runs the (un-rollback-able) replay
1641        // effect *before* calling `decide`, so the "approved" mark lands only
1642        // after the action ran: a crash mid-replay leaves the row undecided and
1643        // safely re-runnable, never "approved but never applied" (#62). Mirrors
1644        // the `archive` `WHERE archived_at IS NULL` idempotency pattern below.
1645        let changed = self.conn.execute(
1646            "UPDATE approvals
1647             SET user_decision = ?2, decided_at = ?3
1648             WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1649            params![id, user_decision, now_rfc3339()],
1650        )?;
1651        anyhow::ensure!(
1652            changed > 0,
1653            "approval {} cannot be decided (already decided, archived, or not found)",
1654            id
1655        );
1656        Ok(())
1657    }
1658
1659    /// Atomically claim an undecided approval for replay (#118). Sets
1660    /// `user_decision='approving'` only when it is currently NULL and
1661    /// un-archived, and reports whether THIS caller won the claim. Two concurrent
1662    /// `approve <id>` calls race this single UPDATE; exactly one sees
1663    /// `rows_affected == 1` and runs the un-rollback-able effect, the other sees
1664    /// `false` and bails — so the effect can't fire twice. A claim that crashes
1665    /// before finalizing is reset to NULL by the daemon's startup reconcile.
1666    pub fn claim(&self, id: &str) -> Result<bool> {
1667        let changed = self.conn.execute(
1668            "UPDATE approvals
1669             SET user_decision = 'approving'
1670             WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1671            params![id],
1672        )?;
1673        Ok(changed == 1)
1674    }
1675
1676    /// Release a claim taken by [`Self::claim`] back to undecided, so the action
1677    /// stays re-runnable after the replay effect failed.
1678    pub fn release_claim(&self, id: &str) -> Result<()> {
1679        self.conn.execute(
1680            "UPDATE approvals SET user_decision = NULL
1681             WHERE id = ?1 AND user_decision = 'approving'",
1682            params![id],
1683        )?;
1684        Ok(())
1685    }
1686
1687    /// Finalize a claimed approval's decision (the `approving` → terminal-value
1688    /// transition that [`Self::decide`]'s `WHERE user_decision IS NULL` can't make).
1689    pub fn finalize_claimed(&self, id: &str, user_decision: &str) -> Result<()> {
1690        let changed = self.conn.execute(
1691            "UPDATE approvals
1692             SET user_decision = ?2, decided_at = ?3
1693             WHERE id = ?1 AND user_decision = 'approving'",
1694            params![id, user_decision, now_rfc3339()],
1695        )?;
1696        anyhow::ensure!(changed > 0, "approval {} was not in the claimed state", id);
1697        Ok(())
1698    }
1699
1700    pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
1701        self.list_pending_with_archived(false)
1702    }
1703
1704    pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
1705        self.list_pending_with_archived(true)
1706    }
1707
1708    pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
1709        let mut stmt = self.conn.prepare(
1710            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1711                    user_decision, args_summary, checkpoint_id, pending_action_json,
1712                    created_at, decided_at, archived_at, archive_reason
1713             FROM approvals
1714             ORDER BY created_at DESC
1715             LIMIT ?1",
1716        )?;
1717        let rows = stmt.query_map([clamp_limit(limit)], approval_from_row)?;
1718        rows.collect::<rusqlite::Result<Vec<_>>>()
1719            .map_err(Into::into)
1720    }
1721
1722    fn list_pending_with_archived(&self, include_archived: bool) -> Result<Vec<ApprovalRecord>> {
1723        let archived_filter = if include_archived {
1724            ""
1725        } else {
1726            " AND archived_at IS NULL"
1727        };
1728        let mut stmt = self.conn.prepare(&format!(
1729            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1730                    user_decision, args_summary, checkpoint_id, pending_action_json,
1731                    created_at, decided_at, archived_at, archive_reason
1732             FROM approvals
1733             WHERE user_decision IS NULL{archived_filter}
1734             ORDER BY created_at DESC"
1735        ))?;
1736        let rows = stmt.query_map([], approval_from_row)?;
1737        rows.collect::<rusqlite::Result<Vec<_>>>()
1738            .map_err(Into::into)
1739    }
1740
1741    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1742        let archived_at = now_rfc3339();
1743        let mut changed = 0;
1744        for id in ids {
1745            changed += self.conn.execute(
1746                "UPDATE approvals
1747                 SET archived_at = COALESCE(archived_at, ?2),
1748                     archive_reason = COALESCE(archive_reason, ?3)
1749                 WHERE id = ?1 AND archived_at IS NULL",
1750                params![id, archived_at, reason],
1751            )?;
1752        }
1753        Ok(changed)
1754    }
1755
1756    pub fn count_archived(&self) -> Result<usize> {
1757        self.conn
1758            .query_row(
1759                "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
1760                [],
1761                |row| row.get::<_, i64>(0),
1762            )
1763            .map(|count| count as usize)
1764            .map_err(Into::into)
1765    }
1766}
1767
1768pub struct ProcessesRepo<'a> {
1769    conn: &'a Connection,
1770}
1771
1772impl ProcessesRepo<'_> {
1773    pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
1774        let now = now_rfc3339();
1775        let id = new.id.unwrap_or_else(|| fresh_id("process"));
1776        self.conn.execute(
1777            "INSERT INTO processes
1778             (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
1779             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1780             ON CONFLICT(id) DO UPDATE SET
1781                task_id = excluded.task_id,
1782                pid = excluded.pid,
1783                command = excluded.command,
1784                cwd = excluded.cwd,
1785                log_path = excluded.log_path,
1786                detected_url = excluded.detected_url,
1787                status = excluded.status,
1788                health = excluded.health,
1789                updated_at = excluded.updated_at",
1790            params![
1791                id,
1792                new.task_id,
1793                new.pid,
1794                new.command,
1795                new.cwd,
1796                new.log_path,
1797                new.detected_url,
1798                new.status.as_str(),
1799                new.health,
1800                now,
1801                now,
1802            ],
1803        )?;
1804        self.get(&id)?
1805            .context("process was upserted but could not be reloaded")
1806    }
1807
1808    pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
1809        self.conn
1810            .query_row(
1811                "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1812                        created_at, updated_at
1813                 FROM processes WHERE id = ?1",
1814                [id],
1815                process_from_row,
1816            )
1817            .optional()
1818            .map_err(Into::into)
1819    }
1820
1821    pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1822        let mut stmt = self.conn.prepare(
1823            "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1824                    created_at, updated_at
1825             FROM processes
1826             ORDER BY updated_at DESC
1827             LIMIT ?1",
1828        )?;
1829        // F19 (RC-E): skip-and-warn an undecodable row (e.g. a status enum a
1830        // different binary wrote) rather than blanking the whole processes panel.
1831        let rows = stmt.query_map([clamp_limit(limit)], process_from_row_opt)?;
1832        collect_tolerant(rows)
1833    }
1834}
1835
1836pub struct CheckpointsRepo<'a> {
1837    conn: &'a Connection,
1838}
1839
1840impl CheckpointsRepo<'_> {
1841    pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
1842        let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
1843        self.conn.execute(
1844            "INSERT INTO checkpoints
1845             (id, task_id, project_path, snapshot_path, changed_files_json,
1846              pending_action_json, approval_id, created_at, session_id, message_index)
1847             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1848            params![
1849                id,
1850                new.task_id,
1851                new.project_path,
1852                new.snapshot_path,
1853                new.changed_files_json,
1854                new.pending_action_json,
1855                new.approval_id,
1856                now_rfc3339(),
1857                new.session_id,
1858                new.message_index,
1859            ],
1860        )?;
1861        self.get(&id)?
1862            .context("checkpoint was inserted but could not be reloaded")
1863    }
1864
1865    pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
1866        self.conn
1867            .query_row(
1868                "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1869                        pending_action_json, approval_id, created_at, archived_at, archive_reason,
1870                        session_id, message_index
1871                 FROM checkpoints WHERE id = ?1",
1872                [id],
1873                checkpoint_from_row,
1874            )
1875            .optional()
1876            .map_err(Into::into)
1877    }
1878
1879    pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
1880        let changed = self.conn.execute(
1881            "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
1882            params![id, approval_id],
1883        )?;
1884        anyhow::ensure!(changed > 0, "checkpoint not found: {}", id);
1885        Ok(())
1886    }
1887
1888    /// Delete a checkpoint row outright. Returns whether a row was removed.
1889    ///
1890    /// F23 (RC-F): coordinates the on-disk checkpoint-dir GC
1891    /// ([`crate::checkpoint::gc_old_checkpoint_dirs`]) with the DB. The dir GC
1892    /// prunes by mtime regardless of archive state, while storage [`Self`] /
1893    /// `gc()` only removes ARCHIVED checkpoint rows — so a never-archived old
1894    /// checkpoint would lose its directory while its row survived, and a later
1895    /// `restore_checkpoint` would fail on the missing manifest. The dir GC now
1896    /// calls this so `list()` and the on-disk dirs stay in agreement.
1897    pub fn delete(&self, id: &str) -> Result<bool> {
1898        let changed = self
1899            .conn
1900            .execute("DELETE FROM checkpoints WHERE id = ?1", params![id])?;
1901        Ok(changed > 0)
1902    }
1903
1904    pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1905        self.list_with_archived(limit, false)
1906    }
1907
1908    pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1909        self.list_with_archived(limit, true)
1910    }
1911
1912    fn list_with_archived(
1913        &self,
1914        limit: usize,
1915        include_archived: bool,
1916    ) -> Result<Vec<CheckpointRecord>> {
1917        let archived_filter = if include_archived {
1918            ""
1919        } else {
1920            "WHERE archived_at IS NULL"
1921        };
1922        let mut stmt = self.conn.prepare(&format!(
1923            "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1924                    pending_action_json, approval_id, created_at, archived_at, archive_reason,
1925                    session_id, message_index
1926             FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
1927        ))?;
1928        let rows = stmt.query_map([clamp_limit(limit)], checkpoint_from_row)?;
1929        rows.collect::<rusqlite::Result<Vec<_>>>()
1930            .map_err(Into::into)
1931    }
1932
1933    /// Unarchived checkpoints of `session_id` anchored STRICTLY past
1934    /// `after_message_index`, oldest first. Strict `>` is the fork-boundary
1935    /// invariant: a fork at user-message index `k` keeps `messages[..k]`, and
1936    /// a checkpoint stamped `message_index == k` snapshotted state from
1937    /// BEFORE that user message existed — it belongs to the kept prefix, not
1938    /// the discarded timeline. Oldest-first because each checkpoint is a
1939    /// PRE-mutation snapshot: the oldest one past the cut holds the file
1940    /// state closest to the fork point.
1941    pub fn list_for_session(
1942        &self,
1943        session_id: &str,
1944        after_message_index: i64,
1945    ) -> Result<Vec<CheckpointRecord>> {
1946        let mut stmt = self.conn.prepare(
1947            "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1948                    pending_action_json, approval_id, created_at, archived_at, archive_reason,
1949                    session_id, message_index
1950             FROM checkpoints
1951             WHERE session_id = ?1 AND message_index > ?2 AND archived_at IS NULL
1952             ORDER BY created_at ASC",
1953        )?;
1954        let rows = stmt.query_map(
1955            params![session_id, after_message_index],
1956            checkpoint_from_row,
1957        )?;
1958        rows.collect::<rusqlite::Result<Vec<_>>>()
1959            .map_err(Into::into)
1960    }
1961
1962    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1963        let archived_at = now_rfc3339();
1964        let mut changed = 0;
1965        for id in ids {
1966            changed += self.conn.execute(
1967                "UPDATE checkpoints
1968                 SET archived_at = COALESCE(archived_at, ?2),
1969                     archive_reason = COALESCE(archive_reason, ?3)
1970                 WHERE id = ?1 AND archived_at IS NULL",
1971                params![id, archived_at, reason],
1972            )?;
1973        }
1974        Ok(changed)
1975    }
1976
1977    pub fn count_archived(&self) -> Result<usize> {
1978        self.conn
1979            .query_row(
1980                "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1981                [],
1982                |row| row.get::<_, i64>(0),
1983            )
1984            .map(|count| count as usize)
1985            .map_err(Into::into)
1986    }
1987}
1988
1989pub struct CompactionsRepo<'a> {
1990    conn: &'a Connection,
1991}
1992
1993impl CompactionsRepo<'_> {
1994    pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
1995        let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
1996        self.conn.execute(
1997            "INSERT INTO compactions
1998             (id, task_id, session_id, source_token_estimate, summary_token_count,
1999              preserved_turns, archive_path, verification_status, created_at)
2000             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2001             ON CONFLICT(id) DO UPDATE SET
2002                task_id = excluded.task_id,
2003                session_id = excluded.session_id,
2004                source_token_estimate = excluded.source_token_estimate,
2005                summary_token_count = excluded.summary_token_count,
2006                preserved_turns = excluded.preserved_turns,
2007                archive_path = excluded.archive_path,
2008                verification_status = excluded.verification_status",
2009            params![
2010                id,
2011                new.task_id,
2012                new.session_id,
2013                new.source_token_estimate,
2014                new.summary_token_count,
2015                new.preserved_turns,
2016                new.archive_path,
2017                new.verification_status,
2018                now_rfc3339(),
2019            ],
2020        )?;
2021        self.get(&id)?
2022            .context("compaction was inserted but could not be reloaded")
2023    }
2024
2025    pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
2026        self.conn
2027            .query_row(
2028                "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
2029                        preserved_turns, archive_path, verification_status, created_at
2030                 FROM compactions WHERE id = ?1",
2031                [id],
2032                compaction_from_row,
2033            )
2034            .optional()
2035            .map_err(Into::into)
2036    }
2037
2038    pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
2039        let mut stmt = self.conn.prepare(
2040            "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
2041                    preserved_turns, archive_path, verification_status, created_at
2042             FROM compactions ORDER BY created_at DESC LIMIT ?1",
2043        )?;
2044        let rows = stmt.query_map([clamp_limit(limit)], compaction_from_row)?;
2045        rows.collect::<rusqlite::Result<Vec<_>>>()
2046            .map_err(Into::into)
2047    }
2048}
2049
2050pub struct PluginsRepo<'a> {
2051    conn: &'a Connection,
2052}
2053
2054impl PluginsRepo<'_> {
2055    pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
2056        let now = now_rfc3339();
2057        let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
2058        self.conn.execute(
2059            "INSERT INTO plugin_installs
2060             (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
2061             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2062             ON CONFLICT(id) DO UPDATE SET
2063                name = excluded.name,
2064                source = excluded.source,
2065                version = excluded.version,
2066                enabled = excluded.enabled,
2067                manifest_json = excluded.manifest_json,
2068                updated_at = excluded.updated_at",
2069            params![
2070                id,
2071                new.name,
2072                new.source,
2073                new.version,
2074                if new.enabled { 1 } else { 0 },
2075                new.manifest_json,
2076                now,
2077                now,
2078            ],
2079        )?;
2080        self.get(&id)?
2081            .context("plugin install was inserted but could not be reloaded")
2082    }
2083
2084    pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
2085        self.conn
2086            .query_row(
2087                "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
2088                 FROM plugin_installs WHERE id = ?1",
2089                [id],
2090                plugin_from_row,
2091            )
2092            .optional()
2093            .map_err(Into::into)
2094    }
2095
2096    pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
2097        let mut stmt = self.conn.prepare(
2098            "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
2099             FROM plugin_installs ORDER BY name ASC",
2100        )?;
2101        let rows = stmt.query_map([], plugin_from_row)?;
2102        rows.collect::<rusqlite::Result<Vec<_>>>()
2103            .map_err(Into::into)
2104    }
2105
2106    pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
2107        self.conn.execute(
2108            "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
2109            params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
2110        )?;
2111        Ok(())
2112    }
2113}
2114
2115pub struct ProviderProbesRepo<'a> {
2116    conn: &'a Connection,
2117}
2118
2119impl ProviderProbesRepo<'_> {
2120    pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
2121        let now = now_rfc3339();
2122        let provider = new.provider;
2123        let model_id = new.model_id;
2124        let capability_key = new.capability_key;
2125        self.conn.execute(
2126            "INSERT INTO provider_probes
2127             (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
2128             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
2129             ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
2130                capability_value = excluded.capability_value,
2131                confidence = excluded.confidence,
2132                error = excluded.error,
2133                probed_at = excluded.probed_at",
2134            params![
2135                &provider,
2136                &model_id,
2137                &capability_key,
2138                new.capability_value,
2139                new.confidence,
2140                new.error,
2141                now,
2142            ],
2143        )?;
2144        self.get(&provider, &model_id, &capability_key)?
2145            .context("provider probe was inserted but could not be reloaded")
2146    }
2147
2148    pub fn get(
2149        &self,
2150        provider: &str,
2151        model_id: &str,
2152        capability_key: &str,
2153    ) -> Result<Option<ProviderProbeRecord>> {
2154        self.conn
2155            .query_row(
2156                "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
2157                 FROM provider_probes
2158                 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
2159                params![provider, model_id, capability_key],
2160                provider_probe_from_row,
2161            )
2162            .optional()
2163            .map_err(Into::into)
2164    }
2165
2166    pub fn list(
2167        &self,
2168        provider: Option<&str>,
2169        model_id: Option<&str>,
2170    ) -> Result<Vec<ProviderProbeRecord>> {
2171        let mut stmt = self.conn.prepare(
2172            "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
2173             FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
2174        )?;
2175        let rows = stmt.query_map([], provider_probe_from_row)?;
2176        let mut out = Vec::new();
2177        for row in rows {
2178            let probe = row?;
2179            if provider.is_some_and(|p| probe.provider != p) {
2180                continue;
2181            }
2182            if model_id.is_some_and(|m| probe.model_id != m) {
2183                continue;
2184            }
2185            out.push(probe);
2186        }
2187        Ok(out)
2188    }
2189}
2190
2191pub struct PairingTokensRepo<'a> {
2192    conn: &'a Connection,
2193}
2194
2195impl PairingTokensRepo<'_> {
2196    pub fn create(
2197        &self,
2198        token_hash: &str,
2199        label: Option<&str>,
2200        expires_at: Option<&str>,
2201    ) -> Result<PairingTokenRecord> {
2202        let id = fresh_id("pairing");
2203        self.conn.execute(
2204            "INSERT INTO pairing_tokens
2205                 (id, token_hash, label, enabled, created_at, last_used_at, expires_at)
2206             VALUES (?1, ?2, ?3, 1, ?4, NULL, ?5)",
2207            params![id, token_hash, label, now_rfc3339(), expires_at],
2208        )?;
2209        self.get(&id)?
2210            .context("pairing token was inserted but could not be reloaded")
2211    }
2212
2213    pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
2214        self.conn
2215            .query_row(
2216                "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2217                 FROM pairing_tokens WHERE id = ?1",
2218                [id],
2219                pairing_from_row,
2220            )
2221            .optional()
2222            .map_err(Into::into)
2223    }
2224
2225    /// Look up an enabled, unexpired pairing token by hash.
2226    ///
2227    /// The hash is **not** matched in SQL (`WHERE token_hash = ?`) — that is a
2228    /// DB-level equality on the secret and a theoretical timing channel.
2229    /// Instead we fetch the enabled, unexpired candidates (neither predicate is
2230    /// secret) and compare each hash in constant time. The candidate count is
2231    /// tiny and not secret. All candidates are scanned without early exit so the
2232    /// timing doesn't reveal which (if any) token matched.
2233    pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
2234        // Expiry is evaluated in Rust as a parsed instant (see `is_expired`),
2235        // not via a SQL `expires_at > ?` string compare. The skipped-because-
2236        // expired branch is on non-secret data; the hash itself is still matched
2237        // in constant time over every non-expired candidate with no early exit.
2238        let now = chrono::Utc::now();
2239        let mut stmt = self.conn.prepare(
2240            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2241             FROM pairing_tokens
2242             WHERE enabled = 1",
2243        )?;
2244        let candidates = stmt
2245            .query_map([], pairing_from_row)?
2246            .collect::<rusqlite::Result<Vec<_>>>()?;
2247        let mut found = None;
2248        for record in candidates {
2249            if is_expired(record.expires_at.as_deref(), now) {
2250                continue;
2251            }
2252            if ct_eq(record.token_hash.as_bytes(), token_hash.as_bytes()) {
2253                found = Some(record);
2254            }
2255        }
2256        Ok(found)
2257    }
2258
2259    pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
2260        let mut stmt = self.conn.prepare(
2261            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2262             FROM pairing_tokens ORDER BY created_at DESC",
2263        )?;
2264        let rows = stmt.query_map([], pairing_from_row)?;
2265        rows.collect::<rusqlite::Result<Vec<_>>>()
2266            .map_err(Into::into)
2267    }
2268
2269    /// Like [`list`](Self::list), but with `token_hash` blanked. Use for any
2270    /// surface that crosses a trust boundary — e.g. the daemon snapshot served
2271    /// over the local socket to same-UID processes. The hash is
2272    /// secret-equivalent (it's all `verify_token` compares against) and must
2273    /// not leave the store.
2274    pub fn list_redacted(&self) -> Result<Vec<PairingTokenRecord>> {
2275        Ok(self
2276            .list()?
2277            .into_iter()
2278            .map(|mut record| {
2279                record.token_hash = String::new();
2280                record
2281            })
2282            .collect())
2283    }
2284
2285    pub fn mark_used(&self, id: &str) -> Result<()> {
2286        self.conn.execute(
2287            "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
2288            params![id, now_rfc3339()],
2289        )?;
2290        Ok(())
2291    }
2292
2293    /// Revoke a token by disabling it. Returns `true` if a live token was
2294    /// revoked, `false` if it was already disabled or unknown.
2295    pub fn revoke(&self, id: &str) -> Result<bool> {
2296        let changed = self.conn.execute(
2297            "UPDATE pairing_tokens SET enabled = 0 WHERE id = ?1 AND enabled = 1",
2298            params![id],
2299        )?;
2300        Ok(changed > 0)
2301    }
2302}
2303
2304/// Add `column` to `table` if it is missing. Returns `true` iff the column was
2305/// just created (so the caller can run a one-time backfill).
2306///
2307/// SQL identifiers cannot be bound as `?` parameters, so `table`/`column`/
2308/// `definition` are interpolated. All call sites pass compile-time constants
2309/// today; the validation below makes that a hard invariant rather than a latent
2310/// injection footgun if a future caller ever threads in dynamic input.
2311fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<bool> {
2312    fn is_sql_identifier(s: &str) -> bool {
2313        let mut chars = s.chars();
2314        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
2315            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
2316    }
2317    const ALLOWED_DEFINITIONS: &[&str] = &["TEXT", "INTEGER", "REAL", "BLOB"];
2318    anyhow::ensure!(
2319        is_sql_identifier(table),
2320        "invalid table identifier: {table}"
2321    );
2322    anyhow::ensure!(
2323        is_sql_identifier(column),
2324        "invalid column identifier: {column}"
2325    );
2326    anyhow::ensure!(
2327        ALLOWED_DEFINITIONS.contains(&definition),
2328        "unsupported column definition: {definition}"
2329    );
2330
2331    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
2332    let mut rows = stmt.query([])?;
2333    while let Some(row) = rows.next()? {
2334        let name: String = row.get(1)?;
2335        if name == column {
2336            return Ok(false);
2337        }
2338    }
2339    // Tolerate a concurrent opener that added the column between our
2340    // `table_info` check and this ALTER. SQLite reports that as a "duplicate
2341    // column name" schema error (not SQLITE_BUSY, so `busy_timeout` can't retry
2342    // it); treat it as already-present rather than failing the whole store open.
2343    match conn.execute(
2344        &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
2345        [],
2346    ) {
2347        Ok(_) => Ok(true),
2348        Err(error) if error.to_string().contains("duplicate column") => Ok(false),
2349        Err(error) => Err(error.into()),
2350    }
2351}
2352
2353pub fn data_dir() -> Result<PathBuf> {
2354    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
2355        return Ok(proj_dirs.data_dir().to_path_buf());
2356    }
2357    let home = std::env::var("HOME")
2358        .or_else(|_| std::env::var("USERPROFILE"))
2359        .context("could not determine home directory")?;
2360    Ok(PathBuf::from(home).join(".local/share/mermaid"))
2361}
2362
2363fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
2364    Ok(SessionRecord {
2365        id: row.get("id")?,
2366        project_path: row.get("project_path")?,
2367        model_id: row.get("model_id")?,
2368        title: row.get("title")?,
2369        conversation_path: row.get("conversation_path")?,
2370        created_at: row.get("created_at")?,
2371        updated_at: row.get("updated_at")?,
2372        total_tokens: row.get("total_tokens")?,
2373    })
2374}
2375
2376fn message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRecord> {
2377    Ok(MessageRecord {
2378        id: row.get("id")?,
2379        session_id: row.get("session_id")?,
2380        role: row.get("role")?,
2381        content_json: row.get("content_json")?,
2382        created_at: row.get("created_at")?,
2383    })
2384}
2385
2386fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
2387    let status_raw: String = row.get("status")?;
2388    let priority_raw: String = row.get("priority")?;
2389    Ok(TaskRecord {
2390        id: row.get("id")?,
2391        title: row.get("title")?,
2392        status: TaskStatus::from_db(&status_raw)
2393            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2394        priority: TaskPriority::from_db(&priority_raw)
2395            .map_err(|e| enum_from_sql_error("priority", priority_raw, e))?,
2396        project_path: row.get("project_path")?,
2397        model_id: row.get("model_id")?,
2398        conversation_id: row.get("conversation_id")?,
2399        created_at: row.get("created_at")?,
2400        updated_at: row.get("updated_at")?,
2401        final_report: row.get("final_report")?,
2402        prompt: row.get("prompt")?,
2403    })
2404}
2405
2406fn process_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProcessRecord> {
2407    let status_raw: String = row.get("status")?;
2408    let pid: i64 = row.get("pid")?;
2409    Ok(ProcessRecord {
2410        id: row.get("id")?,
2411        task_id: row.get("task_id")?,
2412        pid: pid as u32,
2413        command: row.get("command")?,
2414        cwd: row.get("cwd")?,
2415        log_path: row.get("log_path")?,
2416        detected_url: row.get("detected_url")?,
2417        status: ProcessStatus::from_db(&status_raw)
2418            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2419        health: row.get("health")?,
2420        created_at: row.get("created_at")?,
2421        updated_at: row.get("updated_at")?,
2422    })
2423}
2424
2425fn tool_run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ToolRunRecord> {
2426    Ok(ToolRunRecord {
2427        id: row.get("id")?,
2428        task_id: row.get("task_id")?,
2429        turn_id: row.get("turn_id")?,
2430        call_id: row.get("call_id")?,
2431        tool_name: row.get("tool_name")?,
2432        status: row.get("status")?,
2433        args_json: row.get("args_json")?,
2434        output_json: row.get("output_json")?,
2435        started_at: row.get("started_at")?,
2436        finished_at: row.get("finished_at")?,
2437    })
2438}
2439
2440fn approval_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApprovalRecord> {
2441    Ok(ApprovalRecord {
2442        id: row.get("id")?,
2443        task_id: row.get("task_id")?,
2444        proposed_action: row.get("proposed_action")?,
2445        risk_classification: row.get("risk_classification")?,
2446        policy_decision: row.get("policy_decision")?,
2447        user_decision: row.get("user_decision")?,
2448        args_summary: row.get("args_summary")?,
2449        checkpoint_id: row.get("checkpoint_id")?,
2450        pending_action_json: row.get("pending_action_json")?,
2451        created_at: row.get("created_at")?,
2452        decided_at: row.get("decided_at")?,
2453        archived_at: row.get("archived_at")?,
2454        archive_reason: row.get("archive_reason")?,
2455    })
2456}
2457
2458fn checkpoint_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointRecord> {
2459    Ok(CheckpointRecord {
2460        id: row.get("id")?,
2461        task_id: row.get("task_id")?,
2462        project_path: row.get("project_path")?,
2463        snapshot_path: row.get("snapshot_path")?,
2464        changed_files_json: row.get("changed_files_json")?,
2465        pending_action_json: row.get("pending_action_json")?,
2466        approval_id: row.get("approval_id")?,
2467        created_at: row.get("created_at")?,
2468        archived_at: row.get("archived_at")?,
2469        archive_reason: row.get("archive_reason")?,
2470        session_id: row.get("session_id")?,
2471        message_index: row.get("message_index")?,
2472    })
2473}
2474
2475fn compaction_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CompactionRecord> {
2476    Ok(CompactionRecord {
2477        id: row.get("id")?,
2478        task_id: row.get("task_id")?,
2479        session_id: row.get("session_id")?,
2480        source_token_estimate: row.get("source_token_estimate")?,
2481        summary_token_count: row.get("summary_token_count")?,
2482        preserved_turns: row.get("preserved_turns")?,
2483        archive_path: row.get("archive_path")?,
2484        verification_status: row.get("verification_status")?,
2485        created_at: row.get("created_at")?,
2486    })
2487}
2488
2489fn plugin_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PluginInstallRecord> {
2490    let enabled: i64 = row.get("enabled")?;
2491    Ok(PluginInstallRecord {
2492        id: row.get("id")?,
2493        name: row.get("name")?,
2494        source: row.get("source")?,
2495        version: row.get("version")?,
2496        enabled: enabled != 0,
2497        manifest_json: row.get("manifest_json")?,
2498        installed_at: row.get("installed_at")?,
2499        updated_at: row.get("updated_at")?,
2500    })
2501}
2502
2503fn provider_probe_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProviderProbeRecord> {
2504    Ok(ProviderProbeRecord {
2505        provider: row.get("provider")?,
2506        model_id: row.get("model_id")?,
2507        capability_key: row.get("capability_key")?,
2508        capability_value: row.get("capability_value")?,
2509        confidence: row.get("confidence")?,
2510        error: row.get("error")?,
2511        probed_at: row.get("probed_at")?,
2512    })
2513}
2514
2515fn pairing_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PairingTokenRecord> {
2516    let enabled: i64 = row.get("enabled")?;
2517    Ok(PairingTokenRecord {
2518        id: row.get("id")?,
2519        token_hash: row.get("token_hash")?,
2520        label: row.get("label")?,
2521        enabled: enabled != 0,
2522        created_at: row.get("created_at")?,
2523        last_used_at: row.get("last_used_at")?,
2524        expires_at: row.get("expires_at")?,
2525    })
2526}
2527
2528fn task_event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskTimelineEvent> {
2529    Ok(TaskTimelineEvent {
2530        id: row.get("id")?,
2531        task_id: row.get("task_id")?,
2532        kind: row.get("kind")?,
2533        message: row.get("message")?,
2534        created_at: row.get("created_at")?,
2535    })
2536}
2537
2538/// Whether a row error is a per-row DECODE failure — a value a different binary
2539/// wrote that this build can't parse: an unknown enum
2540/// ([`rusqlite::Error::FromSqlConversionFailure`], how `task_from_row` /
2541/// `process_from_row` surface an unknown status) or a column type mismatch
2542/// ([`rusqlite::Error::InvalidColumnType`]). F19 (RC-E): the list/events paths
2543/// skip-and-warn on these so one poison row can't blank an entire panel, while a
2544/// genuine infrastructure error (a locked DB, a dropped column) still propagates.
2545fn is_row_decode_error(err: &rusqlite::Error) -> bool {
2546    matches!(
2547        err,
2548        rusqlite::Error::FromSqlConversionFailure(..) | rusqlite::Error::InvalidColumnType(..)
2549    )
2550}
2551
2552/// Tolerant [`task_from_row`]: `Ok(None)` (with a warning) for a row this build
2553/// can't decode, so [`TasksRepo::list`] skips it instead of failing the list.
2554fn task_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskRecord>> {
2555    match task_from_row(row) {
2556        Ok(record) => Ok(Some(record)),
2557        Err(err) if is_row_decode_error(&err) => {
2558            tracing::warn!(error = %err, "skipping task row this build can't decode (version skew?)");
2559            Ok(None)
2560        },
2561        Err(err) => Err(err),
2562    }
2563}
2564
2565/// Tolerant [`process_from_row`] — see [`task_from_row_opt`].
2566fn process_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<ProcessRecord>> {
2567    match process_from_row(row) {
2568        Ok(record) => Ok(Some(record)),
2569        Err(err) if is_row_decode_error(&err) => {
2570            tracing::warn!(error = %err, "skipping process row this build can't decode (version skew?)");
2571            Ok(None)
2572        },
2573        Err(err) => Err(err),
2574    }
2575}
2576
2577/// Tolerant [`task_event_from_row`] — see [`task_from_row_opt`].
2578fn task_event_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskTimelineEvent>> {
2579    match task_event_from_row(row) {
2580        Ok(record) => Ok(Some(record)),
2581        Err(err) if is_row_decode_error(&err) => {
2582            tracing::warn!(error = %err, "skipping task event row this build can't decode");
2583            Ok(None)
2584        },
2585        Err(err) => Err(err),
2586    }
2587}
2588
2589/// Collect rows from a tolerant decoder (one that yields `Ok(None)` for a
2590/// skipped poison row), dropping the `None`s and propagating any real error.
2591fn collect_tolerant<T>(rows: impl Iterator<Item = rusqlite::Result<Option<T>>>) -> Result<Vec<T>> {
2592    let mut out = Vec::new();
2593    for row in rows {
2594        if let Some(item) = row? {
2595            out.push(item);
2596        }
2597    }
2598    Ok(out)
2599}
2600
2601fn enum_from_sql_error(
2602    column: &'static str,
2603    value: String,
2604    source: UnknownRuntimeEnum,
2605) -> rusqlite::Error {
2606    let _ = value;
2607    rusqlite::Error::FromSqlConversionFailure(column_index(column), Type::Text, Box::new(source))
2608}
2609
2610fn column_index(column: &str) -> usize {
2611    match column {
2612        "status" => 2,
2613        "priority" => 3,
2614        _ => 0,
2615    }
2616}
2617
2618#[derive(Debug)]
2619struct UnknownRuntimeEnum {
2620    kind: &'static str,
2621    value: String,
2622}
2623
2624impl UnknownRuntimeEnum {
2625    fn new(kind: &'static str, value: &str) -> Self {
2626        Self {
2627            kind,
2628            value: value.to_string(),
2629        }
2630    }
2631}
2632
2633impl fmt::Display for UnknownRuntimeEnum {
2634    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2635        write!(f, "unknown {} value `{}`", self.kind, self.value)
2636    }
2637}
2638
2639impl std::error::Error for UnknownRuntimeEnum {}
2640
2641fn now_rfc3339() -> String {
2642    chrono::Utc::now().to_rfc3339()
2643}
2644
2645/// Constant-time byte-slice equality. Unlike `==` (or a SQL `=`), it never
2646/// short-circuits on the first differing byte, so it leaks no timing signal
2647/// about how much of a secret matched. Lengths are compared first; the length
2648/// of a token hash is fixed and not secret.
2649fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2650    if a.len() != b.len() {
2651        return false;
2652    }
2653    let mut diff = 0u8;
2654    for (x, y) in a.iter().zip(b.iter()) {
2655        diff |= x ^ y;
2656    }
2657    diff == 0
2658}
2659
2660/// Whether a pairing token's `expires_at` is in the past relative to `now`.
2661///
2662/// `None` (SQL `NULL`) means "never expires" — the documented `--ttl-days 0`
2663/// opt-out. A present-but-unparseable value fails closed (treated as expired).
2664/// Expiry is compared as a parsed instant rather than via a SQL `expires_at > ?`
2665/// string compare, which only orders correctly while every stored value is the
2666/// canonical `now_rfc3339()` shape (#64).
2667fn is_expired(expires_at: Option<&str>, now: chrono::DateTime<chrono::Utc>) -> bool {
2668    match expires_at {
2669        None => false,
2670        Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) {
2671            Ok(dt) => dt <= now,
2672            Err(_) => true,
2673        },
2674    }
2675}
2676
2677/// Upper bound on any `LIMIT` we bind. A caller-supplied `limit` (e.g. a daemon
2678/// request body's `limit`) can be a huge `u64` that, cast straight to `i64`,
2679/// wraps negative — and SQLite reads a negative `LIMIT` as *unbounded*, so the
2680/// query returns every row (#128). Clamp at the `usize` level before the cast.
2681const MAX_QUERY_LIMIT: usize = 10_000;
2682
2683fn clamp_limit(limit: usize) -> i64 {
2684    limit.min(MAX_QUERY_LIMIT) as i64
2685}
2686
2687/// Upper bound on the rows [`MessagesRepo::list_for_session`] returns (F24/RC-F).
2688/// A session transcript is unbounded and the daemon `session_messages` path loads
2689/// it whole into RAM; this caps the worst-case load at the most recent N messages
2690/// so one pathological session can't OOM the daemon. 5000 turns is far beyond any
2691/// real interactive session yet bounds memory.
2692const MAX_SESSION_MESSAGES: i64 = 5_000;
2693
2694pub(crate) fn fresh_id(prefix: &str) -> String {
2695    // In-process monotonic counter: two ids minted in the same nanosecond (a
2696    // coarse clock, or a clock stepping backward) can never be equal, so the
2697    // `ON CONFLICT(id) DO UPDATE` upserts can't silently overwrite an unrelated
2698    // row (#61). A per-process random salt removes the clock dependence so ids
2699    // minted across a daemon restart don't collide either (getrandom is already
2700    // a dependency — see `daemon.rs`).
2701    static SEQ: AtomicU64 = AtomicU64::new(0);
2702    static SALT: OnceLock<u64> = OnceLock::new();
2703    let salt = *SALT.get_or_init(|| {
2704        let mut bytes = [0u8; 8];
2705        // The monotonic counter alone still guarantees in-process uniqueness if
2706        // the RNG ever fails, so a best-effort fill is fine here.
2707        let _ = getrandom::fill(&mut bytes);
2708        u64::from_le_bytes(bytes)
2709    });
2710    let nanos = SystemTime::now()
2711        .duration_since(UNIX_EPOCH)
2712        .map(|d| d.as_nanos())
2713        .unwrap_or_default();
2714    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
2715    format!("{prefix}-{nanos:x}-{salt:x}-{seq:x}")
2716}
2717
2718/// Acquire an exclusive, auto-released advisory lock on `path` — a process
2719/// singleton guard for the daemon (#131). Returns the held `File` on success
2720/// (keep it alive to hold the lock), or `None` if another process already holds
2721/// it. `flock` releases automatically when the file is dropped OR the process
2722/// exits/crashes, so a dead holder never wedges the lock the way an `O_EXCL`
2723/// pidfile would. Holding it across the socket probe → unlink → bind closes that
2724/// TOCTOU: two daemons can't both decide a stale socket is theirs to rebind.
2725///
2726/// Unix-only: it backs the `#[cfg(unix)]` daemon singleton and relies on
2727/// `flock`, which `rustix` exposes only on Unix targets.
2728#[cfg(unix)]
2729pub fn try_exclusive_lock(path: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
2730    use rustix::fs::{FlockOperation, flock};
2731    // A lockfile's content is irrelevant — only the flock matters — so don't
2732    // truncate (avoids a needless write and any truncate/lock ordering race).
2733    let file = std::fs::OpenOptions::new()
2734        .create(true)
2735        .write(true)
2736        .truncate(false)
2737        .open(path)?;
2738    match flock(&file, FlockOperation::NonBlockingLockExclusive) {
2739        Ok(()) => Ok(Some(file)),
2740        Err(rustix::io::Errno::WOULDBLOCK) => Ok(None),
2741        Err(e) => Err(e.into()),
2742    }
2743}
2744
2745#[cfg(test)]
2746mod tests {
2747    use super::*;
2748
2749    #[test]
2750    fn open_enables_wal_and_busy_timeout() {
2751        // H19: every connection must use WAL so daemon/CLI/effect writers
2752        // don't hit a hard SQLITE_BUSY.
2753        let path = temp_db("wal_check");
2754        let store = RuntimeStore::open(&path).expect("open");
2755        let mode: String = store
2756            .conn
2757            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2758            .expect("journal_mode pragma");
2759        assert_eq!(mode.to_lowercase(), "wal");
2760    }
2761
2762    fn temp_db(name: &str) -> PathBuf {
2763        let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{}", name));
2764        let _ = std::fs::remove_dir_all(&dir);
2765        std::fs::create_dir_all(&dir).expect("create temp dir");
2766        dir.join("runtime.sqlite3")
2767    }
2768
2769    #[test]
2770    fn outcomes_round_trip_and_list_for_task() {
2771        let path = temp_db("outcomes");
2772        let store = RuntimeStore::open(&path).expect("open store");
2773        let task = store
2774            .tasks()
2775            .create(NewTask::new("t", "/tmp/p", "m"))
2776            .expect("create task");
2777
2778        let first = store
2779            .outcomes()
2780            .record(NewOutcome {
2781                id: None,
2782                task_id: Some(task.id.clone()),
2783                tool_run_id: None,
2784                kind: "task_terminal".to_string(),
2785                label: OUTCOME_LABEL_SUCCESS.to_string(),
2786                reward: Some(1.0),
2787                source: OUTCOME_SOURCE_SYSTEM.to_string(),
2788                detail_json: None,
2789            })
2790            .expect("record first");
2791        let second = store
2792            .outcomes()
2793            .record(NewOutcome {
2794                id: None,
2795                task_id: Some(task.id.clone()),
2796                tool_run_id: None,
2797                kind: "preference".to_string(),
2798                label: OUTCOME_LABEL_ACCEPTED.to_string(),
2799                reward: None,
2800                source: OUTCOME_SOURCE_USER.to_string(),
2801                detail_json: Some("{\"chosen\":\"a\",\"rejected\":\"b\"}".to_string()),
2802            })
2803            .expect("record second");
2804
2805        // get() round-trips every field, including the nullable reward and the
2806        // structured detail payload.
2807        assert_eq!(
2808            store.outcomes().get(&first.id).expect("get").as_ref(),
2809            Some(&first)
2810        );
2811        assert_eq!(first.reward, Some(1.0));
2812        assert_eq!(second.reward, None);
2813        assert_eq!(second.source, OUTCOME_SOURCE_USER);
2814        assert!(second.detail_json.as_deref().unwrap().contains("chosen"));
2815
2816        // Both attach to the task. Assert as a set — two records created within
2817        // the same coarse clock tick can share a `created_at`, so the ASC order
2818        // between them isn't something to pin a test on.
2819        let for_task = store
2820            .outcomes()
2821            .list_for_task(&task.id)
2822            .expect("list_for_task");
2823        assert_eq!(for_task.len(), 2);
2824        let ids: std::collections::HashSet<&str> = for_task.iter().map(|o| o.id.as_str()).collect();
2825        assert!(ids.contains(first.id.as_str()));
2826        assert!(ids.contains(second.id.as_str()));
2827
2828        // The global list sees them too.
2829        assert_eq!(store.outcomes().list(10).expect("list").len(), 2);
2830        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2831    }
2832
2833    #[test]
2834    fn claim_next_queued_orders_by_priority_then_fifo_and_skips_unclaimable() {
2835        let path = temp_db("claim_queue");
2836        let store = RuntimeStore::open(&path).expect("open store");
2837
2838        // Unclaimable rows: not daemon-owned; daemon-owned but prompt-less
2839        // (metadata-only); daemon-owned with prompt but already running.
2840        store
2841            .tasks()
2842            .create(NewTask::new("cli", "/p", "m").with_prompt("x"))
2843            .expect("cli task");
2844        store
2845            .tasks()
2846            .create(NewTask::new("meta", "/p", "m").daemon_owned())
2847            .expect("meta task");
2848        let busy = store
2849            .tasks()
2850            .create(
2851                NewTask::new("busy", "/p", "m")
2852                    .daemon_owned()
2853                    .with_prompt("x"),
2854            )
2855            .expect("busy task");
2856        store
2857            .tasks()
2858            .update_status(&busy.id, TaskStatus::Running, None)
2859            .expect("mark busy running");
2860
2861        let normal_first = store
2862            .tasks()
2863            .create(
2864                NewTask::new("n1", "/p", "m")
2865                    .daemon_owned()
2866                    .with_prompt("p1"),
2867            )
2868            .expect("n1");
2869        let low = store
2870            .tasks()
2871            .create(
2872                NewTask::new("l1", "/p", "m")
2873                    .daemon_owned()
2874                    .with_prompt("p2")
2875                    .with_priority(TaskPriority::Low),
2876            )
2877            .expect("l1");
2878        let high = store
2879            .tasks()
2880            .create(
2881                NewTask::new("h1", "/p", "m")
2882                    .daemon_owned()
2883                    .with_prompt("p-high")
2884                    .with_priority(TaskPriority::High),
2885            )
2886            .expect("h1");
2887        let normal_second = store
2888            .tasks()
2889            .create(
2890                NewTask::new("n2", "/p", "m")
2891                    .daemon_owned()
2892                    .with_prompt("p3"),
2893            )
2894            .expect("n2");
2895
2896        // High first (despite being enqueued after the normals), then the two
2897        // normals FIFO, then low; each claim flips the row to Running and
2898        // returns the persisted prompt.
2899        let c1 = store.tasks().claim_next_queued().expect("claim 1").unwrap();
2900        assert_eq!(c1.id, high.id);
2901        assert_eq!(c1.status, TaskStatus::Running);
2902        assert_eq!(c1.prompt.as_deref(), Some("p-high"));
2903        let c2 = store.tasks().claim_next_queued().expect("claim 2").unwrap();
2904        assert_eq!(c2.id, normal_first.id);
2905        let c3 = store.tasks().claim_next_queued().expect("claim 3").unwrap();
2906        assert_eq!(c3.id, normal_second.id);
2907        let c4 = store.tasks().claim_next_queued().expect("claim 4").unwrap();
2908        assert_eq!(c4.id, low.id);
2909        // Queue drained: nothing claimable remains (the unclaimable trio stays).
2910        assert!(
2911            store
2912                .tasks()
2913                .claim_next_queued()
2914                .expect("claim 5")
2915                .is_none()
2916        );
2917        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2918    }
2919
2920    #[test]
2921    fn outcome_allows_null_task_and_tool_run() {
2922        // A free-floating outcome (no task/tool_run) is valid — task_id is
2923        // nullable with ON DELETE SET NULL, so the loop never loses a signal to
2924        // a deleted subject.
2925        let path = temp_db("outcomes_null");
2926        let store = RuntimeStore::open(&path).expect("open store");
2927        let rec = store
2928            .outcomes()
2929            .record(NewOutcome {
2930                id: None,
2931                task_id: None,
2932                tool_run_id: None,
2933                kind: "build".to_string(),
2934                label: OUTCOME_LABEL_FAILURE.to_string(),
2935                reward: Some(-1.0),
2936                source: OUTCOME_SOURCE_VERIFIER.to_string(),
2937                detail_json: None,
2938            })
2939            .expect("record");
2940        assert_eq!(rec.task_id, None);
2941        assert_eq!(rec.tool_run_id, None);
2942        assert_eq!(store.outcomes().list(10).expect("list").len(), 1);
2943        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2944    }
2945
2946    #[test]
2947    fn initializes_runtime_schema() {
2948        let path = temp_db("schema");
2949        let store = RuntimeStore::open(&path).expect("open store");
2950        assert_eq!(store.path(), path.as_path());
2951        let version: i32 = store
2952            .conn
2953            .query_row("PRAGMA user_version", [], |row| row.get(0))
2954            .unwrap();
2955        assert_eq!(version, SCHEMA_VERSION);
2956        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2957    }
2958
2959    #[test]
2960    fn rejects_newer_schema_version() {
2961        // Forward-compat gate: a DB stamped with a newer schema must be
2962        // refused, not silently down-labeled and operated on (RC-5).
2963        let path = temp_db("newer_schema");
2964        {
2965            let store = RuntimeStore::open(&path).expect("first open");
2966            store
2967                .conn
2968                .execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
2969                .expect("bump version");
2970        }
2971        // `RuntimeStore` isn't `Debug`, so match rather than `expect_err`.
2972        let err = match RuntimeStore::open(&path) {
2973            Ok(_) => panic!("must refuse a newer DB"),
2974            Err(e) => e,
2975        };
2976        assert!(
2977            err.to_string().contains("newer than this build"),
2978            "unexpected error: {err}"
2979        );
2980        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2981    }
2982
2983    #[test]
2984    fn checkpoint_anchor_round_trips_and_list_for_session_is_strict() {
2985        let path = temp_db("checkpoint_anchor");
2986        let store = RuntimeStore::open(&path).expect("open store");
2987        for (id, idx) in [("cp-a", 3_i64), ("cp-b", 5), ("cp-c", 9)] {
2988            store
2989                .checkpoints()
2990                .create(NewCheckpoint {
2991                    id: Some(id.to_string()),
2992                    task_id: None,
2993                    project_path: "/tmp/p".to_string(),
2994                    snapshot_path: format!("/data/checkpoints/{id}"),
2995                    changed_files_json: "[]".to_string(),
2996                    pending_action_json: None,
2997                    approval_id: None,
2998                    session_id: Some("sess-1".to_string()),
2999                    message_index: Some(idx),
3000                })
3001                .expect("create checkpoint");
3002        }
3003        // Unanchored + other-session rows never surface.
3004        store
3005            .checkpoints()
3006            .create(NewCheckpoint {
3007                id: Some("cp-unanchored".to_string()),
3008                task_id: None,
3009                project_path: "/tmp/p".to_string(),
3010                snapshot_path: "/x".to_string(),
3011                changed_files_json: "[]".to_string(),
3012                pending_action_json: None,
3013                approval_id: None,
3014                session_id: None,
3015                message_index: None,
3016            })
3017            .expect("create unanchored");
3018
3019        let got = store.checkpoints().get("cp-a").unwrap().unwrap();
3020        assert_eq!(got.session_id.as_deref(), Some("sess-1"));
3021        assert_eq!(got.message_index, Some(3));
3022
3023        // STRICT boundary: fork at k=3 keeps messages[..3]; cp-a (index 3)
3024        // snapshotted state from BEFORE user message 3 existed — kept prefix.
3025        let past = store
3026            .checkpoints()
3027            .list_for_session("sess-1", 3)
3028            .expect("list_for_session");
3029        let ids: Vec<&str> = past.iter().map(|c| c.id.as_str()).collect();
3030        assert_eq!(ids, vec!["cp-b", "cp-c"], "strict > and oldest-first");
3031
3032        assert!(
3033            store
3034                .checkpoints()
3035                .list_for_session("sess-other", 0)
3036                .unwrap()
3037                .is_empty()
3038        );
3039        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3040    }
3041
3042    #[test]
3043    fn v5_database_upgrades_with_null_checkpoint_anchors() {
3044        // A DB created by the previous build (schema v5, no anchor columns)
3045        // must open cleanly, gain the columns, and load old rows as None.
3046        let path = temp_db("v5_upgrade");
3047        {
3048            let conn = Connection::open(&path).expect("raw open");
3049            conn.execute_batch(
3050                r#"
3051                CREATE TABLE checkpoints (
3052                    id TEXT PRIMARY KEY,
3053                    task_id TEXT,
3054                    project_path TEXT NOT NULL,
3055                    snapshot_path TEXT NOT NULL,
3056                    changed_files_json TEXT NOT NULL,
3057                    pending_action_json TEXT,
3058                    approval_id TEXT,
3059                    created_at TEXT NOT NULL,
3060                    archived_at TEXT,
3061                    archive_reason TEXT
3062                );
3063                INSERT INTO checkpoints
3064                    (id, task_id, project_path, snapshot_path, changed_files_json, created_at)
3065                    VALUES ('old-cp', NULL, '/tmp/p', '/snap', '[]', '2026-01-01T00:00:00Z');
3066                PRAGMA user_version = 5;
3067                "#,
3068            )
3069            .expect("seed v5 schema");
3070        }
3071        let store = RuntimeStore::open(&path).expect("upgrade open");
3072        let old = store.checkpoints().get("old-cp").unwrap().unwrap();
3073        assert_eq!(old.session_id, None);
3074        assert_eq!(old.message_index, None);
3075        let version: i32 = store
3076            .conn
3077            .query_row("PRAGMA user_version", [], |r| r.get(0))
3078            .unwrap();
3079        assert_eq!(version, SCHEMA_VERSION);
3080        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3081    }
3082
3083    #[test]
3084    fn init_schema_is_idempotent_across_opens() {
3085        // Re-opening the same DB re-runs `init_schema`; it must succeed (the
3086        // create script and `ensure_column` are idempotent) and keep the
3087        // version stamped.
3088        let path = temp_db("idempotent_schema");
3089        let _ = RuntimeStore::open(&path).expect("first open");
3090        let store = RuntimeStore::open(&path).expect("second open must succeed");
3091        let version: i32 = store
3092            .conn
3093            .query_row("PRAGMA user_version", [], |r| r.get(0))
3094            .unwrap();
3095        assert_eq!(version, SCHEMA_VERSION);
3096        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3097    }
3098
3099    fn explain_query_plan(conn: &Connection, sql: &str) -> String {
3100        let mut stmt = conn
3101            .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
3102            .expect("prepare EXPLAIN QUERY PLAN");
3103        // Column 3 of an EQP row is the human-readable `detail` (e.g.
3104        // "SEARCH approvals USING INDEX idx_approvals_pending ...").
3105        let rows = stmt
3106            .query_map([], |row| row.get::<_, String>(3))
3107            .expect("eqp query")
3108            .collect::<rusqlite::Result<Vec<String>>>()
3109            .expect("eqp rows");
3110        rows.join("\n")
3111    }
3112
3113    #[test]
3114    fn pending_and_reconcile_scans_use_indexes() {
3115        // F75: the pending-approval scan and the reconcile scan must hit their
3116        // covering indexes rather than full-table scans.
3117        let path = temp_db("scan_indexes");
3118        let store = RuntimeStore::open(&path).expect("open");
3119
3120        let index_count: i64 = store
3121            .conn
3122            .query_row(
3123                "SELECT COUNT(*) FROM sqlite_master
3124                 WHERE type = 'index'
3125                   AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
3126                [],
3127                |r| r.get(0),
3128            )
3129            .unwrap();
3130        assert_eq!(index_count, 2, "F75 indexes must be created");
3131
3132        // `list_pending`'s scan must use the partial pending index (it also serves
3133        // the ORDER BY created_at, so no separate sort).
3134        let plan = explain_query_plan(
3135            &store.conn,
3136            "SELECT id FROM approvals WHERE user_decision IS NULL ORDER BY created_at DESC",
3137        );
3138        assert!(
3139            plan.contains("idx_approvals_pending"),
3140            "pending scan must use idx_approvals_pending; plan was:\n{plan}"
3141        );
3142
3143        // `reconcile_after_restart`'s scan must use the (status, owner_kind) index.
3144        let plan = explain_query_plan(
3145            &store.conn,
3146            "SELECT id FROM tasks WHERE status = 'running' AND owner_kind = 'daemon'",
3147        );
3148        assert!(
3149            plan.contains("idx_tasks_status_owner"),
3150            "reconcile scan must use idx_tasks_status_owner; plan was:\n{plan}"
3151        );
3152
3153        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3154    }
3155
3156    #[test]
3157    fn upgrades_from_v2_to_current_and_adds_indexes() {
3158        // F75/F76: a DB stamped at the previous schema version must migrate forward
3159        // on the next open — re-run the idempotent baseline, pick up the F75
3160        // indexes, and stamp the current version — exercising the per-version
3161        // dispatch (`from_version = 2` runs the v3 step).
3162        let path = temp_db("upgrade_v2");
3163        {
3164            let store = RuntimeStore::open(&path).expect("first open");
3165            // Simulate an older v2 DB: drop the new indexes and roll the stamp back.
3166            store
3167                .conn
3168                .execute_batch(
3169                    "DROP INDEX IF EXISTS idx_approvals_pending;
3170                     DROP INDEX IF EXISTS idx_tasks_status_owner;
3171                     PRAGMA user_version = 2;",
3172                )
3173                .expect("downgrade to v2");
3174        }
3175        let store = RuntimeStore::open(&path).expect("reopen must migrate forward");
3176        let version: i32 = store
3177            .conn
3178            .query_row("PRAGMA user_version", [], |r| r.get(0))
3179            .unwrap();
3180        assert_eq!(version, SCHEMA_VERSION);
3181        let index_count: i64 = store
3182            .conn
3183            .query_row(
3184                "SELECT COUNT(*) FROM sqlite_master
3185                 WHERE type = 'index'
3186                   AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
3187                [],
3188                |r| r.get(0),
3189            )
3190            .unwrap();
3191        assert_eq!(
3192            index_count, 2,
3193            "forward migration must recreate the F75 indexes"
3194        );
3195        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3196    }
3197
3198    #[test]
3199    fn task_create_commits_task_and_event_atomically() {
3200        // The task row and its `task_created` event commit in one transaction.
3201        let path = temp_db("task_txn");
3202        let store = RuntimeStore::open(&path).expect("open");
3203        let task = store
3204            .tasks()
3205            .create(NewTask::new("do a thing", "/repo", "anthropic/claude"))
3206            .expect("create task");
3207        let events = store.tasks().events(&task.id).expect("events");
3208        assert!(
3209            events.iter().any(|e| e.kind == "task_created"),
3210            "the task_created event must commit with the task row"
3211        );
3212        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3213    }
3214
3215    #[test]
3216    fn task_lifecycle_round_trips() {
3217        let path = temp_db("task");
3218        let store = RuntimeStore::open(&path).expect("open store");
3219        let session = store
3220            .sessions()
3221            .upsert(NewSession {
3222                id: Some("session-1".to_string()),
3223                project_path: "/repo".to_string(),
3224                model_id: "anthropic/claude".to_string(),
3225                title: Some("Run tests".to_string()),
3226                conversation_path: Some("/repo/.mermaid/session.json".to_string()),
3227                total_tokens: Some(42),
3228            })
3229            .expect("upsert session");
3230        assert_eq!(session.id, "session-1");
3231        let message = store
3232            .messages()
3233            .add(NewMessage {
3234                session_id: session.id.clone(),
3235                role: "user".to_string(),
3236                content_json: "{\"text\":\"hi\"}".to_string(),
3237            })
3238            .expect("add message");
3239        assert_eq!(message.role, "user");
3240        assert_eq!(
3241            store
3242                .messages()
3243                .list_for_session(&session.id)
3244                .unwrap()
3245                .len(),
3246            1
3247        );
3248
3249        let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
3250        new.priority = TaskPriority::High;
3251        let task = store.tasks().create(new).expect("create task");
3252
3253        assert_eq!(task.status, TaskStatus::Queued);
3254        assert_eq!(task.priority, TaskPriority::High);
3255
3256        store
3257            .tasks()
3258            .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
3259            .expect("update task");
3260        let loaded = store.tasks().get(&task.id).unwrap().unwrap();
3261        assert_eq!(loaded.status, TaskStatus::Completed);
3262        assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
3263
3264        let events = store.tasks().events(&task.id).expect("events");
3265        assert_eq!(events.len(), 2);
3266        assert_eq!(events[0].kind, "task_created");
3267        assert_eq!(events[1].kind, "status_changed");
3268        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3269    }
3270
3271    #[test]
3272    fn approval_and_process_records_round_trip() {
3273        let path = temp_db("approval_process");
3274        let store = RuntimeStore::open(&path).expect("open store");
3275        let task = store
3276            .tasks()
3277            .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
3278            .expect("create task");
3279
3280        let approval = store
3281            .approvals()
3282            .create(NewApproval {
3283                task_id: Some(task.id.clone()),
3284                proposed_action: "write_file src/lib.rs".to_string(),
3285                risk_classification: "file_mutation".to_string(),
3286                policy_decision: "ask".to_string(),
3287                args_summary: Some("src/lib.rs".to_string()),
3288                checkpoint_id: Some("checkpoint-1".to_string()),
3289                pending_action_json: Some(
3290                    "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
3291                ),
3292            })
3293            .expect("create approval");
3294        store
3295            .approvals()
3296            .decide(&approval.id, "approved")
3297            .expect("decide approval");
3298        let approval = store.approvals().get(&approval.id).unwrap().unwrap();
3299        assert_eq!(approval.user_decision.as_deref(), Some("approved"));
3300        assert!(approval.pending_action_json.is_some());
3301
3302        let tool_run = store
3303            .tool_runs()
3304            .start(NewToolRun {
3305                id: Some("toolrun-1".to_string()),
3306                task_id: Some(task.id.clone()),
3307                turn_id: Some("turn-1".to_string()),
3308                call_id: Some("call-1".to_string()),
3309                tool_name: "write_file".to_string(),
3310                args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
3311            })
3312            .expect("start tool run");
3313        assert_eq!(tool_run.status, "running");
3314        store
3315            .tool_runs()
3316            .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
3317            .expect("finish tool run");
3318        let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
3319        assert_eq!(tool_run.status, "success");
3320        assert!(tool_run.finished_at.is_some());
3321
3322        let process = store
3323            .processes()
3324            .upsert(NewProcess {
3325                id: Some("proc-1".to_string()),
3326                task_id: Some(task.id),
3327                pid: 123,
3328                command: "npm run dev".to_string(),
3329                cwd: Some("/repo".to_string()),
3330                log_path: Some("/tmp/mermaid.log".to_string()),
3331                detected_url: Some("http://127.0.0.1:5173".to_string()),
3332                status: ProcessStatus::Running,
3333                health: Some("ready".to_string()),
3334            })
3335            .expect("upsert process");
3336        assert_eq!(process.status, ProcessStatus::Running);
3337        assert_eq!(store.processes().list(10).unwrap().len(), 1);
3338        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3339    }
3340
3341    #[test]
3342    fn approval_decide_is_single_shot() {
3343        let path = temp_db("approval_decide_guard");
3344        let store = RuntimeStore::open(&path).expect("open store");
3345        let make = |action: &str| {
3346            store
3347                .approvals()
3348                .create(NewApproval {
3349                    task_id: None,
3350                    proposed_action: action.to_string(),
3351                    risk_classification: "file_mutation".to_string(),
3352                    policy_decision: "ask".to_string(),
3353                    args_summary: None,
3354                    checkpoint_id: None,
3355                    pending_action_json: None,
3356                })
3357                .expect("create approval")
3358        };
3359
3360        // A second decision on an already-decided approval is rejected — this
3361        // is what stops a stored action from being replayed N times.
3362        let a = make("write_file a");
3363        store
3364            .approvals()
3365            .decide(&a.id, "approved")
3366            .expect("first decide");
3367        assert!(
3368            store.approvals().decide(&a.id, "approved").is_err(),
3369            "re-approving an approved approval must be rejected"
3370        );
3371
3372        // A denied approval cannot be resurrected as approved.
3373        let b = make("write_file b");
3374        store.approvals().decide(&b.id, "denied").expect("deny");
3375        assert!(
3376            store.approvals().decide(&b.id, "approved").is_err(),
3377            "a denied approval must not be re-decidable as approved"
3378        );
3379        let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
3380        assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
3381
3382        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3383    }
3384
3385    #[test]
3386    fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
3387        let path = temp_db("archive_visibility");
3388        let store = RuntimeStore::open(&path).expect("open store");
3389
3390        let approval = store
3391            .approvals()
3392            .create(NewApproval {
3393                task_id: None,
3394                proposed_action: "restore replay: write_file".to_string(),
3395                risk_classification: "restored_action".to_string(),
3396                policy_decision: "ask".to_string(),
3397                args_summary: None,
3398                checkpoint_id: Some("checkpoint-1".to_string()),
3399                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3400            })
3401            .expect("create approval");
3402        let checkpoint = store
3403            .checkpoints()
3404            .create(NewCheckpoint {
3405                id: Some("checkpoint-1".to_string()),
3406                task_id: None,
3407                project_path: "/tmp/mermaid_checkpoint_test".to_string(),
3408                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
3409                changed_files_json: "[]".to_string(),
3410                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3411                approval_id: Some(approval.id.clone()),
3412                session_id: None,
3413                message_index: None,
3414            })
3415            .expect("create checkpoint");
3416
3417        assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
3418        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
3419        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
3420        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
3421        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
3422
3423        assert_eq!(
3424            store
3425                .approvals()
3426                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
3427                .unwrap(),
3428            1
3429        );
3430        assert_eq!(
3431            store
3432                .checkpoints()
3433                .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
3434                .unwrap(),
3435            1
3436        );
3437        assert_eq!(
3438            store
3439                .approvals()
3440                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
3441                .unwrap(),
3442            0
3443        );
3444        assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
3445        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
3446        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
3447        assert_eq!(store.approvals().count_archived().unwrap(), 1);
3448        assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
3449        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
3450        assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
3451
3452        let archived = store.approvals().get(&approval.id).unwrap().unwrap();
3453        assert!(archived.archived_at.is_some());
3454        assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
3455        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3456    }
3457
3458    #[test]
3459    fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
3460        let path = temp_db("everything_else");
3461        let store = RuntimeStore::open(&path).expect("open store");
3462
3463        let checkpoint = store
3464            .checkpoints()
3465            .create(NewCheckpoint {
3466                id: Some("checkpoint-1".to_string()),
3467                task_id: None,
3468                project_path: "/repo".to_string(),
3469                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
3470                changed_files_json: "[\"src/lib.rs\"]".to_string(),
3471                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3472                approval_id: None,
3473                session_id: None,
3474                message_index: None,
3475            })
3476            .expect("create checkpoint");
3477        assert_eq!(checkpoint.id, "checkpoint-1");
3478        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
3479
3480        let compaction = store
3481            .compactions()
3482            .create(NewCompaction {
3483                id: Some("compaction-1".to_string()),
3484                task_id: None,
3485                session_id: Some("session-1".to_string()),
3486                source_token_estimate: Some(10_000),
3487                summary_token_count: Some(800),
3488                preserved_turns: Some(6),
3489                archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
3490                verification_status: Some("verified".to_string()),
3491            })
3492            .expect("create compaction");
3493        assert_eq!(compaction.summary_token_count, Some(800));
3494        assert_eq!(store.compactions().list(10).unwrap().len(), 1);
3495
3496        let plugin = store
3497            .plugins()
3498            .install(NewPluginInstall {
3499                id: Some("plugin-1".to_string()),
3500                name: "example".to_string(),
3501                source: "local".to_string(),
3502                version: Some("0.1.0".to_string()),
3503                enabled: true,
3504                manifest_json: "{\"name\":\"example\"}".to_string(),
3505            })
3506            .expect("install plugin");
3507        assert!(plugin.enabled);
3508        store.plugins().set_enabled("plugin-1", false).unwrap();
3509        assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
3510
3511        let probe = store
3512            .provider_probes()
3513            .upsert(NewProviderProbe {
3514                provider: "cerebras".to_string(),
3515                model_id: "gpt-oss-120b".to_string(),
3516                capability_key: "parallel_tool_calls".to_string(),
3517                capability_value: "false".to_string(),
3518                confidence: "static".to_string(),
3519                error: None,
3520            })
3521            .expect("probe");
3522        assert_eq!(probe.confidence, "static");
3523        assert_eq!(
3524            store
3525                .provider_probes()
3526                .list(Some("cerebras"), Some("gpt-oss-120b"))
3527                .unwrap()
3528                .len(),
3529            1
3530        );
3531
3532        let pairing = store
3533            .pairing_tokens()
3534            .create("hash", Some("phone"), None)
3535            .expect("pairing");
3536        store.pairing_tokens().mark_used(&pairing.id).unwrap();
3537        assert!(
3538            store
3539                .pairing_tokens()
3540                .get(&pairing.id)
3541                .unwrap()
3542                .unwrap()
3543                .last_used_at
3544                .is_some()
3545        );
3546        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3547    }
3548
3549    #[test]
3550    fn pairing_token_expiry_and_revoke() {
3551        let path = temp_db("pairing_ttl");
3552        let store = RuntimeStore::open(&path).expect("open store");
3553        let tokens = store.pairing_tokens();
3554
3555        // A never-expiring token verifies.
3556        let live = tokens
3557            .create("live_hash", Some("a"), None)
3558            .expect("create live");
3559        assert!(tokens.verify_token("live_hash").unwrap().is_some());
3560
3561        // A future expiry still verifies; a past expiry does not.
3562        let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
3563        tokens
3564            .create("future_hash", None, Some(&future))
3565            .expect("create future");
3566        assert!(tokens.verify_token("future_hash").unwrap().is_some());
3567
3568        let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
3569        tokens
3570            .create("past_hash", None, Some(&past))
3571            .expect("create past");
3572        assert!(
3573            tokens.verify_token("past_hash").unwrap().is_none(),
3574            "an expired token must not verify"
3575        );
3576
3577        // A future expiry rendered with a non-UTC offset still verifies, even
3578        // though its RFC3339 string sorts lexically *before* `now_rfc3339()` —
3579        // this would wrongly read as expired under the old SQL string compare (#64).
3580        let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
3581            .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
3582            .to_rfc3339();
3583        tokens
3584            .create("skew_hash", None, Some(&skewed))
3585            .expect("create skewed");
3586        assert!(
3587            tokens.verify_token("skew_hash").unwrap().is_some(),
3588            "a future token in a non-UTC offset must verify (parsed-instant compare)"
3589        );
3590
3591        // A present-but-unparseable expiry fails closed (treated as expired).
3592        tokens
3593            .create("garbage_hash", None, Some("not-a-timestamp"))
3594            .expect("create garbage");
3595        assert!(
3596            tokens.verify_token("garbage_hash").unwrap().is_none(),
3597            "an unparseable expiry must fail closed"
3598        );
3599
3600        // Revoking disables the token.
3601        assert!(tokens.revoke(&live.id).unwrap());
3602        assert!(tokens.verify_token("live_hash").unwrap().is_none());
3603        assert!(
3604            !tokens.revoke(&live.id).unwrap(),
3605            "double revoke is a no-op"
3606        );
3607
3608        // A non-matching hash never verifies.
3609        assert!(tokens.verify_token("nope").unwrap().is_none());
3610
3611        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3612    }
3613
3614    #[test]
3615    fn ct_eq_matches_only_identical_bytes() {
3616        assert!(ct_eq(b"abc", b"abc"));
3617        assert!(!ct_eq(b"abc", b"abd"));
3618        assert!(!ct_eq(b"abc", b"ab"));
3619        assert!(!ct_eq(b"", b"x"));
3620        assert!(ct_eq(b"", b""));
3621    }
3622
3623    #[test]
3624    fn fresh_id_is_collision_free_in_tight_loop() {
3625        // The #61 stress: ids minted back-to-back (same nanosecond on a coarse
3626        // clock) must all be distinct and keep the `prefix-` shape.
3627        let mut seen = std::collections::HashSet::new();
3628        for _ in 0..10_000 {
3629            let id = fresh_id("process");
3630            assert!(id.starts_with("process-"), "id must keep prefix: {id}");
3631            assert!(seen.insert(id), "fresh_id produced a duplicate");
3632        }
3633    }
3634
3635    #[test]
3636    fn ensure_column_rejects_non_identifier() {
3637        let path = temp_db("ensure_col");
3638        let store = RuntimeStore::open(&path).expect("open store");
3639        assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
3640        assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
3641        assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
3642        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3643    }
3644
3645    #[test]
3646    fn clamp_limit_never_binds_negative() {
3647        // #128: a huge `limit` must clamp, not wrap to a negative i64 (which
3648        // SQLite reads as unbounded).
3649        assert_eq!(clamp_limit(10), 10);
3650        assert_eq!(clamp_limit(usize::MAX), MAX_QUERY_LIMIT as i64);
3651        assert!(clamp_limit(usize::MAX) > 0);
3652    }
3653
3654    fn make_approval(store: &RuntimeStore, action: &str) -> ApprovalRecord {
3655        store
3656            .approvals()
3657            .create(NewApproval {
3658                task_id: None,
3659                proposed_action: action.to_string(),
3660                risk_classification: "shell_mutation".to_string(),
3661                policy_decision: "ask".to_string(),
3662                args_summary: None,
3663                checkpoint_id: None,
3664                pending_action_json: None,
3665            })
3666            .expect("create approval")
3667    }
3668
3669    #[test]
3670    fn approval_claim_is_single_winner_releasable_and_finalizable() {
3671        // #118: exactly one concurrent claim wins; a released claim re-claims; a
3672        // finalized one is decided and unclaimable.
3673        let path = temp_db("approval_claim");
3674        let store = RuntimeStore::open(&path).expect("open store");
3675        let a = make_approval(&store, "write_file a");
3676
3677        assert!(store.approvals().claim(&a.id).unwrap(), "first claim wins");
3678        assert!(
3679            !store.approvals().claim(&a.id).unwrap(),
3680            "second claim loses"
3681        );
3682
3683        store.approvals().release_claim(&a.id).unwrap();
3684        assert!(
3685            store.approvals().claim(&a.id).unwrap(),
3686            "a released claim is re-claimable (effect-failed path)"
3687        );
3688
3689        store
3690            .approvals()
3691            .finalize_claimed(&a.id, "approved")
3692            .unwrap();
3693        assert_eq!(
3694            store
3695                .approvals()
3696                .get(&a.id)
3697                .unwrap()
3698                .unwrap()
3699                .user_decision
3700                .as_deref(),
3701            Some("approved")
3702        );
3703        assert!(
3704            !store.approvals().claim(&a.id).unwrap(),
3705            "a decided approval cannot be claimed"
3706        );
3707        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3708    }
3709
3710    #[test]
3711    fn reconcile_after_restart_recovers_running_tasks_and_claims() {
3712        // #120/#118: a daemon-owned Running task and an 'approving' claim left by a
3713        // crashed daemon are recovered on the next startup.
3714        let path = temp_db("reconcile");
3715        let store = RuntimeStore::open(&path).expect("open store");
3716        let task = store
3717            .tasks()
3718            .create(NewTask::new("t", "/repo", "m").daemon_owned())
3719            .expect("create task");
3720        store
3721            .tasks()
3722            .update_status(&task.id, TaskStatus::Running, None)
3723            .expect("mark running");
3724        let appr = make_approval(&store, "git push");
3725        assert!(store.approvals().claim(&appr.id).unwrap());
3726
3727        let (tasks, claims) = store.reconcile_after_restart().expect("reconcile");
3728        assert_eq!((tasks, claims), (1, 1));
3729        assert_eq!(
3730            store.tasks().get(&task.id).unwrap().unwrap().status,
3731            TaskStatus::Failed
3732        );
3733        assert!(
3734            store
3735                .approvals()
3736                .get(&appr.id)
3737                .unwrap()
3738                .unwrap()
3739                .user_decision
3740                .is_none(),
3741            "a released claim is undecided and re-runnable"
3742        );
3743        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3744    }
3745
3746    #[test]
3747    fn reconcile_after_restart_spares_non_daemon_running_tasks() {
3748        // F18 (RC-E): a Running task NOT owned by the daemon (an interactive CLI
3749        // run sharing the store, owner_kind = NULL) must survive a daemon restart
3750        // — not be flipped to Failed with a spurious "interrupted" event.
3751        let path = temp_db("reconcile_spare_cli");
3752        let store = RuntimeStore::open(&path).expect("open store");
3753
3754        let cli = store
3755            .tasks()
3756            .create(NewTask::new("cli run", "/repo", "m")) // no .daemon_owned()
3757            .expect("create cli task");
3758        store
3759            .tasks()
3760            .update_status(&cli.id, TaskStatus::Running, None)
3761            .expect("mark cli running");
3762        let daemon = store
3763            .tasks()
3764            .create(NewTask::new("daemon run", "/repo", "m").daemon_owned())
3765            .expect("create daemon task");
3766        store
3767            .tasks()
3768            .update_status(&daemon.id, TaskStatus::Running, None)
3769            .expect("mark daemon running");
3770
3771        let (tasks, _claims) = store.reconcile_after_restart().expect("reconcile");
3772        assert_eq!(tasks, 1, "only the daemon-owned task is reset");
3773        assert_eq!(
3774            store.tasks().get(&cli.id).unwrap().unwrap().status,
3775            TaskStatus::Running,
3776            "a live CLI task must NOT be clobbered by the daemon's reconcile"
3777        );
3778        assert_eq!(
3779            store.tasks().get(&daemon.id).unwrap().unwrap().status,
3780            TaskStatus::Failed,
3781            "a stranded daemon task is still recovered"
3782        );
3783        // The spared CLI task gets no "interrupted" event.
3784        assert!(
3785            !store
3786                .tasks()
3787                .events(&cli.id)
3788                .unwrap()
3789                .iter()
3790                .any(|e| e.kind == "interrupted"),
3791            "the spared task must not receive a spurious interrupted event"
3792        );
3793        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3794    }
3795
3796    #[test]
3797    fn gc_prunes_old_archived_but_keeps_active() {
3798        // #130: GC removes archived rows past the retention window, never active
3799        // ones.
3800        let path = temp_db("gc");
3801        let store = RuntimeStore::open(&path).expect("open store");
3802        let keep = make_approval(&store, "active");
3803        let gone = make_approval(&store, "old archived");
3804        store
3805            .approvals()
3806            .archive(std::slice::from_ref(&gone.id), "test")
3807            .expect("archive");
3808        // Backdate the archive far past the window.
3809        store
3810            .conn
3811            .execute(
3812                "UPDATE approvals SET archived_at = ?2 WHERE id = ?1",
3813                params![gone.id, "2000-01-01T00:00:00+00:00"],
3814            )
3815            .unwrap();
3816
3817        let removed = store.gc(30, 180).expect("gc");
3818        assert!(removed >= 1, "the old archived approval should be pruned");
3819        assert!(
3820            store.approvals().get(&gone.id).unwrap().is_none(),
3821            "old archived row removed"
3822        );
3823        assert!(
3824            store.approvals().get(&keep.id).unwrap().is_some(),
3825            "active row kept"
3826        );
3827        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3828    }
3829
3830    #[test]
3831    fn gc_prunes_outcomes_and_terminal_tasks_on_their_windows() {
3832        // R1: `gc` prunes terminal tasks past the task window and `outcomes` past
3833        // their own (longer) window, never touching a live task or a recent
3834        // outcome. When a task is pruned while its outcome survives, the outcome
3835        // stays with a NULL `task_id` (ON DELETE SET NULL) — the denormalized
3836        // `detail_json` is what keeps it usable for training after the link dies.
3837        let path = temp_db("gc_outcomes");
3838        let store = RuntimeStore::open(&path).expect("open store");
3839        let old = "2000-01-01T00:00:00+00:00"; // far past both windows
3840
3841        // A live (queued) task must survive.
3842        let live = store
3843            .tasks()
3844            .create(NewTask::new("live", "/repo", "m"))
3845            .expect("live task");
3846
3847        // An old terminal task must be pruned.
3848        let done = store
3849            .tasks()
3850            .create(NewTask::new("done", "/repo", "m"))
3851            .expect("done task");
3852        store
3853            .tasks()
3854            .update_status(&done.id, TaskStatus::Completed, Some("ok"))
3855            .expect("finish task");
3856        store
3857            .conn
3858            .execute(
3859                "UPDATE tasks SET updated_at = ?2 WHERE id = ?1",
3860                params![done.id, old],
3861            )
3862            .unwrap();
3863
3864        // An outcome for that pruned task, still inside the (longer) outcomes
3865        // window: it must survive, with its link nulled and its context intact.
3866        let kept_outcome = store
3867            .outcomes()
3868            .record(NewOutcome {
3869                id: None,
3870                task_id: Some(done.id.clone()),
3871                tool_run_id: None,
3872                kind: "task_terminal".to_string(),
3873                label: OUTCOME_LABEL_SUCCESS.to_string(),
3874                reward: Some(1.0),
3875                source: OUTCOME_SOURCE_SYSTEM.to_string(),
3876                detail_json: Some("{\"prompt\":\"do the thing\"}".to_string()),
3877            })
3878            .expect("record kept outcome");
3879
3880        // An ancient outcome, past the outcomes window: it must be pruned.
3881        let gone_outcome = store
3882            .outcomes()
3883            .record(NewOutcome {
3884                id: None,
3885                task_id: None,
3886                tool_run_id: None,
3887                kind: "task_terminal".to_string(),
3888                label: OUTCOME_LABEL_FAILURE.to_string(),
3889                reward: Some(-1.0),
3890                source: OUTCOME_SOURCE_SYSTEM.to_string(),
3891                detail_json: None,
3892            })
3893            .expect("record gone outcome");
3894        store
3895            .conn
3896            .execute(
3897                "UPDATE outcomes SET created_at = ?2 WHERE id = ?1",
3898                params![gone_outcome.id, old],
3899            )
3900            .unwrap();
3901
3902        store.gc(30, 180).expect("gc");
3903
3904        assert!(
3905            store.tasks().get(&live.id).unwrap().is_some(),
3906            "a live (queued) task must survive gc"
3907        );
3908        assert!(
3909            store.tasks().get(&done.id).unwrap().is_none(),
3910            "an old terminal task must be pruned"
3911        );
3912        let kept = store
3913            .outcomes()
3914            .get(&kept_outcome.id)
3915            .unwrap()
3916            .expect("the recent outcome must survive gc");
3917        assert!(
3918            kept.task_id.is_none(),
3919            "the pruned task's link is nulled (ON DELETE SET NULL)"
3920        );
3921        assert_eq!(
3922            kept.detail_json.as_deref(),
3923            Some("{\"prompt\":\"do the thing\"}"),
3924            "the denormalized training context must survive the task prune"
3925        );
3926        assert!(
3927            store.outcomes().get(&gone_outcome.id).unwrap().is_none(),
3928            "an outcome past the outcomes window must be pruned"
3929        );
3930
3931        let _ = std::fs::remove_dir_all(path.parent().unwrap());
3932    }
3933
3934    #[test]
3935    fn gc_prunes_high_churn_and_old_terminal_rows_but_keeps_active() {
3936        // F22 (RC-F): GC prunes finished tool_runs, exited processes, old
3937        // compactions, and stale sessions/messages past the window — never active
3938        // data (a running tool_run, a live process, a fresh session).
3939        let path = temp_db("gc_high_churn");
3940        let store = RuntimeStore::open(&path).expect("open store");
3941        let old = "2000-01-01T00:00:00+00:00";
3942
3943        // Stale session + message (deleted) vs active session + message (kept).
3944        let stale_session = store
3945            .sessions()
3946            .upsert(NewSession {
3947                id: Some("stale".to_string()),
3948                project_path: "/repo".to_string(),
3949                model_id: "m".to_string(),
3950                title: None,
3951                conversation_path: None,
3952                total_tokens: None,
3953            })
3954            .expect("stale session");
3955        store
3956            .messages()
3957            .add(NewMessage {
3958                session_id: stale_session.id.clone(),
3959                role: "user".to_string(),
3960                content_json: "{}".to_string(),
3961            })
3962            .expect("stale message");
3963        let active_session = store
3964            .sessions()
3965            .upsert(NewSession {
3966                id: Some("active".to_string()),
3967                project_path: "/repo".to_string(),
3968                model_id: "m".to_string(),
3969                title: None,
3970                conversation_path: None,
3971                total_tokens: None,
3972            })
3973            .expect("active session");
3974        store
3975            .messages()
3976            .add(NewMessage {
3977                session_id: active_session.id.clone(),
3978                role: "user".to_string(),
3979                content_json: "{}".to_string(),
3980            })
3981            .expect("active message");
3982        store
3983            .conn
3984            .execute(
3985                "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
3986                params![stale_session.id, old],
3987            )
3988            .unwrap();
3989
3990        // Finished (old) tool_run deleted; running tool_run kept.
3991        store
3992            .tool_runs()
3993            .start(NewToolRun {
3994                id: Some("tr-finished".to_string()),
3995                task_id: None,
3996                turn_id: None,
3997                call_id: None,
3998                tool_name: "x".to_string(),
3999                args_json: None,
4000            })
4001            .expect("start finished tr");
4002        store
4003            .tool_runs()
4004            .finish("tr-finished", "success", None)
4005            .expect("finish tr");
4006        store
4007            .conn
4008            .execute(
4009                "UPDATE tool_runs SET finished_at = ?2 WHERE id = ?1",
4010                params!["tr-finished", old],
4011            )
4012            .unwrap();
4013        store
4014            .tool_runs()
4015            .start(NewToolRun {
4016                id: Some("tr-running".to_string()),
4017                task_id: None,
4018                turn_id: None,
4019                call_id: None,
4020                tool_name: "x".to_string(),
4021                args_json: None,
4022            })
4023            .expect("start running tr");
4024
4025        // Exited (old) process deleted; running process kept.
4026        let exited = store
4027            .processes()
4028            .upsert(NewProcess {
4029                id: Some("p-exited".to_string()),
4030                task_id: None,
4031                pid: 1,
4032                command: "c".to_string(),
4033                cwd: None,
4034                log_path: None,
4035                detected_url: None,
4036                status: ProcessStatus::Exited,
4037                health: None,
4038            })
4039            .expect("exited process");
4040        store
4041            .conn
4042            .execute(
4043                "UPDATE processes SET updated_at = ?2 WHERE id = ?1",
4044                params![exited.id, old],
4045            )
4046            .unwrap();
4047        let running_proc = store
4048            .processes()
4049            .upsert(NewProcess {
4050                id: Some("p-running".to_string()),
4051                task_id: None,
4052                pid: 2,
4053                command: "c".to_string(),
4054                cwd: None,
4055                log_path: None,
4056                detected_url: None,
4057                status: ProcessStatus::Running,
4058                health: None,
4059            })
4060            .expect("running process");
4061
4062        // Old compaction deleted.
4063        let comp = store
4064            .compactions()
4065            .create(NewCompaction {
4066                id: Some("comp-old".to_string()),
4067                task_id: None,
4068                session_id: None,
4069                source_token_estimate: None,
4070                summary_token_count: None,
4071                preserved_turns: None,
4072                archive_path: None,
4073                verification_status: None,
4074            })
4075            .expect("compaction");
4076        store
4077            .conn
4078            .execute(
4079                "UPDATE compactions SET created_at = ?2 WHERE id = ?1",
4080                params![comp.id, old],
4081            )
4082            .unwrap();
4083
4084        let removed = store.gc(30, 180).expect("gc");
4085        assert!(removed >= 5, "stale rows pruned (got {removed})");
4086        assert!(
4087            store.sessions().get(&stale_session.id).unwrap().is_none(),
4088            "stale session gone"
4089        );
4090        assert!(
4091            store
4092                .messages()
4093                .list_for_session(&stale_session.id)
4094                .unwrap()
4095                .is_empty(),
4096            "stale messages gone"
4097        );
4098        assert!(
4099            store.sessions().get(&active_session.id).unwrap().is_some(),
4100            "active session kept"
4101        );
4102        assert_eq!(
4103            store
4104                .messages()
4105                .list_for_session(&active_session.id)
4106                .unwrap()
4107                .len(),
4108            1,
4109            "active message kept"
4110        );
4111        assert!(
4112            store.tool_runs().get("tr-finished").unwrap().is_none(),
4113            "old finished tool_run gone"
4114        );
4115        assert!(
4116            store.tool_runs().get("tr-running").unwrap().is_some(),
4117            "running tool_run kept"
4118        );
4119        assert!(
4120            store.processes().get(&exited.id).unwrap().is_none(),
4121            "old exited process gone"
4122        );
4123        assert!(
4124            store.processes().get(&running_proc.id).unwrap().is_some(),
4125            "running process kept"
4126        );
4127        assert!(
4128            store.compactions().get(&comp.id).unwrap().is_none(),
4129            "old compaction gone"
4130        );
4131        let _ = std::fs::remove_dir_all(path.parent().unwrap());
4132    }
4133
4134    #[test]
4135    fn task_list_skips_undecodable_status_row() {
4136        // F19 (RC-E): a task row whose status enum this build can't decode (a
4137        // different binary wrote it) is skipped, not allowed to blank the list.
4138        let path = temp_db("poison_task");
4139        let store = RuntimeStore::open(&path).expect("open store");
4140        let good = store
4141            .tasks()
4142            .create(NewTask::new("good", "/repo", "m"))
4143            .expect("create good task");
4144        store
4145            .conn
4146            .execute(
4147                "INSERT INTO tasks
4148                 (id, title, status, priority, project_path, model_id, created_at, updated_at)
4149                 VALUES ('poison', 't', 'from_the_future', 'normal', '/repo', 'm', ?1, ?1)",
4150                params![now_rfc3339()],
4151            )
4152            .unwrap();
4153        let listed = store.tasks().list(50).expect("list");
4154        assert_eq!(
4155            listed.len(),
4156            1,
4157            "the poison row is skipped, the good row remains"
4158        );
4159        assert_eq!(listed[0].id, good.id);
4160        // The strict get() path still surfaces the poison row as an error.
4161        assert!(store.tasks().get("poison").is_err(), "get() stays strict");
4162        let _ = std::fs::remove_dir_all(path.parent().unwrap());
4163    }
4164
4165    #[test]
4166    fn checkpoint_delete_removes_row() {
4167        // F23 (RC-F): the on-disk dir GC drops a checkpoint's DB row so list()
4168        // and the on-disk dirs stay in agreement.
4169        let path = temp_db("ckpt_delete");
4170        let store = RuntimeStore::open(&path).expect("open store");
4171        let ckpt = store
4172            .checkpoints()
4173            .create(NewCheckpoint {
4174                id: Some("ckpt-1".to_string()),
4175                task_id: None,
4176                project_path: "/repo".to_string(),
4177                snapshot_path: "/data/checkpoints/ckpt-1".to_string(),
4178                changed_files_json: "[]".to_string(),
4179                pending_action_json: None,
4180                approval_id: None,
4181                session_id: None,
4182                message_index: None,
4183            })
4184            .expect("create checkpoint");
4185        assert!(store.checkpoints().get(&ckpt.id).unwrap().is_some());
4186        assert!(store.checkpoints().delete(&ckpt.id).unwrap(), "row deleted");
4187        assert!(
4188            store.checkpoints().get(&ckpt.id).unwrap().is_none(),
4189            "row gone"
4190        );
4191        assert!(
4192            !store.checkpoints().delete(&ckpt.id).unwrap(),
4193            "second delete is a no-op"
4194        );
4195        let _ = std::fs::remove_dir_all(path.parent().unwrap());
4196    }
4197
4198    #[test]
4199    fn list_for_session_caps_at_max_and_keeps_ascending_order() {
4200        // F24 (RC-F): a huge session is bounded — list_for_session returns at most
4201        // MAX_SESSION_MESSAGES, the most recent ones, in ascending id order.
4202        let path = temp_db("session_cap");
4203        let store = RuntimeStore::open(&path).expect("open store");
4204        let session = store
4205            .sessions()
4206            .upsert(NewSession {
4207                id: Some("big".to_string()),
4208                project_path: "/repo".to_string(),
4209                model_id: "m".to_string(),
4210                title: None,
4211                conversation_path: None,
4212                total_tokens: None,
4213            })
4214            .expect("session");
4215        let total = MAX_SESSION_MESSAGES + 10;
4216        let now = now_rfc3339();
4217        let tx = store.conn.unchecked_transaction().unwrap();
4218        for i in 0..total {
4219            tx.execute(
4220                "INSERT INTO messages (session_id, role, content_json, created_at)
4221                 VALUES (?1, 'user', ?2, ?3)",
4222                params![session.id, format!("{{\"n\":{i}}}"), now],
4223            )
4224            .unwrap();
4225        }
4226        tx.commit().unwrap();
4227        let listed = store
4228            .messages()
4229            .list_for_session(&session.id)
4230            .expect("list");
4231        assert_eq!(
4232            listed.len() as i64,
4233            MAX_SESSION_MESSAGES,
4234            "capped at the max"
4235        );
4236        assert!(
4237            listed.windows(2).all(|w| w[0].id < w[1].id),
4238            "ascending id order preserved across the capped tail"
4239        );
4240        let _ = std::fs::remove_dir_all(path.parent().unwrap());
4241    }
4242}