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