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