1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use directories::ProjectDirs;
5use rusqlite::{Connection, params};
6
7pub mod records;
20pub mod repos;
21pub mod rows;
22
23pub use records::*;
24pub use repos::*;
25pub use rows::*;
26
27#[cfg(windows)]
67mod windows_acl {
68 use std::path::Path;
69 use std::process::{Command, Stdio};
70
71 fn icacls(args: &[&std::ffi::OsStr]) -> bool {
72 Command::new("icacls")
73 .args(args)
74 .stdout(Stdio::null())
75 .stderr(Stdio::null())
76 .status()
77 .is_ok_and(|status| status.success())
78 }
79
80 pub(super) fn harden_data_dir(dir: &Path) -> bool {
84 let Ok(user) = std::env::var("USERNAME") else {
85 return false;
86 };
87 if user.is_empty() {
88 return false;
89 }
90 icacls(&[
91 dir.as_os_str(),
92 "/inheritance:r".as_ref(),
93 "/grant:r".as_ref(),
94 format!("{user}:(OI)(CI)F").as_ref(),
95 ])
96 }
97
98 pub(super) fn restore_owner_access(path: &Path) -> bool {
105 let Ok(user) = std::env::var("USERNAME") else {
106 return false;
107 };
108 if user.is_empty() {
109 return false;
110 }
111 icacls(&[
112 path.as_os_str(),
113 "/grant".as_ref(),
114 format!("{user}:(F)").as_ref(),
115 ])
116 }
117
118 pub(super) fn sqlite_opens(path: &Path) -> bool {
121 rusqlite::Connection::open(path).is_ok()
122 }
123}
124
125#[cfg(windows)]
131fn open_connection(path: &Path) -> Result<Connection> {
132 let err = match Connection::open(path) {
133 Ok(conn) => return Ok(conn),
134 Err(err) => err,
135 };
136 let cannot_open = matches!(
137 err,
138 rusqlite::Error::SqliteFailure(
139 rusqlite::ffi::Error {
140 code: rusqlite::ErrorCode::CannotOpen,
141 ..
142 },
143 _
144 )
145 );
146 if !cannot_open || !path.is_file() || !windows_acl::restore_owner_access(path) {
147 return Err(err).with_context(|| format!("failed to open runtime DB {}", path.display()));
148 }
149 tracing::warn!(
150 path = %path.display(),
151 "runtime DB was unreadable (an earlier Mermaid left its ACL empty); \
152 restored owner access and retried"
153 );
154 Connection::open(path).with_context(|| {
155 format!(
156 "failed to open runtime DB {} even after restoring owner access",
157 path.display()
158 )
159 })
160}
161
162#[cfg(not(windows))]
163fn open_connection(path: &Path) -> Result<Connection> {
164 Connection::open(path).with_context(|| format!("failed to open runtime DB {}", path.display()))
165}
166
167pub struct RuntimeStore {
169 conn: Connection,
170 path: PathBuf,
171}
172
173impl RuntimeStore {
174 pub fn open_default() -> Result<Self> {
184 let dir = data_dir()?;
185 std::fs::create_dir_all(&dir)
186 .with_context(|| format!("failed to create Mermaid data dir {}", dir.display()))?;
187 #[cfg(unix)]
191 {
192 use std::os::unix::fs::PermissionsExt;
193 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
194 }
195 #[cfg(windows)]
202 {
203 let sentinel = dir.join(".acl-hardened");
204 let db = dir.join("runtime.sqlite3");
205 if !sentinel.exists()
211 && windows_acl::harden_data_dir(&dir)
212 && windows_acl::sqlite_opens(&db)
213 {
214 let _ = std::fs::write(&sentinel, b"1");
215 }
216 }
217 Self::open(dir.join("runtime.sqlite3"))
218 }
219
220 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
231 let path = path.as_ref().to_path_buf();
232 if let Some(parent) = path.parent() {
233 std::fs::create_dir_all(parent).with_context(|| {
234 format!("failed to create SQLite parent dir {}", parent.display())
235 })?;
236 }
237 let conn = open_connection(&path)?;
238 conn.busy_timeout(std::time::Duration::from_secs(5))
245 .context("failed to set SQLite busy_timeout")?;
246 conn.execute_batch(
251 "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;",
252 )
253 .context("failed to set SQLite connection PRAGMAs")?;
254 let store = Self { conn, path };
255 store.init_schema()?;
256 Ok(store)
257 }
258
259 pub fn path(&self) -> &Path {
260 &self.path
261 }
262
263 pub fn sessions(&self) -> SessionsRepo<'_> {
264 SessionsRepo { conn: &self.conn }
265 }
266
267 pub fn messages(&self) -> MessagesRepo<'_> {
268 MessagesRepo { conn: &self.conn }
269 }
270
271 pub fn tasks(&self) -> TasksRepo<'_> {
272 TasksRepo { conn: &self.conn }
273 }
274
275 pub fn tool_runs(&self) -> ToolRunsRepo<'_> {
276 ToolRunsRepo { conn: &self.conn }
277 }
278
279 pub fn approvals(&self) -> ApprovalsRepo<'_> {
280 ApprovalsRepo { conn: &self.conn }
281 }
282
283 pub fn processes(&self) -> ProcessesRepo<'_> {
284 ProcessesRepo { conn: &self.conn }
285 }
286
287 pub fn checkpoints(&self) -> CheckpointsRepo<'_> {
288 CheckpointsRepo { conn: &self.conn }
289 }
290
291 pub fn compactions(&self) -> CompactionsRepo<'_> {
292 CompactionsRepo { conn: &self.conn }
293 }
294
295 pub fn plugins(&self) -> PluginsRepo<'_> {
296 PluginsRepo { conn: &self.conn }
297 }
298
299 pub fn provider_probes(&self) -> ProviderProbesRepo<'_> {
300 ProviderProbesRepo { conn: &self.conn }
301 }
302
303 pub fn pairing_tokens(&self) -> PairingTokensRepo<'_> {
304 PairingTokensRepo { conn: &self.conn }
305 }
306
307 pub fn outcomes(&self) -> OutcomesRepo<'_> {
308 OutcomesRepo { conn: &self.conn }
309 }
310
311 pub fn reconcile_after_restart(&self) -> Result<(usize, usize)> {
333 let now = now_rfc3339();
334 self.conn.execute_batch("BEGIN IMMEDIATE;")?;
341 let result = (|| -> Result<(usize, usize)> {
342 let running: Vec<String> = {
343 let mut stmt = self
344 .conn
345 .prepare("SELECT id FROM tasks WHERE status = 'running' AND owner_kind = ?1")?;
346 let ids = stmt.query_map([OWNER_KIND_DAEMON], |row| row.get::<_, String>(0))?;
347 ids.collect::<rusqlite::Result<Vec<_>>>()?
348 };
349 for id in &running {
350 self.conn.execute(
351 "UPDATE tasks SET status = 'failed', updated_at = ?2 WHERE id = ?1",
352 params![id, now],
353 )?;
354 self.conn.execute(
355 "INSERT INTO task_events (task_id, kind, message, created_at)
356 VALUES (?1, ?2, ?3, ?4)",
357 params![
358 id,
359 "interrupted",
360 "task was running when the daemon restarted; marked failed",
361 now
362 ],
363 )?;
364 }
365 let claims_released = self.conn.execute(
366 "UPDATE approvals SET user_decision = NULL WHERE user_decision = 'approving'",
367 [],
368 )?;
369 Ok((running.len(), claims_released))
370 })();
371 match result {
372 Ok(v) => {
373 self.conn.execute_batch("COMMIT;")?;
374 Ok(v)
375 },
376 Err(e) => {
377 let _ = self.conn.execute_batch("ROLLBACK;");
378 Err(e)
379 },
380 }
381 }
382
383 pub fn gc(&self, retention_days: i64, outcomes_retention_days: i64) -> Result<u64> {
399 let now = chrono::Utc::now();
400 let cutoff = (now - chrono::Duration::days(retention_days)).to_rfc3339();
401 let outcomes_cutoff = (now - chrono::Duration::days(outcomes_retention_days)).to_rfc3339();
402 let tx = self.conn.unchecked_transaction()?;
403 let mut removed = 0u64;
404 removed += tx.execute(
405 "DELETE FROM approvals WHERE archived_at IS NOT NULL AND archived_at < ?1",
406 params![cutoff],
407 )? as u64;
408 removed += tx.execute(
409 "DELETE FROM checkpoints WHERE archived_at IS NOT NULL AND archived_at < ?1",
410 params![cutoff],
411 )? as u64;
412 removed += tx.execute(
413 "DELETE FROM task_events
414 WHERE created_at < ?1
415 AND task_id IN (
416 SELECT id FROM tasks
417 WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1
418 )",
419 params![cutoff],
420 )? as u64;
421 removed += tx.execute(
425 "DELETE FROM tool_runs WHERE finished_at IS NOT NULL AND finished_at < ?1",
426 params![cutoff],
427 )? as u64;
428 removed += tx.execute(
431 "DELETE FROM processes WHERE status = 'exited' AND updated_at < ?1",
432 params![cutoff],
433 )? as u64;
434 removed += tx.execute(
437 "DELETE FROM compactions WHERE created_at < ?1",
438 params![cutoff],
439 )? as u64;
440 removed += tx.execute(
446 "DELETE FROM messages
447 WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ?1)",
448 params![cutoff],
449 )? as u64;
450 removed += tx.execute(
451 "DELETE FROM sessions WHERE updated_at < ?1",
452 params![cutoff],
453 )? as u64;
454 removed += tx.execute(
462 "DELETE FROM outcomes WHERE created_at < ?1",
463 params![outcomes_cutoff],
464 )? as u64;
465 removed += tx.execute(
471 "DELETE FROM tasks
472 WHERE status IN ('completed', 'failed', 'cancelled') AND updated_at < ?1",
473 params![cutoff],
474 )? as u64;
475 tx.commit()?;
476 Ok(removed)
477 }
478
479 pub(crate) fn init_schema(&self) -> Result<()> {
480 let conn = &self.conn;
481 let current: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
488 anyhow::ensure!(
489 current <= SCHEMA_VERSION,
490 "runtime DB schema version {current} is newer than this build supports ({SCHEMA_VERSION}); upgrade mermaid"
491 );
492
493 if current == SCHEMA_VERSION {
503 return Ok(());
504 }
505
506 conn.execute_batch("BEGIN IMMEDIATE;")?;
514 if let Err(error) = self.migrate_within_txn(current) {
515 let _ = conn.execute_batch("ROLLBACK;");
516 return Err(error);
517 }
518 conn.execute_batch("COMMIT;")?;
519
520 conn.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION};"))?;
523 let version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
524 anyhow::ensure!(
525 version == SCHEMA_VERSION,
526 "unsupported runtime DB schema version {version} (expected {SCHEMA_VERSION})"
527 );
528 Ok(())
529 }
530
531 #[expect(
537 clippy::too_many_lines,
538 reason = "predates the lint; see .github/baselines/expect_budget.txt"
539 )]
540 pub(crate) fn migrate_within_txn(&self, from_version: i32) -> Result<()> {
541 self.conn.execute_batch(
542 r#"
543 CREATE TABLE IF NOT EXISTS sessions (
544 id TEXT PRIMARY KEY,
545 project_path TEXT NOT NULL,
546 model_id TEXT NOT NULL,
547 title TEXT,
548 conversation_path TEXT,
549 created_at TEXT NOT NULL,
550 updated_at TEXT NOT NULL,
551 total_tokens INTEGER
552 );
553
554 CREATE TABLE IF NOT EXISTS messages (
555 id INTEGER PRIMARY KEY AUTOINCREMENT,
556 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
557 role TEXT NOT NULL,
558 content_json TEXT NOT NULL,
559 created_at TEXT NOT NULL
560 );
561 CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
562
563 CREATE TABLE IF NOT EXISTS tasks (
564 id TEXT PRIMARY KEY,
565 title TEXT NOT NULL,
566 status TEXT NOT NULL,
567 priority TEXT NOT NULL,
568 project_path TEXT NOT NULL,
569 model_id TEXT NOT NULL,
570 conversation_id TEXT,
571 created_at TEXT NOT NULL,
572 updated_at TEXT NOT NULL,
573 final_report TEXT,
574 owner_kind TEXT
575 );
576 CREATE INDEX IF NOT EXISTS idx_tasks_project_status
577 ON tasks(project_path, status, updated_at);
578 -- `idx_tasks_status_owner` is NOT here. It indexes `owner_kind`,
579 -- which the `ensure_column` below adds, and on a pre-v2 DB the
580 -- `CREATE TABLE IF NOT EXISTS` above is a no-op against a table
581 -- that has no such column. See the ordered block after the
582 -- `ensure_column` calls.
583
584 CREATE TABLE IF NOT EXISTS task_events (
585 id INTEGER PRIMARY KEY AUTOINCREMENT,
586 task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
587 kind TEXT NOT NULL,
588 message TEXT NOT NULL,
589 created_at TEXT NOT NULL
590 );
591 CREATE INDEX IF NOT EXISTS idx_task_events_task_id
592 ON task_events(task_id, id);
593
594 CREATE TABLE IF NOT EXISTS tool_runs (
595 id TEXT PRIMARY KEY,
596 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
597 turn_id TEXT,
598 call_id TEXT,
599 tool_name TEXT NOT NULL,
600 status TEXT NOT NULL,
601 args_json TEXT,
602 output_json TEXT,
603 started_at TEXT NOT NULL,
604 finished_at TEXT
605 );
606 CREATE INDEX IF NOT EXISTS idx_tool_runs_task_id ON tool_runs(task_id);
607
608 CREATE TABLE IF NOT EXISTS approvals (
609 id TEXT PRIMARY KEY,
610 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
611 proposed_action TEXT NOT NULL,
612 risk_classification TEXT NOT NULL,
613 policy_decision TEXT NOT NULL,
614 user_decision TEXT,
615 args_summary TEXT,
616 checkpoint_id TEXT,
617 pending_action_json TEXT,
618 created_at TEXT NOT NULL,
619 decided_at TEXT,
620 archived_at TEXT,
621 archive_reason TEXT
622 );
623 CREATE INDEX IF NOT EXISTS idx_approvals_task_id ON approvals(task_id);
624 -- F75: `list_pending` scans `user_decision IS NULL ORDER BY
625 -- created_at`. A partial index over only the pending rows stays tiny
626 -- and serves both the filter and the ordering.
627 CREATE INDEX IF NOT EXISTS idx_approvals_pending
628 ON approvals(created_at)
629 WHERE user_decision IS NULL;
630
631 CREATE TABLE IF NOT EXISTS processes (
632 id TEXT PRIMARY KEY,
633 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
634 pid INTEGER NOT NULL,
635 command TEXT NOT NULL,
636 cwd TEXT,
637 log_path TEXT,
638 detected_url TEXT,
639 status TEXT NOT NULL,
640 health TEXT,
641 created_at TEXT NOT NULL,
642 updated_at TEXT NOT NULL
643 );
644 CREATE INDEX IF NOT EXISTS idx_processes_task_id ON processes(task_id);
645 CREATE INDEX IF NOT EXISTS idx_processes_pid ON processes(pid);
646
647 CREATE TABLE IF NOT EXISTS checkpoints (
648 id TEXT PRIMARY KEY,
649 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
650 project_path TEXT NOT NULL,
651 snapshot_path TEXT NOT NULL,
652 changed_files_json TEXT NOT NULL,
653 pending_action_json TEXT,
654 approval_id TEXT REFERENCES approvals(id) ON DELETE SET NULL,
655 created_at TEXT NOT NULL,
656 archived_at TEXT,
657 archive_reason TEXT,
658 session_id TEXT,
659 message_index INTEGER
660 );
661
662 CREATE TABLE IF NOT EXISTS compactions (
663 id TEXT PRIMARY KEY,
664 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
665 session_id TEXT,
666 source_token_estimate INTEGER,
667 summary_token_count INTEGER,
668 preserved_turns INTEGER,
669 archive_path TEXT,
670 verification_status TEXT,
671 created_at TEXT NOT NULL
672 );
673
674 CREATE TABLE IF NOT EXISTS provider_probes (
675 provider TEXT NOT NULL,
676 model_id TEXT NOT NULL,
677 capability_key TEXT NOT NULL,
678 capability_value TEXT NOT NULL,
679 confidence TEXT NOT NULL,
680 error TEXT,
681 probed_at TEXT NOT NULL,
682 PRIMARY KEY (provider, model_id, capability_key)
683 );
684
685 CREATE TABLE IF NOT EXISTS plugin_installs (
686 id TEXT PRIMARY KEY,
687 name TEXT NOT NULL,
688 source TEXT NOT NULL,
689 version TEXT,
690 enabled INTEGER NOT NULL DEFAULT 1,
691 manifest_json TEXT NOT NULL,
692 installed_at TEXT NOT NULL,
693 updated_at TEXT NOT NULL
694 );
695
696 CREATE TABLE IF NOT EXISTS pairing_tokens (
697 id TEXT PRIMARY KEY,
698 token_hash TEXT NOT NULL,
699 label TEXT,
700 enabled INTEGER NOT NULL DEFAULT 1,
701 created_at TEXT NOT NULL,
702 last_used_at TEXT,
703 expires_at TEXT
704 );
705 CREATE INDEX IF NOT EXISTS idx_pairing_tokens_enabled
706 ON pairing_tokens(enabled, created_at);
707
708 CREATE TABLE IF NOT EXISTS outcomes (
709 id TEXT PRIMARY KEY,
710 task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
711 tool_run_id TEXT REFERENCES tool_runs(id) ON DELETE SET NULL,
712 kind TEXT NOT NULL,
713 label TEXT NOT NULL,
714 reward REAL,
715 source TEXT NOT NULL,
716 detail_json TEXT,
717 created_at TEXT NOT NULL
718 );
719 CREATE INDEX IF NOT EXISTS idx_outcomes_task_id ON outcomes(task_id);
720 CREATE INDEX IF NOT EXISTS idx_outcomes_kind ON outcomes(kind, created_at);
721 "#,
722 )?;
723
724 ensure_column(&self.conn, "approvals", "pending_action_json", "TEXT")?;
725 ensure_column(&self.conn, "approvals", "archived_at", "TEXT")?;
726 ensure_column(&self.conn, "approvals", "archive_reason", "TEXT")?;
727 ensure_column(&self.conn, "checkpoints", "archived_at", "TEXT")?;
728 ensure_column(&self.conn, "checkpoints", "archive_reason", "TEXT")?;
729 ensure_column(&self.conn, "checkpoints", "session_id", "TEXT")?;
733 ensure_column(&self.conn, "checkpoints", "message_index", "INTEGER")?;
734 self.conn.execute_batch(
737 "CREATE INDEX IF NOT EXISTS idx_checkpoints_session
738 ON checkpoints(session_id, message_index);",
739 )?;
740 ensure_column(&self.conn, "tasks", "owner_kind", "TEXT")?;
744 ensure_column(&self.conn, "tasks", "prompt", "TEXT")?;
748 self.conn.execute_batch(
763 "CREATE INDEX IF NOT EXISTS idx_tasks_status_owner
764 ON tasks(status, owner_kind);",
765 )?;
766 if ensure_column(&self.conn, "pairing_tokens", "expires_at", "TEXT")? {
772 let grace = (chrono::Utc::now() + chrono::Duration::days(30)).to_rfc3339();
773 self.conn.execute(
774 "UPDATE pairing_tokens SET expires_at = ?1 WHERE expires_at IS NULL",
775 params![grace],
776 )?;
777 }
778
779 for target in (from_version + 1)..=SCHEMA_VERSION {
789 match target {
790 2 => {},
792 3 => self.migrate_to_v3()?,
796 4 => self.migrate_to_v4()?,
800 5 => self.migrate_to_v5()?,
803 6 => {},
806 _ => {},
808 }
809 }
810 Ok(())
811 }
812
813 pub(crate) fn migrate_to_v3(&self) -> Result<()> {
821 Ok(())
822 }
823
824 pub(crate) fn migrate_to_v4(&self) -> Result<()> {
829 Ok(())
830 }
831
832 pub(crate) fn migrate_to_v5(&self) -> Result<()> {
836 Ok(())
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 #[test]
845 pub(crate) fn open_enables_wal_and_busy_timeout() {
846 let path = temp_db("wal_check");
849 let store = RuntimeStore::open(&path).expect("open");
850 let mode: String = store
851 .conn
852 .query_row("PRAGMA journal_mode", [], |r| r.get(0))
853 .expect("journal_mode pragma");
854 assert_eq!(mode.to_lowercase(), "wal");
855 }
856
857 pub(crate) fn temp_db(name: &str) -> PathBuf {
858 let dir = std::env::temp_dir().join(format!("mermaid_runtime_store_{name}"));
859 let _ = std::fs::remove_dir_all(&dir);
860 std::fs::create_dir_all(&dir).expect("create temp dir");
861 dir.join("runtime.sqlite3")
862 }
863
864 #[cfg(windows)]
873 #[test]
874 fn hardening_leaves_the_database_and_its_subdirectories_usable() {
875 let path = temp_db("acl_hardening");
876 let dir = path.parent().expect("temp dir").to_path_buf();
877
878 drop(RuntimeStore::open(&path).expect("seed the DB before hardening"));
882 let sub = dir.join("checkpoints");
883 std::fs::create_dir_all(&sub).expect("create subdir");
884 std::fs::write(sub.join("existing.json"), b"{}").expect("seed a nested file");
885
886 assert!(
887 super::windows_acl::harden_data_dir(&dir),
888 "icacls hardening did not run; the rest of this test would be vacuous"
889 );
890
891 assert!(
896 super::windows_acl::sqlite_opens(&path),
897 "hardening locked the owner out of the database it was protecting"
898 );
899 std::fs::read(sub.join("existing.json")).expect("a nested file must stay readable");
900
901 std::fs::write(sub.join("created-after.json"), b"{}").expect("write a new nested file");
906 std::fs::read(sub.join("created-after.json"))
907 .expect("a file created after hardening must be readable");
908 }
909
910 #[cfg(windows)]
912 #[test]
913 fn an_empty_dacl_is_repaired_on_open_rather_than_surfaced() {
914 let path = temp_db("acl_repair");
915 drop(RuntimeStore::open(&path).expect("seed the DB"));
916
917 let stripped = std::process::Command::new("icacls")
923 .arg(&path)
924 .arg("/inheritance:r")
925 .stdout(std::process::Stdio::null())
926 .stderr(std::process::Stdio::null())
927 .status()
928 .expect("run icacls");
929 assert!(stripped.success(), "icacls must strip the DACL");
930
931 if std::fs::read(&path).is_ok() {
932 println!(
939 "note: this environment reads through an empty DACL; \
940 asserting the repair grant only"
941 );
942 assert!(
943 super::windows_acl::restore_owner_access(&path),
944 "the repair must still be able to grant"
945 );
946 RuntimeStore::open(&path).expect("open must succeed");
947 return;
948 }
949
950 RuntimeStore::open(&path).expect("open must repair the ACL and succeed");
951 }
952
953 #[test]
954 pub(crate) fn outcomes_round_trip_and_list_for_task() {
955 let path = temp_db("outcomes");
956 let store = RuntimeStore::open(&path).expect("open store");
957 let task = store
958 .tasks()
959 .create(NewTask::new("t", "/tmp/p", "m"))
960 .expect("create task");
961
962 let first = store
963 .outcomes()
964 .record(NewOutcome {
965 id: None,
966 task_id: Some(task.id.clone()),
967 tool_run_id: None,
968 kind: "task_terminal".to_string(),
969 label: OUTCOME_LABEL_SUCCESS.to_string(),
970 reward: Some(1.0),
971 source: OUTCOME_SOURCE_SYSTEM.to_string(),
972 detail_json: None,
973 })
974 .expect("record first");
975 let second = store
976 .outcomes()
977 .record(NewOutcome {
978 id: None,
979 task_id: Some(task.id.clone()),
980 tool_run_id: None,
981 kind: "preference".to_string(),
982 label: OUTCOME_LABEL_ACCEPTED.to_string(),
983 reward: None,
984 source: OUTCOME_SOURCE_USER.to_string(),
985 detail_json: Some("{\"chosen\":\"a\",\"rejected\":\"b\"}".to_string()),
986 })
987 .expect("record second");
988
989 assert_eq!(
992 store.outcomes().get(&first.id).expect("get").as_ref(),
993 Some(&first)
994 );
995 assert_eq!(first.reward, Some(1.0));
996 assert_eq!(second.reward, None);
997 assert_eq!(second.source, OUTCOME_SOURCE_USER);
998 assert!(second.detail_json.as_deref().unwrap().contains("chosen"));
999
1000 let for_task = store
1004 .outcomes()
1005 .list_for_task(&task.id)
1006 .expect("list_for_task");
1007 assert_eq!(for_task.len(), 2);
1008 let ids: std::collections::HashSet<&str> = for_task.iter().map(|o| o.id.as_str()).collect();
1009 assert!(ids.contains(first.id.as_str()));
1010 assert!(ids.contains(second.id.as_str()));
1011
1012 assert_eq!(store.outcomes().list(10).expect("list").len(), 2);
1014 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1015 }
1016
1017 #[test]
1018 pub(crate) fn claim_next_queued_orders_by_priority_then_fifo_and_skips_unclaimable() {
1019 let path = temp_db("claim_queue");
1020 let store = RuntimeStore::open(&path).expect("open store");
1021
1022 store
1025 .tasks()
1026 .create(NewTask::new("cli", "/p", "m").with_prompt("x"))
1027 .expect("cli task");
1028 store
1029 .tasks()
1030 .create(NewTask::new("meta", "/p", "m").daemon_owned())
1031 .expect("meta task");
1032 let busy = store
1033 .tasks()
1034 .create(
1035 NewTask::new("busy", "/p", "m")
1036 .daemon_owned()
1037 .with_prompt("x"),
1038 )
1039 .expect("busy task");
1040 store
1041 .tasks()
1042 .update_status(&busy.id, TaskStatus::Running, None)
1043 .expect("mark busy running");
1044
1045 let normal_first = store
1046 .tasks()
1047 .create(
1048 NewTask::new("n1", "/p", "m")
1049 .daemon_owned()
1050 .with_prompt("p1"),
1051 )
1052 .expect("n1");
1053 let low = store
1054 .tasks()
1055 .create(
1056 NewTask::new("l1", "/p", "m")
1057 .daemon_owned()
1058 .with_prompt("p2")
1059 .with_priority(TaskPriority::Low),
1060 )
1061 .expect("l1");
1062 let high = store
1063 .tasks()
1064 .create(
1065 NewTask::new("h1", "/p", "m")
1066 .daemon_owned()
1067 .with_prompt("p-high")
1068 .with_priority(TaskPriority::High),
1069 )
1070 .expect("h1");
1071 let normal_second = store
1072 .tasks()
1073 .create(
1074 NewTask::new("n2", "/p", "m")
1075 .daemon_owned()
1076 .with_prompt("p3"),
1077 )
1078 .expect("n2");
1079
1080 let c1 = store.tasks().claim_next_queued().expect("claim 1").unwrap();
1084 assert_eq!(c1.id, high.id);
1085 assert_eq!(c1.status, TaskStatus::Running);
1086 assert_eq!(c1.prompt.as_deref(), Some("p-high"));
1087 let c2 = store.tasks().claim_next_queued().expect("claim 2").unwrap();
1088 assert_eq!(c2.id, normal_first.id);
1089 let c3 = store.tasks().claim_next_queued().expect("claim 3").unwrap();
1090 assert_eq!(c3.id, normal_second.id);
1091 let c4 = store.tasks().claim_next_queued().expect("claim 4").unwrap();
1092 assert_eq!(c4.id, low.id);
1093 assert!(
1095 store
1096 .tasks()
1097 .claim_next_queued()
1098 .expect("claim 5")
1099 .is_none()
1100 );
1101 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1102 }
1103
1104 #[test]
1105 pub(crate) fn outcome_allows_null_task_and_tool_run() {
1106 let path = temp_db("outcomes_null");
1110 let store = RuntimeStore::open(&path).expect("open store");
1111 let rec = store
1112 .outcomes()
1113 .record(NewOutcome {
1114 id: None,
1115 task_id: None,
1116 tool_run_id: None,
1117 kind: "build".to_string(),
1118 label: OUTCOME_LABEL_FAILURE.to_string(),
1119 reward: Some(-1.0),
1120 source: OUTCOME_SOURCE_VERIFIER.to_string(),
1121 detail_json: None,
1122 })
1123 .expect("record");
1124 assert_eq!(rec.task_id, None);
1125 assert_eq!(rec.tool_run_id, None);
1126 assert_eq!(store.outcomes().list(10).expect("list").len(), 1);
1127 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1128 }
1129
1130 #[test]
1131 pub(crate) fn initializes_runtime_schema() {
1132 let path = temp_db("schema");
1133 let store = RuntimeStore::open(&path).expect("open store");
1134 assert_eq!(store.path(), path.as_path());
1135 let version: i32 = store
1136 .conn
1137 .query_row("PRAGMA user_version", [], |row| row.get(0))
1138 .unwrap();
1139 assert_eq!(version, SCHEMA_VERSION);
1140 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1141 }
1142
1143 #[test]
1144 pub(crate) fn rejects_newer_schema_version() {
1145 let path = temp_db("newer_schema");
1148 {
1149 let store = RuntimeStore::open(&path).expect("first open");
1150 store
1151 .conn
1152 .execute_batch(&format!("PRAGMA user_version = {};", SCHEMA_VERSION + 1))
1153 .expect("bump version");
1154 }
1155 let err = match RuntimeStore::open(&path) {
1157 Ok(_) => panic!("must refuse a newer DB"),
1158 Err(e) => e,
1159 };
1160 assert!(
1161 err.to_string().contains("newer than this build"),
1162 "unexpected error: {err}"
1163 );
1164 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1165 }
1166
1167 #[test]
1168 pub(crate) fn checkpoint_anchor_round_trips_and_list_for_session_is_strict() {
1169 let path = temp_db("checkpoint_anchor");
1170 let store = RuntimeStore::open(&path).expect("open store");
1171 for (id, idx) in [("cp-a", 3_i64), ("cp-b", 5), ("cp-c", 9)] {
1172 store
1173 .checkpoints()
1174 .create(NewCheckpoint {
1175 id: Some(id.to_string()),
1176 task_id: None,
1177 project_path: "/tmp/p".to_string(),
1178 snapshot_path: format!("/data/checkpoints/{id}"),
1179 changed_files_json: "[]".to_string(),
1180 pending_action_json: None,
1181 approval_id: None,
1182 session_id: Some("sess-1".to_string()),
1183 message_index: Some(idx),
1184 })
1185 .expect("create checkpoint");
1186 }
1187 store
1189 .checkpoints()
1190 .create(NewCheckpoint {
1191 id: Some("cp-unanchored".to_string()),
1192 task_id: None,
1193 project_path: "/tmp/p".to_string(),
1194 snapshot_path: "/x".to_string(),
1195 changed_files_json: "[]".to_string(),
1196 pending_action_json: None,
1197 approval_id: None,
1198 session_id: None,
1199 message_index: None,
1200 })
1201 .expect("create unanchored");
1202
1203 let got = store.checkpoints().get("cp-a").unwrap().unwrap();
1204 assert_eq!(got.session_id.as_deref(), Some("sess-1"));
1205 assert_eq!(got.message_index, Some(3));
1206
1207 let past = store
1210 .checkpoints()
1211 .list_for_session("sess-1", 3)
1212 .expect("list_for_session");
1213 let ids: Vec<&str> = past.iter().map(|c| c.id.as_str()).collect();
1214 assert_eq!(ids, vec!["cp-b", "cp-c"], "strict > and oldest-first");
1215
1216 assert!(
1217 store
1218 .checkpoints()
1219 .list_for_session("sess-other", 0)
1220 .unwrap()
1221 .is_empty()
1222 );
1223 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1224 }
1225
1226 #[test]
1227 pub(crate) fn v5_database_upgrades_with_null_checkpoint_anchors() {
1228 let path = temp_db("v5_upgrade");
1231 {
1232 let conn = Connection::open(&path).expect("raw open");
1233 conn.execute_batch(
1234 r#"
1235 CREATE TABLE checkpoints (
1236 id TEXT PRIMARY KEY,
1237 task_id TEXT,
1238 project_path TEXT NOT NULL,
1239 snapshot_path TEXT NOT NULL,
1240 changed_files_json TEXT NOT NULL,
1241 pending_action_json TEXT,
1242 approval_id TEXT,
1243 created_at TEXT NOT NULL,
1244 archived_at TEXT,
1245 archive_reason TEXT
1246 );
1247 INSERT INTO checkpoints
1248 (id, task_id, project_path, snapshot_path, changed_files_json, created_at)
1249 VALUES ('old-cp', NULL, '/tmp/p', '/snap', '[]', '2026-01-01T00:00:00Z');
1250 PRAGMA user_version = 5;
1251 "#,
1252 )
1253 .expect("seed v5 schema");
1254 }
1255 let store = RuntimeStore::open(&path).expect("upgrade open");
1256 let old = store.checkpoints().get("old-cp").unwrap().unwrap();
1257 assert_eq!(old.session_id, None);
1258 assert_eq!(old.message_index, None);
1259 let version: i32 = store
1260 .conn
1261 .query_row("PRAGMA user_version", [], |r| r.get(0))
1262 .unwrap();
1263 assert_eq!(version, SCHEMA_VERSION);
1264 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1265 }
1266
1267 #[test]
1268 pub(crate) fn init_schema_is_idempotent_across_opens() {
1269 let path = temp_db("idempotent_schema");
1273 let _ = RuntimeStore::open(&path).expect("first open");
1274 let store = RuntimeStore::open(&path).expect("second open must succeed");
1275 let version: i32 = store
1276 .conn
1277 .query_row("PRAGMA user_version", [], |r| r.get(0))
1278 .unwrap();
1279 assert_eq!(version, SCHEMA_VERSION);
1280 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1281 }
1282
1283 pub(crate) fn explain_query_plan(conn: &Connection, sql: &str) -> String {
1284 let mut stmt = conn
1285 .prepare(&format!("EXPLAIN QUERY PLAN {sql}"))
1286 .expect("prepare EXPLAIN QUERY PLAN");
1287 let rows = stmt
1290 .query_map([], |row| row.get::<_, String>(3))
1291 .expect("eqp query")
1292 .collect::<rusqlite::Result<Vec<String>>>()
1293 .expect("eqp rows");
1294 rows.join("\n")
1295 }
1296
1297 #[test]
1298 pub(crate) fn pending_and_reconcile_scans_use_indexes() {
1299 let path = temp_db("scan_indexes");
1302 let store = RuntimeStore::open(&path).expect("open");
1303
1304 let index_count: i64 = store
1305 .conn
1306 .query_row(
1307 "SELECT COUNT(*) FROM sqlite_master
1308 WHERE type = 'index'
1309 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
1310 [],
1311 |r| r.get(0),
1312 )
1313 .unwrap();
1314 assert_eq!(index_count, 2, "F75 indexes must be created");
1315
1316 let plan = explain_query_plan(
1319 &store.conn,
1320 "SELECT id FROM approvals WHERE user_decision IS NULL ORDER BY created_at DESC",
1321 );
1322 assert!(
1323 plan.contains("idx_approvals_pending"),
1324 "pending scan must use idx_approvals_pending; plan was:\n{plan}"
1325 );
1326
1327 let plan = explain_query_plan(
1329 &store.conn,
1330 "SELECT id FROM tasks WHERE status = 'running' AND owner_kind = 'daemon'",
1331 );
1332 assert!(
1333 plan.contains("idx_tasks_status_owner"),
1334 "reconcile scan must use idx_tasks_status_owner; plan was:\n{plan}"
1335 );
1336
1337 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1338 }
1339
1340 #[test]
1341 pub(crate) fn upgrades_from_v2_to_current_and_adds_indexes() {
1342 let path = temp_db("upgrade_v2");
1347 {
1348 let store = RuntimeStore::open(&path).expect("first open");
1349 store
1351 .conn
1352 .execute_batch(
1353 "DROP INDEX IF EXISTS idx_approvals_pending;
1354 DROP INDEX IF EXISTS idx_tasks_status_owner;
1355 PRAGMA user_version = 2;",
1356 )
1357 .expect("downgrade to v2");
1358 }
1359 let store = RuntimeStore::open(&path).expect("reopen must migrate forward");
1360 let version: i32 = store
1361 .conn
1362 .query_row("PRAGMA user_version", [], |r| r.get(0))
1363 .unwrap();
1364 assert_eq!(version, SCHEMA_VERSION);
1365 let index_count: i64 = store
1366 .conn
1367 .query_row(
1368 "SELECT COUNT(*) FROM sqlite_master
1369 WHERE type = 'index'
1370 AND name IN ('idx_approvals_pending', 'idx_tasks_status_owner')",
1371 [],
1372 |r| r.get(0),
1373 )
1374 .unwrap();
1375 assert_eq!(
1376 index_count, 2,
1377 "forward migration must recreate the F75 indexes"
1378 );
1379 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1380 }
1381
1382 fn downgrade_schema_to(conn: &Connection, version: i32) {
1391 if version < 6 {
1394 conn.execute_batch(
1395 "DROP INDEX IF EXISTS idx_checkpoints_session;
1396 ALTER TABLE checkpoints DROP COLUMN session_id;
1397 ALTER TABLE checkpoints DROP COLUMN message_index;",
1398 )
1399 .expect("undo v6");
1400 }
1401 if version < 5 {
1402 conn.execute_batch("ALTER TABLE tasks DROP COLUMN prompt;")
1403 .expect("undo v5");
1404 }
1405 if version < 4 {
1406 conn.execute_batch("DROP TABLE IF EXISTS outcomes;")
1407 .expect("undo v4");
1408 }
1409 if version < 3 {
1410 conn.execute_batch(
1411 "DROP INDEX IF EXISTS idx_approvals_pending;
1412 DROP INDEX IF EXISTS idx_tasks_status_owner;",
1413 )
1414 .expect("undo v3");
1415 }
1416 if version < 2 {
1417 conn.execute_batch(
1418 "DROP INDEX IF EXISTS idx_tasks_status_owner;
1419 ALTER TABLE tasks DROP COLUMN owner_kind;",
1420 )
1421 .expect("undo v2");
1422 }
1423 conn.execute_batch(&format!("PRAGMA user_version = {version};"))
1424 .expect("restamp");
1425 }
1426
1427 #[test]
1442 pub(crate) fn every_supported_older_version_upgrades_to_current() {
1443 for version in 0..SCHEMA_VERSION {
1444 let path = temp_db(&format!("upgrade_from_v{version}"));
1445 {
1446 let store = RuntimeStore::open(&path).expect("first open");
1447 downgrade_schema_to(&store.conn, version);
1448 }
1449
1450 let store = RuntimeStore::open(&path)
1451 .unwrap_or_else(|e| panic!("a v{version} DB must upgrade, but: {e:#}"));
1452
1453 let stamped: i32 = store
1454 .conn
1455 .query_row("PRAGMA user_version", [], |r| r.get(0))
1456 .expect("read user_version");
1457 assert_eq!(
1458 stamped, SCHEMA_VERSION,
1459 "v{version} upgraded without stamping the current version"
1460 );
1461
1462 let indexes: i64 = store
1465 .conn
1466 .query_row(
1467 "SELECT COUNT(*) FROM sqlite_master
1468 WHERE type = 'index'
1469 AND name IN ('idx_tasks_status_owner', 'idx_approvals_pending',
1470 'idx_checkpoints_session')",
1471 [],
1472 |r| r.get(0),
1473 )
1474 .expect("count indexes");
1475 assert_eq!(indexes, 3, "v{version} upgrade left indexes missing");
1476
1477 store
1482 .tasks()
1483 .create(NewTask {
1484 title: "migrated".to_string(),
1485 project_path: "/p".to_string(),
1486 model_id: "m".to_string(),
1487 priority: TaskPriority::Normal,
1488 conversation_id: None,
1489 owner_kind: Some("daemon".to_string()),
1490 prompt: None,
1491 })
1492 .unwrap_or_else(|e| panic!("v{version} upgraded DB must accept writes: {e:#}"));
1493
1494 let _ = std::fs::remove_dir_all(path.parent().expect("temp dir"));
1495 }
1496 }
1497
1498 #[test]
1499 pub(crate) fn task_create_commits_task_and_event_atomically() {
1500 let path = temp_db("task_txn");
1502 let store = RuntimeStore::open(&path).expect("open");
1503 let task = store
1504 .tasks()
1505 .create(NewTask::new("do a thing", "/repo", "anthropic/claude"))
1506 .expect("create task");
1507 let events = store.tasks().events(&task.id).expect("events");
1508 assert!(
1509 events.iter().any(|e| e.kind == "task_created"),
1510 "the task_created event must commit with the task row"
1511 );
1512 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1513 }
1514
1515 #[test]
1516 pub(crate) fn task_lifecycle_round_trips() {
1517 let path = temp_db("task");
1518 let store = RuntimeStore::open(&path).expect("open store");
1519 let session = store
1520 .sessions()
1521 .upsert(NewSession {
1522 id: Some("session-1".to_string()),
1523 project_path: "/repo".to_string(),
1524 model_id: "anthropic/claude".to_string(),
1525 title: Some("Run tests".to_string()),
1526 conversation_path: Some("/repo/.mermaid/session.json".to_string()),
1527 total_tokens: Some(42),
1528 })
1529 .expect("upsert session");
1530 assert_eq!(session.id, "session-1");
1531 let message = store
1532 .messages()
1533 .add(NewMessage {
1534 session_id: session.id.clone(),
1535 role: "user".to_string(),
1536 content_json: "{\"text\":\"hi\"}".to_string(),
1537 })
1538 .expect("add message");
1539 assert_eq!(message.role, "user");
1540 assert_eq!(
1541 store
1542 .messages()
1543 .list_for_session(&session.id)
1544 .unwrap()
1545 .len(),
1546 1
1547 );
1548
1549 let mut new = NewTask::new("Run tests", "/repo", "anthropic/claude");
1550 new.priority = TaskPriority::High;
1551 let task = store.tasks().create(new).expect("create task");
1552
1553 assert_eq!(task.status, TaskStatus::Queued);
1554 assert_eq!(task.priority, TaskPriority::High);
1555
1556 store
1557 .tasks()
1558 .update_status(&task.id, TaskStatus::Completed, Some("tests passed"))
1559 .expect("update task");
1560 let loaded = store.tasks().get(&task.id).unwrap().unwrap();
1561 assert_eq!(loaded.status, TaskStatus::Completed);
1562 assert_eq!(loaded.final_report.as_deref(), Some("tests passed"));
1563
1564 let events = store.tasks().events(&task.id).expect("events");
1565 assert_eq!(events.len(), 2);
1566 assert_eq!(events[0].kind, "task_created");
1567 assert_eq!(events[1].kind, "status_changed");
1568 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1569 }
1570
1571 #[test]
1572 pub(crate) fn approval_and_process_records_round_trip() {
1573 let path = temp_db("approval_process");
1574 let store = RuntimeStore::open(&path).expect("open store");
1575 let task = store
1576 .tasks()
1577 .create(NewTask::new("Edit files", "/repo", "openai/gpt-5.2"))
1578 .expect("create task");
1579
1580 let approval = store
1581 .approvals()
1582 .create(NewApproval {
1583 task_id: Some(task.id.clone()),
1584 proposed_action: "write_file src/lib.rs".to_string(),
1585 risk_classification: "file_mutation".to_string(),
1586 policy_decision: "ask".to_string(),
1587 args_summary: Some("src/lib.rs".to_string()),
1588 checkpoint_id: Some("checkpoint-1".to_string()),
1589 pending_action_json: Some(
1590 "{\"tool\":\"write_file\",\"args\":{\"path\":\"src/lib.rs\"}}".to_string(),
1591 ),
1592 })
1593 .expect("create approval");
1594 store
1595 .approvals()
1596 .decide(&approval.id, "approved")
1597 .expect("decide approval");
1598 let approval = store.approvals().get(&approval.id).unwrap().unwrap();
1599 assert_eq!(approval.user_decision.as_deref(), Some("approved"));
1600 assert!(approval.pending_action_json.is_some());
1601
1602 let tool_run = store
1603 .tool_runs()
1604 .start(NewToolRun {
1605 id: Some("toolrun-1".to_string()),
1606 task_id: Some(task.id.clone()),
1607 turn_id: Some("turn-1".to_string()),
1608 call_id: Some("call-1".to_string()),
1609 tool_name: "write_file".to_string(),
1610 args_json: Some("{\"path\":\"src/lib.rs\"}".to_string()),
1611 })
1612 .expect("start tool run");
1613 assert_eq!(tool_run.status, "running");
1614 store
1615 .tool_runs()
1616 .finish("toolrun-1", "success", Some("{\"summary\":\"ok\"}"))
1617 .expect("finish tool run");
1618 let tool_run = store.tool_runs().get("toolrun-1").unwrap().unwrap();
1619 assert_eq!(tool_run.status, "success");
1620 assert!(tool_run.finished_at.is_some());
1621
1622 let process = store
1623 .processes()
1624 .upsert(NewProcess {
1625 id: Some("proc-1".to_string()),
1626 task_id: Some(task.id),
1627 pid: 123,
1628 command: "npm run dev".to_string(),
1629 cwd: Some("/repo".to_string()),
1630 log_path: Some("/tmp/mermaid.log".to_string()),
1631 detected_url: Some("http://127.0.0.1:5173".to_string()),
1632 status: ProcessStatus::Running,
1633 health: Some("ready".to_string()),
1634 })
1635 .expect("upsert process");
1636 assert_eq!(process.status, ProcessStatus::Running);
1637 assert_eq!(store.processes().list(10).unwrap().len(), 1);
1638 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1639 }
1640
1641 #[test]
1642 pub(crate) fn approval_decide_is_single_shot() {
1643 let path = temp_db("approval_decide_guard");
1644 let store = RuntimeStore::open(&path).expect("open store");
1645 let make = |action: &str| {
1646 store
1647 .approvals()
1648 .create(NewApproval {
1649 task_id: None,
1650 proposed_action: action.to_string(),
1651 risk_classification: "file_mutation".to_string(),
1652 policy_decision: "ask".to_string(),
1653 args_summary: None,
1654 checkpoint_id: None,
1655 pending_action_json: None,
1656 })
1657 .expect("create approval")
1658 };
1659
1660 let a = make("write_file a");
1663 store
1664 .approvals()
1665 .decide(&a.id, "approved")
1666 .expect("first decide");
1667 assert!(
1668 store.approvals().decide(&a.id, "approved").is_err(),
1669 "re-approving an approved approval must be rejected"
1670 );
1671
1672 let b = make("write_file b");
1674 store.approvals().decide(&b.id, "denied").expect("deny");
1675 assert!(
1676 store.approvals().decide(&b.id, "approved").is_err(),
1677 "a denied approval must not be re-decidable as approved"
1678 );
1679 let reloaded = store.approvals().get(&b.id).unwrap().unwrap();
1680 assert_eq!(reloaded.user_decision.as_deref(), Some("denied"));
1681
1682 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1683 }
1684
1685 #[test]
1686 pub(crate) fn archived_approvals_and_checkpoints_are_hidden_from_visible_lists() {
1687 let path = temp_db("archive_visibility");
1688 let store = RuntimeStore::open(&path).expect("open store");
1689
1690 let approval = store
1691 .approvals()
1692 .create(NewApproval {
1693 task_id: None,
1694 proposed_action: "restore replay: write_file".to_string(),
1695 risk_classification: "restored_action".to_string(),
1696 policy_decision: "ask".to_string(),
1697 args_summary: None,
1698 checkpoint_id: Some("checkpoint-1".to_string()),
1699 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1700 })
1701 .expect("create approval");
1702 let checkpoint = store
1703 .checkpoints()
1704 .create(NewCheckpoint {
1705 id: Some("checkpoint-1".to_string()),
1706 task_id: None,
1707 project_path: "/tmp/mermaid_checkpoint_test".to_string(),
1708 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
1709 changed_files_json: "[]".to_string(),
1710 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1711 approval_id: Some(approval.id.clone()),
1712 session_id: None,
1713 message_index: None,
1714 })
1715 .expect("create checkpoint");
1716
1717 assert_eq!(store.approvals().list_pending().unwrap().len(), 1);
1718 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
1719 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
1720 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
1721 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
1722
1723 assert_eq!(
1724 store
1725 .approvals()
1726 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
1727 .unwrap(),
1728 1
1729 );
1730 assert_eq!(
1731 store
1732 .checkpoints()
1733 .archive(std::slice::from_ref(&checkpoint.id), "runtime hygiene")
1734 .unwrap(),
1735 1
1736 );
1737 assert_eq!(
1738 store
1739 .approvals()
1740 .archive(std::slice::from_ref(&approval.id), "runtime hygiene")
1741 .unwrap(),
1742 0
1743 );
1744 assert_eq!(store.approvals().list_pending().unwrap().len(), 0);
1745 assert_eq!(store.approvals().list_pending_all().unwrap().len(), 1);
1746 assert_eq!(store.approvals().list_all(10).unwrap().len(), 1);
1747 assert_eq!(store.approvals().count_archived().unwrap(), 1);
1748 assert_eq!(store.checkpoints().list(10).unwrap().len(), 0);
1749 assert_eq!(store.checkpoints().list_all(10).unwrap().len(), 1);
1750 assert_eq!(store.checkpoints().count_archived().unwrap(), 1);
1751
1752 let archived = store.approvals().get(&approval.id).unwrap().unwrap();
1753 assert!(archived.archived_at.is_some());
1754 assert_eq!(archived.archive_reason.as_deref(), Some("runtime hygiene"));
1755 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1756 }
1757
1758 #[test]
1759 pub(crate) fn checkpoint_compaction_plugin_probe_and_pairing_round_trip() {
1760 let path = temp_db("everything_else");
1761 let store = RuntimeStore::open(&path).expect("open store");
1762
1763 let checkpoint = store
1764 .checkpoints()
1765 .create(NewCheckpoint {
1766 id: Some("checkpoint-1".to_string()),
1767 task_id: None,
1768 project_path: "/repo".to_string(),
1769 snapshot_path: "/data/checkpoints/checkpoint-1".to_string(),
1770 changed_files_json: "[\"src/lib.rs\"]".to_string(),
1771 pending_action_json: Some("{\"tool\":\"write_file\"}".to_string()),
1772 approval_id: None,
1773 session_id: None,
1774 message_index: None,
1775 })
1776 .expect("create checkpoint");
1777 assert_eq!(checkpoint.id, "checkpoint-1");
1778 assert_eq!(store.checkpoints().list(10).unwrap().len(), 1);
1779
1780 let compaction = store
1781 .compactions()
1782 .create(NewCompaction {
1783 id: Some("compaction-1".to_string()),
1784 task_id: None,
1785 session_id: Some("session-1".to_string()),
1786 source_token_estimate: Some(10_000),
1787 summary_token_count: Some(800),
1788 preserved_turns: Some(6),
1789 archive_path: Some(".mermaid/compactions/session-1/compaction-1.json".to_string()),
1790 verification_status: Some("verified".to_string()),
1791 })
1792 .expect("create compaction");
1793 assert_eq!(compaction.summary_token_count, Some(800));
1794 assert_eq!(store.compactions().list(10).unwrap().len(), 1);
1795
1796 let plugin = store
1797 .plugins()
1798 .install(NewPluginInstall {
1799 id: Some("plugin-1".to_string()),
1800 name: "example".to_string(),
1801 source: "local".to_string(),
1802 version: Some("0.1.0".to_string()),
1803 enabled: true,
1804 manifest_json: "{\"name\":\"example\"}".to_string(),
1805 })
1806 .expect("install plugin");
1807 assert!(plugin.enabled);
1808 store.plugins().set_enabled("plugin-1", false).unwrap();
1809 assert!(!store.plugins().get("plugin-1").unwrap().unwrap().enabled);
1810
1811 let probe = store
1812 .provider_probes()
1813 .upsert(NewProviderProbe {
1814 provider: "cerebras".to_string(),
1815 model_id: "gpt-oss-120b".to_string(),
1816 capability_key: "parallel_tool_calls".to_string(),
1817 capability_value: "false".to_string(),
1818 confidence: "static".to_string(),
1819 error: None,
1820 })
1821 .expect("probe");
1822 assert_eq!(probe.confidence, "static");
1823 assert_eq!(
1824 store
1825 .provider_probes()
1826 .list(Some("cerebras"), Some("gpt-oss-120b"))
1827 .unwrap()
1828 .len(),
1829 1
1830 );
1831
1832 let pairing = store
1833 .pairing_tokens()
1834 .create("hash", Some("phone"), None)
1835 .expect("pairing");
1836 store.pairing_tokens().mark_used(&pairing.id).unwrap();
1837 assert!(
1838 store
1839 .pairing_tokens()
1840 .get(&pairing.id)
1841 .unwrap()
1842 .unwrap()
1843 .last_used_at
1844 .is_some()
1845 );
1846 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1847 }
1848
1849 #[test]
1850 pub(crate) fn pairing_token_expiry_and_revoke() {
1851 let path = temp_db("pairing_ttl");
1852 let store = RuntimeStore::open(&path).expect("open store");
1853 let tokens = store.pairing_tokens();
1854
1855 let live = tokens
1857 .create("live_hash", Some("a"), None)
1858 .expect("create live");
1859 assert!(tokens.verify_token("live_hash").unwrap().is_some());
1860
1861 let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
1863 tokens
1864 .create("future_hash", None, Some(&future))
1865 .expect("create future");
1866 assert!(tokens.verify_token("future_hash").unwrap().is_some());
1867
1868 let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
1869 tokens
1870 .create("past_hash", None, Some(&past))
1871 .expect("create past");
1872 assert!(
1873 tokens.verify_token("past_hash").unwrap().is_none(),
1874 "an expired token must not verify"
1875 );
1876
1877 let skewed = (chrono::Utc::now() + chrono::Duration::hours(1))
1881 .with_timezone(&chrono::FixedOffset::west_opt(3 * 3600).unwrap())
1882 .to_rfc3339();
1883 tokens
1884 .create("skew_hash", None, Some(&skewed))
1885 .expect("create skewed");
1886 assert!(
1887 tokens.verify_token("skew_hash").unwrap().is_some(),
1888 "a future token in a non-UTC offset must verify (parsed-instant compare)"
1889 );
1890
1891 tokens
1893 .create("garbage_hash", None, Some("not-a-timestamp"))
1894 .expect("create garbage");
1895 assert!(
1896 tokens.verify_token("garbage_hash").unwrap().is_none(),
1897 "an unparseable expiry must fail closed"
1898 );
1899
1900 assert!(tokens.revoke(&live.id).unwrap());
1902 assert!(tokens.verify_token("live_hash").unwrap().is_none());
1903 assert!(
1904 !tokens.revoke(&live.id).unwrap(),
1905 "double revoke is a no-op"
1906 );
1907
1908 assert!(tokens.verify_token("nope").unwrap().is_none());
1910
1911 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1912 }
1913
1914 #[test]
1915 pub(crate) fn ct_eq_matches_only_identical_bytes() {
1916 assert!(ct_eq(b"abc", b"abc"));
1917 assert!(!ct_eq(b"abc", b"abd"));
1918 assert!(!ct_eq(b"abc", b"ab"));
1919 assert!(!ct_eq(b"", b"x"));
1920 assert!(ct_eq(b"", b""));
1921 }
1922
1923 #[test]
1924 pub(crate) fn fresh_id_is_collision_free_in_tight_loop() {
1925 let mut seen = std::collections::HashSet::new();
1928 for _ in 0..10_000 {
1929 let id = fresh_id("process");
1930 assert!(id.starts_with("process-"), "id must keep prefix: {id}");
1931 assert!(seen.insert(id), "fresh_id produced a duplicate");
1932 }
1933 }
1934
1935 #[test]
1936 pub(crate) fn tool_run_repository_redacts_arguments_and_outcomes() {
1937 let path = temp_db("persistence_redaction");
1938 let store = RuntimeStore::open(&path).expect("open store");
1939 let run = store
1940 .tool_runs()
1941 .start(NewToolRun {
1942 id: Some("toolrun-redacted".to_string()),
1943 task_id: None,
1944 turn_id: None,
1945 call_id: None,
1946 tool_name: "web_fetch".to_string(),
1947 args_json: Some(
1948 serde_json::json!({
1949 "url": "https://user:password@example.test/a?X-Goog-Credential=opaque-id&X-Goog-Signature=opaque-signature#fragment",
1950 "password": "abc",
1951 "token": 12345,
1952 "nested": { "client_secret": true }
1953 })
1954 .to_string(),
1955 ),
1956 })
1957 .expect("start tool run");
1958 store
1959 .tool_runs()
1960 .finish(
1961 &run.id,
1962 "success",
1963 Some(
1964 &serde_json::json!({
1965 "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-----"
1966 })
1967 .to_string(),
1968 ),
1969 )
1970 .expect("finish tool run");
1971 let persisted = store.tool_runs().get(&run.id).unwrap().unwrap();
1972 let args: serde_json::Value =
1973 serde_json::from_str(persisted.args_json.as_deref().unwrap()).unwrap();
1974 assert_eq!(args["password"], "[REDACTED]");
1975 assert_eq!(args["token"], "[REDACTED]");
1976 assert_eq!(args["nested"]["client_secret"], "[REDACTED]");
1977 let combined = format!("{:?}{:?}", persisted.args_json, persisted.output_json);
1978 for secret in [
1979 "user",
1980 "opaque-signature",
1981 "opaque-id",
1982 "fragment",
1983 "sk-abcdefghijklmnop1234",
1984 "password=abc",
1985 "Bearer xyz",
1986 "dXNlcjphYmM=",
1987 "sk-zyxwvutsrqponmlk9876",
1988 "cHJpdmF0ZS1tYXRlcmlhbA==",
1989 "-----END PRIVATE KEY-----",
1990 "12345",
1991 ] {
1992 assert!(
1993 !combined.contains(secret),
1994 "tool run leaked {secret}: {combined}"
1995 );
1996 }
1997
1998 let _ = std::fs::remove_dir_all(path.parent().unwrap());
1999 }
2000
2001 #[test]
2002 pub(crate) fn ensure_column_rejects_non_identifier() {
2003 let path = temp_db("ensure_col");
2004 let store = RuntimeStore::open(&path).expect("open store");
2005 assert!(ensure_column(&store.conn, "approvals; DROP", "x", "TEXT").is_err());
2006 assert!(ensure_column(&store.conn, "approvals", "x-y", "TEXT").is_err());
2007 assert!(ensure_column(&store.conn, "approvals", "x", "TEXT; DROP").is_err());
2008 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2009 }
2010
2011 #[test]
2012 pub(crate) fn clamp_limit_never_binds_negative() {
2013 assert_eq!(clamp_limit(10), 10);
2016 assert_eq!(clamp_limit(usize::MAX), MAX_QUERY_LIMIT as i64);
2017 assert!(clamp_limit(usize::MAX) > 0);
2018 }
2019
2020 pub(crate) fn make_approval(store: &RuntimeStore, action: &str) -> ApprovalRecord {
2021 store
2022 .approvals()
2023 .create(NewApproval {
2024 task_id: None,
2025 proposed_action: action.to_string(),
2026 risk_classification: "shell_mutation".to_string(),
2027 policy_decision: "ask".to_string(),
2028 args_summary: None,
2029 checkpoint_id: None,
2030 pending_action_json: None,
2031 })
2032 .expect("create approval")
2033 }
2034
2035 #[test]
2036 pub(crate) fn approval_claim_is_single_winner_releasable_and_finalizable() {
2037 let path = temp_db("approval_claim");
2040 let store = RuntimeStore::open(&path).expect("open store");
2041 let a = make_approval(&store, "write_file a");
2042
2043 assert!(store.approvals().claim(&a.id).unwrap(), "first claim wins");
2044 assert!(
2045 !store.approvals().claim(&a.id).unwrap(),
2046 "second claim loses"
2047 );
2048
2049 store.approvals().release_claim(&a.id).unwrap();
2050 assert!(
2051 store.approvals().claim(&a.id).unwrap(),
2052 "a released claim is re-claimable (effect-failed path)"
2053 );
2054
2055 store
2056 .approvals()
2057 .finalize_claimed(&a.id, "approved")
2058 .unwrap();
2059 assert_eq!(
2060 store
2061 .approvals()
2062 .get(&a.id)
2063 .unwrap()
2064 .unwrap()
2065 .user_decision
2066 .as_deref(),
2067 Some("approved")
2068 );
2069 assert!(
2070 !store.approvals().claim(&a.id).unwrap(),
2071 "a decided approval cannot be claimed"
2072 );
2073 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2074 }
2075
2076 #[test]
2077 pub(crate) fn reconcile_after_restart_recovers_running_tasks_and_claims() {
2078 let path = temp_db("reconcile");
2081 let store = RuntimeStore::open(&path).expect("open store");
2082 let task = store
2083 .tasks()
2084 .create(NewTask::new("t", "/repo", "m").daemon_owned())
2085 .expect("create task");
2086 store
2087 .tasks()
2088 .update_status(&task.id, TaskStatus::Running, None)
2089 .expect("mark running");
2090 let appr = make_approval(&store, "git push");
2091 assert!(store.approvals().claim(&appr.id).unwrap());
2092
2093 let (tasks, claims) = store.reconcile_after_restart().expect("reconcile");
2094 assert_eq!((tasks, claims), (1, 1));
2095 assert_eq!(
2096 store.tasks().get(&task.id).unwrap().unwrap().status,
2097 TaskStatus::Failed
2098 );
2099 assert!(
2100 store
2101 .approvals()
2102 .get(&appr.id)
2103 .unwrap()
2104 .unwrap()
2105 .user_decision
2106 .is_none(),
2107 "a released claim is undecided and re-runnable"
2108 );
2109 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2110 }
2111
2112 #[test]
2113 pub(crate) fn reconcile_after_restart_spares_non_daemon_running_tasks() {
2114 let path = temp_db("reconcile_spare_cli");
2118 let store = RuntimeStore::open(&path).expect("open store");
2119
2120 let cli = store
2121 .tasks()
2122 .create(NewTask::new("cli run", "/repo", "m")) .expect("create cli task");
2124 store
2125 .tasks()
2126 .update_status(&cli.id, TaskStatus::Running, None)
2127 .expect("mark cli running");
2128 let daemon = store
2129 .tasks()
2130 .create(NewTask::new("daemon run", "/repo", "m").daemon_owned())
2131 .expect("create daemon task");
2132 store
2133 .tasks()
2134 .update_status(&daemon.id, TaskStatus::Running, None)
2135 .expect("mark daemon running");
2136
2137 let (tasks, _claims) = store.reconcile_after_restart().expect("reconcile");
2138 assert_eq!(tasks, 1, "only the daemon-owned task is reset");
2139 assert_eq!(
2140 store.tasks().get(&cli.id).unwrap().unwrap().status,
2141 TaskStatus::Running,
2142 "a live CLI task must NOT be clobbered by the daemon's reconcile"
2143 );
2144 assert_eq!(
2145 store.tasks().get(&daemon.id).unwrap().unwrap().status,
2146 TaskStatus::Failed,
2147 "a stranded daemon task is still recovered"
2148 );
2149 assert!(
2151 !store
2152 .tasks()
2153 .events(&cli.id)
2154 .unwrap()
2155 .iter()
2156 .any(|e| e.kind == "interrupted"),
2157 "the spared task must not receive a spurious interrupted event"
2158 );
2159 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2160 }
2161
2162 #[test]
2163 pub(crate) fn gc_prunes_old_archived_but_keeps_active() {
2164 let path = temp_db("gc");
2167 let store = RuntimeStore::open(&path).expect("open store");
2168 let keep = make_approval(&store, "active");
2169 let gone = make_approval(&store, "old archived");
2170 store
2171 .approvals()
2172 .archive(std::slice::from_ref(&gone.id), "test")
2173 .expect("archive");
2174 store
2176 .conn
2177 .execute(
2178 "UPDATE approvals SET archived_at = ?2 WHERE id = ?1",
2179 params![gone.id, "2000-01-01T00:00:00+00:00"],
2180 )
2181 .unwrap();
2182
2183 let removed = store.gc(30, 180).expect("gc");
2184 assert!(removed >= 1, "the old archived approval should be pruned");
2185 assert!(
2186 store.approvals().get(&gone.id).unwrap().is_none(),
2187 "old archived row removed"
2188 );
2189 assert!(
2190 store.approvals().get(&keep.id).unwrap().is_some(),
2191 "active row kept"
2192 );
2193 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2194 }
2195
2196 #[test]
2197 pub(crate) fn gc_prunes_outcomes_and_terminal_tasks_on_their_windows() {
2198 let path = temp_db("gc_outcomes");
2204 let store = RuntimeStore::open(&path).expect("open store");
2205 let old = "2000-01-01T00:00:00+00:00"; let live = store
2209 .tasks()
2210 .create(NewTask::new("live", "/repo", "m"))
2211 .expect("live task");
2212
2213 let done = store
2215 .tasks()
2216 .create(NewTask::new("done", "/repo", "m"))
2217 .expect("done task");
2218 store
2219 .tasks()
2220 .update_status(&done.id, TaskStatus::Completed, Some("ok"))
2221 .expect("finish task");
2222 store
2223 .conn
2224 .execute(
2225 "UPDATE tasks SET updated_at = ?2 WHERE id = ?1",
2226 params![done.id, old],
2227 )
2228 .unwrap();
2229
2230 let kept_outcome = store
2233 .outcomes()
2234 .record(NewOutcome {
2235 id: None,
2236 task_id: Some(done.id.clone()),
2237 tool_run_id: None,
2238 kind: "task_terminal".to_string(),
2239 label: OUTCOME_LABEL_SUCCESS.to_string(),
2240 reward: Some(1.0),
2241 source: OUTCOME_SOURCE_SYSTEM.to_string(),
2242 detail_json: Some("{\"prompt\":\"do the thing\"}".to_string()),
2243 })
2244 .expect("record kept outcome");
2245
2246 let gone_outcome = store
2248 .outcomes()
2249 .record(NewOutcome {
2250 id: None,
2251 task_id: None,
2252 tool_run_id: None,
2253 kind: "task_terminal".to_string(),
2254 label: OUTCOME_LABEL_FAILURE.to_string(),
2255 reward: Some(-1.0),
2256 source: OUTCOME_SOURCE_SYSTEM.to_string(),
2257 detail_json: None,
2258 })
2259 .expect("record gone outcome");
2260 store
2261 .conn
2262 .execute(
2263 "UPDATE outcomes SET created_at = ?2 WHERE id = ?1",
2264 params![gone_outcome.id, old],
2265 )
2266 .unwrap();
2267
2268 store.gc(30, 180).expect("gc");
2269
2270 assert!(
2271 store.tasks().get(&live.id).unwrap().is_some(),
2272 "a live (queued) task must survive gc"
2273 );
2274 assert!(
2275 store.tasks().get(&done.id).unwrap().is_none(),
2276 "an old terminal task must be pruned"
2277 );
2278 let kept = store
2279 .outcomes()
2280 .get(&kept_outcome.id)
2281 .unwrap()
2282 .expect("the recent outcome must survive gc");
2283 assert!(
2284 kept.task_id.is_none(),
2285 "the pruned task's link is nulled (ON DELETE SET NULL)"
2286 );
2287 assert_eq!(
2288 kept.detail_json.as_deref(),
2289 Some("{\"prompt\":\"do the thing\"}"),
2290 "the denormalized training context must survive the task prune"
2291 );
2292 assert!(
2293 store.outcomes().get(&gone_outcome.id).unwrap().is_none(),
2294 "an outcome past the outcomes window must be pruned"
2295 );
2296
2297 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2298 }
2299
2300 #[test]
2301 #[expect(
2302 clippy::too_many_lines,
2303 reason = "predates the lint; see .github/baselines/expect_budget.txt"
2304 )]
2305 pub(crate) fn gc_prunes_high_churn_and_old_terminal_rows_but_keeps_active() {
2306 let path = temp_db("gc_high_churn");
2310 let store = RuntimeStore::open(&path).expect("open store");
2311 let old = "2000-01-01T00:00:00+00:00";
2312
2313 let stale_session = store
2315 .sessions()
2316 .upsert(NewSession {
2317 id: Some("stale".to_string()),
2318 project_path: "/repo".to_string(),
2319 model_id: "m".to_string(),
2320 title: None,
2321 conversation_path: None,
2322 total_tokens: None,
2323 })
2324 .expect("stale session");
2325 store
2326 .messages()
2327 .add(NewMessage {
2328 session_id: stale_session.id.clone(),
2329 role: "user".to_string(),
2330 content_json: "{}".to_string(),
2331 })
2332 .expect("stale message");
2333 let active_session = store
2334 .sessions()
2335 .upsert(NewSession {
2336 id: Some("active".to_string()),
2337 project_path: "/repo".to_string(),
2338 model_id: "m".to_string(),
2339 title: None,
2340 conversation_path: None,
2341 total_tokens: None,
2342 })
2343 .expect("active session");
2344 store
2345 .messages()
2346 .add(NewMessage {
2347 session_id: active_session.id.clone(),
2348 role: "user".to_string(),
2349 content_json: "{}".to_string(),
2350 })
2351 .expect("active message");
2352 store
2353 .conn
2354 .execute(
2355 "UPDATE sessions SET updated_at = ?2 WHERE id = ?1",
2356 params![stale_session.id, old],
2357 )
2358 .unwrap();
2359
2360 store
2362 .tool_runs()
2363 .start(NewToolRun {
2364 id: Some("tr-finished".to_string()),
2365 task_id: None,
2366 turn_id: None,
2367 call_id: None,
2368 tool_name: "x".to_string(),
2369 args_json: None,
2370 })
2371 .expect("start finished tr");
2372 store
2373 .tool_runs()
2374 .finish("tr-finished", "success", None)
2375 .expect("finish tr");
2376 store
2377 .conn
2378 .execute(
2379 "UPDATE tool_runs SET finished_at = ?2 WHERE id = ?1",
2380 params!["tr-finished", old],
2381 )
2382 .unwrap();
2383 store
2384 .tool_runs()
2385 .start(NewToolRun {
2386 id: Some("tr-running".to_string()),
2387 task_id: None,
2388 turn_id: None,
2389 call_id: None,
2390 tool_name: "x".to_string(),
2391 args_json: None,
2392 })
2393 .expect("start running tr");
2394
2395 let exited = store
2397 .processes()
2398 .upsert(NewProcess {
2399 id: Some("p-exited".to_string()),
2400 task_id: None,
2401 pid: 1,
2402 command: "c".to_string(),
2403 cwd: None,
2404 log_path: None,
2405 detected_url: None,
2406 status: ProcessStatus::Exited,
2407 health: None,
2408 })
2409 .expect("exited process");
2410 store
2411 .conn
2412 .execute(
2413 "UPDATE processes SET updated_at = ?2 WHERE id = ?1",
2414 params![exited.id, old],
2415 )
2416 .unwrap();
2417 let running_proc = store
2418 .processes()
2419 .upsert(NewProcess {
2420 id: Some("p-running".to_string()),
2421 task_id: None,
2422 pid: 2,
2423 command: "c".to_string(),
2424 cwd: None,
2425 log_path: None,
2426 detected_url: None,
2427 status: ProcessStatus::Running,
2428 health: None,
2429 })
2430 .expect("running process");
2431
2432 let comp = store
2434 .compactions()
2435 .create(NewCompaction {
2436 id: Some("comp-old".to_string()),
2437 task_id: None,
2438 session_id: None,
2439 source_token_estimate: None,
2440 summary_token_count: None,
2441 preserved_turns: None,
2442 archive_path: None,
2443 verification_status: None,
2444 })
2445 .expect("compaction");
2446 store
2447 .conn
2448 .execute(
2449 "UPDATE compactions SET created_at = ?2 WHERE id = ?1",
2450 params![comp.id, old],
2451 )
2452 .unwrap();
2453
2454 let removed = store.gc(30, 180).expect("gc");
2455 assert!(removed >= 5, "stale rows pruned (got {removed})");
2456 assert!(
2457 store.sessions().get(&stale_session.id).unwrap().is_none(),
2458 "stale session gone"
2459 );
2460 assert!(
2461 store
2462 .messages()
2463 .list_for_session(&stale_session.id)
2464 .unwrap()
2465 .is_empty(),
2466 "stale messages gone"
2467 );
2468 assert!(
2469 store.sessions().get(&active_session.id).unwrap().is_some(),
2470 "active session kept"
2471 );
2472 assert_eq!(
2473 store
2474 .messages()
2475 .list_for_session(&active_session.id)
2476 .unwrap()
2477 .len(),
2478 1,
2479 "active message kept"
2480 );
2481 assert!(
2482 store.tool_runs().get("tr-finished").unwrap().is_none(),
2483 "old finished tool_run gone"
2484 );
2485 assert!(
2486 store.tool_runs().get("tr-running").unwrap().is_some(),
2487 "running tool_run kept"
2488 );
2489 assert!(
2490 store.processes().get(&exited.id).unwrap().is_none(),
2491 "old exited process gone"
2492 );
2493 assert!(
2494 store.processes().get(&running_proc.id).unwrap().is_some(),
2495 "running process kept"
2496 );
2497 assert!(
2498 store.compactions().get(&comp.id).unwrap().is_none(),
2499 "old compaction gone"
2500 );
2501 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2502 }
2503
2504 #[test]
2505 pub(crate) fn task_list_skips_undecodable_status_row() {
2506 let path = temp_db("poison_task");
2509 let store = RuntimeStore::open(&path).expect("open store");
2510 let good = store
2511 .tasks()
2512 .create(NewTask::new("good", "/repo", "m"))
2513 .expect("create good task");
2514 store
2515 .conn
2516 .execute(
2517 "INSERT INTO tasks
2518 (id, title, status, priority, project_path, model_id, created_at, updated_at)
2519 VALUES ('poison', 't', 'from_the_future', 'normal', '/repo', 'm', ?1, ?1)",
2520 params![now_rfc3339()],
2521 )
2522 .unwrap();
2523 let listed = store.tasks().list(50).expect("list");
2524 assert_eq!(
2525 listed.len(),
2526 1,
2527 "the poison row is skipped, the good row remains"
2528 );
2529 assert_eq!(listed[0].id, good.id);
2530 assert!(store.tasks().get("poison").is_err(), "get() stays strict");
2532 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2533 }
2534
2535 #[test]
2536 pub(crate) fn checkpoint_delete_removes_row() {
2537 let path = temp_db("ckpt_delete");
2540 let store = RuntimeStore::open(&path).expect("open store");
2541 let ckpt = store
2542 .checkpoints()
2543 .create(NewCheckpoint {
2544 id: Some("ckpt-1".to_string()),
2545 task_id: None,
2546 project_path: "/repo".to_string(),
2547 snapshot_path: "/data/checkpoints/ckpt-1".to_string(),
2548 changed_files_json: "[]".to_string(),
2549 pending_action_json: None,
2550 approval_id: None,
2551 session_id: None,
2552 message_index: None,
2553 })
2554 .expect("create checkpoint");
2555 assert!(store.checkpoints().get(&ckpt.id).unwrap().is_some());
2556 assert!(store.checkpoints().delete(&ckpt.id).unwrap(), "row deleted");
2557 assert!(
2558 store.checkpoints().get(&ckpt.id).unwrap().is_none(),
2559 "row gone"
2560 );
2561 assert!(
2562 !store.checkpoints().delete(&ckpt.id).unwrap(),
2563 "second delete is a no-op"
2564 );
2565 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2566 }
2567
2568 #[test]
2569 pub(crate) fn list_for_session_caps_at_max_and_keeps_ascending_order() {
2570 let path = temp_db("session_cap");
2573 let store = RuntimeStore::open(&path).expect("open store");
2574 let session = store
2575 .sessions()
2576 .upsert(NewSession {
2577 id: Some("big".to_string()),
2578 project_path: "/repo".to_string(),
2579 model_id: "m".to_string(),
2580 title: None,
2581 conversation_path: None,
2582 total_tokens: None,
2583 })
2584 .expect("session");
2585 let total = MAX_SESSION_MESSAGES + 10;
2586 let now = now_rfc3339();
2587 let tx = store.conn.unchecked_transaction().unwrap();
2588 for i in 0..total {
2589 tx.execute(
2590 "INSERT INTO messages (session_id, role, content_json, created_at)
2591 VALUES (?1, 'user', ?2, ?3)",
2592 params![session.id, format!("{{\"n\":{i}}}"), now],
2593 )
2594 .unwrap();
2595 }
2596 tx.commit().unwrap();
2597 let listed = store
2598 .messages()
2599 .list_for_session(&session.id)
2600 .expect("list");
2601 assert_eq!(
2602 listed.len() as i64,
2603 MAX_SESSION_MESSAGES,
2604 "capped at the max"
2605 );
2606 assert!(
2607 listed.windows(2).all(|w| w[0].id < w[1].id),
2608 "ascending id order preserved across the capped tail"
2609 );
2610 let _ = std::fs::remove_dir_all(path.parent().unwrap());
2611 }
2612}