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