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 = 3;
23
24const OWNER_KIND_DAEMON: &str = "daemon";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum TaskStatus {
35 Queued,
36 Running,
37 WaitingForApproval,
38 Blocked,
39 Completed,
40 Failed,
41 Cancelled,
42}
43
44impl TaskStatus {
45 pub fn as_str(self) -> &'static str {
46 match self {
47 TaskStatus::Queued => "queued",
48 TaskStatus::Running => "running",
49 TaskStatus::WaitingForApproval => "waiting_for_approval",
50 TaskStatus::Blocked => "blocked",
51 TaskStatus::Completed => "completed",
52 TaskStatus::Failed => "failed",
53 TaskStatus::Cancelled => "cancelled",
54 }
55 }
56
57 fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
58 match value {
59 "queued" => Ok(TaskStatus::Queued),
60 "running" => Ok(TaskStatus::Running),
61 "waiting_for_approval" => Ok(TaskStatus::WaitingForApproval),
62 "blocked" => Ok(TaskStatus::Blocked),
63 "completed" => Ok(TaskStatus::Completed),
64 "failed" => Ok(TaskStatus::Failed),
65 "cancelled" => Ok(TaskStatus::Cancelled),
66 other => Err(UnknownRuntimeEnum::new("task status", other)),
67 }
68 }
69}
70
71impl fmt::Display for TaskStatus {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str(self.as_str())
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum TaskPriority {
80 Low,
81 Normal,
82 High,
83}
84
85impl TaskPriority {
86 pub fn as_str(self) -> &'static str {
87 match self {
88 TaskPriority::Low => "low",
89 TaskPriority::Normal => "normal",
90 TaskPriority::High => "high",
91 }
92 }
93
94 fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
95 match value {
96 "low" => Ok(TaskPriority::Low),
97 "normal" => Ok(TaskPriority::Normal),
98 "high" => Ok(TaskPriority::High),
99 other => Err(UnknownRuntimeEnum::new("task priority", other)),
100 }
101 }
102}
103
104impl fmt::Display for TaskPriority {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.write_str(self.as_str())
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum ProcessStatus {
113 Running,
114 Exited,
115 Unknown,
116}
117
118impl ProcessStatus {
119 pub fn as_str(self) -> &'static str {
120 match self {
121 ProcessStatus::Running => "running",
122 ProcessStatus::Exited => "exited",
123 ProcessStatus::Unknown => "unknown",
124 }
125 }
126
127 fn from_db(value: &str) -> std::result::Result<Self, UnknownRuntimeEnum> {
128 match value {
129 "running" => Ok(ProcessStatus::Running),
130 "exited" => Ok(ProcessStatus::Exited),
131 "unknown" => Ok(ProcessStatus::Unknown),
132 other => Err(UnknownRuntimeEnum::new("process status", other)),
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct TaskRecord {
139 pub id: String,
140 pub title: String,
141 pub status: TaskStatus,
142 pub priority: TaskPriority,
143 pub project_path: String,
144 pub model_id: String,
145 pub conversation_id: Option<String>,
146 pub created_at: String,
147 pub updated_at: String,
148 pub final_report: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct TaskTimelineEvent {
153 pub id: i64,
154 pub task_id: String,
155 pub kind: String,
156 pub message: String,
157 pub created_at: String,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct SessionRecord {
162 pub id: String,
163 pub project_path: String,
164 pub model_id: String,
165 pub title: Option<String>,
166 pub conversation_path: Option<String>,
167 pub created_at: String,
168 pub updated_at: String,
169 pub total_tokens: Option<i64>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct NewSession {
174 pub id: Option<String>,
175 pub project_path: String,
176 pub model_id: String,
177 pub title: Option<String>,
178 pub conversation_path: Option<String>,
179 pub total_tokens: Option<i64>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct MessageRecord {
184 pub id: i64,
185 pub session_id: String,
186 pub role: String,
187 pub content_json: String,
188 pub created_at: String,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct NewMessage {
193 pub session_id: String,
194 pub role: String,
195 pub content_json: String,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct NewTask {
200 pub title: String,
201 pub project_path: String,
202 pub model_id: String,
203 pub priority: TaskPriority,
204 pub conversation_id: Option<String>,
205 pub owner_kind: Option<String>,
211}
212
213impl NewTask {
214 pub fn new(
215 title: impl Into<String>,
216 project_path: impl Into<String>,
217 model_id: impl Into<String>,
218 ) -> Self {
219 Self {
220 title: title.into(),
221 project_path: project_path.into(),
222 model_id: model_id.into(),
223 priority: TaskPriority::Normal,
224 conversation_id: None,
225 owner_kind: None,
226 }
227 }
228
229 pub fn daemon_owned(mut self) -> Self {
233 self.owner_kind = Some(OWNER_KIND_DAEMON.to_string());
234 self
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ApprovalRecord {
240 pub id: String,
241 pub task_id: Option<String>,
242 pub proposed_action: String,
243 pub risk_classification: String,
244 pub policy_decision: String,
245 pub user_decision: Option<String>,
246 pub args_summary: Option<String>,
247 pub checkpoint_id: Option<String>,
248 pub pending_action_json: Option<String>,
249 pub created_at: String,
250 pub decided_at: Option<String>,
251 pub archived_at: Option<String>,
252 pub archive_reason: Option<String>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct NewApproval {
257 pub task_id: Option<String>,
258 pub proposed_action: String,
259 pub risk_classification: String,
260 pub policy_decision: String,
261 pub args_summary: Option<String>,
262 pub checkpoint_id: Option<String>,
263 pub pending_action_json: Option<String>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct ToolRunRecord {
268 pub id: String,
269 pub task_id: Option<String>,
270 pub turn_id: Option<String>,
271 pub call_id: Option<String>,
272 pub tool_name: String,
273 pub status: String,
274 pub args_json: Option<String>,
275 pub output_json: Option<String>,
276 pub started_at: String,
277 pub finished_at: Option<String>,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct NewToolRun {
282 pub id: Option<String>,
283 pub task_id: Option<String>,
284 pub turn_id: Option<String>,
285 pub call_id: Option<String>,
286 pub tool_name: String,
287 pub args_json: Option<String>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct ProcessRecord {
292 pub id: String,
293 pub task_id: Option<String>,
294 pub pid: u32,
295 pub command: String,
296 pub cwd: Option<String>,
297 pub log_path: Option<String>,
298 pub detected_url: Option<String>,
299 pub status: ProcessStatus,
300 pub health: Option<String>,
301 pub created_at: String,
302 pub updated_at: String,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct NewProcess {
307 pub id: Option<String>,
308 pub task_id: Option<String>,
309 pub pid: u32,
310 pub command: String,
311 pub cwd: Option<String>,
312 pub log_path: Option<String>,
313 pub detected_url: Option<String>,
314 pub status: ProcessStatus,
315 pub health: Option<String>,
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct CheckpointRecord {
320 pub id: String,
321 pub task_id: Option<String>,
322 pub project_path: String,
323 pub snapshot_path: String,
324 pub changed_files_json: String,
325 pub pending_action_json: Option<String>,
326 pub approval_id: Option<String>,
327 pub created_at: String,
328 pub archived_at: Option<String>,
329 pub archive_reason: Option<String>,
330}
331
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct NewCheckpoint {
334 pub id: Option<String>,
335 pub task_id: Option<String>,
336 pub project_path: String,
337 pub snapshot_path: String,
338 pub changed_files_json: String,
339 pub pending_action_json: Option<String>,
340 pub approval_id: Option<String>,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct CompactionRecord {
345 pub id: String,
346 pub task_id: Option<String>,
347 pub session_id: Option<String>,
348 pub source_token_estimate: Option<i64>,
349 pub summary_token_count: Option<i64>,
350 pub preserved_turns: Option<i64>,
351 pub archive_path: Option<String>,
352 pub verification_status: Option<String>,
353 pub created_at: String,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct NewCompaction {
358 pub id: Option<String>,
359 pub task_id: Option<String>,
360 pub session_id: Option<String>,
361 pub source_token_estimate: Option<i64>,
362 pub summary_token_count: Option<i64>,
363 pub preserved_turns: Option<i64>,
364 pub archive_path: Option<String>,
365 pub verification_status: Option<String>,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct PluginInstallRecord {
370 pub id: String,
371 pub name: String,
372 pub source: String,
373 pub version: Option<String>,
374 pub enabled: bool,
375 pub manifest_json: String,
376 pub installed_at: String,
377 pub updated_at: String,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct NewPluginInstall {
382 pub id: Option<String>,
383 pub name: String,
384 pub source: String,
385 pub version: Option<String>,
386 pub enabled: bool,
387 pub manifest_json: String,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct ProviderProbeRecord {
392 pub provider: String,
393 pub model_id: String,
394 pub capability_key: String,
395 pub capability_value: String,
396 pub confidence: String,
397 pub error: Option<String>,
398 pub probed_at: String,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct NewProviderProbe {
403 pub provider: String,
404 pub model_id: String,
405 pub capability_key: String,
406 pub capability_value: String,
407 pub confidence: String,
408 pub error: Option<String>,
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412pub struct PairingTokenRecord {
413 pub id: String,
414 pub token_hash: String,
415 pub label: Option<String>,
416 pub enabled: bool,
417 pub created_at: String,
418 pub last_used_at: Option<String>,
419 pub expires_at: Option<String>,
421}
422
423pub struct RuntimeStore {
425 conn: Connection,
426 path: PathBuf,
427}
428
429impl RuntimeStore {
430 pub fn open_default() -> Result<Self> {
431 let dir = data_dir()?;
432 std::fs::create_dir_all(&dir)
433 .with_context(|| format!("failed to create Mermaid data dir {}", dir.display()))?;
434 #[cfg(unix)]
438 {
439 use std::os::unix::fs::PermissionsExt;
440 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
441 }
442 #[cfg(windows)]
449 {
450 let sentinel = dir.join(".acl-hardened");
451 if !sentinel.exists()
452 && let Ok(user) = std::env::var("USERNAME")
453 && !user.is_empty()
454 {
455 let hardened = std::process::Command::new("icacls")
456 .arg(&dir)
457 .arg("/inheritance:r")
458 .arg("/grant:r")
459 .arg(format!("{user}:(OI)(CI)F"))
460 .arg("/T")
461 .stdout(std::process::Stdio::null())
462 .stderr(std::process::Stdio::null())
463 .status()
464 .map(|status| status.success())
465 .unwrap_or(false);
466 if hardened {
467 let _ = std::fs::write(&sentinel, b"1");
468 }
469 }
470 }
471 Self::open(dir.join("runtime.sqlite3"))
472 }
473
474 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
475 let path = path.as_ref().to_path_buf();
476 if let Some(parent) = path.parent() {
477 std::fs::create_dir_all(parent).with_context(|| {
478 format!("failed to create SQLite parent dir {}", parent.display())
479 })?;
480 }
481 let conn = Connection::open(&path)
482 .with_context(|| format!("failed to open runtime DB {}", path.display()))?;
483 conn.busy_timeout(std::time::Duration::from_secs(5))
490 .context("failed to set SQLite busy_timeout")?;
491 conn.execute_batch(
496 "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
497 )
498 .context("failed to set SQLite connection PRAGMAs")?;
499 let store = Self { conn, path };
500 store.init_schema()?;
501 Ok(store)
502 }
503
504 pub fn path(&self) -> &Path {
505 &self.path
506 }
507
508 pub fn sessions(&self) -> SessionsRepo<'_> {
509 SessionsRepo { conn: &self.conn }
510 }
511
512 pub fn messages(&self) -> MessagesRepo<'_> {
513 MessagesRepo { conn: &self.conn }
514 }
515
516 pub fn tasks(&self) -> TasksRepo<'_> {
517 TasksRepo { conn: &self.conn }
518 }
519
520 pub fn tool_runs(&self) -> ToolRunsRepo<'_> {
521 ToolRunsRepo { conn: &self.conn }
522 }
523
524 pub fn approvals(&self) -> ApprovalsRepo<'_> {
525 ApprovalsRepo { conn: &self.conn }
526 }
527
528 pub fn processes(&self) -> ProcessesRepo<'_> {
529 ProcessesRepo { conn: &self.conn }
530 }
531
532 pub fn checkpoints(&self) -> CheckpointsRepo<'_> {
533 CheckpointsRepo { conn: &self.conn }
534 }
535
536 pub fn compactions(&self) -> CompactionsRepo<'_> {
537 CompactionsRepo { conn: &self.conn }
538 }
539
540 pub fn plugins(&self) -> PluginsRepo<'_> {
541 PluginsRepo { conn: &self.conn }
542 }
543
544 pub fn provider_probes(&self) -> ProviderProbesRepo<'_> {
545 ProviderProbesRepo { conn: &self.conn }
546 }
547
548 pub fn pairing_tokens(&self) -> PairingTokensRepo<'_> {
549 PairingTokensRepo { conn: &self.conn }
550 }
551
552 pub fn reconcile_after_restart(&self) -> Result<(usize, usize)> {
566 let now = now_rfc3339();
567 self.conn.execute_batch("BEGIN IMMEDIATE;")?;
574 let result = (|| -> Result<(usize, usize)> {
575 let running: Vec<String> = {
576 let mut stmt = self
577 .conn
578 .prepare("SELECT id FROM tasks WHERE status = 'running' AND owner_kind = ?1")?;
579 let ids = stmt.query_map([OWNER_KIND_DAEMON], |row| row.get::<_, String>(0))?;
580 ids.collect::<rusqlite::Result<Vec<_>>>()?
581 };
582 for id in &running {
583 self.conn.execute(
584 "UPDATE tasks SET status = 'failed', updated_at = ?2 WHERE id = ?1",
585 params![id, now],
586 )?;
587 self.conn.execute(
588 "INSERT INTO task_events (task_id, kind, message, created_at)
589 VALUES (?1, ?2, ?3, ?4)",
590 params![
591 id,
592 "interrupted",
593 "task was running when the daemon restarted; marked failed",
594 now
595 ],
596 )?;
597 }
598 let claims_released = self.conn.execute(
599 "UPDATE approvals SET user_decision = NULL WHERE user_decision = 'approving'",
600 [],
601 )?;
602 Ok((running.len(), claims_released))
603 })();
604 match result {
605 Ok(v) => {
606 self.conn.execute_batch("COMMIT;")?;
607 Ok(v)
608 },
609 Err(e) => {
610 let _ = self.conn.execute_batch("ROLLBACK;");
611 Err(e)
612 },
613 }
614 }
615
616 pub fn gc(&self, retention_days: i64) -> Result<u64> {
624 let cutoff = (chrono::Utc::now() - chrono::Duration::days(retention_days)).to_rfc3339();
625 let tx = self.conn.unchecked_transaction()?;
626 let mut removed = 0u64;
627 removed += tx.execute(
628 "DELETE FROM approvals WHERE archived_at IS NOT NULL AND archived_at < ?1",
629 params![cutoff],
630 )? as u64;
631 removed += tx.execute(
632 "DELETE FROM checkpoints WHERE archived_at IS NOT NULL AND archived_at < ?1",
633 params![cutoff],
634 )? as u64;
635 removed += tx.execute(
636 "DELETE FROM task_events
637 WHERE created_at < ?1
638 AND task_id IN (
639 SELECT id FROM tasks
640 WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1
641 )",
642 params![cutoff],
643 )? as u64;
644 removed += tx.execute(
648 "DELETE FROM tool_runs WHERE finished_at IS NOT NULL AND finished_at < ?1",
649 params![cutoff],
650 )? as u64;
651 removed += tx.execute(
654 "DELETE FROM processes WHERE status = 'exited' AND updated_at < ?1",
655 params![cutoff],
656 )? as u64;
657 removed += tx.execute(
660 "DELETE FROM compactions WHERE created_at < ?1",
661 params![cutoff],
662 )? as u64;
663 removed += tx.execute(
669 "DELETE FROM messages
670 WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ?1)",
671 params![cutoff],
672 )? as u64;
673 removed += tx.execute(
674 "DELETE FROM sessions WHERE updated_at < ?1",
675 params![cutoff],
676 )? as u64;
677 tx.commit()?;
678 Ok(removed)
679 }
680
681 fn init_schema(&self) -> Result<()> {
682 let conn = &self.conn;
683 let current: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
690 anyhow::ensure!(
691 current <= SCHEMA_VERSION,
692 "runtime DB schema version {} is newer than this build supports ({}); upgrade mermaid",
693 current,
694 SCHEMA_VERSION
695 );
696
697 if current == SCHEMA_VERSION {
707 return Ok(());
708 }
709
710 conn.execute_batch("BEGIN IMMEDIATE;")?;
718 if let Err(error) = self.migrate_within_txn(current) {
719 let _ = conn.execute_batch("ROLLBACK;");
720 return Err(error);
721 }
722 conn.execute_batch("COMMIT;")?;
723
724 conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
727 let version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
728 anyhow::ensure!(
729 version == SCHEMA_VERSION,
730 "unsupported runtime DB schema version {} (expected {})",
731 version,
732 SCHEMA_VERSION
733 );
734 Ok(())
735 }
736
737 fn migrate_within_txn(&self, from_version: i32) -> Result<()> {
743 self.conn.execute_batch(
744 r#"
745 CREATE TABLE IF NOT EXISTS sessions (
746 id TEXT PRIMARY KEY,
747 project_path TEXT NOT NULL,
748 model_id TEXT NOT NULL,
749 title TEXT,
750 conversation_path TEXT,
751 created_at TEXT NOT NULL,
752 updated_at TEXT NOT NULL,
753 total_tokens INTEGER
754 );
755
756 CREATE TABLE IF NOT EXISTS messages (
757 id INTEGER PRIMARY KEY AUTOINCREMENT,
758 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
759 role TEXT NOT NULL,
760 content_json TEXT NOT NULL,
761 created_at TEXT NOT NULL
762 );
763 CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
764
765 CREATE TABLE IF NOT EXISTS tasks (
766 id TEXT PRIMARY KEY,
767 title TEXT NOT NULL,
768 status TEXT NOT NULL,
769 priority TEXT NOT NULL,
770 project_path TEXT NOT NULL,
771 model_id TEXT NOT NULL,
772 conversation_id TEXT,
773 created_at TEXT NOT NULL,
774 updated_at TEXT NOT NULL,
775 final_report TEXT,
776 owner_kind TEXT
777 );
778 CREATE INDEX IF NOT EXISTS idx_tasks_project_status
779 ON tasks(project_path, status, updated_at);
780 -- F75: `reconcile_after_restart` filters `status = 'running' AND
781 -- owner_kind = ?`, which the (project_path, ...) index above cannot
782 -- serve (wrong leading column). This covering index does.
783 CREATE INDEX IF NOT EXISTS idx_tasks_status_owner
784 ON tasks(status, owner_kind);
785
786 CREATE TABLE IF NOT EXISTS task_events (
787 id INTEGER PRIMARY KEY AUTOINCREMENT,
788 task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
789 kind TEXT NOT NULL,
790 message TEXT NOT NULL,
791 created_at TEXT NOT NULL
792 );
793 CREATE INDEX IF NOT EXISTS idx_task_events_task_id
794 ON task_events(task_id, id);
795
796 CREATE TABLE IF NOT EXISTS tool_runs (
797 id TEXT PRIMARY KEY,
798 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
799 turn_id TEXT,
800 call_id TEXT,
801 tool_name TEXT NOT NULL,
802 status TEXT NOT NULL,
803 args_json TEXT,
804 output_json TEXT,
805 started_at TEXT NOT NULL,
806 finished_at TEXT
807 );
808 CREATE INDEX IF NOT EXISTS idx_tool_runs_task_id ON tool_runs(task_id);
809
810 CREATE TABLE IF NOT EXISTS approvals (
811 id TEXT PRIMARY KEY,
812 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
813 proposed_action TEXT NOT NULL,
814 risk_classification TEXT NOT NULL,
815 policy_decision TEXT NOT NULL,
816 user_decision TEXT,
817 args_summary TEXT,
818 checkpoint_id TEXT,
819 pending_action_json TEXT,
820 created_at TEXT NOT NULL,
821 decided_at TEXT,
822 archived_at TEXT,
823 archive_reason TEXT
824 );
825 CREATE INDEX IF NOT EXISTS idx_approvals_task_id ON approvals(task_id);
826 -- F75: `list_pending` scans `user_decision IS NULL ORDER BY
827 -- created_at`. A partial index over only the pending rows stays tiny
828 -- and serves both the filter and the ordering.
829 CREATE INDEX IF NOT EXISTS idx_approvals_pending
830 ON approvals(created_at)
831 WHERE user_decision IS NULL;
832
833 CREATE TABLE IF NOT EXISTS processes (
834 id TEXT PRIMARY KEY,
835 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
836 pid INTEGER NOT NULL,
837 command TEXT NOT NULL,
838 cwd TEXT,
839 log_path TEXT,
840 detected_url TEXT,
841 status TEXT NOT NULL,
842 health TEXT,
843 created_at TEXT NOT NULL,
844 updated_at TEXT NOT NULL
845 );
846 CREATE INDEX IF NOT EXISTS idx_processes_task_id ON processes(task_id);
847 CREATE INDEX IF NOT EXISTS idx_processes_pid ON processes(pid);
848
849 CREATE TABLE IF NOT EXISTS checkpoints (
850 id TEXT PRIMARY KEY,
851 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
852 project_path TEXT NOT NULL,
853 snapshot_path TEXT NOT NULL,
854 changed_files_json TEXT NOT NULL,
855 pending_action_json TEXT,
856 approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL,
857 created_at TEXT NOT NULL,
858 archived_at TEXT,
859 archive_reason TEXT
860 );
861
862 CREATE TABLE IF NOT EXISTS compactions (
863 id TEXT PRIMARY KEY,
864 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
865 session_id TEXT,
866 source_token_estimate INTEGER,
867 summary_token_count INTEGER,
868 preserved_turns INTEGER,
869 archive_path TEXT,
870 verification_status TEXT,
871 created_at TEXT NOT NULL
872 );
873
874 CREATE TABLE IF NOT EXISTS provider_probes (
875 provider TEXT NOT NULL,
876 model_id TEXT NOT NULL,
877 capability_key TEXT NOT NULL,
878 capability_value TEXT NOT NULL,
879 confidence TEXT NOT NULL,
880 error TEXT,
881 probed_at TEXT NOT NULL,
882 PRIMARY KEY (provider, model_id, capability_key)
883 );
884
885 CREATE TABLE IF NOT EXISTS plugin_installs (
886 id TEXT PRIMARY KEY,
887 name TEXT NOT NULL,
888 source TEXT NOT NULL,
889 version TEXT,
890 enabled INTEGER NOT NULL DEFAULT 1,
891 manifest_json TEXT NOT NULL,
892 installed_at TEXT NOT NULL,
893 updated_at TEXT NOT NULL
894 );
895
896 CREATE TABLE IF NOT EXISTS pairing_tokens (
897 id TEXT PRIMARY KEY,
898 token_hash TEXT NOT NULL,
899 label TEXT,
900 enabled INTEGER NOT NULL DEFAULT 1,
901 created_at TEXT NOT NULL,
902 last_used_at TEXT,
903 expires_at TEXT
904 );
905 CREATE INDEX IF NOT EXISTS idx_pairing_tokens_enabled
906 ON pairing_tokens(enabled, created_at);
907 "#,
908 )?;
909
910 ensure_column(&self.conn, "approvals", "pending_action_json", "TEXT")?;
911 ensure_column(&self.conn, "approvals", "archived_at", "TEXT")?;
912 ensure_column(&self.conn, "approvals", "archive_reason", "TEXT")?;
913 ensure_column(&self.conn, "checkpoints", "archived_at", "TEXT")?;
914 ensure_column(&self.conn, "checkpoints", "archive_reason", "TEXT")?;
915 ensure_column(&self.conn, "tasks", "owner_kind", "TEXT")?;
919 if ensure_column(&self.conn, "pairing_tokens", "expires_at", "TEXT")? {
925 let grace = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
926 self.conn.execute(
927 "UPDATE pairing_tokens SET expires_at = ?1 WHERE expires_at IS NULL",
928 params![grace],
929 )?;
930 }
931
932 for target in (from_version + 1)..=SCHEMA_VERSION {
942 match target {
943 2 => {},
945 3 => self.migrate_to_v3()?,
949 _ => {},
951 }
952 }
953 Ok(())
954 }
955
956 fn migrate_to_v3(&self) -> Result<()> {
964 Ok(())
965 }
966}
967
968pub struct TasksRepo<'a> {
969 conn: &'a Connection,
970}
971
972pub struct SessionsRepo<'a> {
973 conn: &'a Connection,
974}
975
976impl SessionsRepo<'_> {
977 pub fn upsert(&self, new: NewSession) -> Result<SessionRecord> {
978 let now = now_rfc3339();
979 let id = new.id.unwrap_or_else(|| fresh_id("session"));
980 self.conn.execute(
981 "INSERT INTO sessions
982 (id, project_path, model_id, title, conversation_path, created_at, updated_at, total_tokens)
983 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
984 ON CONFLICT(id) DO UPDATE SET
985 project_path = excluded.project_path,
986 model_id = excluded.model_id,
987 title = excluded.title,
988 conversation_path = excluded.conversation_path,
989 updated_at = excluded.updated_at,
990 total_tokens = excluded.total_tokens",
991 params![
992 id,
993 new.project_path,
994 new.model_id,
995 new.title,
996 new.conversation_path,
997 now,
998 now,
999 new.total_tokens,
1000 ],
1001 )?;
1002 self.get(&id)?
1003 .context("session was upserted but could not be reloaded")
1004 }
1005
1006 pub fn get(&self, id: &str) -> Result<Option<SessionRecord>> {
1007 self.conn
1008 .query_row(
1009 "SELECT id, project_path, model_id, title, conversation_path,
1010 created_at, updated_at, total_tokens
1011 FROM sessions WHERE id = ?1",
1012 [id],
1013 session_from_row,
1014 )
1015 .optional()
1016 .map_err(Into::into)
1017 }
1018
1019 pub fn list(&self, limit: usize) -> Result<Vec<SessionRecord>> {
1020 let mut stmt = self.conn.prepare(
1021 "SELECT id, project_path, model_id, title, conversation_path,
1022 created_at, updated_at, total_tokens
1023 FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1024 )?;
1025 let rows = stmt.query_map([clamp_limit(limit)], session_from_row)?;
1026 rows.collect::<rusqlite::Result<Vec<_>>>()
1027 .map_err(Into::into)
1028 }
1029}
1030
1031pub struct MessagesRepo<'a> {
1032 conn: &'a Connection,
1033}
1034
1035impl MessagesRepo<'_> {
1036 pub fn add(&self, new: NewMessage) -> Result<MessageRecord> {
1037 self.conn.execute(
1038 "INSERT INTO messages (session_id, role, content_json, created_at)
1039 VALUES (?1, ?2, ?3, ?4)",
1040 params![new.session_id, new.role, new.content_json, now_rfc3339()],
1041 )?;
1042 let id = self.conn.last_insert_rowid();
1043 self.get(id)?
1044 .context("message was inserted but could not be reloaded")
1045 }
1046
1047 pub fn get(&self, id: i64) -> Result<Option<MessageRecord>> {
1048 self.conn
1049 .query_row(
1050 "SELECT id, session_id, role, content_json, created_at
1051 FROM messages WHERE id = ?1",
1052 [id],
1053 message_from_row,
1054 )
1055 .optional()
1056 .map_err(Into::into)
1057 }
1058
1059 pub fn list_for_session(&self, session_id: &str) -> Result<Vec<MessageRecord>> {
1068 let mut stmt = self.conn.prepare(
1069 "SELECT id, session_id, role, content_json, created_at FROM (
1070 SELECT id, session_id, role, content_json, created_at
1071 FROM messages WHERE session_id = ?1
1072 ORDER BY id DESC LIMIT ?2
1073 ) ORDER BY id ASC",
1074 )?;
1075 let rows = stmt.query_map(params![session_id, MAX_SESSION_MESSAGES], message_from_row)?;
1076 rows.collect::<rusqlite::Result<Vec<_>>>()
1077 .map_err(Into::into)
1078 }
1079}
1080
1081impl TasksRepo<'_> {
1082 pub fn create(&self, new: NewTask) -> Result<TaskRecord> {
1083 let now = now_rfc3339();
1084 let owner_kind = new.owner_kind;
1087 let record = TaskRecord {
1088 id: fresh_id("task"),
1089 title: new.title,
1090 status: TaskStatus::Queued,
1091 priority: new.priority,
1092 project_path: new.project_path,
1093 model_id: new.model_id,
1094 conversation_id: new.conversation_id,
1095 created_at: now.clone(),
1096 updated_at: now.clone(),
1097 final_report: None,
1098 };
1099 let tx = self.conn.unchecked_transaction()?;
1102 tx.execute(
1103 "INSERT INTO tasks
1104 (id, title, status, priority, project_path, model_id, conversation_id, created_at, updated_at, final_report, owner_kind)
1105 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1106 params![
1107 record.id,
1108 record.title,
1109 record.status.as_str(),
1110 record.priority.as_str(),
1111 record.project_path,
1112 record.model_id,
1113 record.conversation_id,
1114 record.created_at,
1115 record.updated_at,
1116 record.final_report,
1117 owner_kind,
1118 ],
1119 )?;
1120 tx.execute(
1121 "INSERT INTO task_events (task_id, kind, message, created_at)
1122 VALUES (?1, ?2, ?3, ?4)",
1123 params![record.id, "task_created", "task created", now],
1124 )?;
1125 tx.commit()?;
1126 self.get(&record.id)?
1127 .context("task was inserted but could not be reloaded")
1128 }
1129
1130 pub fn get(&self, id: &str) -> Result<Option<TaskRecord>> {
1131 self.conn
1132 .query_row(
1133 "SELECT id, title, status, priority, project_path, model_id, conversation_id,
1134 created_at, updated_at, final_report
1135 FROM tasks WHERE id = ?1",
1136 [id],
1137 task_from_row,
1138 )
1139 .optional()
1140 .map_err(Into::into)
1141 }
1142
1143 pub fn list(&self, limit: usize) -> Result<Vec<TaskRecord>> {
1144 let mut stmt = self.conn.prepare(
1145 "SELECT id, title, status, priority, project_path, model_id, conversation_id,
1146 created_at, updated_at, final_report
1147 FROM tasks
1148 ORDER BY updated_at DESC
1149 LIMIT ?1",
1150 )?;
1151 let rows = stmt.query_map([clamp_limit(limit)], task_from_row_opt)?;
1155 collect_tolerant(rows)
1156 }
1157
1158 pub fn update_status(
1159 &self,
1160 id: &str,
1161 status: TaskStatus,
1162 final_report: Option<&str>,
1163 ) -> Result<()> {
1164 let now = now_rfc3339();
1165 let tx = self.conn.unchecked_transaction()?;
1167 tx.execute(
1168 "UPDATE tasks
1169 SET status = ?2, updated_at = ?3, final_report = COALESCE(?4, final_report)
1170 WHERE id = ?1",
1171 params![id, status.as_str(), now, final_report],
1172 )?;
1173 tx.execute(
1174 "INSERT INTO task_events (task_id, kind, message, created_at)
1175 VALUES (?1, ?2, ?3, ?4)",
1176 params![
1177 id,
1178 "status_changed",
1179 format!("status changed to {status}"),
1180 now
1181 ],
1182 )?;
1183 tx.commit()?;
1184 Ok(())
1185 }
1186
1187 pub fn add_event(&self, task_id: &str, kind: &str, message: &str) -> Result<()> {
1188 self.conn.execute(
1189 "INSERT INTO task_events (task_id, kind, message, created_at)
1190 VALUES (?1, ?2, ?3, ?4)",
1191 params![task_id, kind, message, now_rfc3339()],
1192 )?;
1193 Ok(())
1194 }
1195
1196 pub fn events(&self, task_id: &str) -> Result<Vec<TaskTimelineEvent>> {
1197 let mut stmt = self.conn.prepare(
1198 "SELECT id, task_id, kind, message, created_at
1199 FROM task_events
1200 WHERE task_id = ?1
1201 ORDER BY id ASC",
1202 )?;
1203 let rows = stmt.query_map([task_id], task_event_from_row_opt)?;
1205 collect_tolerant(rows)
1206 }
1207}
1208
1209pub struct ToolRunsRepo<'a> {
1210 conn: &'a Connection,
1211}
1212
1213impl ToolRunsRepo<'_> {
1214 pub fn start(&self, new: NewToolRun) -> Result<ToolRunRecord> {
1215 let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
1216 self.conn.execute(
1217 "INSERT INTO tool_runs
1218 (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
1219 VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
1220 params![
1221 id,
1222 new.task_id,
1223 new.turn_id,
1224 new.call_id,
1225 new.tool_name,
1226 new.args_json,
1227 now_rfc3339(),
1228 ],
1229 )?;
1230 self.get(&id)?
1231 .context("tool run was inserted but could not be reloaded")
1232 }
1233
1234 pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
1235 let changed = self.conn.execute(
1236 "UPDATE tool_runs
1237 SET status = ?2, output_json = ?3, finished_at = ?4
1238 WHERE id = ?1",
1239 params![id, status, output_json, now_rfc3339()],
1240 )?;
1241 anyhow::ensure!(changed > 0, "tool run not found: {}", id);
1242 Ok(())
1243 }
1244
1245 pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
1246 self.conn
1247 .query_row(
1248 "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1249 output_json, started_at, finished_at
1250 FROM tool_runs WHERE id = ?1",
1251 [id],
1252 tool_run_from_row,
1253 )
1254 .optional()
1255 .map_err(Into::into)
1256 }
1257
1258 pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1259 let mut stmt = self.conn.prepare(
1260 "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1261 output_json, started_at, finished_at
1262 FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
1263 )?;
1264 let rows = stmt.query_map([clamp_limit(limit)], tool_run_from_row)?;
1265 rows.collect::<rusqlite::Result<Vec<_>>>()
1266 .map_err(Into::into)
1267 }
1268}
1269
1270pub struct ApprovalsRepo<'a> {
1271 conn: &'a Connection,
1272}
1273
1274impl ApprovalsRepo<'_> {
1275 pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
1276 let record = ApprovalRecord {
1277 id: fresh_id("approval"),
1278 task_id: new.task_id,
1279 proposed_action: new.proposed_action,
1280 risk_classification: new.risk_classification,
1281 policy_decision: new.policy_decision,
1282 user_decision: None,
1283 args_summary: new.args_summary,
1284 checkpoint_id: new.checkpoint_id,
1285 pending_action_json: new.pending_action_json,
1286 created_at: now_rfc3339(),
1287 decided_at: None,
1288 archived_at: None,
1289 archive_reason: None,
1290 };
1291 self.conn.execute(
1292 "INSERT INTO approvals
1293 (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
1294 args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
1295 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1296 params![
1297 record.id,
1298 record.task_id,
1299 record.proposed_action,
1300 record.risk_classification,
1301 record.policy_decision,
1302 record.user_decision,
1303 record.args_summary,
1304 record.checkpoint_id,
1305 record.pending_action_json,
1306 record.created_at,
1307 record.decided_at,
1308 ],
1309 )?;
1310 self.get(&record.id)?
1311 .context("approval was inserted but could not be reloaded")
1312 }
1313
1314 pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
1315 self.conn
1316 .query_row(
1317 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1318 user_decision, args_summary, checkpoint_id, pending_action_json,
1319 created_at, decided_at, archived_at, archive_reason
1320 FROM approvals WHERE id = ?1",
1321 [id],
1322 approval_from_row,
1323 )
1324 .optional()
1325 .map_err(Into::into)
1326 }
1327
1328 pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
1329 let changed = self.conn.execute(
1337 "UPDATE approvals
1338 SET user_decision = ?2, decided_at = ?3
1339 WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1340 params![id, user_decision, now_rfc3339()],
1341 )?;
1342 anyhow::ensure!(
1343 changed > 0,
1344 "approval {} cannot be decided (already decided, archived, or not found)",
1345 id
1346 );
1347 Ok(())
1348 }
1349
1350 pub fn claim(&self, id: &str) -> Result<bool> {
1358 let changed = self.conn.execute(
1359 "UPDATE approvals
1360 SET user_decision = 'approving'
1361 WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1362 params![id],
1363 )?;
1364 Ok(changed == 1)
1365 }
1366
1367 pub fn release_claim(&self, id: &str) -> Result<()> {
1370 self.conn.execute(
1371 "UPDATE approvals SET user_decision = NULL
1372 WHERE id = ?1 AND user_decision = 'approving'",
1373 params![id],
1374 )?;
1375 Ok(())
1376 }
1377
1378 pub fn finalize_claimed(&self, id: &str, user_decision: &str) -> Result<()> {
1381 let changed = self.conn.execute(
1382 "UPDATE approvals
1383 SET user_decision = ?2, decided_at = ?3
1384 WHERE id = ?1 AND user_decision = 'approving'",
1385 params![id, user_decision, now_rfc3339()],
1386 )?;
1387 anyhow::ensure!(changed > 0, "approval {} was not in the claimed state", id);
1388 Ok(())
1389 }
1390
1391 pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
1392 self.list_pending_with_archived(false)
1393 }
1394
1395 pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
1396 self.list_pending_with_archived(true)
1397 }
1398
1399 pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
1400 let mut stmt = self.conn.prepare(
1401 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1402 user_decision, args_summary, checkpoint_id, pending_action_json,
1403 created_at, decided_at, archived_at, archive_reason
1404 FROM approvals
1405 ORDER BY created_at DESC
1406 LIMIT ?1",
1407 )?;
1408 let rows = stmt.query_map([clamp_limit(limit)], approval_from_row)?;
1409 rows.collect::<rusqlite::Result<Vec<_>>>()
1410 .map_err(Into::into)
1411 }
1412
1413 fn list_pending_with_archived(&self, include_archived: bool) -> Result<Vec<ApprovalRecord>> {
1414 let archived_filter = if include_archived {
1415 ""
1416 } else {
1417 " AND archived_at IS NULL"
1418 };
1419 let mut stmt = self.conn.prepare(&format!(
1420 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1421 user_decision, args_summary, checkpoint_id, pending_action_json,
1422 created_at, decided_at, archived_at, archive_reason
1423 FROM approvals
1424 WHERE user_decision IS NULL{archived_filter}
1425 ORDER BY created_at DESC"
1426 ))?;
1427 let rows = stmt.query_map([], approval_from_row)?;
1428 rows.collect::<rusqlite::Result<Vec<_>>>()
1429 .map_err(Into::into)
1430 }
1431
1432 pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1433 let archived_at = now_rfc3339();
1434 let mut changed = 0;
1435 for id in ids {
1436 changed += self.conn.execute(
1437 "UPDATE approvals
1438 SET archived_at = COALESCE(archived_at, ?2),
1439 archive_reason = COALESCE(archive_reason, ?3)
1440 WHERE id = ?1 AND archived_at IS NULL",
1441 params![id, archived_at, reason],
1442 )?;
1443 }
1444 Ok(changed)
1445 }
1446
1447 pub fn count_archived(&self) -> Result<usize> {
1448 self.conn
1449 .query_row(
1450 "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
1451 [],
1452 |row| row.get::<_, i64>(0),
1453 )
1454 .map(|count| count as usize)
1455 .map_err(Into::into)
1456 }
1457}
1458
1459pub struct ProcessesRepo<'a> {
1460 conn: &'a Connection,
1461}
1462
1463impl ProcessesRepo<'_> {
1464 pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
1465 let now = now_rfc3339();
1466 let id = new.id.unwrap_or_else(|| fresh_id("process"));
1467 self.conn.execute(
1468 "INSERT INTO processes
1469 (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
1470 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1471 ON CONFLICT(id) DO UPDATE SET
1472 task_id = excluded.task_id,
1473 pid = excluded.pid,
1474 command = excluded.command,
1475 cwd = excluded.cwd,
1476 log_path = excluded.log_path,
1477 detected_url = excluded.detected_url,
1478 status = excluded.status,
1479 health = excluded.health,
1480 updated_at = excluded.updated_at",
1481 params![
1482 id,
1483 new.task_id,
1484 new.pid,
1485 new.command,
1486 new.cwd,
1487 new.log_path,
1488 new.detected_url,
1489 new.status.as_str(),
1490 new.health,
1491 now,
1492 now,
1493 ],
1494 )?;
1495 self.get(&id)?
1496 .context("process was upserted but could not be reloaded")
1497 }
1498
1499 pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
1500 self.conn
1501 .query_row(
1502 "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1503 created_at, updated_at
1504 FROM processes WHERE id = ?1",
1505 [id],
1506 process_from_row,
1507 )
1508 .optional()
1509 .map_err(Into::into)
1510 }
1511
1512 pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1513 let mut stmt = self.conn.prepare(
1514 "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1515 created_at, updated_at
1516 FROM processes
1517 ORDER BY updated_at DESC
1518 LIMIT ?1",
1519 )?;
1520 let rows = stmt.query_map([clamp_limit(limit)], process_from_row_opt)?;
1523 collect_tolerant(rows)
1524 }
1525}
1526
1527pub struct CheckpointsRepo<'a> {
1528 conn: &'a Connection,
1529}
1530
1531impl CheckpointsRepo<'_> {
1532 pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
1533 let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
1534 self.conn.execute(
1535 "INSERT INTO checkpoints
1536 (id, task_id, project_path, snapshot_path, changed_files_json,
1537 pending_action_json, approval_id, created_at)
1538 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1539 params![
1540 id,
1541 new.task_id,
1542 new.project_path,
1543 new.snapshot_path,
1544 new.changed_files_json,
1545 new.pending_action_json,
1546 new.approval_id,
1547 now_rfc3339(),
1548 ],
1549 )?;
1550 self.get(&id)?
1551 .context("checkpoint was inserted but could not be reloaded")
1552 }
1553
1554 pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
1555 self.conn
1556 .query_row(
1557 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1558 pending_action_json, approval_id, created_at, archived_at, archive_reason
1559 FROM checkpoints WHERE id = ?1",
1560 [id],
1561 checkpoint_from_row,
1562 )
1563 .optional()
1564 .map_err(Into::into)
1565 }
1566
1567 pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
1568 let changed = self.conn.execute(
1569 "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
1570 params![id, approval_id],
1571 )?;
1572 anyhow::ensure!(changed > 0, "checkpoint not found: {}", id);
1573 Ok(())
1574 }
1575
1576 pub fn delete(&self, id: &str) -> Result<bool> {
1586 let changed = self
1587 .conn
1588 .execute("DELETE FROM checkpoints WHERE id = ?1", params![id])?;
1589 Ok(changed > 0)
1590 }
1591
1592 pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1593 self.list_with_archived(limit, false)
1594 }
1595
1596 pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1597 self.list_with_archived(limit, true)
1598 }
1599
1600 fn list_with_archived(
1601 &self,
1602 limit: usize,
1603 include_archived: bool,
1604 ) -> Result<Vec<CheckpointRecord>> {
1605 let archived_filter = if include_archived {
1606 ""
1607 } else {
1608 "WHERE archived_at IS NULL"
1609 };
1610 let mut stmt = self.conn.prepare(&format!(
1611 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1612 pending_action_json, approval_id, created_at, archived_at, archive_reason
1613 FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
1614 ))?;
1615 let rows = stmt.query_map([clamp_limit(limit)], checkpoint_from_row)?;
1616 rows.collect::<rusqlite::Result<Vec<_>>>()
1617 .map_err(Into::into)
1618 }
1619
1620 pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1621 let archived_at = now_rfc3339();
1622 let mut changed = 0;
1623 for id in ids {
1624 changed += self.conn.execute(
1625 "UPDATE checkpoints
1626 SET archived_at = COALESCE(archived_at, ?2),
1627 archive_reason = COALESCE(archive_reason, ?3)
1628 WHERE id = ?1 AND archived_at IS NULL",
1629 params![id, archived_at, reason],
1630 )?;
1631 }
1632 Ok(changed)
1633 }
1634
1635 pub fn count_archived(&self) -> Result<usize> {
1636 self.conn
1637 .query_row(
1638 "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1639 [],
1640 |row| row.get::<_, i64>(0),
1641 )
1642 .map(|count| count as usize)
1643 .map_err(Into::into)
1644 }
1645}
1646
1647pub struct CompactionsRepo<'a> {
1648 conn: &'a Connection,
1649}
1650
1651impl CompactionsRepo<'_> {
1652 pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
1653 let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
1654 self.conn.execute(
1655 "INSERT INTO compactions
1656 (id, task_id, session_id, source_token_estimate, summary_token_count,
1657 preserved_turns, archive_path, verification_status, created_at)
1658 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
1659 ON CONFLICT(id) DO UPDATE SET
1660 task_id = excluded.task_id,
1661 session_id = excluded.session_id,
1662 source_token_estimate = excluded.source_token_estimate,
1663 summary_token_count = excluded.summary_token_count,
1664 preserved_turns = excluded.preserved_turns,
1665 archive_path = excluded.archive_path,
1666 verification_status = excluded.verification_status",
1667 params![
1668 id,
1669 new.task_id,
1670 new.session_id,
1671 new.source_token_estimate,
1672 new.summary_token_count,
1673 new.preserved_turns,
1674 new.archive_path,
1675 new.verification_status,
1676 now_rfc3339(),
1677 ],
1678 )?;
1679 self.get(&id)?
1680 .context("compaction was inserted but could not be reloaded")
1681 }
1682
1683 pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
1684 self.conn
1685 .query_row(
1686 "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1687 preserved_turns, archive_path, verification_status, created_at
1688 FROM compactions WHERE id = ?1",
1689 [id],
1690 compaction_from_row,
1691 )
1692 .optional()
1693 .map_err(Into::into)
1694 }
1695
1696 pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
1697 let mut stmt = self.conn.prepare(
1698 "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
1699 preserved_turns, archive_path, verification_status, created_at
1700 FROM compactions ORDER BY created_at DESC LIMIT ?1",
1701 )?;
1702 let rows = stmt.query_map([clamp_limit(limit)], compaction_from_row)?;
1703 rows.collect::<rusqlite::Result<Vec<_>>>()
1704 .map_err(Into::into)
1705 }
1706}
1707
1708pub struct PluginsRepo<'a> {
1709 conn: &'a Connection,
1710}
1711
1712impl PluginsRepo<'_> {
1713 pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
1714 let now = now_rfc3339();
1715 let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
1716 self.conn.execute(
1717 "INSERT INTO plugin_installs
1718 (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
1719 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1720 ON CONFLICT(id) DO UPDATE SET
1721 name = excluded.name,
1722 source = excluded.source,
1723 version = excluded.version,
1724 enabled = excluded.enabled,
1725 manifest_json = excluded.manifest_json,
1726 updated_at = excluded.updated_at",
1727 params![
1728 id,
1729 new.name,
1730 new.source,
1731 new.version,
1732 if new.enabled { 1 } else { 0 },
1733 new.manifest_json,
1734 now,
1735 now,
1736 ],
1737 )?;
1738 self.get(&id)?
1739 .context("plugin install was inserted but could not be reloaded")
1740 }
1741
1742 pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
1743 self.conn
1744 .query_row(
1745 "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1746 FROM plugin_installs WHERE id = ?1",
1747 [id],
1748 plugin_from_row,
1749 )
1750 .optional()
1751 .map_err(Into::into)
1752 }
1753
1754 pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
1755 let mut stmt = self.conn.prepare(
1756 "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
1757 FROM plugin_installs ORDER BY name ASC",
1758 )?;
1759 let rows = stmt.query_map([], plugin_from_row)?;
1760 rows.collect::<rusqlite::Result<Vec<_>>>()
1761 .map_err(Into::into)
1762 }
1763
1764 pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
1765 self.conn.execute(
1766 "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
1767 params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
1768 )?;
1769 Ok(())
1770 }
1771}
1772
1773pub struct ProviderProbesRepo<'a> {
1774 conn: &'a Connection,
1775}
1776
1777impl ProviderProbesRepo<'_> {
1778 pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
1779 let now = now_rfc3339();
1780 let provider = new.provider;
1781 let model_id = new.model_id;
1782 let capability_key = new.capability_key;
1783 self.conn.execute(
1784 "INSERT INTO provider_probes
1785 (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
1786 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
1787 ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
1788 capability_value = excluded.capability_value,
1789 confidence = excluded.confidence,
1790 error = excluded.error,
1791 probed_at = excluded.probed_at",
1792 params![
1793 &provider,
1794 &model_id,
1795 &capability_key,
1796 new.capability_value,
1797 new.confidence,
1798 new.error,
1799 now,
1800 ],
1801 )?;
1802 self.get(&provider, &model_id, &capability_key)?
1803 .context("provider probe was inserted but could not be reloaded")
1804 }
1805
1806 pub fn get(
1807 &self,
1808 provider: &str,
1809 model_id: &str,
1810 capability_key: &str,
1811 ) -> Result<Option<ProviderProbeRecord>> {
1812 self.conn
1813 .query_row(
1814 "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1815 FROM provider_probes
1816 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
1817 params![provider, model_id, capability_key],
1818 provider_probe_from_row,
1819 )
1820 .optional()
1821 .map_err(Into::into)
1822 }
1823
1824 pub fn list(
1825 &self,
1826 provider: Option<&str>,
1827 model_id: Option<&str>,
1828 ) -> Result<Vec<ProviderProbeRecord>> {
1829 let mut stmt = self.conn.prepare(
1830 "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
1831 FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
1832 )?;
1833 let rows = stmt.query_map([], provider_probe_from_row)?;
1834 let mut out = Vec::new();
1835 for row in rows {
1836 let probe = row?;
1837 if provider.is_some_and(|p| probe.provider != p) {
1838 continue;
1839 }
1840 if model_id.is_some_and(|m| probe.model_id != m) {
1841 continue;
1842 }
1843 out.push(probe);
1844 }
1845 Ok(out)
1846 }
1847}
1848
1849pub struct PairingTokensRepo<'a> {
1850 conn: &'a Connection,
1851}
1852
1853impl PairingTokensRepo<'_> {
1854 pub fn create(
1855 &self,
1856 token_hash: &str,
1857 label: Option<&str>,
1858 expires_at: Option<&str>,
1859 ) -> Result<PairingTokenRecord> {
1860 let id = fresh_id("pairing");
1861 self.conn.execute(
1862 "INSERT INTO pairing_tokens
1863 (id, token_hash, label, enabled, created_at, last_used_at, expires_at)
1864 VALUES (?1, ?2, ?3, 1, ?4, NULL, ?5)",
1865 params![id, token_hash, label, now_rfc3339(), expires_at],
1866 )?;
1867 self.get(&id)?
1868 .context("pairing token was inserted but could not be reloaded")
1869 }
1870
1871 pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
1872 self.conn
1873 .query_row(
1874 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1875 FROM pairing_tokens WHERE id = ?1",
1876 [id],
1877 pairing_from_row,
1878 )
1879 .optional()
1880 .map_err(Into::into)
1881 }
1882
1883 pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
1892 let now = chrono::Utc::now();
1897 let mut stmt = self.conn.prepare(
1898 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1899 FROM pairing_tokens
1900 WHERE enabled = 1",
1901 )?;
1902 let candidates = stmt
1903 .query_map([], pairing_from_row)?
1904 .collect::<rusqlite::Result<Vec<_>>>()?;
1905 let mut found = None;
1906 for record in candidates {
1907 if is_expired(record.expires_at.as_deref(), now) {
1908 continue;
1909 }
1910 if ct_eq(record.token_hash.as_bytes(), token_hash.as_bytes()) {
1911 found = Some(record);
1912 }
1913 }
1914 Ok(found)
1915 }
1916
1917 pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
1918 let mut stmt = self.conn.prepare(
1919 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
1920 FROM pairing_tokens ORDER BY created_at DESC",
1921 )?;
1922 let rows = stmt.query_map([], pairing_from_row)?;
1923 rows.collect::<rusqlite::Result<Vec<_>>>()
1924 .map_err(Into::into)
1925 }
1926
1927 pub fn list_redacted(&self) -> Result<Vec<PairingTokenRecord>> {
1933 Ok(self
1934 .list()?
1935 .into_iter()
1936 .map(|mut record| {
1937 record.token_hash = String::new();
1938 record
1939 })
1940 .collect())
1941 }
1942
1943 pub fn mark_used(&self, id: &str) -> Result<()> {
1944 self.conn.execute(
1945 "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
1946 params![id, now_rfc3339()],
1947 )?;
1948 Ok(())
1949 }
1950
1951 pub fn revoke(&self, id: &str) -> Result<bool> {
1954 let changed = self.conn.execute(
1955 "UPDATE pairing_tokens SET enabled = 0 WHERE id = ?1 AND enabled = 1",
1956 params![id],
1957 )?;
1958 Ok(changed > 0)
1959 }
1960}
1961
1962fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<bool> {
1970 fn is_sql_identifier(s: &str) -> bool {
1971 let mut chars = s.chars();
1972 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
1973 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1974 }
1975 const ALLOWED_DEFINITIONS: &[&str] = &["TEXT", "INTEGER", "REAL", "BLOB"];
1976 anyhow::ensure!(
1977 is_sql_identifier(table),
1978 "invalid table identifier: {table}"
1979 );
1980 anyhow::ensure!(
1981 is_sql_identifier(column),
1982 "invalid column identifier: {column}"
1983 );
1984 anyhow::ensure!(
1985 ALLOWED_DEFINITIONS.contains(&definition),
1986 "unsupported column definition: {definition}"
1987 );
1988
1989 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
1990 let mut rows = stmt.query([])?;
1991 while let Some(row) = rows.next()? {
1992 let name: String = row.get(1)?;
1993 if name == column {
1994 return Ok(false);
1995 }
1996 }
1997 match conn.execute(
2002 &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
2003 [],
2004 ) {
2005 Ok(_) => Ok(true),
2006 Err(error) if error.to_string().contains("duplicate column") => Ok(false),
2007 Err(error) => Err(error.into()),
2008 }
2009}
2010
2011pub fn data_dir() -> Result<PathBuf> {
2012 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
2013 return Ok(proj_dirs.data_dir().to_path_buf());
2014 }
2015 let home = std::env::var("HOME")
2016 .or_else(|_| std::env::var("USERPROFILE"))
2017 .context("could not determine home directory")?;
2018 Ok(PathBuf::from(home).join(".local/share/mermaid"))
2019}
2020
2021fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
2022 Ok(SessionRecord {
2023 id: row.get("id")?,
2024 project_path: row.get("project_path")?,
2025 model_id: row.get("model_id")?,
2026 title: row.get("title")?,
2027 conversation_path: row.get("conversation_path")?,
2028 created_at: row.get("created_at")?,
2029 updated_at: row.get("updated_at")?,
2030 total_tokens: row.get("total_tokens")?,
2031 })
2032}
2033
2034fn message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRecord> {
2035 Ok(MessageRecord {
2036 id: row.get("id")?,
2037 session_id: row.get("session_id")?,
2038 role: row.get("role")?,
2039 content_json: row.get("content_json")?,
2040 created_at: row.get("created_at")?,
2041 })
2042}
2043
2044fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
2045 let status_raw: String = row.get("status")?;
2046 let priority_raw: String = row.get("priority")?;
2047 Ok(TaskRecord {
2048 id: row.get("id")?,
2049 title: row.get("title")?,
2050 status: TaskStatus::from_db(&status_raw)
2051 .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2052 priority: TaskPriority::from_db(&priority_raw)
2053 .map_err(|e| enum_from_sql_error("priority", priority_raw, e))?,
2054 project_path: row.get("project_path")?,
2055 model_id: row.get("model_id")?,
2056 conversation_id: row.get("conversation_id")?,
2057 created_at: row.get("created_at")?,
2058 updated_at: row.get("updated_at")?,
2059 final_report: row.get("final_report")?,
2060 })
2061}
2062
2063fn process_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProcessRecord> {
2064 let status_raw: String = row.get("status")?;
2065 let pid: i64 = row.get("pid")?;
2066 Ok(ProcessRecord {
2067 id: row.get("id")?,
2068 task_id: row.get("task_id")?,
2069 pid: pid as u32,
2070 command: row.get("command")?,
2071 cwd: row.get("cwd")?,
2072 log_path: row.get("log_path")?,
2073 detected_url: row.get("detected_url")?,
2074 status: ProcessStatus::from_db(&status_raw)
2075 .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2076 health: row.get("health")?,
2077 created_at: row.get("created_at")?,
2078 updated_at: row.get("updated_at")?,
2079 })
2080}
2081
2082fn tool_run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ToolRunRecord> {
2083 Ok(ToolRunRecord {
2084 id: row.get("id")?,
2085 task_id: row.get("task_id")?,
2086 turn_id: row.get("turn_id")?,
2087 call_id: row.get("call_id")?,
2088 tool_name: row.get("tool_name")?,
2089 status: row.get("status")?,
2090 args_json: row.get("args_json")?,
2091 output_json: row.get("output_json")?,
2092 started_at: row.get("started_at")?,
2093 finished_at: row.get("finished_at")?,
2094 })
2095}
2096
2097fn approval_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApprovalRecord> {
2098 Ok(ApprovalRecord {
2099 id: row.get("id")?,
2100 task_id: row.get("task_id")?,
2101 proposed_action: row.get("proposed_action")?,
2102 risk_classification: row.get("risk_classification")?,
2103 policy_decision: row.get("policy_decision")?,
2104 user_decision: row.get("user_decision")?,
2105 args_summary: row.get("args_summary")?,
2106 checkpoint_id: row.get("checkpoint_id")?,
2107 pending_action_json: row.get("pending_action_json")?,
2108 created_at: row.get("created_at")?,
2109 decided_at: row.get("decided_at")?,
2110 archived_at: row.get("archived_at")?,
2111 archive_reason: row.get("archive_reason")?,
2112 })
2113}
2114
2115fn checkpoint_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointRecord> {
2116 Ok(CheckpointRecord {
2117 id: row.get("id")?,
2118 task_id: row.get("task_id")?,
2119 project_path: row.get("project_path")?,
2120 snapshot_path: row.get("snapshot_path")?,
2121 changed_files_json: row.get("changed_files_json")?,
2122 pending_action_json: row.get("pending_action_json")?,
2123 approval_id: row.get("approval_id")?,
2124 created_at: row.get("created_at")?,
2125 archived_at: row.get("archived_at")?,
2126 archive_reason: row.get("archive_reason")?,
2127 })
2128}
2129
2130fn compaction_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CompactionRecord> {
2131 Ok(CompactionRecord {
2132 id: row.get("id")?,
2133 task_id: row.get("task_id")?,
2134 session_id: row.get("session_id")?,
2135 source_token_estimate: row.get("source_token_estimate")?,
2136 summary_token_count: row.get("summary_token_count")?,
2137 preserved_turns: row.get("preserved_turns")?,
2138 archive_path: row.get("archive_path")?,
2139 verification_status: row.get("verification_status")?,
2140 created_at: row.get("created_at")?,
2141 })
2142}
2143
2144fn plugin_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PluginInstallRecord> {
2145 let enabled: i64 = row.get("enabled")?;
2146 Ok(PluginInstallRecord {
2147 id: row.get("id")?,
2148 name: row.get("name")?,
2149 source: row.get("source")?,
2150 version: row.get("version")?,
2151 enabled: enabled != 0,
2152 manifest_json: row.get("manifest_json")?,
2153 installed_at: row.get("installed_at")?,
2154 updated_at: row.get("updated_at")?,
2155 })
2156}
2157
2158fn provider_probe_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProviderProbeRecord> {
2159 Ok(ProviderProbeRecord {
2160 provider: row.get("provider")?,
2161 model_id: row.get("model_id")?,
2162 capability_key: row.get("capability_key")?,
2163 capability_value: row.get("capability_value")?,
2164 confidence: row.get("confidence")?,
2165 error: row.get("error")?,
2166 probed_at: row.get("probed_at")?,
2167 })
2168}
2169
2170fn pairing_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PairingTokenRecord> {
2171 let enabled: i64 = row.get("enabled")?;
2172 Ok(PairingTokenRecord {
2173 id: row.get("id")?,
2174 token_hash: row.get("token_hash")?,
2175 label: row.get("label")?,
2176 enabled: enabled != 0,
2177 created_at: row.get("created_at")?,
2178 last_used_at: row.get("last_used_at")?,
2179 expires_at: row.get("expires_at")?,
2180 })
2181}
2182
2183fn task_event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskTimelineEvent> {
2184 Ok(TaskTimelineEvent {
2185 id: row.get("id")?,
2186 task_id: row.get("task_id")?,
2187 kind: row.get("kind")?,
2188 message: row.get("message")?,
2189 created_at: row.get("created_at")?,
2190 })
2191}
2192
2193fn is_row_decode_error(err: &rusqlite::Error) -> bool {
2201 matches!(
2202 err,
2203 rusqlite::Error::FromSqlConversionFailure(..) | rusqlite::Error::InvalidColumnType(..)
2204 )
2205}
2206
2207fn task_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskRecord>> {
2210 match task_from_row(row) {
2211 Ok(record) => Ok(Some(record)),
2212 Err(err) if is_row_decode_error(&err) => {
2213 tracing::warn!(error = %err, "skipping task row this build can't decode (version skew?)");
2214 Ok(None)
2215 },
2216 Err(err) => Err(err),
2217 }
2218}
2219
2220fn process_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<ProcessRecord>> {
2222 match process_from_row(row) {
2223 Ok(record) => Ok(Some(record)),
2224 Err(err) if is_row_decode_error(&err) => {
2225 tracing::warn!(error = %err, "skipping process row this build can't decode (version skew?)");
2226 Ok(None)
2227 },
2228 Err(err) => Err(err),
2229 }
2230}
2231
2232fn task_event_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskTimelineEvent>> {
2234 match task_event_from_row(row) {
2235 Ok(record) => Ok(Some(record)),
2236 Err(err) if is_row_decode_error(&err) => {
2237 tracing::warn!(error = %err, "skipping task event row this build can't decode");
2238 Ok(None)
2239 },
2240 Err(err) => Err(err),
2241 }
2242}
2243
2244fn collect_tolerant<T>(rows: impl Iterator<Item = rusqlite::Result<Option<T>>>) -> Result<Vec<T>> {
2247 let mut out = Vec::new();
2248 for row in rows {
2249 if let Some(item) = row? {
2250 out.push(item);
2251 }
2252 }
2253 Ok(out)
2254}
2255
2256fn enum_from_sql_error(
2257 column: &'static str,
2258 value: String,
2259 source: UnknownRuntimeEnum,
2260) -> rusqlite::Error {
2261 let _ = value;
2262 rusqlite::Error::FromSqlConversionFailure(column_index(column), Type::Text, Box::new(source))
2263}
2264
2265fn column_index(column: &str) -> usize {
2266 match column {
2267 "status" => 2,
2268 "priority" => 3,
2269 _ => 0,
2270 }
2271}
2272
2273#[derive(Debug)]
2274struct UnknownRuntimeEnum {
2275 kind: &'static str,
2276 value: String,
2277}
2278
2279impl UnknownRuntimeEnum {
2280 fn new(kind: &'static str, value: &str) -> Self {
2281 Self {
2282 kind,
2283 value: value.to_string(),
2284 }
2285 }
2286}
2287
2288impl fmt::Display for UnknownRuntimeEnum {
2289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2290 write!(f, "unknown {} value `{}`", self.kind, self.value)
2291 }
2292}
2293
2294impl std::error::Error for UnknownRuntimeEnum {}
2295
2296fn now_rfc3339() -> String {
2297 chrono::Utc::now().to_rfc3339()
2298}
2299
2300fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2305 if a.len() != b.len() {
2306 return false;
2307 }
2308 let mut diff = 0u8;
2309 for (x, y) in a.iter().zip(b.iter()) {
2310 diff |= x ^ y;
2311 }
2312 diff == 0
2313}
2314
2315fn is_expired(expires_at: Option<&str>, now: chrono::DateTime<chrono::Utc>) -> bool {
2323 match expires_at {
2324 None => false,
2325 Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) {
2326 Ok(dt) => dt <= now,
2327 Err(_) => true,
2328 },
2329 }
2330}
2331
2332const MAX_QUERY_LIMIT: usize = 10_000;
2337
2338fn clamp_limit(limit: usize) -> i64 {
2339 limit.min(MAX_QUERY_LIMIT) as i64
2340}
2341
2342const MAX_SESSION_MESSAGES: i64 = 5_000;
2348
2349pub(crate) fn fresh_id(prefix: &str) -> String {
2350 static SEQ: AtomicU64 = AtomicU64::new(0);
2357 static SALT: OnceLock<u64> = OnceLock::new();
2358 let salt = *SALT.get_or_init(|| {
2359 let mut bytes = [0u8; 8];
2360 let _ = getrandom::fill(&mut bytes);
2363 u64::from_le_bytes(bytes)
2364 });
2365 let nanos = SystemTime::now()
2366 .duration_since(UNIX_EPOCH)
2367 .map(|d| d.as_nanos())
2368 .unwrap_or_default();
2369 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
2370 format!("{prefix}-{nanos:x}-{salt:x}-{seq:x}")
2371}
2372
2373#[cfg(unix)]
2384pub fn try_exclusive_lock(path: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
2385 use rustix::fs::{FlockOperation, flock};
2386 let file = std::fs::OpenOptions::new()
2389 .create(true)
2390 .write(true)
2391 .truncate(false)
2392 .open(path)?;
2393 match flock(&file, FlockOperation::NonBlockingLockExclusive) {
2394 Ok(()) => Ok(Some(file)),
2395 Err(rustix::io::Errno::WOULDBLOCK) => Ok(None),
2396 Err(e) => Err(e.into()),
2397 }
2398}
2399
2400#[cfg(test)]
2401mod tests {
2402 use super::*;
2403
2404 #[test]
2405 fn open_enables_wal_and_busy_timeout() {
2406 let path = temp_db("wal_check");
2409 let store = RuntimeStore::open(&path).expect("open");
2410 let mode: String = store
2411 .conn
2412 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2413 .expect("journal_mode pragma");
2414 assert_eq!(mode.to_lowercase(), "wal");
2415 }
2416
2417 fn temp_db(name: &str) -> PathBuf {
2418 let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{}", name));
2419 let _ = std::fs::remove_dir_all(&dir);
2420 std::fs::create_dir_all(&dir).expect("create temp dir");
2421 dir.join("runtime.sqlite3")
2422 }
2423
2424 #[test]
2425 fn initializes_runtime_schema() {
2426 let path = temp_db("schema");
2427 let store = RuntimeStore::open(&path).expect("open store");
2428 assert_eq!(store.path(), path.as_path());
2429 let version: i32 = store
2430 .conn
2431 .query_row("PRAGMA user_version", [], |row| row.get(0))
2432 .unwrap();
2433 assert_eq!(version, SCHEMA_VERSION);
2434 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2435 }
2436
2437 #[test]
2438 fn rejects_newer_schema_version() {
2439 let path = temp_db("newer_schema");
2442 {
2443 let store = RuntimeStore::open(&path).expect("first open");
2444 store
2445 .conn
2446 .execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
2447 .expect("bump version");
2448 }
2449 let err = match RuntimeStore::open(&path) {
2451 Ok(_) => panic!("must refuse a newer DB"),
2452 Err(e) => e,
2453 };
2454 assert!(
2455 err.to_string().contains("newer than this build"),
2456 "unexpected error: {err}"
2457 );
2458 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2459 }
2460
2461 #[test]
2462 fn init_schema_is_idempotent_across_opens() {
2463 let path = temp_db("idempotent_schema");
2467 let _ = RuntimeStore::open(&path).expect("first open");
2468 let store = RuntimeStore::open(&path).expect("second open must succeed");
2469 let version: i32 = store
2470 .conn
2471 .query_row("PRAGMA user_version", [], |r| r.get(0))
2472 .unwrap();
2473 assert_eq!(version, SCHEMA_VERSION);
2474 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2475 }
2476
2477 fn explain_query_plan(conn: &Connection, sql: &str) -> String {
2478 let mut stmt = conn
2479 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
2480 .expect("prepare EXPLAIN QUERY PLAN");
2481 let rows = stmt
2484 .query_map([], |row| row.get::<_, String>(3))
2485 .expect("eqp query")
2486 .collect::<rusqlite::Result<Vec<String>>>()
2487 .expect("eqp rows");
2488 rows.join("\n")
2489 }
2490
2491 #[test]
2492 fn pending_and_reconcile_scans_use_indexes() {
2493 let path = temp_db("scan_indexes");
2496 let store = RuntimeStore::open(&path).expect("open");
2497
2498 let index_count: i64 = store
2499 .conn
2500 .query_row(
2501 "SELECT COUNT(*) FROM sqlite_master
2502 WHERE type = 'index'
2503 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
2504 [],
2505 |r| r.get(0),
2506 )
2507 .unwrap();
2508 assert_eq!(index_count, 2, "F75 indexes must be created");
2509
2510 let plan = explain_query_plan(
2513 &store.conn,
2514 "SELECT id FROM approvals WHERE user_decision IS NULL ORDER BY created_at DESC",
2515 );
2516 assert!(
2517 plan.contains("idx_approvals_pending"),
2518 "pending scan must use idx_approvals_pending; plan was:\n{plan}"
2519 );
2520
2521 let plan = explain_query_plan(
2523 &store.conn,
2524 "SELECT id FROM tasks WHERE status = 'running' AND owner_kind = 'daemon'",
2525 );
2526 assert!(
2527 plan.contains("idx_tasks_status_owner"),
2528 "reconcile scan must use idx_tasks_status_owner; plan was:\n{plan}"
2529 );
2530
2531 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2532 }
2533
2534 #[test]
2535 fn upgrades_from_v2_to_current_and_adds_indexes() {
2536 let path = temp_db("upgrade_v2");
2541 {
2542 let store = RuntimeStore::open(&path).expect("first open");
2543 store
2545 .conn
2546 .execute_batch(
2547 "DROP INDEX IF EXISTS idx_approvals_pending;
2548 DROP INDEX IF EXISTS idx_tasks_status_owner;
2549 PRAGMA user_version = 2;",
2550 )
2551 .expect("downgrade to v2");
2552 }
2553 let store = RuntimeStore::open(&path).expect("reopen must migrate forward");
2554 let version: i32 = store
2555 .conn
2556 .query_row("PRAGMA user_version", [], |r| r.get(0))
2557 .unwrap();
2558 assert_eq!(version, SCHEMA_VERSION);
2559 let index_count: i64 = store
2560 .conn
2561 .query_row(
2562 "SELECT COUNT(*) FROM sqlite_master
2563 WHERE type = 'index'
2564 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
2565 [],
2566 |r| r.get(0),
2567 )
2568 .unwrap();
2569 assert_eq!(
2570 index_count, 2,
2571 "forward migration must recreate the F75 indexes"
2572 );
2573 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2574 }
2575
2576 #[test]
2577 fn task_create_commits_task_and_event_atomically() {
2578 let path = temp_db("task_txn");
2580 let store = RuntimeStore::open(&path).expect("open");
2581 let task = store
2582 .tasks()
2583 .create(NewTask::new("do a thing", "/repo", "anthropic/claude"))
2584 .expect("create task");
2585 let events = store.tasks().events(&task.id).expect("events");
2586 assert!(
2587 events.iter().any(|e| e.kind == "task_created"),
2588 "the task_created event must commit with the task row"
2589 );
2590 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2591 }
2592
2593 #[test]
2594 fn task_lifecycle_round_trips() {
2595 let path = temp_db("task");
2596 let store = RuntimeStore::open(&path).expect("open store");
2597 let session = store
2598 .sessions()
2599 .upsert(NewSession {
2600 id: Some("session-1".to_string()),
2601 project_path: "/repo".to_string(),
2602 model_id: "anthropic/claude".to_string(),
2603 title: Some("Run tests".to_string()),
2604 conversation_path: Some("/repo/.mermaid/session.json".to_string()),
2605 total_tokens: Some(42),
2606 })
2607 .expect("upsert session");
2608 assert_eq!(session.id, "session-1");
2609 let message = store
2610 .messages()
2611 .add(NewMessage {
2612 session_id: session.id.clone(),
2613 role: "user".to_string(),
2614 content_json: "{\"text\":\"hi\"}".to_string(),
2615 })
2616 .expect("add message");
2617 assert_eq!(message.role, "user");
2618 assert_eq!(
2619 store
2620 .messages()
2621 .list_for_session(&session.id)
2622 .unwrap()
2623 .len(),
2624 1
2625 );
2626
2627 let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
2628 new.priority = TaskPriority::High;
2629 let task = store.tasks().create(new).expect("create task");
2630
2631 assert_eq!(task.status, TaskStatus::Queued);
2632 assert_eq!(task.priority, TaskPriority::High);
2633
2634 store
2635 .tasks()
2636 .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
2637 .expect("update task");
2638 let loaded = store.tasks().get(&task.id).unwrap().unwrap();
2639 assert_eq!(loaded.status, TaskStatus::Completed);
2640 assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
2641
2642 let events = store.tasks().events(&task.id).expect("events");
2643 assert_eq!(events.len(), 2);
2644 assert_eq!(events[0].kind, "task_created");
2645 assert_eq!(events[1].kind, "status_changed");
2646 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2647 }
2648
2649 #[test]
2650 fn approval_and_process_records_round_trip() {
2651 let path = temp_db("approval_process");
2652 let store = RuntimeStore::open(&path).expect("open store");
2653 let task = store
2654 .tasks()
2655 .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
2656 .expect("create task");
2657
2658 let approval = store
2659 .approvals()
2660 .create(NewApproval {
2661 task_id: Some(task.id.clone()),
2662 proposed_action: "write_file src/lib.rs".to_string(),
2663 risk_classification: "file_mutation".to_string(),
2664 policy_decision: "ask".to_string(),
2665 args_summary: Some("src/lib.rs".to_string()),
2666 checkpoint_id: Some("checkpoint-1".to_string()),
2667 pending_action_json: Some(
2668 "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
2669 ),
2670 })
2671 .expect("create approval");
2672 store
2673 .approvals()
2674 .decide(&approval.id, "approved")
2675 .expect("decide approval");
2676 let approval = store.approvals().get(&approval.id).unwrap().unwrap();
2677 assert_eq!(approval.user_decision.as_deref(), Some("approved"));
2678 assert!(approval.pending_action_json.is_some());
2679
2680 let tool_run = store
2681 .tool_runs()
2682 .start(NewToolRun {
2683 id: Some("toolrun-1".to_string()),
2684 task_id: Some(task.id.clone()),
2685 turn_id: Some("turn-1".to_string()),
2686 call_id: Some("call-1".to_string()),
2687 tool_name: "write_file".to_string(),
2688 args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
2689 })
2690 .expect("start tool run");
2691 assert_eq!(tool_run.status, "running");
2692 store
2693 .tool_runs()
2694 .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
2695 .expect("finish tool run");
2696 let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
2697 assert_eq!(tool_run.status, "success");
2698 assert!(tool_run.finished_at.is_some());
2699
2700 let process = store
2701 .processes()
2702 .upsert(NewProcess {
2703 id: Some("proc-1".to_string()),
2704 task_id: Some(task.id),
2705 pid: 123,
2706 command: "npm run dev".to_string(),
2707 cwd: Some("/repo".to_string()),
2708 log_path: Some("/tmp/mermaid.log".to_string()),
2709 detected_url: Some("http://127.0.0.1:5173".to_string()),
2710 status: ProcessStatus::Running,
2711 health: Some("ready".to_string()),
2712 })
2713 .expect("upsert process");
2714 assert_eq!(process.status, ProcessStatus::Running);
2715 assert_eq!(store.processes().list(10).unwrap().len(), 1);
2716 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2717 }
2718
2719 #[test]
2720 fn approval_decide_is_single_shot() {
2721 let path = temp_db("approval_decide_guard");
2722 let store = RuntimeStore::open(&path).expect("open store");
2723 let make = |action: &str| {
2724 store
2725 .approvals()
2726 .create(NewApproval {
2727 task_id: None,
2728 proposed_action: action.to_string(),
2729 risk_classification: "file_mutation".to_string(),
2730 policy_decision: "ask".to_string(),
2731 args_summary: None,
2732 checkpoint_id: None,
2733 pending_action_json: None,
2734 })
2735 .expect("create approval")
2736 };
2737
2738 let a = make("write_file a");
2741 store
2742 .approvals()
2743 .decide(&a.id, "approved")
2744 .expect("first decide");
2745 assert!(
2746 store.approvals().decide(&a.id, "approved").is_err(),
2747 "re-approving an approved approval must be rejected"
2748 );
2749
2750 let b = make("write_file b");
2752 store.approvals().decide(&b.id, "denied").expect("deny");
2753 assert!(
2754 store.approvals().decide(&b.id, "approved").is_err(),
2755 "a denied approval must not be re-decidable as approved"
2756 );
2757 let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
2758 assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
2759
2760 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2761 }
2762
2763 #[test]
2764 fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
2765 let path = temp_db("archive_visibility");
2766 let store = RuntimeStore::open(&path).expect("open store");
2767
2768 let approval = store
2769 .approvals()
2770 .create(NewApproval {
2771 task_id: None,
2772 proposed_action: "restore replay: write_file".to_string(),
2773 risk_classification: "restored_action".to_string(),
2774 policy_decision: "ask".to_string(),
2775 args_summary: None,
2776 checkpoint_id: Some("checkpoint-1".to_string()),
2777 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2778 })
2779 .expect("create approval");
2780 let checkpoint = store
2781 .checkpoints()
2782 .create(NewCheckpoint {
2783 id: Some("checkpoint-1".to_string()),
2784 task_id: None,
2785 project_path: "/tmp/mermaid_checkpoint_test".to_string(),
2786 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
2787 changed_files_json: "[]".to_string(),
2788 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2789 approval_id: Some(approval.id.clone()),
2790 })
2791 .expect("create checkpoint");
2792
2793 assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
2794 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
2795 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
2796 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
2797 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
2798
2799 assert_eq!(
2800 store
2801 .approvals()
2802 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
2803 .unwrap(),
2804 1
2805 );
2806 assert_eq!(
2807 store
2808 .checkpoints()
2809 .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
2810 .unwrap(),
2811 1
2812 );
2813 assert_eq!(
2814 store
2815 .approvals()
2816 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
2817 .unwrap(),
2818 0
2819 );
2820 assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
2821 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
2822 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
2823 assert_eq!(store.approvals().count_archived().unwrap(), 1);
2824 assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
2825 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
2826 assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
2827
2828 let archived = store.approvals().get(&approval.id).unwrap().unwrap();
2829 assert!(archived.archived_at.is_some());
2830 assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
2831 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2832 }
2833
2834 #[test]
2835 fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
2836 let path = temp_db("everything_else");
2837 let store = RuntimeStore::open(&path).expect("open store");
2838
2839 let checkpoint = store
2840 .checkpoints()
2841 .create(NewCheckpoint {
2842 id: Some("checkpoint-1".to_string()),
2843 task_id: None,
2844 project_path: "/repo".to_string(),
2845 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
2846 changed_files_json: "[\"src/lib.rs\"]".to_string(),
2847 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
2848 approval_id: None,
2849 })
2850 .expect("create checkpoint");
2851 assert_eq!(checkpoint.id, "checkpoint-1");
2852 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
2853
2854 let compaction = store
2855 .compactions()
2856 .create(NewCompaction {
2857 id: Some("compaction-1".to_string()),
2858 task_id: None,
2859 session_id: Some("session-1".to_string()),
2860 source_token_estimate: Some(10_000),
2861 summary_token_count: Some(800),
2862 preserved_turns: Some(6),
2863 archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
2864 verification_status: Some("verified".to_string()),
2865 })
2866 .expect("create compaction");
2867 assert_eq!(compaction.summary_token_count, Some(800));
2868 assert_eq!(store.compactions().list(10).unwrap().len(), 1);
2869
2870 let plugin = store
2871 .plugins()
2872 .install(NewPluginInstall {
2873 id: Some("plugin-1".to_string()),
2874 name: "example".to_string(),
2875 source: "local".to_string(),
2876 version: Some("0.1.0".to_string()),
2877 enabled: true,
2878 manifest_json: "{\"name\":\"example\"}".to_string(),
2879 })
2880 .expect("install plugin");
2881 assert!(plugin.enabled);
2882 store.plugins().set_enabled("plugin-1", false).unwrap();
2883 assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
2884
2885 let probe = store
2886 .provider_probes()
2887 .upsert(NewProviderProbe {
2888 provider: "cerebras".to_string(),
2889 model_id: "gpt-oss-120b".to_string(),
2890 capability_key: "parallel_tool_calls".to_string(),
2891 capability_value: "false".to_string(),
2892 confidence: "static".to_string(),
2893 error: None,
2894 })
2895 .expect("probe");
2896 assert_eq!(probe.confidence, "static");
2897 assert_eq!(
2898 store
2899 .provider_probes()
2900 .list(Some("cerebras"), Some("gpt-oss-120b"))
2901 .unwrap()
2902 .len(),
2903 1
2904 );
2905
2906 let pairing = store
2907 .pairing_tokens()
2908 .create("hash", Some("phone"), None)
2909 .expect("pairing");
2910 store.pairing_tokens().mark_used(&pairing.id).unwrap();
2911 assert!(
2912 store
2913 .pairing_tokens()
2914 .get(&pairing.id)
2915 .unwrap()
2916 .unwrap()
2917 .last_used_at
2918 .is_some()
2919 );
2920 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2921 }
2922
2923 #[test]
2924 fn pairing_token_expiry_and_revoke() {
2925 let path = temp_db("pairing_ttl");
2926 let store = RuntimeStore::open(&path).expect("open store");
2927 let tokens = store.pairing_tokens();
2928
2929 let live = tokens
2931 .create("live_hash", Some("a"), None)
2932 .expect("create live");
2933 assert!(tokens.verify_token("live_hash").unwrap().is_some());
2934
2935 let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
2937 tokens
2938 .create("future_hash", None, Some(&future))
2939 .expect("create future");
2940 assert!(tokens.verify_token("future_hash").unwrap().is_some());
2941
2942 let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
2943 tokens
2944 .create("past_hash", None, Some(&past))
2945 .expect("create past");
2946 assert!(
2947 tokens.verify_token("past_hash").unwrap().is_none(),
2948 "an expired token must not verify"
2949 );
2950
2951 let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
2955 .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
2956 .to_rfc3339();
2957 tokens
2958 .create("skew_hash", None, Some(&skewed))
2959 .expect("create skewed");
2960 assert!(
2961 tokens.verify_token("skew_hash").unwrap().is_some(),
2962 "a future token in a non-UTC offset must verify (parsed-instant compare)"
2963 );
2964
2965 tokens
2967 .create("garbage_hash", None, Some("not-a-timestamp"))
2968 .expect("create garbage");
2969 assert!(
2970 tokens.verify_token("garbage_hash").unwrap().is_none(),
2971 "an unparseable expiry must fail closed"
2972 );
2973
2974 assert!(tokens.revoke(&live.id).unwrap());
2976 assert!(tokens.verify_token("live_hash").unwrap().is_none());
2977 assert!(
2978 !tokens.revoke(&live.id).unwrap(),
2979 "double revoke is a no-op"
2980 );
2981
2982 assert!(tokens.verify_token("nope").unwrap().is_none());
2984
2985 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2986 }
2987
2988 #[test]
2989 fn ct_eq_matches_only_identical_bytes() {
2990 assert!(ct_eq(b"abc", b"abc"));
2991 assert!(!ct_eq(b"abc", b"abd"));
2992 assert!(!ct_eq(b"abc", b"ab"));
2993 assert!(!ct_eq(b"", b"x"));
2994 assert!(ct_eq(b"", b""));
2995 }
2996
2997 #[test]
2998 fn fresh_id_is_collision_free_in_tight_loop() {
2999 let mut seen = std::collections::HashSet::new();
3002 for _ in 0..10_000 {
3003 let id = fresh_id("process");
3004 assert!(id.starts_with("process-"), "id must keep prefix: {id}");
3005 assert!(seen.insert(id), "fresh_id produced a duplicate");
3006 }
3007 }
3008
3009 #[test]
3010 fn ensure_column_rejects_non_identifier() {
3011 let path = temp_db("ensure_col");
3012 let store = RuntimeStore::open(&path).expect("open store");
3013 assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
3014 assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
3015 assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
3016 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3017 }
3018
3019 #[test]
3020 fn clamp_limit_never_binds_negative() {
3021 assert_eq!(clamp_limit(10), 10);
3024 assert_eq!(clamp_limit(usize::MAX), MAX_QUERY_LIMIT as i64);
3025 assert!(clamp_limit(usize::MAX) > 0);
3026 }
3027
3028 fn make_approval(store: &RuntimeStore, action: &str) -> ApprovalRecord {
3029 store
3030 .approvals()
3031 .create(NewApproval {
3032 task_id: None,
3033 proposed_action: action.to_string(),
3034 risk_classification: "shell_mutation".to_string(),
3035 policy_decision: "ask".to_string(),
3036 args_summary: None,
3037 checkpoint_id: None,
3038 pending_action_json: None,
3039 })
3040 .expect("create approval")
3041 }
3042
3043 #[test]
3044 fn approval_claim_is_single_winner_releasable_and_finalizable() {
3045 let path = temp_db("approval_claim");
3048 let store = RuntimeStore::open(&path).expect("open store");
3049 let a = make_approval(&store, "write_file a");
3050
3051 assert!(store.approvals().claim(&a.id).unwrap(), "first claim wins");
3052 assert!(
3053 !store.approvals().claim(&a.id).unwrap(),
3054 "second claim loses"
3055 );
3056
3057 store.approvals().release_claim(&a.id).unwrap();
3058 assert!(
3059 store.approvals().claim(&a.id).unwrap(),
3060 "a released claim is re-claimable (effect-failed path)"
3061 );
3062
3063 store
3064 .approvals()
3065 .finalize_claimed(&a.id, "approved")
3066 .unwrap();
3067 assert_eq!(
3068 store
3069 .approvals()
3070 .get(&a.id)
3071 .unwrap()
3072 .unwrap()
3073 .user_decision
3074 .as_deref(),
3075 Some("approved")
3076 );
3077 assert!(
3078 !store.approvals().claim(&a.id).unwrap(),
3079 "a decided approval cannot be claimed"
3080 );
3081 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3082 }
3083
3084 #[test]
3085 fn reconcile_after_restart_recovers_running_tasks_and_claims() {
3086 let path = temp_db("reconcile");
3089 let store = RuntimeStore::open(&path).expect("open store");
3090 let task = store
3091 .tasks()
3092 .create(NewTask::new("t", "/repo", "m").daemon_owned())
3093 .expect("create task");
3094 store
3095 .tasks()
3096 .update_status(&task.id, TaskStatus::Running, None)
3097 .expect("mark running");
3098 let appr = make_approval(&store, "git push");
3099 assert!(store.approvals().claim(&appr.id).unwrap());
3100
3101 let (tasks, claims) = store.reconcile_after_restart().expect("reconcile");
3102 assert_eq!((tasks, claims), (1, 1));
3103 assert_eq!(
3104 store.tasks().get(&task.id).unwrap().unwrap().status,
3105 TaskStatus::Failed
3106 );
3107 assert!(
3108 store
3109 .approvals()
3110 .get(&appr.id)
3111 .unwrap()
3112 .unwrap()
3113 .user_decision
3114 .is_none(),
3115 "a released claim is undecided and re-runnable"
3116 );
3117 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3118 }
3119
3120 #[test]
3121 fn reconcile_after_restart_spares_non_daemon_running_tasks() {
3122 let path = temp_db("reconcile_spare_cli");
3126 let store = RuntimeStore::open(&path).expect("open store");
3127
3128 let cli = store
3129 .tasks()
3130 .create(NewTask::new("cli run", "/repo", "m")) .expect("create cli task");
3132 store
3133 .tasks()
3134 .update_status(&cli.id, TaskStatus::Running, None)
3135 .expect("mark cli running");
3136 let daemon = store
3137 .tasks()
3138 .create(NewTask::new("daemon run", "/repo", "m").daemon_owned())
3139 .expect("create daemon task");
3140 store
3141 .tasks()
3142 .update_status(&daemon.id, TaskStatus::Running, None)
3143 .expect("mark daemon running");
3144
3145 let (tasks, _claims) = store.reconcile_after_restart().expect("reconcile");
3146 assert_eq!(tasks, 1, "only the daemon-owned task is reset");
3147 assert_eq!(
3148 store.tasks().get(&cli.id).unwrap().unwrap().status,
3149 TaskStatus::Running,
3150 "a live CLI task must NOT be clobbered by the daemon's reconcile"
3151 );
3152 assert_eq!(
3153 store.tasks().get(&daemon.id).unwrap().unwrap().status,
3154 TaskStatus::Failed,
3155 "a stranded daemon task is still recovered"
3156 );
3157 assert!(
3159 !store
3160 .tasks()
3161 .events(&cli.id)
3162 .unwrap()
3163 .iter()
3164 .any(|e| e.kind == "interrupted"),
3165 "the spared task must not receive a spurious interrupted event"
3166 );
3167 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3168 }
3169
3170 #[test]
3171 fn gc_prunes_old_archived_but_keeps_active() {
3172 let path = temp_db("gc");
3175 let store = RuntimeStore::open(&path).expect("open store");
3176 let keep = make_approval(&store, "active");
3177 let gone = make_approval(&store, "old archived");
3178 store
3179 .approvals()
3180 .archive(std::slice::from_ref(&gone.id), "test")
3181 .expect("archive");
3182 store
3184 .conn
3185 .execute(
3186 "UPDATE approvals SET archived_at = ?2 WHERE id = ?1",
3187 params![gone.id, "2000-01-01T00:00:00+00:00"],
3188 )
3189 .unwrap();
3190
3191 let removed = store.gc(30).expect("gc");
3192 assert!(removed >= 1, "the old archived approval should be pruned");
3193 assert!(
3194 store.approvals().get(&gone.id).unwrap().is_none(),
3195 "old archived row removed"
3196 );
3197 assert!(
3198 store.approvals().get(&keep.id).unwrap().is_some(),
3199 "active row kept"
3200 );
3201 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3202 }
3203
3204 #[test]
3205 fn gc_prunes_high_churn_and_old_terminal_rows_but_keeps_active() {
3206 let path = temp_db("gc_high_churn");
3210 let store = RuntimeStore::open(&path).expect("open store");
3211 let old = "2000-01-01T00:00:00+00:00";
3212
3213 let stale_session = store
3215 .sessions()
3216 .upsert(NewSession {
3217 id: Some("stale".to_string()),
3218 project_path: "/repo".to_string(),
3219 model_id: "m".to_string(),
3220 title: None,
3221 conversation_path: None,
3222 total_tokens: None,
3223 })
3224 .expect("stale session");
3225 store
3226 .messages()
3227 .add(NewMessage {
3228 session_id: stale_session.id.clone(),
3229 role: "user".to_string(),
3230 content_json: "{}".to_string(),
3231 })
3232 .expect("stale message");
3233 let active_session = store
3234 .sessions()
3235 .upsert(NewSession {
3236 id: Some("active".to_string()),
3237 project_path: "/repo".to_string(),
3238 model_id: "m".to_string(),
3239 title: None,
3240 conversation_path: None,
3241 total_tokens: None,
3242 })
3243 .expect("active session");
3244 store
3245 .messages()
3246 .add(NewMessage {
3247 session_id: active_session.id.clone(),
3248 role: "user".to_string(),
3249 content_json: "{}".to_string(),
3250 })
3251 .expect("active message");
3252 store
3253 .conn
3254 .execute(
3255 "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
3256 params![stale_session.id, old],
3257 )
3258 .unwrap();
3259
3260 store
3262 .tool_runs()
3263 .start(NewToolRun {
3264 id: Some("tr-finished".to_string()),
3265 task_id: None,
3266 turn_id: None,
3267 call_id: None,
3268 tool_name: "x".to_string(),
3269 args_json: None,
3270 })
3271 .expect("start finished tr");
3272 store
3273 .tool_runs()
3274 .finish("tr-finished", "success", None)
3275 .expect("finish tr");
3276 store
3277 .conn
3278 .execute(
3279 "UPDATE tool_runs SET finished_at = ?2 WHERE id = ?1",
3280 params!["tr-finished", old],
3281 )
3282 .unwrap();
3283 store
3284 .tool_runs()
3285 .start(NewToolRun {
3286 id: Some("tr-running".to_string()),
3287 task_id: None,
3288 turn_id: None,
3289 call_id: None,
3290 tool_name: "x".to_string(),
3291 args_json: None,
3292 })
3293 .expect("start running tr");
3294
3295 let exited = store
3297 .processes()
3298 .upsert(NewProcess {
3299 id: Some("p-exited".to_string()),
3300 task_id: None,
3301 pid: 1,
3302 command: "c".to_string(),
3303 cwd: None,
3304 log_path: None,
3305 detected_url: None,
3306 status: ProcessStatus::Exited,
3307 health: None,
3308 })
3309 .expect("exited process");
3310 store
3311 .conn
3312 .execute(
3313 "UPDATE processes SET updated_at = ?2 WHERE id = ?1",
3314 params![exited.id, old],
3315 )
3316 .unwrap();
3317 let running_proc = store
3318 .processes()
3319 .upsert(NewProcess {
3320 id: Some("p-running".to_string()),
3321 task_id: None,
3322 pid: 2,
3323 command: "c".to_string(),
3324 cwd: None,
3325 log_path: None,
3326 detected_url: None,
3327 status: ProcessStatus::Running,
3328 health: None,
3329 })
3330 .expect("running process");
3331
3332 let comp = store
3334 .compactions()
3335 .create(NewCompaction {
3336 id: Some("comp-old".to_string()),
3337 task_id: None,
3338 session_id: None,
3339 source_token_estimate: None,
3340 summary_token_count: None,
3341 preserved_turns: None,
3342 archive_path: None,
3343 verification_status: None,
3344 })
3345 .expect("compaction");
3346 store
3347 .conn
3348 .execute(
3349 "UPDATE compactions SET created_at = ?2 WHERE id = ?1",
3350 params![comp.id, old],
3351 )
3352 .unwrap();
3353
3354 let removed = store.gc(30).expect("gc");
3355 assert!(removed >= 5, "stale rows pruned (got {removed})");
3356 assert!(
3357 store.sessions().get(&stale_session.id).unwrap().is_none(),
3358 "stale session gone"
3359 );
3360 assert!(
3361 store
3362 .messages()
3363 .list_for_session(&stale_session.id)
3364 .unwrap()
3365 .is_empty(),
3366 "stale messages gone"
3367 );
3368 assert!(
3369 store.sessions().get(&active_session.id).unwrap().is_some(),
3370 "active session kept"
3371 );
3372 assert_eq!(
3373 store
3374 .messages()
3375 .list_for_session(&active_session.id)
3376 .unwrap()
3377 .len(),
3378 1,
3379 "active message kept"
3380 );
3381 assert!(
3382 store.tool_runs().get("tr-finished").unwrap().is_none(),
3383 "old finished tool_run gone"
3384 );
3385 assert!(
3386 store.tool_runs().get("tr-running").unwrap().is_some(),
3387 "running tool_run kept"
3388 );
3389 assert!(
3390 store.processes().get(&exited.id).unwrap().is_none(),
3391 "old exited process gone"
3392 );
3393 assert!(
3394 store.processes().get(&running_proc.id).unwrap().is_some(),
3395 "running process kept"
3396 );
3397 assert!(
3398 store.compactions().get(&comp.id).unwrap().is_none(),
3399 "old compaction gone"
3400 );
3401 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3402 }
3403
3404 #[test]
3405 fn task_list_skips_undecodable_status_row() {
3406 let path = temp_db("poison_task");
3409 let store = RuntimeStore::open(&path).expect("open store");
3410 let good = store
3411 .tasks()
3412 .create(NewTask::new("good", "/repo", "m"))
3413 .expect("create good task");
3414 store
3415 .conn
3416 .execute(
3417 "INSERT INTO tasks
3418 (id, title, status, priority, project_path, model_id, created_at, updated_at)
3419 VALUES ('poison', 't', 'from_the_future', 'normal', '/repo', 'm', ?1, ?1)",
3420 params![now_rfc3339()],
3421 )
3422 .unwrap();
3423 let listed = store.tasks().list(50).expect("list");
3424 assert_eq!(
3425 listed.len(),
3426 1,
3427 "the poison row is skipped, the good row remains"
3428 );
3429 assert_eq!(listed[0].id, good.id);
3430 assert!(store.tasks().get("poison").is_err(), "get() stays strict");
3432 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3433 }
3434
3435 #[test]
3436 fn checkpoint_delete_removes_row() {
3437 let path = temp_db("ckpt_delete");
3440 let store = RuntimeStore::open(&path).expect("open store");
3441 let ckpt = store
3442 .checkpoints()
3443 .create(NewCheckpoint {
3444 id: Some("ckpt-1".to_string()),
3445 task_id: None,
3446 project_path: "/repo".to_string(),
3447 snapshot_path: "/data/checkpoints/ckpt-1".to_string(),
3448 changed_files_json: "[]".to_string(),
3449 pending_action_json: None,
3450 approval_id: None,
3451 })
3452 .expect("create checkpoint");
3453 assert!(store.checkpoints().get(&ckpt.id).unwrap().is_some());
3454 assert!(store.checkpoints().delete(&ckpt.id).unwrap(), "row deleted");
3455 assert!(
3456 store.checkpoints().get(&ckpt.id).unwrap().is_none(),
3457 "row gone"
3458 );
3459 assert!(
3460 !store.checkpoints().delete(&ckpt.id).unwrap(),
3461 "second delete is a no-op"
3462 );
3463 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3464 }
3465
3466 #[test]
3467 fn list_for_session_caps_at_max_and_keeps_ascending_order() {
3468 let path = temp_db("session_cap");
3471 let store = RuntimeStore::open(&path).expect("open store");
3472 let session = store
3473 .sessions()
3474 .upsert(NewSession {
3475 id: Some("big".to_string()),
3476 project_path: "/repo".to_string(),
3477 model_id: "m".to_string(),
3478 title: None,
3479 conversation_path: None,
3480 total_tokens: None,
3481 })
3482 .expect("session");
3483 let total = MAX_SESSION_MESSAGES + 10;
3484 let now = now_rfc3339();
3485 let tx = store.conn.unchecked_transaction().unwrap();
3486 for i in 0..total {
3487 tx.execute(
3488 "INSERT INTO messages (session_id, role, content_json, created_at)
3489 VALUES (?1, 'user', ?2, ?3)",
3490 params![session.id, format!("{{\"n\":{i}}}"), now],
3491 )
3492 .unwrap();
3493 }
3494 tx.commit().unwrap();
3495 let listed = store
3496 .messages()
3497 .list_for_session(&session.id)
3498 .expect("list");
3499 assert_eq!(
3500 listed.len() as i64,
3501 MAX_SESSION_MESSAGES,
3502 "capped at the max"
3503 );
3504 assert!(
3505 listed.windows(2).all(|w| w[0].id < w[1].id),
3506 "ascending id order preserved across the capped tail"
3507 );
3508 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3509 }
3510}