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 = 6;
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 pub prompt: Option<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct TaskTimelineEvent {
159 pub id: i64,
160 pub task_id: String,
161 pub kind: String,
162 pub message: String,
163 pub created_at: String,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub struct SessionRecord {
168 pub id: String,
169 pub project_path: String,
170 pub model_id: String,
171 pub title: Option<String>,
172 pub conversation_path: Option<String>,
173 pub created_at: String,
174 pub updated_at: String,
175 pub total_tokens: Option<i64>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct NewSession {
180 pub id: Option<String>,
181 pub project_path: String,
182 pub model_id: String,
183 pub title: Option<String>,
184 pub conversation_path: Option<String>,
185 pub total_tokens: Option<i64>,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189pub struct MessageRecord {
190 pub id: i64,
191 pub session_id: String,
192 pub role: String,
193 pub content_json: String,
194 pub created_at: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct NewMessage {
199 pub session_id: String,
200 pub role: String,
201 pub content_json: String,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct NewTask {
206 pub title: String,
207 pub project_path: String,
208 pub model_id: String,
209 pub priority: TaskPriority,
210 pub conversation_id: Option<String>,
211 pub owner_kind: Option<String>,
217 pub prompt: Option<String>,
220}
221
222impl NewTask {
223 pub fn new(
224 title: impl Into<String>,
225 project_path: impl Into<String>,
226 model_id: impl Into<String>,
227 ) -> Self {
228 Self {
229 title: title.into(),
230 project_path: project_path.into(),
231 model_id: model_id.into(),
232 priority: TaskPriority::Normal,
233 conversation_id: None,
234 owner_kind: None,
235 prompt: None,
236 }
237 }
238
239 pub fn daemon_owned(mut self) -> Self {
243 self.owner_kind = Some(OWNER_KIND_DAEMON.to_string());
244 self
245 }
246
247 pub fn with_prompt(mut self, prompt: impl Into<String>) -> Self {
249 self.prompt = Some(prompt.into());
250 self
251 }
252
253 pub fn with_priority(mut self, priority: TaskPriority) -> Self {
254 self.priority = priority;
255 self
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct ApprovalRecord {
261 pub id: String,
262 pub task_id: Option<String>,
263 pub proposed_action: String,
264 pub risk_classification: String,
265 pub policy_decision: String,
266 pub user_decision: Option<String>,
267 pub args_summary: Option<String>,
268 pub checkpoint_id: Option<String>,
269 pub pending_action_json: Option<String>,
270 pub created_at: String,
271 pub decided_at: Option<String>,
272 pub archived_at: Option<String>,
273 pub archive_reason: Option<String>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct NewApproval {
278 pub task_id: Option<String>,
279 pub proposed_action: String,
280 pub risk_classification: String,
281 pub policy_decision: String,
282 pub args_summary: Option<String>,
283 pub checkpoint_id: Option<String>,
284 pub pending_action_json: Option<String>,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct ToolRunRecord {
289 pub id: String,
290 pub task_id: Option<String>,
291 pub turn_id: Option<String>,
292 pub call_id: Option<String>,
293 pub tool_name: String,
294 pub status: String,
295 pub args_json: Option<String>,
296 pub output_json: Option<String>,
297 pub started_at: String,
298 pub finished_at: Option<String>,
299}
300
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct NewToolRun {
303 pub id: Option<String>,
304 pub task_id: Option<String>,
305 pub turn_id: Option<String>,
306 pub call_id: Option<String>,
307 pub tool_name: String,
308 pub args_json: Option<String>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct ProcessRecord {
313 pub id: String,
314 pub task_id: Option<String>,
315 pub pid: u32,
316 pub command: String,
317 pub cwd: Option<String>,
318 pub log_path: Option<String>,
319 pub detected_url: Option<String>,
320 pub status: ProcessStatus,
321 pub health: Option<String>,
322 pub created_at: String,
323 pub updated_at: String,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct NewProcess {
328 pub id: Option<String>,
329 pub task_id: Option<String>,
330 pub pid: u32,
331 pub command: String,
332 pub cwd: Option<String>,
333 pub log_path: Option<String>,
334 pub detected_url: Option<String>,
335 pub status: ProcessStatus,
336 pub health: Option<String>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
340pub struct CheckpointRecord {
341 pub id: String,
342 pub task_id: Option<String>,
343 pub project_path: String,
344 pub snapshot_path: String,
345 pub changed_files_json: String,
346 pub pending_action_json: Option<String>,
347 pub approval_id: Option<String>,
348 pub created_at: String,
349 pub archived_at: Option<String>,
350 pub archive_reason: Option<String>,
351 pub session_id: Option<String>,
355 pub message_index: Option<i64>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct NewCheckpoint {
364 pub id: Option<String>,
365 pub task_id: Option<String>,
366 pub project_path: String,
367 pub snapshot_path: String,
368 pub changed_files_json: String,
369 pub pending_action_json: Option<String>,
370 pub approval_id: Option<String>,
371 pub session_id: Option<String>,
372 pub message_index: Option<i64>,
373}
374
375pub const OUTCOME_SOURCE_VERIFIER: &str = "verifier";
382pub const OUTCOME_SOURCE_USER: &str = "user";
383pub const OUTCOME_SOURCE_MODEL: &str = "model";
384pub const OUTCOME_SOURCE_SYSTEM: &str = "system";
385
386pub const OUTCOME_LABEL_SUCCESS: &str = "success";
390pub const OUTCOME_LABEL_FAILURE: &str = "failure";
391pub const OUTCOME_LABEL_PARTIAL: &str = "partial";
392pub const OUTCOME_LABEL_ACCEPTED: &str = "accepted";
393pub const OUTCOME_LABEL_REJECTED: &str = "rejected";
394pub const OUTCOME_LABEL_UNKNOWN: &str = "unknown";
395
396#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
402pub struct OutcomeRecord {
403 pub id: String,
404 pub task_id: Option<String>,
405 pub tool_run_id: Option<String>,
406 pub kind: String,
409 pub label: String,
411 pub reward: Option<f64>,
414 pub source: String,
416 pub detail_json: Option<String>,
419 pub created_at: String,
420}
421
422#[derive(Debug, Clone, PartialEq)]
423pub struct NewOutcome {
424 pub id: Option<String>,
425 pub task_id: Option<String>,
426 pub tool_run_id: Option<String>,
427 pub kind: String,
428 pub label: String,
429 pub reward: Option<f64>,
430 pub source: String,
431 pub detail_json: Option<String>,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct CompactionRecord {
436 pub id: String,
437 pub task_id: Option<String>,
438 pub session_id: Option<String>,
439 pub source_token_estimate: Option<i64>,
440 pub summary_token_count: Option<i64>,
441 pub preserved_turns: Option<i64>,
442 pub archive_path: Option<String>,
443 pub verification_status: Option<String>,
444 pub created_at: String,
445}
446
447#[derive(Debug, Clone, PartialEq, Eq)]
448pub struct NewCompaction {
449 pub id: Option<String>,
450 pub task_id: Option<String>,
451 pub session_id: Option<String>,
452 pub source_token_estimate: Option<i64>,
453 pub summary_token_count: Option<i64>,
454 pub preserved_turns: Option<i64>,
455 pub archive_path: Option<String>,
456 pub verification_status: Option<String>,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct PluginInstallRecord {
461 pub id: String,
462 pub name: String,
463 pub source: String,
464 pub version: Option<String>,
465 pub enabled: bool,
466 pub manifest_json: String,
467 pub installed_at: String,
468 pub updated_at: String,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct NewPluginInstall {
473 pub id: Option<String>,
474 pub name: String,
475 pub source: String,
476 pub version: Option<String>,
477 pub enabled: bool,
478 pub manifest_json: String,
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482pub struct ProviderProbeRecord {
483 pub provider: String,
484 pub model_id: String,
485 pub capability_key: String,
486 pub capability_value: String,
487 pub confidence: String,
488 pub error: Option<String>,
489 pub probed_at: String,
490}
491
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct NewProviderProbe {
494 pub provider: String,
495 pub model_id: String,
496 pub capability_key: String,
497 pub capability_value: String,
498 pub confidence: String,
499 pub error: Option<String>,
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503pub struct PairingTokenRecord {
504 pub id: String,
505 pub token_hash: String,
506 pub label: Option<String>,
507 pub enabled: bool,
508 pub created_at: String,
509 pub last_used_at: Option<String>,
510 pub expires_at: Option<String>,
512}
513
514pub struct RuntimeStore {
516 conn: Connection,
517 path: PathBuf,
518}
519
520impl RuntimeStore {
521 pub fn open_default() -> Result<Self> {
522 let dir = data_dir()?;
523 std::fs::create_dir_all(&dir)
524 .with_context(|| format!("failed to create Mermaid data dir {}", dir.display()))?;
525 #[cfg(unix)]
529 {
530 use std::os::unix::fs::PermissionsExt;
531 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
532 }
533 #[cfg(windows)]
540 {
541 let sentinel = dir.join(".acl-hardened");
542 if !sentinel.exists()
543 && let Ok(user) = std::env::var("USERNAME")
544 && !user.is_empty()
545 {
546 let hardened = std::process::Command::new("icacls")
547 .arg(&dir)
548 .arg("/inheritance:r")
549 .arg("/grant:r")
550 .arg(format!("{user}:(OI)(CI)F"))
551 .arg("/T")
552 .stdout(std::process::Stdio::null())
553 .stderr(std::process::Stdio::null())
554 .status()
555 .map(|status| status.success())
556 .unwrap_or(false);
557 if hardened {
558 let _ = std::fs::write(&sentinel, b"1");
559 }
560 }
561 }
562 Self::open(dir.join("runtime.sqlite3"))
563 }
564
565 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
566 let path = path.as_ref().to_path_buf();
567 if let Some(parent) = path.parent() {
568 std::fs::create_dir_all(parent).with_context(|| {
569 format!("failed to create SQLite parent dir {}", parent.display())
570 })?;
571 }
572 let conn = Connection::open(&path)
573 .with_context(|| format!("failed to open runtime DB {}", path.display()))?;
574 conn.busy_timeout(std::time::Duration::from_secs(5))
581 .context("failed to set SQLite busy_timeout")?;
582 conn.execute_batch(
587 "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
588 )
589 .context("failed to set SQLite connection PRAGMAs")?;
590 let store = Self { conn, path };
591 store.init_schema()?;
592 Ok(store)
593 }
594
595 pub fn path(&self) -> &Path {
596 &self.path
597 }
598
599 pub fn sessions(&self) -> SessionsRepo<'_> {
600 SessionsRepo { conn: &self.conn }
601 }
602
603 pub fn messages(&self) -> MessagesRepo<'_> {
604 MessagesRepo { conn: &self.conn }
605 }
606
607 pub fn tasks(&self) -> TasksRepo<'_> {
608 TasksRepo { conn: &self.conn }
609 }
610
611 pub fn tool_runs(&self) -> ToolRunsRepo<'_> {
612 ToolRunsRepo { conn: &self.conn }
613 }
614
615 pub fn approvals(&self) -> ApprovalsRepo<'_> {
616 ApprovalsRepo { conn: &self.conn }
617 }
618
619 pub fn processes(&self) -> ProcessesRepo<'_> {
620 ProcessesRepo { conn: &self.conn }
621 }
622
623 pub fn checkpoints(&self) -> CheckpointsRepo<'_> {
624 CheckpointsRepo { conn: &self.conn }
625 }
626
627 pub fn compactions(&self) -> CompactionsRepo<'_> {
628 CompactionsRepo { conn: &self.conn }
629 }
630
631 pub fn plugins(&self) -> PluginsRepo<'_> {
632 PluginsRepo { conn: &self.conn }
633 }
634
635 pub fn provider_probes(&self) -> ProviderProbesRepo<'_> {
636 ProviderProbesRepo { conn: &self.conn }
637 }
638
639 pub fn pairing_tokens(&self) -> PairingTokensRepo<'_> {
640 PairingTokensRepo { conn: &self.conn }
641 }
642
643 pub fn outcomes(&self) -> OutcomesRepo<'_> {
644 OutcomesRepo { conn: &self.conn }
645 }
646
647 pub fn reconcile_after_restart(&self) -> Result<(usize, usize)> {
661 let now = now_rfc3339();
662 self.conn.execute_batch("BEGIN IMMEDIATE;")?;
669 let result = (|| -> Result<(usize, usize)> {
670 let running: Vec<String> = {
671 let mut stmt = self
672 .conn
673 .prepare("SELECT id FROM tasks WHERE status = 'running' AND owner_kind = ?1")?;
674 let ids = stmt.query_map([OWNER_KIND_DAEMON], |row| row.get::<_, String>(0))?;
675 ids.collect::<rusqlite::Result<Vec<_>>>()?
676 };
677 for id in &running {
678 self.conn.execute(
679 "UPDATE tasks SET status = 'failed', updated_at = ?2 WHERE id = ?1",
680 params![id, now],
681 )?;
682 self.conn.execute(
683 "INSERT INTO task_events (task_id, kind, message, created_at)
684 VALUES (?1, ?2, ?3, ?4)",
685 params![
686 id,
687 "interrupted",
688 "task was running when the daemon restarted; marked failed",
689 now
690 ],
691 )?;
692 }
693 let claims_released = self.conn.execute(
694 "UPDATE approvals SET user_decision = NULL WHERE user_decision = 'approving'",
695 [],
696 )?;
697 Ok((running.len(), claims_released))
698 })();
699 match result {
700 Ok(v) => {
701 self.conn.execute_batch("COMMIT;")?;
702 Ok(v)
703 },
704 Err(e) => {
705 let _ = self.conn.execute_batch("ROLLBACK;");
706 Err(e)
707 },
708 }
709 }
710
711 pub fn gc(&self, retention_days: i64, outcomes_retention_days: i64) -> Result<u64> {
722 let now = chrono::Utc::now();
723 let cutoff = (now - chrono::Duration::days(retention_days)).to_rfc3339();
724 let outcomes_cutoff = (now - chrono::Duration::days(outcomes_retention_days)).to_rfc3339();
725 let tx = self.conn.unchecked_transaction()?;
726 let mut removed = 0u64;
727 removed += tx.execute(
728 "DELETE FROM approvals WHERE archived_at IS NOT NULL AND archived_at < ?1",
729 params![cutoff],
730 )? as u64;
731 removed += tx.execute(
732 "DELETE FROM checkpoints WHERE archived_at IS NOT NULL AND archived_at < ?1",
733 params![cutoff],
734 )? as u64;
735 removed += tx.execute(
736 "DELETE FROM task_events
737 WHERE created_at < ?1
738 AND task_id IN (
739 SELECT id FROM tasks
740 WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1
741 )",
742 params![cutoff],
743 )? as u64;
744 removed += tx.execute(
748 "DELETE FROM tool_runs WHERE finished_at IS NOT NULL AND finished_at < ?1",
749 params![cutoff],
750 )? as u64;
751 removed += tx.execute(
754 "DELETE FROM processes WHERE status = 'exited' AND updated_at < ?1",
755 params![cutoff],
756 )? as u64;
757 removed += tx.execute(
760 "DELETE FROM compactions WHERE created_at < ?1",
761 params![cutoff],
762 )? as u64;
763 removed += tx.execute(
769 "DELETE FROM messages
770 WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ?1)",
771 params![cutoff],
772 )? as u64;
773 removed += tx.execute(
774 "DELETE FROM sessions WHERE updated_at < ?1",
775 params![cutoff],
776 )? as u64;
777 removed += tx.execute(
785 "DELETE FROM outcomes WHERE created_at < ?1",
786 params![outcomes_cutoff],
787 )? as u64;
788 removed += tx.execute(
794 "DELETE FROM tasks
795 WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1",
796 params![cutoff],
797 )? as u64;
798 tx.commit()?;
799 Ok(removed)
800 }
801
802 fn init_schema(&self) -> Result<()> {
803 let conn = &self.conn;
804 let current: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
811 anyhow::ensure!(
812 current <= SCHEMA_VERSION,
813 "runtime DB schema version {} is newer than this build supports ({}); upgrade mermaid",
814 current,
815 SCHEMA_VERSION
816 );
817
818 if current == SCHEMA_VERSION {
828 return Ok(());
829 }
830
831 conn.execute_batch("BEGIN IMMEDIATE;")?;
839 if let Err(error) = self.migrate_within_txn(current) {
840 let _ = conn.execute_batch("ROLLBACK;");
841 return Err(error);
842 }
843 conn.execute_batch("COMMIT;")?;
844
845 conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
848 let version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
849 anyhow::ensure!(
850 version == SCHEMA_VERSION,
851 "unsupported runtime DB schema version {} (expected {})",
852 version,
853 SCHEMA_VERSION
854 );
855 Ok(())
856 }
857
858 fn migrate_within_txn(&self, from_version: i32) -> Result<()> {
864 self.conn.execute_batch(
865 r#"
866 CREATE TABLE IF NOT EXISTS sessions (
867 id TEXT PRIMARY KEY,
868 project_path TEXT NOT NULL,
869 model_id TEXT NOT NULL,
870 title TEXT,
871 conversation_path TEXT,
872 created_at TEXT NOT NULL,
873 updated_at TEXT NOT NULL,
874 total_tokens INTEGER
875 );
876
877 CREATE TABLE IF NOT EXISTS messages (
878 id INTEGER PRIMARY KEY AUTOINCREMENT,
879 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
880 role TEXT NOT NULL,
881 content_json TEXT NOT NULL,
882 created_at TEXT NOT NULL
883 );
884 CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
885
886 CREATE TABLE IF NOT EXISTS tasks (
887 id TEXT PRIMARY KEY,
888 title TEXT NOT NULL,
889 status TEXT NOT NULL,
890 priority TEXT NOT NULL,
891 project_path TEXT NOT NULL,
892 model_id TEXT NOT NULL,
893 conversation_id TEXT,
894 created_at TEXT NOT NULL,
895 updated_at TEXT NOT NULL,
896 final_report TEXT,
897 owner_kind TEXT
898 );
899 CREATE INDEX IF NOT EXISTS idx_tasks_project_status
900 ON tasks(project_path, status, updated_at);
901 -- F75: `reconcile_after_restart` filters `status = 'running' AND
902 -- owner_kind = ?`, which the (project_path, ...) index above cannot
903 -- serve (wrong leading column). This covering index does.
904 CREATE INDEX IF NOT EXISTS idx_tasks_status_owner
905 ON tasks(status, owner_kind);
906
907 CREATE TABLE IF NOT EXISTS task_events (
908 id INTEGER PRIMARY KEY AUTOINCREMENT,
909 task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
910 kind TEXT NOT NULL,
911 message TEXT NOT NULL,
912 created_at TEXT NOT NULL
913 );
914 CREATE INDEX IF NOT EXISTS idx_task_events_task_id
915 ON task_events(task_id, id);
916
917 CREATE TABLE IF NOT EXISTS tool_runs (
918 id TEXT PRIMARY KEY,
919 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
920 turn_id TEXT,
921 call_id TEXT,
922 tool_name TEXT NOT NULL,
923 status TEXT NOT NULL,
924 args_json TEXT,
925 output_json TEXT,
926 started_at TEXT NOT NULL,
927 finished_at TEXT
928 );
929 CREATE INDEX IF NOT EXISTS idx_tool_runs_task_id ON tool_runs(task_id);
930
931 CREATE TABLE IF NOT EXISTS approvals (
932 id TEXT PRIMARY KEY,
933 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
934 proposed_action TEXT NOT NULL,
935 risk_classification TEXT NOT NULL,
936 policy_decision TEXT NOT NULL,
937 user_decision TEXT,
938 args_summary TEXT,
939 checkpoint_id TEXT,
940 pending_action_json TEXT,
941 created_at TEXT NOT NULL,
942 decided_at TEXT,
943 archived_at TEXT,
944 archive_reason TEXT
945 );
946 CREATE INDEX IF NOT EXISTS idx_approvals_task_id ON approvals(task_id);
947 -- F75: `list_pending` scans `user_decision IS NULL ORDER BY
948 -- created_at`. A partial index over only the pending rows stays tiny
949 -- and serves both the filter and the ordering.
950 CREATE INDEX IF NOT EXISTS idx_approvals_pending
951 ON approvals(created_at)
952 WHERE user_decision IS NULL;
953
954 CREATE TABLE IF NOT EXISTS processes (
955 id TEXT PRIMARY KEY,
956 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
957 pid INTEGER NOT NULL,
958 command TEXT NOT NULL,
959 cwd TEXT,
960 log_path TEXT,
961 detected_url TEXT,
962 status TEXT NOT NULL,
963 health TEXT,
964 created_at TEXT NOT NULL,
965 updated_at TEXT NOT NULL
966 );
967 CREATE INDEX IF NOT EXISTS idx_processes_task_id ON processes(task_id);
968 CREATE INDEX IF NOT EXISTS idx_processes_pid ON processes(pid);
969
970 CREATE TABLE IF NOT EXISTS checkpoints (
971 id TEXT PRIMARY KEY,
972 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
973 project_path TEXT NOT NULL,
974 snapshot_path TEXT NOT NULL,
975 changed_files_json TEXT NOT NULL,
976 pending_action_json TEXT,
977 approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL,
978 created_at TEXT NOT NULL,
979 archived_at TEXT,
980 archive_reason TEXT,
981 session_id TEXT,
982 message_index INTEGER
983 );
984
985 CREATE TABLE IF NOT EXISTS compactions (
986 id TEXT PRIMARY KEY,
987 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
988 session_id TEXT,
989 source_token_estimate INTEGER,
990 summary_token_count INTEGER,
991 preserved_turns INTEGER,
992 archive_path TEXT,
993 verification_status TEXT,
994 created_at TEXT NOT NULL
995 );
996
997 CREATE TABLE IF NOT EXISTS provider_probes (
998 provider TEXT NOT NULL,
999 model_id TEXT NOT NULL,
1000 capability_key TEXT NOT NULL,
1001 capability_value TEXT NOT NULL,
1002 confidence TEXT NOT NULL,
1003 error TEXT,
1004 probed_at TEXT NOT NULL,
1005 PRIMARY KEY (provider, model_id, capability_key)
1006 );
1007
1008 CREATE TABLE IF NOT EXISTS plugin_installs (
1009 id TEXT PRIMARY KEY,
1010 name TEXT NOT NULL,
1011 source TEXT NOT NULL,
1012 version TEXT,
1013 enabled INTEGER NOT NULL DEFAULT 1,
1014 manifest_json TEXT NOT NULL,
1015 installed_at TEXT NOT NULL,
1016 updated_at TEXT NOT NULL
1017 );
1018
1019 CREATE TABLE IF NOT EXISTS pairing_tokens (
1020 id TEXT PRIMARY KEY,
1021 token_hash TEXT NOT NULL,
1022 label TEXT,
1023 enabled INTEGER NOT NULL DEFAULT 1,
1024 created_at TEXT NOT NULL,
1025 last_used_at TEXT,
1026 expires_at TEXT
1027 );
1028 CREATE INDEX IF NOT EXISTS idx_pairing_tokens_enabled
1029 ON pairing_tokens(enabled, created_at);
1030
1031 CREATE TABLE IF NOT EXISTS outcomes (
1032 id TEXT PRIMARY KEY,
1033 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
1034 tool_run_id TEXT REFERENCES tool_runs(id) ON DELETE SET NULL,
1035 kind TEXT NOT NULL,
1036 label TEXT NOT NULL,
1037 reward REAL,
1038 source TEXT NOT NULL,
1039 detail_json TEXT,
1040 created_at TEXT NOT NULL
1041 );
1042 CREATE INDEX IF NOT EXISTS idx_outcomes_task_id ON outcomes(task_id);
1043 CREATE INDEX IF NOT EXISTS idx_outcomes_kind ON outcomes(kind, created_at);
1044 "#,
1045 )?;
1046
1047 ensure_column(&self.conn, "approvals", "pending_action_json", "TEXT")?;
1048 ensure_column(&self.conn, "approvals", "archived_at", "TEXT")?;
1049 ensure_column(&self.conn, "approvals", "archive_reason", "TEXT")?;
1050 ensure_column(&self.conn, "checkpoints", "archived_at", "TEXT")?;
1051 ensure_column(&self.conn, "checkpoints", "archive_reason", "TEXT")?;
1052 ensure_column(&self.conn, "checkpoints", "session_id", "TEXT")?;
1056 ensure_column(&self.conn, "checkpoints", "message_index", "INTEGER")?;
1057 self.conn.execute_batch(
1060 "CREATE INDEX IF NOT EXISTS idx_checkpoints_session
1061 ON checkpoints(session_id, message_index);",
1062 )?;
1063 ensure_column(&self.conn, "tasks", "owner_kind", "TEXT")?;
1067 ensure_column(&self.conn, "tasks", "prompt", "TEXT")?;
1071 if ensure_column(&self.conn, "pairing_tokens", "expires_at", "TEXT")? {
1077 let grace = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
1078 self.conn.execute(
1079 "UPDATE pairing_tokens SET expires_at = ?1 WHERE expires_at IS NULL",
1080 params![grace],
1081 )?;
1082 }
1083
1084 for target in (from_version + 1)..=SCHEMA_VERSION {
1094 match target {
1095 2 => {},
1097 3 => self.migrate_to_v3()?,
1101 4 => self.migrate_to_v4()?,
1105 5 => self.migrate_to_v5()?,
1108 6 => {},
1111 _ => {},
1113 }
1114 }
1115 Ok(())
1116 }
1117
1118 fn migrate_to_v3(&self) -> Result<()> {
1126 Ok(())
1127 }
1128
1129 fn migrate_to_v4(&self) -> Result<()> {
1134 Ok(())
1135 }
1136
1137 fn migrate_to_v5(&self) -> Result<()> {
1141 Ok(())
1142 }
1143}
1144
1145pub struct TasksRepo<'a> {
1146 conn: &'a Connection,
1147}
1148
1149pub struct SessionsRepo<'a> {
1150 conn: &'a Connection,
1151}
1152
1153impl SessionsRepo<'_> {
1154 pub fn upsert(&self, new: NewSession) -> Result<SessionRecord> {
1155 let now = now_rfc3339();
1156 let id = new.id.unwrap_or_else(|| fresh_id("session"));
1157 self.conn.execute(
1158 "INSERT INTO sessions
1159 (id, project_path, model_id, title, conversation_path, created_at, updated_at, total_tokens)
1160 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
1161 ON CONFLICT(id) DO UPDATE SET
1162 project_path = excluded.project_path,
1163 model_id = excluded.model_id,
1164 title = excluded.title,
1165 conversation_path = excluded.conversation_path,
1166 updated_at = excluded.updated_at,
1167 total_tokens = excluded.total_tokens",
1168 params![
1169 id,
1170 new.project_path,
1171 new.model_id,
1172 new.title,
1173 new.conversation_path,
1174 now,
1175 now,
1176 new.total_tokens,
1177 ],
1178 )?;
1179 self.get(&id)?
1180 .context("session was upserted but could not be reloaded")
1181 }
1182
1183 pub fn get(&self, id: &str) -> Result<Option<SessionRecord>> {
1184 self.conn
1185 .query_row(
1186 "SELECT id, project_path, model_id, title, conversation_path,
1187 created_at, updated_at, total_tokens
1188 FROM sessions WHERE id = ?1",
1189 [id],
1190 session_from_row,
1191 )
1192 .optional()
1193 .map_err(Into::into)
1194 }
1195
1196 pub fn list(&self, limit: usize) -> Result<Vec<SessionRecord>> {
1197 let mut stmt = self.conn.prepare(
1198 "SELECT id, project_path, model_id, title, conversation_path,
1199 created_at, updated_at, total_tokens
1200 FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1201 )?;
1202 let rows = stmt.query_map([clamp_limit(limit)], session_from_row)?;
1203 rows.collect::<rusqlite::Result<Vec<_>>>()
1204 .map_err(Into::into)
1205 }
1206}
1207
1208pub struct MessagesRepo<'a> {
1209 conn: &'a Connection,
1210}
1211
1212impl MessagesRepo<'_> {
1213 pub fn add(&self, new: NewMessage) -> Result<MessageRecord> {
1214 self.conn.execute(
1215 "INSERT INTO messages (session_id, role, content_json, created_at)
1216 VALUES (?1, ?2, ?3, ?4)",
1217 params![new.session_id, new.role, new.content_json, now_rfc3339()],
1218 )?;
1219 let id = self.conn.last_insert_rowid();
1220 self.get(id)?
1221 .context("message was inserted but could not be reloaded")
1222 }
1223
1224 pub fn get(&self, id: i64) -> Result<Option<MessageRecord>> {
1225 self.conn
1226 .query_row(
1227 "SELECT id, session_id, role, content_json, created_at
1228 FROM messages WHERE id = ?1",
1229 [id],
1230 message_from_row,
1231 )
1232 .optional()
1233 .map_err(Into::into)
1234 }
1235
1236 pub fn list_for_session(&self, session_id: &str) -> Result<Vec<MessageRecord>> {
1245 let mut stmt = self.conn.prepare(
1246 "SELECT id, session_id, role, content_json, created_at FROM (
1247 SELECT id, session_id, role, content_json, created_at
1248 FROM messages WHERE session_id = ?1
1249 ORDER BY id DESC LIMIT ?2
1250 ) ORDER BY id ASC",
1251 )?;
1252 let rows = stmt.query_map(params![session_id, MAX_SESSION_MESSAGES], message_from_row)?;
1253 rows.collect::<rusqlite::Result<Vec<_>>>()
1254 .map_err(Into::into)
1255 }
1256}
1257
1258impl TasksRepo<'_> {
1259 pub fn create(&self, new: NewTask) -> Result<TaskRecord> {
1260 let now = now_rfc3339();
1261 let owner_kind = new.owner_kind;
1264 let record = TaskRecord {
1265 id: fresh_id("task"),
1266 title: new.title,
1267 status: TaskStatus::Queued,
1268 priority: new.priority,
1269 project_path: new.project_path,
1270 model_id: new.model_id,
1271 conversation_id: new.conversation_id,
1272 created_at: now.clone(),
1273 updated_at: now.clone(),
1274 final_report: None,
1275 prompt: new.prompt,
1276 };
1277 let tx = self.conn.unchecked_transaction()?;
1280 tx.execute(
1281 "INSERT INTO tasks
1282 (id, title, status, priority, project_path, model_id, conversation_id, created_at, updated_at, final_report, owner_kind, prompt)
1283 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1284 params![
1285 record.id,
1286 record.title,
1287 record.status.as_str(),
1288 record.priority.as_str(),
1289 record.project_path,
1290 record.model_id,
1291 record.conversation_id,
1292 record.created_at,
1293 record.updated_at,
1294 record.final_report,
1295 owner_kind,
1296 record.prompt,
1297 ],
1298 )?;
1299 tx.execute(
1300 "INSERT INTO task_events (task_id, kind, message, created_at)
1301 VALUES (?1, ?2, ?3, ?4)",
1302 params![record.id, "task_created", "task created", now],
1303 )?;
1304 tx.commit()?;
1305 self.get(&record.id)?
1306 .context("task was inserted but could not be reloaded")
1307 }
1308
1309 pub fn get(&self, id: &str) -> Result<Option<TaskRecord>> {
1310 self.conn
1311 .query_row(
1312 "SELECT id, title, status, priority, project_path, model_id, conversation_id,
1313 created_at, updated_at, final_report, prompt
1314 FROM tasks WHERE id = ?1",
1315 [id],
1316 task_from_row,
1317 )
1318 .optional()
1319 .map_err(Into::into)
1320 }
1321
1322 pub fn list(&self, limit: usize) -> Result<Vec<TaskRecord>> {
1323 let mut stmt = self.conn.prepare(
1324 "SELECT id, title, status, priority, project_path, model_id, conversation_id,
1325 created_at, updated_at, final_report, prompt
1326 FROM tasks
1327 ORDER BY updated_at DESC
1328 LIMIT ?1",
1329 )?;
1330 let rows = stmt.query_map([clamp_limit(limit)], task_from_row_opt)?;
1334 collect_tolerant(rows)
1335 }
1336
1337 pub fn update_status(
1338 &self,
1339 id: &str,
1340 status: TaskStatus,
1341 final_report: Option<&str>,
1342 ) -> Result<()> {
1343 let now = now_rfc3339();
1344 let tx = self.conn.unchecked_transaction()?;
1346 tx.execute(
1347 "UPDATE tasks
1348 SET status = ?2, updated_at = ?3, final_report = COALESCE(?4, final_report)
1349 WHERE id = ?1",
1350 params![id, status.as_str(), now, final_report],
1351 )?;
1352 tx.execute(
1353 "INSERT INTO task_events (task_id, kind, message, created_at)
1354 VALUES (?1, ?2, ?3, ?4)",
1355 params![
1356 id,
1357 "status_changed",
1358 format!("status changed to {status}"),
1359 now
1360 ],
1361 )?;
1362 tx.commit()?;
1363 Ok(())
1364 }
1365
1366 pub fn claim_next_queued(&self) -> Result<Option<TaskRecord>> {
1377 let tx = self.conn.unchecked_transaction()?;
1378 let claimed = tx
1379 .query_row(
1380 "UPDATE tasks SET status = 'running', updated_at = ?1
1381 WHERE id = (
1382 SELECT id FROM tasks
1383 WHERE status = 'queued' AND owner_kind = ?2 AND prompt IS NOT NULL
1384 ORDER BY CASE priority
1385 WHEN 'high' THEN 0
1386 WHEN 'normal' THEN 1
1387 WHEN 'low' THEN 2
1388 ELSE 1
1389 END,
1390 created_at ASC, id ASC
1391 LIMIT 1
1392 )
1393 RETURNING id, title, status, priority, project_path, model_id,
1394 conversation_id, created_at, updated_at, final_report, prompt",
1395 params![now_rfc3339(), OWNER_KIND_DAEMON],
1396 task_from_row,
1397 )
1398 .optional()?;
1399 if let Some(task) = &claimed {
1400 tx.execute(
1401 "INSERT INTO task_events (task_id, kind, message, created_at)
1402 VALUES (?1, ?2, ?3, ?4)",
1403 params![
1404 task.id,
1405 "status_changed",
1406 "status changed to running (claimed by scheduler)",
1407 now_rfc3339(),
1408 ],
1409 )?;
1410 }
1411 tx.commit()?;
1412 Ok(claimed)
1413 }
1414
1415 pub fn add_event(&self, task_id: &str, kind: &str, message: &str) -> Result<()> {
1416 self.conn.execute(
1417 "INSERT INTO task_events (task_id, kind, message, created_at)
1418 VALUES (?1, ?2, ?3, ?4)",
1419 params![task_id, kind, message, now_rfc3339()],
1420 )?;
1421 Ok(())
1422 }
1423
1424 pub fn events(&self, task_id: &str) -> Result<Vec<TaskTimelineEvent>> {
1425 let mut stmt = self.conn.prepare(
1426 "SELECT id, task_id, kind, message, created_at
1427 FROM task_events
1428 WHERE task_id = ?1
1429 ORDER BY id ASC",
1430 )?;
1431 let rows = stmt.query_map([task_id], task_event_from_row_opt)?;
1433 collect_tolerant(rows)
1434 }
1435}
1436
1437pub struct ToolRunsRepo<'a> {
1438 conn: &'a Connection,
1439}
1440
1441impl ToolRunsRepo<'_> {
1442 pub fn start(&self, new: NewToolRun) -> Result<ToolRunRecord> {
1443 let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
1444 self.conn.execute(
1445 "INSERT INTO tool_runs
1446 (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
1447 VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
1448 params![
1449 id,
1450 new.task_id,
1451 new.turn_id,
1452 new.call_id,
1453 new.tool_name,
1454 new.args_json,
1455 now_rfc3339(),
1456 ],
1457 )?;
1458 self.get(&id)?
1459 .context("tool run was inserted but could not be reloaded")
1460 }
1461
1462 pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
1463 let changed = self.conn.execute(
1464 "UPDATE tool_runs
1465 SET status = ?2, output_json = ?3, finished_at = ?4
1466 WHERE id = ?1",
1467 params![id, status, output_json, now_rfc3339()],
1468 )?;
1469 anyhow::ensure!(changed > 0, "tool run not found: {}", id);
1470 Ok(())
1471 }
1472
1473 pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
1474 self.conn
1475 .query_row(
1476 "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1477 output_json, started_at, finished_at
1478 FROM tool_runs WHERE id = ?1",
1479 [id],
1480 tool_run_from_row,
1481 )
1482 .optional()
1483 .map_err(Into::into)
1484 }
1485
1486 pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1487 let mut stmt = self.conn.prepare(
1488 "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1489 output_json, started_at, finished_at
1490 FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
1491 )?;
1492 let rows = stmt.query_map([clamp_limit(limit)], tool_run_from_row)?;
1493 rows.collect::<rusqlite::Result<Vec<_>>>()
1494 .map_err(Into::into)
1495 }
1496}
1497
1498pub struct OutcomesRepo<'a> {
1499 conn: &'a Connection,
1500}
1501
1502impl OutcomesRepo<'_> {
1503 pub fn record(&self, new: NewOutcome) -> Result<OutcomeRecord> {
1506 let id = new.id.unwrap_or_else(|| fresh_id("outcome"));
1507 self.conn.execute(
1508 "INSERT INTO outcomes
1509 (id, task_id, tool_run_id, kind, label, reward, source, detail_json, created_at)
1510 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1511 params![
1512 id,
1513 new.task_id,
1514 new.tool_run_id,
1515 new.kind,
1516 new.label,
1517 new.reward,
1518 new.source,
1519 new.detail_json,
1520 now_rfc3339(),
1521 ],
1522 )?;
1523 self.get(&id)?
1524 .context("outcome was inserted but could not be reloaded")
1525 }
1526
1527 pub fn get(&self, id: &str) -> Result<Option<OutcomeRecord>> {
1528 self.conn
1529 .query_row(
1530 "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1531 detail_json, created_at
1532 FROM outcomes WHERE id = ?1",
1533 [id],
1534 outcome_from_row,
1535 )
1536 .optional()
1537 .map_err(Into::into)
1538 }
1539
1540 pub fn list_for_task(&self, task_id: &str) -> Result<Vec<OutcomeRecord>> {
1543 let mut stmt = self.conn.prepare(
1544 "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1545 detail_json, created_at
1546 FROM outcomes WHERE task_id = ?1 ORDER BY created_at ASC",
1547 )?;
1548 let rows = stmt.query_map([task_id], outcome_from_row)?;
1549 rows.collect::<rusqlite::Result<Vec<_>>>()
1550 .map_err(Into::into)
1551 }
1552
1553 pub fn list(&self, limit: usize) -> Result<Vec<OutcomeRecord>> {
1554 let mut stmt = self.conn.prepare(
1555 "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1556 detail_json, created_at
1557 FROM outcomes ORDER BY created_at DESC LIMIT ?1",
1558 )?;
1559 let rows = stmt.query_map([clamp_limit(limit)], outcome_from_row)?;
1560 rows.collect::<rusqlite::Result<Vec<_>>>()
1561 .map_err(Into::into)
1562 }
1563}
1564
1565fn outcome_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<OutcomeRecord> {
1566 Ok(OutcomeRecord {
1567 id: row.get(0)?,
1568 task_id: row.get(1)?,
1569 tool_run_id: row.get(2)?,
1570 kind: row.get(3)?,
1571 label: row.get(4)?,
1572 reward: row.get(5)?,
1573 source: row.get(6)?,
1574 detail_json: row.get(7)?,
1575 created_at: row.get(8)?,
1576 })
1577}
1578
1579pub struct ApprovalsRepo<'a> {
1580 conn: &'a Connection,
1581}
1582
1583impl ApprovalsRepo<'_> {
1584 pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
1585 let record = ApprovalRecord {
1586 id: fresh_id("approval"),
1587 task_id: new.task_id,
1588 proposed_action: new.proposed_action,
1589 risk_classification: new.risk_classification,
1590 policy_decision: new.policy_decision,
1591 user_decision: None,
1592 args_summary: new.args_summary,
1593 checkpoint_id: new.checkpoint_id,
1594 pending_action_json: new.pending_action_json,
1595 created_at: now_rfc3339(),
1596 decided_at: None,
1597 archived_at: None,
1598 archive_reason: None,
1599 };
1600 self.conn.execute(
1601 "INSERT INTO approvals
1602 (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
1603 args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
1604 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1605 params![
1606 record.id,
1607 record.task_id,
1608 record.proposed_action,
1609 record.risk_classification,
1610 record.policy_decision,
1611 record.user_decision,
1612 record.args_summary,
1613 record.checkpoint_id,
1614 record.pending_action_json,
1615 record.created_at,
1616 record.decided_at,
1617 ],
1618 )?;
1619 self.get(&record.id)?
1620 .context("approval was inserted but could not be reloaded")
1621 }
1622
1623 pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
1624 self.conn
1625 .query_row(
1626 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1627 user_decision, args_summary, checkpoint_id, pending_action_json,
1628 created_at, decided_at, archived_at, archive_reason
1629 FROM approvals WHERE id = ?1",
1630 [id],
1631 approval_from_row,
1632 )
1633 .optional()
1634 .map_err(Into::into)
1635 }
1636
1637 pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
1638 let changed = self.conn.execute(
1646 "UPDATE approvals
1647 SET user_decision = ?2, decided_at = ?3
1648 WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1649 params![id, user_decision, now_rfc3339()],
1650 )?;
1651 anyhow::ensure!(
1652 changed > 0,
1653 "approval {} cannot be decided (already decided, archived, or not found)",
1654 id
1655 );
1656 Ok(())
1657 }
1658
1659 pub fn claim(&self, id: &str) -> Result<bool> {
1667 let changed = self.conn.execute(
1668 "UPDATE approvals
1669 SET user_decision = 'approving'
1670 WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1671 params![id],
1672 )?;
1673 Ok(changed == 1)
1674 }
1675
1676 pub fn release_claim(&self, id: &str) -> Result<()> {
1679 self.conn.execute(
1680 "UPDATE approvals SET user_decision = NULL
1681 WHERE id = ?1 AND user_decision = 'approving'",
1682 params![id],
1683 )?;
1684 Ok(())
1685 }
1686
1687 pub fn finalize_claimed(&self, id: &str, user_decision: &str) -> Result<()> {
1690 let changed = self.conn.execute(
1691 "UPDATE approvals
1692 SET user_decision = ?2, decided_at = ?3
1693 WHERE id = ?1 AND user_decision = 'approving'",
1694 params![id, user_decision, now_rfc3339()],
1695 )?;
1696 anyhow::ensure!(changed > 0, "approval {} was not in the claimed state", id);
1697 Ok(())
1698 }
1699
1700 pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
1701 self.list_pending_with_archived(false)
1702 }
1703
1704 pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
1705 self.list_pending_with_archived(true)
1706 }
1707
1708 pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
1709 let mut stmt = self.conn.prepare(
1710 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1711 user_decision, args_summary, checkpoint_id, pending_action_json,
1712 created_at, decided_at, archived_at, archive_reason
1713 FROM approvals
1714 ORDER BY created_at DESC
1715 LIMIT ?1",
1716 )?;
1717 let rows = stmt.query_map([clamp_limit(limit)], approval_from_row)?;
1718 rows.collect::<rusqlite::Result<Vec<_>>>()
1719 .map_err(Into::into)
1720 }
1721
1722 fn list_pending_with_archived(&self, include_archived: bool) -> Result<Vec<ApprovalRecord>> {
1723 let archived_filter = if include_archived {
1724 ""
1725 } else {
1726 " AND archived_at IS NULL"
1727 };
1728 let mut stmt = self.conn.prepare(&format!(
1729 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1730 user_decision, args_summary, checkpoint_id, pending_action_json,
1731 created_at, decided_at, archived_at, archive_reason
1732 FROM approvals
1733 WHERE user_decision IS NULL{archived_filter}
1734 ORDER BY created_at DESC"
1735 ))?;
1736 let rows = stmt.query_map([], approval_from_row)?;
1737 rows.collect::<rusqlite::Result<Vec<_>>>()
1738 .map_err(Into::into)
1739 }
1740
1741 pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1742 let archived_at = now_rfc3339();
1743 let mut changed = 0;
1744 for id in ids {
1745 changed += self.conn.execute(
1746 "UPDATE approvals
1747 SET archived_at = COALESCE(archived_at, ?2),
1748 archive_reason = COALESCE(archive_reason, ?3)
1749 WHERE id = ?1 AND archived_at IS NULL",
1750 params![id, archived_at, reason],
1751 )?;
1752 }
1753 Ok(changed)
1754 }
1755
1756 pub fn count_archived(&self) -> Result<usize> {
1757 self.conn
1758 .query_row(
1759 "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
1760 [],
1761 |row| row.get::<_, i64>(0),
1762 )
1763 .map(|count| count as usize)
1764 .map_err(Into::into)
1765 }
1766}
1767
1768pub struct ProcessesRepo<'a> {
1769 conn: &'a Connection,
1770}
1771
1772impl ProcessesRepo<'_> {
1773 pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
1774 let now = now_rfc3339();
1775 let id = new.id.unwrap_or_else(|| fresh_id("process"));
1776 self.conn.execute(
1777 "INSERT INTO processes
1778 (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
1779 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1780 ON CONFLICT(id) DO UPDATE SET
1781 task_id = excluded.task_id,
1782 pid = excluded.pid,
1783 command = excluded.command,
1784 cwd = excluded.cwd,
1785 log_path = excluded.log_path,
1786 detected_url = excluded.detected_url,
1787 status = excluded.status,
1788 health = excluded.health,
1789 updated_at = excluded.updated_at",
1790 params![
1791 id,
1792 new.task_id,
1793 new.pid,
1794 new.command,
1795 new.cwd,
1796 new.log_path,
1797 new.detected_url,
1798 new.status.as_str(),
1799 new.health,
1800 now,
1801 now,
1802 ],
1803 )?;
1804 self.get(&id)?
1805 .context("process was upserted but could not be reloaded")
1806 }
1807
1808 pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
1809 self.conn
1810 .query_row(
1811 "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1812 created_at, updated_at
1813 FROM processes WHERE id = ?1",
1814 [id],
1815 process_from_row,
1816 )
1817 .optional()
1818 .map_err(Into::into)
1819 }
1820
1821 pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1822 let mut stmt = self.conn.prepare(
1823 "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1824 created_at, updated_at
1825 FROM processes
1826 ORDER BY updated_at DESC
1827 LIMIT ?1",
1828 )?;
1829 let rows = stmt.query_map([clamp_limit(limit)], process_from_row_opt)?;
1832 collect_tolerant(rows)
1833 }
1834}
1835
1836pub struct CheckpointsRepo<'a> {
1837 conn: &'a Connection,
1838}
1839
1840impl CheckpointsRepo<'_> {
1841 pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
1842 let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
1843 self.conn.execute(
1844 "INSERT INTO checkpoints
1845 (id, task_id, project_path, snapshot_path, changed_files_json,
1846 pending_action_json, approval_id, created_at, session_id, message_index)
1847 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1848 params![
1849 id,
1850 new.task_id,
1851 new.project_path,
1852 new.snapshot_path,
1853 new.changed_files_json,
1854 new.pending_action_json,
1855 new.approval_id,
1856 now_rfc3339(),
1857 new.session_id,
1858 new.message_index,
1859 ],
1860 )?;
1861 self.get(&id)?
1862 .context("checkpoint was inserted but could not be reloaded")
1863 }
1864
1865 pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
1866 self.conn
1867 .query_row(
1868 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1869 pending_action_json, approval_id, created_at, archived_at, archive_reason,
1870 session_id, message_index
1871 FROM checkpoints WHERE id = ?1",
1872 [id],
1873 checkpoint_from_row,
1874 )
1875 .optional()
1876 .map_err(Into::into)
1877 }
1878
1879 pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
1880 let changed = self.conn.execute(
1881 "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
1882 params![id, approval_id],
1883 )?;
1884 anyhow::ensure!(changed > 0, "checkpoint not found: {}", id);
1885 Ok(())
1886 }
1887
1888 pub fn delete(&self, id: &str) -> Result<bool> {
1898 let changed = self
1899 .conn
1900 .execute("DELETE FROM checkpoints WHERE id = ?1", params![id])?;
1901 Ok(changed > 0)
1902 }
1903
1904 pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1905 self.list_with_archived(limit, false)
1906 }
1907
1908 pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1909 self.list_with_archived(limit, true)
1910 }
1911
1912 fn list_with_archived(
1913 &self,
1914 limit: usize,
1915 include_archived: bool,
1916 ) -> Result<Vec<CheckpointRecord>> {
1917 let archived_filter = if include_archived {
1918 ""
1919 } else {
1920 "WHERE archived_at IS NULL"
1921 };
1922 let mut stmt = self.conn.prepare(&format!(
1923 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1924 pending_action_json, approval_id, created_at, archived_at, archive_reason,
1925 session_id, message_index
1926 FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
1927 ))?;
1928 let rows = stmt.query_map([clamp_limit(limit)], checkpoint_from_row)?;
1929 rows.collect::<rusqlite::Result<Vec<_>>>()
1930 .map_err(Into::into)
1931 }
1932
1933 pub fn list_for_session(
1942 &self,
1943 session_id: &str,
1944 after_message_index: i64,
1945 ) -> Result<Vec<CheckpointRecord>> {
1946 let mut stmt = self.conn.prepare(
1947 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1948 pending_action_json, approval_id, created_at, archived_at, archive_reason,
1949 session_id, message_index
1950 FROM checkpoints
1951 WHERE session_id = ?1 AND message_index > ?2 AND archived_at IS NULL
1952 ORDER BY created_at ASC",
1953 )?;
1954 let rows = stmt.query_map(
1955 params![session_id, after_message_index],
1956 checkpoint_from_row,
1957 )?;
1958 rows.collect::<rusqlite::Result<Vec<_>>>()
1959 .map_err(Into::into)
1960 }
1961
1962 pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1963 let archived_at = now_rfc3339();
1964 let mut changed = 0;
1965 for id in ids {
1966 changed += self.conn.execute(
1967 "UPDATE checkpoints
1968 SET archived_at = COALESCE(archived_at, ?2),
1969 archive_reason = COALESCE(archive_reason, ?3)
1970 WHERE id = ?1 AND archived_at IS NULL",
1971 params![id, archived_at, reason],
1972 )?;
1973 }
1974 Ok(changed)
1975 }
1976
1977 pub fn count_archived(&self) -> Result<usize> {
1978 self.conn
1979 .query_row(
1980 "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1981 [],
1982 |row| row.get::<_, i64>(0),
1983 )
1984 .map(|count| count as usize)
1985 .map_err(Into::into)
1986 }
1987}
1988
1989pub struct CompactionsRepo<'a> {
1990 conn: &'a Connection,
1991}
1992
1993impl CompactionsRepo<'_> {
1994 pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
1995 let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
1996 self.conn.execute(
1997 "INSERT INTO compactions
1998 (id, task_id, session_id, source_token_estimate, summary_token_count,
1999 preserved_turns, archive_path, verification_status, created_at)
2000 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2001 ON CONFLICT(id) DO UPDATE SET
2002 task_id = excluded.task_id,
2003 session_id = excluded.session_id,
2004 source_token_estimate = excluded.source_token_estimate,
2005 summary_token_count = excluded.summary_token_count,
2006 preserved_turns = excluded.preserved_turns,
2007 archive_path = excluded.archive_path,
2008 verification_status = excluded.verification_status",
2009 params![
2010 id,
2011 new.task_id,
2012 new.session_id,
2013 new.source_token_estimate,
2014 new.summary_token_count,
2015 new.preserved_turns,
2016 new.archive_path,
2017 new.verification_status,
2018 now_rfc3339(),
2019 ],
2020 )?;
2021 self.get(&id)?
2022 .context("compaction was inserted but could not be reloaded")
2023 }
2024
2025 pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
2026 self.conn
2027 .query_row(
2028 "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
2029 preserved_turns, archive_path, verification_status, created_at
2030 FROM compactions WHERE id = ?1",
2031 [id],
2032 compaction_from_row,
2033 )
2034 .optional()
2035 .map_err(Into::into)
2036 }
2037
2038 pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
2039 let mut stmt = self.conn.prepare(
2040 "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
2041 preserved_turns, archive_path, verification_status, created_at
2042 FROM compactions ORDER BY created_at DESC LIMIT ?1",
2043 )?;
2044 let rows = stmt.query_map([clamp_limit(limit)], compaction_from_row)?;
2045 rows.collect::<rusqlite::Result<Vec<_>>>()
2046 .map_err(Into::into)
2047 }
2048}
2049
2050pub struct PluginsRepo<'a> {
2051 conn: &'a Connection,
2052}
2053
2054impl PluginsRepo<'_> {
2055 pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
2056 let now = now_rfc3339();
2057 let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
2058 self.conn.execute(
2059 "INSERT INTO plugin_installs
2060 (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
2061 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2062 ON CONFLICT(id) DO UPDATE SET
2063 name = excluded.name,
2064 source = excluded.source,
2065 version = excluded.version,
2066 enabled = excluded.enabled,
2067 manifest_json = excluded.manifest_json,
2068 updated_at = excluded.updated_at",
2069 params![
2070 id,
2071 new.name,
2072 new.source,
2073 new.version,
2074 if new.enabled { 1 } else { 0 },
2075 new.manifest_json,
2076 now,
2077 now,
2078 ],
2079 )?;
2080 self.get(&id)?
2081 .context("plugin install was inserted but could not be reloaded")
2082 }
2083
2084 pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
2085 self.conn
2086 .query_row(
2087 "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
2088 FROM plugin_installs WHERE id = ?1",
2089 [id],
2090 plugin_from_row,
2091 )
2092 .optional()
2093 .map_err(Into::into)
2094 }
2095
2096 pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
2097 let mut stmt = self.conn.prepare(
2098 "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
2099 FROM plugin_installs ORDER BY name ASC",
2100 )?;
2101 let rows = stmt.query_map([], plugin_from_row)?;
2102 rows.collect::<rusqlite::Result<Vec<_>>>()
2103 .map_err(Into::into)
2104 }
2105
2106 pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
2107 self.conn.execute(
2108 "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
2109 params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
2110 )?;
2111 Ok(())
2112 }
2113}
2114
2115pub struct ProviderProbesRepo<'a> {
2116 conn: &'a Connection,
2117}
2118
2119impl ProviderProbesRepo<'_> {
2120 pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
2121 let now = now_rfc3339();
2122 let provider = new.provider;
2123 let model_id = new.model_id;
2124 let capability_key = new.capability_key;
2125 self.conn.execute(
2126 "INSERT INTO provider_probes
2127 (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
2128 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
2129 ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
2130 capability_value = excluded.capability_value,
2131 confidence = excluded.confidence,
2132 error = excluded.error,
2133 probed_at = excluded.probed_at",
2134 params![
2135 &provider,
2136 &model_id,
2137 &capability_key,
2138 new.capability_value,
2139 new.confidence,
2140 new.error,
2141 now,
2142 ],
2143 )?;
2144 self.get(&provider, &model_id, &capability_key)?
2145 .context("provider probe was inserted but could not be reloaded")
2146 }
2147
2148 pub fn get(
2149 &self,
2150 provider: &str,
2151 model_id: &str,
2152 capability_key: &str,
2153 ) -> Result<Option<ProviderProbeRecord>> {
2154 self.conn
2155 .query_row(
2156 "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
2157 FROM provider_probes
2158 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
2159 params![provider, model_id, capability_key],
2160 provider_probe_from_row,
2161 )
2162 .optional()
2163 .map_err(Into::into)
2164 }
2165
2166 pub fn list(
2167 &self,
2168 provider: Option<&str>,
2169 model_id: Option<&str>,
2170 ) -> Result<Vec<ProviderProbeRecord>> {
2171 let mut stmt = self.conn.prepare(
2172 "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
2173 FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
2174 )?;
2175 let rows = stmt.query_map([], provider_probe_from_row)?;
2176 let mut out = Vec::new();
2177 for row in rows {
2178 let probe = row?;
2179 if provider.is_some_and(|p| probe.provider != p) {
2180 continue;
2181 }
2182 if model_id.is_some_and(|m| probe.model_id != m) {
2183 continue;
2184 }
2185 out.push(probe);
2186 }
2187 Ok(out)
2188 }
2189}
2190
2191pub struct PairingTokensRepo<'a> {
2192 conn: &'a Connection,
2193}
2194
2195impl PairingTokensRepo<'_> {
2196 pub fn create(
2197 &self,
2198 token_hash: &str,
2199 label: Option<&str>,
2200 expires_at: Option<&str>,
2201 ) -> Result<PairingTokenRecord> {
2202 let id = fresh_id("pairing");
2203 self.conn.execute(
2204 "INSERT INTO pairing_tokens
2205 (id, token_hash, label, enabled, created_at, last_used_at, expires_at)
2206 VALUES (?1, ?2, ?3, 1, ?4, NULL, ?5)",
2207 params![id, token_hash, label, now_rfc3339(), expires_at],
2208 )?;
2209 self.get(&id)?
2210 .context("pairing token was inserted but could not be reloaded")
2211 }
2212
2213 pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
2214 self.conn
2215 .query_row(
2216 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2217 FROM pairing_tokens WHERE id = ?1",
2218 [id],
2219 pairing_from_row,
2220 )
2221 .optional()
2222 .map_err(Into::into)
2223 }
2224
2225 pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
2234 let now = chrono::Utc::now();
2239 let mut stmt = self.conn.prepare(
2240 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2241 FROM pairing_tokens
2242 WHERE enabled = 1",
2243 )?;
2244 let candidates = stmt
2245 .query_map([], pairing_from_row)?
2246 .collect::<rusqlite::Result<Vec<_>>>()?;
2247 let mut found = None;
2248 for record in candidates {
2249 if is_expired(record.expires_at.as_deref(), now) {
2250 continue;
2251 }
2252 if ct_eq(record.token_hash.as_bytes(), token_hash.as_bytes()) {
2253 found = Some(record);
2254 }
2255 }
2256 Ok(found)
2257 }
2258
2259 pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
2260 let mut stmt = self.conn.prepare(
2261 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2262 FROM pairing_tokens ORDER BY created_at DESC",
2263 )?;
2264 let rows = stmt.query_map([], pairing_from_row)?;
2265 rows.collect::<rusqlite::Result<Vec<_>>>()
2266 .map_err(Into::into)
2267 }
2268
2269 pub fn list_redacted(&self) -> Result<Vec<PairingTokenRecord>> {
2275 Ok(self
2276 .list()?
2277 .into_iter()
2278 .map(|mut record| {
2279 record.token_hash = String::new();
2280 record
2281 })
2282 .collect())
2283 }
2284
2285 pub fn mark_used(&self, id: &str) -> Result<()> {
2286 self.conn.execute(
2287 "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
2288 params![id, now_rfc3339()],
2289 )?;
2290 Ok(())
2291 }
2292
2293 pub fn revoke(&self, id: &str) -> Result<bool> {
2296 let changed = self.conn.execute(
2297 "UPDATE pairing_tokens SET enabled = 0 WHERE id = ?1 AND enabled = 1",
2298 params![id],
2299 )?;
2300 Ok(changed > 0)
2301 }
2302}
2303
2304fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<bool> {
2312 fn is_sql_identifier(s: &str) -> bool {
2313 let mut chars = s.chars();
2314 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
2315 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
2316 }
2317 const ALLOWED_DEFINITIONS: &[&str] = &["TEXT", "INTEGER", "REAL", "BLOB"];
2318 anyhow::ensure!(
2319 is_sql_identifier(table),
2320 "invalid table identifier: {table}"
2321 );
2322 anyhow::ensure!(
2323 is_sql_identifier(column),
2324 "invalid column identifier: {column}"
2325 );
2326 anyhow::ensure!(
2327 ALLOWED_DEFINITIONS.contains(&definition),
2328 "unsupported column definition: {definition}"
2329 );
2330
2331 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
2332 let mut rows = stmt.query([])?;
2333 while let Some(row) = rows.next()? {
2334 let name: String = row.get(1)?;
2335 if name == column {
2336 return Ok(false);
2337 }
2338 }
2339 match conn.execute(
2344 &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
2345 [],
2346 ) {
2347 Ok(_) => Ok(true),
2348 Err(error) if error.to_string().contains("duplicate column") => Ok(false),
2349 Err(error) => Err(error.into()),
2350 }
2351}
2352
2353pub fn data_dir() -> Result<PathBuf> {
2354 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
2355 return Ok(proj_dirs.data_dir().to_path_buf());
2356 }
2357 let home = std::env::var("HOME")
2358 .or_else(|_| std::env::var("USERPROFILE"))
2359 .context("could not determine home directory")?;
2360 Ok(PathBuf::from(home).join(".local/share/mermaid"))
2361}
2362
2363fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
2364 Ok(SessionRecord {
2365 id: row.get("id")?,
2366 project_path: row.get("project_path")?,
2367 model_id: row.get("model_id")?,
2368 title: row.get("title")?,
2369 conversation_path: row.get("conversation_path")?,
2370 created_at: row.get("created_at")?,
2371 updated_at: row.get("updated_at")?,
2372 total_tokens: row.get("total_tokens")?,
2373 })
2374}
2375
2376fn message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRecord> {
2377 Ok(MessageRecord {
2378 id: row.get("id")?,
2379 session_id: row.get("session_id")?,
2380 role: row.get("role")?,
2381 content_json: row.get("content_json")?,
2382 created_at: row.get("created_at")?,
2383 })
2384}
2385
2386fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
2387 let status_raw: String = row.get("status")?;
2388 let priority_raw: String = row.get("priority")?;
2389 Ok(TaskRecord {
2390 id: row.get("id")?,
2391 title: row.get("title")?,
2392 status: TaskStatus::from_db(&status_raw)
2393 .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2394 priority: TaskPriority::from_db(&priority_raw)
2395 .map_err(|e| enum_from_sql_error("priority", priority_raw, e))?,
2396 project_path: row.get("project_path")?,
2397 model_id: row.get("model_id")?,
2398 conversation_id: row.get("conversation_id")?,
2399 created_at: row.get("created_at")?,
2400 updated_at: row.get("updated_at")?,
2401 final_report: row.get("final_report")?,
2402 prompt: row.get("prompt")?,
2403 })
2404}
2405
2406fn process_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProcessRecord> {
2407 let status_raw: String = row.get("status")?;
2408 let pid: i64 = row.get("pid")?;
2409 Ok(ProcessRecord {
2410 id: row.get("id")?,
2411 task_id: row.get("task_id")?,
2412 pid: pid as u32,
2413 command: row.get("command")?,
2414 cwd: row.get("cwd")?,
2415 log_path: row.get("log_path")?,
2416 detected_url: row.get("detected_url")?,
2417 status: ProcessStatus::from_db(&status_raw)
2418 .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2419 health: row.get("health")?,
2420 created_at: row.get("created_at")?,
2421 updated_at: row.get("updated_at")?,
2422 })
2423}
2424
2425fn tool_run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ToolRunRecord> {
2426 Ok(ToolRunRecord {
2427 id: row.get("id")?,
2428 task_id: row.get("task_id")?,
2429 turn_id: row.get("turn_id")?,
2430 call_id: row.get("call_id")?,
2431 tool_name: row.get("tool_name")?,
2432 status: row.get("status")?,
2433 args_json: row.get("args_json")?,
2434 output_json: row.get("output_json")?,
2435 started_at: row.get("started_at")?,
2436 finished_at: row.get("finished_at")?,
2437 })
2438}
2439
2440fn approval_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApprovalRecord> {
2441 Ok(ApprovalRecord {
2442 id: row.get("id")?,
2443 task_id: row.get("task_id")?,
2444 proposed_action: row.get("proposed_action")?,
2445 risk_classification: row.get("risk_classification")?,
2446 policy_decision: row.get("policy_decision")?,
2447 user_decision: row.get("user_decision")?,
2448 args_summary: row.get("args_summary")?,
2449 checkpoint_id: row.get("checkpoint_id")?,
2450 pending_action_json: row.get("pending_action_json")?,
2451 created_at: row.get("created_at")?,
2452 decided_at: row.get("decided_at")?,
2453 archived_at: row.get("archived_at")?,
2454 archive_reason: row.get("archive_reason")?,
2455 })
2456}
2457
2458fn checkpoint_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointRecord> {
2459 Ok(CheckpointRecord {
2460 id: row.get("id")?,
2461 task_id: row.get("task_id")?,
2462 project_path: row.get("project_path")?,
2463 snapshot_path: row.get("snapshot_path")?,
2464 changed_files_json: row.get("changed_files_json")?,
2465 pending_action_json: row.get("pending_action_json")?,
2466 approval_id: row.get("approval_id")?,
2467 created_at: row.get("created_at")?,
2468 archived_at: row.get("archived_at")?,
2469 archive_reason: row.get("archive_reason")?,
2470 session_id: row.get("session_id")?,
2471 message_index: row.get("message_index")?,
2472 })
2473}
2474
2475fn compaction_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CompactionRecord> {
2476 Ok(CompactionRecord {
2477 id: row.get("id")?,
2478 task_id: row.get("task_id")?,
2479 session_id: row.get("session_id")?,
2480 source_token_estimate: row.get("source_token_estimate")?,
2481 summary_token_count: row.get("summary_token_count")?,
2482 preserved_turns: row.get("preserved_turns")?,
2483 archive_path: row.get("archive_path")?,
2484 verification_status: row.get("verification_status")?,
2485 created_at: row.get("created_at")?,
2486 })
2487}
2488
2489fn plugin_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PluginInstallRecord> {
2490 let enabled: i64 = row.get("enabled")?;
2491 Ok(PluginInstallRecord {
2492 id: row.get("id")?,
2493 name: row.get("name")?,
2494 source: row.get("source")?,
2495 version: row.get("version")?,
2496 enabled: enabled != 0,
2497 manifest_json: row.get("manifest_json")?,
2498 installed_at: row.get("installed_at")?,
2499 updated_at: row.get("updated_at")?,
2500 })
2501}
2502
2503fn provider_probe_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProviderProbeRecord> {
2504 Ok(ProviderProbeRecord {
2505 provider: row.get("provider")?,
2506 model_id: row.get("model_id")?,
2507 capability_key: row.get("capability_key")?,
2508 capability_value: row.get("capability_value")?,
2509 confidence: row.get("confidence")?,
2510 error: row.get("error")?,
2511 probed_at: row.get("probed_at")?,
2512 })
2513}
2514
2515fn pairing_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PairingTokenRecord> {
2516 let enabled: i64 = row.get("enabled")?;
2517 Ok(PairingTokenRecord {
2518 id: row.get("id")?,
2519 token_hash: row.get("token_hash")?,
2520 label: row.get("label")?,
2521 enabled: enabled != 0,
2522 created_at: row.get("created_at")?,
2523 last_used_at: row.get("last_used_at")?,
2524 expires_at: row.get("expires_at")?,
2525 })
2526}
2527
2528fn task_event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskTimelineEvent> {
2529 Ok(TaskTimelineEvent {
2530 id: row.get("id")?,
2531 task_id: row.get("task_id")?,
2532 kind: row.get("kind")?,
2533 message: row.get("message")?,
2534 created_at: row.get("created_at")?,
2535 })
2536}
2537
2538fn is_row_decode_error(err: &rusqlite::Error) -> bool {
2546 matches!(
2547 err,
2548 rusqlite::Error::FromSqlConversionFailure(..) | rusqlite::Error::InvalidColumnType(..)
2549 )
2550}
2551
2552fn task_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskRecord>> {
2555 match task_from_row(row) {
2556 Ok(record) => Ok(Some(record)),
2557 Err(err) if is_row_decode_error(&err) => {
2558 tracing::warn!(error = %err, "skipping task row this build can't decode (version skew?)");
2559 Ok(None)
2560 },
2561 Err(err) => Err(err),
2562 }
2563}
2564
2565fn process_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<ProcessRecord>> {
2567 match process_from_row(row) {
2568 Ok(record) => Ok(Some(record)),
2569 Err(err) if is_row_decode_error(&err) => {
2570 tracing::warn!(error = %err, "skipping process row this build can't decode (version skew?)");
2571 Ok(None)
2572 },
2573 Err(err) => Err(err),
2574 }
2575}
2576
2577fn task_event_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskTimelineEvent>> {
2579 match task_event_from_row(row) {
2580 Ok(record) => Ok(Some(record)),
2581 Err(err) if is_row_decode_error(&err) => {
2582 tracing::warn!(error = %err, "skipping task event row this build can't decode");
2583 Ok(None)
2584 },
2585 Err(err) => Err(err),
2586 }
2587}
2588
2589fn collect_tolerant<T>(rows: impl Iterator<Item = rusqlite::Result<Option<T>>>) -> Result<Vec<T>> {
2592 let mut out = Vec::new();
2593 for row in rows {
2594 if let Some(item) = row? {
2595 out.push(item);
2596 }
2597 }
2598 Ok(out)
2599}
2600
2601fn enum_from_sql_error(
2602 column: &'static str,
2603 value: String,
2604 source: UnknownRuntimeEnum,
2605) -> rusqlite::Error {
2606 let _ = value;
2607 rusqlite::Error::FromSqlConversionFailure(column_index(column), Type::Text, Box::new(source))
2608}
2609
2610fn column_index(column: &str) -> usize {
2611 match column {
2612 "status" => 2,
2613 "priority" => 3,
2614 _ => 0,
2615 }
2616}
2617
2618#[derive(Debug)]
2619struct UnknownRuntimeEnum {
2620 kind: &'static str,
2621 value: String,
2622}
2623
2624impl UnknownRuntimeEnum {
2625 fn new(kind: &'static str, value: &str) -> Self {
2626 Self {
2627 kind,
2628 value: value.to_string(),
2629 }
2630 }
2631}
2632
2633impl fmt::Display for UnknownRuntimeEnum {
2634 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2635 write!(f, "unknown {} value `{}`", self.kind, self.value)
2636 }
2637}
2638
2639impl std::error::Error for UnknownRuntimeEnum {}
2640
2641fn now_rfc3339() -> String {
2642 chrono::Utc::now().to_rfc3339()
2643}
2644
2645fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2650 if a.len() != b.len() {
2651 return false;
2652 }
2653 let mut diff = 0u8;
2654 for (x, y) in a.iter().zip(b.iter()) {
2655 diff |= x ^ y;
2656 }
2657 diff == 0
2658}
2659
2660fn is_expired(expires_at: Option<&str>, now: chrono::DateTime<chrono::Utc>) -> bool {
2668 match expires_at {
2669 None => false,
2670 Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) {
2671 Ok(dt) => dt <= now,
2672 Err(_) => true,
2673 },
2674 }
2675}
2676
2677const MAX_QUERY_LIMIT: usize = 10_000;
2682
2683fn clamp_limit(limit: usize) -> i64 {
2684 limit.min(MAX_QUERY_LIMIT) as i64
2685}
2686
2687const MAX_SESSION_MESSAGES: i64 = 5_000;
2693
2694pub(crate) fn fresh_id(prefix: &str) -> String {
2695 static SEQ: AtomicU64 = AtomicU64::new(0);
2702 static SALT: OnceLock<u64> = OnceLock::new();
2703 let salt = *SALT.get_or_init(|| {
2704 let mut bytes = [0u8; 8];
2705 let _ = getrandom::fill(&mut bytes);
2708 u64::from_le_bytes(bytes)
2709 });
2710 let nanos = SystemTime::now()
2711 .duration_since(UNIX_EPOCH)
2712 .map(|d| d.as_nanos())
2713 .unwrap_or_default();
2714 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
2715 format!("{prefix}-{nanos:x}-{salt:x}-{seq:x}")
2716}
2717
2718#[cfg(unix)]
2729pub fn try_exclusive_lock(path: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
2730 use rustix::fs::{FlockOperation, flock};
2731 let file = std::fs::OpenOptions::new()
2734 .create(true)
2735 .write(true)
2736 .truncate(false)
2737 .open(path)?;
2738 match flock(&file, FlockOperation::NonBlockingLockExclusive) {
2739 Ok(()) => Ok(Some(file)),
2740 Err(rustix::io::Errno::WOULDBLOCK) => Ok(None),
2741 Err(e) => Err(e.into()),
2742 }
2743}
2744
2745#[cfg(test)]
2746mod tests {
2747 use super::*;
2748
2749 #[test]
2750 fn open_enables_wal_and_busy_timeout() {
2751 let path = temp_db("wal_check");
2754 let store = RuntimeStore::open(&path).expect("open");
2755 let mode: String = store
2756 .conn
2757 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2758 .expect("journal_mode pragma");
2759 assert_eq!(mode.to_lowercase(), "wal");
2760 }
2761
2762 fn temp_db(name: &str) -> PathBuf {
2763 let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{}", name));
2764 let _ = std::fs::remove_dir_all(&dir);
2765 std::fs::create_dir_all(&dir).expect("create temp dir");
2766 dir.join("runtime.sqlite3")
2767 }
2768
2769 #[test]
2770 fn outcomes_round_trip_and_list_for_task() {
2771 let path = temp_db("outcomes");
2772 let store = RuntimeStore::open(&path).expect("open store");
2773 let task = store
2774 .tasks()
2775 .create(NewTask::new("t", "/tmp/p", "m"))
2776 .expect("create task");
2777
2778 let first = store
2779 .outcomes()
2780 .record(NewOutcome {
2781 id: None,
2782 task_id: Some(task.id.clone()),
2783 tool_run_id: None,
2784 kind: "task_terminal".to_string(),
2785 label: OUTCOME_LABEL_SUCCESS.to_string(),
2786 reward: Some(1.0),
2787 source: OUTCOME_SOURCE_SYSTEM.to_string(),
2788 detail_json: None,
2789 })
2790 .expect("record first");
2791 let second = store
2792 .outcomes()
2793 .record(NewOutcome {
2794 id: None,
2795 task_id: Some(task.id.clone()),
2796 tool_run_id: None,
2797 kind: "preference".to_string(),
2798 label: OUTCOME_LABEL_ACCEPTED.to_string(),
2799 reward: None,
2800 source: OUTCOME_SOURCE_USER.to_string(),
2801 detail_json: Some("{\"chosen\":\"a\",\"rejected\":\"b\"}".to_string()),
2802 })
2803 .expect("record second");
2804
2805 assert_eq!(
2808 store.outcomes().get(&first.id).expect("get").as_ref(),
2809 Some(&first)
2810 );
2811 assert_eq!(first.reward, Some(1.0));
2812 assert_eq!(second.reward, None);
2813 assert_eq!(second.source, OUTCOME_SOURCE_USER);
2814 assert!(second.detail_json.as_deref().unwrap().contains("chosen"));
2815
2816 let for_task = store
2820 .outcomes()
2821 .list_for_task(&task.id)
2822 .expect("list_for_task");
2823 assert_eq!(for_task.len(), 2);
2824 let ids: std::collections::HashSet<&str> = for_task.iter().map(|o| o.id.as_str()).collect();
2825 assert!(ids.contains(first.id.as_str()));
2826 assert!(ids.contains(second.id.as_str()));
2827
2828 assert_eq!(store.outcomes().list(10).expect("list").len(), 2);
2830 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2831 }
2832
2833 #[test]
2834 fn claim_next_queued_orders_by_priority_then_fifo_and_skips_unclaimable() {
2835 let path = temp_db("claim_queue");
2836 let store = RuntimeStore::open(&path).expect("open store");
2837
2838 store
2841 .tasks()
2842 .create(NewTask::new("cli", "/p", "m").with_prompt("x"))
2843 .expect("cli task");
2844 store
2845 .tasks()
2846 .create(NewTask::new("meta", "/p", "m").daemon_owned())
2847 .expect("meta task");
2848 let busy = store
2849 .tasks()
2850 .create(
2851 NewTask::new("busy", "/p", "m")
2852 .daemon_owned()
2853 .with_prompt("x"),
2854 )
2855 .expect("busy task");
2856 store
2857 .tasks()
2858 .update_status(&busy.id, TaskStatus::Running, None)
2859 .expect("mark busy running");
2860
2861 let normal_first = store
2862 .tasks()
2863 .create(
2864 NewTask::new("n1", "/p", "m")
2865 .daemon_owned()
2866 .with_prompt("p1"),
2867 )
2868 .expect("n1");
2869 let low = store
2870 .tasks()
2871 .create(
2872 NewTask::new("l1", "/p", "m")
2873 .daemon_owned()
2874 .with_prompt("p2")
2875 .with_priority(TaskPriority::Low),
2876 )
2877 .expect("l1");
2878 let high = store
2879 .tasks()
2880 .create(
2881 NewTask::new("h1", "/p", "m")
2882 .daemon_owned()
2883 .with_prompt("p-high")
2884 .with_priority(TaskPriority::High),
2885 )
2886 .expect("h1");
2887 let normal_second = store
2888 .tasks()
2889 .create(
2890 NewTask::new("n2", "/p", "m")
2891 .daemon_owned()
2892 .with_prompt("p3"),
2893 )
2894 .expect("n2");
2895
2896 let c1 = store.tasks().claim_next_queued().expect("claim 1").unwrap();
2900 assert_eq!(c1.id, high.id);
2901 assert_eq!(c1.status, TaskStatus::Running);
2902 assert_eq!(c1.prompt.as_deref(), Some("p-high"));
2903 let c2 = store.tasks().claim_next_queued().expect("claim 2").unwrap();
2904 assert_eq!(c2.id, normal_first.id);
2905 let c3 = store.tasks().claim_next_queued().expect("claim 3").unwrap();
2906 assert_eq!(c3.id, normal_second.id);
2907 let c4 = store.tasks().claim_next_queued().expect("claim 4").unwrap();
2908 assert_eq!(c4.id, low.id);
2909 assert!(
2911 store
2912 .tasks()
2913 .claim_next_queued()
2914 .expect("claim 5")
2915 .is_none()
2916 );
2917 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2918 }
2919
2920 #[test]
2921 fn outcome_allows_null_task_and_tool_run() {
2922 let path = temp_db("outcomes_null");
2926 let store = RuntimeStore::open(&path).expect("open store");
2927 let rec = store
2928 .outcomes()
2929 .record(NewOutcome {
2930 id: None,
2931 task_id: None,
2932 tool_run_id: None,
2933 kind: "build".to_string(),
2934 label: OUTCOME_LABEL_FAILURE.to_string(),
2935 reward: Some(-1.0),
2936 source: OUTCOME_SOURCE_VERIFIER.to_string(),
2937 detail_json: None,
2938 })
2939 .expect("record");
2940 assert_eq!(rec.task_id, None);
2941 assert_eq!(rec.tool_run_id, None);
2942 assert_eq!(store.outcomes().list(10).expect("list").len(), 1);
2943 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2944 }
2945
2946 #[test]
2947 fn initializes_runtime_schema() {
2948 let path = temp_db("schema");
2949 let store = RuntimeStore::open(&path).expect("open store");
2950 assert_eq!(store.path(), path.as_path());
2951 let version: i32 = store
2952 .conn
2953 .query_row("PRAGMA user_version", [], |row| row.get(0))
2954 .unwrap();
2955 assert_eq!(version, SCHEMA_VERSION);
2956 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2957 }
2958
2959 #[test]
2960 fn rejects_newer_schema_version() {
2961 let path = temp_db("newer_schema");
2964 {
2965 let store = RuntimeStore::open(&path).expect("first open");
2966 store
2967 .conn
2968 .execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
2969 .expect("bump version");
2970 }
2971 let err = match RuntimeStore::open(&path) {
2973 Ok(_) => panic!("must refuse a newer DB"),
2974 Err(e) => e,
2975 };
2976 assert!(
2977 err.to_string().contains("newer than this build"),
2978 "unexpected error: {err}"
2979 );
2980 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2981 }
2982
2983 #[test]
2984 fn checkpoint_anchor_round_trips_and_list_for_session_is_strict() {
2985 let path = temp_db("checkpoint_anchor");
2986 let store = RuntimeStore::open(&path).expect("open store");
2987 for (id, idx) in [("cp-a", 3_i64), ("cp-b", 5), ("cp-c", 9)] {
2988 store
2989 .checkpoints()
2990 .create(NewCheckpoint {
2991 id: Some(id.to_string()),
2992 task_id: None,
2993 project_path: "/tmp/p".to_string(),
2994 snapshot_path: format!("/data/checkpoints/{id}"),
2995 changed_files_json: "[]".to_string(),
2996 pending_action_json: None,
2997 approval_id: None,
2998 session_id: Some("sess-1".to_string()),
2999 message_index: Some(idx),
3000 })
3001 .expect("create checkpoint");
3002 }
3003 store
3005 .checkpoints()
3006 .create(NewCheckpoint {
3007 id: Some("cp-unanchored".to_string()),
3008 task_id: None,
3009 project_path: "/tmp/p".to_string(),
3010 snapshot_path: "/x".to_string(),
3011 changed_files_json: "[]".to_string(),
3012 pending_action_json: None,
3013 approval_id: None,
3014 session_id: None,
3015 message_index: None,
3016 })
3017 .expect("create unanchored");
3018
3019 let got = store.checkpoints().get("cp-a").unwrap().unwrap();
3020 assert_eq!(got.session_id.as_deref(), Some("sess-1"));
3021 assert_eq!(got.message_index, Some(3));
3022
3023 let past = store
3026 .checkpoints()
3027 .list_for_session("sess-1", 3)
3028 .expect("list_for_session");
3029 let ids: Vec<&str> = past.iter().map(|c| c.id.as_str()).collect();
3030 assert_eq!(ids, vec!["cp-b", "cp-c"], "strict > and oldest-first");
3031
3032 assert!(
3033 store
3034 .checkpoints()
3035 .list_for_session("sess-other", 0)
3036 .unwrap()
3037 .is_empty()
3038 );
3039 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3040 }
3041
3042 #[test]
3043 fn v5_database_upgrades_with_null_checkpoint_anchors() {
3044 let path = temp_db("v5_upgrade");
3047 {
3048 let conn = Connection::open(&path).expect("raw open");
3049 conn.execute_batch(
3050 r#"
3051 CREATE TABLE checkpoints (
3052 id TEXT PRIMARY KEY,
3053 task_id TEXT,
3054 project_path TEXT NOT NULL,
3055 snapshot_path TEXT NOT NULL,
3056 changed_files_json TEXT NOT NULL,
3057 pending_action_json TEXT,
3058 approval_id TEXT,
3059 created_at TEXT NOT NULL,
3060 archived_at TEXT,
3061 archive_reason TEXT
3062 );
3063 INSERT INTO checkpoints
3064 (id, task_id, project_path, snapshot_path, changed_files_json, created_at)
3065 VALUES ('old-cp', NULL, '/tmp/p', '/snap', '[]', '2026-01-01T00:00:00Z');
3066 PRAGMA user_version = 5;
3067 "#,
3068 )
3069 .expect("seed v5 schema");
3070 }
3071 let store = RuntimeStore::open(&path).expect("upgrade open");
3072 let old = store.checkpoints().get("old-cp").unwrap().unwrap();
3073 assert_eq!(old.session_id, None);
3074 assert_eq!(old.message_index, None);
3075 let version: i32 = store
3076 .conn
3077 .query_row("PRAGMA user_version", [], |r| r.get(0))
3078 .unwrap();
3079 assert_eq!(version, SCHEMA_VERSION);
3080 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3081 }
3082
3083 #[test]
3084 fn init_schema_is_idempotent_across_opens() {
3085 let path = temp_db("idempotent_schema");
3089 let _ = RuntimeStore::open(&path).expect("first open");
3090 let store = RuntimeStore::open(&path).expect("second open must succeed");
3091 let version: i32 = store
3092 .conn
3093 .query_row("PRAGMA user_version", [], |r| r.get(0))
3094 .unwrap();
3095 assert_eq!(version, SCHEMA_VERSION);
3096 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3097 }
3098
3099 fn explain_query_plan(conn: &Connection, sql: &str) -> String {
3100 let mut stmt = conn
3101 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
3102 .expect("prepare EXPLAIN QUERY PLAN");
3103 let rows = stmt
3106 .query_map([], |row| row.get::<_, String>(3))
3107 .expect("eqp query")
3108 .collect::<rusqlite::Result<Vec<String>>>()
3109 .expect("eqp rows");
3110 rows.join("\n")
3111 }
3112
3113 #[test]
3114 fn pending_and_reconcile_scans_use_indexes() {
3115 let path = temp_db("scan_indexes");
3118 let store = RuntimeStore::open(&path).expect("open");
3119
3120 let index_count: i64 = store
3121 .conn
3122 .query_row(
3123 "SELECT COUNT(*) FROM sqlite_master
3124 WHERE type = 'index'
3125 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
3126 [],
3127 |r| r.get(0),
3128 )
3129 .unwrap();
3130 assert_eq!(index_count, 2, "F75 indexes must be created");
3131
3132 let plan = explain_query_plan(
3135 &store.conn,
3136 "SELECT id FROM approvals WHERE user_decision IS NULL ORDER BY created_at DESC",
3137 );
3138 assert!(
3139 plan.contains("idx_approvals_pending"),
3140 "pending scan must use idx_approvals_pending; plan was:\n{plan}"
3141 );
3142
3143 let plan = explain_query_plan(
3145 &store.conn,
3146 "SELECT id FROM tasks WHERE status = 'running' AND owner_kind = 'daemon'",
3147 );
3148 assert!(
3149 plan.contains("idx_tasks_status_owner"),
3150 "reconcile scan must use idx_tasks_status_owner; plan was:\n{plan}"
3151 );
3152
3153 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3154 }
3155
3156 #[test]
3157 fn upgrades_from_v2_to_current_and_adds_indexes() {
3158 let path = temp_db("upgrade_v2");
3163 {
3164 let store = RuntimeStore::open(&path).expect("first open");
3165 store
3167 .conn
3168 .execute_batch(
3169 "DROP INDEX IF EXISTS idx_approvals_pending;
3170 DROP INDEX IF EXISTS idx_tasks_status_owner;
3171 PRAGMA user_version = 2;",
3172 )
3173 .expect("downgrade to v2");
3174 }
3175 let store = RuntimeStore::open(&path).expect("reopen must migrate forward");
3176 let version: i32 = store
3177 .conn
3178 .query_row("PRAGMA user_version", [], |r| r.get(0))
3179 .unwrap();
3180 assert_eq!(version, SCHEMA_VERSION);
3181 let index_count: i64 = store
3182 .conn
3183 .query_row(
3184 "SELECT COUNT(*) FROM sqlite_master
3185 WHERE type = 'index'
3186 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
3187 [],
3188 |r| r.get(0),
3189 )
3190 .unwrap();
3191 assert_eq!(
3192 index_count, 2,
3193 "forward migration must recreate the F75 indexes"
3194 );
3195 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3196 }
3197
3198 #[test]
3199 fn task_create_commits_task_and_event_atomically() {
3200 let path = temp_db("task_txn");
3202 let store = RuntimeStore::open(&path).expect("open");
3203 let task = store
3204 .tasks()
3205 .create(NewTask::new("do a thing", "/repo", "anthropic/claude"))
3206 .expect("create task");
3207 let events = store.tasks().events(&task.id).expect("events");
3208 assert!(
3209 events.iter().any(|e| e.kind == "task_created"),
3210 "the task_created event must commit with the task row"
3211 );
3212 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3213 }
3214
3215 #[test]
3216 fn task_lifecycle_round_trips() {
3217 let path = temp_db("task");
3218 let store = RuntimeStore::open(&path).expect("open store");
3219 let session = store
3220 .sessions()
3221 .upsert(NewSession {
3222 id: Some("session-1".to_string()),
3223 project_path: "/repo".to_string(),
3224 model_id: "anthropic/claude".to_string(),
3225 title: Some("Run tests".to_string()),
3226 conversation_path: Some("/repo/.mermaid/session.json".to_string()),
3227 total_tokens: Some(42),
3228 })
3229 .expect("upsert session");
3230 assert_eq!(session.id, "session-1");
3231 let message = store
3232 .messages()
3233 .add(NewMessage {
3234 session_id: session.id.clone(),
3235 role: "user".to_string(),
3236 content_json: "{\"text\":\"hi\"}".to_string(),
3237 })
3238 .expect("add message");
3239 assert_eq!(message.role, "user");
3240 assert_eq!(
3241 store
3242 .messages()
3243 .list_for_session(&session.id)
3244 .unwrap()
3245 .len(),
3246 1
3247 );
3248
3249 let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
3250 new.priority = TaskPriority::High;
3251 let task = store.tasks().create(new).expect("create task");
3252
3253 assert_eq!(task.status, TaskStatus::Queued);
3254 assert_eq!(task.priority, TaskPriority::High);
3255
3256 store
3257 .tasks()
3258 .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
3259 .expect("update task");
3260 let loaded = store.tasks().get(&task.id).unwrap().unwrap();
3261 assert_eq!(loaded.status, TaskStatus::Completed);
3262 assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
3263
3264 let events = store.tasks().events(&task.id).expect("events");
3265 assert_eq!(events.len(), 2);
3266 assert_eq!(events[0].kind, "task_created");
3267 assert_eq!(events[1].kind, "status_changed");
3268 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3269 }
3270
3271 #[test]
3272 fn approval_and_process_records_round_trip() {
3273 let path = temp_db("approval_process");
3274 let store = RuntimeStore::open(&path).expect("open store");
3275 let task = store
3276 .tasks()
3277 .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
3278 .expect("create task");
3279
3280 let approval = store
3281 .approvals()
3282 .create(NewApproval {
3283 task_id: Some(task.id.clone()),
3284 proposed_action: "write_file src/lib.rs".to_string(),
3285 risk_classification: "file_mutation".to_string(),
3286 policy_decision: "ask".to_string(),
3287 args_summary: Some("src/lib.rs".to_string()),
3288 checkpoint_id: Some("checkpoint-1".to_string()),
3289 pending_action_json: Some(
3290 "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
3291 ),
3292 })
3293 .expect("create approval");
3294 store
3295 .approvals()
3296 .decide(&approval.id, "approved")
3297 .expect("decide approval");
3298 let approval = store.approvals().get(&approval.id).unwrap().unwrap();
3299 assert_eq!(approval.user_decision.as_deref(), Some("approved"));
3300 assert!(approval.pending_action_json.is_some());
3301
3302 let tool_run = store
3303 .tool_runs()
3304 .start(NewToolRun {
3305 id: Some("toolrun-1".to_string()),
3306 task_id: Some(task.id.clone()),
3307 turn_id: Some("turn-1".to_string()),
3308 call_id: Some("call-1".to_string()),
3309 tool_name: "write_file".to_string(),
3310 args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
3311 })
3312 .expect("start tool run");
3313 assert_eq!(tool_run.status, "running");
3314 store
3315 .tool_runs()
3316 .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
3317 .expect("finish tool run");
3318 let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
3319 assert_eq!(tool_run.status, "success");
3320 assert!(tool_run.finished_at.is_some());
3321
3322 let process = store
3323 .processes()
3324 .upsert(NewProcess {
3325 id: Some("proc-1".to_string()),
3326 task_id: Some(task.id),
3327 pid: 123,
3328 command: "npm run dev".to_string(),
3329 cwd: Some("/repo".to_string()),
3330 log_path: Some("/tmp/mermaid.log".to_string()),
3331 detected_url: Some("http://127.0.0.1:5173".to_string()),
3332 status: ProcessStatus::Running,
3333 health: Some("ready".to_string()),
3334 })
3335 .expect("upsert process");
3336 assert_eq!(process.status, ProcessStatus::Running);
3337 assert_eq!(store.processes().list(10).unwrap().len(), 1);
3338 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3339 }
3340
3341 #[test]
3342 fn approval_decide_is_single_shot() {
3343 let path = temp_db("approval_decide_guard");
3344 let store = RuntimeStore::open(&path).expect("open store");
3345 let make = |action: &str| {
3346 store
3347 .approvals()
3348 .create(NewApproval {
3349 task_id: None,
3350 proposed_action: action.to_string(),
3351 risk_classification: "file_mutation".to_string(),
3352 policy_decision: "ask".to_string(),
3353 args_summary: None,
3354 checkpoint_id: None,
3355 pending_action_json: None,
3356 })
3357 .expect("create approval")
3358 };
3359
3360 let a = make("write_file a");
3363 store
3364 .approvals()
3365 .decide(&a.id, "approved")
3366 .expect("first decide");
3367 assert!(
3368 store.approvals().decide(&a.id, "approved").is_err(),
3369 "re-approving an approved approval must be rejected"
3370 );
3371
3372 let b = make("write_file b");
3374 store.approvals().decide(&b.id, "denied").expect("deny");
3375 assert!(
3376 store.approvals().decide(&b.id, "approved").is_err(),
3377 "a denied approval must not be re-decidable as approved"
3378 );
3379 let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
3380 assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
3381
3382 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3383 }
3384
3385 #[test]
3386 fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
3387 let path = temp_db("archive_visibility");
3388 let store = RuntimeStore::open(&path).expect("open store");
3389
3390 let approval = store
3391 .approvals()
3392 .create(NewApproval {
3393 task_id: None,
3394 proposed_action: "restore replay: write_file".to_string(),
3395 risk_classification: "restored_action".to_string(),
3396 policy_decision: "ask".to_string(),
3397 args_summary: None,
3398 checkpoint_id: Some("checkpoint-1".to_string()),
3399 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3400 })
3401 .expect("create approval");
3402 let checkpoint = store
3403 .checkpoints()
3404 .create(NewCheckpoint {
3405 id: Some("checkpoint-1".to_string()),
3406 task_id: None,
3407 project_path: "/tmp/mermaid_checkpoint_test".to_string(),
3408 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
3409 changed_files_json: "[]".to_string(),
3410 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3411 approval_id: Some(approval.id.clone()),
3412 session_id: None,
3413 message_index: None,
3414 })
3415 .expect("create checkpoint");
3416
3417 assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
3418 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
3419 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
3420 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
3421 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
3422
3423 assert_eq!(
3424 store
3425 .approvals()
3426 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
3427 .unwrap(),
3428 1
3429 );
3430 assert_eq!(
3431 store
3432 .checkpoints()
3433 .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
3434 .unwrap(),
3435 1
3436 );
3437 assert_eq!(
3438 store
3439 .approvals()
3440 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
3441 .unwrap(),
3442 0
3443 );
3444 assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
3445 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
3446 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
3447 assert_eq!(store.approvals().count_archived().unwrap(), 1);
3448 assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
3449 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
3450 assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
3451
3452 let archived = store.approvals().get(&approval.id).unwrap().unwrap();
3453 assert!(archived.archived_at.is_some());
3454 assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
3455 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3456 }
3457
3458 #[test]
3459 fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
3460 let path = temp_db("everything_else");
3461 let store = RuntimeStore::open(&path).expect("open store");
3462
3463 let checkpoint = store
3464 .checkpoints()
3465 .create(NewCheckpoint {
3466 id: Some("checkpoint-1".to_string()),
3467 task_id: None,
3468 project_path: "/repo".to_string(),
3469 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
3470 changed_files_json: "[\"src/lib.rs\"]".to_string(),
3471 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3472 approval_id: None,
3473 session_id: None,
3474 message_index: None,
3475 })
3476 .expect("create checkpoint");
3477 assert_eq!(checkpoint.id, "checkpoint-1");
3478 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
3479
3480 let compaction = store
3481 .compactions()
3482 .create(NewCompaction {
3483 id: Some("compaction-1".to_string()),
3484 task_id: None,
3485 session_id: Some("session-1".to_string()),
3486 source_token_estimate: Some(10_000),
3487 summary_token_count: Some(800),
3488 preserved_turns: Some(6),
3489 archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
3490 verification_status: Some("verified".to_string()),
3491 })
3492 .expect("create compaction");
3493 assert_eq!(compaction.summary_token_count, Some(800));
3494 assert_eq!(store.compactions().list(10).unwrap().len(), 1);
3495
3496 let plugin = store
3497 .plugins()
3498 .install(NewPluginInstall {
3499 id: Some("plugin-1".to_string()),
3500 name: "example".to_string(),
3501 source: "local".to_string(),
3502 version: Some("0.1.0".to_string()),
3503 enabled: true,
3504 manifest_json: "{\"name\":\"example\"}".to_string(),
3505 })
3506 .expect("install plugin");
3507 assert!(plugin.enabled);
3508 store.plugins().set_enabled("plugin-1", false).unwrap();
3509 assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
3510
3511 let probe = store
3512 .provider_probes()
3513 .upsert(NewProviderProbe {
3514 provider: "cerebras".to_string(),
3515 model_id: "gpt-oss-120b".to_string(),
3516 capability_key: "parallel_tool_calls".to_string(),
3517 capability_value: "false".to_string(),
3518 confidence: "static".to_string(),
3519 error: None,
3520 })
3521 .expect("probe");
3522 assert_eq!(probe.confidence, "static");
3523 assert_eq!(
3524 store
3525 .provider_probes()
3526 .list(Some("cerebras"), Some("gpt-oss-120b"))
3527 .unwrap()
3528 .len(),
3529 1
3530 );
3531
3532 let pairing = store
3533 .pairing_tokens()
3534 .create("hash", Some("phone"), None)
3535 .expect("pairing");
3536 store.pairing_tokens().mark_used(&pairing.id).unwrap();
3537 assert!(
3538 store
3539 .pairing_tokens()
3540 .get(&pairing.id)
3541 .unwrap()
3542 .unwrap()
3543 .last_used_at
3544 .is_some()
3545 );
3546 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3547 }
3548
3549 #[test]
3550 fn pairing_token_expiry_and_revoke() {
3551 let path = temp_db("pairing_ttl");
3552 let store = RuntimeStore::open(&path).expect("open store");
3553 let tokens = store.pairing_tokens();
3554
3555 let live = tokens
3557 .create("live_hash", Some("a"), None)
3558 .expect("create live");
3559 assert!(tokens.verify_token("live_hash").unwrap().is_some());
3560
3561 let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
3563 tokens
3564 .create("future_hash", None, Some(&future))
3565 .expect("create future");
3566 assert!(tokens.verify_token("future_hash").unwrap().is_some());
3567
3568 let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
3569 tokens
3570 .create("past_hash", None, Some(&past))
3571 .expect("create past");
3572 assert!(
3573 tokens.verify_token("past_hash").unwrap().is_none(),
3574 "an expired token must not verify"
3575 );
3576
3577 let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
3581 .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
3582 .to_rfc3339();
3583 tokens
3584 .create("skew_hash", None, Some(&skewed))
3585 .expect("create skewed");
3586 assert!(
3587 tokens.verify_token("skew_hash").unwrap().is_some(),
3588 "a future token in a non-UTC offset must verify (parsed-instant compare)"
3589 );
3590
3591 tokens
3593 .create("garbage_hash", None, Some("not-a-timestamp"))
3594 .expect("create garbage");
3595 assert!(
3596 tokens.verify_token("garbage_hash").unwrap().is_none(),
3597 "an unparseable expiry must fail closed"
3598 );
3599
3600 assert!(tokens.revoke(&live.id).unwrap());
3602 assert!(tokens.verify_token("live_hash").unwrap().is_none());
3603 assert!(
3604 !tokens.revoke(&live.id).unwrap(),
3605 "double revoke is a no-op"
3606 );
3607
3608 assert!(tokens.verify_token("nope").unwrap().is_none());
3610
3611 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3612 }
3613
3614 #[test]
3615 fn ct_eq_matches_only_identical_bytes() {
3616 assert!(ct_eq(b"abc", b"abc"));
3617 assert!(!ct_eq(b"abc", b"abd"));
3618 assert!(!ct_eq(b"abc", b"ab"));
3619 assert!(!ct_eq(b"", b"x"));
3620 assert!(ct_eq(b"", b""));
3621 }
3622
3623 #[test]
3624 fn fresh_id_is_collision_free_in_tight_loop() {
3625 let mut seen = std::collections::HashSet::new();
3628 for _ in 0..10_000 {
3629 let id = fresh_id("process");
3630 assert!(id.starts_with("process-"), "id must keep prefix: {id}");
3631 assert!(seen.insert(id), "fresh_id produced a duplicate");
3632 }
3633 }
3634
3635 #[test]
3636 fn ensure_column_rejects_non_identifier() {
3637 let path = temp_db("ensure_col");
3638 let store = RuntimeStore::open(&path).expect("open store");
3639 assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
3640 assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
3641 assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
3642 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3643 }
3644
3645 #[test]
3646 fn clamp_limit_never_binds_negative() {
3647 assert_eq!(clamp_limit(10), 10);
3650 assert_eq!(clamp_limit(usize::MAX), MAX_QUERY_LIMIT as i64);
3651 assert!(clamp_limit(usize::MAX) > 0);
3652 }
3653
3654 fn make_approval(store: &RuntimeStore, action: &str) -> ApprovalRecord {
3655 store
3656 .approvals()
3657 .create(NewApproval {
3658 task_id: None,
3659 proposed_action: action.to_string(),
3660 risk_classification: "shell_mutation".to_string(),
3661 policy_decision: "ask".to_string(),
3662 args_summary: None,
3663 checkpoint_id: None,
3664 pending_action_json: None,
3665 })
3666 .expect("create approval")
3667 }
3668
3669 #[test]
3670 fn approval_claim_is_single_winner_releasable_and_finalizable() {
3671 let path = temp_db("approval_claim");
3674 let store = RuntimeStore::open(&path).expect("open store");
3675 let a = make_approval(&store, "write_file a");
3676
3677 assert!(store.approvals().claim(&a.id).unwrap(), "first claim wins");
3678 assert!(
3679 !store.approvals().claim(&a.id).unwrap(),
3680 "second claim loses"
3681 );
3682
3683 store.approvals().release_claim(&a.id).unwrap();
3684 assert!(
3685 store.approvals().claim(&a.id).unwrap(),
3686 "a released claim is re-claimable (effect-failed path)"
3687 );
3688
3689 store
3690 .approvals()
3691 .finalize_claimed(&a.id, "approved")
3692 .unwrap();
3693 assert_eq!(
3694 store
3695 .approvals()
3696 .get(&a.id)
3697 .unwrap()
3698 .unwrap()
3699 .user_decision
3700 .as_deref(),
3701 Some("approved")
3702 );
3703 assert!(
3704 !store.approvals().claim(&a.id).unwrap(),
3705 "a decided approval cannot be claimed"
3706 );
3707 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3708 }
3709
3710 #[test]
3711 fn reconcile_after_restart_recovers_running_tasks_and_claims() {
3712 let path = temp_db("reconcile");
3715 let store = RuntimeStore::open(&path).expect("open store");
3716 let task = store
3717 .tasks()
3718 .create(NewTask::new("t", "/repo", "m").daemon_owned())
3719 .expect("create task");
3720 store
3721 .tasks()
3722 .update_status(&task.id, TaskStatus::Running, None)
3723 .expect("mark running");
3724 let appr = make_approval(&store, "git push");
3725 assert!(store.approvals().claim(&appr.id).unwrap());
3726
3727 let (tasks, claims) = store.reconcile_after_restart().expect("reconcile");
3728 assert_eq!((tasks, claims), (1, 1));
3729 assert_eq!(
3730 store.tasks().get(&task.id).unwrap().unwrap().status,
3731 TaskStatus::Failed
3732 );
3733 assert!(
3734 store
3735 .approvals()
3736 .get(&appr.id)
3737 .unwrap()
3738 .unwrap()
3739 .user_decision
3740 .is_none(),
3741 "a released claim is undecided and re-runnable"
3742 );
3743 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3744 }
3745
3746 #[test]
3747 fn reconcile_after_restart_spares_non_daemon_running_tasks() {
3748 let path = temp_db("reconcile_spare_cli");
3752 let store = RuntimeStore::open(&path).expect("open store");
3753
3754 let cli = store
3755 .tasks()
3756 .create(NewTask::new("cli run", "/repo", "m")) .expect("create cli task");
3758 store
3759 .tasks()
3760 .update_status(&cli.id, TaskStatus::Running, None)
3761 .expect("mark cli running");
3762 let daemon = store
3763 .tasks()
3764 .create(NewTask::new("daemon run", "/repo", "m").daemon_owned())
3765 .expect("create daemon task");
3766 store
3767 .tasks()
3768 .update_status(&daemon.id, TaskStatus::Running, None)
3769 .expect("mark daemon running");
3770
3771 let (tasks, _claims) = store.reconcile_after_restart().expect("reconcile");
3772 assert_eq!(tasks, 1, "only the daemon-owned task is reset");
3773 assert_eq!(
3774 store.tasks().get(&cli.id).unwrap().unwrap().status,
3775 TaskStatus::Running,
3776 "a live CLI task must NOT be clobbered by the daemon's reconcile"
3777 );
3778 assert_eq!(
3779 store.tasks().get(&daemon.id).unwrap().unwrap().status,
3780 TaskStatus::Failed,
3781 "a stranded daemon task is still recovered"
3782 );
3783 assert!(
3785 !store
3786 .tasks()
3787 .events(&cli.id)
3788 .unwrap()
3789 .iter()
3790 .any(|e| e.kind == "interrupted"),
3791 "the spared task must not receive a spurious interrupted event"
3792 );
3793 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3794 }
3795
3796 #[test]
3797 fn gc_prunes_old_archived_but_keeps_active() {
3798 let path = temp_db("gc");
3801 let store = RuntimeStore::open(&path).expect("open store");
3802 let keep = make_approval(&store, "active");
3803 let gone = make_approval(&store, "old archived");
3804 store
3805 .approvals()
3806 .archive(std::slice::from_ref(&gone.id), "test")
3807 .expect("archive");
3808 store
3810 .conn
3811 .execute(
3812 "UPDATE approvals SET archived_at = ?2 WHERE id = ?1",
3813 params![gone.id, "2000-01-01T00:00:00+00:00"],
3814 )
3815 .unwrap();
3816
3817 let removed = store.gc(30, 180).expect("gc");
3818 assert!(removed >= 1, "the old archived approval should be pruned");
3819 assert!(
3820 store.approvals().get(&gone.id).unwrap().is_none(),
3821 "old archived row removed"
3822 );
3823 assert!(
3824 store.approvals().get(&keep.id).unwrap().is_some(),
3825 "active row kept"
3826 );
3827 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3828 }
3829
3830 #[test]
3831 fn gc_prunes_outcomes_and_terminal_tasks_on_their_windows() {
3832 let path = temp_db("gc_outcomes");
3838 let store = RuntimeStore::open(&path).expect("open store");
3839 let old = "2000-01-01T00:00:00+00:00"; let live = store
3843 .tasks()
3844 .create(NewTask::new("live", "/repo", "m"))
3845 .expect("live task");
3846
3847 let done = store
3849 .tasks()
3850 .create(NewTask::new("done", "/repo", "m"))
3851 .expect("done task");
3852 store
3853 .tasks()
3854 .update_status(&done.id, TaskStatus::Completed, Some("ok"))
3855 .expect("finish task");
3856 store
3857 .conn
3858 .execute(
3859 "UPDATE tasks SET updated_at = ?2 WHERE id = ?1",
3860 params![done.id, old],
3861 )
3862 .unwrap();
3863
3864 let kept_outcome = store
3867 .outcomes()
3868 .record(NewOutcome {
3869 id: None,
3870 task_id: Some(done.id.clone()),
3871 tool_run_id: None,
3872 kind: "task_terminal".to_string(),
3873 label: OUTCOME_LABEL_SUCCESS.to_string(),
3874 reward: Some(1.0),
3875 source: OUTCOME_SOURCE_SYSTEM.to_string(),
3876 detail_json: Some("{\"prompt\":\"do the thing\"}".to_string()),
3877 })
3878 .expect("record kept outcome");
3879
3880 let gone_outcome = store
3882 .outcomes()
3883 .record(NewOutcome {
3884 id: None,
3885 task_id: None,
3886 tool_run_id: None,
3887 kind: "task_terminal".to_string(),
3888 label: OUTCOME_LABEL_FAILURE.to_string(),
3889 reward: Some(-1.0),
3890 source: OUTCOME_SOURCE_SYSTEM.to_string(),
3891 detail_json: None,
3892 })
3893 .expect("record gone outcome");
3894 store
3895 .conn
3896 .execute(
3897 "UPDATE outcomes SET created_at = ?2 WHERE id = ?1",
3898 params![gone_outcome.id, old],
3899 )
3900 .unwrap();
3901
3902 store.gc(30, 180).expect("gc");
3903
3904 assert!(
3905 store.tasks().get(&live.id).unwrap().is_some(),
3906 "a live (queued) task must survive gc"
3907 );
3908 assert!(
3909 store.tasks().get(&done.id).unwrap().is_none(),
3910 "an old terminal task must be pruned"
3911 );
3912 let kept = store
3913 .outcomes()
3914 .get(&kept_outcome.id)
3915 .unwrap()
3916 .expect("the recent outcome must survive gc");
3917 assert!(
3918 kept.task_id.is_none(),
3919 "the pruned task's link is nulled (ON DELETE SET NULL)"
3920 );
3921 assert_eq!(
3922 kept.detail_json.as_deref(),
3923 Some("{\"prompt\":\"do the thing\"}"),
3924 "the denormalized training context must survive the task prune"
3925 );
3926 assert!(
3927 store.outcomes().get(&gone_outcome.id).unwrap().is_none(),
3928 "an outcome past the outcomes window must be pruned"
3929 );
3930
3931 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3932 }
3933
3934 #[test]
3935 fn gc_prunes_high_churn_and_old_terminal_rows_but_keeps_active() {
3936 let path = temp_db("gc_high_churn");
3940 let store = RuntimeStore::open(&path).expect("open store");
3941 let old = "2000-01-01T00:00:00+00:00";
3942
3943 let stale_session = store
3945 .sessions()
3946 .upsert(NewSession {
3947 id: Some("stale".to_string()),
3948 project_path: "/repo".to_string(),
3949 model_id: "m".to_string(),
3950 title: None,
3951 conversation_path: None,
3952 total_tokens: None,
3953 })
3954 .expect("stale session");
3955 store
3956 .messages()
3957 .add(NewMessage {
3958 session_id: stale_session.id.clone(),
3959 role: "user".to_string(),
3960 content_json: "{}".to_string(),
3961 })
3962 .expect("stale message");
3963 let active_session = store
3964 .sessions()
3965 .upsert(NewSession {
3966 id: Some("active".to_string()),
3967 project_path: "/repo".to_string(),
3968 model_id: "m".to_string(),
3969 title: None,
3970 conversation_path: None,
3971 total_tokens: None,
3972 })
3973 .expect("active session");
3974 store
3975 .messages()
3976 .add(NewMessage {
3977 session_id: active_session.id.clone(),
3978 role: "user".to_string(),
3979 content_json: "{}".to_string(),
3980 })
3981 .expect("active message");
3982 store
3983 .conn
3984 .execute(
3985 "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
3986 params![stale_session.id, old],
3987 )
3988 .unwrap();
3989
3990 store
3992 .tool_runs()
3993 .start(NewToolRun {
3994 id: Some("tr-finished".to_string()),
3995 task_id: None,
3996 turn_id: None,
3997 call_id: None,
3998 tool_name: "x".to_string(),
3999 args_json: None,
4000 })
4001 .expect("start finished tr");
4002 store
4003 .tool_runs()
4004 .finish("tr-finished", "success", None)
4005 .expect("finish tr");
4006 store
4007 .conn
4008 .execute(
4009 "UPDATE tool_runs SET finished_at = ?2 WHERE id = ?1",
4010 params!["tr-finished", old],
4011 )
4012 .unwrap();
4013 store
4014 .tool_runs()
4015 .start(NewToolRun {
4016 id: Some("tr-running".to_string()),
4017 task_id: None,
4018 turn_id: None,
4019 call_id: None,
4020 tool_name: "x".to_string(),
4021 args_json: None,
4022 })
4023 .expect("start running tr");
4024
4025 let exited = store
4027 .processes()
4028 .upsert(NewProcess {
4029 id: Some("p-exited".to_string()),
4030 task_id: None,
4031 pid: 1,
4032 command: "c".to_string(),
4033 cwd: None,
4034 log_path: None,
4035 detected_url: None,
4036 status: ProcessStatus::Exited,
4037 health: None,
4038 })
4039 .expect("exited process");
4040 store
4041 .conn
4042 .execute(
4043 "UPDATE processes SET updated_at = ?2 WHERE id = ?1",
4044 params![exited.id, old],
4045 )
4046 .unwrap();
4047 let running_proc = store
4048 .processes()
4049 .upsert(NewProcess {
4050 id: Some("p-running".to_string()),
4051 task_id: None,
4052 pid: 2,
4053 command: "c".to_string(),
4054 cwd: None,
4055 log_path: None,
4056 detected_url: None,
4057 status: ProcessStatus::Running,
4058 health: None,
4059 })
4060 .expect("running process");
4061
4062 let comp = store
4064 .compactions()
4065 .create(NewCompaction {
4066 id: Some("comp-old".to_string()),
4067 task_id: None,
4068 session_id: None,
4069 source_token_estimate: None,
4070 summary_token_count: None,
4071 preserved_turns: None,
4072 archive_path: None,
4073 verification_status: None,
4074 })
4075 .expect("compaction");
4076 store
4077 .conn
4078 .execute(
4079 "UPDATE compactions SET created_at = ?2 WHERE id = ?1",
4080 params![comp.id, old],
4081 )
4082 .unwrap();
4083
4084 let removed = store.gc(30, 180).expect("gc");
4085 assert!(removed >= 5, "stale rows pruned (got {removed})");
4086 assert!(
4087 store.sessions().get(&stale_session.id).unwrap().is_none(),
4088 "stale session gone"
4089 );
4090 assert!(
4091 store
4092 .messages()
4093 .list_for_session(&stale_session.id)
4094 .unwrap()
4095 .is_empty(),
4096 "stale messages gone"
4097 );
4098 assert!(
4099 store.sessions().get(&active_session.id).unwrap().is_some(),
4100 "active session kept"
4101 );
4102 assert_eq!(
4103 store
4104 .messages()
4105 .list_for_session(&active_session.id)
4106 .unwrap()
4107 .len(),
4108 1,
4109 "active message kept"
4110 );
4111 assert!(
4112 store.tool_runs().get("tr-finished").unwrap().is_none(),
4113 "old finished tool_run gone"
4114 );
4115 assert!(
4116 store.tool_runs().get("tr-running").unwrap().is_some(),
4117 "running tool_run kept"
4118 );
4119 assert!(
4120 store.processes().get(&exited.id).unwrap().is_none(),
4121 "old exited process gone"
4122 );
4123 assert!(
4124 store.processes().get(&running_proc.id).unwrap().is_some(),
4125 "running process kept"
4126 );
4127 assert!(
4128 store.compactions().get(&comp.id).unwrap().is_none(),
4129 "old compaction gone"
4130 );
4131 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4132 }
4133
4134 #[test]
4135 fn task_list_skips_undecodable_status_row() {
4136 let path = temp_db("poison_task");
4139 let store = RuntimeStore::open(&path).expect("open store");
4140 let good = store
4141 .tasks()
4142 .create(NewTask::new("good", "/repo", "m"))
4143 .expect("create good task");
4144 store
4145 .conn
4146 .execute(
4147 "INSERT INTO tasks
4148 (id, title, status, priority, project_path, model_id, created_at, updated_at)
4149 VALUES ('poison', 't', 'from_the_future', 'normal', '/repo', 'm', ?1, ?1)",
4150 params![now_rfc3339()],
4151 )
4152 .unwrap();
4153 let listed = store.tasks().list(50).expect("list");
4154 assert_eq!(
4155 listed.len(),
4156 1,
4157 "the poison row is skipped, the good row remains"
4158 );
4159 assert_eq!(listed[0].id, good.id);
4160 assert!(store.tasks().get("poison").is_err(), "get() stays strict");
4162 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4163 }
4164
4165 #[test]
4166 fn checkpoint_delete_removes_row() {
4167 let path = temp_db("ckpt_delete");
4170 let store = RuntimeStore::open(&path).expect("open store");
4171 let ckpt = store
4172 .checkpoints()
4173 .create(NewCheckpoint {
4174 id: Some("ckpt-1".to_string()),
4175 task_id: None,
4176 project_path: "/repo".to_string(),
4177 snapshot_path: "/data/checkpoints/ckpt-1".to_string(),
4178 changed_files_json: "[]".to_string(),
4179 pending_action_json: None,
4180 approval_id: None,
4181 session_id: None,
4182 message_index: None,
4183 })
4184 .expect("create checkpoint");
4185 assert!(store.checkpoints().get(&ckpt.id).unwrap().is_some());
4186 assert!(store.checkpoints().delete(&ckpt.id).unwrap(), "row deleted");
4187 assert!(
4188 store.checkpoints().get(&ckpt.id).unwrap().is_none(),
4189 "row gone"
4190 );
4191 assert!(
4192 !store.checkpoints().delete(&ckpt.id).unwrap(),
4193 "second delete is a no-op"
4194 );
4195 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4196 }
4197
4198 #[test]
4199 fn list_for_session_caps_at_max_and_keeps_ascending_order() {
4200 let path = temp_db("session_cap");
4203 let store = RuntimeStore::open(&path).expect("open store");
4204 let session = store
4205 .sessions()
4206 .upsert(NewSession {
4207 id: Some("big".to_string()),
4208 project_path: "/repo".to_string(),
4209 model_id: "m".to_string(),
4210 title: None,
4211 conversation_path: None,
4212 total_tokens: None,
4213 })
4214 .expect("session");
4215 let total = MAX_SESSION_MESSAGES + 10;
4216 let now = now_rfc3339();
4217 let tx = store.conn.unchecked_transaction().unwrap();
4218 for i in 0..total {
4219 tx.execute(
4220 "INSERT INTO messages (session_id, role, content_json, created_at)
4221 VALUES (?1, 'user', ?2, ?3)",
4222 params![session.id, format!("{{\"n\":{i}}}"), now],
4223 )
4224 .unwrap();
4225 }
4226 tx.commit().unwrap();
4227 let listed = store
4228 .messages()
4229 .list_for_session(&session.id)
4230 .expect("list");
4231 assert_eq!(
4232 listed.len() as i64,
4233 MAX_SESSION_MESSAGES,
4234 "capped at the max"
4235 );
4236 assert!(
4237 listed.windows(2).all(|w| w[0].id < w[1].id),
4238 "ascending id order preserved across the capped tail"
4239 );
4240 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4241 }
4242}