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
13const SCHEMA_VERSION: i32 = 1;
14
15/// Durable task state. A task is the daemon-level work unit; a chat
16/// transcript is just one artifact linked to it.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum TaskStatus {
20    Queued,
21    Running,
22    WaitingForApproval,
23    Blocked,
24    Completed,
25    Failed,
26    Cancelled,
27}
28
29impl TaskStatus {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            TaskStatus::Queued => "queued",
33            TaskStatus::Running => "running",
34            TaskStatus::WaitingForApproval => "waiting_for_approval",
35            TaskStatus::Blocked => "blocked",
36            TaskStatus::Completed => "completed",
37            TaskStatus::Failed => "failed",
38            TaskStatus::Cancelled => "cancelled",
39        }
40    }
41
42    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
43        match value {
44            "queued" => Ok(TaskStatus::Queued),
45            "running" => Ok(TaskStatus::Running),
46            "waiting_for_approval" => Ok(TaskStatus::WaitingForApproval),
47            "blocked" => Ok(TaskStatus::Blocked),
48            "completed" => Ok(TaskStatus::Completed),
49            "failed" => Ok(TaskStatus::Failed),
50            "cancelled" => Ok(TaskStatus::Cancelled),
51            other => Err(UnknownRuntimeEnum::new("task status", other)),
52        }
53    }
54}
55
56impl fmt::Display for TaskStatus {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.write_str(self.as_str())
59    }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum TaskPriority {
65    Low,
66    Normal,
67    High,
68}
69
70impl TaskPriority {
71    pub fn as_str(self) -> &'static str {
72        match self {
73            TaskPriority::Low => "low",
74            TaskPriority::Normal => "normal",
75            TaskPriority::High => "high",
76        }
77    }
78
79    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
80        match value {
81            "low" => Ok(TaskPriority::Low),
82            "normal" => Ok(TaskPriority::Normal),
83            "high" => Ok(TaskPriority::High),
84            other => Err(UnknownRuntimeEnum::new("task priority", other)),
85        }
86    }
87}
88
89impl fmt::Display for TaskPriority {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum ProcessStatus {
98    Running,
99    Exited,
100    Unknown,
101}
102
103impl ProcessStatus {
104    pub fn as_str(self) -> &'static str {
105        match self {
106            ProcessStatus::Running => "running",
107            ProcessStatus::Exited => "exited",
108            ProcessStatus::Unknown => "unknown",
109        }
110    }
111
112    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
113        match value {
114            "running" => Ok(ProcessStatus::Running),
115            "exited" => Ok(ProcessStatus::Exited),
116            "unknown" => Ok(ProcessStatus::Unknown),
117            other => Err(UnknownRuntimeEnum::new("process status", other)),
118        }
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct TaskRecord {
124    pub id: String,
125    pub title: String,
126    pub status: TaskStatus,
127    pub priority: TaskPriority,
128    pub project_path: String,
129    pub model_id: String,
130    pub conversation_id: Option<String>,
131    pub created_at: String,
132    pub updated_at: String,
133    pub final_report: Option<String>,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct TaskTimelineEvent {
138    pub id: i64,
139    pub task_id: String,
140    pub kind: String,
141    pub message: String,
142    pub created_at: String,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct SessionRecord {
147    pub id: String,
148    pub project_path: String,
149    pub model_id: String,
150    pub title: Option<String>,
151    pub conversation_path: Option<String>,
152    pub created_at: String,
153    pub updated_at: String,
154    pub total_tokens: Option<i64>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct NewSession {
159    pub id: Option<String>,
160    pub project_path: String,
161    pub model_id: String,
162    pub title: Option<String>,
163    pub conversation_path: Option<String>,
164    pub total_tokens: Option<i64>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct MessageRecord {
169    pub id: i64,
170    pub session_id: String,
171    pub role: String,
172    pub content_json: String,
173    pub created_at: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct NewMessage {
178    pub session_id: String,
179    pub role: String,
180    pub content_json: String,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct NewTask {
185    pub title: String,
186    pub project_path: String,
187    pub model_id: String,
188    pub priority: TaskPriority,
189    pub conversation_id: Option<String>,
190}
191
192impl NewTask {
193    pub fn new(
194        title: impl Into<String>,
195        project_path: impl Into<String>,
196        model_id: impl Into<String>,
197    ) -> Self {
198        Self {
199            title: title.into(),
200            project_path: project_path.into(),
201            model_id: model_id.into(),
202            priority: TaskPriority::Normal,
203            conversation_id: None,
204        }
205    }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209pub struct ApprovalRecord {
210    pub id: String,
211    pub task_id: Option<String>,
212    pub proposed_action: String,
213    pub risk_classification: String,
214    pub policy_decision: String,
215    pub user_decision: Option<String>,
216    pub args_summary: Option<String>,
217    pub checkpoint_id: Option<String>,
218    pub pending_action_json: Option<String>,
219    pub created_at: String,
220    pub decided_at: Option<String>,
221    pub archived_at: Option<String>,
222    pub archive_reason: Option<String>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct NewApproval {
227    pub task_id: Option<String>,
228    pub proposed_action: String,
229    pub risk_classification: String,
230    pub policy_decision: String,
231    pub args_summary: Option<String>,
232    pub checkpoint_id: Option<String>,
233    pub pending_action_json: Option<String>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct ToolRunRecord {
238    pub id: String,
239    pub task_id: Option<String>,
240    pub turn_id: Option<String>,
241    pub call_id: Option<String>,
242    pub tool_name: String,
243    pub status: String,
244    pub args_json: Option<String>,
245    pub output_json: Option<String>,
246    pub started_at: String,
247    pub finished_at: Option<String>,
248}
249
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct NewToolRun {
252    pub id: Option<String>,
253    pub task_id: Option<String>,
254    pub turn_id: Option<String>,
255    pub call_id: Option<String>,
256    pub tool_name: String,
257    pub args_json: Option<String>,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct ProcessRecord {
262    pub id: String,
263    pub task_id: Option<String>,
264    pub pid: u32,
265    pub command: String,
266    pub cwd: Option<String>,
267    pub log_path: Option<String>,
268    pub detected_url: Option<String>,
269    pub status: ProcessStatus,
270    pub health: Option<String>,
271    pub created_at: String,
272    pub updated_at: String,
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
276pub struct NewProcess {
277    pub id: Option<String>,
278    pub task_id: Option<String>,
279    pub pid: u32,
280    pub command: String,
281    pub cwd: Option<String>,
282    pub log_path: Option<String>,
283    pub detected_url: Option<String>,
284    pub status: ProcessStatus,
285    pub health: Option<String>,
286}
287
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
289pub struct CheckpointRecord {
290    pub id: String,
291    pub task_id: Option<String>,
292    pub project_path: String,
293    pub snapshot_path: String,
294    pub changed_files_json: String,
295    pub pending_action_json: Option<String>,
296    pub approval_id: Option<String>,
297    pub created_at: String,
298    pub archived_at: Option<String>,
299    pub archive_reason: Option<String>,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub struct NewCheckpoint {
304    pub id: Option<String>,
305    pub task_id: Option<String>,
306    pub project_path: String,
307    pub snapshot_path: String,
308    pub changed_files_json: String,
309    pub pending_action_json: Option<String>,
310    pub approval_id: Option<String>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct CompactionRecord {
315    pub id: String,
316    pub task_id: Option<String>,
317    pub session_id: Option<String>,
318    pub source_token_estimate: Option<i64>,
319    pub summary_token_count: Option<i64>,
320    pub preserved_turns: Option<i64>,
321    pub archive_path: Option<String>,
322    pub verification_status: Option<String>,
323    pub created_at: String,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct NewCompaction {
328    pub id: Option<String>,
329    pub task_id: Option<String>,
330    pub session_id: Option<String>,
331    pub source_token_estimate: Option<i64>,
332    pub summary_token_count: Option<i64>,
333    pub preserved_turns: Option<i64>,
334    pub archive_path: Option<String>,
335    pub verification_status: Option<String>,
336}
337
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339pub struct PluginInstallRecord {
340    pub id: String,
341    pub name: String,
342    pub source: String,
343    pub version: Option<String>,
344    pub enabled: bool,
345    pub manifest_json: String,
346    pub installed_at: String,
347    pub updated_at: String,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct NewPluginInstall {
352    pub id: Option<String>,
353    pub name: String,
354    pub source: String,
355    pub version: Option<String>,
356    pub enabled: bool,
357    pub manifest_json: String,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
361pub struct ProviderProbeRecord {
362    pub provider: String,
363    pub model_id: String,
364    pub capability_key: String,
365    pub capability_value: String,
366    pub confidence: String,
367    pub error: Option<String>,
368    pub probed_at: String,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct NewProviderProbe {
373    pub provider: String,
374    pub model_id: String,
375    pub capability_key: String,
376    pub capability_value: String,
377    pub confidence: String,
378    pub error: Option<String>,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct PairingTokenRecord {
383    pub id: String,
384    pub token_hash: String,
385    pub label: Option<String>,
386    pub enabled: bool,
387    pub created_at: String,
388    pub last_used_at: Option<String>,
389    /// RFC3339 expiry. `None` = never expires (opt-in via `--ttl-days 0`).
390    pub expires_at: Option<String>,
391}
392
393/// SQLite-backed durable runtime state.
394pub struct RuntimeStore {
395    conn: Connection,
396    path: PathBuf,
397}
398
399impl RuntimeStore {
400    pub fn open_default() -> Result<Self> {
401        let dir = data_dir()?;
402        std::fs::create_dir_all(&dir)
403            .with_context(|| format!("failed to create Mermaid data dir {}", dir.display()))?;
404        // The data dir holds the daemon control socket, pairing tokens, and
405        // session/memory state. Restrict it to the owning user (0700) so no
406        // other local UID can reach the socket or read the DB.
407        #[cfg(unix)]
408        {
409            use std::os::unix::fs::PermissionsExt;
410            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
411        }
412        // Windows has no mode bits, so the DB (token hashes, transcripts) would
413        // otherwise inherit the parent's default ACL. Lock it to the current
414        // user via `icacls`. A sentinel makes this run once (first open, or
415        // first open after upgrade for an already-loose dir) rather than on
416        // every store open — the daemon opens the store per request. Best-effort
417        // like the Unix branch: never fail the store open on an ACL hiccup.
418        #[cfg(windows)]
419        {
420            let sentinel = dir.join(".acl-hardened");
421            if !sentinel.exists()
422                && let Ok(user) = std::env::var("USERNAME")
423                && !user.is_empty()
424            {
425                let hardened = std::process::Command::new("icacls")
426                    .arg(&dir)
427                    .arg("/inheritance:r")
428                    .arg("/grant:r")
429                    .arg(format!("{user}:(OI)(CI)F"))
430                    .arg("/T")
431                    .stdout(std::process::Stdio::null())
432                    .stderr(std::process::Stdio::null())
433                    .status()
434                    .map(|status| status.success())
435                    .unwrap_or(false);
436                if hardened {
437                    let _ = std::fs::write(&sentinel, b"1");
438                }
439            }
440        }
441        Self::open(dir.join("runtime.sqlite3"))
442    }
443
444    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
445        let path = path.as_ref().to_path_buf();
446        if let Some(parent) = path.parent() {
447            std::fs::create_dir_all(parent).with_context(|| {
448                format!("failed to create SQLite parent dir {}", parent.display())
449            })?;
450        }
451        let conn = Connection::open(&path)
452            .with_context(|| format!("failed to open runtime DB {}", path.display()))?;
453        // The daemon, CLI, and per-turn effect tasks each open their own
454        // connection (often in separate processes). Without WAL + a busy
455        // timeout, a writer holding the DB makes a concurrent write fail
456        // immediately with SQLITE_BUSY (lost task/tool/approval updates).
457        // WAL allows concurrent readers with a single writer; busy_timeout
458        // serializes writers gracefully.
459        conn.busy_timeout(std::time::Duration::from_secs(5))
460            .context("failed to set SQLite busy_timeout")?;
461        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
462            .context("failed to enable SQLite WAL mode")?;
463        let store = Self { conn, path };
464        store.init_schema()?;
465        Ok(store)
466    }
467
468    pub fn path(&self) -> &Path {
469        &self.path
470    }
471
472    pub fn sessions(&self) -> SessionsRepo<'_> {
473        SessionsRepo { conn: &self.conn }
474    }
475
476    pub fn messages(&self) -> MessagesRepo<'_> {
477        MessagesRepo { conn: &self.conn }
478    }
479
480    pub fn tasks(&self) -> TasksRepo<'_> {
481        TasksRepo { conn: &self.conn }
482    }
483
484    pub fn tool_runs(&self) -> ToolRunsRepo<'_> {
485        ToolRunsRepo { conn: &self.conn }
486    }
487
488    pub fn approvals(&self) -> ApprovalsRepo<'_> {
489        ApprovalsRepo { conn: &self.conn }
490    }
491
492    pub fn processes(&self) -> ProcessesRepo<'_> {
493        ProcessesRepo { conn: &self.conn }
494    }
495
496    pub fn checkpoints(&self) -> CheckpointsRepo<'_> {
497        CheckpointsRepo { conn: &self.conn }
498    }
499
500    pub fn compactions(&self) -> CompactionsRepo<'_> {
501        CompactionsRepo { conn: &self.conn }
502    }
503
504    pub fn plugins(&self) -> PluginsRepo<'_> {
505        PluginsRepo { conn: &self.conn }
506    }
507
508    pub fn provider_probes(&self) -> ProviderProbesRepo<'_> {
509        ProviderProbesRepo { conn: &self.conn }
510    }
511
512    pub fn pairing_tokens(&self) -> PairingTokensRepo<'_> {
513        PairingTokensRepo { conn: &self.conn }
514    }
515
516    fn init_schema(&self) -> Result<()> {
517        self.conn.execute_batch(
518            r#"
519            PRAGMA foreign_keys = ON;
520
521            CREATE TABLE IF NOT EXISTS sessions (
522                id TEXT PRIMARY KEY,
523                project_path TEXT NOT NULL,
524                model_id TEXT NOT NULL,
525                title TEXT,
526                conversation_path TEXT,
527                created_at TEXT NOT NULL,
528                updated_at TEXT NOT NULL,
529                total_tokens INTEGER
530            );
531
532            CREATE TABLE IF NOT EXISTS messages (
533                id INTEGER PRIMARY KEY AUTOINCREMENT,
534                session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
535                role TEXT NOT NULL,
536                content_json TEXT NOT NULL,
537                created_at TEXT NOT NULL
538            );
539            CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
540
541            CREATE TABLE IF NOT EXISTS tasks (
542                id TEXT PRIMARY KEY,
543                title TEXT NOT NULL,
544                status TEXT NOT NULL,
545                priority TEXT NOT NULL,
546                project_path TEXT NOT NULL,
547                model_id TEXT NOT NULL,
548                conversation_id TEXT,
549                created_at TEXT NOT NULL,
550                updated_at TEXT NOT NULL,
551                final_report TEXT
552            );
553            CREATE INDEX IF NOT EXISTS idx_tasks_project_status
554                ON tasks(project_path, status, updated_at);
555
556            CREATE TABLE IF NOT EXISTS task_events (
557                id INTEGER PRIMARY KEY AUTOINCREMENT,
558                task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
559                kind TEXT NOT NULL,
560                message TEXT NOT NULL,
561                created_at TEXT NOT NULL
562            );
563            CREATE INDEX IF NOT EXISTS idx_task_events_task_id
564                ON task_events(task_id, id);
565
566            CREATE TABLE IF NOT EXISTS tool_runs (
567                id TEXT PRIMARY KEY,
568                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
569                turn_id TEXT,
570                call_id TEXT,
571                tool_name TEXT NOT NULL,
572                status TEXT NOT NULL,
573                args_json TEXT,
574                output_json TEXT,
575                started_at TEXT NOT NULL,
576                finished_at TEXT
577            );
578            CREATE INDEX IF NOT EXISTS idx_tool_runs_task_id ON tool_runs(task_id);
579
580            CREATE TABLE IF NOT EXISTS approvals (
581                id TEXT PRIMARY KEY,
582                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
583                proposed_action TEXT NOT NULL,
584                risk_classification TEXT NOT NULL,
585                policy_decision TEXT NOT NULL,
586                user_decision TEXT,
587                args_summary TEXT,
588                checkpoint_id TEXT,
589                pending_action_json TEXT,
590                created_at TEXT NOT NULL,
591                decided_at TEXT,
592                archived_at TEXT,
593                archive_reason TEXT
594            );
595            CREATE INDEX IF NOT EXISTS idx_approvals_task_id ON approvals(task_id);
596
597            CREATE TABLE IF NOT EXISTS processes (
598                id TEXT PRIMARY KEY,
599                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
600                pid INTEGER NOT NULL,
601                command TEXT NOT NULL,
602                cwd TEXT,
603                log_path TEXT,
604                detected_url TEXT,
605                status TEXT NOT NULL,
606                health TEXT,
607                created_at TEXT NOT NULL,
608                updated_at TEXT NOT NULL
609            );
610            CREATE INDEX IF NOT EXISTS idx_processes_task_id ON processes(task_id);
611            CREATE INDEX IF NOT EXISTS idx_processes_pid ON processes(pid);
612
613            CREATE TABLE IF NOT EXISTS checkpoints (
614                id TEXT PRIMARY KEY,
615                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
616                project_path TEXT NOT NULL,
617                snapshot_path TEXT NOT NULL,
618                changed_files_json TEXT NOT NULL,
619                pending_action_json TEXT,
620                approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL,
621                created_at TEXT NOT NULL,
622                archived_at TEXT,
623                archive_reason TEXT
624            );
625
626            CREATE TABLE IF NOT EXISTS compactions (
627                id TEXT PRIMARY KEY,
628                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
629                session_id TEXT,
630                source_token_estimate INTEGER,
631                summary_token_count INTEGER,
632                preserved_turns INTEGER,
633                archive_path TEXT,
634                verification_status TEXT,
635                created_at TEXT NOT NULL
636            );
637
638            CREATE TABLE IF NOT EXISTS provider_probes (
639                provider TEXT NOT NULL,
640                model_id TEXT NOT NULL,
641                capability_key TEXT NOT NULL,
642                capability_value TEXT NOT NULL,
643                confidence TEXT NOT NULL,
644                error TEXT,
645                probed_at TEXT NOT NULL,
646                PRIMARY KEY (provider, model_id, capability_key)
647            );
648
649            CREATE TABLE IF NOT EXISTS plugin_installs (
650                id TEXT PRIMARY KEY,
651                name TEXT NOT NULL,
652                source TEXT NOT NULL,
653                version TEXT,
654                enabled INTEGER NOT NULL DEFAULT 1,
655                manifest_json TEXT NOT NULL,
656                installed_at TEXT NOT NULL,
657                updated_at TEXT NOT NULL
658            );
659
660            CREATE TABLE IF NOT EXISTS pairing_tokens (
661                id TEXT PRIMARY KEY,
662                token_hash TEXT NOT NULL,
663                label TEXT,
664                enabled INTEGER NOT NULL DEFAULT 1,
665                created_at TEXT NOT NULL,
666                last_used_at TEXT,
667                expires_at TEXT
668            );
669            CREATE INDEX IF NOT EXISTS idx_pairing_tokens_enabled
670                ON pairing_tokens(enabled, created_at);
671
672            PRAGMA user_version = 1;
673            "#,
674        )?;
675
676        ensure_column(&self.conn, "approvals", "pending_action_json", "TEXT")?;
677        ensure_column(&self.conn, "approvals", "archived_at", "TEXT")?;
678        ensure_column(&self.conn, "approvals", "archive_reason", "TEXT")?;
679        ensure_column(&self.conn, "checkpoints", "archived_at", "TEXT")?;
680        ensure_column(&self.conn, "checkpoints", "archive_reason", "TEXT")?;
681        // Pairing-token TTL. When the column is first added to an existing DB,
682        // backfill live tokens with a 30-day grace window from now rather than
683        // expiring them instantly on upgrade. Fresh DBs already have the column
684        // (so no backfill) and only tokens minted with `--ttl-days 0` keep a
685        // NULL (never-expires) value going forward.
686        if ensure_column(&self.conn, "pairing_tokens", "expires_at", "TEXT")? {
687            let grace = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
688            self.conn.execute(
689                "UPDATE pairing_tokens SET expires_at = ?1 WHERE expires_at IS NULL",
690                params![grace],
691            )?;
692        }
693
694        let version: i32 = self
695            .conn
696            .query_row("PRAGMA user_version", [], |row| row.get(0))?;
697        anyhow::ensure!(
698            version == SCHEMA_VERSION,
699            "unsupported runtime DB schema version {} (expected {})",
700            version,
701            SCHEMA_VERSION
702        );
703        Ok(())
704    }
705}
706
707pub struct TasksRepo<'a> {
708    conn: &'a Connection,
709}
710
711pub struct SessionsRepo<'a> {
712    conn: &'a Connection,
713}
714
715impl SessionsRepo<'_> {
716    pub fn upsert(&self, new: NewSession) -> Result<SessionRecord> {
717        let now = now_rfc3339();
718        let id = new.id.unwrap_or_else(|| fresh_id("session"));
719        self.conn.execute(
720            "INSERT INTO sessions
721             (id, project_path, model_id, title, conversation_path, created_at, updated_at, total_tokens)
722             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
723             ON CONFLICT(id) DO UPDATE SET
724                project_path = excluded.project_path,
725                model_id = excluded.model_id,
726                title = excluded.title,
727                conversation_path = excluded.conversation_path,
728                updated_at = excluded.updated_at,
729                total_tokens = excluded.total_tokens",
730            params![
731                id,
732                new.project_path,
733                new.model_id,
734                new.title,
735                new.conversation_path,
736                now,
737                now,
738                new.total_tokens,
739            ],
740        )?;
741        self.get(&id)?
742            .context("session was upserted but could not be reloaded")
743    }
744
745    pub fn get(&self, id: &str) -> Result<Option<SessionRecord>> {
746        self.conn
747            .query_row(
748                "SELECT id, project_path, model_id, title, conversation_path,
749                        created_at, updated_at, total_tokens
750                 FROM sessions WHERE id = ?1",
751                [id],
752                session_from_row,
753            )
754            .optional()
755            .map_err(Into::into)
756    }
757
758    pub fn list(&self, limit: usize) -> Result<Vec<SessionRecord>> {
759        let mut stmt = self.conn.prepare(
760            "SELECT id, project_path, model_id, title, conversation_path,
761                    created_at, updated_at, total_tokens
762             FROM sessions ORDER BY updated_at DESC LIMIT ?1",
763        )?;
764        let rows = stmt.query_map([limit as i64], session_from_row)?;
765        rows.collect::<rusqlite::Result<Vec<_>>>()
766            .map_err(Into::into)
767    }
768}
769
770pub struct MessagesRepo<'a> {
771    conn: &'a Connection,
772}
773
774impl MessagesRepo<'_> {
775    pub fn add(&self, new: NewMessage) -> Result<MessageRecord> {
776        self.conn.execute(
777            "INSERT INTO messages (session_id, role, content_json, created_at)
778             VALUES (?1, ?2, ?3, ?4)",
779            params![new.session_id, new.role, new.content_json, now_rfc3339()],
780        )?;
781        let id = self.conn.last_insert_rowid();
782        self.get(id)?
783            .context("message was inserted but could not be reloaded")
784    }
785
786    pub fn get(&self, id: i64) -> Result<Option<MessageRecord>> {
787        self.conn
788            .query_row(
789                "SELECT id, session_id, role, content_json, created_at
790                 FROM messages WHERE id = ?1",
791                [id],
792                message_from_row,
793            )
794            .optional()
795            .map_err(Into::into)
796    }
797
798    pub fn list_for_session(&self, session_id: &str) -> Result<Vec<MessageRecord>> {
799        let mut stmt = self.conn.prepare(
800            "SELECT id, session_id, role, content_json, created_at
801             FROM messages WHERE session_id = ?1 ORDER BY id ASC",
802        )?;
803        let rows = stmt.query_map([session_id], message_from_row)?;
804        rows.collect::<rusqlite::Result<Vec<_>>>()
805            .map_err(Into::into)
806    }
807}
808
809impl TasksRepo<'_> {
810    pub fn create(&self, new: NewTask) -> Result<TaskRecord> {
811        let now = now_rfc3339();
812        let record = TaskRecord {
813            id: fresh_id("task"),
814            title: new.title,
815            status: TaskStatus::Queued,
816            priority: new.priority,
817            project_path: new.project_path,
818            model_id: new.model_id,
819            conversation_id: new.conversation_id,
820            created_at: now.clone(),
821            updated_at: now.clone(),
822            final_report: None,
823        };
824        self.conn.execute(
825            "INSERT INTO tasks
826             (id, title, status, priority, project_path, model_id, conversation_id, created_at, updated_at, final_report)
827             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
828            params![
829                record.id,
830                record.title,
831                record.status.as_str(),
832                record.priority.as_str(),
833                record.project_path,
834                record.model_id,
835                record.conversation_id,
836                record.created_at,
837                record.updated_at,
838                record.final_report,
839            ],
840        )?;
841        self.add_event(&record.id, "task_created", "task created")?;
842        self.get(&record.id)?
843            .context("task was inserted but could not be reloaded")
844    }
845
846    pub fn get(&self, id: &str) -> Result<Option<TaskRecord>> {
847        self.conn
848            .query_row(
849                "SELECT id, title, status, priority, project_path, model_id, conversation_id,
850                        created_at, updated_at, final_report
851                 FROM tasks WHERE id = ?1",
852                [id],
853                task_from_row,
854            )
855            .optional()
856            .map_err(Into::into)
857    }
858
859    pub fn list(&self, limit: usize) -> Result<Vec<TaskRecord>> {
860        let mut stmt = self.conn.prepare(
861            "SELECT id, title, status, priority, project_path, model_id, conversation_id,
862                    created_at, updated_at, final_report
863             FROM tasks
864             ORDER BY updated_at DESC
865             LIMIT ?1",
866        )?;
867        let rows = stmt.query_map([limit as i64], task_from_row)?;
868        rows.collect::<rusqlite::Result<Vec<_>>>()
869            .map_err(Into::into)
870    }
871
872    pub fn update_status(
873        &self,
874        id: &str,
875        status: TaskStatus,
876        final_report: Option<&str>,
877    ) -> Result<()> {
878        let now = now_rfc3339();
879        self.conn.execute(
880            "UPDATE tasks
881             SET status = ?2, updated_at = ?3, final_report = COALESCE(?4, final_report)
882             WHERE id = ?1",
883            params![id, status.as_str(), now, final_report],
884        )?;
885        self.add_event(
886            id,
887            "status_changed",
888            &format!("status changed to {}", status),
889        )?;
890        Ok(())
891    }
892
893    pub fn add_event(&self, task_id: &str, kind: &str, message: &str) -> Result<()> {
894        self.conn.execute(
895            "INSERT INTO task_events (task_id, kind, message, created_at)
896             VALUES (?1, ?2, ?3, ?4)",
897            params![task_id, kind, message, now_rfc3339()],
898        )?;
899        Ok(())
900    }
901
902    pub fn events(&self, task_id: &str) -> Result<Vec<TaskTimelineEvent>> {
903        let mut stmt = self.conn.prepare(
904            "SELECT id, task_id, kind, message, created_at
905             FROM task_events
906             WHERE task_id = ?1
907             ORDER BY id ASC",
908        )?;
909        let rows = stmt.query_map([task_id], |row| {
910            Ok(TaskTimelineEvent {
911                id: row.get("id")?,
912                task_id: row.get("task_id")?,
913                kind: row.get("kind")?,
914                message: row.get("message")?,
915                created_at: row.get("created_at")?,
916            })
917        })?;
918        rows.collect::<rusqlite::Result<Vec<_>>>()
919            .map_err(Into::into)
920    }
921}
922
923pub struct ToolRunsRepo<'a> {
924    conn: &'a Connection,
925}
926
927impl ToolRunsRepo<'_> {
928    pub fn start(&self, new: NewToolRun) -> Result<ToolRunRecord> {
929        let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
930        self.conn.execute(
931            "INSERT INTO tool_runs
932             (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
933             VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
934            params![
935                id,
936                new.task_id,
937                new.turn_id,
938                new.call_id,
939                new.tool_name,
940                new.args_json,
941                now_rfc3339(),
942            ],
943        )?;
944        self.get(&id)?
945            .context("tool run was inserted but could not be reloaded")
946    }
947
948    pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
949        let changed = self.conn.execute(
950            "UPDATE tool_runs
951             SET status = ?2, output_json = ?3, finished_at = ?4
952             WHERE id = ?1",
953            params![id, status, output_json, now_rfc3339()],
954        )?;
955        anyhow::ensure!(changed > 0, "tool run not found: {}", id);
956        Ok(())
957    }
958
959    pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
960        self.conn
961            .query_row(
962                "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
963                        output_json, started_at, finished_at
964                 FROM tool_runs WHERE id = ?1",
965                [id],
966                tool_run_from_row,
967            )
968            .optional()
969            .map_err(Into::into)
970    }
971
972    pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
973        let mut stmt = self.conn.prepare(
974            "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
975                    output_json, started_at, finished_at
976             FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
977        )?;
978        let rows = stmt.query_map([limit as i64], tool_run_from_row)?;
979        rows.collect::<rusqlite::Result<Vec<_>>>()
980            .map_err(Into::into)
981    }
982}
983
984pub struct ApprovalsRepo<'a> {
985    conn: &'a Connection,
986}
987
988impl ApprovalsRepo<'_> {
989    pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
990        let record = ApprovalRecord {
991            id: fresh_id("approval"),
992            task_id: new.task_id,
993            proposed_action: new.proposed_action,
994            risk_classification: new.risk_classification,
995            policy_decision: new.policy_decision,
996            user_decision: None,
997            args_summary: new.args_summary,
998            checkpoint_id: new.checkpoint_id,
999            pending_action_json: new.pending_action_json,
1000            created_at: now_rfc3339(),
1001            decided_at: None,
1002            archived_at: None,
1003            archive_reason: None,
1004        };
1005        self.conn.execute(
1006            "INSERT INTO approvals
1007             (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
1008              args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
1009             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1010            params![
1011                record.id,
1012                record.task_id,
1013                record.proposed_action,
1014                record.risk_classification,
1015                record.policy_decision,
1016                record.user_decision,
1017                record.args_summary,
1018                record.checkpoint_id,
1019                record.pending_action_json,
1020                record.created_at,
1021                record.decided_at,
1022            ],
1023        )?;
1024        self.get(&record.id)?
1025            .context("approval was inserted but could not be reloaded")
1026    }
1027
1028    pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
1029        self.conn
1030            .query_row(
1031                "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1032                        user_decision, args_summary, checkpoint_id, pending_action_json,
1033                        created_at, decided_at, archived_at, archive_reason
1034                 FROM approvals WHERE id = ?1",
1035                [id],
1036                approval_from_row,
1037            )
1038            .optional()
1039            .map_err(Into::into)
1040    }
1041
1042    pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
1043        // Single-shot decision: only an undecided, un-archived approval can be
1044        // decided, so a denied approval cannot be resurrected as "approved".
1045        // `approval::approve_and_replay` runs the (un-rollback-able) replay
1046        // effect *before* calling `decide`, so the "approved" mark lands only
1047        // after the action ran: a crash mid-replay leaves the row undecided and
1048        // safely re-runnable, never "approved but never applied" (#62). Mirrors
1049        // the `archive` `WHERE archived_at IS NULL` idempotency pattern below.
1050        let changed = self.conn.execute(
1051            "UPDATE approvals
1052             SET user_decision = ?2, decided_at = ?3
1053             WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1054            params![id, user_decision, now_rfc3339()],
1055        )?;
1056        anyhow::ensure!(
1057            changed > 0,
1058            "approval {} cannot be decided (already decided, archived, or not found)",
1059            id
1060        );
1061        Ok(())
1062    }
1063
1064    pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
1065        self.list_pending_with_archived(false)
1066    }
1067
1068    pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
1069        self.list_pending_with_archived(true)
1070    }
1071
1072    pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
1073        let mut stmt = self.conn.prepare(
1074            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1075                    user_decision, args_summary, checkpoint_id, pending_action_json,
1076                    created_at, decided_at, archived_at, archive_reason
1077             FROM approvals
1078             ORDER BY created_at DESC
1079             LIMIT ?1",
1080        )?;
1081        let rows = stmt.query_map([limit as i64], approval_from_row)?;
1082        rows.collect::<rusqlite::Result<Vec<_>>>()
1083            .map_err(Into::into)
1084    }
1085
1086    fn list_pending_with_archived(&self, include_archived: bool) -> Result<Vec<ApprovalRecord>> {
1087        let archived_filter = if include_archived {
1088            ""
1089        } else {
1090            " AND archived_at IS NULL"
1091        };
1092        let mut stmt = self.conn.prepare(&format!(
1093            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1094                    user_decision, args_summary, checkpoint_id, pending_action_json,
1095                    created_at, decided_at, archived_at, archive_reason
1096             FROM approvals
1097             WHERE user_decision IS NULL{archived_filter}
1098             ORDER BY created_at DESC"
1099        ))?;
1100        let rows = stmt.query_map([], approval_from_row)?;
1101        rows.collect::<rusqlite::Result<Vec<_>>>()
1102            .map_err(Into::into)
1103    }
1104
1105    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1106        let archived_at = now_rfc3339();
1107        let mut changed = 0;
1108        for id in ids {
1109            changed += self.conn.execute(
1110                "UPDATE approvals
1111                 SET archived_at = COALESCE(archived_at, ?2),
1112                     archive_reason = COALESCE(archive_reason, ?3)
1113                 WHERE id = ?1 AND archived_at IS NULL",
1114                params![id, archived_at, reason],
1115            )?;
1116        }
1117        Ok(changed)
1118    }
1119
1120    pub fn count_archived(&self) -> Result<usize> {
1121        self.conn
1122            .query_row(
1123                "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
1124                [],
1125                |row| row.get::<_, i64>(0),
1126            )
1127            .map(|count| count as usize)
1128            .map_err(Into::into)
1129    }
1130}
1131
1132pub struct ProcessesRepo<'a> {
1133    conn: &'a Connection,
1134}
1135
1136impl ProcessesRepo<'_> {
1137    pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
1138        let now = now_rfc3339();
1139        let id = new.id.unwrap_or_else(|| fresh_id("process"));
1140        self.conn.execute(
1141            "INSERT INTO processes
1142             (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
1143             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1144             ON CONFLICT(id) DO UPDATE SET
1145                task_id = excluded.task_id,
1146                pid = excluded.pid,
1147                command = excluded.command,
1148                cwd = excluded.cwd,
1149                log_path = excluded.log_path,
1150                detected_url = excluded.detected_url,
1151                status = excluded.status,
1152                health = excluded.health,
1153                updated_at = excluded.updated_at",
1154            params![
1155                id,
1156                new.task_id,
1157                new.pid,
1158                new.command,
1159                new.cwd,
1160                new.log_path,
1161                new.detected_url,
1162                new.status.as_str(),
1163                new.health,
1164                now,
1165                now,
1166            ],
1167        )?;
1168        self.get(&id)?
1169            .context("process was upserted but could not be reloaded")
1170    }
1171
1172    pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
1173        self.conn
1174            .query_row(
1175                "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1176                        created_at, updated_at
1177                 FROM processes WHERE id = ?1",
1178                [id],
1179                process_from_row,
1180            )
1181            .optional()
1182            .map_err(Into::into)
1183    }
1184
1185    pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1186        let mut stmt = self.conn.prepare(
1187            "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1188                    created_at, updated_at
1189             FROM processes
1190             ORDER BY updated_at DESC
1191             LIMIT ?1",
1192        )?;
1193        let rows = stmt.query_map([limit as i64], process_from_row)?;
1194        rows.collect::<rusqlite::Result<Vec<_>>>()
1195            .map_err(Into::into)
1196    }
1197}
1198
1199pub struct CheckpointsRepo<'a> {
1200    conn: &'a Connection,
1201}
1202
1203impl CheckpointsRepo<'_> {
1204    pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
1205        let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
1206        self.conn.execute(
1207            "INSERT INTO checkpoints
1208             (id, task_id, project_path, snapshot_path, changed_files_json,
1209              pending_action_json, approval_id, created_at)
1210             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1211            params![
1212                id,
1213                new.task_id,
1214                new.project_path,
1215                new.snapshot_path,
1216                new.changed_files_json,
1217                new.pending_action_json,
1218                new.approval_id,
1219                now_rfc3339(),
1220            ],
1221        )?;
1222        self.get(&id)?
1223            .context("checkpoint was inserted but could not be reloaded")
1224    }
1225
1226    pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
1227        self.conn
1228            .query_row(
1229                "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1230                        pending_action_json, approval_id, created_at, archived_at, archive_reason
1231                 FROM checkpoints WHERE id = ?1",
1232                [id],
1233                checkpoint_from_row,
1234            )
1235            .optional()
1236            .map_err(Into::into)
1237    }
1238
1239    pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
1240        let changed = self.conn.execute(
1241            "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
1242            params![id, approval_id],
1243        )?;
1244        anyhow::ensure!(changed > 0, "checkpoint not found: {}", id);
1245        Ok(())
1246    }
1247
1248    pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1249        self.list_with_archived(limit, false)
1250    }
1251
1252    pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1253        self.list_with_archived(limit, true)
1254    }
1255
1256    fn list_with_archived(
1257        &self,
1258        limit: usize,
1259        include_archived: bool,
1260    ) -> Result<Vec<CheckpointRecord>> {
1261        let archived_filter = if include_archived {
1262            ""
1263        } else {
1264            "WHERE archived_at IS NULL"
1265        };
1266        let mut stmt = self.conn.prepare(&format!(
1267            "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1268                    pending_action_json, approval_id, created_at, archived_at, archive_reason
1269             FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
1270        ))?;
1271        let rows = stmt.query_map([limit as i64], checkpoint_from_row)?;
1272        rows.collect::<rusqlite::Result<Vec<_>>>()
1273            .map_err(Into::into)
1274    }
1275
1276    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1277        let archived_at = now_rfc3339();
1278        let mut changed = 0;
1279        for id in ids {
1280            changed += self.conn.execute(
1281                "UPDATE checkpoints
1282                 SET archived_at = COALESCE(archived_at, ?2),
1283                     archive_reason = COALESCE(archive_reason, ?3)
1284                 WHERE id = ?1 AND archived_at IS NULL",
1285                params![id, archived_at, reason],
1286            )?;
1287        }
1288        Ok(changed)
1289    }
1290
1291    pub fn count_archived(&self) -> Result<usize> {
1292        self.conn
1293            .query_row(
1294                "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1295                [],
1296                |row| row.get::<_, i64>(0),
1297            )
1298            .map(|count| count as usize)
1299            .map_err(Into::into)
1300    }
1301}
1302
1303pub struct CompactionsRepo<'a> {
1304    conn: &'a Connection,
1305}
1306
1307impl CompactionsRepo<'_> {
1308    pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
1309        let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
1310        self.conn.execute(
1311            "INSERT INTO compactions
1312             (id, task_id, session_id, source_token_estimate, summary_token_count,
1313              preserved_turns, archive_path, verification_status, created_at)
1314             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1315             ON CONFLICT(id) DO UPDATE SET
1316                task_id = excluded.task_id,
1317                session_id = excluded.session_id,
1318                source_token_estimate = excluded.source_token_estimate,
1319                summary_token_count = excluded.summary_token_count,
1320                preserved_turns = excluded.preserved_turns,
1321                archive_path = excluded.archive_path,
1322                verification_status = excluded.verification_status",
1323            params![
1324                id,
1325                new.task_id,
1326                new.session_id,
1327                new.source_token_estimate,
1328                new.summary_token_count,
1329                new.preserved_turns,
1330                new.archive_path,
1331                new.verification_status,
1332                now_rfc3339(),
1333            ],
1334        )?;
1335        self.get(&id)?
1336            .context("compaction was inserted but could not be reloaded")
1337    }
1338
1339    pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
1340        self.conn
1341            .query_row(
1342                "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1343                        preserved_turns, archive_path, verification_status, created_at
1344                 FROM compactions WHERE id = ?1",
1345                [id],
1346                compaction_from_row,
1347            )
1348            .optional()
1349            .map_err(Into::into)
1350    }
1351
1352    pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
1353        let mut stmt = self.conn.prepare(
1354            "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1355                    preserved_turns, archive_path, verification_status, created_at
1356             FROM compactions ORDER BY created_at DESC LIMIT ?1",
1357        )?;
1358        let rows = stmt.query_map([limit as i64], compaction_from_row)?;
1359        rows.collect::<rusqlite::Result<Vec<_>>>()
1360            .map_err(Into::into)
1361    }
1362}
1363
1364pub struct PluginsRepo<'a> {
1365    conn: &'a Connection,
1366}
1367
1368impl PluginsRepo<'_> {
1369    pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
1370        let now = now_rfc3339();
1371        let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
1372        self.conn.execute(
1373            "INSERT INTO plugin_installs
1374             (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
1375             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1376             ON CONFLICT(id) DO UPDATE SET
1377                name = excluded.name,
1378                source = excluded.source,
1379                version = excluded.version,
1380                enabled = excluded.enabled,
1381                manifest_json = excluded.manifest_json,
1382                updated_at = excluded.updated_at",
1383            params![
1384                id,
1385                new.name,
1386                new.source,
1387                new.version,
1388                if new.enabled { 1 } else { 0 },
1389                new.manifest_json,
1390                now,
1391                now,
1392            ],
1393        )?;
1394        self.get(&id)?
1395            .context("plugin install was inserted but could not be reloaded")
1396    }
1397
1398    pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
1399        self.conn
1400            .query_row(
1401                "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1402                 FROM plugin_installs WHERE id = ?1",
1403                [id],
1404                plugin_from_row,
1405            )
1406            .optional()
1407            .map_err(Into::into)
1408    }
1409
1410    pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
1411        let mut stmt = self.conn.prepare(
1412            "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1413             FROM plugin_installs ORDER BY name ASC",
1414        )?;
1415        let rows = stmt.query_map([], plugin_from_row)?;
1416        rows.collect::<rusqlite::Result<Vec<_>>>()
1417            .map_err(Into::into)
1418    }
1419
1420    pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1421        self.conn.execute(
1422            "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
1423            params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
1424        )?;
1425        Ok(())
1426    }
1427}
1428
1429pub struct ProviderProbesRepo<'a> {
1430    conn: &'a Connection,
1431}
1432
1433impl ProviderProbesRepo<'_> {
1434    pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
1435        let now = now_rfc3339();
1436        let provider = new.provider;
1437        let model_id = new.model_id;
1438        let capability_key = new.capability_key;
1439        self.conn.execute(
1440            "INSERT INTO provider_probes
1441             (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
1442             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1443             ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
1444                capability_value = excluded.capability_value,
1445                confidence = excluded.confidence,
1446                error = excluded.error,
1447                probed_at = excluded.probed_at",
1448            params![
1449                &provider,
1450                &model_id,
1451                &capability_key,
1452                new.capability_value,
1453                new.confidence,
1454                new.error,
1455                now,
1456            ],
1457        )?;
1458        self.get(&provider, &model_id, &capability_key)?
1459            .context("provider probe was inserted but could not be reloaded")
1460    }
1461
1462    pub fn get(
1463        &self,
1464        provider: &str,
1465        model_id: &str,
1466        capability_key: &str,
1467    ) -> Result<Option<ProviderProbeRecord>> {
1468        self.conn
1469            .query_row(
1470                "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1471                 FROM provider_probes
1472                 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
1473                params![provider, model_id, capability_key],
1474                provider_probe_from_row,
1475            )
1476            .optional()
1477            .map_err(Into::into)
1478    }
1479
1480    pub fn list(
1481        &self,
1482        provider: Option<&str>,
1483        model_id: Option<&str>,
1484    ) -> Result<Vec<ProviderProbeRecord>> {
1485        let mut stmt = self.conn.prepare(
1486            "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1487             FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
1488        )?;
1489        let rows = stmt.query_map([], provider_probe_from_row)?;
1490        let mut out = Vec::new();
1491        for row in rows {
1492            let probe = row?;
1493            if provider.is_some_and(|p| probe.provider != p) {
1494                continue;
1495            }
1496            if model_id.is_some_and(|m| probe.model_id != m) {
1497                continue;
1498            }
1499            out.push(probe);
1500        }
1501        Ok(out)
1502    }
1503}
1504
1505pub struct PairingTokensRepo<'a> {
1506    conn: &'a Connection,
1507}
1508
1509impl PairingTokensRepo<'_> {
1510    pub fn create(
1511        &self,
1512        token_hash: &str,
1513        label: Option<&str>,
1514        expires_at: Option<&str>,
1515    ) -> Result<PairingTokenRecord> {
1516        let id = fresh_id("pairing");
1517        self.conn.execute(
1518            "INSERT INTO pairing_tokens
1519                 (id, token_hash, label, enabled, created_at, last_used_at, expires_at)
1520             VALUES (?1, ?2, ?3, 1, ?4, NULL, ?5)",
1521            params![id, token_hash, label, now_rfc3339(), expires_at],
1522        )?;
1523        self.get(&id)?
1524            .context("pairing token was inserted but could not be reloaded")
1525    }
1526
1527    pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
1528        self.conn
1529            .query_row(
1530                "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1531                 FROM pairing_tokens WHERE id = ?1",
1532                [id],
1533                pairing_from_row,
1534            )
1535            .optional()
1536            .map_err(Into::into)
1537    }
1538
1539    /// Look up an enabled, unexpired pairing token by hash.
1540    ///
1541    /// The hash is **not** matched in SQL (`WHERE token_hash = ?`) — that is a
1542    /// DB-level equality on the secret and a theoretical timing channel.
1543    /// Instead we fetch the enabled, unexpired candidates (neither predicate is
1544    /// secret) and compare each hash in constant time. The candidate count is
1545    /// tiny and not secret. All candidates are scanned without early exit so the
1546    /// timing doesn't reveal which (if any) token matched.
1547    pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
1548        // Expiry is evaluated in Rust as a parsed instant (see `is_expired`),
1549        // not via a SQL `expires_at > ?` string compare. The skipped-because-
1550        // expired branch is on non-secret data; the hash itself is still matched
1551        // in constant time over every non-expired candidate with no early exit.
1552        let now = chrono::Utc::now();
1553        let mut stmt = self.conn.prepare(
1554            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1555             FROM pairing_tokens
1556             WHERE enabled = 1",
1557        )?;
1558        let candidates = stmt
1559            .query_map([], pairing_from_row)?
1560            .collect::<rusqlite::Result<Vec<_>>>()?;
1561        let mut found = None;
1562        for record in candidates {
1563            if is_expired(record.expires_at.as_deref(), now) {
1564                continue;
1565            }
1566            if ct_eq(record.token_hash.as_bytes(), token_hash.as_bytes()) {
1567                found = Some(record);
1568            }
1569        }
1570        Ok(found)
1571    }
1572
1573    pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
1574        let mut stmt = self.conn.prepare(
1575            "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1576             FROM pairing_tokens ORDER BY created_at DESC",
1577        )?;
1578        let rows = stmt.query_map([], pairing_from_row)?;
1579        rows.collect::<rusqlite::Result<Vec<_>>>()
1580            .map_err(Into::into)
1581    }
1582
1583    /// Like [`list`](Self::list), but with `token_hash` blanked. Use for any
1584    /// surface that crosses a trust boundary — e.g. the daemon snapshot served
1585    /// over the local socket to same-UID processes. The hash is
1586    /// secret-equivalent (it's all `verify_token` compares against) and must
1587    /// not leave the store.
1588    pub fn list_redacted(&self) -> Result<Vec<PairingTokenRecord>> {
1589        Ok(self
1590            .list()?
1591            .into_iter()
1592            .map(|mut record| {
1593                record.token_hash = String::new();
1594                record
1595            })
1596            .collect())
1597    }
1598
1599    pub fn mark_used(&self, id: &str) -> Result<()> {
1600        self.conn.execute(
1601            "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
1602            params![id, now_rfc3339()],
1603        )?;
1604        Ok(())
1605    }
1606
1607    /// Revoke a token by disabling it. Returns `true` if a live token was
1608    /// revoked, `false` if it was already disabled or unknown.
1609    pub fn revoke(&self, id: &str) -> Result<bool> {
1610        let changed = self.conn.execute(
1611            "UPDATE pairing_tokens SET enabled = 0 WHERE id = ?1 AND enabled = 1",
1612            params![id],
1613        )?;
1614        Ok(changed > 0)
1615    }
1616}
1617
1618/// Add `column` to `table` if it is missing. Returns `true` iff the column was
1619/// just created (so the caller can run a one-time backfill).
1620///
1621/// SQL identifiers cannot be bound as `?` parameters, so `table`/`column`/
1622/// `definition` are interpolated. All call sites pass compile-time constants
1623/// today; the validation below makes that a hard invariant rather than a latent
1624/// injection footgun if a future caller ever threads in dynamic input.
1625fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<bool> {
1626    fn is_sql_identifier(s: &str) -> bool {
1627        let mut chars = s.chars();
1628        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
1629            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1630    }
1631    const ALLOWED_DEFINITIONS: &[&str] = &["TEXT", "INTEGER", "REAL", "BLOB"];
1632    anyhow::ensure!(
1633        is_sql_identifier(table),
1634        "invalid table identifier: {table}"
1635    );
1636    anyhow::ensure!(
1637        is_sql_identifier(column),
1638        "invalid column identifier: {column}"
1639    );
1640    anyhow::ensure!(
1641        ALLOWED_DEFINITIONS.contains(&definition),
1642        "unsupported column definition: {definition}"
1643    );
1644
1645    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
1646    let mut rows = stmt.query([])?;
1647    while let Some(row) = rows.next()? {
1648        let name: String = row.get(1)?;
1649        if name == column {
1650            return Ok(false);
1651        }
1652    }
1653    conn.execute(
1654        &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
1655        [],
1656    )?;
1657    Ok(true)
1658}
1659
1660pub fn data_dir() -> Result<PathBuf> {
1661    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1662        return Ok(proj_dirs.data_dir().to_path_buf());
1663    }
1664    let home = std::env::var("HOME")
1665        .or_else(|_| std::env::var("USERPROFILE"))
1666        .context("could not determine home directory")?;
1667    Ok(PathBuf::from(home).join(".local/share/mermaid"))
1668}
1669
1670fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
1671    Ok(SessionRecord {
1672        id: row.get("id")?,
1673        project_path: row.get("project_path")?,
1674        model_id: row.get("model_id")?,
1675        title: row.get("title")?,
1676        conversation_path: row.get("conversation_path")?,
1677        created_at: row.get("created_at")?,
1678        updated_at: row.get("updated_at")?,
1679        total_tokens: row.get("total_tokens")?,
1680    })
1681}
1682
1683fn message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRecord> {
1684    Ok(MessageRecord {
1685        id: row.get("id")?,
1686        session_id: row.get("session_id")?,
1687        role: row.get("role")?,
1688        content_json: row.get("content_json")?,
1689        created_at: row.get("created_at")?,
1690    })
1691}
1692
1693fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
1694    let status_raw: String = row.get("status")?;
1695    let priority_raw: String = row.get("priority")?;
1696    Ok(TaskRecord {
1697        id: row.get("id")?,
1698        title: row.get("title")?,
1699        status: TaskStatus::from_db(&status_raw)
1700            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
1701        priority: TaskPriority::from_db(&priority_raw)
1702            .map_err(|e| enum_from_sql_error("priority", priority_raw, e))?,
1703        project_path: row.get("project_path")?,
1704        model_id: row.get("model_id")?,
1705        conversation_id: row.get("conversation_id")?,
1706        created_at: row.get("created_at")?,
1707        updated_at: row.get("updated_at")?,
1708        final_report: row.get("final_report")?,
1709    })
1710}
1711
1712fn process_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProcessRecord> {
1713    let status_raw: String = row.get("status")?;
1714    let pid: i64 = row.get("pid")?;
1715    Ok(ProcessRecord {
1716        id: row.get("id")?,
1717        task_id: row.get("task_id")?,
1718        pid: pid as u32,
1719        command: row.get("command")?,
1720        cwd: row.get("cwd")?,
1721        log_path: row.get("log_path")?,
1722        detected_url: row.get("detected_url")?,
1723        status: ProcessStatus::from_db(&status_raw)
1724            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
1725        health: row.get("health")?,
1726        created_at: row.get("created_at")?,
1727        updated_at: row.get("updated_at")?,
1728    })
1729}
1730
1731fn tool_run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ToolRunRecord> {
1732    Ok(ToolRunRecord {
1733        id: row.get("id")?,
1734        task_id: row.get("task_id")?,
1735        turn_id: row.get("turn_id")?,
1736        call_id: row.get("call_id")?,
1737        tool_name: row.get("tool_name")?,
1738        status: row.get("status")?,
1739        args_json: row.get("args_json")?,
1740        output_json: row.get("output_json")?,
1741        started_at: row.get("started_at")?,
1742        finished_at: row.get("finished_at")?,
1743    })
1744}
1745
1746fn approval_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApprovalRecord> {
1747    Ok(ApprovalRecord {
1748        id: row.get("id")?,
1749        task_id: row.get("task_id")?,
1750        proposed_action: row.get("proposed_action")?,
1751        risk_classification: row.get("risk_classification")?,
1752        policy_decision: row.get("policy_decision")?,
1753        user_decision: row.get("user_decision")?,
1754        args_summary: row.get("args_summary")?,
1755        checkpoint_id: row.get("checkpoint_id")?,
1756        pending_action_json: row.get("pending_action_json")?,
1757        created_at: row.get("created_at")?,
1758        decided_at: row.get("decided_at")?,
1759        archived_at: row.get("archived_at")?,
1760        archive_reason: row.get("archive_reason")?,
1761    })
1762}
1763
1764fn checkpoint_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointRecord> {
1765    Ok(CheckpointRecord {
1766        id: row.get("id")?,
1767        task_id: row.get("task_id")?,
1768        project_path: row.get("project_path")?,
1769        snapshot_path: row.get("snapshot_path")?,
1770        changed_files_json: row.get("changed_files_json")?,
1771        pending_action_json: row.get("pending_action_json")?,
1772        approval_id: row.get("approval_id")?,
1773        created_at: row.get("created_at")?,
1774        archived_at: row.get("archived_at")?,
1775        archive_reason: row.get("archive_reason")?,
1776    })
1777}
1778
1779fn compaction_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CompactionRecord> {
1780    Ok(CompactionRecord {
1781        id: row.get("id")?,
1782        task_id: row.get("task_id")?,
1783        session_id: row.get("session_id")?,
1784        source_token_estimate: row.get("source_token_estimate")?,
1785        summary_token_count: row.get("summary_token_count")?,
1786        preserved_turns: row.get("preserved_turns")?,
1787        archive_path: row.get("archive_path")?,
1788        verification_status: row.get("verification_status")?,
1789        created_at: row.get("created_at")?,
1790    })
1791}
1792
1793fn plugin_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PluginInstallRecord> {
1794    let enabled: i64 = row.get("enabled")?;
1795    Ok(PluginInstallRecord {
1796        id: row.get("id")?,
1797        name: row.get("name")?,
1798        source: row.get("source")?,
1799        version: row.get("version")?,
1800        enabled: enabled != 0,
1801        manifest_json: row.get("manifest_json")?,
1802        installed_at: row.get("installed_at")?,
1803        updated_at: row.get("updated_at")?,
1804    })
1805}
1806
1807fn provider_probe_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProviderProbeRecord> {
1808    Ok(ProviderProbeRecord {
1809        provider: row.get("provider")?,
1810        model_id: row.get("model_id")?,
1811        capability_key: row.get("capability_key")?,
1812        capability_value: row.get("capability_value")?,
1813        confidence: row.get("confidence")?,
1814        error: row.get("error")?,
1815        probed_at: row.get("probed_at")?,
1816    })
1817}
1818
1819fn pairing_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PairingTokenRecord> {
1820    let enabled: i64 = row.get("enabled")?;
1821    Ok(PairingTokenRecord {
1822        id: row.get("id")?,
1823        token_hash: row.get("token_hash")?,
1824        label: row.get("label")?,
1825        enabled: enabled != 0,
1826        created_at: row.get("created_at")?,
1827        last_used_at: row.get("last_used_at")?,
1828        expires_at: row.get("expires_at")?,
1829    })
1830}
1831
1832fn enum_from_sql_error(
1833    column: &'static str,
1834    value: String,
1835    source: UnknownRuntimeEnum,
1836) -> rusqlite::Error {
1837    let _ = value;
1838    rusqlite::Error::FromSqlConversionFailure(column_index(column), Type::Text, Box::new(source))
1839}
1840
1841fn column_index(column: &str) -> usize {
1842    match column {
1843        "status" => 2,
1844        "priority" => 3,
1845        _ => 0,
1846    }
1847}
1848
1849#[derive(Debug)]
1850struct UnknownRuntimeEnum {
1851    kind: &'static str,
1852    value: String,
1853}
1854
1855impl UnknownRuntimeEnum {
1856    fn new(kind: &'static str, value: &str) -> Self {
1857        Self {
1858            kind,
1859            value: value.to_string(),
1860        }
1861    }
1862}
1863
1864impl fmt::Display for UnknownRuntimeEnum {
1865    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1866        write!(f, "unknown {} value `{}`", self.kind, self.value)
1867    }
1868}
1869
1870impl std::error::Error for UnknownRuntimeEnum {}
1871
1872fn now_rfc3339() -> String {
1873    chrono::Utc::now().to_rfc3339()
1874}
1875
1876/// Constant-time byte-slice equality. Unlike `==` (or a SQL `=`), it never
1877/// short-circuits on the first differing byte, so it leaks no timing signal
1878/// about how much of a secret matched. Lengths are compared first; the length
1879/// of a token hash is fixed and not secret.
1880fn ct_eq(a: &[u8], b: &[u8]) -> bool {
1881    if a.len() != b.len() {
1882        return false;
1883    }
1884    let mut diff = 0u8;
1885    for (x, y) in a.iter().zip(b.iter()) {
1886        diff |= x ^ y;
1887    }
1888    diff == 0
1889}
1890
1891/// Whether a pairing token's `expires_at` is in the past relative to `now`.
1892///
1893/// `None` (SQL `NULL`) means "never expires" — the documented `--ttl-days 0`
1894/// opt-out. A present-but-unparseable value fails closed (treated as expired).
1895/// Expiry is compared as a parsed instant rather than via a SQL `expires_at > ?`
1896/// string compare, which only orders correctly while every stored value is the
1897/// canonical `now_rfc3339()` shape (#64).
1898fn is_expired(expires_at: Option<&str>, now: chrono::DateTime<chrono::Utc>) -> bool {
1899    match expires_at {
1900        None => false,
1901        Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) {
1902            Ok(dt) => dt <= now,
1903            Err(_) => true,
1904        },
1905    }
1906}
1907
1908fn fresh_id(prefix: &str) -> String {
1909    // In-process monotonic counter: two ids minted in the same nanosecond (a
1910    // coarse clock, or a clock stepping backward) can never be equal, so the
1911    // `ON CONFLICT(id) DO UPDATE` upserts can't silently overwrite an unrelated
1912    // row (#61). A per-process random salt removes the clock dependence so ids
1913    // minted across a daemon restart don't collide either (getrandom is already
1914    // a dependency — see `daemon.rs`).
1915    static SEQ: AtomicU64 = AtomicU64::new(0);
1916    static SALT: OnceLock<u64> = OnceLock::new();
1917    let salt = *SALT.get_or_init(|| {
1918        let mut bytes = [0u8; 8];
1919        // The monotonic counter alone still guarantees in-process uniqueness if
1920        // the RNG ever fails, so a best-effort fill is fine here.
1921        let _ = getrandom::fill(&mut bytes);
1922        u64::from_le_bytes(bytes)
1923    });
1924    let nanos = SystemTime::now()
1925        .duration_since(UNIX_EPOCH)
1926        .map(|d| d.as_nanos())
1927        .unwrap_or_default();
1928    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
1929    format!("{prefix}-{nanos:x}-{salt:x}-{seq:x}")
1930}
1931
1932#[cfg(test)]
1933mod tests {
1934    use super::*;
1935
1936    #[test]
1937    fn open_enables_wal_and_busy_timeout() {
1938        // H19: every connection must use WAL so daemon/CLI/effect writers
1939        // don't hit a hard SQLITE_BUSY.
1940        let path = temp_db("wal_check");
1941        let store = RuntimeStore::open(&path).expect("open");
1942        let mode: String = store
1943            .conn
1944            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
1945            .expect("journal_mode pragma");
1946        assert_eq!(mode.to_lowercase(), "wal");
1947    }
1948
1949    fn temp_db(name: &str) -> PathBuf {
1950        let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{}", name));
1951        let _ = std::fs::remove_dir_all(&dir);
1952        std::fs::create_dir_all(&dir).expect("create temp dir");
1953        dir.join("runtime.sqlite3")
1954    }
1955
1956    #[test]
1957    fn initializes_runtime_schema() {
1958        let path = temp_db("schema");
1959        let store = RuntimeStore::open(&path).expect("open store");
1960        assert_eq!(store.path(), path.as_path());
1961        let version: i32 = store
1962            .conn
1963            .query_row("PRAGMA user_version", [], |row| row.get(0))
1964            .unwrap();
1965        assert_eq!(version, SCHEMA_VERSION);
1966        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1967    }
1968
1969    #[test]
1970    fn task_lifecycle_round_trips() {
1971        let path = temp_db("task");
1972        let store = RuntimeStore::open(&path).expect("open store");
1973        let session = store
1974            .sessions()
1975            .upsert(NewSession {
1976                id: Some("session-1".to_string()),
1977                project_path: "/repo".to_string(),
1978                model_id: "anthropic/claude".to_string(),
1979                title: Some("Run tests".to_string()),
1980                conversation_path: Some("/repo/.mermaid/session.json".to_string()),
1981                total_tokens: Some(42),
1982            })
1983            .expect("upsert session");
1984        assert_eq!(session.id, "session-1");
1985        let message = store
1986            .messages()
1987            .add(NewMessage {
1988                session_id: session.id.clone(),
1989                role: "user".to_string(),
1990                content_json: "{\"text\":\"hi\"}".to_string(),
1991            })
1992            .expect("add message");
1993        assert_eq!(message.role, "user");
1994        assert_eq!(
1995            store
1996                .messages()
1997                .list_for_session(&session.id)
1998                .unwrap()
1999                .len(),
2000            1
2001        );
2002
2003        let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
2004        new.priority = TaskPriority::High;
2005        let task = store.tasks().create(new).expect("create task");
2006
2007        assert_eq!(task.status, TaskStatus::Queued);
2008        assert_eq!(task.priority, TaskPriority::High);
2009
2010        store
2011            .tasks()
2012            .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
2013            .expect("update task");
2014        let loaded = store.tasks().get(&task.id).unwrap().unwrap();
2015        assert_eq!(loaded.status, TaskStatus::Completed);
2016        assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
2017
2018        let events = store.tasks().events(&task.id).expect("events");
2019        assert_eq!(events.len(), 2);
2020        assert_eq!(events[0].kind, "task_created");
2021        assert_eq!(events[1].kind, "status_changed");
2022        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2023    }
2024
2025    #[test]
2026    fn approval_and_process_records_round_trip() {
2027        let path = temp_db("approval_process");
2028        let store = RuntimeStore::open(&path).expect("open store");
2029        let task = store
2030            .tasks()
2031            .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
2032            .expect("create task");
2033
2034        let approval = store
2035            .approvals()
2036            .create(NewApproval {
2037                task_id: Some(task.id.clone()),
2038                proposed_action: "write_file src/lib.rs".to_string(),
2039                risk_classification: "file_mutation".to_string(),
2040                policy_decision: "ask".to_string(),
2041                args_summary: Some("src/lib.rs".to_string()),
2042                checkpoint_id: Some("checkpoint-1".to_string()),
2043                pending_action_json: Some(
2044                    "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
2045                ),
2046            })
2047            .expect("create approval");
2048        store
2049            .approvals()
2050            .decide(&approval.id, "approved")
2051            .expect("decide approval");
2052        let approval = store.approvals().get(&approval.id).unwrap().unwrap();
2053        assert_eq!(approval.user_decision.as_deref(), Some("approved"));
2054        assert!(approval.pending_action_json.is_some());
2055
2056        let tool_run = store
2057            .tool_runs()
2058            .start(NewToolRun {
2059                id: Some("toolrun-1".to_string()),
2060                task_id: Some(task.id.clone()),
2061                turn_id: Some("turn-1".to_string()),
2062                call_id: Some("call-1".to_string()),
2063                tool_name: "write_file".to_string(),
2064                args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
2065            })
2066            .expect("start tool run");
2067        assert_eq!(tool_run.status, "running");
2068        store
2069            .tool_runs()
2070            .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
2071            .expect("finish tool run");
2072        let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
2073        assert_eq!(tool_run.status, "success");
2074        assert!(tool_run.finished_at.is_some());
2075
2076        let process = store
2077            .processes()
2078            .upsert(NewProcess {
2079                id: Some("proc-1".to_string()),
2080                task_id: Some(task.id),
2081                pid: 123,
2082                command: "npm run dev".to_string(),
2083                cwd: Some("/repo".to_string()),
2084                log_path: Some("/tmp/mermaid.log".to_string()),
2085                detected_url: Some("http://127.0.0.1:5173".to_string()),
2086                status: ProcessStatus::Running,
2087                health: Some("ready".to_string()),
2088            })
2089            .expect("upsert process");
2090        assert_eq!(process.status, ProcessStatus::Running);
2091        assert_eq!(store.processes().list(10).unwrap().len(), 1);
2092        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2093    }
2094
2095    #[test]
2096    fn approval_decide_is_single_shot() {
2097        let path = temp_db("approval_decide_guard");
2098        let store = RuntimeStore::open(&path).expect("open store");
2099        let make = |action: &str| {
2100            store
2101                .approvals()
2102                .create(NewApproval {
2103                    task_id: None,
2104                    proposed_action: action.to_string(),
2105                    risk_classification: "file_mutation".to_string(),
2106                    policy_decision: "ask".to_string(),
2107                    args_summary: None,
2108                    checkpoint_id: None,
2109                    pending_action_json: None,
2110                })
2111                .expect("create approval")
2112        };
2113
2114        // A second decision on an already-decided approval is rejected — this
2115        // is what stops a stored action from being replayed N times.
2116        let a = make("write_file a");
2117        store
2118            .approvals()
2119            .decide(&a.id, "approved")
2120            .expect("first decide");
2121        assert!(
2122            store.approvals().decide(&a.id, "approved").is_err(),
2123            "re-approving an approved approval must be rejected"
2124        );
2125
2126        // A denied approval cannot be resurrected as approved.
2127        let b = make("write_file b");
2128        store.approvals().decide(&b.id, "denied").expect("deny");
2129        assert!(
2130            store.approvals().decide(&b.id, "approved").is_err(),
2131            "a denied approval must not be re-decidable as approved"
2132        );
2133        let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
2134        assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
2135
2136        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2137    }
2138
2139    #[test]
2140    fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
2141        let path = temp_db("archive_visibility");
2142        let store = RuntimeStore::open(&path).expect("open store");
2143
2144        let approval = store
2145            .approvals()
2146            .create(NewApproval {
2147                task_id: None,
2148                proposed_action: "restore replay: write_file".to_string(),
2149                risk_classification: "restored_action".to_string(),
2150                policy_decision: "ask".to_string(),
2151                args_summary: None,
2152                checkpoint_id: Some("checkpoint-1".to_string()),
2153                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2154            })
2155            .expect("create approval");
2156        let checkpoint = store
2157            .checkpoints()
2158            .create(NewCheckpoint {
2159                id: Some("checkpoint-1".to_string()),
2160                task_id: None,
2161                project_path: "/tmp/mermaid_checkpoint_test".to_string(),
2162                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
2163                changed_files_json: "[]".to_string(),
2164                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2165                approval_id: Some(approval.id.clone()),
2166            })
2167            .expect("create checkpoint");
2168
2169        assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
2170        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
2171        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
2172        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
2173        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
2174
2175        assert_eq!(
2176            store
2177                .approvals()
2178                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
2179                .unwrap(),
2180            1
2181        );
2182        assert_eq!(
2183            store
2184                .checkpoints()
2185                .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
2186                .unwrap(),
2187            1
2188        );
2189        assert_eq!(
2190            store
2191                .approvals()
2192                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
2193                .unwrap(),
2194            0
2195        );
2196        assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
2197        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
2198        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
2199        assert_eq!(store.approvals().count_archived().unwrap(), 1);
2200        assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
2201        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
2202        assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
2203
2204        let archived = store.approvals().get(&approval.id).unwrap().unwrap();
2205        assert!(archived.archived_at.is_some());
2206        assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
2207        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2208    }
2209
2210    #[test]
2211    fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
2212        let path = temp_db("everything_else");
2213        let store = RuntimeStore::open(&path).expect("open store");
2214
2215        let checkpoint = store
2216            .checkpoints()
2217            .create(NewCheckpoint {
2218                id: Some("checkpoint-1".to_string()),
2219                task_id: None,
2220                project_path: "/repo".to_string(),
2221                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
2222                changed_files_json: "[\"src/lib.rs\"]".to_string(),
2223                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2224                approval_id: None,
2225            })
2226            .expect("create checkpoint");
2227        assert_eq!(checkpoint.id, "checkpoint-1");
2228        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
2229
2230        let compaction = store
2231            .compactions()
2232            .create(NewCompaction {
2233                id: Some("compaction-1".to_string()),
2234                task_id: None,
2235                session_id: Some("session-1".to_string()),
2236                source_token_estimate: Some(10_000),
2237                summary_token_count: Some(800),
2238                preserved_turns: Some(6),
2239                archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
2240                verification_status: Some("verified".to_string()),
2241            })
2242            .expect("create compaction");
2243        assert_eq!(compaction.summary_token_count, Some(800));
2244        assert_eq!(store.compactions().list(10).unwrap().len(), 1);
2245
2246        let plugin = store
2247            .plugins()
2248            .install(NewPluginInstall {
2249                id: Some("plugin-1".to_string()),
2250                name: "example".to_string(),
2251                source: "local".to_string(),
2252                version: Some("0.1.0".to_string()),
2253                enabled: true,
2254                manifest_json: "{\"name\":\"example\"}".to_string(),
2255            })
2256            .expect("install plugin");
2257        assert!(plugin.enabled);
2258        store.plugins().set_enabled("plugin-1", false).unwrap();
2259        assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
2260
2261        let probe = store
2262            .provider_probes()
2263            .upsert(NewProviderProbe {
2264                provider: "cerebras".to_string(),
2265                model_id: "gpt-oss-120b".to_string(),
2266                capability_key: "parallel_tool_calls".to_string(),
2267                capability_value: "false".to_string(),
2268                confidence: "static".to_string(),
2269                error: None,
2270            })
2271            .expect("probe");
2272        assert_eq!(probe.confidence, "static");
2273        assert_eq!(
2274            store
2275                .provider_probes()
2276                .list(Some("cerebras"), Some("gpt-oss-120b"))
2277                .unwrap()
2278                .len(),
2279            1
2280        );
2281
2282        let pairing = store
2283            .pairing_tokens()
2284            .create("hash", Some("phone"), None)
2285            .expect("pairing");
2286        store.pairing_tokens().mark_used(&pairing.id).unwrap();
2287        assert!(
2288            store
2289                .pairing_tokens()
2290                .get(&pairing.id)
2291                .unwrap()
2292                .unwrap()
2293                .last_used_at
2294                .is_some()
2295        );
2296        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2297    }
2298
2299    #[test]
2300    fn pairing_token_expiry_and_revoke() {
2301        let path = temp_db("pairing_ttl");
2302        let store = RuntimeStore::open(&path).expect("open store");
2303        let tokens = store.pairing_tokens();
2304
2305        // A never-expiring token verifies.
2306        let live = tokens
2307            .create("live_hash", Some("a"), None)
2308            .expect("create live");
2309        assert!(tokens.verify_token("live_hash").unwrap().is_some());
2310
2311        // A future expiry still verifies; a past expiry does not.
2312        let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
2313        tokens
2314            .create("future_hash", None, Some(&future))
2315            .expect("create future");
2316        assert!(tokens.verify_token("future_hash").unwrap().is_some());
2317
2318        let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
2319        tokens
2320            .create("past_hash", None, Some(&past))
2321            .expect("create past");
2322        assert!(
2323            tokens.verify_token("past_hash").unwrap().is_none(),
2324            "an expired token must not verify"
2325        );
2326
2327        // A future expiry rendered with a non-UTC offset still verifies, even
2328        // though its RFC3339 string sorts lexically *before* `now_rfc3339()` —
2329        // this would wrongly read as expired under the old SQL string compare (#64).
2330        let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
2331            .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
2332            .to_rfc3339();
2333        tokens
2334            .create("skew_hash", None, Some(&skewed))
2335            .expect("create skewed");
2336        assert!(
2337            tokens.verify_token("skew_hash").unwrap().is_some(),
2338            "a future token in a non-UTC offset must verify (parsed-instant compare)"
2339        );
2340
2341        // A present-but-unparseable expiry fails closed (treated as expired).
2342        tokens
2343            .create("garbage_hash", None, Some("not-a-timestamp"))
2344            .expect("create garbage");
2345        assert!(
2346            tokens.verify_token("garbage_hash").unwrap().is_none(),
2347            "an unparseable expiry must fail closed"
2348        );
2349
2350        // Revoking disables the token.
2351        assert!(tokens.revoke(&live.id).unwrap());
2352        assert!(tokens.verify_token("live_hash").unwrap().is_none());
2353        assert!(
2354            !tokens.revoke(&live.id).unwrap(),
2355            "double revoke is a no-op"
2356        );
2357
2358        // A non-matching hash never verifies.
2359        assert!(tokens.verify_token("nope").unwrap().is_none());
2360
2361        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2362    }
2363
2364    #[test]
2365    fn ct_eq_matches_only_identical_bytes() {
2366        assert!(ct_eq(b"abc", b"abc"));
2367        assert!(!ct_eq(b"abc", b"abd"));
2368        assert!(!ct_eq(b"abc", b"ab"));
2369        assert!(!ct_eq(b"", b"x"));
2370        assert!(ct_eq(b"", b""));
2371    }
2372
2373    #[test]
2374    fn fresh_id_is_collision_free_in_tight_loop() {
2375        // The #61 stress: ids minted back-to-back (same nanosecond on a coarse
2376        // clock) must all be distinct and keep the `prefix-` shape.
2377        let mut seen = std::collections::HashSet::new();
2378        for _ in 0..10_000 {
2379            let id = fresh_id("process");
2380            assert!(id.starts_with("process-"), "id must keep prefix: {id}");
2381            assert!(seen.insert(id), "fresh_id produced a duplicate");
2382        }
2383    }
2384
2385    #[test]
2386    fn ensure_column_rejects_non_identifier() {
2387        let path = temp_db("ensure_col");
2388        let store = RuntimeStore::open(&path).expect("open store");
2389        assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
2390        assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
2391        assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
2392        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2393    }
2394}