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