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