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