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#[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
411pub 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 #[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 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 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}