Skip to main content

mermaid_runtime/
storage.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use directories::ProjectDirs;
7use rusqlite::types::Type;
8use rusqlite::{Connection, OptionalExtension, params};
9use serde::{Deserialize, Serialize};
10
11const SCHEMA_VERSION: i32 = 1;
12
13/// Durable task state. A task is the daemon-level work unit; a chat
14/// transcript is just one artifact linked to it.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum TaskStatus {
18    Queued,
19    Running,
20    WaitingForApproval,
21    Blocked,
22    Completed,
23    Failed,
24    Cancelled,
25}
26
27impl TaskStatus {
28    pub fn as_str(self) -> &'static str {
29        match self {
30            TaskStatus::Queued => "queued",
31            TaskStatus::Running => "running",
32            TaskStatus::WaitingForApproval => "waiting_for_approval",
33            TaskStatus::Blocked => "blocked",
34            TaskStatus::Completed => "completed",
35            TaskStatus::Failed => "failed",
36            TaskStatus::Cancelled => "cancelled",
37        }
38    }
39
40    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
41        match value {
42            "queued" => Ok(TaskStatus::Queued),
43            "running" => Ok(TaskStatus::Running),
44            "waiting_for_approval" => Ok(TaskStatus::WaitingForApproval),
45            "blocked" => Ok(TaskStatus::Blocked),
46            "completed" => Ok(TaskStatus::Completed),
47            "failed" => Ok(TaskStatus::Failed),
48            "cancelled" => Ok(TaskStatus::Cancelled),
49            other => Err(UnknownRuntimeEnum::new("task status", other)),
50        }
51    }
52}
53
54impl fmt::Display for TaskStatus {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum TaskPriority {
63    Low,
64    Normal,
65    High,
66}
67
68impl TaskPriority {
69    pub fn as_str(self) -> &'static str {
70        match self {
71            TaskPriority::Low => "low",
72            TaskPriority::Normal => "normal",
73            TaskPriority::High => "high",
74        }
75    }
76
77    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
78        match value {
79            "low" => Ok(TaskPriority::Low),
80            "normal" => Ok(TaskPriority::Normal),
81            "high" => Ok(TaskPriority::High),
82            other => Err(UnknownRuntimeEnum::new("task priority", other)),
83        }
84    }
85}
86
87impl fmt::Display for TaskPriority {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum ProcessStatus {
96    Running,
97    Exited,
98    Unknown,
99}
100
101impl ProcessStatus {
102    pub fn as_str(self) -> &'static str {
103        match self {
104            ProcessStatus::Running => "running",
105            ProcessStatus::Exited => "exited",
106            ProcessStatus::Unknown => "unknown",
107        }
108    }
109
110    fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
111        match value {
112            "running" => Ok(ProcessStatus::Running),
113            "exited" => Ok(ProcessStatus::Exited),
114            "unknown" => Ok(ProcessStatus::Unknown),
115            other => Err(UnknownRuntimeEnum::new("process status", other)),
116        }
117    }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct TaskRecord {
122    pub id: String,
123    pub title: String,
124    pub status: TaskStatus,
125    pub priority: TaskPriority,
126    pub project_path: String,
127    pub model_id: String,
128    pub conversation_id: Option<String>,
129    pub created_at: String,
130    pub updated_at: String,
131    pub final_report: Option<String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct TaskTimelineEvent {
136    pub id: i64,
137    pub task_id: String,
138    pub kind: String,
139    pub message: String,
140    pub created_at: String,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct SessionRecord {
145    pub id: String,
146    pub project_path: String,
147    pub model_id: String,
148    pub title: Option<String>,
149    pub conversation_path: Option<String>,
150    pub created_at: String,
151    pub updated_at: String,
152    pub total_tokens: Option<i64>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct NewSession {
157    pub id: Option<String>,
158    pub project_path: String,
159    pub model_id: String,
160    pub title: Option<String>,
161    pub conversation_path: Option<String>,
162    pub total_tokens: Option<i64>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct MessageRecord {
167    pub id: i64,
168    pub session_id: String,
169    pub role: String,
170    pub content_json: String,
171    pub created_at: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct NewMessage {
176    pub session_id: String,
177    pub role: String,
178    pub content_json: String,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct NewTask {
183    pub title: String,
184    pub project_path: String,
185    pub model_id: String,
186    pub priority: TaskPriority,
187    pub conversation_id: Option<String>,
188}
189
190impl NewTask {
191    pub fn new(
192        title: impl Into<String>,
193        project_path: impl Into<String>,
194        model_id: impl Into<String>,
195    ) -> Self {
196        Self {
197            title: title.into(),
198            project_path: project_path.into(),
199            model_id: model_id.into(),
200            priority: TaskPriority::Normal,
201            conversation_id: None,
202        }
203    }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct ApprovalRecord {
208    pub id: String,
209    pub task_id: Option<String>,
210    pub proposed_action: String,
211    pub risk_classification: String,
212    pub policy_decision: String,
213    pub user_decision: Option<String>,
214    pub args_summary: Option<String>,
215    pub checkpoint_id: Option<String>,
216    pub pending_action_json: Option<String>,
217    pub created_at: String,
218    pub decided_at: Option<String>,
219    pub archived_at: Option<String>,
220    pub archive_reason: Option<String>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct NewApproval {
225    pub task_id: Option<String>,
226    pub proposed_action: String,
227    pub risk_classification: String,
228    pub policy_decision: String,
229    pub args_summary: Option<String>,
230    pub checkpoint_id: Option<String>,
231    pub pending_action_json: Option<String>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct ToolRunRecord {
236    pub id: String,
237    pub task_id: Option<String>,
238    pub turn_id: Option<String>,
239    pub call_id: Option<String>,
240    pub tool_name: String,
241    pub status: String,
242    pub args_json: Option<String>,
243    pub output_json: Option<String>,
244    pub started_at: String,
245    pub finished_at: Option<String>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct NewToolRun {
250    pub id: Option<String>,
251    pub task_id: Option<String>,
252    pub turn_id: Option<String>,
253    pub call_id: Option<String>,
254    pub tool_name: String,
255    pub args_json: Option<String>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct ProcessRecord {
260    pub id: String,
261    pub task_id: Option<String>,
262    pub pid: u32,
263    pub command: String,
264    pub cwd: Option<String>,
265    pub log_path: Option<String>,
266    pub detected_url: Option<String>,
267    pub status: ProcessStatus,
268    pub health: Option<String>,
269    pub created_at: String,
270    pub updated_at: String,
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct NewProcess {
275    pub id: Option<String>,
276    pub task_id: Option<String>,
277    pub pid: u32,
278    pub command: String,
279    pub cwd: Option<String>,
280    pub log_path: Option<String>,
281    pub detected_url: Option<String>,
282    pub status: ProcessStatus,
283    pub health: Option<String>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct CheckpointRecord {
288    pub id: String,
289    pub task_id: Option<String>,
290    pub project_path: String,
291    pub snapshot_path: String,
292    pub changed_files_json: String,
293    pub pending_action_json: Option<String>,
294    pub approval_id: Option<String>,
295    pub created_at: String,
296    pub archived_at: Option<String>,
297    pub archive_reason: Option<String>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct NewCheckpoint {
302    pub id: Option<String>,
303    pub task_id: Option<String>,
304    pub project_path: String,
305    pub snapshot_path: String,
306    pub changed_files_json: String,
307    pub pending_action_json: Option<String>,
308    pub approval_id: Option<String>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct CompactionRecord {
313    pub id: String,
314    pub task_id: Option<String>,
315    pub session_id: Option<String>,
316    pub source_token_estimate: Option<i64>,
317    pub summary_token_count: Option<i64>,
318    pub preserved_turns: Option<i64>,
319    pub archive_path: Option<String>,
320    pub verification_status: Option<String>,
321    pub created_at: String,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct NewCompaction {
326    pub id: Option<String>,
327    pub task_id: Option<String>,
328    pub session_id: Option<String>,
329    pub source_token_estimate: Option<i64>,
330    pub summary_token_count: Option<i64>,
331    pub preserved_turns: Option<i64>,
332    pub archive_path: Option<String>,
333    pub verification_status: Option<String>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct PluginInstallRecord {
338    pub id: String,
339    pub name: String,
340    pub source: String,
341    pub version: Option<String>,
342    pub enabled: bool,
343    pub manifest_json: String,
344    pub installed_at: String,
345    pub updated_at: String,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct NewPluginInstall {
350    pub id: Option<String>,
351    pub name: String,
352    pub source: String,
353    pub version: Option<String>,
354    pub enabled: bool,
355    pub manifest_json: String,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct ProviderProbeRecord {
360    pub provider: String,
361    pub model_id: String,
362    pub capability_key: String,
363    pub capability_value: String,
364    pub confidence: String,
365    pub error: Option<String>,
366    pub probed_at: String,
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct NewProviderProbe {
371    pub provider: String,
372    pub model_id: String,
373    pub capability_key: String,
374    pub capability_value: String,
375    pub confidence: String,
376    pub error: Option<String>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct PairingTokenRecord {
381    pub id: String,
382    pub token_hash: String,
383    pub label: Option<String>,
384    pub enabled: bool,
385    pub created_at: String,
386    pub last_used_at: Option<String>,
387}
388
389/// SQLite-backed durable runtime state.
390pub struct RuntimeStore {
391    conn: Connection,
392    path: PathBuf,
393}
394
395impl RuntimeStore {
396    pub fn open_default() -> Result<Self> {
397        let dir = data_dir()?;
398        std::fs::create_dir_all(&dir)
399            .with_context(|| format!("failed to create Mermaid data dir {}", dir.display()))?;
400        // The data dir holds the daemon control socket, pairing tokens, and
401        // session/memory state. Restrict it to the owning user (0700) so no
402        // other local UID can reach the socket or read the DB.
403        #[cfg(unix)]
404        {
405            use std::os::unix::fs::PermissionsExt;
406            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
407        }
408        Self::open(dir.join("runtime.sqlite3"))
409    }
410
411    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
412        let path = path.as_ref().to_path_buf();
413        if let Some(parent) = path.parent() {
414            std::fs::create_dir_all(parent).with_context(|| {
415                format!("failed to create SQLite parent dir {}", parent.display())
416            })?;
417        }
418        let conn = Connection::open(&path)
419            .with_context(|| format!("failed to open runtime DB {}", path.display()))?;
420        // The daemon, CLI, and per-turn effect tasks each open their own
421        // connection (often in separate processes). Without WAL + a busy
422        // timeout, a writer holding the DB makes a concurrent write fail
423        // immediately with SQLITE_BUSY (lost task/tool/approval updates).
424        // WAL allows concurrent readers with a single writer; busy_timeout
425        // serializes writers gracefully.
426        conn.busy_timeout(std::time::Duration::from_secs(5))
427            .context("failed to set SQLite busy_timeout")?;
428        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
429            .context("failed to enable SQLite WAL mode")?;
430        let store = Self { conn, path };
431        store.init_schema()?;
432        Ok(store)
433    }
434
435    pub fn path(&self) -> &Path {
436        &self.path
437    }
438
439    pub fn sessions(&self) -> SessionsRepo<'_> {
440        SessionsRepo { conn: &self.conn }
441    }
442
443    pub fn messages(&self) -> MessagesRepo<'_> {
444        MessagesRepo { conn: &self.conn }
445    }
446
447    pub fn tasks(&self) -> TasksRepo<'_> {
448        TasksRepo { conn: &self.conn }
449    }
450
451    pub fn tool_runs(&self) -> ToolRunsRepo<'_> {
452        ToolRunsRepo { conn: &self.conn }
453    }
454
455    pub fn approvals(&self) -> ApprovalsRepo<'_> {
456        ApprovalsRepo { conn: &self.conn }
457    }
458
459    pub fn processes(&self) -> ProcessesRepo<'_> {
460        ProcessesRepo { conn: &self.conn }
461    }
462
463    pub fn checkpoints(&self) -> CheckpointsRepo<'_> {
464        CheckpointsRepo { conn: &self.conn }
465    }
466
467    pub fn compactions(&self) -> CompactionsRepo<'_> {
468        CompactionsRepo { conn: &self.conn }
469    }
470
471    pub fn plugins(&self) -> PluginsRepo<'_> {
472        PluginsRepo { conn: &self.conn }
473    }
474
475    pub fn provider_probes(&self) -> ProviderProbesRepo<'_> {
476        ProviderProbesRepo { conn: &self.conn }
477    }
478
479    pub fn pairing_tokens(&self) -> PairingTokensRepo<'_> {
480        PairingTokensRepo { conn: &self.conn }
481    }
482
483    fn init_schema(&self) -> Result<()> {
484        self.conn.execute_batch(
485            r#"
486            PRAGMA foreign_keys = ON;
487
488            CREATE TABLE IF NOT EXISTS sessions (
489                id TEXT PRIMARY KEY,
490                project_path TEXT NOT NULL,
491                model_id TEXT NOT NULL,
492                title TEXT,
493                conversation_path TEXT,
494                created_at TEXT NOT NULL,
495                updated_at TEXT NOT NULL,
496                total_tokens INTEGER
497            );
498
499            CREATE TABLE IF NOT EXISTS messages (
500                id INTEGER PRIMARY KEY AUTOINCREMENT,
501                session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
502                role TEXT NOT NULL,
503                content_json TEXT NOT NULL,
504                created_at TEXT NOT NULL
505            );
506            CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
507
508            CREATE TABLE IF NOT EXISTS tasks (
509                id TEXT PRIMARY KEY,
510                title TEXT NOT NULL,
511                status TEXT NOT NULL,
512                priority TEXT NOT NULL,
513                project_path TEXT NOT NULL,
514                model_id TEXT NOT NULL,
515                conversation_id TEXT,
516                created_at TEXT NOT NULL,
517                updated_at TEXT NOT NULL,
518                final_report TEXT
519            );
520            CREATE INDEX IF NOT EXISTS idx_tasks_project_status
521                ON tasks(project_path, status, updated_at);
522
523            CREATE TABLE IF NOT EXISTS task_events (
524                id INTEGER PRIMARY KEY AUTOINCREMENT,
525                task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
526                kind TEXT NOT NULL,
527                message TEXT NOT NULL,
528                created_at TEXT NOT NULL
529            );
530            CREATE INDEX IF NOT EXISTS idx_task_events_task_id
531                ON task_events(task_id, id);
532
533            CREATE TABLE IF NOT EXISTS tool_runs (
534                id TEXT PRIMARY KEY,
535                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
536                turn_id TEXT,
537                call_id TEXT,
538                tool_name TEXT NOT NULL,
539                status TEXT NOT NULL,
540                args_json TEXT,
541                output_json TEXT,
542                started_at TEXT NOT NULL,
543                finished_at TEXT
544            );
545            CREATE INDEX IF NOT EXISTS idx_tool_runs_task_id ON tool_runs(task_id);
546
547            CREATE TABLE IF NOT EXISTS approvals (
548                id TEXT PRIMARY KEY,
549                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
550                proposed_action TEXT NOT NULL,
551                risk_classification TEXT NOT NULL,
552                policy_decision TEXT NOT NULL,
553                user_decision TEXT,
554                args_summary TEXT,
555                checkpoint_id TEXT,
556                pending_action_json TEXT,
557                created_at TEXT NOT NULL,
558                decided_at TEXT,
559                archived_at TEXT,
560                archive_reason TEXT
561            );
562            CREATE INDEX IF NOT EXISTS idx_approvals_task_id ON approvals(task_id);
563
564            CREATE TABLE IF NOT EXISTS processes (
565                id TEXT PRIMARY KEY,
566                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
567                pid INTEGER NOT NULL,
568                command TEXT NOT NULL,
569                cwd TEXT,
570                log_path TEXT,
571                detected_url TEXT,
572                status TEXT NOT NULL,
573                health TEXT,
574                created_at TEXT NOT NULL,
575                updated_at TEXT NOT NULL
576            );
577            CREATE INDEX IF NOT EXISTS idx_processes_task_id ON processes(task_id);
578            CREATE INDEX IF NOT EXISTS idx_processes_pid ON processes(pid);
579
580            CREATE TABLE IF NOT EXISTS checkpoints (
581                id TEXT PRIMARY KEY,
582                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
583                project_path TEXT NOT NULL,
584                snapshot_path TEXT NOT NULL,
585                changed_files_json TEXT NOT NULL,
586                pending_action_json TEXT,
587                approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL,
588                created_at TEXT NOT NULL,
589                archived_at TEXT,
590                archive_reason TEXT
591            );
592
593            CREATE TABLE IF NOT EXISTS compactions (
594                id TEXT PRIMARY KEY,
595                task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
596                session_id TEXT,
597                source_token_estimate INTEGER,
598                summary_token_count INTEGER,
599                preserved_turns INTEGER,
600                archive_path TEXT,
601                verification_status TEXT,
602                created_at TEXT NOT NULL
603            );
604
605            CREATE TABLE IF NOT EXISTS provider_probes (
606                provider TEXT NOT NULL,
607                model_id TEXT NOT NULL,
608                capability_key TEXT NOT NULL,
609                capability_value TEXT NOT NULL,
610                confidence TEXT NOT NULL,
611                error TEXT,
612                probed_at TEXT NOT NULL,
613                PRIMARY KEY (provider, model_id, capability_key)
614            );
615
616            CREATE TABLE IF NOT EXISTS plugin_installs (
617                id TEXT PRIMARY KEY,
618                name TEXT NOT NULL,
619                source TEXT NOT NULL,
620                version TEXT,
621                enabled INTEGER NOT NULL DEFAULT 1,
622                manifest_json TEXT NOT NULL,
623                installed_at TEXT NOT NULL,
624                updated_at TEXT NOT NULL
625            );
626
627            CREATE TABLE IF NOT EXISTS pairing_tokens (
628                id TEXT PRIMARY KEY,
629                token_hash TEXT NOT NULL,
630                label TEXT,
631                enabled INTEGER NOT NULL DEFAULT 1,
632                created_at TEXT NOT NULL,
633                last_used_at TEXT
634            );
635            CREATE INDEX IF NOT EXISTS idx_pairing_tokens_enabled
636                ON pairing_tokens(enabled, created_at);
637
638            PRAGMA user_version = 1;
639            "#,
640        )?;
641
642        ensure_column(&self.conn, "approvals", "pending_action_json", "TEXT")?;
643        ensure_column(&self.conn, "approvals", "archived_at", "TEXT")?;
644        ensure_column(&self.conn, "approvals", "archive_reason", "TEXT")?;
645        ensure_column(&self.conn, "checkpoints", "archived_at", "TEXT")?;
646        ensure_column(&self.conn, "checkpoints", "archive_reason", "TEXT")?;
647
648        let version: i32 = self
649            .conn
650            .query_row("PRAGMA user_version", [], |row| row.get(0))?;
651        anyhow::ensure!(
652            version == SCHEMA_VERSION,
653            "unsupported runtime DB schema version {} (expected {})",
654            version,
655            SCHEMA_VERSION
656        );
657        Ok(())
658    }
659}
660
661pub struct TasksRepo<'a> {
662    conn: &'a Connection,
663}
664
665pub struct SessionsRepo<'a> {
666    conn: &'a Connection,
667}
668
669impl SessionsRepo<'_> {
670    pub fn upsert(&self, new: NewSession) -> Result<SessionRecord> {
671        let now = now_rfc3339();
672        let id = new.id.unwrap_or_else(|| fresh_id("session"));
673        self.conn.execute(
674            "INSERT INTO sessions
675             (id, project_path, model_id, title, conversation_path, created_at, updated_at, total_tokens)
676             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
677             ON CONFLICT(id) DO UPDATE SET
678                project_path = excluded.project_path,
679                model_id = excluded.model_id,
680                title = excluded.title,
681                conversation_path = excluded.conversation_path,
682                updated_at = excluded.updated_at,
683                total_tokens = excluded.total_tokens",
684            params![
685                id,
686                new.project_path,
687                new.model_id,
688                new.title,
689                new.conversation_path,
690                now,
691                now,
692                new.total_tokens,
693            ],
694        )?;
695        self.get(&id)?
696            .context("session was upserted but could not be reloaded")
697    }
698
699    pub fn get(&self, id: &str) -> Result<Option<SessionRecord>> {
700        self.conn
701            .query_row(
702                "SELECT id, project_path, model_id, title, conversation_path,
703                        created_at, updated_at, total_tokens
704                 FROM sessions WHERE id = ?1",
705                [id],
706                session_from_row,
707            )
708            .optional()
709            .map_err(Into::into)
710    }
711
712    pub fn list(&self, limit: usize) -> Result<Vec<SessionRecord>> {
713        let mut stmt = self.conn.prepare(
714            "SELECT id, project_path, model_id, title, conversation_path,
715                    created_at, updated_at, total_tokens
716             FROM sessions ORDER BY updated_at DESC LIMIT ?1",
717        )?;
718        let rows = stmt.query_map([limit as i64], session_from_row)?;
719        rows.collect::<rusqlite::Result<Vec<_>>>()
720            .map_err(Into::into)
721    }
722}
723
724pub struct MessagesRepo<'a> {
725    conn: &'a Connection,
726}
727
728impl MessagesRepo<'_> {
729    pub fn add(&self, new: NewMessage) -> Result<MessageRecord> {
730        self.conn.execute(
731            "INSERT INTO messages (session_id, role, content_json, created_at)
732             VALUES (?1, ?2, ?3, ?4)",
733            params![new.session_id, new.role, new.content_json, now_rfc3339()],
734        )?;
735        let id = self.conn.last_insert_rowid();
736        self.get(id)?
737            .context("message was inserted but could not be reloaded")
738    }
739
740    pub fn get(&self, id: i64) -> Result<Option<MessageRecord>> {
741        self.conn
742            .query_row(
743                "SELECT id, session_id, role, content_json, created_at
744                 FROM messages WHERE id = ?1",
745                [id],
746                message_from_row,
747            )
748            .optional()
749            .map_err(Into::into)
750    }
751
752    pub fn list_for_session(&self, session_id: &str) -> Result<Vec<MessageRecord>> {
753        let mut stmt = self.conn.prepare(
754            "SELECT id, session_id, role, content_json, created_at
755             FROM messages WHERE session_id = ?1 ORDER BY id ASC",
756        )?;
757        let rows = stmt.query_map([session_id], message_from_row)?;
758        rows.collect::<rusqlite::Result<Vec<_>>>()
759            .map_err(Into::into)
760    }
761}
762
763impl TasksRepo<'_> {
764    pub fn create(&self, new: NewTask) -> Result<TaskRecord> {
765        let now = now_rfc3339();
766        let record = TaskRecord {
767            id: fresh_id("task"),
768            title: new.title,
769            status: TaskStatus::Queued,
770            priority: new.priority,
771            project_path: new.project_path,
772            model_id: new.model_id,
773            conversation_id: new.conversation_id,
774            created_at: now.clone(),
775            updated_at: now.clone(),
776            final_report: None,
777        };
778        self.conn.execute(
779            "INSERT INTO tasks
780             (id, title, status, priority, project_path, model_id, conversation_id, created_at, updated_at, final_report)
781             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
782            params![
783                record.id,
784                record.title,
785                record.status.as_str(),
786                record.priority.as_str(),
787                record.project_path,
788                record.model_id,
789                record.conversation_id,
790                record.created_at,
791                record.updated_at,
792                record.final_report,
793            ],
794        )?;
795        self.add_event(&record.id, "task_created", "task created")?;
796        self.get(&record.id)?
797            .context("task was inserted but could not be reloaded")
798    }
799
800    pub fn get(&self, id: &str) -> Result<Option<TaskRecord>> {
801        self.conn
802            .query_row(
803                "SELECT id, title, status, priority, project_path, model_id, conversation_id,
804                        created_at, updated_at, final_report
805                 FROM tasks WHERE id = ?1",
806                [id],
807                task_from_row,
808            )
809            .optional()
810            .map_err(Into::into)
811    }
812
813    pub fn list(&self, limit: usize) -> Result<Vec<TaskRecord>> {
814        let mut stmt = self.conn.prepare(
815            "SELECT id, title, status, priority, project_path, model_id, conversation_id,
816                    created_at, updated_at, final_report
817             FROM tasks
818             ORDER BY updated_at DESC
819             LIMIT ?1",
820        )?;
821        let rows = stmt.query_map([limit as i64], task_from_row)?;
822        rows.collect::<rusqlite::Result<Vec<_>>>()
823            .map_err(Into::into)
824    }
825
826    pub fn update_status(
827        &self,
828        id: &str,
829        status: TaskStatus,
830        final_report: Option<&str>,
831    ) -> Result<()> {
832        let now = now_rfc3339();
833        self.conn.execute(
834            "UPDATE tasks
835             SET status = ?2, updated_at = ?3, final_report = COALESCE(?4, final_report)
836             WHERE id = ?1",
837            params![id, status.as_str(), now, final_report],
838        )?;
839        self.add_event(
840            id,
841            "status_changed",
842            &format!("status changed to {}", status),
843        )?;
844        Ok(())
845    }
846
847    pub fn add_event(&self, task_id: &str, kind: &str, message: &str) -> Result<()> {
848        self.conn.execute(
849            "INSERT INTO task_events (task_id, kind, message, created_at)
850             VALUES (?1, ?2, ?3, ?4)",
851            params![task_id, kind, message, now_rfc3339()],
852        )?;
853        Ok(())
854    }
855
856    pub fn events(&self, task_id: &str) -> Result<Vec<TaskTimelineEvent>> {
857        let mut stmt = self.conn.prepare(
858            "SELECT id, task_id, kind, message, created_at
859             FROM task_events
860             WHERE task_id = ?1
861             ORDER BY id ASC",
862        )?;
863        let rows = stmt.query_map([task_id], |row| {
864            Ok(TaskTimelineEvent {
865                id: row.get("id")?,
866                task_id: row.get("task_id")?,
867                kind: row.get("kind")?,
868                message: row.get("message")?,
869                created_at: row.get("created_at")?,
870            })
871        })?;
872        rows.collect::<rusqlite::Result<Vec<_>>>()
873            .map_err(Into::into)
874    }
875}
876
877pub struct ToolRunsRepo<'a> {
878    conn: &'a Connection,
879}
880
881impl ToolRunsRepo<'_> {
882    pub fn start(&self, new: NewToolRun) -> Result<ToolRunRecord> {
883        let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
884        self.conn.execute(
885            "INSERT INTO tool_runs
886             (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
887             VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
888            params![
889                id,
890                new.task_id,
891                new.turn_id,
892                new.call_id,
893                new.tool_name,
894                new.args_json,
895                now_rfc3339(),
896            ],
897        )?;
898        self.get(&id)?
899            .context("tool run was inserted but could not be reloaded")
900    }
901
902    pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
903        let changed = self.conn.execute(
904            "UPDATE tool_runs
905             SET status = ?2, output_json = ?3, finished_at = ?4
906             WHERE id = ?1",
907            params![id, status, output_json, now_rfc3339()],
908        )?;
909        anyhow::ensure!(changed > 0, "tool run not found: {}", id);
910        Ok(())
911    }
912
913    pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
914        self.conn
915            .query_row(
916                "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
917                        output_json, started_at, finished_at
918                 FROM tool_runs WHERE id = ?1",
919                [id],
920                tool_run_from_row,
921            )
922            .optional()
923            .map_err(Into::into)
924    }
925
926    pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
927        let mut stmt = self.conn.prepare(
928            "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
929                    output_json, started_at, finished_at
930             FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
931        )?;
932        let rows = stmt.query_map([limit as i64], tool_run_from_row)?;
933        rows.collect::<rusqlite::Result<Vec<_>>>()
934            .map_err(Into::into)
935    }
936}
937
938pub struct ApprovalsRepo<'a> {
939    conn: &'a Connection,
940}
941
942impl ApprovalsRepo<'_> {
943    pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
944        let record = ApprovalRecord {
945            id: fresh_id("approval"),
946            task_id: new.task_id,
947            proposed_action: new.proposed_action,
948            risk_classification: new.risk_classification,
949            policy_decision: new.policy_decision,
950            user_decision: None,
951            args_summary: new.args_summary,
952            checkpoint_id: new.checkpoint_id,
953            pending_action_json: new.pending_action_json,
954            created_at: now_rfc3339(),
955            decided_at: None,
956            archived_at: None,
957            archive_reason: None,
958        };
959        self.conn.execute(
960            "INSERT INTO approvals
961             (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
962              args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
963             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
964            params![
965                record.id,
966                record.task_id,
967                record.proposed_action,
968                record.risk_classification,
969                record.policy_decision,
970                record.user_decision,
971                record.args_summary,
972                record.checkpoint_id,
973                record.pending_action_json,
974                record.created_at,
975                record.decided_at,
976            ],
977        )?;
978        self.get(&record.id)?
979            .context("approval was inserted but could not be reloaded")
980    }
981
982    pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
983        self.conn
984            .query_row(
985                "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
986                        user_decision, args_summary, checkpoint_id, pending_action_json,
987                        created_at, decided_at, archived_at, archive_reason
988                 FROM approvals WHERE id = ?1",
989                [id],
990                approval_from_row,
991            )
992            .optional()
993            .map_err(Into::into)
994    }
995
996    pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
997        let changed = self.conn.execute(
998            "UPDATE approvals
999             SET user_decision = ?2, decided_at = ?3
1000             WHERE id = ?1",
1001            params![id, user_decision, now_rfc3339()],
1002        )?;
1003        anyhow::ensure!(changed > 0, "approval not found: {}", id);
1004        Ok(())
1005    }
1006
1007    pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
1008        self.list_pending_with_archived(false)
1009    }
1010
1011    pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
1012        self.list_pending_with_archived(true)
1013    }
1014
1015    pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
1016        let mut stmt = self.conn.prepare(
1017            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1018                    user_decision, args_summary, checkpoint_id, pending_action_json,
1019                    created_at, decided_at, archived_at, archive_reason
1020             FROM approvals
1021             ORDER BY created_at DESC
1022             LIMIT ?1",
1023        )?;
1024        let rows = stmt.query_map([limit as i64], approval_from_row)?;
1025        rows.collect::<rusqlite::Result<Vec<_>>>()
1026            .map_err(Into::into)
1027    }
1028
1029    fn list_pending_with_archived(&self, include_archived: bool) -> Result<Vec<ApprovalRecord>> {
1030        let archived_filter = if include_archived {
1031            ""
1032        } else {
1033            " AND archived_at IS NULL"
1034        };
1035        let mut stmt = self.conn.prepare(&format!(
1036            "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1037                    user_decision, args_summary, checkpoint_id, pending_action_json,
1038                    created_at, decided_at, archived_at, archive_reason
1039             FROM approvals
1040             WHERE user_decision IS NULL{archived_filter}
1041             ORDER BY created_at DESC"
1042        ))?;
1043        let rows = stmt.query_map([], approval_from_row)?;
1044        rows.collect::<rusqlite::Result<Vec<_>>>()
1045            .map_err(Into::into)
1046    }
1047
1048    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1049        let archived_at = now_rfc3339();
1050        let mut changed = 0;
1051        for id in ids {
1052            changed += self.conn.execute(
1053                "UPDATE approvals
1054                 SET archived_at = COALESCE(archived_at, ?2),
1055                     archive_reason = COALESCE(archive_reason, ?3)
1056                 WHERE id = ?1 AND archived_at IS NULL",
1057                params![id, archived_at, reason],
1058            )?;
1059        }
1060        Ok(changed)
1061    }
1062
1063    pub fn count_archived(&self) -> Result<usize> {
1064        self.conn
1065            .query_row(
1066                "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
1067                [],
1068                |row| row.get::<_, i64>(0),
1069            )
1070            .map(|count| count as usize)
1071            .map_err(Into::into)
1072    }
1073}
1074
1075pub struct ProcessesRepo<'a> {
1076    conn: &'a Connection,
1077}
1078
1079impl ProcessesRepo<'_> {
1080    pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
1081        let now = now_rfc3339();
1082        let id = new.id.unwrap_or_else(|| fresh_id("process"));
1083        self.conn.execute(
1084            "INSERT INTO processes
1085             (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
1086             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1087             ON CONFLICT(id) DO UPDATE SET
1088                task_id = excluded.task_id,
1089                pid = excluded.pid,
1090                command = excluded.command,
1091                cwd = excluded.cwd,
1092                log_path = excluded.log_path,
1093                detected_url = excluded.detected_url,
1094                status = excluded.status,
1095                health = excluded.health,
1096                updated_at = excluded.updated_at",
1097            params![
1098                id,
1099                new.task_id,
1100                new.pid,
1101                new.command,
1102                new.cwd,
1103                new.log_path,
1104                new.detected_url,
1105                new.status.as_str(),
1106                new.health,
1107                now,
1108                now,
1109            ],
1110        )?;
1111        self.get(&id)?
1112            .context("process was upserted but could not be reloaded")
1113    }
1114
1115    pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
1116        self.conn
1117            .query_row(
1118                "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1119                        created_at, updated_at
1120                 FROM processes WHERE id = ?1",
1121                [id],
1122                process_from_row,
1123            )
1124            .optional()
1125            .map_err(Into::into)
1126    }
1127
1128    pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1129        let mut stmt = self.conn.prepare(
1130            "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1131                    created_at, updated_at
1132             FROM processes
1133             ORDER BY updated_at DESC
1134             LIMIT ?1",
1135        )?;
1136        let rows = stmt.query_map([limit as i64], process_from_row)?;
1137        rows.collect::<rusqlite::Result<Vec<_>>>()
1138            .map_err(Into::into)
1139    }
1140}
1141
1142pub struct CheckpointsRepo<'a> {
1143    conn: &'a Connection,
1144}
1145
1146impl CheckpointsRepo<'_> {
1147    pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
1148        let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
1149        self.conn.execute(
1150            "INSERT INTO checkpoints
1151             (id, task_id, project_path, snapshot_path, changed_files_json,
1152              pending_action_json, approval_id, created_at)
1153             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1154            params![
1155                id,
1156                new.task_id,
1157                new.project_path,
1158                new.snapshot_path,
1159                new.changed_files_json,
1160                new.pending_action_json,
1161                new.approval_id,
1162                now_rfc3339(),
1163            ],
1164        )?;
1165        self.get(&id)?
1166            .context("checkpoint was inserted but could not be reloaded")
1167    }
1168
1169    pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
1170        self.conn
1171            .query_row(
1172                "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1173                        pending_action_json, approval_id, created_at, archived_at, archive_reason
1174                 FROM checkpoints WHERE id = ?1",
1175                [id],
1176                checkpoint_from_row,
1177            )
1178            .optional()
1179            .map_err(Into::into)
1180    }
1181
1182    pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
1183        let changed = self.conn.execute(
1184            "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
1185            params![id, approval_id],
1186        )?;
1187        anyhow::ensure!(changed > 0, "checkpoint not found: {}", id);
1188        Ok(())
1189    }
1190
1191    pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1192        self.list_with_archived(limit, false)
1193    }
1194
1195    pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1196        self.list_with_archived(limit, true)
1197    }
1198
1199    fn list_with_archived(
1200        &self,
1201        limit: usize,
1202        include_archived: bool,
1203    ) -> Result<Vec<CheckpointRecord>> {
1204        let archived_filter = if include_archived {
1205            ""
1206        } else {
1207            "WHERE archived_at IS NULL"
1208        };
1209        let mut stmt = self.conn.prepare(&format!(
1210            "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1211                    pending_action_json, approval_id, created_at, archived_at, archive_reason
1212             FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
1213        ))?;
1214        let rows = stmt.query_map([limit as i64], checkpoint_from_row)?;
1215        rows.collect::<rusqlite::Result<Vec<_>>>()
1216            .map_err(Into::into)
1217    }
1218
1219    pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1220        let archived_at = now_rfc3339();
1221        let mut changed = 0;
1222        for id in ids {
1223            changed += self.conn.execute(
1224                "UPDATE checkpoints
1225                 SET archived_at = COALESCE(archived_at, ?2),
1226                     archive_reason = COALESCE(archive_reason, ?3)
1227                 WHERE id = ?1 AND archived_at IS NULL",
1228                params![id, archived_at, reason],
1229            )?;
1230        }
1231        Ok(changed)
1232    }
1233
1234    pub fn count_archived(&self) -> Result<usize> {
1235        self.conn
1236            .query_row(
1237                "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1238                [],
1239                |row| row.get::<_, i64>(0),
1240            )
1241            .map(|count| count as usize)
1242            .map_err(Into::into)
1243    }
1244}
1245
1246pub struct CompactionsRepo<'a> {
1247    conn: &'a Connection,
1248}
1249
1250impl CompactionsRepo<'_> {
1251    pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
1252        let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
1253        self.conn.execute(
1254            "INSERT INTO compactions
1255             (id, task_id, session_id, source_token_estimate, summary_token_count,
1256              preserved_turns, archive_path, verification_status, created_at)
1257             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1258             ON CONFLICT(id) DO UPDATE SET
1259                task_id = excluded.task_id,
1260                session_id = excluded.session_id,
1261                source_token_estimate = excluded.source_token_estimate,
1262                summary_token_count = excluded.summary_token_count,
1263                preserved_turns = excluded.preserved_turns,
1264                archive_path = excluded.archive_path,
1265                verification_status = excluded.verification_status",
1266            params![
1267                id,
1268                new.task_id,
1269                new.session_id,
1270                new.source_token_estimate,
1271                new.summary_token_count,
1272                new.preserved_turns,
1273                new.archive_path,
1274                new.verification_status,
1275                now_rfc3339(),
1276            ],
1277        )?;
1278        self.get(&id)?
1279            .context("compaction was inserted but could not be reloaded")
1280    }
1281
1282    pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
1283        self.conn
1284            .query_row(
1285                "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1286                        preserved_turns, archive_path, verification_status, created_at
1287                 FROM compactions WHERE id = ?1",
1288                [id],
1289                compaction_from_row,
1290            )
1291            .optional()
1292            .map_err(Into::into)
1293    }
1294
1295    pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
1296        let mut stmt = self.conn.prepare(
1297            "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1298                    preserved_turns, archive_path, verification_status, created_at
1299             FROM compactions ORDER BY created_at DESC LIMIT ?1",
1300        )?;
1301        let rows = stmt.query_map([limit as i64], compaction_from_row)?;
1302        rows.collect::<rusqlite::Result<Vec<_>>>()
1303            .map_err(Into::into)
1304    }
1305}
1306
1307pub struct PluginsRepo<'a> {
1308    conn: &'a Connection,
1309}
1310
1311impl PluginsRepo<'_> {
1312    pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
1313        let now = now_rfc3339();
1314        let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
1315        self.conn.execute(
1316            "INSERT INTO plugin_installs
1317             (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
1318             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1319             ON CONFLICT(id) DO UPDATE SET
1320                name = excluded.name,
1321                source = excluded.source,
1322                version = excluded.version,
1323                enabled = excluded.enabled,
1324                manifest_json = excluded.manifest_json,
1325                updated_at = excluded.updated_at",
1326            params![
1327                id,
1328                new.name,
1329                new.source,
1330                new.version,
1331                if new.enabled { 1 } else { 0 },
1332                new.manifest_json,
1333                now,
1334                now,
1335            ],
1336        )?;
1337        self.get(&id)?
1338            .context("plugin install was inserted but could not be reloaded")
1339    }
1340
1341    pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
1342        self.conn
1343            .query_row(
1344                "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1345                 FROM plugin_installs WHERE id = ?1",
1346                [id],
1347                plugin_from_row,
1348            )
1349            .optional()
1350            .map_err(Into::into)
1351    }
1352
1353    pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
1354        let mut stmt = self.conn.prepare(
1355            "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1356             FROM plugin_installs ORDER BY name ASC",
1357        )?;
1358        let rows = stmt.query_map([], plugin_from_row)?;
1359        rows.collect::<rusqlite::Result<Vec<_>>>()
1360            .map_err(Into::into)
1361    }
1362
1363    pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1364        self.conn.execute(
1365            "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
1366            params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
1367        )?;
1368        Ok(())
1369    }
1370}
1371
1372pub struct ProviderProbesRepo<'a> {
1373    conn: &'a Connection,
1374}
1375
1376impl ProviderProbesRepo<'_> {
1377    pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
1378        let now = now_rfc3339();
1379        let provider = new.provider;
1380        let model_id = new.model_id;
1381        let capability_key = new.capability_key;
1382        self.conn.execute(
1383            "INSERT INTO provider_probes
1384             (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
1385             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1386             ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
1387                capability_value = excluded.capability_value,
1388                confidence = excluded.confidence,
1389                error = excluded.error,
1390                probed_at = excluded.probed_at",
1391            params![
1392                &provider,
1393                &model_id,
1394                &capability_key,
1395                new.capability_value,
1396                new.confidence,
1397                new.error,
1398                now,
1399            ],
1400        )?;
1401        self.get(&provider, &model_id, &capability_key)?
1402            .context("provider probe was inserted but could not be reloaded")
1403    }
1404
1405    pub fn get(
1406        &self,
1407        provider: &str,
1408        model_id: &str,
1409        capability_key: &str,
1410    ) -> Result<Option<ProviderProbeRecord>> {
1411        self.conn
1412            .query_row(
1413                "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1414                 FROM provider_probes
1415                 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
1416                params![provider, model_id, capability_key],
1417                provider_probe_from_row,
1418            )
1419            .optional()
1420            .map_err(Into::into)
1421    }
1422
1423    pub fn list(
1424        &self,
1425        provider: Option<&str>,
1426        model_id: Option<&str>,
1427    ) -> Result<Vec<ProviderProbeRecord>> {
1428        let mut stmt = self.conn.prepare(
1429            "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1430             FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
1431        )?;
1432        let rows = stmt.query_map([], provider_probe_from_row)?;
1433        let mut out = Vec::new();
1434        for row in rows {
1435            let probe = row?;
1436            if provider.is_some_and(|p| probe.provider != p) {
1437                continue;
1438            }
1439            if model_id.is_some_and(|m| probe.model_id != m) {
1440                continue;
1441            }
1442            out.push(probe);
1443        }
1444        Ok(out)
1445    }
1446}
1447
1448pub struct PairingTokensRepo<'a> {
1449    conn: &'a Connection,
1450}
1451
1452impl PairingTokensRepo<'_> {
1453    pub fn create(&self, token_hash: &str, label: Option<&str>) -> Result<PairingTokenRecord> {
1454        let id = fresh_id("pairing");
1455        self.conn.execute(
1456            "INSERT INTO pairing_tokens (id, token_hash, label, enabled, created_at, last_used_at)
1457             VALUES (?1, ?2, ?3, 1, ?4, NULL)",
1458            params![id, token_hash, label, now_rfc3339()],
1459        )?;
1460        self.get(&id)?
1461            .context("pairing token was inserted but could not be reloaded")
1462    }
1463
1464    pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
1465        self.conn
1466            .query_row(
1467                "SELECT id, token_hash, label, enabled, created_at, last_used_at
1468                 FROM pairing_tokens WHERE id = ?1",
1469                [id],
1470                pairing_from_row,
1471            )
1472            .optional()
1473            .map_err(Into::into)
1474    }
1475
1476    pub fn get_enabled_by_hash(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
1477        self.conn
1478            .query_row(
1479                "SELECT id, token_hash, label, enabled, created_at, last_used_at
1480                 FROM pairing_tokens WHERE token_hash = ?1 AND enabled = 1",
1481                [token_hash],
1482                pairing_from_row,
1483            )
1484            .optional()
1485            .map_err(Into::into)
1486    }
1487
1488    pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
1489        let mut stmt = self.conn.prepare(
1490            "SELECT id, token_hash, label, enabled, created_at, last_used_at
1491             FROM pairing_tokens ORDER BY created_at DESC",
1492        )?;
1493        let rows = stmt.query_map([], pairing_from_row)?;
1494        rows.collect::<rusqlite::Result<Vec<_>>>()
1495            .map_err(Into::into)
1496    }
1497
1498    pub fn mark_used(&self, id: &str) -> Result<()> {
1499        self.conn.execute(
1500            "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
1501            params![id, now_rfc3339()],
1502        )?;
1503        Ok(())
1504    }
1505}
1506
1507fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
1508    let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
1509    let mut rows = stmt.query([])?;
1510    while let Some(row) = rows.next()? {
1511        let name: String = row.get(1)?;
1512        if name == column {
1513            return Ok(());
1514        }
1515    }
1516    conn.execute(
1517        &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
1518        [],
1519    )?;
1520    Ok(())
1521}
1522
1523pub fn data_dir() -> Result<PathBuf> {
1524    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1525        return Ok(proj_dirs.data_dir().to_path_buf());
1526    }
1527    let home = std::env::var("HOME")
1528        .or_else(|_| std::env::var("USERPROFILE"))
1529        .context("could not determine home directory")?;
1530    Ok(PathBuf::from(home).join(".local/share/mermaid"))
1531}
1532
1533fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
1534    Ok(SessionRecord {
1535        id: row.get("id")?,
1536        project_path: row.get("project_path")?,
1537        model_id: row.get("model_id")?,
1538        title: row.get("title")?,
1539        conversation_path: row.get("conversation_path")?,
1540        created_at: row.get("created_at")?,
1541        updated_at: row.get("updated_at")?,
1542        total_tokens: row.get("total_tokens")?,
1543    })
1544}
1545
1546fn message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRecord> {
1547    Ok(MessageRecord {
1548        id: row.get("id")?,
1549        session_id: row.get("session_id")?,
1550        role: row.get("role")?,
1551        content_json: row.get("content_json")?,
1552        created_at: row.get("created_at")?,
1553    })
1554}
1555
1556fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
1557    let status_raw: String = row.get("status")?;
1558    let priority_raw: String = row.get("priority")?;
1559    Ok(TaskRecord {
1560        id: row.get("id")?,
1561        title: row.get("title")?,
1562        status: TaskStatus::from_db(&status_raw)
1563            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
1564        priority: TaskPriority::from_db(&priority_raw)
1565            .map_err(|e| enum_from_sql_error("priority", priority_raw, e))?,
1566        project_path: row.get("project_path")?,
1567        model_id: row.get("model_id")?,
1568        conversation_id: row.get("conversation_id")?,
1569        created_at: row.get("created_at")?,
1570        updated_at: row.get("updated_at")?,
1571        final_report: row.get("final_report")?,
1572    })
1573}
1574
1575fn process_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProcessRecord> {
1576    let status_raw: String = row.get("status")?;
1577    let pid: i64 = row.get("pid")?;
1578    Ok(ProcessRecord {
1579        id: row.get("id")?,
1580        task_id: row.get("task_id")?,
1581        pid: pid as u32,
1582        command: row.get("command")?,
1583        cwd: row.get("cwd")?,
1584        log_path: row.get("log_path")?,
1585        detected_url: row.get("detected_url")?,
1586        status: ProcessStatus::from_db(&status_raw)
1587            .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
1588        health: row.get("health")?,
1589        created_at: row.get("created_at")?,
1590        updated_at: row.get("updated_at")?,
1591    })
1592}
1593
1594fn tool_run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ToolRunRecord> {
1595    Ok(ToolRunRecord {
1596        id: row.get("id")?,
1597        task_id: row.get("task_id")?,
1598        turn_id: row.get("turn_id")?,
1599        call_id: row.get("call_id")?,
1600        tool_name: row.get("tool_name")?,
1601        status: row.get("status")?,
1602        args_json: row.get("args_json")?,
1603        output_json: row.get("output_json")?,
1604        started_at: row.get("started_at")?,
1605        finished_at: row.get("finished_at")?,
1606    })
1607}
1608
1609fn approval_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApprovalRecord> {
1610    Ok(ApprovalRecord {
1611        id: row.get("id")?,
1612        task_id: row.get("task_id")?,
1613        proposed_action: row.get("proposed_action")?,
1614        risk_classification: row.get("risk_classification")?,
1615        policy_decision: row.get("policy_decision")?,
1616        user_decision: row.get("user_decision")?,
1617        args_summary: row.get("args_summary")?,
1618        checkpoint_id: row.get("checkpoint_id")?,
1619        pending_action_json: row.get("pending_action_json")?,
1620        created_at: row.get("created_at")?,
1621        decided_at: row.get("decided_at")?,
1622        archived_at: row.get("archived_at")?,
1623        archive_reason: row.get("archive_reason")?,
1624    })
1625}
1626
1627fn checkpoint_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointRecord> {
1628    Ok(CheckpointRecord {
1629        id: row.get("id")?,
1630        task_id: row.get("task_id")?,
1631        project_path: row.get("project_path")?,
1632        snapshot_path: row.get("snapshot_path")?,
1633        changed_files_json: row.get("changed_files_json")?,
1634        pending_action_json: row.get("pending_action_json")?,
1635        approval_id: row.get("approval_id")?,
1636        created_at: row.get("created_at")?,
1637        archived_at: row.get("archived_at")?,
1638        archive_reason: row.get("archive_reason")?,
1639    })
1640}
1641
1642fn compaction_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CompactionRecord> {
1643    Ok(CompactionRecord {
1644        id: row.get("id")?,
1645        task_id: row.get("task_id")?,
1646        session_id: row.get("session_id")?,
1647        source_token_estimate: row.get("source_token_estimate")?,
1648        summary_token_count: row.get("summary_token_count")?,
1649        preserved_turns: row.get("preserved_turns")?,
1650        archive_path: row.get("archive_path")?,
1651        verification_status: row.get("verification_status")?,
1652        created_at: row.get("created_at")?,
1653    })
1654}
1655
1656fn plugin_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PluginInstallRecord> {
1657    let enabled: i64 = row.get("enabled")?;
1658    Ok(PluginInstallRecord {
1659        id: row.get("id")?,
1660        name: row.get("name")?,
1661        source: row.get("source")?,
1662        version: row.get("version")?,
1663        enabled: enabled != 0,
1664        manifest_json: row.get("manifest_json")?,
1665        installed_at: row.get("installed_at")?,
1666        updated_at: row.get("updated_at")?,
1667    })
1668}
1669
1670fn provider_probe_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProviderProbeRecord> {
1671    Ok(ProviderProbeRecord {
1672        provider: row.get("provider")?,
1673        model_id: row.get("model_id")?,
1674        capability_key: row.get("capability_key")?,
1675        capability_value: row.get("capability_value")?,
1676        confidence: row.get("confidence")?,
1677        error: row.get("error")?,
1678        probed_at: row.get("probed_at")?,
1679    })
1680}
1681
1682fn pairing_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PairingTokenRecord> {
1683    let enabled: i64 = row.get("enabled")?;
1684    Ok(PairingTokenRecord {
1685        id: row.get("id")?,
1686        token_hash: row.get("token_hash")?,
1687        label: row.get("label")?,
1688        enabled: enabled != 0,
1689        created_at: row.get("created_at")?,
1690        last_used_at: row.get("last_used_at")?,
1691    })
1692}
1693
1694fn enum_from_sql_error(
1695    column: &'static str,
1696    value: String,
1697    source: UnknownRuntimeEnum,
1698) -> rusqlite::Error {
1699    let _ = value;
1700    rusqlite::Error::FromSqlConversionFailure(column_index(column), Type::Text, Box::new(source))
1701}
1702
1703fn column_index(column: &str) -> usize {
1704    match column {
1705        "status" => 2,
1706        "priority" => 3,
1707        _ => 0,
1708    }
1709}
1710
1711#[derive(Debug)]
1712struct UnknownRuntimeEnum {
1713    kind: &'static str,
1714    value: String,
1715}
1716
1717impl UnknownRuntimeEnum {
1718    fn new(kind: &'static str, value: &str) -> Self {
1719        Self {
1720            kind,
1721            value: value.to_string(),
1722        }
1723    }
1724}
1725
1726impl fmt::Display for UnknownRuntimeEnum {
1727    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1728        write!(f, "unknown {} value `{}`", self.kind, self.value)
1729    }
1730}
1731
1732impl std::error::Error for UnknownRuntimeEnum {}
1733
1734fn now_rfc3339() -> String {
1735    chrono::Utc::now().to_rfc3339()
1736}
1737
1738fn fresh_id(prefix: &str) -> String {
1739    let nanos = SystemTime::now()
1740        .duration_since(UNIX_EPOCH)
1741        .map(|d| d.as_nanos())
1742        .unwrap_or_default();
1743    format!("{}-{:x}", prefix, nanos)
1744}
1745
1746#[cfg(test)]
1747mod tests {
1748    use super::*;
1749
1750    #[test]
1751    fn open_enables_wal_and_busy_timeout() {
1752        // H19: every connection must use WAL so daemon/CLI/effect writers
1753        // don't hit a hard SQLITE_BUSY.
1754        let path = temp_db("wal_check");
1755        let store = RuntimeStore::open(&path).expect("open");
1756        let mode: String = store
1757            .conn
1758            .query_row("PRAGMA journal_mode", [], |r| r.get(0))
1759            .expect("journal_mode pragma");
1760        assert_eq!(mode.to_lowercase(), "wal");
1761    }
1762
1763    fn temp_db(name: &str) -> PathBuf {
1764        let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{}", name));
1765        let _ = std::fs::remove_dir_all(&dir);
1766        std::fs::create_dir_all(&dir).expect("create temp dir");
1767        dir.join("runtime.sqlite3")
1768    }
1769
1770    #[test]
1771    fn initializes_runtime_schema() {
1772        let path = temp_db("schema");
1773        let store = RuntimeStore::open(&path).expect("open store");
1774        assert_eq!(store.path(), path.as_path());
1775        let version: i32 = store
1776            .conn
1777            .query_row("PRAGMA user_version", [], |row| row.get(0))
1778            .unwrap();
1779        assert_eq!(version, SCHEMA_VERSION);
1780        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1781    }
1782
1783    #[test]
1784    fn task_lifecycle_round_trips() {
1785        let path = temp_db("task");
1786        let store = RuntimeStore::open(&path).expect("open store");
1787        let session = store
1788            .sessions()
1789            .upsert(NewSession {
1790                id: Some("session-1".to_string()),
1791                project_path: "/repo".to_string(),
1792                model_id: "anthropic/claude".to_string(),
1793                title: Some("Run tests".to_string()),
1794                conversation_path: Some("/repo/.mermaid/session.json".to_string()),
1795                total_tokens: Some(42),
1796            })
1797            .expect("upsert session");
1798        assert_eq!(session.id, "session-1");
1799        let message = store
1800            .messages()
1801            .add(NewMessage {
1802                session_id: session.id.clone(),
1803                role: "user".to_string(),
1804                content_json: "{\"text\":\"hi\"}".to_string(),
1805            })
1806            .expect("add message");
1807        assert_eq!(message.role, "user");
1808        assert_eq!(
1809            store
1810                .messages()
1811                .list_for_session(&session.id)
1812                .unwrap()
1813                .len(),
1814            1
1815        );
1816
1817        let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
1818        new.priority = TaskPriority::High;
1819        let task = store.tasks().create(new).expect("create task");
1820
1821        assert_eq!(task.status, TaskStatus::Queued);
1822        assert_eq!(task.priority, TaskPriority::High);
1823
1824        store
1825            .tasks()
1826            .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
1827            .expect("update task");
1828        let loaded = store.tasks().get(&task.id).unwrap().unwrap();
1829        assert_eq!(loaded.status, TaskStatus::Completed);
1830        assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
1831
1832        let events = store.tasks().events(&task.id).expect("events");
1833        assert_eq!(events.len(), 2);
1834        assert_eq!(events[0].kind, "task_created");
1835        assert_eq!(events[1].kind, "status_changed");
1836        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1837    }
1838
1839    #[test]
1840    fn approval_and_process_records_round_trip() {
1841        let path = temp_db("approval_process");
1842        let store = RuntimeStore::open(&path).expect("open store");
1843        let task = store
1844            .tasks()
1845            .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
1846            .expect("create task");
1847
1848        let approval = store
1849            .approvals()
1850            .create(NewApproval {
1851                task_id: Some(task.id.clone()),
1852                proposed_action: "write_file src/lib.rs".to_string(),
1853                risk_classification: "file_mutation".to_string(),
1854                policy_decision: "ask".to_string(),
1855                args_summary: Some("src/lib.rs".to_string()),
1856                checkpoint_id: Some("checkpoint-1".to_string()),
1857                pending_action_json: Some(
1858                    "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
1859                ),
1860            })
1861            .expect("create approval");
1862        store
1863            .approvals()
1864            .decide(&approval.id, "approved")
1865            .expect("decide approval");
1866        let approval = store.approvals().get(&approval.id).unwrap().unwrap();
1867        assert_eq!(approval.user_decision.as_deref(), Some("approved"));
1868        assert!(approval.pending_action_json.is_some());
1869
1870        let tool_run = store
1871            .tool_runs()
1872            .start(NewToolRun {
1873                id: Some("toolrun-1".to_string()),
1874                task_id: Some(task.id.clone()),
1875                turn_id: Some("turn-1".to_string()),
1876                call_id: Some("call-1".to_string()),
1877                tool_name: "write_file".to_string(),
1878                args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
1879            })
1880            .expect("start tool run");
1881        assert_eq!(tool_run.status, "running");
1882        store
1883            .tool_runs()
1884            .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
1885            .expect("finish tool run");
1886        let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
1887        assert_eq!(tool_run.status, "success");
1888        assert!(tool_run.finished_at.is_some());
1889
1890        let process = store
1891            .processes()
1892            .upsert(NewProcess {
1893                id: Some("proc-1".to_string()),
1894                task_id: Some(task.id),
1895                pid: 123,
1896                command: "npm run dev".to_string(),
1897                cwd: Some("/repo".to_string()),
1898                log_path: Some("/tmp/mermaid.log".to_string()),
1899                detected_url: Some("http://127.0.0.1:5173".to_string()),
1900                status: ProcessStatus::Running,
1901                health: Some("ready".to_string()),
1902            })
1903            .expect("upsert process");
1904        assert_eq!(process.status, ProcessStatus::Running);
1905        assert_eq!(store.processes().list(10).unwrap().len(), 1);
1906        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1907    }
1908
1909    #[test]
1910    fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
1911        let path = temp_db("archive_visibility");
1912        let store = RuntimeStore::open(&path).expect("open store");
1913
1914        let approval = store
1915            .approvals()
1916            .create(NewApproval {
1917                task_id: None,
1918                proposed_action: "restore replay: write_file".to_string(),
1919                risk_classification: "restored_action".to_string(),
1920                policy_decision: "ask".to_string(),
1921                args_summary: None,
1922                checkpoint_id: Some("checkpoint-1".to_string()),
1923                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1924            })
1925            .expect("create approval");
1926        let checkpoint = store
1927            .checkpoints()
1928            .create(NewCheckpoint {
1929                id: Some("checkpoint-1".to_string()),
1930                task_id: None,
1931                project_path: "/tmp/mermaid_checkpoint_test".to_string(),
1932                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
1933                changed_files_json: "[]".to_string(),
1934                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1935                approval_id: Some(approval.id.clone()),
1936            })
1937            .expect("create checkpoint");
1938
1939        assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
1940        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
1941        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
1942        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
1943        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
1944
1945        assert_eq!(
1946            store
1947                .approvals()
1948                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
1949                .unwrap(),
1950            1
1951        );
1952        assert_eq!(
1953            store
1954                .checkpoints()
1955                .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
1956                .unwrap(),
1957            1
1958        );
1959        assert_eq!(
1960            store
1961                .approvals()
1962                .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
1963                .unwrap(),
1964            0
1965        );
1966        assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
1967        assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
1968        assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
1969        assert_eq!(store.approvals().count_archived().unwrap(), 1);
1970        assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
1971        assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
1972        assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
1973
1974        let archived = store.approvals().get(&approval.id).unwrap().unwrap();
1975        assert!(archived.archived_at.is_some());
1976        assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
1977        let _ = std::fs::remove_dir_all(path.parent().unwrap());
1978    }
1979
1980    #[test]
1981    fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
1982        let path = temp_db("everything_else");
1983        let store = RuntimeStore::open(&path).expect("open store");
1984
1985        let checkpoint = store
1986            .checkpoints()
1987            .create(NewCheckpoint {
1988                id: Some("checkpoint-1".to_string()),
1989                task_id: None,
1990                project_path: "/repo".to_string(),
1991                snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
1992                changed_files_json: "[\"src/lib.rs\"]".to_string(),
1993                pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1994                approval_id: None,
1995            })
1996            .expect("create checkpoint");
1997        assert_eq!(checkpoint.id, "checkpoint-1");
1998        assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
1999
2000        let compaction = store
2001            .compactions()
2002            .create(NewCompaction {
2003                id: Some("compaction-1".to_string()),
2004                task_id: None,
2005                session_id: Some("session-1".to_string()),
2006                source_token_estimate: Some(10_000),
2007                summary_token_count: Some(800),
2008                preserved_turns: Some(6),
2009                archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
2010                verification_status: Some("verified".to_string()),
2011            })
2012            .expect("create compaction");
2013        assert_eq!(compaction.summary_token_count, Some(800));
2014        assert_eq!(store.compactions().list(10).unwrap().len(), 1);
2015
2016        let plugin = store
2017            .plugins()
2018            .install(NewPluginInstall {
2019                id: Some("plugin-1".to_string()),
2020                name: "example".to_string(),
2021                source: "local".to_string(),
2022                version: Some("0.1.0".to_string()),
2023                enabled: true,
2024                manifest_json: "{\"name\":\"example\"}".to_string(),
2025            })
2026            .expect("install plugin");
2027        assert!(plugin.enabled);
2028        store.plugins().set_enabled("plugin-1", false).unwrap();
2029        assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
2030
2031        let probe = store
2032            .provider_probes()
2033            .upsert(NewProviderProbe {
2034                provider: "cerebras".to_string(),
2035                model_id: "gpt-oss-120b".to_string(),
2036                capability_key: "parallel_tool_calls".to_string(),
2037                capability_value: "false".to_string(),
2038                confidence: "static".to_string(),
2039                error: None,
2040            })
2041            .expect("probe");
2042        assert_eq!(probe.confidence, "static");
2043        assert_eq!(
2044            store
2045                .provider_probes()
2046                .list(Some("cerebras"), Some("gpt-oss-120b"))
2047                .unwrap()
2048                .len(),
2049            1
2050        );
2051
2052        let pairing = store
2053            .pairing_tokens()
2054            .create("hash", Some("phone"))
2055            .expect("pairing");
2056        store.pairing_tokens().mark_used(&pairing.id).unwrap();
2057        assert!(
2058            store
2059                .pairing_tokens()
2060                .get(&pairing.id)
2061                .unwrap()
2062                .unwrap()
2063                .last_used_at
2064                .is_some()
2065        );
2066        let _ = std::fs::remove_dir_all(path.parent().unwrap());
2067    }
2068}