1use super::*;
2use rusqlite::OpenFlags;
3use std::collections::HashMap;
4
5const COMPATIBILITY_METADATA_VERSION: i64 = 30;
6
7pub(super) struct SchemaState {
8 pub(super) revision: i64,
9 pub(super) minimum_compatible: Option<i64>,
10}
11
12impl SchemaState {
13 pub(super) fn ensure_supported(&self) -> Result<()> {
14 self.ensure_supported_by(SCHEMA_VERSION)
15 }
16
17 pub(super) fn ensure_supported_by(&self, supported: i64) -> Result<()> {
18 let reason = if self.revision < supported {
19 StoreSchemaMismatchReason::NeedsMigration
20 } else if let Some(minimum_compatible) = self.minimum_compatible {
21 if minimum_compatible <= supported {
22 return Ok(());
23 }
24 StoreSchemaMismatchReason::Incompatible { minimum_compatible }
25 } else {
26 StoreSchemaMismatchReason::InvalidCompatibilityMetadata
27 };
28 Err(StoreSchemaMismatch {
29 found: self.revision,
30 supported,
31 reason,
32 }
33 .into())
34 }
35}
36
37pub(super) fn read_schema_state(connection: &Connection) -> Result<SchemaState> {
40 let snapshot = connection
41 .unchecked_transaction()
42 .context("start database compatibility snapshot")?;
43 let revision: i64 = snapshot
44 .query_row("PRAGMA user_version", [], |row| row.get(0))
45 .context("read database migration revision")?;
46 let minimum_compatible = if revision >= COMPATIBILITY_METADATA_VERSION {
47 let invalid = || StoreSchemaMismatch {
48 found: revision,
49 supported: SCHEMA_VERSION,
50 reason: StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
51 };
52 let (count, singleton, floor, recorded): (i64, Option<i64>, Option<i64>, Option<i64>) =
53 snapshot
54 .query_row(
55 "SELECT count(*), min(singleton), min(minimum_compatible_version),
56 (SELECT max(version) FROM schema_migrations)
57 FROM schema_compatibility",
58 [],
59 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
60 )
61 .map_err(|error| {
62 let structural = match &error {
66 rusqlite::Error::SqliteFailure(code, _) => {
67 code.code == rusqlite::ErrorCode::Unknown
68 }
69 _ => true,
70 };
71 let error = anyhow::Error::new(error);
72 if structural {
73 error.context(invalid())
74 } else {
75 error.context("read database compatibility metadata")
76 }
77 })?;
78 if count != 1
79 || singleton != Some(1)
80 || recorded != Some(revision)
81 || !floor
82 .is_some_and(|floor| (COMPATIBILITY_METADATA_VERSION..=revision).contains(&floor))
83 {
84 return Err(invalid().into());
85 }
86 floor
87 } else {
88 None
89 };
90 snapshot
91 .commit()
92 .context("finish database compatibility snapshot")?;
93 Ok(SchemaState {
94 revision,
95 minimum_compatible,
96 })
97}
98
99pub fn database_path() -> PathBuf {
100 data_dir().join("mj.sqlite3")
101}
102
103pub fn check_read_compatibility() -> Result<()> {
107 open_reader_strict(&database_path()).map(drop)
108}
109
110pub(super) fn open_writer(path: &Path) -> Result<Connection> {
116 let mut connection = open_writable(path)?;
117 connection.set_transaction_behavior(rusqlite::TransactionBehavior::Immediate);
118 Ok(connection)
119}
120
121fn open_writable(path: &Path) -> Result<Connection> {
122 if let Some(store) = path.parent() {
125 mj_core::config::ensure_may_control_store(store, "open this database for writing")?;
126 }
127 if let Some(parent) = path.parent() {
128 fs::create_dir_all(parent)
129 .with_context(|| format!("create Mjolnir data directory {}", parent.display()))?;
130 }
131 let connection = Connection::open(path)
132 .with_context(|| format!("open Mjolnir database {}", path.display()))?;
133 connection.busy_timeout(Duration::from_secs(5))?;
134 connection.execute_batch(
135 "PRAGMA foreign_keys = ON;
136 PRAGMA journal_mode = WAL;
137 PRAGMA synchronous = FULL;",
138 )?;
139 verify_schema_once(path, &connection)?;
140 committed::observe_connection(&connection, path)?;
141 Ok(connection)
142}
143
144pub(super) fn open(path: &Path) -> Result<Connection> {
145 open_writer(path)
146}
147
148#[cfg(not(test))]
152pub(super) fn open_reader(path: &Path) -> Result<Reader> {
153 open_reader_strict(path)
154}
155
156#[cfg(test)]
157pub(super) fn open_reader(path: &Path) -> Result<Reader> {
158 Ok(Reader {
164 connection: Some(open_writable(path)?),
165 home: None,
166 })
167}
168
169const IDLE_READERS_PER_STORE: usize = 8;
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182struct StoreIdentity {
183 #[cfg(unix)]
184 device: u64,
185 #[cfg(unix)]
186 inode: u64,
187}
188
189impl StoreIdentity {
190 fn of(path: &Path) -> Option<Self> {
191 let metadata = fs::metadata(path).ok()?;
192 #[cfg(unix)]
193 {
194 use std::os::unix::fs::MetadataExt as _;
195 Some(Self {
196 device: metadata.dev(),
197 inode: metadata.ino(),
198 })
199 }
200 #[cfg(not(unix))]
201 {
202 let _ = metadata;
203 Some(Self {})
204 }
205 }
206}
207
208struct IdleReader {
209 connection: Connection,
210 identity: StoreIdentity,
211}
212
213fn idle_readers() -> &'static Mutex<HashMap<PathBuf, Vec<IdleReader>>> {
215 static IDLE: OnceLock<Mutex<HashMap<PathBuf, Vec<IdleReader>>>> = OnceLock::new();
216 IDLE.get_or_init(Mutex::default)
217}
218
219#[cfg(test)]
220thread_local! {
221 static OPENED_READERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
223}
224
225pub(super) struct Reader {
228 connection: Option<Connection>,
229 home: Option<(PathBuf, StoreIdentity)>,
230}
231
232impl std::fmt::Debug for Reader {
233 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 formatter
235 .debug_struct("Reader")
236 .field("home", &self.home)
237 .finish_non_exhaustive()
238 }
239}
240
241impl std::ops::Deref for Reader {
242 type Target = Connection;
243
244 fn deref(&self) -> &Connection {
245 self.connection.as_ref().expect("reader connection is held")
246 }
247}
248
249impl std::ops::DerefMut for Reader {
250 fn deref_mut(&mut self) -> &mut Connection {
251 self.connection.as_mut().expect("reader connection is held")
252 }
253}
254
255impl Drop for Reader {
256 fn drop(&mut self) {
257 let (Some(connection), Some((path, identity))) = (self.connection.take(), self.home.take())
258 else {
259 return;
260 };
261 if !connection.is_autocommit() {
262 return;
263 }
264 let mut idle = idle_readers()
265 .lock()
266 .unwrap_or_else(PoisonError::into_inner);
267 let readers = idle.entry(path).or_default();
268 if readers.len() < IDLE_READERS_PER_STORE {
269 readers.push(IdleReader {
270 connection,
271 identity,
272 });
273 }
274 }
275}
276
277#[cfg_attr(test, allow(dead_code))]
283fn open_reader_strict(path: &Path) -> Result<Reader> {
284 let identity = StoreIdentity::of(path);
285 let reused = identity.and_then(|identity| {
286 let mut idle = idle_readers()
287 .lock()
288 .unwrap_or_else(PoisonError::into_inner);
289 let readers = idle.get_mut(path)?;
290 readers.retain(|reader| reader.identity == identity);
292 readers.pop().map(|reader| reader.connection)
293 });
294 let connection = match reused {
295 Some(connection) => connection,
296 None => {
297 let connection = Connection::open_with_flags(
298 path,
299 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
300 )
301 .with_context(|| format!("open Mjolnir database read-only {}", path.display()))?;
302 #[cfg(test)]
303 OPENED_READERS.with(|opened| opened.set(opened.get() + 1));
304 connection.busy_timeout(Duration::from_secs(5))?;
305 connection.execute_batch(
306 "PRAGMA foreign_keys = ON;
307 PRAGMA query_only = ON;",
308 )?;
309 connection
310 }
311 };
312 read_schema_state(&connection)?.ensure_supported()?;
313 Ok(Reader {
314 connection: Some(connection),
315 home: identity.map(|identity| (path.to_owned(), identity)),
316 })
317}
318
319fn verified_schemas() -> &'static Mutex<HashSet<PathBuf>> {
323 static VERIFIED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
324 VERIFIED.get_or_init(|| Mutex::new(HashSet::new()))
325}
326
327fn schema_cache_key(path: &Path) -> PathBuf {
330 let Some(parent) = path
331 .parent()
332 .filter(|parent| !parent.as_os_str().is_empty())
333 else {
334 return path.to_owned();
335 };
336 match (fs::canonicalize(parent), path.file_name()) {
337 (Ok(canonical), Some(name)) => canonical.join(name),
338 _ => path.to_owned(),
339 }
340}
341
342fn verify_schema_once(path: &Path, connection: &Connection) -> Result<()> {
347 let key = schema_cache_key(path);
348 let mut verified = verified_schemas()
349 .lock()
350 .unwrap_or_else(PoisonError::into_inner);
351 let state = read_schema_state(connection)?;
352 if state.revision > SCHEMA_VERSION
353 || (state.revision == SCHEMA_VERSION && verified.contains(&key))
354 {
355 return state.ensure_supported();
357 }
358 migrate_schema(connection)?;
361 read_schema_state(connection)?.ensure_supported()?;
362 verified.insert(key);
363 Ok(())
364}
365
366#[cfg(test)]
370pub(super) fn forget_verified_schema(path: &Path) {
371 verified_schemas()
372 .lock()
373 .unwrap_or_else(PoisonError::into_inner)
374 .remove(&schema_cache_key(path));
375}
376
377const BASELINE_SCHEMA_VERSION: i64 = 33;
380
381const BASELINE_MINIMUM_COMPATIBLE_VERSION: i64 = 32;
384
385const ACCOUNTING_MIGRATION_SQL: &str = "
388 ALTER TABLE session_turn_usage RENAME TO old_session_turn_usage;
389 CREATE TABLE session_turn_usage (
390 session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
391 command_id TEXT NOT NULL,
392 completed_ordinal INTEGER NOT NULL,
393 turn_start_position INTEGER,
394 body TEXT NOT NULL,
395 PRIMARY KEY(session_id, command_id)
396 );
397 INSERT INTO session_turn_usage SELECT * FROM old_session_turn_usage;
398 DROP TABLE old_session_turn_usage;
399 CREATE INDEX session_turn_usage_order ON session_turn_usage(session_id, completed_ordinal);
400 ALTER TABLE session_provider_cost RENAME TO old_session_provider_cost;
401 CREATE TABLE session_provider_cost (
402 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
403 body TEXT NOT NULL
404 );
405 INSERT INTO session_provider_cost SELECT * FROM old_session_provider_cost;
406 DROP TABLE old_session_provider_cost;
407 CREATE TABLE subagent_accounting (
408 child_session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
409 parent_session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
410 task_name TEXT NOT NULL,
411 CHECK(child_session_id <> parent_session_id)
412 ) STRICT;
413 CREATE INDEX subagent_accounting_parent ON subagent_accounting(parent_session_id);
414 INSERT INTO subagent_accounting SELECT child_session_id, parent_session_id,
415 json_extract(record_json, '$.task_name') FROM subagent_sessions;
416 CREATE TABLE session_turn_selections (
417 session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
418 command_id TEXT NOT NULL,
419 model TEXT,
420 effort TEXT,
421 PRIMARY KEY(session_id, command_id)
422 ) STRICT;
423";
424
425fn migrate_schema(connection: &Connection) -> Result<()> {
426 let state = read_schema_state(connection)?;
427 let version = state.revision;
428 if version > SCHEMA_VERSION {
429 return state.ensure_supported();
430 }
431 if version == 0 {
432 create_baseline_schema(connection)?;
433 } else if version < BASELINE_SCHEMA_VERSION {
434 super::legacy_schema::migrate_to_baseline(connection)
435 .context("upgrade historical database schema")?;
436 }
437 if version < 34 {
445 connection.execute_batch(
446 "BEGIN IMMEDIATE;
447 CREATE TABLE IF NOT EXISTS session_mount_access (
448 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
449 source BLOB NOT NULL,
450 destination BLOB NOT NULL,
451 access TEXT NOT NULL CHECK(access IN ('rw')),
452 PRIMARY KEY(session_id, destination)
453 ) STRICT;
454 INSERT INTO schema_migrations(version, applied_at)
455 VALUES (34, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
456 PRAGMA user_version = 34;
457 COMMIT;",
458 )?;
459 }
460 if version < 35 {
467 connection.execute_batch(
468 "BEGIN IMMEDIATE;
469 ALTER TABLE sessions ADD COLUMN container_workspace TEXT;
470 INSERT INTO schema_migrations(version, applied_at)
471 VALUES (35, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
472 PRAGMA user_version = 35;
473 COMMIT;",
474 )?;
475 }
476 if version < 36 {
482 connection.execute_batch(
483 "BEGIN IMMEDIATE;
484 ALTER TABLE sessions ADD COLUMN build_cache_json TEXT;
485 INSERT INTO schema_migrations(version, applied_at)
486 VALUES (36, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
487 PRAGMA user_version = 36;
488 COMMIT;",
489 )?;
490 }
491 if version < 37 {
497 connection.execute_batch(
498 "BEGIN IMMEDIATE;
499 ALTER TABLE session_targets ADD COLUMN borrowed_from TEXT;
500 INSERT INTO schema_migrations(version, applied_at)
501 VALUES (37, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
502 PRAGMA user_version = 37;
503 COMMIT;",
504 )?;
505 }
506 if version < 38 {
513 connection.execute_batch(
514 "BEGIN IMMEDIATE;
515 CREATE TABLE IF NOT EXISTS workspace_layouts (
516 workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
517 layout TEXT NOT NULL
518 ) STRICT;
519 INSERT INTO schema_migrations(version, applied_at)
520 VALUES (38, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
521 PRAGMA user_version = 38;
522 COMMIT;",
523 )?;
524 }
525 if version < 39 {
529 connection.execute_batch(
530 "BEGIN IMMEDIATE;
531 UPDATE schema_compatibility SET minimum_compatible_version = 39 WHERE singleton = 1;
532 INSERT INTO schema_migrations(version, applied_at)
533 VALUES (39, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
534 PRAGMA user_version = 39;
535 COMMIT;",
536 )?;
537 }
538 if version < 40 {
541 connection.execute_batch(
542 "BEGIN IMMEDIATE;
543 CREATE TABLE native_agents (
544 owner TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
545 child TEXT NOT NULL,
546 staging INTEGER NOT NULL CHECK(staging IN (0,1)),
547 body TEXT NOT NULL CHECK(json_valid(body)),
548 PRIMARY KEY(owner, child, staging)
549 ) STRICT;
550 CREATE TABLE native_agent_transcript (
551 owner TEXT NOT NULL,
552 child TEXT NOT NULL,
553 staging INTEGER NOT NULL,
554 stable_id TEXT NOT NULL,
555 position INTEGER NOT NULL,
556 body TEXT NOT NULL CHECK(json_valid(body)),
557 PRIMARY KEY(owner, child, staging, stable_id),
558 FOREIGN KEY(owner, child, staging) REFERENCES native_agents(owner, child, staging)
559 ON DELETE CASCADE ON UPDATE CASCADE
560 ) STRICT;
561 CREATE INDEX native_agent_transcript_position ON native_agent_transcript(owner, child, staging, position);
562 CREATE TABLE native_agent_replay (
563 owner TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE
564 ) STRICT;
565 UPDATE schema_compatibility SET minimum_compatible_version = 40 WHERE singleton = 1;
566 INSERT INTO schema_migrations(version, applied_at)
567 VALUES (40, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
568 PRAGMA user_version = 40;
569 COMMIT;",
570 )?;
571 }
572 if version < 41 {
575 connection.execute_batch(
576 "BEGIN IMMEDIATE;
577 UPDATE schema_compatibility SET minimum_compatible_version = 41 WHERE singleton = 1;
578 INSERT INTO schema_migrations(version, applied_at)
579 VALUES (41, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
580 PRAGMA user_version = 41;
581 COMMIT;",
582 )?;
583 }
584
585 if version < 42 {
587 connection.execute_batch(
588 "BEGIN IMMEDIATE;
589 UPDATE schema_compatibility SET minimum_compatible_version = 42 WHERE singleton = 1;
590 INSERT INTO schema_migrations(version, applied_at)
591 VALUES (42, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
592 PRAGMA user_version = 42;
593 COMMIT;",
594 )?;
595 }
596
597 if version < 43 {
600 connection.execute_batch("BEGIN IMMEDIATE;
601 UPDATE schema_compatibility SET minimum_compatible_version = 43 WHERE singleton = 1;
602 INSERT INTO schema_migrations(version, applied_at) VALUES (43, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
603 PRAGMA user_version = 43;
604 COMMIT;")?;
605 }
606
607 if version < 44 {
610 connection.execute_batch("BEGIN IMMEDIATE;
611 CREATE TABLE quota_reset_cache (identity TEXT PRIMARY KEY, body TEXT NOT NULL);
612 UPDATE schema_compatibility SET minimum_compatible_version = 44 WHERE singleton = 1;
613 INSERT INTO schema_migrations(version, applied_at) VALUES (44, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
614 PRAGMA user_version = 44;
615 COMMIT;")?;
616 }
617
618 if version < 45 {
625 let add_column =
629 match super::legacy_schema::table_has_column(connection, "sessions", "launch_base")? {
630 true => "",
631 false => "ALTER TABLE sessions ADD COLUMN launch_base TEXT;",
632 };
633 connection.execute_batch(&format!(
634 "BEGIN IMMEDIATE;
635 {add_column}
636 INSERT INTO schema_migrations(version, applied_at)
637 VALUES (45, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
638 PRAGMA user_version = 45;
639 COMMIT;"
640 ))?;
641 }
642
643 if version < 46 {
647 let add_column = if super::legacy_schema::table_has_column(
648 connection,
649 "sessions",
650 "target_runtime_json",
651 )? {
652 ""
653 } else {
654 "ALTER TABLE sessions ADD COLUMN target_runtime_json TEXT;"
655 };
656 connection.execute_batch(&format!(
657 "BEGIN IMMEDIATE;
658 {add_column}
659 UPDATE schema_compatibility SET minimum_compatible_version = 46 WHERE singleton = 1;
660 INSERT INTO schema_migrations(version, applied_at)
661 VALUES (46, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
662 PRAGMA user_version = 46;
663 COMMIT;"
664 ))?;
665 }
666
667 if version < 47 {
671 let add_branch =
672 if super::legacy_schema::table_has_column(connection, "sessions", "launch_branch")? {
673 ""
674 } else {
675 "ALTER TABLE sessions ADD COLUMN launch_branch TEXT;"
676 };
677 let add_publication = if super::legacy_schema::table_has_column(
678 connection,
679 "sessions",
680 "publication_json",
681 )? {
682 ""
683 } else {
684 "ALTER TABLE sessions ADD COLUMN publication_json TEXT;"
685 };
686 connection.execute_batch(&format!(
687 "BEGIN IMMEDIATE;
688 {add_branch}
689 {add_publication}
690 UPDATE schema_compatibility SET minimum_compatible_version = 47 WHERE singleton = 1;
691 INSERT INTO schema_migrations(version, applied_at)
692 VALUES (47, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
693 PRAGMA user_version = 47;
694 COMMIT;",
695 ))?;
696 }
697
698 if version < 48 {
702 connection.execute_batch(
703 "BEGIN IMMEDIATE;
704 UPDATE schema_compatibility SET minimum_compatible_version = 48 WHERE singleton = 1;
705 INSERT INTO schema_migrations(version, applied_at)
706 VALUES (48, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
707 PRAGMA user_version = 48;
708 COMMIT;",
709 )?;
710 }
711
712 if version < 49 {
717 connection.execute_batch(
718 "BEGIN IMMEDIATE;
719 CREATE TABLE IF NOT EXISTS subagent_handbacks (
720 child_session_id TEXT PRIMARY KEY,
721 handback_command_id TEXT,
722 handback_message TEXT,
723 handback_recorded_at_ms INTEGER,
724 reminder_command_id TEXT,
725 reminder_for_command_id TEXT,
726 reminder_sent_at_ms INTEGER,
727 reminder_failed_for_command_id TEXT
728 );
729 UPDATE schema_compatibility SET minimum_compatible_version = 49 WHERE singleton = 1;
730 INSERT INTO schema_migrations(version, applied_at)
731 VALUES (49, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
732 PRAGMA user_version = 49;
733 COMMIT;",
734 )?;
735 }
736
737 if version < 50 {
741 let add_column = if super::legacy_schema::table_has_column(
742 connection,
743 "subagent_handbacks",
744 "awaited_ordinal",
745 )? {
746 ""
747 } else {
748 "ALTER TABLE subagent_handbacks ADD COLUMN awaited_ordinal INTEGER;"
749 };
750 connection.execute_batch(&format!(
751 "BEGIN IMMEDIATE;
752 {add_column}
753 INSERT INTO schema_migrations(version, applied_at)
754 VALUES (50, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
755 PRAGMA user_version = 50;
756 COMMIT;"
757 ))?;
758 }
759
760 if version < 51 {
764 let add_column = if super::legacy_schema::table_has_column(
765 connection,
766 "subagent_handbacks",
767 "report_dir",
768 )? {
769 ""
770 } else {
771 "ALTER TABLE subagent_handbacks ADD COLUMN report_dir TEXT;"
772 };
773 connection.execute_batch(&format!(
774 "BEGIN IMMEDIATE;
775 {add_column}
776 INSERT INTO schema_migrations(version, applied_at)
777 VALUES (51, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
778 PRAGMA user_version = 51;
779 COMMIT;"
780 ))?;
781 }
782
783 if version < 52 {
790 connection.execute_batch(
791 "BEGIN IMMEDIATE;
792 CREATE TABLE IF NOT EXISTS stopped_subagents (
793 parent_session_id TEXT NOT NULL
794 REFERENCES sessions(session_id) ON DELETE CASCADE,
795 child_session_id TEXT NOT NULL,
796 record_json TEXT NOT NULL CHECK(json_valid(record_json)),
797 PRIMARY KEY(parent_session_id, child_session_id)
798 ) STRICT;
799 INSERT INTO schema_migrations(version, applied_at)
800 VALUES (52, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
801 PRAGMA user_version = 52;
802 COMMIT;",
803 )?;
804 }
805
806 if version < 53 {
811 migrate_parked_session_state(connection)?;
812 }
813
814 if version < 54 {
817 let add_column =
818 if super::legacy_schema::table_has_column(connection, "sessions", "checkout_json")? {
819 ""
820 } else {
821 "ALTER TABLE sessions ADD COLUMN checkout_json TEXT;"
822 };
823 connection.execute_batch(&format!(
824 "BEGIN IMMEDIATE;
825 {add_column}
826 UPDATE schema_compatibility SET minimum_compatible_version = 54 WHERE singleton = 1;
827 INSERT INTO schema_migrations(version, applied_at)
828 VALUES (54, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
829 PRAGMA user_version = 54;
830 COMMIT;"
831 ))?;
832 }
833
834 if version < 55 {
837 let add_column = if super::legacy_schema::table_has_column(
838 connection,
839 "sessions",
840 "expected_runtime_identity",
841 )? {
842 ""
843 } else {
844 "ALTER TABLE sessions ADD COLUMN expected_runtime_identity TEXT;"
845 };
846 connection.execute_batch(&format!(
847 "BEGIN IMMEDIATE;
848 {add_column}
849 UPDATE schema_compatibility SET minimum_compatible_version = 55 WHERE singleton = 1;
850 INSERT INTO schema_migrations(version, applied_at)
851 VALUES (55, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
852 PRAGMA user_version = 55;
853 COMMIT;"
854 ))?;
855 }
856
857 if version < 56 {
859 let add_column = if super::legacy_schema::table_has_column(
860 connection,
861 "sessions",
862 "subagents",
863 )? {
864 ""
865 } else {
866 "ALTER TABLE sessions ADD COLUMN subagents TEXT CHECK(subagents IS NULL OR json_valid(subagents));"
867 };
868 connection.execute_batch(&format!(
869 "BEGIN IMMEDIATE;
870 {add_column}
871 UPDATE sessions SET subagents = CASE WHEN mjolnir_subagents = 1
872 THEN '{{\"mode\":\"all_models\"}}' ELSE '{{\"mode\":\"native\"}}' END WHERE subagents IS NULL AND mjolnir_subagents IS NOT NULL;
873 CREATE TABLE IF NOT EXISTS subagent_preference (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), policy TEXT NOT NULL CHECK(json_valid(policy)));
874 UPDATE schema_compatibility SET minimum_compatible_version = 56 WHERE singleton = 1;
875 INSERT INTO schema_migrations(version, applied_at) VALUES (56, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
876 PRAGMA user_version = 56;
877 COMMIT;"
878 ))?;
879 }
880
881 if version < 57 {
884 let tx = connection.unchecked_transaction()?;
885 tx.execute_batch("DROP TRIGGER IF EXISTS api_session_error_updated;
886 DROP TRIGGER IF EXISTS api_session_error_inserted;
887 CREATE TABLE IF NOT EXISTS checkpoint_operations (
888 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
889 command_id TEXT NOT NULL UNIQUE,
890 related_command_ids TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(related_command_ids))
891 ) STRICT;")?;
892 super::events::migrate_event_outcomes(&tx)?;
893 tx.execute_batch("UPDATE schema_compatibility SET minimum_compatible_version = 57 WHERE singleton = 1;
894 INSERT INTO schema_migrations(version, applied_at) VALUES (57, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
895 PRAGMA user_version = 57;")?;
896 tx.commit()?;
897 }
898
899 if version < 58 {
902 connection.execute_batch("BEGIN IMMEDIATE;
903 UPDATE schema_compatibility SET minimum_compatible_version = 58 WHERE singleton = 1;
904 INSERT INTO schema_migrations(version, applied_at) VALUES (58, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
905 PRAGMA user_version = 58;
906 COMMIT;")?;
907 }
908
909 if version < 59 {
912 connection.execute_batch("BEGIN IMMEDIATE;
913 CREATE TABLE IF NOT EXISTS startup_steps (
914 sequence INTEGER PRIMARY KEY AUTOINCREMENT,
915 session_id TEXT NOT NULL,
916 group_id TEXT,
917 command_id TEXT NOT NULL UNIQUE,
918 step_json TEXT NOT NULL,
919 phase TEXT NOT NULL DEFAULT 'pending'
920 CHECK (phase IN ('pending', 'delivering', 'accepted', 'cancelling', 'rejecting', 'done', 'failed', 'dismissed')),
921 error TEXT,
922 accepted_ordinal INTEGER
923 ) STRICT;
924 CREATE INDEX IF NOT EXISTS startup_steps_session_sequence
925 ON startup_steps(session_id, sequence);
926 CREATE INDEX IF NOT EXISTS startup_steps_group_sequence
927 ON startup_steps(group_id, sequence) WHERE group_id IS NOT NULL;
928 CREATE INDEX IF NOT EXISTS startup_steps_pending
929 ON startup_steps(session_id, sequence)
930 WHERE phase IN ('pending', 'delivering', 'accepted', 'cancelling', 'rejecting');
931 UPDATE schema_compatibility SET minimum_compatible_version = 59 WHERE singleton = 1;
932 INSERT INTO schema_migrations(version, applied_at) VALUES (59, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
933 PRAGMA user_version = 59;
934 COMMIT;")?;
935 }
936 if version < 60 {
939 connection.execute_batch("BEGIN IMMEDIATE;
940 CREATE TABLE IF NOT EXISTS delegation_effects (
941 parent_session_id TEXT NOT NULL,
942 request_id TEXT NOT NULL,
943 phase TEXT NOT NULL,
944 prepared_json TEXT,
945 result_json TEXT,
946 receipt_pending INTEGER NOT NULL DEFAULT 0,
947 PRIMARY KEY(parent_session_id, request_id)
948 ) STRICT;
949 CREATE INDEX IF NOT EXISTS delegation_effects_receipt_cleanup
950 ON delegation_effects(parent_session_id, request_id) WHERE receipt_pending = 1;
951 UPDATE schema_compatibility SET minimum_compatible_version = 60 WHERE singleton = 1;
952 INSERT INTO schema_migrations(version, applied_at) VALUES (60, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
953 PRAGMA user_version = 60;
954 COMMIT;")?;
955 }
956 if version < 61 {
959 let add_column = if super::legacy_schema::table_has_column(
960 connection,
961 "turn_review_state",
962 "orchestration",
963 )? {
964 ""
965 } else {
966 "ALTER TABLE turn_review_state ADD COLUMN orchestration TEXT;"
967 };
968 connection.execute_batch(&format!("BEGIN IMMEDIATE;
969 {add_column}
970 UPDATE schema_compatibility SET minimum_compatible_version = 61 WHERE singleton = 1;
971 INSERT INTO schema_migrations(version, applied_at) VALUES (61, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
972 PRAGMA user_version = 61;
973 COMMIT;"))?;
974 }
975 if version < 62 {
978 connection.execute_batch("BEGIN IMMEDIATE;
979 CREATE TABLE IF NOT EXISTS worker_restart_intents (
980 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
981 operation_id TEXT NOT NULL,
982 target_json TEXT NOT NULL,
983 desired_build TEXT NOT NULL,
984 phase TEXT NOT NULL CHECK(phase IN ('prepared', 'swapping', 'awaiting_readiness'))
985 ) STRICT;
986 UPDATE schema_compatibility SET minimum_compatible_version = 62 WHERE singleton = 1;
987 INSERT INTO schema_migrations(version, applied_at) VALUES (62, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
988 PRAGMA user_version = 62;
989 COMMIT;")?;
990 }
991
992 if version < 63 {
995 connection.execute_batch("BEGIN IMMEDIATE;
996 CREATE TABLE IF NOT EXISTS session_incarnations (
997 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
998 identity TEXT NOT NULL
999 ) STRICT;
1000 INSERT OR IGNORE INTO session_incarnations(session_id, identity)
1001 SELECT session_id, lower(hex(randomblob(16))) FROM sessions;
1002 CREATE TRIGGER IF NOT EXISTS session_incarnation_insert
1003 AFTER INSERT ON sessions BEGIN
1004 INSERT INTO session_incarnations(session_id, identity)
1005 VALUES(NEW.session_id, lower(hex(randomblob(16))));
1006 END;
1007 CREATE TRIGGER IF NOT EXISTS session_incarnation_resume
1008 AFTER UPDATE OF state ON sessions
1009 WHEN (NEW.state = 'provisioning' AND OLD.state <> 'provisioning')
1010 OR (NEW.state = 'running' AND OLD.state IN
1011 ('stopped', 'parked', 'error', 'lost', 'destroyed-with-data-loss'))
1012 BEGIN
1013 UPDATE session_incarnations SET identity = lower(hex(randomblob(16)))
1014 WHERE session_id = NEW.session_id;
1015 END;
1016 UPDATE schema_compatibility SET minimum_compatible_version = 63 WHERE singleton = 1;
1017 INSERT INTO schema_migrations(version, applied_at) VALUES (63, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1018 PRAGMA user_version = 63;
1019 COMMIT;")?;
1020 }
1021
1022 if version < 64 {
1025 connection.execute_batch("BEGIN IMMEDIATE;
1026 CREATE TABLE IF NOT EXISTS retained_move_sources (
1027 operation_id TEXT PRIMARY KEY,
1028 session_id TEXT NOT NULL,
1029 source_json TEXT NOT NULL,
1030 exclusions_json TEXT NOT NULL,
1031 created_at TEXT NOT NULL
1032 ) STRICT;
1033 UPDATE schema_compatibility SET minimum_compatible_version = 64 WHERE singleton = 1;
1034 INSERT INTO schema_migrations(version, applied_at) VALUES (64, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1035 PRAGMA user_version = 64;
1036 COMMIT;")?;
1037 }
1038
1039 if version < 65 {
1045 let drop_column = if super::legacy_schema::table_has_column(
1046 connection,
1047 "turn_review_state",
1048 "orchestration",
1049 )? {
1050 "ALTER TABLE turn_review_state DROP COLUMN orchestration;"
1051 } else {
1052 ""
1053 };
1054 connection.execute_batch(&format!("BEGIN IMMEDIATE;
1055 DROP TRIGGER IF EXISTS session_incarnation_insert;
1056 DROP TRIGGER IF EXISTS session_incarnation_resume;
1057 DROP TABLE IF EXISTS session_incarnations;
1058 {drop_column}
1059 UPDATE schema_compatibility SET minimum_compatible_version = 65 WHERE singleton = 1;
1060 INSERT INTO schema_migrations(version, applied_at) VALUES (65, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1061 PRAGMA user_version = 65;
1062 COMMIT;"))?;
1063 }
1064
1065 if version < 66 {
1068 let drop_column = if super::legacy_schema::table_has_column(
1069 connection,
1070 "sessions",
1071 "expected_runtime_identity",
1072 )? {
1073 "ALTER TABLE sessions DROP COLUMN expected_runtime_identity;"
1074 } else {
1075 ""
1076 };
1077 connection.execute_batch(&format!("BEGIN IMMEDIATE;
1078 {drop_column}
1079 UPDATE schema_compatibility SET minimum_compatible_version = 66 WHERE singleton = 1;
1080 INSERT INTO schema_migrations(version, applied_at) VALUES (66, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1081 PRAGMA user_version = 66;
1082 COMMIT;"))?;
1083 }
1084
1085 if version < 67 {
1088 connection.execute_batch(&format!("BEGIN IMMEDIATE;
1089 {ACCOUNTING_MIGRATION_SQL}
1090 UPDATE schema_compatibility SET minimum_compatible_version = 67 WHERE singleton = 1;
1091 INSERT INTO schema_migrations(version, applied_at) VALUES (67, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1092 PRAGMA user_version = 67;
1093 COMMIT;"))?;
1094 }
1095
1096 if version < 68 {
1099 migrate_startup_cleanup_state(connection)?;
1100 }
1101
1102 if version < 69 {
1105 let has_accounting = connection
1106 .prepare(
1107 "SELECT 1 FROM sqlite_schema WHERE type='table' AND name='subagent_accounting'",
1108 )?
1109 .exists([])?;
1110 let accounting = if has_accounting {
1111 ""
1112 } else {
1113 ACCOUNTING_MIGRATION_SQL
1114 };
1115 let add_snapshot = if super::legacy_schema::table_has_column(
1116 connection,
1117 "sessions",
1118 "project_json",
1119 )? {
1120 ""
1121 } else {
1122 "ALTER TABLE sessions ADD COLUMN project_json TEXT CHECK(project_json IS NULL OR json_valid(project_json));"
1123 };
1124 connection.execute_batch(&format!("BEGIN IMMEDIATE;
1125 {accounting}
1126 {add_snapshot}
1127 CREATE TABLE IF NOT EXISTS project_catalog (
1128 bundle_id TEXT PRIMARY KEY,
1129 project_key TEXT NOT NULL UNIQUE,
1130 snapshot_json TEXT NOT NULL CHECK(json_valid(snapshot_json)),
1131 hidden INTEGER NOT NULL DEFAULT 0 CHECK(hidden IN (0,1))
1132 ) STRICT;
1133 CREATE TABLE IF NOT EXISTS project_aliases (
1134 bundle_id TEXT PRIMARY KEY,
1135 canonical_id TEXT NOT NULL REFERENCES project_catalog(bundle_id),
1136 snapshot_json TEXT NOT NULL CHECK(json_valid(snapshot_json)),
1137 config_pending INTEGER NOT NULL DEFAULT 0 CHECK(config_pending IN (0,1))
1138 ) STRICT;
1139 CREATE TABLE IF NOT EXISTS project_session_aliases (
1140 session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
1141 bundle_id TEXT NOT NULL, PRIMARY KEY(session_id,bundle_id)
1142 ) STRICT;
1143 CREATE TABLE IF NOT EXISTS project_locations (
1144 host TEXT NOT NULL,
1145 directory BLOB NOT NULL,
1146 checkout_root BLOB NOT NULL,
1147 repository_root BLOB NOT NULL,
1148 identity_json TEXT NOT NULL CHECK(json_valid(identity_json)),
1149 seen_at TEXT NOT NULL,
1150 PRIMARY KEY(host, directory)
1151 ) STRICT;
1152 CREATE TABLE IF NOT EXISTS project_seed_homes (
1153 harness TEXT NOT NULL,
1154 home BLOB NOT NULL,
1155 PRIMARY KEY(harness, home)
1156 ) STRICT;
1157 CREATE TABLE IF NOT EXISTS project_seed_failures (
1158 harness TEXT NOT NULL, home BLOB NOT NULL, directory BLOB NOT NULL, error TEXT NOT NULL, source_file INTEGER NOT NULL CHECK(source_file IN (0,1)),
1159 PRIMARY KEY(harness,home,directory)
1160 ) STRICT;
1161 CREATE TABLE IF NOT EXISTS project_discovery_changes (
1162 sequence INTEGER PRIMARY KEY AUTOINCREMENT,
1163 session_id TEXT NOT NULL,
1164 directory BLOB,
1165 managed_worktree TEXT,
1166 target_template_id TEXT NOT NULL
1167 ) STRICT;
1168 CREATE TABLE IF NOT EXISTS project_discovery_progress (
1169 singleton INTEGER PRIMARY KEY CHECK(singleton=1),
1170 sequence INTEGER NOT NULL DEFAULT 0
1171 ) STRICT;
1172 CREATE TABLE IF NOT EXISTS project_discovery_failures (
1173 sequence INTEGER PRIMARY KEY REFERENCES project_discovery_changes(sequence) ON DELETE CASCADE,
1174 error TEXT NOT NULL
1175 ) STRICT;
1176 INSERT OR IGNORE INTO project_discovery_progress(singleton) VALUES(1);
1177 INSERT INTO project_discovery_changes(session_id, directory, managed_worktree, target_template_id)
1178 SELECT session_id, project_directory, managed_worktree, target_template_id FROM sessions
1179 WHERE project_directory IS NOT NULL AND NOT EXISTS (
1180 SELECT 1 FROM project_discovery_changes d WHERE d.session_id=sessions.session_id
1181 );
1182 CREATE TRIGGER IF NOT EXISTS project_discovery_insert AFTER INSERT ON sessions
1183 WHEN NEW.project_directory IS NOT NULL BEGIN
1184 INSERT INTO project_discovery_changes(session_id,directory,managed_worktree,target_template_id)
1185 VALUES(NEW.session_id,NEW.project_directory,NEW.managed_worktree,NEW.target_template_id);
1186 END;
1187 CREATE TRIGGER IF NOT EXISTS project_discovery_update AFTER UPDATE OF project_directory,managed_worktree,target_template_id ON sessions
1188 WHEN NEW.project_directory IS NOT NULL AND
1189 (NEW.project_directory IS NOT OLD.project_directory
1190 OR NEW.managed_worktree IS NOT OLD.managed_worktree
1191 OR NEW.target_template_id IS NOT OLD.target_template_id) BEGIN
1192 INSERT INTO project_discovery_changes(session_id,directory,managed_worktree,target_template_id)
1193 VALUES(NEW.session_id,NEW.project_directory,NEW.managed_worktree,NEW.target_template_id);
1194 END;
1195 UPDATE schema_compatibility SET minimum_compatible_version=69 WHERE singleton=1;
1196 INSERT INTO schema_migrations(version,applied_at) VALUES(69,strftime('%Y-%m-%dT%H:%M:%fZ','now'));
1197 PRAGMA user_version=69;
1198 COMMIT;"))?;
1199 }
1200
1201 if version < 70 {
1204 let transaction = connection.unchecked_transaction()?;
1205 let titles: Vec<(String, String)> = transaction
1206 .prepare("SELECT session_id, acp_session_title FROM sessions WHERE length(acp_session_title) > ?1")?
1207 .query_map([mj_core::state::MAX_SESSION_TITLE_CHARS], |row| {
1208 Ok((row.get(0)?, row.get(1)?))
1209 })?
1210 .collect::<rusqlite::Result<_>>()?;
1211 for (session_id, title) in titles {
1212 transaction.execute(
1213 "UPDATE sessions SET acp_session_title = ?2 WHERE session_id = ?1",
1214 params![session_id, mj_core::state::normalize_session_title(&title)],
1215 )?;
1216 }
1217 transaction.execute_batch(
1218 "INSERT INTO schema_migrations(version, applied_at)
1219 VALUES (70, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
1220 PRAGMA user_version = 70;",
1221 )?;
1222 transaction.commit()?;
1223 }
1224
1225 if version < 71 {
1227 let transaction = connection.unchecked_transaction()?;
1228 transaction.execute_batch(
1229 "UPDATE schema_compatibility SET minimum_compatible_version=71 WHERE singleton=1;
1230 INSERT INTO schema_migrations(version,applied_at)
1231 VALUES(71,strftime('%Y-%m-%dT%H:%M:%fZ','now'));
1232 PRAGMA user_version=71;",
1233 )?;
1234 transaction.commit()?;
1235 }
1236
1237 if version < 72 {
1243 let add_column =
1244 if super::legacy_schema::table_has_column(connection, "sessions", "review_json")? {
1245 ""
1246 } else {
1247 "ALTER TABLE sessions ADD COLUMN review_json TEXT;"
1248 };
1249 connection.execute_batch(&format!(
1250 "BEGIN IMMEDIATE;
1251 {add_column}
1252 INSERT INTO schema_migrations(version, applied_at)
1253 VALUES (72, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1254 PRAGMA user_version = 72;
1255 COMMIT;"
1256 ))?;
1257 }
1258
1259 if version < 73 {
1264 connection.execute_batch(
1265 "BEGIN IMMEDIATE;
1266 CREATE TABLE IF NOT EXISTS session_restart_intents (
1267 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1268 operation_id TEXT NOT NULL,
1269 phase TEXT NOT NULL CHECK(phase IN ('stopping', 'restoring_in_place', 'fallback')),
1270 updated_at TEXT NOT NULL,
1271 checkpoint_sha256 TEXT,
1272 checkpoint_started INTEGER NOT NULL DEFAULT 0 CHECK(checkpoint_started IN (0, 1))
1273 ) STRICT;
1274 INSERT INTO schema_migrations(version, applied_at)
1275 VALUES (73, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1276 PRAGMA user_version = 73;
1277 COMMIT;",
1278 )?;
1279 }
1280
1281 if version < 74 {
1285 migrate_opencode_harness_kind(connection)?;
1286 }
1287
1288 if version < 75 {
1293 connection.execute_batch(
1294 "BEGIN IMMEDIATE;
1295 CREATE TABLE IF NOT EXISTS github_watch_cursors (
1296 owner TEXT NOT NULL,
1297 repo TEXT NOT NULL,
1298 items_watermark_at TEXT,
1299 items_watermark_id INTEGER,
1300 items_etag TEXT,
1301 comments_cursor TEXT,
1302 PRIMARY KEY(owner, repo)
1303 ) STRICT;
1304 CREATE TABLE IF NOT EXISTS github_watch_classifications (
1305 owner TEXT NOT NULL,
1306 repo TEXT NOT NULL,
1307 number INTEGER NOT NULL,
1308 session_id TEXT NOT NULL,
1309 kind TEXT NOT NULL CHECK(kind IN ('issue', 'pull_request')),
1310 title TEXT NOT NULL,
1311 url TEXT NOT NULL,
1312 created_at TEXT NOT NULL,
1313 interested INTEGER NOT NULL CHECK(interested IN (0, 1)),
1314 created INTEGER NOT NULL CHECK(created IN (0, 1)),
1315 PRIMARY KEY(owner, repo, number, session_id)
1316 ) STRICT;
1317 CREATE TABLE IF NOT EXISTS github_watch_items (
1318 owner TEXT NOT NULL,
1319 repo TEXT NOT NULL,
1320 number INTEGER NOT NULL,
1321 creator_session_id TEXT NOT NULL,
1322 kind TEXT NOT NULL CHECK(kind IN ('issue', 'pull_request')),
1323 title TEXT NOT NULL,
1324 url TEXT NOT NULL,
1325 created_at TEXT NOT NULL,
1326 pull_request_etag TEXT,
1327 pull_request_state TEXT CHECK(pull_request_state IS NULL OR pull_request_state IN ('open', 'closed')),
1328 PRIMARY KEY(owner, repo, number)
1329 ) STRICT;
1330 CREATE TABLE IF NOT EXISTS mailbox_outbox (
1331 event_key TEXT PRIMARY KEY,
1332 target_session_id TEXT NOT NULL,
1333 event_json TEXT NOT NULL,
1334 wake INTEGER NOT NULL CHECK(wake IN (0, 1)),
1335 unpark INTEGER NOT NULL DEFAULT 0 CHECK(unpark IN (0, 1)),
1336 created_at TEXT NOT NULL,
1337 accepted_at TEXT,
1338 accepted_command_id TEXT,
1339 accepted_ordinal INTEGER
1340 ) STRICT;
1341 CREATE INDEX IF NOT EXISTS mailbox_outbox_pending
1342 ON mailbox_outbox(accepted_at, created_at);
1343 INSERT INTO schema_migrations(version, applied_at)
1344 VALUES (75, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1345 PRAGMA user_version = 75;
1346 COMMIT;",
1347 )?;
1348 }
1349
1350 if version < 76 {
1355 let add_column =
1356 if super::legacy_schema::table_has_column(connection, "mailbox_outbox", "failure")? {
1357 ""
1358 } else {
1359 "ALTER TABLE mailbox_outbox ADD COLUMN failure TEXT;"
1360 };
1361 connection.execute_batch(&format!(
1362 "BEGIN IMMEDIATE;
1363 {add_column}
1364 INSERT INTO schema_migrations(version, applied_at)
1365 VALUES (76, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1366 PRAGMA user_version = 76;
1367 COMMIT;"
1368 ))?;
1369 }
1370
1371 if version < 77 {
1377 connection.execute_batch(
1378 "BEGIN IMMEDIATE;
1379 CREATE TABLE prompt_history_nullable_v77 (
1380 history_id INTEGER PRIMARY KEY,
1381 session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
1382 event_ordinal INTEGER CHECK(event_ordinal IS NULL OR event_ordinal >= 0),
1383 submitted_at TEXT NOT NULL,
1384 text TEXT NOT NULL CHECK(length(trim(text)) > 0),
1385 UNIQUE(session_id, event_ordinal)
1386 ) STRICT;
1387 INSERT INTO prompt_history_nullable_v77
1388 SELECT history_id, session_id, event_ordinal, submitted_at, text
1389 FROM prompt_history;
1390 DROP TABLE prompt_history;
1391 ALTER TABLE prompt_history_nullable_v77 RENAME TO prompt_history;
1392 CREATE INDEX prompt_history_session_recent
1393 ON prompt_history(session_id, history_id DESC);
1394 CREATE INDEX prompt_history_recent
1395 ON prompt_history(history_id DESC);
1396 INSERT INTO schema_migrations(version, applied_at)
1397 VALUES (77, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1398 PRAGMA user_version = 77;
1399 COMMIT;",
1400 )?;
1401 }
1402
1403 if version < 78 {
1406 connection.execute_batch(
1407 "BEGIN IMMEDIATE;
1408 UPDATE schema_compatibility SET minimum_compatible_version = 78 WHERE singleton = 1;
1409 INSERT INTO schema_migrations(version, applied_at)
1410 VALUES (78, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1411 PRAGMA user_version = 78;
1412 COMMIT;",
1413 )?;
1414 }
1415
1416 if version < 79 {
1420 connection.execute_batch(
1421 "BEGIN IMMEDIATE;
1422 UPDATE schema_compatibility SET minimum_compatible_version = 79 WHERE singleton = 1;
1423 INSERT INTO schema_migrations(version, applied_at)
1424 VALUES (79, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1425 PRAGMA user_version = 79;
1426 COMMIT;",
1427 )?;
1428 }
1429
1430 if version < 80 {
1438 let add_column = |column: &str| -> Result<String> {
1439 Ok(
1440 if super::legacy_schema::table_has_column(connection, "sessions", column)? {
1441 String::new()
1442 } else {
1443 format!("ALTER TABLE sessions ADD COLUMN {column} INTEGER NOT NULL DEFAULT 0;")
1444 },
1445 )
1446 };
1447 let add_columns = add_column("no_project_memory")? + &add_column("no_mailbox")?;
1448 connection.execute_batch(&format!(
1449 "BEGIN IMMEDIATE;
1450 {add_columns}
1451 INSERT INTO schema_migrations(version, applied_at)
1452 VALUES (80, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1453 PRAGMA user_version = 80;
1454 COMMIT;"
1455 ))?;
1456 }
1457
1458 if version < 81 {
1461 connection.execute_batch(
1462 "BEGIN IMMEDIATE;
1463 UPDATE schema_compatibility SET minimum_compatible_version = 81 WHERE singleton = 1;
1464 INSERT INTO schema_migrations(version, applied_at)
1465 VALUES (81, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1466 PRAGMA user_version = 81;
1467 COMMIT;",
1468 )?;
1469 }
1470
1471 if version < 82 {
1474 migrate_turn_outcome_schema(connection)?;
1475 }
1476
1477 let recorded: Option<i64> =
1478 connection.query_row("SELECT max(version) FROM schema_migrations", [], |row| {
1479 row.get(0)
1480 })?;
1481 if recorded == Some(SCHEMA_VERSION) && version < SCHEMA_VERSION {
1482 tracing::info!(
1483 from_revision = version,
1484 to_revision = SCHEMA_VERSION,
1485 "database migrations applied"
1486 );
1487 }
1488 if recorded != Some(SCHEMA_VERSION) {
1489 bail!(
1490 "Mjolnir database migration ledger {:?} does not match schema {}",
1491 recorded,
1492 SCHEMA_VERSION
1493 );
1494 }
1495 Ok(())
1496}
1497
1498fn migrate_parked_session_state(connection: &Connection) -> Result<()> {
1505 const BEFORE: &str = "'stopped','lost',";
1506 const AFTER: &str = "'stopped','parked','lost',";
1507 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1508 let migration = (|| -> Result<()> {
1509 let transaction = connection.unchecked_transaction()?;
1510 let sql: String = transaction.query_row(
1511 "SELECT sql FROM sqlite_schema WHERE type='table' AND name='sessions'",
1512 [],
1513 |row| row.get(0),
1514 )?;
1515 let (_, definition) = sql
1516 .split_once('(')
1517 .context("missing sessions table definition")?;
1518 if !definition.contains(AFTER) {
1519 ensure!(
1520 definition.matches(BEFORE).count() == 1,
1521 "unexpected sessions state constraint"
1522 );
1523 let definition = definition.replace(BEFORE, AFTER);
1524 let objects: Vec<String> = transaction
1525 .prepare(
1526 "SELECT sql FROM sqlite_schema WHERE tbl_name='sessions'
1527 AND type IN ('index','trigger') AND sql IS NOT NULL",
1528 )?
1529 .query_map([], |row| row.get(0))?
1530 .collect::<rusqlite::Result<_>>()?;
1531 transaction.execute_batch(&format!(
1532 "CREATE TABLE sessions_parked_v53 ({definition};
1533 INSERT INTO sessions_parked_v53 SELECT * FROM sessions;
1534 DROP TABLE sessions;
1535 ALTER TABLE sessions_parked_v53 RENAME TO sessions;"
1536 ))?;
1537 for object in objects {
1538 transaction.execute_batch(&object)?;
1539 }
1540 ensure!(
1541 !transaction
1542 .prepare("PRAGMA foreign_key_check")?
1543 .exists([])?,
1544 "foreign key violation in the parked-state migration"
1545 );
1546 }
1547 transaction.execute_batch(
1548 "UPDATE schema_compatibility SET minimum_compatible_version = 53
1549 WHERE singleton = 1;
1550 INSERT INTO schema_migrations(version, applied_at)
1551 VALUES (53, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
1552 PRAGMA user_version = 53;",
1553 )?;
1554 transaction.commit()?;
1555 Ok(())
1556 })();
1557 let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1558 migration.context("migrate the sessions state constraint for parked sub-agents")?;
1559 restored.context("restore foreign key enforcement after the parked-state migration")?;
1560 Ok(())
1561}
1562
1563fn migrate_opencode_harness_kind(connection: &Connection) -> Result<()> {
1572 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1573 let migration = (|| -> Result<()> {
1574 let transaction = connection.unchecked_transaction()?;
1575 for table in ["sessions", "hidden_native_sessions"] {
1576 let sql: String = transaction.query_row(
1577 "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
1578 [table],
1579 |row| row.get(0),
1580 )?;
1581 let (_, definition) = sql
1582 .split_once('(')
1583 .context("missing harness table definition")?;
1584 if definition.contains("'opencode')") {
1585 continue;
1586 }
1587 ensure!(
1588 definition.contains("'zcode')"),
1589 "unexpected {table} harness constraint"
1590 );
1591 let definition = definition.replace("'zcode')", "'zcode','opencode')");
1592 let objects: Vec<String> = transaction
1593 .prepare(
1594 "SELECT sql FROM sqlite_schema WHERE tbl_name=?1
1595 AND type IN ('index','trigger') AND sql IS NOT NULL",
1596 )?
1597 .query_map([table], |row| row.get(0))?
1598 .collect::<rusqlite::Result<_>>()?;
1599 transaction.execute_batch(&format!(
1600 "CREATE TABLE {table}_opencode_v74 ({definition};
1601 INSERT INTO {table}_opencode_v74 SELECT * FROM {table};
1602 DROP TABLE {table};
1603 ALTER TABLE {table}_opencode_v74 RENAME TO {table};"
1604 ))?;
1605 for object in objects {
1606 transaction.execute_batch(&object)?;
1607 }
1608 }
1609 ensure!(
1610 !transaction
1611 .prepare("PRAGMA foreign_key_check")?
1612 .exists([])?,
1613 "foreign key violation in OpenCode migration"
1614 );
1615 transaction.execute_batch(
1616 "UPDATE schema_compatibility SET minimum_compatible_version = 74
1617 WHERE singleton = 1;
1618 INSERT INTO schema_migrations(version, applied_at)
1619 VALUES (74, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
1620 PRAGMA user_version = 74;",
1621 )?;
1622 transaction.commit()?;
1623 Ok(())
1624 })();
1625 let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1626 migration.context("migrate OpenCode harness constraints")?;
1627 restored.context("restore foreign key enforcement after OpenCode migration")?;
1628 Ok(())
1629}
1630
1631fn migrate_startup_cleanup_state(connection: &Connection) -> Result<()> {
1633 const BEFORE: &str = "'stopped','parked','lost',";
1634 const AFTER: &str = "'stopped','parked','startup-cleanup','lost',";
1635 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1636 let migration = (|| -> Result<()> {
1637 let transaction = connection.unchecked_transaction()?;
1638 let sql: String = transaction.query_row(
1639 "SELECT sql FROM sqlite_schema WHERE type='table' AND name='sessions'",
1640 [],
1641 |row| row.get(0),
1642 )?;
1643 let (_, definition) = sql
1644 .split_once('(')
1645 .context("missing sessions table definition")?;
1646 if !definition.contains(AFTER) {
1647 ensure!(
1648 definition.matches(BEFORE).count() == 1,
1649 "unexpected sessions state constraint"
1650 );
1651 let definition = definition.replace(BEFORE, AFTER);
1652 let objects: Vec<String> = transaction
1653 .prepare(
1654 "SELECT sql FROM sqlite_schema WHERE tbl_name='sessions'
1655 AND type IN ('index','trigger') AND sql IS NOT NULL",
1656 )?
1657 .query_map([], |row| row.get(0))?
1658 .collect::<rusqlite::Result<_>>()?;
1659 transaction.execute_batch(&format!(
1660 "CREATE TABLE sessions_startup_cleanup_v68 ({definition};
1661 INSERT INTO sessions_startup_cleanup_v68 SELECT * FROM sessions;
1662 DROP TABLE sessions;
1663 ALTER TABLE sessions_startup_cleanup_v68 RENAME TO sessions;"
1664 ))?;
1665 for object in objects {
1666 transaction.execute_batch(&object)?;
1667 }
1668 ensure!(
1669 !transaction
1670 .prepare("PRAGMA foreign_key_check")?
1671 .exists([])?,
1672 "foreign key violation in the startup-cleanup migration"
1673 );
1674 }
1675 transaction.execute_batch(
1676 "UPDATE schema_compatibility SET minimum_compatible_version = 68
1677 WHERE singleton = 1;
1678 INSERT INTO schema_migrations(version, applied_at)
1679 VALUES (68, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
1680 PRAGMA user_version = 68;",
1681 )?;
1682 transaction.commit()?;
1683 Ok(())
1684 })();
1685 let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1686 migration.context("migrate the sessions state constraint for failed startup cleanup")?;
1687 restored.context("restore foreign key enforcement after the startup-cleanup migration")?;
1688 Ok(())
1689}
1690
1691pub(super) fn migrate_turn_outcome_schema(connection: &Connection) -> Result<()> {
1692 let tx = connection.unchecked_transaction()?;
1693 super::turn_outcome::migrate_turn_outcomes(&tx)?;
1694 tx.execute_batch(
1695 "UPDATE schema_compatibility SET minimum_compatible_version = 82 WHERE singleton = 1;
1696 INSERT INTO schema_migrations(version, applied_at)
1697 VALUES (82, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1698 PRAGMA user_version = 82;",
1699 )?;
1700 tx.commit()?;
1701 Ok(())
1702}
1703
1704fn create_baseline_schema(connection: &Connection) -> Result<()> {
1708 connection.execute_batch("BEGIN IMMEDIATE;")?;
1709 let created = (|| -> Result<()> {
1710 let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1711 if version != 0 {
1712 return Ok(());
1713 }
1714 connection.execute_batch(include_str!("baseline.sql"))?;
1715 connection.execute(
1716 "INSERT INTO schema_compatibility(singleton, minimum_compatible_version) VALUES (1, ?1)",
1717 [BASELINE_MINIMUM_COMPATIBLE_VERSION],
1718 )?;
1719 connection.execute(
1720 "INSERT INTO schema_migrations(version, applied_at)
1721 VALUES (?1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))",
1722 [BASELINE_SCHEMA_VERSION],
1723 )?;
1724 connection.pragma_update(None, "user_version", BASELINE_SCHEMA_VERSION)?;
1725 Ok(())
1726 })();
1727 match created {
1728 Ok(()) => connection
1729 .execute_batch("COMMIT;")
1730 .context("commit baseline database schema"),
1731 Err(error) => {
1732 if let Err(rollback) = connection.execute_batch("ROLLBACK;") {
1733 tracing::warn!(%rollback, "could not roll back a failed baseline schema");
1734 }
1735 Err(error.context("create baseline database schema"))
1736 }
1737 }
1738}
1739
1740#[cfg(test)]
1741pub(super) fn advance_test_schema(path: &Path, revision: i64, minimum_compatible: i64) {
1742 let connection = Connection::open(path).unwrap();
1743 let transaction = connection.unchecked_transaction().unwrap();
1744 transaction
1745 .execute(
1746 "UPDATE schema_compatibility SET minimum_compatible_version = ?1",
1747 [minimum_compatible],
1748 )
1749 .unwrap();
1750 transaction
1751 .execute(
1752 "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, 'test')",
1753 [revision],
1754 )
1755 .unwrap();
1756 transaction
1757 .pragma_update(None, "user_version", revision)
1758 .unwrap();
1759 transaction.commit().unwrap();
1760 forget_verified_schema(path);
1761}
1762
1763#[cfg(test)]
1764mod reader_tests {
1765 use super::*;
1766
1767 #[test]
1768 fn restart_intent_schema_matches_for_fresh_and_upgraded_stores() {
1769 let directory = tempfile::tempdir().unwrap();
1770 let fresh = Connection::open(directory.path().join("fresh.sqlite3")).unwrap();
1771 migrate_schema(&fresh).unwrap();
1772
1773 let upgraded = Connection::open(directory.path().join("upgraded.sqlite3")).unwrap();
1774 create_baseline_schema(&upgraded).unwrap();
1775 upgraded
1776 .execute_batch(
1777 "CREATE TRIGGER stop_before_restart_migration BEFORE INSERT ON schema_migrations
1778 WHEN NEW.version > 72
1779 BEGIN SELECT RAISE(ABORT, 'fixture migration boundary'); END;",
1780 )
1781 .unwrap();
1782 assert!(migrate_schema(&upgraded).is_err());
1783 if !upgraded.is_autocommit() {
1784 upgraded.execute_batch("ROLLBACK").unwrap();
1785 }
1786 upgraded
1787 .execute_batch("DROP TRIGGER stop_before_restart_migration")
1788 .unwrap();
1789 assert_eq!(read_schema_state(&upgraded).unwrap().revision, 72);
1790 assert!(migrate_schema(&upgraded).is_ok());
1791
1792 let table_sql = |connection: &Connection| {
1793 connection
1794 .query_row(
1795 "SELECT sql FROM sqlite_schema WHERE type='table' AND name='session_restart_intents'",
1796 [],
1797 |row| row.get::<_, String>(0),
1798 )
1799 .unwrap()
1800 };
1801 assert_eq!(table_sql(&fresh), table_sql(&upgraded));
1802 assert_eq!(read_schema_state(&fresh).unwrap().revision, SCHEMA_VERSION);
1803 assert_eq!(
1804 read_schema_state(&upgraded).unwrap().revision,
1805 SCHEMA_VERSION
1806 );
1807 }
1808
1809 fn assert_divergent_history_upgrades(revision: i64, project_history: bool, interrupt: bool) {
1810 let directory = tempfile::tempdir().unwrap();
1811 let path = directory.path().join("divergent-history.sqlite");
1812 let connection = Connection::open(&path).unwrap();
1813 create_baseline_schema(&connection).unwrap();
1814 connection.execute_batch(&format!(
1815 "INSERT INTO session_contexts(session_id,bundle_id,created_at)
1816 VALUES ('kept','project','now');
1817 INSERT INTO sessions(session_id,title,harness_kind,last_profile,target_template_id,state,updated_at,project_directory)
1818 VALUES ('kept','Keep my work','codex','codex','local','error','now',X'2F7265706F');
1819 INSERT INTO materialized_sessions(session_id) VALUES ('kept');
1820 INSERT INTO session_turn_usage VALUES ('kept','turn',1,1,'{{\"command_id\":\"turn\",\"completed_ordinal\":1,\"completed_at_ms\":100,\"outcome\":{{\"kind\":\"completed\",\"stop_reason\":\"EndTurn\"}}}}');
1821 INSERT INTO session_provider_cost VALUES ('kept','{{\"amount\":1}}');
1822 CREATE TRIGGER stop_at_revision BEFORE INSERT ON schema_migrations
1823 WHEN NEW.version > {revision}
1824 BEGIN SELECT RAISE(ABORT,'fixture migration boundary'); END;"
1825 )).unwrap();
1826 assert!(migrate_schema(&connection).is_err());
1827 if !connection.is_autocommit() {
1828 connection.execute_batch("ROLLBACK").unwrap();
1829 }
1830 connection
1831 .execute_batch("DROP TRIGGER stop_at_revision")
1832 .unwrap();
1833 assert_eq!(read_schema_state(&connection).unwrap().revision, revision);
1834 if project_history {
1835 connection
1836 .execute_batch(include_str!("project_catalog_v67.sql"))
1837 .unwrap();
1838 connection
1839 .execute_batch(
1840 "INSERT INTO project_catalog VALUES ('project','key','{}',0);
1841 INSERT INTO project_aliases VALUES ('alias','project','{}',1);
1842 INSERT INTO project_session_aliases VALUES ('kept','alias');
1843 UPDATE sessions SET project_json='{\"kept\":true}';",
1844 )
1845 .unwrap();
1846 }
1847 if interrupt {
1848 connection
1849 .execute_batch(
1850 "CREATE TRIGGER interrupt_reconciliation BEFORE INSERT ON schema_migrations
1851 WHEN NEW.version=69 BEGIN SELECT RAISE(ABORT,'interrupted reconciliation'); END;",
1852 )
1853 .unwrap();
1854 assert!(migrate_schema(&connection).is_err());
1855 drop(connection);
1856 let connection = Connection::open(&path).unwrap();
1857 assert_eq!(read_schema_state(&connection).unwrap().revision, 68);
1858 connection
1859 .execute_batch("DROP TRIGGER interrupt_reconciliation")
1860 .unwrap();
1861 } else {
1862 drop(connection);
1863 }
1864
1865 let writer = open_writer(&path).unwrap();
1866 let state = read_schema_state(&writer).unwrap();
1867 assert_eq!(state.revision, SCHEMA_VERSION);
1868 assert!(state.ensure_supported_by(68).is_err());
1869 assert!(state.ensure_supported_by(67).is_err());
1870 if project_history {
1871 let snapshot: String = writer
1872 .query_row(
1873 "SELECT project_json FROM sessions WHERE session_id='kept'",
1874 [],
1875 |row| row.get(0),
1876 )
1877 .unwrap();
1878 assert_eq!(snapshot, r#"{"kept":true}"#);
1879 let alias: (String, i64) = writer.query_row(
1880 "SELECT canonical_id,config_pending FROM project_aliases WHERE bundle_id='alias'", [],
1881 |row| Ok((row.get(0)?, row.get(1)?))
1882 ).unwrap();
1883 assert_eq!(alias, ("project".to_owned(), 1));
1884 }
1885 writer.execute_batch(
1887 "UPDATE sessions SET state='startup-cleanup',project_directory=X'2F6E6577' WHERE session_id='kept';
1888 INSERT INTO session_turn_selections VALUES ('kept','turn','model','high');"
1889 ).unwrap();
1890 let changed: Vec<u8> = writer
1891 .query_row(
1892 "SELECT directory FROM project_discovery_changes ORDER BY sequence DESC LIMIT 1",
1893 [],
1894 |row| row.get(0),
1895 )
1896 .unwrap();
1897 assert_eq!(changed, b"/new");
1898 writer
1899 .execute("DELETE FROM sessions WHERE session_id='kept'", [])
1900 .unwrap();
1901 let usage: String = writer
1902 .query_row("SELECT body FROM session_turn_usage", [], |row| row.get(0))
1903 .unwrap();
1904 assert_eq!(deserialize_turn_outcome(&usage).unwrap().command_id, "turn");
1905 let cost: String = writer
1906 .query_row("SELECT body FROM session_provider_cost", [], |row| {
1907 row.get(0)
1908 })
1909 .unwrap();
1910 assert_eq!(cost, r#"{"amount":1}"#);
1911 assert!(
1912 !writer
1913 .prepare("PRAGMA foreign_key_check")
1914 .unwrap()
1915 .exists([])
1916 .unwrap()
1917 );
1918 drop(writer);
1919 forget_verified_schema(&path);
1920 assert_eq!(
1921 read_schema_state(&open_writer(&path).unwrap())
1922 .unwrap()
1923 .revision,
1924 SCHEMA_VERSION
1925 );
1926 }
1927
1928 #[test]
1929 fn divergent_accounting_revision_67_preserves_usage_and_adds_projects() {
1930 assert_divergent_history_upgrades(67, false, false);
1931 }
1932
1933 #[test]
1934 fn divergent_cleanup_revision_68_preserves_usage_and_adds_projects() {
1935 assert_divergent_history_upgrades(68, false, false);
1936 }
1937
1938 #[test]
1939 fn divergent_project_revision_67_preserves_aliases_snapshots_and_usage() {
1940 assert_divergent_history_upgrades(66, true, false);
1941 }
1942
1943 #[test]
1944 fn divergent_project_reconciliation_resumes_after_interruption() {
1945 assert_divergent_history_upgrades(66, true, true);
1946 }
1947
1948 #[test]
1949 fn move_ownership_upgrade_retains_sources_and_refuses_previous_daemons() {
1950 let directory = tempfile::tempdir().unwrap();
1951 let path = directory.path().join("move.sqlite");
1952 let connection = open_writer(&path).unwrap();
1953 connection.execute_batch("DROP TABLE retained_move_sources; DELETE FROM schema_migrations WHERE version>=64; UPDATE schema_compatibility SET minimum_compatible_version=63 WHERE singleton=1; DROP TABLE IF EXISTS subagent_accounting; DROP TABLE IF EXISTS session_turn_selections; ALTER TABLE mailbox_outbox DROP COLUMN failure; PRAGMA writable_schema=ON; UPDATE sqlite_schema SET sql=replace(sql, '''startup-cleanup'',', '') WHERE type='table' AND name='sessions'; PRAGMA writable_schema=RESET; PRAGMA user_version=63;").unwrap();
1954 drop(connection);
1955 forget_verified_schema(&path);
1956 let upgraded = open_writer(&path).unwrap();
1957 let schema = read_schema_state(&upgraded).unwrap();
1958 assert!(schema.ensure_supported_by(63).is_err());
1959 upgraded.execute("INSERT INTO retained_move_sources VALUES ('move-one','session-one','{}','[]','now')", []).unwrap();
1960 drop(upgraded);
1961 let reopened = open_writer(&path).unwrap();
1962 let count: i64 = reopened
1963 .query_row("SELECT count(*) FROM retained_move_sources", [], |row| {
1964 row.get(0)
1965 })
1966 .unwrap();
1967 assert_eq!(count, 1);
1968 }
1969
1970 #[test]
1971 fn ec2_move_ownership_upgrade_is_atomic_and_preserves_existing_moves() {
1972 let directory = tempfile::tempdir().unwrap();
1973 let path = directory.path().join("ec2-move-migration.sqlite3");
1974 save_session_to(&path, &super::super::tests::session("source", "project")).unwrap();
1975 stamp_schema_version(&path, 70);
1976 let old = Connection::open(&path).unwrap();
1977 old.execute_batch(
1978 "UPDATE schema_compatibility SET minimum_compatible_version=69;
1979 INSERT INTO session_moves(session_id,operation_id,operation_json)
1980 VALUES('source','existing-move','{\"operation_id\":\"existing-move\"}');
1981 CREATE TRIGGER stop_ec2_move_migration BEFORE INSERT ON schema_migrations
1982 WHEN NEW.version=71 BEGIN SELECT RAISE(ABORT,'fixture boundary'); END;",
1983 )
1984 .unwrap();
1985 assert!(migrate_schema(&old).is_err());
1986 let state = read_schema_state(&old).unwrap();
1987 assert_eq!(state.revision, 70);
1988 assert_eq!(state.minimum_compatible, Some(69));
1989 old.execute_batch("DROP TRIGGER stop_ec2_move_migration")
1990 .unwrap();
1991 drop(old);
1992 let upgraded = open_writer(&path).unwrap();
1993 let state = read_schema_state(&upgraded).unwrap();
1994 assert_eq!(state.revision, SCHEMA_VERSION);
1995 assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
1996 assert!(state.ensure_supported_by(70).is_err());
1997 let preserved: String = upgraded
1998 .query_row(
1999 "SELECT operation_json FROM session_moves WHERE session_id='source'",
2000 [],
2001 |row| row.get(0),
2002 )
2003 .unwrap();
2004 assert_eq!(preserved, r#"{"operation_id":"existing-move"}"#);
2005 assert_eq!(
2006 upgraded
2007 .query_row(
2008 "SELECT count(*) FROM sessions WHERE session_id='source'",
2009 [],
2010 |row| row.get::<_, i64>(0)
2011 )
2012 .unwrap(),
2013 1
2014 );
2015 }
2016
2017 #[test]
2018 fn title_migration_caps_old_titles_preserves_short_titles_and_is_compatible() {
2019 let directory = tempfile::tempdir().unwrap();
2020 let path = directory.path().join("title-migration.sqlite3");
2021 let titles = [
2022 ("long", Some("word ".repeat(20_000))),
2023 ("unicode", Some("界".repeat(257))),
2024 ("exact", Some("界".repeat(256))),
2025 ("short", Some(" Keep\nthis title ".into())),
2026 ("unset", None),
2027 ];
2028 for (id, _) in &titles {
2029 let mut session = super::super::tests::session(id, "project");
2030 session.state = SessionState::Stopped;
2031 save_session_to(&path, &session).unwrap();
2032 }
2033 stamp_schema_version(&path, 69);
2034 let old = Connection::open(&path).unwrap();
2035 old.execute_batch(
2036 "UPDATE schema_compatibility SET minimum_compatible_version=69;
2037 CREATE TRIGGER stop_after_title_migration BEFORE INSERT ON schema_migrations
2038 WHEN NEW.version=71 BEGIN SELECT RAISE(ABORT,'fixture boundary'); END;",
2039 )
2040 .unwrap();
2041 for (id, title) in &titles {
2042 old.execute(
2043 "UPDATE sessions SET acp_session_title=?2 WHERE session_id=?1",
2044 params![id, title],
2045 )
2046 .unwrap();
2047 }
2048 assert!(migrate_schema(&old).is_err());
2049 let upgraded = old;
2050 let state = read_schema_state(&upgraded).unwrap();
2051 assert_eq!(state.revision, 70);
2052 assert_eq!(state.minimum_compatible, Some(69));
2053 state.ensure_supported_by(69).unwrap();
2054 for (id, original) in &titles {
2055 let stored: Option<String> = upgraded
2056 .query_row(
2057 "SELECT acp_session_title FROM sessions WHERE session_id=?1",
2058 [id],
2059 |row| row.get(0),
2060 )
2061 .unwrap();
2062 let expected = match *id {
2063 "long" => Some(format!("{}word…", "word ".repeat(50))),
2064 "unicode" => Some(format!("{}…", "界".repeat(255))),
2065 _ => original.clone(),
2066 };
2067 assert_eq!(stored, expected, "session {id}");
2068 assert!(stored.is_none_or(|title| title.chars().count() <= 256));
2069 }
2070 upgraded
2071 .execute_batch("DROP TRIGGER stop_after_title_migration")
2072 .unwrap();
2073 drop(upgraded);
2074 forget_verified_schema(&path);
2075 drop(open_writer(&path).unwrap());
2076 }
2077
2078 #[test]
2079 fn interrupted_title_migration_rolls_back_titles_and_revision() {
2080 let directory = tempfile::tempdir().unwrap();
2081 let path = directory.path().join("title-migration-interrupted.sqlite3");
2082 save_session_to(&path, &super::super::tests::session("old", "project")).unwrap();
2083 stamp_schema_version(&path, 69);
2084 let original = "word ".repeat(20_000);
2085 let old = Connection::open(&path).unwrap();
2086 old.execute("UPDATE sessions SET acp_session_title=?1", [&original])
2087 .unwrap();
2088 old.execute_batch(
2089 "CREATE TRIGGER stop_title_migration BEFORE INSERT ON schema_migrations
2090 WHEN NEW.version=70 BEGIN SELECT RAISE(ABORT,'fixture boundary'); END;",
2091 )
2092 .unwrap();
2093 assert!(migrate_schema(&old).is_err());
2094 assert_eq!(read_schema_state(&old).unwrap().revision, 69);
2095 let stored: String = old
2096 .query_row("SELECT acp_session_title FROM sessions", [], |row| {
2097 row.get(0)
2098 })
2099 .unwrap();
2100 assert_eq!(stored, original);
2101 old.execute_batch("DROP TRIGGER stop_title_migration")
2102 .unwrap();
2103 drop(old);
2104 drop(open_writer(&path).unwrap());
2105 assert!(
2106 load_state_from(&path).unwrap().sessions["old"]
2107 .acp_session_title
2108 .as_ref()
2109 .unwrap()
2110 .chars()
2111 .count()
2112 <= 256
2113 );
2114 }
2115
2116 #[test]
2117 fn recent_revisions_upgrade_directly_and_preserve_user_data() {
2118 const TEST_REVISION_FLOOR: i64 = 47;
2121 for revision in TEST_REVISION_FLOOR..SCHEMA_VERSION {
2122 let directory = tempfile::tempdir().unwrap();
2123 let path = directory.path().join("mj.sqlite3");
2124 let connection = Connection::open(&path).unwrap();
2125 connection
2126 .execute_batch(include_str!("legacy_v1.sql"))
2127 .unwrap();
2128 connection.execute_batch(
2129 "INSERT INTO session_contexts VALUES ('old-session', 'project', '2026-01-01T00:00:00Z');
2130 INSERT INTO sessions(session_id, title, harness_kind, last_profile,
2131 target_template_id, state, updated_at, native_session_id)
2132 VALUES ('old-session', 'Keep my work', 'codex', 'codex', 'local', 'error',
2133 '2026-01-01T00:00:00Z', 'native-original');
2134 INSERT INTO prompt_history(session_id, event_sequence, submitted_at, text)
2135 VALUES ('old-session', 1, '2026-01-01T00:00:00Z', 'Keep my prompt');"
2136 ).unwrap();
2137 connection
2141 .execute_batch(&format!(
2142 "CREATE TRIGGER stop_at_revision BEFORE INSERT ON schema_migrations
2143 WHEN NEW.version > {revision}
2144 BEGIN SELECT RAISE(ABORT, 'fixture migration boundary'); END;"
2145 ))
2146 .unwrap();
2147 super::super::legacy_schema::migrate_to_baseline(&connection).unwrap();
2148 assert!(migrate_schema(&connection).is_err());
2149 drop(connection);
2150 let connection = Connection::open(&path).unwrap();
2151 let found: i64 = connection
2152 .query_row("PRAGMA user_version", [], |row| row.get(0))
2153 .unwrap();
2154 assert_eq!(found, revision);
2155 connection
2156 .execute_batch("DROP TRIGGER stop_at_revision")
2157 .unwrap();
2158 drop(connection);
2159
2160 let writer =
2161 open_writer(&path).unwrap_or_else(|error| panic!("revision {revision}: {error:#}"));
2162 let schema = read_schema_state(&writer).unwrap();
2163 assert_eq!(schema.revision, SCHEMA_VERSION);
2164 assert_eq!(schema.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
2165 assert!(schema.ensure_supported_by(78).is_err());
2166 assert_eq!(
2167 writer
2168 .query_row("PRAGMA integrity_check", [], |row| row.get::<_, String>(0))
2169 .unwrap(),
2170 "ok"
2171 );
2172 assert!(
2173 !writer
2174 .prepare("PRAGMA foreign_key_check")
2175 .unwrap()
2176 .exists([])
2177 .unwrap()
2178 );
2179 drop(writer);
2180 let reader = open_reader_strict(&path).unwrap();
2181 let prompt: String = reader
2182 .query_row("SELECT text FROM prompt_history", [], |row| row.get(0))
2183 .unwrap();
2184 assert_eq!(prompt, "Keep my prompt");
2185 let state = load_state_from(&path).unwrap();
2186 assert_eq!(state.sessions["old-session"].title, "Keep my work");
2187 assert_eq!(
2188 state.sessions["old-session"].native_session_id.as_deref(),
2189 Some("native-original")
2190 );
2191 drop(reader);
2192 forget_verified_schema(&path);
2194 drop(open_writer(&path).unwrap());
2195 }
2196 }
2197
2198 #[test]
2199 fn revision_77_makes_prompt_ordinals_nullable_and_preserves_history() {
2200 let directory = tempfile::tempdir().unwrap();
2201 let path = directory.path().join("mj.sqlite3");
2202 let connection = Connection::open(&path).unwrap();
2203 create_baseline_schema(&connection).unwrap();
2204 migrate_schema(&connection).unwrap();
2205 connection
2206 .execute(
2207 "INSERT INTO session_contexts(session_id, bundle_id, created_at)
2208 VALUES ('old-session', 'project', '2026-01-01T00:00:00Z')",
2209 [],
2210 )
2211 .unwrap();
2212 connection
2213 .execute(
2214 "INSERT INTO prompt_history(session_id, event_ordinal, submitted_at, text)
2215 VALUES ('old-session', 11, '2026-01-01T00:00:00Z', 'kept prompt')",
2216 [],
2217 )
2218 .unwrap();
2219 connection
2222 .execute_batch(
2223 "CREATE TABLE prompt_history_v76 (
2224 history_id INTEGER PRIMARY KEY,
2225 session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
2226 event_ordinal INTEGER NOT NULL CHECK(event_ordinal >= 0),
2227 submitted_at TEXT NOT NULL,
2228 text TEXT NOT NULL CHECK(length(trim(text)) > 0),
2229 UNIQUE(session_id, event_ordinal)
2230 ) STRICT;
2231 INSERT INTO prompt_history_v76
2232 SELECT history_id, session_id, event_ordinal, submitted_at, text
2233 FROM prompt_history;
2234 DROP TABLE prompt_history;
2235 ALTER TABLE prompt_history_v76 RENAME TO prompt_history;
2236 CREATE INDEX prompt_history_session_recent
2237 ON prompt_history(session_id, history_id DESC);
2238 CREATE INDEX prompt_history_recent ON prompt_history(history_id DESC);
2239 DELETE FROM schema_migrations WHERE version >= 77;
2240 UPDATE schema_compatibility SET minimum_compatible_version = 74;
2241 PRAGMA user_version = 76;",
2242 )
2243 .unwrap();
2244 drop(connection);
2245 forget_verified_schema(&path);
2246
2247 let migrated = open_writer(&path).unwrap();
2248 assert_eq!(
2249 read_schema_state(&migrated).unwrap().revision,
2250 SCHEMA_VERSION
2251 );
2252 let preserved: (i64, i64, String) = migrated
2253 .query_row(
2254 "SELECT history_id, event_ordinal, text FROM prompt_history",
2255 [],
2256 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2257 )
2258 .unwrap();
2259 assert_eq!(preserved, (1, 11, "kept prompt".into()));
2260 let ordinal_not_null: i64 = migrated
2261 .query_row(
2262 "SELECT \"notnull\" FROM pragma_table_info('prompt_history')
2263 WHERE name = 'event_ordinal'",
2264 [],
2265 |row| row.get(0),
2266 )
2267 .unwrap();
2268 assert_eq!(ordinal_not_null, 0);
2269 let indexes: Vec<String> = migrated
2270 .prepare("PRAGMA index_list('prompt_history')")
2271 .unwrap()
2272 .query_map([], |row| row.get(1))
2273 .unwrap()
2274 .collect::<rusqlite::Result<_>>()
2275 .unwrap();
2276 assert!(indexes.contains(&"prompt_history_session_recent".into()));
2277 assert!(indexes.contains(&"prompt_history_recent".into()));
2278 }
2279
2280 #[test]
2281 fn accounting_migration_preserves_usage_and_changes_its_deletion_owner() {
2282 let dir = tempfile::tempdir().unwrap();
2283 let path = dir.path().join("migration.sqlite");
2284 let connection = Connection::open(&path).unwrap();
2285 connection
2286 .execute_batch(include_str!("legacy_v1.sql"))
2287 .unwrap();
2288 connection.execute_batch("INSERT INTO session_contexts VALUES ('old-session','project','2026-01-01T00:00:00Z');
2289 INSERT INTO sessions(session_id,title,harness_kind,last_profile,target_template_id,state,updated_at)
2290 VALUES ('old-session','Retain usage','codex','codex','local','error','2026-01-01T00:00:00Z');
2291 CREATE TRIGGER stop_before_accounting BEFORE INSERT ON schema_migrations WHEN NEW.version=67
2292 BEGIN SELECT RAISE(ABORT,'fixture boundary'); END;").unwrap();
2293 super::super::legacy_schema::migrate_to_baseline(&connection).unwrap();
2294 assert!(migrate_schema(&connection).is_err());
2295 connection
2296 .execute_batch(
2297 "ROLLBACK; DROP TRIGGER stop_before_accounting;
2298 INSERT INTO session_turn_usage VALUES ('old-session','turn',7,1,'{\"command_id\":\"turn\",\"completed_ordinal\":7,\"completed_at_ms\":100,\"outcome\":{\"kind\":\"completed\",\"stop_reason\":\"EndTurn\"}}');
2299 INSERT INTO session_provider_cost VALUES ('old-session','{\"amount\":1}');",
2300 )
2301 .unwrap();
2302 drop(connection);
2303 let writer = open_writer(&path).unwrap();
2304 writer
2305 .execute("DELETE FROM sessions WHERE session_id='old-session'", [])
2306 .unwrap();
2307 for table in ["session_turn_usage", "session_provider_cost"] {
2308 assert_eq!(
2309 writer
2310 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
2311 .get::<_, u64>(0))
2312 .unwrap(),
2313 1
2314 );
2315 }
2316 let body: String = writer
2317 .query_row("SELECT body FROM session_turn_usage", [], |row| row.get(0))
2318 .unwrap();
2319 assert_eq!(deserialize_turn_outcome(&body).unwrap().command_id, "turn");
2320 assert!(
2321 !writer
2322 .prepare("PRAGMA foreign_key_check")
2323 .unwrap()
2324 .exists([])
2325 .unwrap()
2326 );
2327 assert_eq!(
2328 read_schema_state(&writer).unwrap().minimum_compatible,
2329 Some(MINIMUM_COMPATIBLE_VERSION)
2330 );
2331 }
2332
2333 const MINIMUM_COMPATIBLE_VERSION: i64 = 82;
2336
2337 fn stamp_schema_version(path: &Path, version: i64) {
2340 if version > SCHEMA_VERSION {
2341 advance_test_schema(path, version, version);
2342 return;
2343 }
2344 let connection = Connection::open(path).unwrap();
2345 if version < 67 {
2346 connection.execute_batch("DROP TABLE IF EXISTS subagent_accounting; DROP TABLE IF EXISTS session_turn_selections; PRAGMA writable_schema=ON; UPDATE sqlite_schema SET sql=replace(sql, '''startup-cleanup'',', '') WHERE type='table' AND name='sessions'; PRAGMA writable_schema=RESET;").unwrap();
2347 }
2348 if version < 76
2350 && legacy_schema::table_has_column(&connection, "mailbox_outbox", "failure").unwrap()
2351 {
2352 connection
2353 .execute_batch("ALTER TABLE mailbox_outbox DROP COLUMN failure;")
2354 .unwrap();
2355 }
2356 connection
2357 .execute_batch(&format!("PRAGMA user_version = {version};"))
2358 .unwrap();
2359 connection
2360 .execute(
2361 "DELETE FROM schema_migrations WHERE version > ?1",
2362 [version],
2363 )
2364 .unwrap();
2365 if version == 30 {
2366 connection
2367 .execute(
2368 "UPDATE schema_compatibility SET minimum_compatible_version = 30 WHERE singleton = 1",
2369 [],
2370 )
2371 .unwrap();
2372 }
2373 if matches!(version, 69 | 70) {
2374 connection.execute(
2375 "UPDATE schema_compatibility SET minimum_compatible_version=69 WHERE singleton=1", [],
2376 ).unwrap();
2377 }
2378 drop(connection);
2379 forget_verified_schema(path);
2380 }
2381
2382 #[test]
2383 fn subagent_policy_migration_preserves_legacy_choices_and_refuses_old_writers() {
2384 use mj_core::subagent::SubagentPolicy;
2385 let directory = tempfile::tempdir().unwrap();
2386 let path = directory.path().join("subagent-policy.sqlite3");
2387 for id in ["all", "native", "unset"] {
2388 save_session_to(&path, &super::super::tests::session(id, "project")).unwrap();
2389 }
2390 let connection = Connection::open(&path).unwrap();
2391 connection
2392 .execute_batch(
2393 "UPDATE sessions SET mjolnir_subagents = 1 WHERE session_id = 'all';
2394 UPDATE sessions SET mjolnir_subagents = 0 WHERE session_id = 'native';
2395 ALTER TABLE sessions DROP COLUMN subagents;
2396 DROP TABLE subagent_preference;
2397 DELETE FROM schema_migrations WHERE version >= 56;
2398 UPDATE schema_compatibility SET minimum_compatible_version = 55;
2399 DROP TABLE IF EXISTS subagent_accounting; DROP TABLE IF EXISTS session_turn_selections; ALTER TABLE mailbox_outbox DROP COLUMN failure; PRAGMA writable_schema=ON; UPDATE sqlite_schema SET sql=replace(sql, '''startup-cleanup'',', '') WHERE type='table' AND name='sessions'; PRAGMA writable_schema=RESET; PRAGMA user_version = 55;",
2400 )
2401 .unwrap();
2402 drop(connection);
2403 forget_verified_schema(&path);
2404 let upgraded = open_writer(&path).unwrap();
2405 assert!(
2406 read_schema_state(&upgraded)
2407 .unwrap()
2408 .ensure_supported_by(55)
2409 .is_err()
2410 );
2411 drop(upgraded);
2412 let state = load_state_from(&path).unwrap();
2413 assert_eq!(
2414 state.sessions["all"].subagents,
2415 Some(SubagentPolicy::Mjolnir {
2416 straightforward: None,
2417 subtle: None
2418 })
2419 );
2420 assert_eq!(
2421 state.sessions["native"].subagents,
2422 Some(SubagentPolicy::Native)
2423 );
2424 assert_eq!(state.sessions["unset"].subagents, None);
2425 assert_eq!(state.last_subagent_policy, SubagentPolicy::Native);
2426 }
2427
2428 #[test]
2429 fn steering_migration_refuses_builds_that_cannot_read_returned_steers() {
2430 let directory = tempfile::tempdir().unwrap();
2431 let path = directory.path().join("steering-migration.sqlite3");
2432 let record = super::super::tests::session("steered-session", "project");
2433 save_session_to(&path, &record).unwrap();
2434 let connection = Connection::open(&path).unwrap();
2435 connection
2436 .execute_batch(
2437 "DELETE FROM schema_migrations WHERE version >= 48;
2438 UPDATE schema_compatibility SET minimum_compatible_version = 47;
2439 DROP TABLE IF EXISTS subagent_accounting; DROP TABLE IF EXISTS session_turn_selections; ALTER TABLE mailbox_outbox DROP COLUMN failure; PRAGMA writable_schema=ON; UPDATE sqlite_schema SET sql=replace(sql, '''startup-cleanup'',', '') WHERE type='table' AND name='sessions'; PRAGMA writable_schema=RESET; PRAGMA user_version = 47;",
2440 )
2441 .unwrap();
2442 drop(connection);
2443 forget_verified_schema(&path);
2444 let upgraded = open_writer(&path).unwrap();
2445 let schema = read_schema_state(&upgraded).unwrap();
2446 assert_eq!(schema.revision, SCHEMA_VERSION);
2447 assert_eq!(schema.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
2448 let error = schema.ensure_supported_by(47).unwrap_err();
2449 assert!(matches!(
2450 error.downcast_ref::<StoreSchemaMismatch>().unwrap().reason,
2451 StoreSchemaMismatchReason::Incompatible {
2452 minimum_compatible: MINIMUM_COMPATIBLE_VERSION
2453 }
2454 ));
2455 drop(upgraded);
2456 assert_eq!(load_state_from(&path).unwrap().sessions[&record.id], record);
2457 }
2458
2459 #[test]
2460 fn durable_target_migration_preserves_sessions_and_refuses_previous_builds() {
2461 let directory = tempfile::tempdir().unwrap();
2462 let path = directory.path().join("target-migration.sqlite3");
2463 let record = super::super::tests::session("preserved-session", "project");
2464 save_session_to(&path, &record).unwrap();
2465 let connection = Connection::open(&path).unwrap();
2466 connection
2467 .execute_batch(
2468 "ALTER TABLE sessions DROP COLUMN target_runtime_json;
2469 DELETE FROM schema_migrations WHERE version >= 46;
2470 UPDATE schema_compatibility SET minimum_compatible_version = 44;
2471 DROP TABLE IF EXISTS subagent_accounting; DROP TABLE IF EXISTS session_turn_selections; ALTER TABLE mailbox_outbox DROP COLUMN failure; PRAGMA writable_schema=ON; UPDATE sqlite_schema SET sql=replace(sql, '''startup-cleanup'',', '') WHERE type='table' AND name='sessions'; PRAGMA writable_schema=RESET; PRAGMA user_version = 45;",
2472 )
2473 .unwrap();
2474 forget_verified_schema(&path);
2475 let upgraded = open_writer(&path).unwrap();
2476 let schema = read_schema_state(&upgraded).unwrap();
2477 assert_eq!(schema.revision, SCHEMA_VERSION);
2478 assert_eq!(schema.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
2479 let error = schema.ensure_supported_by(45).unwrap_err();
2480 assert!(matches!(
2481 error.downcast_ref::<StoreSchemaMismatch>().unwrap().reason,
2482 StoreSchemaMismatchReason::Incompatible {
2483 minimum_compatible: MINIMUM_COMPATIBLE_VERSION
2484 }
2485 ));
2486 drop(upgraded);
2487 let restored = load_state_from(&path).unwrap();
2488 assert_eq!(restored.sessions[&record.id], record);
2489 }
2490
2491 #[test]
2492 fn native_agents_and_unstructured_input_raise_the_store_compatibility_floor() {
2493 let directory = tempfile::tempdir().unwrap();
2494 let path = directory.path().join("mj.sqlite3");
2495 let connection = open_writer(&path).unwrap();
2496 connection
2497 .execute_batch(
2498 "BEGIN IMMEDIATE;
2499 DROP TABLE quota_reset_cache;
2500 DROP TABLE native_agent_transcript;
2501 DROP TABLE native_agents;
2502 DROP TABLE native_agent_replay;
2503 DELETE FROM schema_migrations WHERE version >= 39;
2504 UPDATE schema_compatibility SET minimum_compatible_version = 32;
2505 DROP TABLE IF EXISTS subagent_accounting; DROP TABLE IF EXISTS session_turn_selections; ALTER TABLE mailbox_outbox DROP COLUMN failure; PRAGMA writable_schema=ON; UPDATE sqlite_schema SET sql=replace(sql, '''startup-cleanup'',', '') WHERE type='table' AND name='sessions'; PRAGMA writable_schema=RESET; PRAGMA user_version = 38;
2506 COMMIT;",
2507 )
2508 .unwrap();
2509 migrate_schema(&connection).unwrap();
2510 let state = read_schema_state(&connection).unwrap();
2511 assert_eq!(state.revision, SCHEMA_VERSION);
2512 assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
2513 let event = ApiEventData::InputRequired {
2514 request: None,
2515 turn_id: Some(1),
2516 };
2517 #[derive(serde::Deserialize)]
2518 struct LegacyInputEvent {
2519 #[serde(rename = "request")]
2520 _request: mj_core::elicitation::ElicitationRequest,
2521 }
2522 let encoded = serde_json::to_value(&event).unwrap();
2523 assert!(serde_json::from_value::<LegacyInputEvent>(encoded["data"].clone()).is_err());
2524 }
2525
2526 #[test]
2527 fn older_readers_and_reopened_writers_preserve_a_compatible_future_schema() {
2528 let directory = tempfile::tempdir().unwrap();
2529 let path = directory.path().join("mj.sqlite3");
2530 let connection = open_writer(&path).unwrap();
2531 connection
2532 .execute_batch(
2533 "CREATE TABLE future_feature(value TEXT NOT NULL);
2534 INSERT INTO future_feature VALUES ('preserve me');",
2535 )
2536 .unwrap();
2537 drop(connection);
2538 advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
2539
2540 let reader = open_reader_strict(&path).unwrap();
2541 assert_eq!(
2542 reader
2543 .query_row("SELECT value FROM future_feature", [], |row| row
2544 .get::<_, String>(0))
2545 .unwrap(),
2546 "preserve me"
2547 );
2548 assert!(reader.execute("DELETE FROM future_feature", []).is_err());
2549 drop(reader);
2550
2551 let raw = Connection::open(&path).unwrap();
2554 raw.execute_batch("DROP TRIGGER session_contexts_workspace_update;")
2555 .unwrap();
2556 drop(raw);
2557 let writer = open_writer(&path).unwrap();
2558 assert!(!writer.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'session_contexts_workspace_update')", [], |row| row.get::<_, bool>(0)).unwrap());
2559 assert_eq!(
2560 writer
2561 .query_row("SELECT value FROM future_feature", [], |row| row
2562 .get::<_, String>(0))
2563 .unwrap(),
2564 "preserve me"
2565 );
2566 let state = read_schema_state(&writer).unwrap();
2567 assert_eq!(state.revision, SCHEMA_VERSION + 1);
2568 assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
2569 }
2570
2571 #[test]
2572 fn invalid_compatibility_metadata_refuses_readers_and_writers() {
2573 for alteration in [
2574 "DROP TABLE schema_compatibility",
2575 "DELETE FROM schema_compatibility",
2576 "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET minimum_compatible_version = 0",
2577 "UPDATE schema_compatibility SET minimum_compatible_version = 99999",
2578 "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET singleton = 2",
2579 "PRAGMA ignore_check_constraints = ON; INSERT INTO schema_compatibility VALUES (2, 30)",
2580 "DROP TABLE schema_compatibility; CREATE TABLE schema_compatibility(singleton, minimum_compatible_version); INSERT INTO schema_compatibility VALUES (1, 'invalid')",
2581 "DELETE FROM schema_migrations WHERE version = (SELECT max(version) FROM schema_migrations)",
2582 ] {
2583 for future in [false, true] {
2584 let directory = tempfile::tempdir().unwrap();
2585 let path = directory.path().join("mj.sqlite3");
2586 drop(open_writer(&path).unwrap());
2587 if future {
2588 advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
2589 }
2590 let raw = Connection::open(&path).unwrap();
2591 raw.execute_batch(alteration).unwrap();
2592 let before: i64 = raw
2593 .query_row("PRAGMA schema_version", [], |row| row.get(0))
2594 .unwrap();
2595 for error in [
2597 open_reader_strict(&path).unwrap_err(),
2598 open_writer(&path).unwrap_err(),
2599 ] {
2600 let mismatch = error.downcast_ref::<StoreSchemaMismatch>().unwrap();
2601 assert_eq!(
2602 mismatch.reason,
2603 StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
2604 "{alteration}"
2605 );
2606 }
2607 forget_verified_schema(&path);
2608 assert!(open_writer(&path).is_err(), "{alteration}");
2609 let after: i64 = raw
2610 .query_row("PRAGMA schema_version", [], |row| row.get(0))
2611 .unwrap();
2612 assert_eq!(
2613 before, after,
2614 "a rejected open repaired schema: {alteration}"
2615 );
2616 }
2617 }
2618 }
2619
2620 #[test]
2621 fn a_failed_baseline_leaves_an_empty_store_that_a_retry_creates() {
2622 let directory = tempfile::tempdir().unwrap();
2623 let path = directory.path().join("mj.sqlite3");
2624 let connection = Connection::open(&path).unwrap();
2625 connection
2627 .execute_batch("CREATE TABLE workspaces(conflict TEXT)")
2628 .unwrap();
2629
2630 let error = migrate_schema(&connection).unwrap_err();
2631
2632 assert!(format!("{error:#}").contains("create baseline database schema"));
2633 assert!(
2634 connection.is_autocommit(),
2635 "the failed baseline left a transaction open"
2636 );
2637 assert_eq!(read_schema_state(&connection).unwrap().revision, 0);
2638 let tables: i64 = connection
2639 .query_row(
2640 "SELECT count(*) FROM sqlite_schema WHERE type = 'table'",
2641 [],
2642 |row| row.get(0),
2643 )
2644 .unwrap();
2645 assert_eq!(tables, 1, "only the conflicting table remains");
2646
2647 connection.execute_batch("DROP TABLE workspaces").unwrap();
2648 drop(connection);
2649 let writer = open_writer(&path).unwrap();
2650 let state = read_schema_state(&writer).unwrap();
2651 assert_eq!(state.revision, SCHEMA_VERSION);
2652 assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
2653 }
2654
2655 #[test]
2659 fn strict_reader_reports_a_newer_store_without_blaming_the_daemon() {
2660 let directory = tempfile::tempdir().unwrap();
2661 let path = directory.path().join("mj.sqlite3");
2662 drop(open_writer(&path).unwrap());
2663 stamp_schema_version(&path, SCHEMA_VERSION + 1);
2664
2665 let error = open_reader_strict(&path).unwrap_err();
2666
2667 let mismatch = error
2668 .chain()
2669 .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
2670 .expect("the reader reports the mismatch as a typed cause");
2671 assert_eq!(mismatch.found, SCHEMA_VERSION + 1);
2672 assert_eq!(mismatch.supported, SCHEMA_VERSION);
2673 let message = mismatch.to_string();
2674 assert!(message.contains("upgrade Mjolnir"), "got {message}");
2675 assert!(
2676 !message.contains("start the Mjolnir daemon"),
2677 "got {message}"
2678 );
2679 }
2680
2681 #[test]
2684 fn strict_reader_keeps_the_migrate_advice_when_the_store_is_behind() {
2685 let directory = tempfile::tempdir().unwrap();
2686 let path = directory.path().join("mj.sqlite3");
2687 drop(open_writer(&path).unwrap());
2688 let raw = Connection::open(&path).unwrap();
2689 raw.execute_batch(&format!(
2690 "UPDATE schema_compatibility SET minimum_compatible_version = {0};
2691 DELETE FROM schema_migrations WHERE version > {0};
2692 INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES ({0}, 'test');
2693 PRAGMA user_version = {0};",
2694 SCHEMA_VERSION - 1
2695 ))
2696 .unwrap();
2697 drop(raw);
2698
2699 let error = open_reader_strict(&path).unwrap_err();
2700
2701 let mismatch = error
2702 .chain()
2703 .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
2704 .expect("the reader reports the mismatch as a typed cause");
2705 assert_eq!(
2706 mismatch.to_string(),
2707 format!(
2708 "Mjolnir database schema {} is not the supported schema {SCHEMA_VERSION}; \
2709 start the Mjolnir daemon to migrate it",
2710 SCHEMA_VERSION - 1
2711 )
2712 );
2713 }
2714
2715 #[test]
2716 fn strict_reader_rejects_mutation() {
2717 let directory = tempfile::tempdir().unwrap();
2718 let path = directory.path().join("mj.sqlite3");
2719 drop(open_writer(&path).unwrap());
2720
2721 let reader = open_reader_strict(&path).unwrap();
2722 let error = reader
2723 .execute("CREATE TABLE forbidden(value TEXT)", [])
2724 .unwrap_err();
2725 assert!(
2726 matches!(
2727 error.sqlite_error_code(),
2728 Some(rusqlite::ErrorCode::ReadOnly)
2729 ),
2730 "unexpected mutation error: {error}"
2731 );
2732 }
2733
2734 fn opened_readers() -> usize {
2735 OPENED_READERS.with(std::cell::Cell::get)
2736 }
2737
2738 fn has_workspace(reader: &Connection, name: &str) -> bool {
2739 reader
2740 .query_row(
2741 "SELECT EXISTS(SELECT 1 FROM workspaces WHERE name = ?1)",
2742 [name],
2743 |row| row.get(0),
2744 )
2745 .unwrap()
2746 }
2747
2748 fn add_workspace(path: &Path, name: &str) {
2749 open_writer(path)
2750 .unwrap()
2751 .execute(
2752 "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
2753 VALUES (?1, ?1, ?1, 'now', 'now')",
2754 [name],
2755 )
2756 .unwrap();
2757 }
2758
2759 #[test]
2762 fn strict_readers_are_reused_and_see_later_commits() {
2763 let directory = tempfile::tempdir().unwrap();
2764 let path = directory.path().join("mj.sqlite3");
2765 drop(open_writer(&path).unwrap());
2766 let before = opened_readers();
2767 assert!(!has_workspace(&open_reader_strict(&path).unwrap(), "later"));
2768 add_workspace(&path, "later");
2769 for _ in 0..20 {
2770 assert!(has_workspace(&open_reader_strict(&path).unwrap(), "later"));
2771 }
2772 assert_eq!(
2773 opened_readers() - before,
2774 1,
2775 "one connection served every read"
2776 );
2777
2778 let first = open_reader_strict(&path).unwrap();
2780 let second = open_reader_strict(&path).unwrap();
2781 drop((first, second));
2782 assert_eq!(opened_readers() - before, 2);
2783 drop(open_reader_strict(&path).unwrap());
2784 drop(open_reader_strict(&path).unwrap());
2785 assert_eq!(opened_readers() - before, 2);
2786
2787 let reader = open_reader_strict(&path).unwrap();
2789 reader.execute_batch("BEGIN").unwrap();
2790 drop(reader);
2791 let (first, second) = (
2792 open_reader_strict(&path).unwrap(),
2793 open_reader_strict(&path).unwrap(),
2794 );
2795 assert!(first.is_autocommit() && second.is_autocommit());
2796 assert_eq!(opened_readers() - before, 3);
2797 }
2798
2799 #[test]
2802 fn idle_readers_follow_store_replacement_and_schema_changes() {
2803 let directory = tempfile::tempdir().unwrap();
2804 let path = directory.path().join("mj.sqlite3");
2805 drop(open_writer(&path).unwrap());
2806 add_workspace(&path, "old store");
2807 drop(open_reader_strict(&path).unwrap());
2808
2809 for suffix in ["", "-wal", "-shm"] {
2810 let file = PathBuf::from(format!("{}{suffix}", path.display()));
2811 if file.exists() {
2812 fs::remove_file(file).unwrap();
2813 }
2814 }
2815 forget_verified_schema(&path);
2816 drop(open_writer(&path).unwrap());
2817 add_workspace(&path, "new store");
2818 let reader = open_reader_strict(&path).unwrap();
2819 assert!(has_workspace(&reader, "new store"));
2820 assert!(!has_workspace(&reader, "old store"));
2821 drop(reader);
2822
2823 drop(open_reader_strict(&path).unwrap());
2824 stamp_schema_version(&path, SCHEMA_VERSION + 1);
2825 let error = open_reader_strict(&path).unwrap_err();
2826 assert!(
2827 error
2828 .chain()
2829 .any(|cause| cause.downcast_ref::<StoreSchemaMismatch>().is_some()),
2830 "a reused connection checks compatibility like a new one: {error:#}"
2831 );
2832 }
2833}