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, mut new: NewToolRun) -> Result<ToolRunRecord> {
1443 new.args_json = new
1447 .args_json
1448 .as_deref()
1449 .map(crate::redact::redact_json_text);
1450 let id = new.id.unwrap_or_else(|| fresh_id("toolrun"));
1451 self.conn.execute(
1452 "INSERT INTO tool_runs
1453 (id, task_id, turn_id, call_id, tool_name, status, args_json, output_json, started_at, finished_at)
1454 VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6, NULL, ?7, NULL)",
1455 params![
1456 id,
1457 new.task_id,
1458 new.turn_id,
1459 new.call_id,
1460 new.tool_name,
1461 new.args_json,
1462 now_rfc3339(),
1463 ],
1464 )?;
1465 self.get(&id)?
1466 .context("tool run was inserted but could not be reloaded")
1467 }
1468
1469 pub fn finish(&self, id: &str, status: &str, output_json: Option<&str>) -> Result<()> {
1470 let output_json = output_json.map(crate::redact::redact_json_text);
1471 let changed = self.conn.execute(
1472 "UPDATE tool_runs
1473 SET status = ?2, output_json = ?3, finished_at = ?4
1474 WHERE id = ?1",
1475 params![id, status, output_json, now_rfc3339()],
1476 )?;
1477 anyhow::ensure!(changed > 0, "tool run not found: {}", id);
1478 Ok(())
1479 }
1480
1481 pub fn get(&self, id: &str) -> Result<Option<ToolRunRecord>> {
1482 self.conn
1483 .query_row(
1484 "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1485 output_json, started_at, finished_at
1486 FROM tool_runs WHERE id = ?1",
1487 [id],
1488 tool_run_from_row,
1489 )
1490 .optional()
1491 .map_err(Into::into)
1492 }
1493
1494 pub fn list(&self, limit: usize) -> Result<Vec<ToolRunRecord>> {
1495 let mut stmt = self.conn.prepare(
1496 "SELECT id, task_id, turn_id, call_id, tool_name, status, args_json,
1497 output_json, started_at, finished_at
1498 FROM tool_runs ORDER BY started_at DESC LIMIT ?1",
1499 )?;
1500 let rows = stmt.query_map([clamp_limit(limit)], tool_run_from_row)?;
1501 rows.collect::<rusqlite::Result<Vec<_>>>()
1502 .map_err(Into::into)
1503 }
1504}
1505
1506pub struct OutcomesRepo<'a> {
1507 conn: &'a Connection,
1508}
1509
1510impl OutcomesRepo<'_> {
1511 pub fn record(&self, new: NewOutcome) -> Result<OutcomeRecord> {
1514 let id = new.id.unwrap_or_else(|| fresh_id("outcome"));
1515 self.conn.execute(
1516 "INSERT INTO outcomes
1517 (id, task_id, tool_run_id, kind, label, reward, source, detail_json, created_at)
1518 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1519 params![
1520 id,
1521 new.task_id,
1522 new.tool_run_id,
1523 new.kind,
1524 new.label,
1525 new.reward,
1526 new.source,
1527 new.detail_json,
1528 now_rfc3339(),
1529 ],
1530 )?;
1531 self.get(&id)?
1532 .context("outcome was inserted but could not be reloaded")
1533 }
1534
1535 pub fn get(&self, id: &str) -> Result<Option<OutcomeRecord>> {
1536 self.conn
1537 .query_row(
1538 "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1539 detail_json, created_at
1540 FROM outcomes WHERE id = ?1",
1541 [id],
1542 outcome_from_row,
1543 )
1544 .optional()
1545 .map_err(Into::into)
1546 }
1547
1548 pub fn list_for_task(&self, task_id: &str) -> Result<Vec<OutcomeRecord>> {
1551 let mut stmt = self.conn.prepare(
1552 "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1553 detail_json, created_at
1554 FROM outcomes WHERE task_id = ?1 ORDER BY created_at ASC",
1555 )?;
1556 let rows = stmt.query_map([task_id], outcome_from_row)?;
1557 rows.collect::<rusqlite::Result<Vec<_>>>()
1558 .map_err(Into::into)
1559 }
1560
1561 pub fn list(&self, limit: usize) -> Result<Vec<OutcomeRecord>> {
1562 let mut stmt = self.conn.prepare(
1563 "SELECT id, task_id, tool_run_id, kind, label, reward, source,
1564 detail_json, created_at
1565 FROM outcomes ORDER BY created_at DESC LIMIT ?1",
1566 )?;
1567 let rows = stmt.query_map([clamp_limit(limit)], outcome_from_row)?;
1568 rows.collect::<rusqlite::Result<Vec<_>>>()
1569 .map_err(Into::into)
1570 }
1571}
1572
1573fn outcome_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<OutcomeRecord> {
1574 Ok(OutcomeRecord {
1575 id: row.get(0)?,
1576 task_id: row.get(1)?,
1577 tool_run_id: row.get(2)?,
1578 kind: row.get(3)?,
1579 label: row.get(4)?,
1580 reward: row.get(5)?,
1581 source: row.get(6)?,
1582 detail_json: row.get(7)?,
1583 created_at: row.get(8)?,
1584 })
1585}
1586
1587pub struct ApprovalsRepo<'a> {
1588 conn: &'a Connection,
1589}
1590
1591impl ApprovalsRepo<'_> {
1592 pub fn create(&self, new: NewApproval) -> Result<ApprovalRecord> {
1593 let record = ApprovalRecord {
1594 id: fresh_id("approval"),
1595 task_id: new.task_id,
1596 proposed_action: new.proposed_action,
1597 risk_classification: new.risk_classification,
1598 policy_decision: new.policy_decision,
1599 user_decision: None,
1600 args_summary: new.args_summary,
1601 checkpoint_id: new.checkpoint_id,
1602 pending_action_json: new.pending_action_json,
1603 created_at: now_rfc3339(),
1604 decided_at: None,
1605 archived_at: None,
1606 archive_reason: None,
1607 };
1608 self.conn.execute(
1609 "INSERT INTO approvals
1610 (id, task_id, proposed_action, risk_classification, policy_decision, user_decision,
1611 args_summary, checkpoint_id, pending_action_json, created_at, decided_at)
1612 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
1613 params![
1614 record.id,
1615 record.task_id,
1616 record.proposed_action,
1617 record.risk_classification,
1618 record.policy_decision,
1619 record.user_decision,
1620 record.args_summary,
1621 record.checkpoint_id,
1622 record.pending_action_json,
1623 record.created_at,
1624 record.decided_at,
1625 ],
1626 )?;
1627 self.get(&record.id)?
1628 .context("approval was inserted but could not be reloaded")
1629 }
1630
1631 pub fn get(&self, id: &str) -> Result<Option<ApprovalRecord>> {
1632 self.conn
1633 .query_row(
1634 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1635 user_decision, args_summary, checkpoint_id, pending_action_json,
1636 created_at, decided_at, archived_at, archive_reason
1637 FROM approvals WHERE id = ?1",
1638 [id],
1639 approval_from_row,
1640 )
1641 .optional()
1642 .map_err(Into::into)
1643 }
1644
1645 pub fn decide(&self, id: &str, user_decision: &str) -> Result<()> {
1646 let changed = self.conn.execute(
1654 "UPDATE approvals
1655 SET user_decision = ?2, decided_at = ?3
1656 WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1657 params![id, user_decision, now_rfc3339()],
1658 )?;
1659 anyhow::ensure!(
1660 changed > 0,
1661 "approval {} cannot be decided (already decided, archived, or not found)",
1662 id
1663 );
1664 Ok(())
1665 }
1666
1667 pub fn claim(&self, id: &str) -> Result<bool> {
1675 let changed = self.conn.execute(
1676 "UPDATE approvals
1677 SET user_decision = 'approving'
1678 WHERE id = ?1 AND user_decision IS NULL AND archived_at IS NULL",
1679 params![id],
1680 )?;
1681 Ok(changed == 1)
1682 }
1683
1684 pub fn release_claim(&self, id: &str) -> Result<()> {
1687 self.conn.execute(
1688 "UPDATE approvals SET user_decision = NULL
1689 WHERE id = ?1 AND user_decision = 'approving'",
1690 params![id],
1691 )?;
1692 Ok(())
1693 }
1694
1695 pub fn finalize_claimed(&self, id: &str, user_decision: &str) -> Result<()> {
1698 let changed = self.conn.execute(
1699 "UPDATE approvals
1700 SET user_decision = ?2, decided_at = ?3
1701 WHERE id = ?1 AND user_decision = 'approving'",
1702 params![id, user_decision, now_rfc3339()],
1703 )?;
1704 anyhow::ensure!(changed > 0, "approval {} was not in the claimed state", id);
1705 Ok(())
1706 }
1707
1708 pub fn list_pending(&self) -> Result<Vec<ApprovalRecord>> {
1709 self.list_pending_with_archived(false)
1710 }
1711
1712 pub fn list_pending_all(&self) -> Result<Vec<ApprovalRecord>> {
1713 self.list_pending_with_archived(true)
1714 }
1715
1716 pub fn list_all(&self, limit: usize) -> Result<Vec<ApprovalRecord>> {
1717 let mut stmt = self.conn.prepare(
1718 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1719 user_decision, args_summary, checkpoint_id, pending_action_json,
1720 created_at, decided_at, archived_at, archive_reason
1721 FROM approvals
1722 ORDER BY created_at DESC
1723 LIMIT ?1",
1724 )?;
1725 let rows = stmt.query_map([clamp_limit(limit)], approval_from_row)?;
1726 rows.collect::<rusqlite::Result<Vec<_>>>()
1727 .map_err(Into::into)
1728 }
1729
1730 fn list_pending_with_archived(&self, include_archived: bool) -> Result<Vec<ApprovalRecord>> {
1731 let archived_filter = if include_archived {
1732 ""
1733 } else {
1734 " AND archived_at IS NULL"
1735 };
1736 let mut stmt = self.conn.prepare(&format!(
1737 "SELECT id, task_id, proposed_action, risk_classification, policy_decision,
1738 user_decision, args_summary, checkpoint_id, pending_action_json,
1739 created_at, decided_at, archived_at, archive_reason
1740 FROM approvals
1741 WHERE user_decision IS NULL{archived_filter}
1742 ORDER BY created_at DESC"
1743 ))?;
1744 let rows = stmt.query_map([], approval_from_row)?;
1745 rows.collect::<rusqlite::Result<Vec<_>>>()
1746 .map_err(Into::into)
1747 }
1748
1749 pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1750 let archived_at = now_rfc3339();
1751 let mut changed = 0;
1752 for id in ids {
1753 changed += self.conn.execute(
1754 "UPDATE approvals
1755 SET archived_at = COALESCE(archived_at, ?2),
1756 archive_reason = COALESCE(archive_reason, ?3)
1757 WHERE id = ?1 AND archived_at IS NULL",
1758 params![id, archived_at, reason],
1759 )?;
1760 }
1761 Ok(changed)
1762 }
1763
1764 pub fn count_archived(&self) -> Result<usize> {
1765 self.conn
1766 .query_row(
1767 "SELECT COUNT(*) FROM approvals WHERE archived_at IS NOT NULL",
1768 [],
1769 |row| row.get::<_, i64>(0),
1770 )
1771 .map(|count| count as usize)
1772 .map_err(Into::into)
1773 }
1774}
1775
1776pub struct ProcessesRepo<'a> {
1777 conn: &'a Connection,
1778}
1779
1780impl ProcessesRepo<'_> {
1781 pub fn upsert(&self, new: NewProcess) -> Result<ProcessRecord> {
1782 let now = now_rfc3339();
1783 let id = new.id.unwrap_or_else(|| fresh_id("process"));
1784 self.conn.execute(
1785 "INSERT INTO processes
1786 (id, task_id, pid, command, cwd, log_path, detected_url, status, health, created_at, updated_at)
1787 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
1788 ON CONFLICT(id) DO UPDATE SET
1789 task_id = excluded.task_id,
1790 pid = excluded.pid,
1791 command = excluded.command,
1792 cwd = excluded.cwd,
1793 log_path = excluded.log_path,
1794 detected_url = excluded.detected_url,
1795 status = excluded.status,
1796 health = excluded.health,
1797 updated_at = excluded.updated_at",
1798 params![
1799 id,
1800 new.task_id,
1801 new.pid,
1802 new.command,
1803 new.cwd,
1804 new.log_path,
1805 new.detected_url,
1806 new.status.as_str(),
1807 new.health,
1808 now,
1809 now,
1810 ],
1811 )?;
1812 self.get(&id)?
1813 .context("process was upserted but could not be reloaded")
1814 }
1815
1816 pub fn get(&self, id: &str) -> Result<Option<ProcessRecord>> {
1817 self.conn
1818 .query_row(
1819 "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1820 created_at, updated_at
1821 FROM processes WHERE id = ?1",
1822 [id],
1823 process_from_row,
1824 )
1825 .optional()
1826 .map_err(Into::into)
1827 }
1828
1829 pub fn list(&self, limit: usize) -> Result<Vec<ProcessRecord>> {
1830 let mut stmt = self.conn.prepare(
1831 "SELECT id, task_id, pid, command, cwd, log_path, detected_url, status, health,
1832 created_at, updated_at
1833 FROM processes
1834 ORDER BY updated_at DESC
1835 LIMIT ?1",
1836 )?;
1837 let rows = stmt.query_map([clamp_limit(limit)], process_from_row_opt)?;
1840 collect_tolerant(rows)
1841 }
1842}
1843
1844pub struct CheckpointsRepo<'a> {
1845 conn: &'a Connection,
1846}
1847
1848impl CheckpointsRepo<'_> {
1849 pub fn create(&self, new: NewCheckpoint) -> Result<CheckpointRecord> {
1850 let id = new.id.unwrap_or_else(|| fresh_id("checkpoint"));
1851 self.conn.execute(
1852 "INSERT INTO checkpoints
1853 (id, task_id, project_path, snapshot_path, changed_files_json,
1854 pending_action_json, approval_id, created_at, session_id, message_index)
1855 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1856 params![
1857 id,
1858 new.task_id,
1859 new.project_path,
1860 new.snapshot_path,
1861 new.changed_files_json,
1862 new.pending_action_json,
1863 new.approval_id,
1864 now_rfc3339(),
1865 new.session_id,
1866 new.message_index,
1867 ],
1868 )?;
1869 self.get(&id)?
1870 .context("checkpoint was inserted but could not be reloaded")
1871 }
1872
1873 pub fn get(&self, id: &str) -> Result<Option<CheckpointRecord>> {
1874 self.conn
1875 .query_row(
1876 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1877 pending_action_json, approval_id, created_at, archived_at, archive_reason,
1878 session_id, message_index
1879 FROM checkpoints WHERE id = ?1",
1880 [id],
1881 checkpoint_from_row,
1882 )
1883 .optional()
1884 .map_err(Into::into)
1885 }
1886
1887 pub fn set_approval(&self, id: &str, approval_id: &str) -> Result<()> {
1888 let changed = self.conn.execute(
1889 "UPDATE checkpoints SET approval_id = ?2 WHERE id = ?1",
1890 params![id, approval_id],
1891 )?;
1892 anyhow::ensure!(changed > 0, "checkpoint not found: {}", id);
1893 Ok(())
1894 }
1895
1896 pub fn delete(&self, id: &str) -> Result<bool> {
1906 let changed = self
1907 .conn
1908 .execute("DELETE FROM checkpoints WHERE id = ?1", params![id])?;
1909 Ok(changed > 0)
1910 }
1911
1912 pub fn list(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1913 self.list_with_archived(limit, false)
1914 }
1915
1916 pub fn list_all(&self, limit: usize) -> Result<Vec<CheckpointRecord>> {
1917 self.list_with_archived(limit, true)
1918 }
1919
1920 fn list_with_archived(
1921 &self,
1922 limit: usize,
1923 include_archived: bool,
1924 ) -> Result<Vec<CheckpointRecord>> {
1925 let archived_filter = if include_archived {
1926 ""
1927 } else {
1928 "WHERE archived_at IS NULL"
1929 };
1930 let mut stmt = self.conn.prepare(&format!(
1931 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1932 pending_action_json, approval_id, created_at, archived_at, archive_reason,
1933 session_id, message_index
1934 FROM checkpoints {archived_filter} ORDER BY created_at DESC LIMIT ?1"
1935 ))?;
1936 let rows = stmt.query_map([clamp_limit(limit)], checkpoint_from_row)?;
1937 rows.collect::<rusqlite::Result<Vec<_>>>()
1938 .map_err(Into::into)
1939 }
1940
1941 pub fn list_for_session(
1950 &self,
1951 session_id: &str,
1952 after_message_index: i64,
1953 ) -> Result<Vec<CheckpointRecord>> {
1954 let mut stmt = self.conn.prepare(
1955 "SELECT id, task_id, project_path, snapshot_path, changed_files_json,
1956 pending_action_json, approval_id, created_at, archived_at, archive_reason,
1957 session_id, message_index
1958 FROM checkpoints
1959 WHERE session_id = ?1 AND message_index > ?2 AND archived_at IS NULL
1960 ORDER BY created_at ASC",
1961 )?;
1962 let rows = stmt.query_map(
1963 params![session_id, after_message_index],
1964 checkpoint_from_row,
1965 )?;
1966 rows.collect::<rusqlite::Result<Vec<_>>>()
1967 .map_err(Into::into)
1968 }
1969
1970 pub fn archive(&self, ids: &[String], reason: &str) -> Result<usize> {
1971 let archived_at = now_rfc3339();
1972 let mut changed = 0;
1973 for id in ids {
1974 changed += self.conn.execute(
1975 "UPDATE checkpoints
1976 SET archived_at = COALESCE(archived_at, ?2),
1977 archive_reason = COALESCE(archive_reason, ?3)
1978 WHERE id = ?1 AND archived_at IS NULL",
1979 params![id, archived_at, reason],
1980 )?;
1981 }
1982 Ok(changed)
1983 }
1984
1985 pub fn count_archived(&self) -> Result<usize> {
1986 self.conn
1987 .query_row(
1988 "SELECT COUNT(*) FROM checkpoints WHERE archived_at IS NOT NULL",
1989 [],
1990 |row| row.get::<_, i64>(0),
1991 )
1992 .map(|count| count as usize)
1993 .map_err(Into::into)
1994 }
1995}
1996
1997pub struct CompactionsRepo<'a> {
1998 conn: &'a Connection,
1999}
2000
2001impl CompactionsRepo<'_> {
2002 pub fn create(&self, new: NewCompaction) -> Result<CompactionRecord> {
2003 let id = new.id.unwrap_or_else(|| fresh_id("compaction"));
2004 self.conn.execute(
2005 "INSERT INTO compactions
2006 (id, task_id, session_id, source_token_estimate, summary_token_count,
2007 preserved_turns, archive_path, verification_status, created_at)
2008 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2009 ON CONFLICT(id) DO UPDATE SET
2010 task_id = excluded.task_id,
2011 session_id = excluded.session_id,
2012 source_token_estimate = excluded.source_token_estimate,
2013 summary_token_count = excluded.summary_token_count,
2014 preserved_turns = excluded.preserved_turns,
2015 archive_path = excluded.archive_path,
2016 verification_status = excluded.verification_status",
2017 params![
2018 id,
2019 new.task_id,
2020 new.session_id,
2021 new.source_token_estimate,
2022 new.summary_token_count,
2023 new.preserved_turns,
2024 new.archive_path,
2025 new.verification_status,
2026 now_rfc3339(),
2027 ],
2028 )?;
2029 self.get(&id)?
2030 .context("compaction was inserted but could not be reloaded")
2031 }
2032
2033 pub fn get(&self, id: &str) -> Result<Option<CompactionRecord>> {
2034 self.conn
2035 .query_row(
2036 "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
2037 preserved_turns, archive_path, verification_status, created_at
2038 FROM compactions WHERE id = ?1",
2039 [id],
2040 compaction_from_row,
2041 )
2042 .optional()
2043 .map_err(Into::into)
2044 }
2045
2046 pub fn list(&self, limit: usize) -> Result<Vec<CompactionRecord>> {
2047 let mut stmt = self.conn.prepare(
2048 "SELECT id, task_id, session_id, source_token_estimate, summary_token_count,
2049 preserved_turns, archive_path, verification_status, created_at
2050 FROM compactions ORDER BY created_at DESC LIMIT ?1",
2051 )?;
2052 let rows = stmt.query_map([clamp_limit(limit)], compaction_from_row)?;
2053 rows.collect::<rusqlite::Result<Vec<_>>>()
2054 .map_err(Into::into)
2055 }
2056}
2057
2058pub struct PluginsRepo<'a> {
2059 conn: &'a Connection,
2060}
2061
2062impl PluginsRepo<'_> {
2063 pub fn install(&self, new: NewPluginInstall) -> Result<PluginInstallRecord> {
2064 let now = now_rfc3339();
2065 let id = new.id.unwrap_or_else(|| fresh_id("plugin"));
2066 self.conn.execute(
2067 "INSERT INTO plugin_installs
2068 (id, name, source, version, enabled, manifest_json, installed_at, updated_at)
2069 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
2070 ON CONFLICT(id) DO UPDATE SET
2071 name = excluded.name,
2072 source = excluded.source,
2073 version = excluded.version,
2074 enabled = excluded.enabled,
2075 manifest_json = excluded.manifest_json,
2076 updated_at = excluded.updated_at",
2077 params![
2078 id,
2079 new.name,
2080 new.source,
2081 new.version,
2082 if new.enabled { 1 } else { 0 },
2083 new.manifest_json,
2084 now,
2085 now,
2086 ],
2087 )?;
2088 self.get(&id)?
2089 .context("plugin install was inserted but could not be reloaded")
2090 }
2091
2092 pub fn get(&self, id: &str) -> Result<Option<PluginInstallRecord>> {
2093 self.conn
2094 .query_row(
2095 "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
2096 FROM plugin_installs WHERE id = ?1",
2097 [id],
2098 plugin_from_row,
2099 )
2100 .optional()
2101 .map_err(Into::into)
2102 }
2103
2104 pub fn list(&self) -> Result<Vec<PluginInstallRecord>> {
2105 let mut stmt = self.conn.prepare(
2106 "SELECT id, name, source, version, enabled, manifest_json, installed_at, updated_at
2107 FROM plugin_installs ORDER BY name ASC",
2108 )?;
2109 let rows = stmt.query_map([], plugin_from_row)?;
2110 rows.collect::<rusqlite::Result<Vec<_>>>()
2111 .map_err(Into::into)
2112 }
2113
2114 pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
2115 self.conn.execute(
2116 "UPDATE plugin_installs SET enabled = ?2, updated_at = ?3 WHERE id = ?1",
2117 params![id, if enabled { 1 } else { 0 }, now_rfc3339()],
2118 )?;
2119 Ok(())
2120 }
2121}
2122
2123pub struct ProviderProbesRepo<'a> {
2124 conn: &'a Connection,
2125}
2126
2127impl ProviderProbesRepo<'_> {
2128 pub fn upsert(&self, new: NewProviderProbe) -> Result<ProviderProbeRecord> {
2129 let now = now_rfc3339();
2130 let provider = new.provider;
2131 let model_id = new.model_id;
2132 let capability_key = new.capability_key;
2133 self.conn.execute(
2134 "INSERT INTO provider_probes
2135 (provider, model_id, capability_key, capability_value, confidence, error, probed_at)
2136 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
2137 ON CONFLICT(provider, model_id, capability_key) DO UPDATE SET
2138 capability_value = excluded.capability_value,
2139 confidence = excluded.confidence,
2140 error = excluded.error,
2141 probed_at = excluded.probed_at",
2142 params![
2143 &provider,
2144 &model_id,
2145 &capability_key,
2146 new.capability_value,
2147 new.confidence,
2148 new.error,
2149 now,
2150 ],
2151 )?;
2152 self.get(&provider, &model_id, &capability_key)?
2153 .context("provider probe was inserted but could not be reloaded")
2154 }
2155
2156 pub fn get(
2157 &self,
2158 provider: &str,
2159 model_id: &str,
2160 capability_key: &str,
2161 ) -> Result<Option<ProviderProbeRecord>> {
2162 self.conn
2163 .query_row(
2164 "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
2165 FROM provider_probes
2166 WHERE provider = ?1 AND model_id = ?2 AND capability_key = ?3",
2167 params![provider, model_id, capability_key],
2168 provider_probe_from_row,
2169 )
2170 .optional()
2171 .map_err(Into::into)
2172 }
2173
2174 pub fn list(
2175 &self,
2176 provider: Option<&str>,
2177 model_id: Option<&str>,
2178 ) -> Result<Vec<ProviderProbeRecord>> {
2179 let mut stmt = self.conn.prepare(
2180 "SELECT provider, model_id, capability_key, capability_value, confidence, error, probed_at
2181 FROM provider_probes ORDER BY provider ASC, model_id ASC, capability_key ASC",
2182 )?;
2183 let rows = stmt.query_map([], provider_probe_from_row)?;
2184 let mut out = Vec::new();
2185 for row in rows {
2186 let probe = row?;
2187 if provider.is_some_and(|p| probe.provider != p) {
2188 continue;
2189 }
2190 if model_id.is_some_and(|m| probe.model_id != m) {
2191 continue;
2192 }
2193 out.push(probe);
2194 }
2195 Ok(out)
2196 }
2197}
2198
2199pub struct PairingTokensRepo<'a> {
2200 conn: &'a Connection,
2201}
2202
2203impl PairingTokensRepo<'_> {
2204 pub fn create(
2205 &self,
2206 token_hash: &str,
2207 label: Option<&str>,
2208 expires_at: Option<&str>,
2209 ) -> Result<PairingTokenRecord> {
2210 let id = fresh_id("pairing");
2211 self.conn.execute(
2212 "INSERT INTO pairing_tokens
2213 (id, token_hash, label, enabled, created_at, last_used_at, expires_at)
2214 VALUES (?1, ?2, ?3, 1, ?4, NULL, ?5)",
2215 params![id, token_hash, label, now_rfc3339(), expires_at],
2216 )?;
2217 self.get(&id)?
2218 .context("pairing token was inserted but could not be reloaded")
2219 }
2220
2221 pub fn get(&self, id: &str) -> Result<Option<PairingTokenRecord>> {
2222 self.conn
2223 .query_row(
2224 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2225 FROM pairing_tokens WHERE id = ?1",
2226 [id],
2227 pairing_from_row,
2228 )
2229 .optional()
2230 .map_err(Into::into)
2231 }
2232
2233 pub fn verify_token(&self, token_hash: &str) -> Result<Option<PairingTokenRecord>> {
2242 let now = chrono::Utc::now();
2247 let mut stmt = self.conn.prepare(
2248 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2249 FROM pairing_tokens
2250 WHERE enabled = 1",
2251 )?;
2252 let candidates = stmt
2253 .query_map([], pairing_from_row)?
2254 .collect::<rusqlite::Result<Vec<_>>>()?;
2255 let mut found = None;
2256 for record in candidates {
2257 if is_expired(record.expires_at.as_deref(), now) {
2258 continue;
2259 }
2260 if ct_eq(record.token_hash.as_bytes(), token_hash.as_bytes()) {
2261 found = Some(record);
2262 }
2263 }
2264 Ok(found)
2265 }
2266
2267 pub fn list(&self) -> Result<Vec<PairingTokenRecord>> {
2268 let mut stmt = self.conn.prepare(
2269 "SELECT id, token_hash, label, enabled, created_at, last_used_at, expires_at
2270 FROM pairing_tokens ORDER BY created_at DESC",
2271 )?;
2272 let rows = stmt.query_map([], pairing_from_row)?;
2273 rows.collect::<rusqlite::Result<Vec<_>>>()
2274 .map_err(Into::into)
2275 }
2276
2277 pub fn list_redacted(&self) -> Result<Vec<PairingTokenRecord>> {
2283 Ok(self
2284 .list()?
2285 .into_iter()
2286 .map(|mut record| {
2287 record.token_hash = String::new();
2288 record
2289 })
2290 .collect())
2291 }
2292
2293 pub fn mark_used(&self, id: &str) -> Result<()> {
2294 self.conn.execute(
2295 "UPDATE pairing_tokens SET last_used_at = ?2 WHERE id = ?1 AND enabled = 1",
2296 params![id, now_rfc3339()],
2297 )?;
2298 Ok(())
2299 }
2300
2301 pub fn revoke(&self, id: &str) -> Result<bool> {
2304 let changed = self.conn.execute(
2305 "UPDATE pairing_tokens SET enabled = 0 WHERE id = ?1 AND enabled = 1",
2306 params![id],
2307 )?;
2308 Ok(changed > 0)
2309 }
2310}
2311
2312fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<bool> {
2320 fn is_sql_identifier(s: &str) -> bool {
2321 let mut chars = s.chars();
2322 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
2323 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
2324 }
2325 const ALLOWED_DEFINITIONS: &[&str] = &["TEXT", "INTEGER", "REAL", "BLOB"];
2326 anyhow::ensure!(
2327 is_sql_identifier(table),
2328 "invalid table identifier: {table}"
2329 );
2330 anyhow::ensure!(
2331 is_sql_identifier(column),
2332 "invalid column identifier: {column}"
2333 );
2334 anyhow::ensure!(
2335 ALLOWED_DEFINITIONS.contains(&definition),
2336 "unsupported column definition: {definition}"
2337 );
2338
2339 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
2340 let mut rows = stmt.query([])?;
2341 while let Some(row) = rows.next()? {
2342 let name: String = row.get(1)?;
2343 if name == column {
2344 return Ok(false);
2345 }
2346 }
2347 match conn.execute(
2352 &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
2353 [],
2354 ) {
2355 Ok(_) => Ok(true),
2356 Err(error) if error.to_string().contains("duplicate column") => Ok(false),
2357 Err(error) => Err(error.into()),
2358 }
2359}
2360
2361pub fn data_dir() -> Result<PathBuf> {
2362 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
2363 return Ok(proj_dirs.data_dir().to_path_buf());
2364 }
2365 let home = std::env::var("HOME")
2366 .or_else(|_| std::env::var("USERPROFILE"))
2367 .context("could not determine home directory")?;
2368 Ok(PathBuf::from(home).join(".local/share/mermaid"))
2369}
2370
2371fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionRecord> {
2372 Ok(SessionRecord {
2373 id: row.get("id")?,
2374 project_path: row.get("project_path")?,
2375 model_id: row.get("model_id")?,
2376 title: row.get("title")?,
2377 conversation_path: row.get("conversation_path")?,
2378 created_at: row.get("created_at")?,
2379 updated_at: row.get("updated_at")?,
2380 total_tokens: row.get("total_tokens")?,
2381 })
2382}
2383
2384fn message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MessageRecord> {
2385 Ok(MessageRecord {
2386 id: row.get("id")?,
2387 session_id: row.get("session_id")?,
2388 role: row.get("role")?,
2389 content_json: row.get("content_json")?,
2390 created_at: row.get("created_at")?,
2391 })
2392}
2393
2394fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
2395 let status_raw: String = row.get("status")?;
2396 let priority_raw: String = row.get("priority")?;
2397 Ok(TaskRecord {
2398 id: row.get("id")?,
2399 title: row.get("title")?,
2400 status: TaskStatus::from_db(&status_raw)
2401 .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2402 priority: TaskPriority::from_db(&priority_raw)
2403 .map_err(|e| enum_from_sql_error("priority", priority_raw, e))?,
2404 project_path: row.get("project_path")?,
2405 model_id: row.get("model_id")?,
2406 conversation_id: row.get("conversation_id")?,
2407 created_at: row.get("created_at")?,
2408 updated_at: row.get("updated_at")?,
2409 final_report: row.get("final_report")?,
2410 prompt: row.get("prompt")?,
2411 })
2412}
2413
2414fn process_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProcessRecord> {
2415 let status_raw: String = row.get("status")?;
2416 let pid: i64 = row.get("pid")?;
2417 Ok(ProcessRecord {
2418 id: row.get("id")?,
2419 task_id: row.get("task_id")?,
2420 pid: pid as u32,
2421 command: row.get("command")?,
2422 cwd: row.get("cwd")?,
2423 log_path: row.get("log_path")?,
2424 detected_url: row.get("detected_url")?,
2425 status: ProcessStatus::from_db(&status_raw)
2426 .map_err(|e| enum_from_sql_error("status", status_raw, e))?,
2427 health: row.get("health")?,
2428 created_at: row.get("created_at")?,
2429 updated_at: row.get("updated_at")?,
2430 })
2431}
2432
2433fn tool_run_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ToolRunRecord> {
2434 Ok(ToolRunRecord {
2435 id: row.get("id")?,
2436 task_id: row.get("task_id")?,
2437 turn_id: row.get("turn_id")?,
2438 call_id: row.get("call_id")?,
2439 tool_name: row.get("tool_name")?,
2440 status: row.get("status")?,
2441 args_json: row.get("args_json")?,
2442 output_json: row.get("output_json")?,
2443 started_at: row.get("started_at")?,
2444 finished_at: row.get("finished_at")?,
2445 })
2446}
2447
2448fn approval_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApprovalRecord> {
2449 Ok(ApprovalRecord {
2450 id: row.get("id")?,
2451 task_id: row.get("task_id")?,
2452 proposed_action: row.get("proposed_action")?,
2453 risk_classification: row.get("risk_classification")?,
2454 policy_decision: row.get("policy_decision")?,
2455 user_decision: row.get("user_decision")?,
2456 args_summary: row.get("args_summary")?,
2457 checkpoint_id: row.get("checkpoint_id")?,
2458 pending_action_json: row.get("pending_action_json")?,
2459 created_at: row.get("created_at")?,
2460 decided_at: row.get("decided_at")?,
2461 archived_at: row.get("archived_at")?,
2462 archive_reason: row.get("archive_reason")?,
2463 })
2464}
2465
2466fn checkpoint_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CheckpointRecord> {
2467 Ok(CheckpointRecord {
2468 id: row.get("id")?,
2469 task_id: row.get("task_id")?,
2470 project_path: row.get("project_path")?,
2471 snapshot_path: row.get("snapshot_path")?,
2472 changed_files_json: row.get("changed_files_json")?,
2473 pending_action_json: row.get("pending_action_json")?,
2474 approval_id: row.get("approval_id")?,
2475 created_at: row.get("created_at")?,
2476 archived_at: row.get("archived_at")?,
2477 archive_reason: row.get("archive_reason")?,
2478 session_id: row.get("session_id")?,
2479 message_index: row.get("message_index")?,
2480 })
2481}
2482
2483fn compaction_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<CompactionRecord> {
2484 Ok(CompactionRecord {
2485 id: row.get("id")?,
2486 task_id: row.get("task_id")?,
2487 session_id: row.get("session_id")?,
2488 source_token_estimate: row.get("source_token_estimate")?,
2489 summary_token_count: row.get("summary_token_count")?,
2490 preserved_turns: row.get("preserved_turns")?,
2491 archive_path: row.get("archive_path")?,
2492 verification_status: row.get("verification_status")?,
2493 created_at: row.get("created_at")?,
2494 })
2495}
2496
2497fn plugin_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PluginInstallRecord> {
2498 let enabled: i64 = row.get("enabled")?;
2499 Ok(PluginInstallRecord {
2500 id: row.get("id")?,
2501 name: row.get("name")?,
2502 source: row.get("source")?,
2503 version: row.get("version")?,
2504 enabled: enabled != 0,
2505 manifest_json: row.get("manifest_json")?,
2506 installed_at: row.get("installed_at")?,
2507 updated_at: row.get("updated_at")?,
2508 })
2509}
2510
2511fn provider_probe_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProviderProbeRecord> {
2512 Ok(ProviderProbeRecord {
2513 provider: row.get("provider")?,
2514 model_id: row.get("model_id")?,
2515 capability_key: row.get("capability_key")?,
2516 capability_value: row.get("capability_value")?,
2517 confidence: row.get("confidence")?,
2518 error: row.get("error")?,
2519 probed_at: row.get("probed_at")?,
2520 })
2521}
2522
2523fn pairing_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<PairingTokenRecord> {
2524 let enabled: i64 = row.get("enabled")?;
2525 Ok(PairingTokenRecord {
2526 id: row.get("id")?,
2527 token_hash: row.get("token_hash")?,
2528 label: row.get("label")?,
2529 enabled: enabled != 0,
2530 created_at: row.get("created_at")?,
2531 last_used_at: row.get("last_used_at")?,
2532 expires_at: row.get("expires_at")?,
2533 })
2534}
2535
2536fn task_event_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskTimelineEvent> {
2537 Ok(TaskTimelineEvent {
2538 id: row.get("id")?,
2539 task_id: row.get("task_id")?,
2540 kind: row.get("kind")?,
2541 message: row.get("message")?,
2542 created_at: row.get("created_at")?,
2543 })
2544}
2545
2546fn is_row_decode_error(err: &rusqlite::Error) -> bool {
2554 matches!(
2555 err,
2556 rusqlite::Error::FromSqlConversionFailure(..) | rusqlite::Error::InvalidColumnType(..)
2557 )
2558}
2559
2560fn task_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskRecord>> {
2563 match task_from_row(row) {
2564 Ok(record) => Ok(Some(record)),
2565 Err(err) if is_row_decode_error(&err) => {
2566 tracing::warn!(error = %err, "skipping task row this build can't decode (version skew?)");
2567 Ok(None)
2568 },
2569 Err(err) => Err(err),
2570 }
2571}
2572
2573fn process_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<ProcessRecord>> {
2575 match process_from_row(row) {
2576 Ok(record) => Ok(Some(record)),
2577 Err(err) if is_row_decode_error(&err) => {
2578 tracing::warn!(error = %err, "skipping process row this build can't decode (version skew?)");
2579 Ok(None)
2580 },
2581 Err(err) => Err(err),
2582 }
2583}
2584
2585fn task_event_from_row_opt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<TaskTimelineEvent>> {
2587 match task_event_from_row(row) {
2588 Ok(record) => Ok(Some(record)),
2589 Err(err) if is_row_decode_error(&err) => {
2590 tracing::warn!(error = %err, "skipping task event row this build can't decode");
2591 Ok(None)
2592 },
2593 Err(err) => Err(err),
2594 }
2595}
2596
2597fn collect_tolerant<T>(rows: impl Iterator<Item = rusqlite::Result<Option<T>>>) -> Result<Vec<T>> {
2600 let mut out = Vec::new();
2601 for row in rows {
2602 if let Some(item) = row? {
2603 out.push(item);
2604 }
2605 }
2606 Ok(out)
2607}
2608
2609fn enum_from_sql_error(
2610 column: &'static str,
2611 value: String,
2612 source: UnknownRuntimeEnum,
2613) -> rusqlite::Error {
2614 let _ = value;
2615 rusqlite::Error::FromSqlConversionFailure(column_index(column), Type::Text, Box::new(source))
2616}
2617
2618fn column_index(column: &str) -> usize {
2619 match column {
2620 "status" => 2,
2621 "priority" => 3,
2622 _ => 0,
2623 }
2624}
2625
2626#[derive(Debug)]
2627struct UnknownRuntimeEnum {
2628 kind: &'static str,
2629 value: String,
2630}
2631
2632impl UnknownRuntimeEnum {
2633 fn new(kind: &'static str, value: &str) -> Self {
2634 Self {
2635 kind,
2636 value: value.to_string(),
2637 }
2638 }
2639}
2640
2641impl fmt::Display for UnknownRuntimeEnum {
2642 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2643 write!(f, "unknown {} value `{}`", self.kind, self.value)
2644 }
2645}
2646
2647impl std::error::Error for UnknownRuntimeEnum {}
2648
2649fn now_rfc3339() -> String {
2650 chrono::Utc::now().to_rfc3339()
2651}
2652
2653fn ct_eq(a: &[u8], b: &[u8]) -> bool {
2658 if a.len() != b.len() {
2659 return false;
2660 }
2661 let mut diff = 0u8;
2662 for (x, y) in a.iter().zip(b.iter()) {
2663 diff |= x ^ y;
2664 }
2665 diff == 0
2666}
2667
2668fn is_expired(expires_at: Option<&str>, now: chrono::DateTime<chrono::Utc>) -> bool {
2676 match expires_at {
2677 None => false,
2678 Some(raw) => match chrono::DateTime::parse_from_rfc3339(raw) {
2679 Ok(dt) => dt <= now,
2680 Err(_) => true,
2681 },
2682 }
2683}
2684
2685const MAX_QUERY_LIMIT: usize = 10_000;
2690
2691fn clamp_limit(limit: usize) -> i64 {
2692 limit.min(MAX_QUERY_LIMIT) as i64
2693}
2694
2695const MAX_SESSION_MESSAGES: i64 = 5_000;
2701
2702pub(crate) fn fresh_id(prefix: &str) -> String {
2703 static SEQ: AtomicU64 = AtomicU64::new(0);
2710 static SALT: OnceLock<u64> = OnceLock::new();
2711 let salt = *SALT.get_or_init(|| {
2712 let mut bytes = [0u8; 8];
2713 let _ = getrandom::fill(&mut bytes);
2716 u64::from_le_bytes(bytes)
2717 });
2718 let nanos = SystemTime::now()
2719 .duration_since(UNIX_EPOCH)
2720 .map(|d| d.as_nanos())
2721 .unwrap_or_default();
2722 let seq = SEQ.fetch_add(1, Ordering::Relaxed);
2723 format!("{prefix}-{nanos:x}-{salt:x}-{seq:x}")
2724}
2725
2726#[cfg(unix)]
2737pub fn try_exclusive_lock(path: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
2738 use rustix::fs::{FlockOperation, flock};
2739 let file = std::fs::OpenOptions::new()
2742 .create(true)
2743 .write(true)
2744 .truncate(false)
2745 .open(path)?;
2746 match flock(&file, FlockOperation::NonBlockingLockExclusive) {
2747 Ok(()) => Ok(Some(file)),
2748 Err(rustix::io::Errno::WOULDBLOCK) => Ok(None),
2749 Err(e) => Err(e.into()),
2750 }
2751}
2752
2753#[cfg(test)]
2754mod tests {
2755 use super::*;
2756
2757 #[test]
2758 fn open_enables_wal_and_busy_timeout() {
2759 let path = temp_db("wal_check");
2762 let store = RuntimeStore::open(&path).expect("open");
2763 let mode: String = store
2764 .conn
2765 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
2766 .expect("journal_mode pragma");
2767 assert_eq!(mode.to_lowercase(), "wal");
2768 }
2769
2770 fn temp_db(name: &str) -> PathBuf {
2771 let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{}", name));
2772 let _ = std::fs::remove_dir_all(&dir);
2773 std::fs::create_dir_all(&dir).expect("create temp dir");
2774 dir.join("runtime.sqlite3")
2775 }
2776
2777 #[test]
2778 fn outcomes_round_trip_and_list_for_task() {
2779 let path = temp_db("outcomes");
2780 let store = RuntimeStore::open(&path).expect("open store");
2781 let task = store
2782 .tasks()
2783 .create(NewTask::new("t", "/tmp/p", "m"))
2784 .expect("create task");
2785
2786 let first = store
2787 .outcomes()
2788 .record(NewOutcome {
2789 id: None,
2790 task_id: Some(task.id.clone()),
2791 tool_run_id: None,
2792 kind: "task_terminal".to_string(),
2793 label: OUTCOME_LABEL_SUCCESS.to_string(),
2794 reward: Some(1.0),
2795 source: OUTCOME_SOURCE_SYSTEM.to_string(),
2796 detail_json: None,
2797 })
2798 .expect("record first");
2799 let second = store
2800 .outcomes()
2801 .record(NewOutcome {
2802 id: None,
2803 task_id: Some(task.id.clone()),
2804 tool_run_id: None,
2805 kind: "preference".to_string(),
2806 label: OUTCOME_LABEL_ACCEPTED.to_string(),
2807 reward: None,
2808 source: OUTCOME_SOURCE_USER.to_string(),
2809 detail_json: Some("{\"chosen\":\"a\",\"rejected\":\"b\"}".to_string()),
2810 })
2811 .expect("record second");
2812
2813 assert_eq!(
2816 store.outcomes().get(&first.id).expect("get").as_ref(),
2817 Some(&first)
2818 );
2819 assert_eq!(first.reward, Some(1.0));
2820 assert_eq!(second.reward, None);
2821 assert_eq!(second.source, OUTCOME_SOURCE_USER);
2822 assert!(second.detail_json.as_deref().unwrap().contains("chosen"));
2823
2824 let for_task = store
2828 .outcomes()
2829 .list_for_task(&task.id)
2830 .expect("list_for_task");
2831 assert_eq!(for_task.len(), 2);
2832 let ids: std::collections::HashSet<&str> = for_task.iter().map(|o| o.id.as_str()).collect();
2833 assert!(ids.contains(first.id.as_str()));
2834 assert!(ids.contains(second.id.as_str()));
2835
2836 assert_eq!(store.outcomes().list(10).expect("list").len(), 2);
2838 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2839 }
2840
2841 #[test]
2842 fn claim_next_queued_orders_by_priority_then_fifo_and_skips_unclaimable() {
2843 let path = temp_db("claim_queue");
2844 let store = RuntimeStore::open(&path).expect("open store");
2845
2846 store
2849 .tasks()
2850 .create(NewTask::new("cli", "/p", "m").with_prompt("x"))
2851 .expect("cli task");
2852 store
2853 .tasks()
2854 .create(NewTask::new("meta", "/p", "m").daemon_owned())
2855 .expect("meta task");
2856 let busy = store
2857 .tasks()
2858 .create(
2859 NewTask::new("busy", "/p", "m")
2860 .daemon_owned()
2861 .with_prompt("x"),
2862 )
2863 .expect("busy task");
2864 store
2865 .tasks()
2866 .update_status(&busy.id, TaskStatus::Running, None)
2867 .expect("mark busy running");
2868
2869 let normal_first = store
2870 .tasks()
2871 .create(
2872 NewTask::new("n1", "/p", "m")
2873 .daemon_owned()
2874 .with_prompt("p1"),
2875 )
2876 .expect("n1");
2877 let low = store
2878 .tasks()
2879 .create(
2880 NewTask::new("l1", "/p", "m")
2881 .daemon_owned()
2882 .with_prompt("p2")
2883 .with_priority(TaskPriority::Low),
2884 )
2885 .expect("l1");
2886 let high = store
2887 .tasks()
2888 .create(
2889 NewTask::new("h1", "/p", "m")
2890 .daemon_owned()
2891 .with_prompt("p-high")
2892 .with_priority(TaskPriority::High),
2893 )
2894 .expect("h1");
2895 let normal_second = store
2896 .tasks()
2897 .create(
2898 NewTask::new("n2", "/p", "m")
2899 .daemon_owned()
2900 .with_prompt("p3"),
2901 )
2902 .expect("n2");
2903
2904 let c1 = store.tasks().claim_next_queued().expect("claim 1").unwrap();
2908 assert_eq!(c1.id, high.id);
2909 assert_eq!(c1.status, TaskStatus::Running);
2910 assert_eq!(c1.prompt.as_deref(), Some("p-high"));
2911 let c2 = store.tasks().claim_next_queued().expect("claim 2").unwrap();
2912 assert_eq!(c2.id, normal_first.id);
2913 let c3 = store.tasks().claim_next_queued().expect("claim 3").unwrap();
2914 assert_eq!(c3.id, normal_second.id);
2915 let c4 = store.tasks().claim_next_queued().expect("claim 4").unwrap();
2916 assert_eq!(c4.id, low.id);
2917 assert!(
2919 store
2920 .tasks()
2921 .claim_next_queued()
2922 .expect("claim 5")
2923 .is_none()
2924 );
2925 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2926 }
2927
2928 #[test]
2929 fn outcome_allows_null_task_and_tool_run() {
2930 let path = temp_db("outcomes_null");
2934 let store = RuntimeStore::open(&path).expect("open store");
2935 let rec = store
2936 .outcomes()
2937 .record(NewOutcome {
2938 id: None,
2939 task_id: None,
2940 tool_run_id: None,
2941 kind: "build".to_string(),
2942 label: OUTCOME_LABEL_FAILURE.to_string(),
2943 reward: Some(-1.0),
2944 source: OUTCOME_SOURCE_VERIFIER.to_string(),
2945 detail_json: None,
2946 })
2947 .expect("record");
2948 assert_eq!(rec.task_id, None);
2949 assert_eq!(rec.tool_run_id, None);
2950 assert_eq!(store.outcomes().list(10).expect("list").len(), 1);
2951 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2952 }
2953
2954 #[test]
2955 fn initializes_runtime_schema() {
2956 let path = temp_db("schema");
2957 let store = RuntimeStore::open(&path).expect("open store");
2958 assert_eq!(store.path(), path.as_path());
2959 let version: i32 = store
2960 .conn
2961 .query_row("PRAGMA user_version", [], |row| row.get(0))
2962 .unwrap();
2963 assert_eq!(version, SCHEMA_VERSION);
2964 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2965 }
2966
2967 #[test]
2968 fn rejects_newer_schema_version() {
2969 let path = temp_db("newer_schema");
2972 {
2973 let store = RuntimeStore::open(&path).expect("first open");
2974 store
2975 .conn
2976 .execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
2977 .expect("bump version");
2978 }
2979 let err = match RuntimeStore::open(&path) {
2981 Ok(_) => panic!("must refuse a newer DB"),
2982 Err(e) => e,
2983 };
2984 assert!(
2985 err.to_string().contains("newer than this build"),
2986 "unexpected error: {err}"
2987 );
2988 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2989 }
2990
2991 #[test]
2992 fn checkpoint_anchor_round_trips_and_list_for_session_is_strict() {
2993 let path = temp_db("checkpoint_anchor");
2994 let store = RuntimeStore::open(&path).expect("open store");
2995 for (id, idx) in [("cp-a", 3_i64), ("cp-b", 5), ("cp-c", 9)] {
2996 store
2997 .checkpoints()
2998 .create(NewCheckpoint {
2999 id: Some(id.to_string()),
3000 task_id: None,
3001 project_path: "/tmp/p".to_string(),
3002 snapshot_path: format!("/data/checkpoints/{id}"),
3003 changed_files_json: "[]".to_string(),
3004 pending_action_json: None,
3005 approval_id: None,
3006 session_id: Some("sess-1".to_string()),
3007 message_index: Some(idx),
3008 })
3009 .expect("create checkpoint");
3010 }
3011 store
3013 .checkpoints()
3014 .create(NewCheckpoint {
3015 id: Some("cp-unanchored".to_string()),
3016 task_id: None,
3017 project_path: "/tmp/p".to_string(),
3018 snapshot_path: "/x".to_string(),
3019 changed_files_json: "[]".to_string(),
3020 pending_action_json: None,
3021 approval_id: None,
3022 session_id: None,
3023 message_index: None,
3024 })
3025 .expect("create unanchored");
3026
3027 let got = store.checkpoints().get("cp-a").unwrap().unwrap();
3028 assert_eq!(got.session_id.as_deref(), Some("sess-1"));
3029 assert_eq!(got.message_index, Some(3));
3030
3031 let past = store
3034 .checkpoints()
3035 .list_for_session("sess-1", 3)
3036 .expect("list_for_session");
3037 let ids: Vec<&str> = past.iter().map(|c| c.id.as_str()).collect();
3038 assert_eq!(ids, vec!["cp-b", "cp-c"], "strict > and oldest-first");
3039
3040 assert!(
3041 store
3042 .checkpoints()
3043 .list_for_session("sess-other", 0)
3044 .unwrap()
3045 .is_empty()
3046 );
3047 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3048 }
3049
3050 #[test]
3051 fn v5_database_upgrades_with_null_checkpoint_anchors() {
3052 let path = temp_db("v5_upgrade");
3055 {
3056 let conn = Connection::open(&path).expect("raw open");
3057 conn.execute_batch(
3058 r#"
3059 CREATE TABLE checkpoints (
3060 id TEXT PRIMARY KEY,
3061 task_id TEXT,
3062 project_path TEXT NOT NULL,
3063 snapshot_path TEXT NOT NULL,
3064 changed_files_json TEXT NOT NULL,
3065 pending_action_json TEXT,
3066 approval_id TEXT,
3067 created_at TEXT NOT NULL,
3068 archived_at TEXT,
3069 archive_reason TEXT
3070 );
3071 INSERT INTO checkpoints
3072 (id, task_id, project_path, snapshot_path, changed_files_json, created_at)
3073 VALUES ('old-cp', NULL, '/tmp/p', '/snap', '[]', '2026-01-01T00:00:00Z');
3074 PRAGMA user_version = 5;
3075 "#,
3076 )
3077 .expect("seed v5 schema");
3078 }
3079 let store = RuntimeStore::open(&path).expect("upgrade open");
3080 let old = store.checkpoints().get("old-cp").unwrap().unwrap();
3081 assert_eq!(old.session_id, None);
3082 assert_eq!(old.message_index, None);
3083 let version: i32 = store
3084 .conn
3085 .query_row("PRAGMA user_version", [], |r| r.get(0))
3086 .unwrap();
3087 assert_eq!(version, SCHEMA_VERSION);
3088 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3089 }
3090
3091 #[test]
3092 fn init_schema_is_idempotent_across_opens() {
3093 let path = temp_db("idempotent_schema");
3097 let _ = RuntimeStore::open(&path).expect("first open");
3098 let store = RuntimeStore::open(&path).expect("second open must succeed");
3099 let version: i32 = store
3100 .conn
3101 .query_row("PRAGMA user_version", [], |r| r.get(0))
3102 .unwrap();
3103 assert_eq!(version, SCHEMA_VERSION);
3104 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3105 }
3106
3107 fn explain_query_plan(conn: &Connection, sql: &str) -> String {
3108 let mut stmt = conn
3109 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
3110 .expect("prepare EXPLAIN QUERY PLAN");
3111 let rows = stmt
3114 .query_map([], |row| row.get::<_, String>(3))
3115 .expect("eqp query")
3116 .collect::<rusqlite::Result<Vec<String>>>()
3117 .expect("eqp rows");
3118 rows.join("\n")
3119 }
3120
3121 #[test]
3122 fn pending_and_reconcile_scans_use_indexes() {
3123 let path = temp_db("scan_indexes");
3126 let store = RuntimeStore::open(&path).expect("open");
3127
3128 let index_count: i64 = store
3129 .conn
3130 .query_row(
3131 "SELECT COUNT(*) FROM sqlite_master
3132 WHERE type = 'index'
3133 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
3134 [],
3135 |r| r.get(0),
3136 )
3137 .unwrap();
3138 assert_eq!(index_count, 2, "F75 indexes must be created");
3139
3140 let plan = explain_query_plan(
3143 &store.conn,
3144 "SELECT id FROM approvals WHERE user_decision IS NULL ORDER BY created_at DESC",
3145 );
3146 assert!(
3147 plan.contains("idx_approvals_pending"),
3148 "pending scan must use idx_approvals_pending; plan was:\n{plan}"
3149 );
3150
3151 let plan = explain_query_plan(
3153 &store.conn,
3154 "SELECT id FROM tasks WHERE status = 'running' AND owner_kind = 'daemon'",
3155 );
3156 assert!(
3157 plan.contains("idx_tasks_status_owner"),
3158 "reconcile scan must use idx_tasks_status_owner; plan was:\n{plan}"
3159 );
3160
3161 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3162 }
3163
3164 #[test]
3165 fn upgrades_from_v2_to_current_and_adds_indexes() {
3166 let path = temp_db("upgrade_v2");
3171 {
3172 let store = RuntimeStore::open(&path).expect("first open");
3173 store
3175 .conn
3176 .execute_batch(
3177 "DROP INDEX IF EXISTS idx_approvals_pending;
3178 DROP INDEX IF EXISTS idx_tasks_status_owner;
3179 PRAGMA user_version = 2;",
3180 )
3181 .expect("downgrade to v2");
3182 }
3183 let store = RuntimeStore::open(&path).expect("reopen must migrate forward");
3184 let version: i32 = store
3185 .conn
3186 .query_row("PRAGMA user_version", [], |r| r.get(0))
3187 .unwrap();
3188 assert_eq!(version, SCHEMA_VERSION);
3189 let index_count: i64 = store
3190 .conn
3191 .query_row(
3192 "SELECT COUNT(*) FROM sqlite_master
3193 WHERE type = 'index'
3194 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
3195 [],
3196 |r| r.get(0),
3197 )
3198 .unwrap();
3199 assert_eq!(
3200 index_count, 2,
3201 "forward migration must recreate the F75 indexes"
3202 );
3203 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3204 }
3205
3206 #[test]
3207 fn task_create_commits_task_and_event_atomically() {
3208 let path = temp_db("task_txn");
3210 let store = RuntimeStore::open(&path).expect("open");
3211 let task = store
3212 .tasks()
3213 .create(NewTask::new("do a thing", "/repo", "anthropic/claude"))
3214 .expect("create task");
3215 let events = store.tasks().events(&task.id).expect("events");
3216 assert!(
3217 events.iter().any(|e| e.kind == "task_created"),
3218 "the task_created event must commit with the task row"
3219 );
3220 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3221 }
3222
3223 #[test]
3224 fn task_lifecycle_round_trips() {
3225 let path = temp_db("task");
3226 let store = RuntimeStore::open(&path).expect("open store");
3227 let session = store
3228 .sessions()
3229 .upsert(NewSession {
3230 id: Some("session-1".to_string()),
3231 project_path: "/repo".to_string(),
3232 model_id: "anthropic/claude".to_string(),
3233 title: Some("Run tests".to_string()),
3234 conversation_path: Some("/repo/.mermaid/session.json".to_string()),
3235 total_tokens: Some(42),
3236 })
3237 .expect("upsert session");
3238 assert_eq!(session.id, "session-1");
3239 let message = store
3240 .messages()
3241 .add(NewMessage {
3242 session_id: session.id.clone(),
3243 role: "user".to_string(),
3244 content_json: "{\"text\":\"hi\"}".to_string(),
3245 })
3246 .expect("add message");
3247 assert_eq!(message.role, "user");
3248 assert_eq!(
3249 store
3250 .messages()
3251 .list_for_session(&session.id)
3252 .unwrap()
3253 .len(),
3254 1
3255 );
3256
3257 let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
3258 new.priority = TaskPriority::High;
3259 let task = store.tasks().create(new).expect("create task");
3260
3261 assert_eq!(task.status, TaskStatus::Queued);
3262 assert_eq!(task.priority, TaskPriority::High);
3263
3264 store
3265 .tasks()
3266 .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
3267 .expect("update task");
3268 let loaded = store.tasks().get(&task.id).unwrap().unwrap();
3269 assert_eq!(loaded.status, TaskStatus::Completed);
3270 assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
3271
3272 let events = store.tasks().events(&task.id).expect("events");
3273 assert_eq!(events.len(), 2);
3274 assert_eq!(events[0].kind, "task_created");
3275 assert_eq!(events[1].kind, "status_changed");
3276 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3277 }
3278
3279 #[test]
3280 fn approval_and_process_records_round_trip() {
3281 let path = temp_db("approval_process");
3282 let store = RuntimeStore::open(&path).expect("open store");
3283 let task = store
3284 .tasks()
3285 .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
3286 .expect("create task");
3287
3288 let approval = store
3289 .approvals()
3290 .create(NewApproval {
3291 task_id: Some(task.id.clone()),
3292 proposed_action: "write_file src/lib.rs".to_string(),
3293 risk_classification: "file_mutation".to_string(),
3294 policy_decision: "ask".to_string(),
3295 args_summary: Some("src/lib.rs".to_string()),
3296 checkpoint_id: Some("checkpoint-1".to_string()),
3297 pending_action_json: Some(
3298 "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
3299 ),
3300 })
3301 .expect("create approval");
3302 store
3303 .approvals()
3304 .decide(&approval.id, "approved")
3305 .expect("decide approval");
3306 let approval = store.approvals().get(&approval.id).unwrap().unwrap();
3307 assert_eq!(approval.user_decision.as_deref(), Some("approved"));
3308 assert!(approval.pending_action_json.is_some());
3309
3310 let tool_run = store
3311 .tool_runs()
3312 .start(NewToolRun {
3313 id: Some("toolrun-1".to_string()),
3314 task_id: Some(task.id.clone()),
3315 turn_id: Some("turn-1".to_string()),
3316 call_id: Some("call-1".to_string()),
3317 tool_name: "write_file".to_string(),
3318 args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
3319 })
3320 .expect("start tool run");
3321 assert_eq!(tool_run.status, "running");
3322 store
3323 .tool_runs()
3324 .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
3325 .expect("finish tool run");
3326 let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
3327 assert_eq!(tool_run.status, "success");
3328 assert!(tool_run.finished_at.is_some());
3329
3330 let process = store
3331 .processes()
3332 .upsert(NewProcess {
3333 id: Some("proc-1".to_string()),
3334 task_id: Some(task.id),
3335 pid: 123,
3336 command: "npm run dev".to_string(),
3337 cwd: Some("/repo".to_string()),
3338 log_path: Some("/tmp/mermaid.log".to_string()),
3339 detected_url: Some("http://127.0.0.1:5173".to_string()),
3340 status: ProcessStatus::Running,
3341 health: Some("ready".to_string()),
3342 })
3343 .expect("upsert process");
3344 assert_eq!(process.status, ProcessStatus::Running);
3345 assert_eq!(store.processes().list(10).unwrap().len(), 1);
3346 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3347 }
3348
3349 #[test]
3350 fn approval_decide_is_single_shot() {
3351 let path = temp_db("approval_decide_guard");
3352 let store = RuntimeStore::open(&path).expect("open store");
3353 let make = |action: &str| {
3354 store
3355 .approvals()
3356 .create(NewApproval {
3357 task_id: None,
3358 proposed_action: action.to_string(),
3359 risk_classification: "file_mutation".to_string(),
3360 policy_decision: "ask".to_string(),
3361 args_summary: None,
3362 checkpoint_id: None,
3363 pending_action_json: None,
3364 })
3365 .expect("create approval")
3366 };
3367
3368 let a = make("write_file a");
3371 store
3372 .approvals()
3373 .decide(&a.id, "approved")
3374 .expect("first decide");
3375 assert!(
3376 store.approvals().decide(&a.id, "approved").is_err(),
3377 "re-approving an approved approval must be rejected"
3378 );
3379
3380 let b = make("write_file b");
3382 store.approvals().decide(&b.id, "denied").expect("deny");
3383 assert!(
3384 store.approvals().decide(&b.id, "approved").is_err(),
3385 "a denied approval must not be re-decidable as approved"
3386 );
3387 let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
3388 assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
3389
3390 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3391 }
3392
3393 #[test]
3394 fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
3395 let path = temp_db("archive_visibility");
3396 let store = RuntimeStore::open(&path).expect("open store");
3397
3398 let approval = store
3399 .approvals()
3400 .create(NewApproval {
3401 task_id: None,
3402 proposed_action: "restore replay: write_file".to_string(),
3403 risk_classification: "restored_action".to_string(),
3404 policy_decision: "ask".to_string(),
3405 args_summary: None,
3406 checkpoint_id: Some("checkpoint-1".to_string()),
3407 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3408 })
3409 .expect("create approval");
3410 let checkpoint = store
3411 .checkpoints()
3412 .create(NewCheckpoint {
3413 id: Some("checkpoint-1".to_string()),
3414 task_id: None,
3415 project_path: "/tmp/mermaid_checkpoint_test".to_string(),
3416 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
3417 changed_files_json: "[]".to_string(),
3418 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3419 approval_id: Some(approval.id.clone()),
3420 session_id: None,
3421 message_index: None,
3422 })
3423 .expect("create checkpoint");
3424
3425 assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
3426 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
3427 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
3428 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
3429 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
3430
3431 assert_eq!(
3432 store
3433 .approvals()
3434 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
3435 .unwrap(),
3436 1
3437 );
3438 assert_eq!(
3439 store
3440 .checkpoints()
3441 .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
3442 .unwrap(),
3443 1
3444 );
3445 assert_eq!(
3446 store
3447 .approvals()
3448 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
3449 .unwrap(),
3450 0
3451 );
3452 assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
3453 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
3454 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
3455 assert_eq!(store.approvals().count_archived().unwrap(), 1);
3456 assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
3457 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
3458 assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
3459
3460 let archived = store.approvals().get(&approval.id).unwrap().unwrap();
3461 assert!(archived.archived_at.is_some());
3462 assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
3463 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3464 }
3465
3466 #[test]
3467 fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
3468 let path = temp_db("everything_else");
3469 let store = RuntimeStore::open(&path).expect("open store");
3470
3471 let checkpoint = store
3472 .checkpoints()
3473 .create(NewCheckpoint {
3474 id: Some("checkpoint-1".to_string()),
3475 task_id: None,
3476 project_path: "/repo".to_string(),
3477 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
3478 changed_files_json: "[\"src/lib.rs\"]".to_string(),
3479 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
3480 approval_id: None,
3481 session_id: None,
3482 message_index: None,
3483 })
3484 .expect("create checkpoint");
3485 assert_eq!(checkpoint.id, "checkpoint-1");
3486 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
3487
3488 let compaction = store
3489 .compactions()
3490 .create(NewCompaction {
3491 id: Some("compaction-1".to_string()),
3492 task_id: None,
3493 session_id: Some("session-1".to_string()),
3494 source_token_estimate: Some(10_000),
3495 summary_token_count: Some(800),
3496 preserved_turns: Some(6),
3497 archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
3498 verification_status: Some("verified".to_string()),
3499 })
3500 .expect("create compaction");
3501 assert_eq!(compaction.summary_token_count, Some(800));
3502 assert_eq!(store.compactions().list(10).unwrap().len(), 1);
3503
3504 let plugin = store
3505 .plugins()
3506 .install(NewPluginInstall {
3507 id: Some("plugin-1".to_string()),
3508 name: "example".to_string(),
3509 source: "local".to_string(),
3510 version: Some("0.1.0".to_string()),
3511 enabled: true,
3512 manifest_json: "{\"name\":\"example\"}".to_string(),
3513 })
3514 .expect("install plugin");
3515 assert!(plugin.enabled);
3516 store.plugins().set_enabled("plugin-1", false).unwrap();
3517 assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
3518
3519 let probe = store
3520 .provider_probes()
3521 .upsert(NewProviderProbe {
3522 provider: "cerebras".to_string(),
3523 model_id: "gpt-oss-120b".to_string(),
3524 capability_key: "parallel_tool_calls".to_string(),
3525 capability_value: "false".to_string(),
3526 confidence: "static".to_string(),
3527 error: None,
3528 })
3529 .expect("probe");
3530 assert_eq!(probe.confidence, "static");
3531 assert_eq!(
3532 store
3533 .provider_probes()
3534 .list(Some("cerebras"), Some("gpt-oss-120b"))
3535 .unwrap()
3536 .len(),
3537 1
3538 );
3539
3540 let pairing = store
3541 .pairing_tokens()
3542 .create("hash", Some("phone"), None)
3543 .expect("pairing");
3544 store.pairing_tokens().mark_used(&pairing.id).unwrap();
3545 assert!(
3546 store
3547 .pairing_tokens()
3548 .get(&pairing.id)
3549 .unwrap()
3550 .unwrap()
3551 .last_used_at
3552 .is_some()
3553 );
3554 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3555 }
3556
3557 #[test]
3558 fn pairing_token_expiry_and_revoke() {
3559 let path = temp_db("pairing_ttl");
3560 let store = RuntimeStore::open(&path).expect("open store");
3561 let tokens = store.pairing_tokens();
3562
3563 let live = tokens
3565 .create("live_hash", Some("a"), None)
3566 .expect("create live");
3567 assert!(tokens.verify_token("live_hash").unwrap().is_some());
3568
3569 let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
3571 tokens
3572 .create("future_hash", None, Some(&future))
3573 .expect("create future");
3574 assert!(tokens.verify_token("future_hash").unwrap().is_some());
3575
3576 let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
3577 tokens
3578 .create("past_hash", None, Some(&past))
3579 .expect("create past");
3580 assert!(
3581 tokens.verify_token("past_hash").unwrap().is_none(),
3582 "an expired token must not verify"
3583 );
3584
3585 let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
3589 .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
3590 .to_rfc3339();
3591 tokens
3592 .create("skew_hash", None, Some(&skewed))
3593 .expect("create skewed");
3594 assert!(
3595 tokens.verify_token("skew_hash").unwrap().is_some(),
3596 "a future token in a non-UTC offset must verify (parsed-instant compare)"
3597 );
3598
3599 tokens
3601 .create("garbage_hash", None, Some("not-a-timestamp"))
3602 .expect("create garbage");
3603 assert!(
3604 tokens.verify_token("garbage_hash").unwrap().is_none(),
3605 "an unparseable expiry must fail closed"
3606 );
3607
3608 assert!(tokens.revoke(&live.id).unwrap());
3610 assert!(tokens.verify_token("live_hash").unwrap().is_none());
3611 assert!(
3612 !tokens.revoke(&live.id).unwrap(),
3613 "double revoke is a no-op"
3614 );
3615
3616 assert!(tokens.verify_token("nope").unwrap().is_none());
3618
3619 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3620 }
3621
3622 #[test]
3623 fn ct_eq_matches_only_identical_bytes() {
3624 assert!(ct_eq(b"abc", b"abc"));
3625 assert!(!ct_eq(b"abc", b"abd"));
3626 assert!(!ct_eq(b"abc", b"ab"));
3627 assert!(!ct_eq(b"", b"x"));
3628 assert!(ct_eq(b"", b""));
3629 }
3630
3631 #[test]
3632 fn fresh_id_is_collision_free_in_tight_loop() {
3633 let mut seen = std::collections::HashSet::new();
3636 for _ in 0..10_000 {
3637 let id = fresh_id("process");
3638 assert!(id.starts_with("process-"), "id must keep prefix: {id}");
3639 assert!(seen.insert(id), "fresh_id produced a duplicate");
3640 }
3641 }
3642
3643 #[test]
3644 fn tool_run_repository_redacts_arguments_and_outcomes() {
3645 let path = temp_db("persistence_redaction");
3646 let store = RuntimeStore::open(&path).expect("open store");
3647 let run = store
3648 .tool_runs()
3649 .start(NewToolRun {
3650 id: Some("toolrun-redacted".to_string()),
3651 task_id: None,
3652 turn_id: None,
3653 call_id: None,
3654 tool_name: "web_fetch".to_string(),
3655 args_json: Some(
3656 serde_json::json!({
3657 "url": "https://user:password@example.test/a?X-Goog-Credential=opaque-id&X-Goog-Signature=opaque-signature#fragment",
3658 "password": "abc",
3659 "token": 12345,
3660 "nested": { "client_secret": true }
3661 })
3662 .to_string(),
3663 ),
3664 })
3665 .expect("start tool run");
3666 store
3667 .tool_runs()
3668 .finish(
3669 &run.id,
3670 "success",
3671 Some(
3672 &serde_json::json!({
3673 "model_content": "OPENAI_API_KEY=sk-abcdefghijklmnop1234\npassword=abc\nAuthorization: Bearer xyz\nAuthorization: Basic dXNlcjphYmM=\nhttps://example.test/download/sk-zyxwvutsrqponmlk9876\n-----BEGIN PRIVATE KEY-----\ncHJpdmF0ZS1tYXRlcmlhbA==\n-----END PRIVATE KEY-----"
3674 })
3675 .to_string(),
3676 ),
3677 )
3678 .expect("finish tool run");
3679 let persisted = store.tool_runs().get(&run.id).unwrap().unwrap();
3680 let args: serde_json::Value =
3681 serde_json::from_str(persisted.args_json.as_deref().unwrap()).unwrap();
3682 assert_eq!(args["password"], "[REDACTED]");
3683 assert_eq!(args["token"], "[REDACTED]");
3684 assert_eq!(args["nested"]["client_secret"], "[REDACTED]");
3685 let combined = format!("{:?}{:?}", persisted.args_json, persisted.output_json);
3686 for secret in [
3687 "user",
3688 "opaque-signature",
3689 "opaque-id",
3690 "fragment",
3691 "sk-abcdefghijklmnop1234",
3692 "password=abc",
3693 "Bearer xyz",
3694 "dXNlcjphYmM=",
3695 "sk-zyxwvutsrqponmlk9876",
3696 "cHJpdmF0ZS1tYXRlcmlhbA==",
3697 "-----END PRIVATE KEY-----",
3698 "12345",
3699 ] {
3700 assert!(
3701 !combined.contains(secret),
3702 "tool run leaked {secret}: {combined}"
3703 );
3704 }
3705
3706 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3707 }
3708
3709 #[test]
3710 fn ensure_column_rejects_non_identifier() {
3711 let path = temp_db("ensure_col");
3712 let store = RuntimeStore::open(&path).expect("open store");
3713 assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
3714 assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
3715 assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
3716 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3717 }
3718
3719 #[test]
3720 fn clamp_limit_never_binds_negative() {
3721 assert_eq!(clamp_limit(10), 10);
3724 assert_eq!(clamp_limit(usize::MAX), MAX_QUERY_LIMIT as i64);
3725 assert!(clamp_limit(usize::MAX) > 0);
3726 }
3727
3728 fn make_approval(store: &RuntimeStore, action: &str) -> ApprovalRecord {
3729 store
3730 .approvals()
3731 .create(NewApproval {
3732 task_id: None,
3733 proposed_action: action.to_string(),
3734 risk_classification: "shell_mutation".to_string(),
3735 policy_decision: "ask".to_string(),
3736 args_summary: None,
3737 checkpoint_id: None,
3738 pending_action_json: None,
3739 })
3740 .expect("create approval")
3741 }
3742
3743 #[test]
3744 fn approval_claim_is_single_winner_releasable_and_finalizable() {
3745 let path = temp_db("approval_claim");
3748 let store = RuntimeStore::open(&path).expect("open store");
3749 let a = make_approval(&store, "write_file a");
3750
3751 assert!(store.approvals().claim(&a.id).unwrap(), "first claim wins");
3752 assert!(
3753 !store.approvals().claim(&a.id).unwrap(),
3754 "second claim loses"
3755 );
3756
3757 store.approvals().release_claim(&a.id).unwrap();
3758 assert!(
3759 store.approvals().claim(&a.id).unwrap(),
3760 "a released claim is re-claimable (effect-failed path)"
3761 );
3762
3763 store
3764 .approvals()
3765 .finalize_claimed(&a.id, "approved")
3766 .unwrap();
3767 assert_eq!(
3768 store
3769 .approvals()
3770 .get(&a.id)
3771 .unwrap()
3772 .unwrap()
3773 .user_decision
3774 .as_deref(),
3775 Some("approved")
3776 );
3777 assert!(
3778 !store.approvals().claim(&a.id).unwrap(),
3779 "a decided approval cannot be claimed"
3780 );
3781 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3782 }
3783
3784 #[test]
3785 fn reconcile_after_restart_recovers_running_tasks_and_claims() {
3786 let path = temp_db("reconcile");
3789 let store = RuntimeStore::open(&path).expect("open store");
3790 let task = store
3791 .tasks()
3792 .create(NewTask::new("t", "/repo", "m").daemon_owned())
3793 .expect("create task");
3794 store
3795 .tasks()
3796 .update_status(&task.id, TaskStatus::Running, None)
3797 .expect("mark running");
3798 let appr = make_approval(&store, "git push");
3799 assert!(store.approvals().claim(&appr.id).unwrap());
3800
3801 let (tasks, claims) = store.reconcile_after_restart().expect("reconcile");
3802 assert_eq!((tasks, claims), (1, 1));
3803 assert_eq!(
3804 store.tasks().get(&task.id).unwrap().unwrap().status,
3805 TaskStatus::Failed
3806 );
3807 assert!(
3808 store
3809 .approvals()
3810 .get(&appr.id)
3811 .unwrap()
3812 .unwrap()
3813 .user_decision
3814 .is_none(),
3815 "a released claim is undecided and re-runnable"
3816 );
3817 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3818 }
3819
3820 #[test]
3821 fn reconcile_after_restart_spares_non_daemon_running_tasks() {
3822 let path = temp_db("reconcile_spare_cli");
3826 let store = RuntimeStore::open(&path).expect("open store");
3827
3828 let cli = store
3829 .tasks()
3830 .create(NewTask::new("cli run", "/repo", "m")) .expect("create cli task");
3832 store
3833 .tasks()
3834 .update_status(&cli.id, TaskStatus::Running, None)
3835 .expect("mark cli running");
3836 let daemon = store
3837 .tasks()
3838 .create(NewTask::new("daemon run", "/repo", "m").daemon_owned())
3839 .expect("create daemon task");
3840 store
3841 .tasks()
3842 .update_status(&daemon.id, TaskStatus::Running, None)
3843 .expect("mark daemon running");
3844
3845 let (tasks, _claims) = store.reconcile_after_restart().expect("reconcile");
3846 assert_eq!(tasks, 1, "only the daemon-owned task is reset");
3847 assert_eq!(
3848 store.tasks().get(&cli.id).unwrap().unwrap().status,
3849 TaskStatus::Running,
3850 "a live CLI task must NOT be clobbered by the daemon's reconcile"
3851 );
3852 assert_eq!(
3853 store.tasks().get(&daemon.id).unwrap().unwrap().status,
3854 TaskStatus::Failed,
3855 "a stranded daemon task is still recovered"
3856 );
3857 assert!(
3859 !store
3860 .tasks()
3861 .events(&cli.id)
3862 .unwrap()
3863 .iter()
3864 .any(|e| e.kind == "interrupted"),
3865 "the spared task must not receive a spurious interrupted event"
3866 );
3867 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3868 }
3869
3870 #[test]
3871 fn gc_prunes_old_archived_but_keeps_active() {
3872 let path = temp_db("gc");
3875 let store = RuntimeStore::open(&path).expect("open store");
3876 let keep = make_approval(&store, "active");
3877 let gone = make_approval(&store, "old archived");
3878 store
3879 .approvals()
3880 .archive(std::slice::from_ref(&gone.id), "test")
3881 .expect("archive");
3882 store
3884 .conn
3885 .execute(
3886 "UPDATE approvals SET archived_at = ?2 WHERE id = ?1",
3887 params![gone.id, "2000-01-01T00:00:00+00:00"],
3888 )
3889 .unwrap();
3890
3891 let removed = store.gc(30, 180).expect("gc");
3892 assert!(removed >= 1, "the old archived approval should be pruned");
3893 assert!(
3894 store.approvals().get(&gone.id).unwrap().is_none(),
3895 "old archived row removed"
3896 );
3897 assert!(
3898 store.approvals().get(&keep.id).unwrap().is_some(),
3899 "active row kept"
3900 );
3901 let _ = std::fs::remove_dir_all(path.parent().unwrap());
3902 }
3903
3904 #[test]
3905 fn gc_prunes_outcomes_and_terminal_tasks_on_their_windows() {
3906 let path = temp_db("gc_outcomes");
3912 let store = RuntimeStore::open(&path).expect("open store");
3913 let old = "2000-01-01T00:00:00+00:00"; let live = store
3917 .tasks()
3918 .create(NewTask::new("live", "/repo", "m"))
3919 .expect("live task");
3920
3921 let done = store
3923 .tasks()
3924 .create(NewTask::new("done", "/repo", "m"))
3925 .expect("done task");
3926 store
3927 .tasks()
3928 .update_status(&done.id, TaskStatus::Completed, Some("ok"))
3929 .expect("finish task");
3930 store
3931 .conn
3932 .execute(
3933 "UPDATE tasks SET updated_at = ?2 WHERE id = ?1",
3934 params![done.id, old],
3935 )
3936 .unwrap();
3937
3938 let kept_outcome = store
3941 .outcomes()
3942 .record(NewOutcome {
3943 id: None,
3944 task_id: Some(done.id.clone()),
3945 tool_run_id: None,
3946 kind: "task_terminal".to_string(),
3947 label: OUTCOME_LABEL_SUCCESS.to_string(),
3948 reward: Some(1.0),
3949 source: OUTCOME_SOURCE_SYSTEM.to_string(),
3950 detail_json: Some("{\"prompt\":\"do the thing\"}".to_string()),
3951 })
3952 .expect("record kept outcome");
3953
3954 let gone_outcome = store
3956 .outcomes()
3957 .record(NewOutcome {
3958 id: None,
3959 task_id: None,
3960 tool_run_id: None,
3961 kind: "task_terminal".to_string(),
3962 label: OUTCOME_LABEL_FAILURE.to_string(),
3963 reward: Some(-1.0),
3964 source: OUTCOME_SOURCE_SYSTEM.to_string(),
3965 detail_json: None,
3966 })
3967 .expect("record gone outcome");
3968 store
3969 .conn
3970 .execute(
3971 "UPDATE outcomes SET created_at = ?2 WHERE id = ?1",
3972 params![gone_outcome.id, old],
3973 )
3974 .unwrap();
3975
3976 store.gc(30, 180).expect("gc");
3977
3978 assert!(
3979 store.tasks().get(&live.id).unwrap().is_some(),
3980 "a live (queued) task must survive gc"
3981 );
3982 assert!(
3983 store.tasks().get(&done.id).unwrap().is_none(),
3984 "an old terminal task must be pruned"
3985 );
3986 let kept = store
3987 .outcomes()
3988 .get(&kept_outcome.id)
3989 .unwrap()
3990 .expect("the recent outcome must survive gc");
3991 assert!(
3992 kept.task_id.is_none(),
3993 "the pruned task's link is nulled (ON DELETE SET NULL)"
3994 );
3995 assert_eq!(
3996 kept.detail_json.as_deref(),
3997 Some("{\"prompt\":\"do the thing\"}"),
3998 "the denormalized training context must survive the task prune"
3999 );
4000 assert!(
4001 store.outcomes().get(&gone_outcome.id).unwrap().is_none(),
4002 "an outcome past the outcomes window must be pruned"
4003 );
4004
4005 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4006 }
4007
4008 #[test]
4009 fn gc_prunes_high_churn_and_old_terminal_rows_but_keeps_active() {
4010 let path = temp_db("gc_high_churn");
4014 let store = RuntimeStore::open(&path).expect("open store");
4015 let old = "2000-01-01T00:00:00+00:00";
4016
4017 let stale_session = store
4019 .sessions()
4020 .upsert(NewSession {
4021 id: Some("stale".to_string()),
4022 project_path: "/repo".to_string(),
4023 model_id: "m".to_string(),
4024 title: None,
4025 conversation_path: None,
4026 total_tokens: None,
4027 })
4028 .expect("stale session");
4029 store
4030 .messages()
4031 .add(NewMessage {
4032 session_id: stale_session.id.clone(),
4033 role: "user".to_string(),
4034 content_json: "{}".to_string(),
4035 })
4036 .expect("stale message");
4037 let active_session = store
4038 .sessions()
4039 .upsert(NewSession {
4040 id: Some("active".to_string()),
4041 project_path: "/repo".to_string(),
4042 model_id: "m".to_string(),
4043 title: None,
4044 conversation_path: None,
4045 total_tokens: None,
4046 })
4047 .expect("active session");
4048 store
4049 .messages()
4050 .add(NewMessage {
4051 session_id: active_session.id.clone(),
4052 role: "user".to_string(),
4053 content_json: "{}".to_string(),
4054 })
4055 .expect("active message");
4056 store
4057 .conn
4058 .execute(
4059 "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
4060 params![stale_session.id, old],
4061 )
4062 .unwrap();
4063
4064 store
4066 .tool_runs()
4067 .start(NewToolRun {
4068 id: Some("tr-finished".to_string()),
4069 task_id: None,
4070 turn_id: None,
4071 call_id: None,
4072 tool_name: "x".to_string(),
4073 args_json: None,
4074 })
4075 .expect("start finished tr");
4076 store
4077 .tool_runs()
4078 .finish("tr-finished", "success", None)
4079 .expect("finish tr");
4080 store
4081 .conn
4082 .execute(
4083 "UPDATE tool_runs SET finished_at = ?2 WHERE id = ?1",
4084 params!["tr-finished", old],
4085 )
4086 .unwrap();
4087 store
4088 .tool_runs()
4089 .start(NewToolRun {
4090 id: Some("tr-running".to_string()),
4091 task_id: None,
4092 turn_id: None,
4093 call_id: None,
4094 tool_name: "x".to_string(),
4095 args_json: None,
4096 })
4097 .expect("start running tr");
4098
4099 let exited = store
4101 .processes()
4102 .upsert(NewProcess {
4103 id: Some("p-exited".to_string()),
4104 task_id: None,
4105 pid: 1,
4106 command: "c".to_string(),
4107 cwd: None,
4108 log_path: None,
4109 detected_url: None,
4110 status: ProcessStatus::Exited,
4111 health: None,
4112 })
4113 .expect("exited process");
4114 store
4115 .conn
4116 .execute(
4117 "UPDATE processes SET updated_at = ?2 WHERE id = ?1",
4118 params![exited.id, old],
4119 )
4120 .unwrap();
4121 let running_proc = store
4122 .processes()
4123 .upsert(NewProcess {
4124 id: Some("p-running".to_string()),
4125 task_id: None,
4126 pid: 2,
4127 command: "c".to_string(),
4128 cwd: None,
4129 log_path: None,
4130 detected_url: None,
4131 status: ProcessStatus::Running,
4132 health: None,
4133 })
4134 .expect("running process");
4135
4136 let comp = store
4138 .compactions()
4139 .create(NewCompaction {
4140 id: Some("comp-old".to_string()),
4141 task_id: None,
4142 session_id: None,
4143 source_token_estimate: None,
4144 summary_token_count: None,
4145 preserved_turns: None,
4146 archive_path: None,
4147 verification_status: None,
4148 })
4149 .expect("compaction");
4150 store
4151 .conn
4152 .execute(
4153 "UPDATE compactions SET created_at = ?2 WHERE id = ?1",
4154 params![comp.id, old],
4155 )
4156 .unwrap();
4157
4158 let removed = store.gc(30, 180).expect("gc");
4159 assert!(removed >= 5, "stale rows pruned (got {removed})");
4160 assert!(
4161 store.sessions().get(&stale_session.id).unwrap().is_none(),
4162 "stale session gone"
4163 );
4164 assert!(
4165 store
4166 .messages()
4167 .list_for_session(&stale_session.id)
4168 .unwrap()
4169 .is_empty(),
4170 "stale messages gone"
4171 );
4172 assert!(
4173 store.sessions().get(&active_session.id).unwrap().is_some(),
4174 "active session kept"
4175 );
4176 assert_eq!(
4177 store
4178 .messages()
4179 .list_for_session(&active_session.id)
4180 .unwrap()
4181 .len(),
4182 1,
4183 "active message kept"
4184 );
4185 assert!(
4186 store.tool_runs().get("tr-finished").unwrap().is_none(),
4187 "old finished tool_run gone"
4188 );
4189 assert!(
4190 store.tool_runs().get("tr-running").unwrap().is_some(),
4191 "running tool_run kept"
4192 );
4193 assert!(
4194 store.processes().get(&exited.id).unwrap().is_none(),
4195 "old exited process gone"
4196 );
4197 assert!(
4198 store.processes().get(&running_proc.id).unwrap().is_some(),
4199 "running process kept"
4200 );
4201 assert!(
4202 store.compactions().get(&comp.id).unwrap().is_none(),
4203 "old compaction gone"
4204 );
4205 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4206 }
4207
4208 #[test]
4209 fn task_list_skips_undecodable_status_row() {
4210 let path = temp_db("poison_task");
4213 let store = RuntimeStore::open(&path).expect("open store");
4214 let good = store
4215 .tasks()
4216 .create(NewTask::new("good", "/repo", "m"))
4217 .expect("create good task");
4218 store
4219 .conn
4220 .execute(
4221 "INSERT INTO tasks
4222 (id, title, status, priority, project_path, model_id, created_at, updated_at)
4223 VALUES ('poison', 't', 'from_the_future', 'normal', '/repo', 'm', ?1, ?1)",
4224 params![now_rfc3339()],
4225 )
4226 .unwrap();
4227 let listed = store.tasks().list(50).expect("list");
4228 assert_eq!(
4229 listed.len(),
4230 1,
4231 "the poison row is skipped, the good row remains"
4232 );
4233 assert_eq!(listed[0].id, good.id);
4234 assert!(store.tasks().get("poison").is_err(), "get() stays strict");
4236 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4237 }
4238
4239 #[test]
4240 fn checkpoint_delete_removes_row() {
4241 let path = temp_db("ckpt_delete");
4244 let store = RuntimeStore::open(&path).expect("open store");
4245 let ckpt = store
4246 .checkpoints()
4247 .create(NewCheckpoint {
4248 id: Some("ckpt-1".to_string()),
4249 task_id: None,
4250 project_path: "/repo".to_string(),
4251 snapshot_path: "/data/checkpoints/ckpt-1".to_string(),
4252 changed_files_json: "[]".to_string(),
4253 pending_action_json: None,
4254 approval_id: None,
4255 session_id: None,
4256 message_index: None,
4257 })
4258 .expect("create checkpoint");
4259 assert!(store.checkpoints().get(&ckpt.id).unwrap().is_some());
4260 assert!(store.checkpoints().delete(&ckpt.id).unwrap(), "row deleted");
4261 assert!(
4262 store.checkpoints().get(&ckpt.id).unwrap().is_none(),
4263 "row gone"
4264 );
4265 assert!(
4266 !store.checkpoints().delete(&ckpt.id).unwrap(),
4267 "second delete is a no-op"
4268 );
4269 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4270 }
4271
4272 #[test]
4273 fn list_for_session_caps_at_max_and_keeps_ascending_order() {
4274 let path = temp_db("session_cap");
4277 let store = RuntimeStore::open(&path).expect("open store");
4278 let session = store
4279 .sessions()
4280 .upsert(NewSession {
4281 id: Some("big".to_string()),
4282 project_path: "/repo".to_string(),
4283 model_id: "m".to_string(),
4284 title: None,
4285 conversation_path: None,
4286 total_tokens: None,
4287 })
4288 .expect("session");
4289 let total = MAX_SESSION_MESSAGES + 10;
4290 let now = now_rfc3339();
4291 let tx = store.conn.unchecked_transaction().unwrap();
4292 for i in 0..total {
4293 tx.execute(
4294 "INSERT INTO messages (session_id, role, content_json, created_at)
4295 VALUES (?1, 'user', ?2, ?3)",
4296 params![session.id, format!("{{\"n\":{i}}}"), now],
4297 )
4298 .unwrap();
4299 }
4300 tx.commit().unwrap();
4301 let listed = store
4302 .messages()
4303 .list_for_session(&session.id)
4304 .expect("list");
4305 assert_eq!(
4306 listed.len() as i64,
4307 MAX_SESSION_MESSAGES,
4308 "capped at the max"
4309 );
4310 assert!(
4311 listed.windows(2).all(|w| w[0].id < w[1].id),
4312 "ascending id order preserved across the capped tail"
4313 );
4314 let _ = std::fs::remove_dir_all(path.parent().unwrap());
4315 }
4316}