1use super::*;
2use rusqlite::OpenFlags;
3
4const COMPATIBILITY_METADATA_VERSION: i64 = 30;
5
6pub(super) struct SchemaState {
7 pub(super) revision: i64,
8 minimum_compatible: Option<i64>,
9}
10
11impl SchemaState {
12 pub(super) fn ensure_supported(&self) -> Result<()> {
13 let reason = if self.revision < SCHEMA_VERSION {
14 StoreSchemaMismatchReason::NeedsMigration
15 } else if let Some(minimum_compatible) = self.minimum_compatible {
16 if minimum_compatible <= SCHEMA_VERSION {
17 return Ok(());
18 }
19 StoreSchemaMismatchReason::Incompatible { minimum_compatible }
20 } else {
21 StoreSchemaMismatchReason::InvalidCompatibilityMetadata
22 };
23 Err(StoreSchemaMismatch {
24 found: self.revision,
25 supported: SCHEMA_VERSION,
26 reason,
27 }
28 .into())
29 }
30}
31
32pub(super) fn read_schema_state(connection: &Connection) -> Result<SchemaState> {
35 let snapshot = connection
36 .unchecked_transaction()
37 .context("start database compatibility snapshot")?;
38 let revision: i64 = snapshot
39 .query_row("PRAGMA user_version", [], |row| row.get(0))
40 .context("read database migration revision")?;
41 let minimum_compatible = if revision >= COMPATIBILITY_METADATA_VERSION {
42 let invalid = || StoreSchemaMismatch {
43 found: revision,
44 supported: SCHEMA_VERSION,
45 reason: StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
46 };
47 let (count, singleton, floor, recorded): (i64, Option<i64>, Option<i64>, Option<i64>) =
48 snapshot
49 .query_row(
50 "SELECT count(*), min(singleton), min(minimum_compatible_version),
51 (SELECT max(version) FROM schema_migrations)
52 FROM schema_compatibility",
53 [],
54 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
55 )
56 .map_err(|error| {
57 let structural = match &error {
61 rusqlite::Error::SqliteFailure(code, _) => {
62 code.code == rusqlite::ErrorCode::Unknown
63 }
64 _ => true,
65 };
66 let error = anyhow::Error::new(error);
67 if structural {
68 error.context(invalid())
69 } else {
70 error.context("read database compatibility metadata")
71 }
72 })?;
73 if count != 1
74 || singleton != Some(1)
75 || recorded != Some(revision)
76 || !floor
77 .is_some_and(|floor| (COMPATIBILITY_METADATA_VERSION..=revision).contains(&floor))
78 {
79 return Err(invalid().into());
80 }
81 floor
82 } else {
83 None
84 };
85 snapshot
86 .commit()
87 .context("finish database compatibility snapshot")?;
88 Ok(SchemaState {
89 revision,
90 minimum_compatible,
91 })
92}
93
94pub fn database_path() -> PathBuf {
95 data_dir().join("mj.sqlite3")
96}
97
98pub(super) fn open_writer(path: &Path) -> Result<Connection> {
99 if let Some(parent) = path.parent() {
100 fs::create_dir_all(parent)
101 .with_context(|| format!("create Mjolnir data directory {}", parent.display()))?;
102 }
103 let connection = Connection::open(path)
104 .with_context(|| format!("open Mjolnir database {}", path.display()))?;
105 connection.busy_timeout(Duration::from_secs(5))?;
106 connection.execute_batch(
107 "PRAGMA foreign_keys = ON;
108 PRAGMA journal_mode = WAL;
109 PRAGMA synchronous = FULL;",
110 )?;
111 verify_schema_once(path, &connection)?;
112 Ok(connection)
113}
114
115pub(super) fn open(path: &Path) -> Result<Connection> {
116 open_writer(path)
117}
118
119#[cfg(not(test))]
123pub(super) fn open_reader(path: &Path) -> Result<Connection> {
124 open_reader_strict(path)
125}
126
127#[cfg(test)]
128pub(super) fn open_reader(path: &Path) -> Result<Connection> {
129 open_writer(path)
133}
134
135#[cfg_attr(test, allow(dead_code))]
136fn open_reader_strict(path: &Path) -> Result<Connection> {
137 let connection = Connection::open_with_flags(
138 path,
139 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
140 )
141 .with_context(|| format!("open Mjolnir database read-only {}", path.display()))?;
142 connection.busy_timeout(Duration::from_secs(5))?;
143 connection.execute_batch(
144 "PRAGMA foreign_keys = ON;
145 PRAGMA query_only = ON;",
146 )?;
147 read_schema_state(&connection)?.ensure_supported()?;
148 Ok(connection)
149}
150
151fn verified_schemas() -> &'static Mutex<HashSet<PathBuf>> {
155 static VERIFIED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
156 VERIFIED.get_or_init(|| Mutex::new(HashSet::new()))
157}
158
159fn schema_cache_key(path: &Path) -> PathBuf {
162 let Some(parent) = path
163 .parent()
164 .filter(|parent| !parent.as_os_str().is_empty())
165 else {
166 return path.to_owned();
167 };
168 match (fs::canonicalize(parent), path.file_name()) {
169 (Ok(canonical), Some(name)) => canonical.join(name),
170 _ => path.to_owned(),
171 }
172}
173
174fn verify_schema_once(path: &Path, connection: &Connection) -> Result<()> {
179 let key = schema_cache_key(path);
180 let mut verified = verified_schemas()
181 .lock()
182 .unwrap_or_else(PoisonError::into_inner);
183 let state = read_schema_state(connection)?;
184 if state.revision > SCHEMA_VERSION
185 || (state.revision == SCHEMA_VERSION && verified.contains(&key))
186 {
187 return state.ensure_supported();
189 }
190 migrate_schema(connection)?;
193 read_schema_state(connection)?.ensure_supported()?;
194 verified.insert(key);
195 Ok(())
196}
197
198#[cfg(test)]
202pub(super) fn forget_verified_schema(path: &Path) {
203 verified_schemas()
204 .lock()
205 .unwrap_or_else(PoisonError::into_inner)
206 .remove(&schema_cache_key(path));
207}
208
209fn migrate_schema(connection: &Connection) -> Result<()> {
210 let state = read_schema_state(connection)?;
211 let version = state.revision;
212 if version > SCHEMA_VERSION {
213 return state.ensure_supported();
214 }
215 if version == 0 {
216 connection.execute_batch(
217 "BEGIN IMMEDIATE;
218 CREATE TABLE schema_migrations (
219 version INTEGER PRIMARY KEY CHECK(version > 0),
220 applied_at TEXT NOT NULL
221 ) STRICT;
222 CREATE TABLE session_contexts (
223 session_id TEXT PRIMARY KEY,
224 bundle_id TEXT NOT NULL,
225 created_at TEXT NOT NULL
226 ) STRICT;
227 CREATE TABLE sessions (
228 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
229 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
230 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
231 last_profile TEXT NOT NULL,
232 target_template_id TEXT NOT NULL,
233 state TEXT NOT NULL CHECK(state IN (
234 'provisioning','running','disconnected','checkpointing','closing','destroying',
235 'archived','lost','error','destroyed-with-data-loss'
236 )),
237 native_session_id TEXT,
238 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
239 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
240 updated_at TEXT NOT NULL,
241 last_viewed_event_sequence INTEGER NOT NULL DEFAULT 0 CHECK(last_viewed_event_sequence >= 0),
242 last_error TEXT
243 ) STRICT;
244 CREATE TABLE session_targets (
245 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
246 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','apple-container','aws-ec2','ssh-bare','ssh-podman')),
247 host TEXT,
248 resource_id TEXT,
249 address TEXT,
250 workspace BLOB,
251 worker_id TEXT,
252 CHECK(
253 (kind = 'local-bare' AND workspace IS NOT NULL
254 AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
255 OR (kind IN ('local-podman','apple-container') AND resource_id IS NOT NULL
256 AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
257 OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
258 AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
259 OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
260 AND resource_id IS NULL AND address IS NULL)
261 OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
262 AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
263 )
264 ) STRICT;
265 CREATE TABLE session_mounts (
266 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
267 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
268 source BLOB NOT NULL,
269 destination BLOB NOT NULL,
270 PRIMARY KEY(session_id, ordinal),
271 UNIQUE(session_id, destination)
272 ) STRICT;
273 CREATE TABLE session_checkpoints (
274 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
275 archive_path BLOB NOT NULL,
276 sha256 TEXT NOT NULL CHECK(length(sha256) = 64 AND sha256 NOT GLOB '*[^0-9a-f]*'),
277 created_at TEXT NOT NULL,
278 event_sequence INTEGER NOT NULL CHECK(event_sequence >= 0)
279 ) STRICT;
280 CREATE TABLE mount_history (
281 host TEXT NOT NULL CHECK(length(trim(host)) > 0),
282 source BLOB NOT NULL,
283 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
284 PRIMARY KEY(host, ordinal),
285 UNIQUE(host, source)
286 ) STRICT;
287 CREATE TABLE prompt_history (
288 history_id INTEGER PRIMARY KEY,
289 session_id TEXT NOT NULL REFERENCES session_contexts(session_id),
290 event_sequence INTEGER NOT NULL CHECK(event_sequence >= 0),
291 submitted_at TEXT NOT NULL,
292 text TEXT NOT NULL CHECK(length(trim(text)) > 0),
293 UNIQUE(session_id, event_sequence)
294 ) STRICT;
295 CREATE INDEX prompt_history_session_recent
296 ON prompt_history(session_id, history_id DESC);
297 CREATE INDEX session_contexts_bundle
298 ON session_contexts(bundle_id, session_id);
299 CREATE INDEX prompt_history_recent
300 ON prompt_history(history_id DESC);
301 INSERT INTO schema_migrations(version, applied_at)
302 VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
303 PRAGMA user_version = 1;
304 COMMIT;",
305 )?;
306 }
307 if version < 2 {
308 connection.execute_batch(
309 "BEGIN IMMEDIATE;
310 ALTER TABLE sessions ADD COLUMN resource_allocation TEXT;
311 INSERT INTO schema_migrations(version, applied_at)
312 VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
313 PRAGMA user_version = 2;
314 COMMIT;",
315 )?;
316 }
317 if version < 3 {
318 connection.execute_batch(
319 "BEGIN IMMEDIATE;
320 ALTER TABLE sessions ADD COLUMN last_checkpoint_error TEXT;
321 INSERT INTO schema_migrations(version, applied_at)
322 VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
323 PRAGMA user_version = 3;
324 COMMIT;",
325 )?;
326 }
327 if version < 4 {
328 connection.execute_batch(
329 "BEGIN IMMEDIATE;
330 ALTER TABLE sessions ADD COLUMN project_directory BLOB;
331 INSERT INTO schema_migrations(version, applied_at)
332 VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
333 PRAGMA user_version = 4;
334 COMMIT;",
335 )?;
336 }
337 if version < 5 {
338 connection.execute_batch(
339 "BEGIN IMMEDIATE;
340 ALTER TABLE session_targets RENAME TO session_targets_v4;
341 CREATE TABLE session_targets (
342 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
343 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','apple-container','aws-ec2','ssh-bare','ssh-podman')),
344 host TEXT,
345 resource_id TEXT,
346 address TEXT,
347 workspace BLOB,
348 worker_id TEXT,
349 CHECK(
350 (kind = 'local-bare' AND workspace IS NOT NULL
351 AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
352 OR (kind IN ('local-podman','apple-container') AND resource_id IS NOT NULL
353 AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
354 OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
355 AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
356 OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
357 AND resource_id IS NULL AND address IS NULL)
358 OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
359 AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
360 )
361 ) STRICT;
362 INSERT INTO session_targets
363 SELECT * FROM session_targets_v4;
364 DROP TABLE session_targets_v4;
365 INSERT INTO schema_migrations(version, applied_at)
366 VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
367 PRAGMA user_version = 5;
368 COMMIT;",
369 )?;
370 }
371 if version < 6 {
372 connection.execute_batch(&format!(
373 "BEGIN IMMEDIATE;
374 ALTER TABLE session_checkpoints
375 RENAME COLUMN event_sequence TO event_frontier;
376 ALTER TABLE prompt_history
377 RENAME COLUMN event_sequence TO event_ordinal;
378 ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
379 DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
380 ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
381 CREATE TABLE materialized_sessions (
382 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
383 applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
384 applied_event_digest TEXT NOT NULL
385 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
386 CHECK(length(applied_event_digest) = 64
387 AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
388 last_activity_at_ms INTEGER,
389 execution_state TEXT NOT NULL DEFAULT 'idle'
390 CHECK(execution_state IN ('idle','running','closing','closed')),
391 running_started_at_ms INTEGER,
392 session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
393 configuration_json TEXT NOT NULL DEFAULT '{{}}',
394 CHECK(
395 (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
396 OR (execution_state != 'running' AND running_started_at_ms IS NULL)
397 )
398 ) STRICT;
399 CREATE TABLE materialized_transcript_items (
400 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
401 stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
402 position INTEGER NOT NULL CHECK(position > 0),
403 latest_content_event_ordinal INTEGER
404 CHECK(latest_content_event_ordinal IS NULL
405 OR latest_content_event_ordinal >= position),
406 created_at_ms INTEGER NOT NULL,
407 last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
408 body_json TEXT NOT NULL,
409 PRIMARY KEY(session_id, stable_id)
410 ) STRICT;
411 CREATE INDEX materialized_transcript_position
412 ON materialized_transcript_items(session_id, position, stable_id);
413 CREATE TABLE materialized_queued_prompts (
414 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
415 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
416 command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
417 content_json TEXT NOT NULL,
418 queued_at_ms INTEGER NOT NULL,
419 PRIMARY KEY(session_id, ordinal),
420 UNIQUE(session_id, command_id)
421 ) STRICT;
422 INSERT INTO materialized_sessions(session_id)
423 SELECT session_id FROM sessions;
424 INSERT INTO schema_migrations(version, applied_at)
425 VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
426 PRAGMA user_version = 6;
427 COMMIT;",
428 ))?;
429 }
430 ensure_managed_worktree_column(connection)?;
435 if version < 7 {
436 ensure_relay_projection_schema(connection)?;
437 migrate_destroying_session_state(connection)?;
438 }
439 ensure_projection_digest_column(connection)?;
440 ensure_session_draft_input_column(connection)?;
441 if version < 8 {
442 connection.execute_batch(
445 "BEGIN IMMEDIATE;
446 ALTER TABLE materialized_queued_prompts
447 ADD COLUMN kind_json TEXT NOT NULL DEFAULT '\"prompt\"';
448 INSERT INTO schema_migrations(version, applied_at)
449 VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
450 PRAGMA user_version = 8;
451 COMMIT;",
452 )?;
453 }
454 if version < 9 {
457 migrate_grok_harness_kind(connection)?;
458 }
459 ensure_session_container_override_columns(connection)?;
461 ensure_session_mount_read_only_column(connection)?;
462 ensure_materialized_elicitation_column(connection)?;
463 connection.execute_batch(
464 "CREATE TABLE IF NOT EXISTS profile_config_cache (
465 profile TEXT NOT NULL, model TEXT NOT NULL, fingerprint TEXT NOT NULL,
466 observed_at INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(profile, model));
467 CREATE TABLE IF NOT EXISTS api_config_results (
468 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
469 command_id TEXT NOT NULL, error TEXT, PRIMARY KEY(session_id, command_id));
470 CREATE TABLE IF NOT EXISTS session_turn_usage (
471 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
472 command_id TEXT NOT NULL, completed_ordinal INTEGER NOT NULL, turn_start_position INTEGER,
473 body TEXT NOT NULL, PRIMARY KEY(session_id, command_id));
474 CREATE INDEX IF NOT EXISTS session_turn_usage_order ON session_turn_usage(session_id, completed_ordinal);
475 CREATE TABLE IF NOT EXISTS session_provider_cost (
476 session_id TEXT PRIMARY KEY REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
477 body TEXT NOT NULL);",
478 )?;
479
480 if version < 10 {
481 migrate_stopped_session_state(connection)?;
482 }
483 if version < 11 {
484 migrate_deepseek_harness_kind(connection)?;
485 }
486 if version < 12 {
487 connection.execute_batch(
488 "BEGIN IMMEDIATE;
489 ALTER TABLE sessions
490 RENAME COLUMN detached_after_event_ordinal TO viewed_through_event_ordinal;
491 UPDATE sessions
492 SET target_template_id = 'localhost'
493 WHERE target_template_id = 'raw-localhost';
494 INSERT INTO schema_migrations(version, applied_at)
495 VALUES (12, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
496 PRAGMA user_version = 12;
497 COMMIT;",
498 )?;
499 }
500 if version < 13 {
501 connection.execute_batch(
502 "BEGIN IMMEDIATE;
503 UPDATE sessions
504 SET state = 'error'
505 WHERE state = 'lost'
506 AND EXISTS(
507 SELECT 1
508 FROM session_checkpoints
509 WHERE session_checkpoints.session_id = sessions.session_id
510 );
511 INSERT INTO schema_migrations(version, applied_at)
512 VALUES (13, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
513 PRAGMA user_version = 13;
514 COMMIT;",
515 )?;
516 }
517 if version < 14 {
518 ensure_workspace_schema(connection)?;
519 connection.execute_batch(
520 "BEGIN IMMEDIATE;
521 INSERT OR IGNORE INTO schema_migrations(version, applied_at)
522 VALUES (14, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
523 PRAGMA user_version = 14;
524 COMMIT;",
525 )?;
526 }
527 if version < 15 {
528 connection.execute_batch(
529 "BEGIN IMMEDIATE;
530 CREATE TABLE host_container_sizes (
531 host TEXT PRIMARY KEY CHECK(length(trim(host)) > 0),
532 cpus INTEGER NOT NULL CHECK(cpus > 0),
533 memory_bytes INTEGER NOT NULL CHECK(memory_bytes > 0)
534 ) STRICT;
535 INSERT INTO schema_migrations(version, applied_at)
536 VALUES (15, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
537 PRAGMA user_version = 15;
538 COMMIT;",
539 )?;
540 }
541 if version < 16 {
542 connection.execute_batch(
543 "BEGIN IMMEDIATE;
544 CREATE TABLE second_opinion_defaults (
545 workspace_id TEXT NOT NULL CHECK(length(trim(workspace_id)) > 0),
546 profile_id TEXT NOT NULL CHECK(length(trim(profile_id)) > 0),
547 model TEXT NOT NULL,
548 effort TEXT NOT NULL,
549 PRIMARY KEY (workspace_id, profile_id, model)
550 ) STRICT;
551 INSERT INTO schema_migrations(version, applied_at)
552 VALUES (16, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
553 PRAGMA user_version = 16;
554 COMMIT;",
555 )?;
556 }
557 if version < 17 {
558 connection.execute_batch(
559 "BEGIN IMMEDIATE;
560 CREATE TABLE second_opinion_reviews (
561 session_id TEXT PRIMARY KEY
562 REFERENCES sessions(session_id) ON DELETE CASCADE,
563 workflow TEXT NOT NULL,
564 generation INTEGER NOT NULL CHECK(generation >= 0),
565 context_baseline INTEGER NOT NULL CHECK(context_baseline >= 0),
566 native_lost INTEGER NOT NULL CHECK(native_lost IN (0, 1))
567 ) STRICT;
568 INSERT INTO schema_migrations(version, applied_at)
569 VALUES (17, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
570 PRAGMA user_version = 17;
571 COMMIT;",
572 )?;
573 }
574 if version < 18 {
575 connection.execute_batch(
580 "BEGIN IMMEDIATE;
581 ALTER TABLE second_opinion_reviews
582 ADD COLUMN reviewer_transcript TEXT NOT NULL DEFAULT '[]';
583 INSERT INTO schema_migrations(version, applied_at)
584 VALUES (18, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
585 PRAGMA user_version = 18;
586 COMMIT;",
587 )?;
588 }
589 if version < 19 {
590 connection.execute_batch(
596 "BEGIN IMMEDIATE;
597 CREATE TABLE turn_review_settings (
598 workspace_id TEXT PRIMARY KEY
599 CHECK(length(trim(workspace_id)) > 0),
600 auto_review INTEGER NOT NULL CHECK(auto_review IN (0, 1)),
601 tier TEXT NOT NULL CHECK(tier IN ('quick', 'extended'))
602 ) STRICT;
603 CREATE TABLE turn_review_state (
604 session_id TEXT PRIMARY KEY
605 REFERENCES sessions(session_id) ON DELETE CASCADE,
606 baselines TEXT NOT NULL,
607 reviewed_through_ordinal INTEGER NOT NULL
608 CHECK(reviewed_through_ordinal >= 0),
609 prior_review TEXT,
610 active TEXT
611 ) STRICT;
612 INSERT INTO schema_migrations(version, applied_at)
613 VALUES (19, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
614 PRAGMA user_version = 19;
615 COMMIT;",
616 )?;
617 }
618 if version < 20 {
619 let target_table_exists: bool = connection.query_row(
620 "SELECT EXISTS(
621 SELECT 1 FROM sqlite_master
622 WHERE type = 'table' AND name = 'session_targets'
623 )",
624 [],
625 |row| row.get(0),
626 )?;
627 let rebuild = if target_table_exists {
628 "ALTER TABLE session_targets RENAME TO session_targets_v19;"
629 } else {
630 ""
631 };
632 let copy = if target_table_exists {
633 "INSERT INTO session_targets SELECT * FROM session_targets_v19;
634 DROP TABLE session_targets_v19;"
635 } else {
636 ""
637 };
638 connection.execute_batch(&format!(
639 "BEGIN IMMEDIATE;
640 {rebuild}
641 CREATE TABLE session_targets (
642 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
643 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','local-docker','apple-container','aws-ec2','ssh-bare','ssh-podman')),
644 host TEXT,
645 resource_id TEXT,
646 address TEXT,
647 workspace BLOB,
648 worker_id TEXT,
649 CHECK(
650 (kind = 'local-bare' AND workspace IS NOT NULL
651 AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
652 OR (kind IN ('local-podman','local-docker','apple-container') AND resource_id IS NOT NULL
653 AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
654 OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
655 AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
656 OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
657 AND resource_id IS NULL AND address IS NULL)
658 OR (kind = 'ssh-podman' AND host IS NOT NULL AND resource_id IS NOT NULL
659 AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
660 )
661 ) STRICT;
662 {copy}
663 INSERT INTO schema_migrations(version, applied_at)
664 VALUES (20, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
665 PRAGMA user_version = 20;
666 COMMIT;"
667 ))?;
668 }
669 if version < 21 {
670 connection.execute_batch(
676 "BEGIN IMMEDIATE;
677 DROP TABLE IF EXISTS turn_review_settings;
678 INSERT INTO schema_migrations(version, applied_at)
679 VALUES (21, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
680 PRAGMA user_version = 21;
681 COMMIT;",
682 )?;
683 }
684 if version < 22 {
685 connection.execute_batch(
686 "BEGIN IMMEDIATE;
687 ALTER TABLE session_targets ADD COLUMN workspace_storage TEXT;
688 INSERT INTO schema_migrations(version, applied_at)
689 VALUES (22, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
690 PRAGMA user_version = 22;
691 COMMIT;",
692 )?;
693 }
694 if version < 23 {
695 let add_pending_forward =
696 if table_has_column(connection, "turn_review_state", "pending_forward")? {
697 ""
698 } else {
699 "ALTER TABLE turn_review_state ADD COLUMN pending_forward TEXT;"
700 };
701 connection.execute_batch(&format!(
702 "BEGIN IMMEDIATE;
703 {add_pending_forward}
704 INSERT INTO schema_migrations(version, applied_at)
705 VALUES (23, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
706 PRAGMA user_version = 23;
707 COMMIT;"
708 ))?;
709 }
710 if version < 24 {
711 connection.execute_batch(
712 "BEGIN IMMEDIATE;
713 ALTER TABLE session_targets RENAME TO session_targets_v23;
714 CREATE TABLE session_targets (
715 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
716 kind TEXT NOT NULL CHECK(kind IN ('local-bare','local-podman','local-docker','apple-container','aws-ec2','ssh-bare','ssh-podman','ssh-docker')),
717 host TEXT,
718 resource_id TEXT,
719 address TEXT,
720 workspace BLOB,
721 worker_id TEXT,
722 workspace_storage TEXT,
723 CHECK(
724 (kind = 'local-bare' AND workspace IS NOT NULL
725 AND host IS NULL AND resource_id IS NULL AND address IS NULL AND worker_id IS NULL)
726 OR (kind IN ('local-podman','local-docker','apple-container') AND resource_id IS NOT NULL
727 AND host IS NULL AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
728 OR (kind = 'aws-ec2' AND resource_id IS NOT NULL
729 AND host IS NULL AND workspace IS NULL AND worker_id IS NULL)
730 OR (kind = 'ssh-bare' AND host IS NOT NULL AND workspace IS NOT NULL
731 AND resource_id IS NULL AND address IS NULL)
732 OR (kind IN ('ssh-podman','ssh-docker') AND host IS NOT NULL AND resource_id IS NOT NULL
733 AND address IS NULL AND workspace IS NULL AND worker_id IS NULL)
734 )
735 ) STRICT;
736 INSERT INTO session_targets SELECT * FROM session_targets_v23;
737 DROP TABLE session_targets_v23;
738 INSERT INTO schema_migrations(version, applied_at)
739 VALUES (24, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
740 PRAGMA user_version = 24;
741 COMMIT;",
742 )?;
743 }
744 if version < 25 {
745 connection.execute_batch(
746 "BEGIN IMMEDIATE;
747 CREATE TABLE workspace_pane_sizes (
748 workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
749 sessions TEXT NOT NULL CHECK(sessions IN ('minimized', 'standard', 'maximized')),
750 targets TEXT NOT NULL CHECK(targets IN ('minimized', 'standard', 'maximized')),
751 quota TEXT NOT NULL CHECK(quota IN ('minimized', 'standard', 'maximized')),
752 CHECK((sessions = 'maximized') + (targets = 'maximized') + (quota = 'maximized') <= 1)
753 ) STRICT;
754 INSERT INTO schema_migrations(version, applied_at)
755 VALUES (25, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
756 PRAGMA user_version = 25;
757 COMMIT;",
758 )?;
759 }
760 if version < 26 {
761 connection.execute_batch(
762 "BEGIN IMMEDIATE;
763 CREATE TABLE session_moves (
764 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
765 operation_id TEXT NOT NULL UNIQUE,
766 operation_json TEXT NOT NULL CHECK(json_valid(operation_json))
767 ) STRICT;
768 INSERT INTO schema_migrations(version, applied_at)
769 VALUES (26, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
770 PRAGMA user_version = 26;
771 COMMIT;",
772 )?;
773 }
774 if version < 27 {
775 migrate_muse_harness_kind(connection)?;
776 }
777 if version < 28 {
781 migrate_turn_outcome_columns(connection)?;
782 }
783 if version < 29 {
784 connection.execute_batch(
785 "BEGIN IMMEDIATE;
786 ALTER TABLE sessions ADD COLUMN create_managed_worktree INTEGER
787 CHECK(create_managed_worktree IN (0, 1));
788 INSERT INTO schema_migrations(version, applied_at)
789 VALUES (29, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
790 PRAGMA user_version = 29;
791 COMMIT;",
792 )?;
793 }
794 if version < 30 {
795 migrate_compatibility_metadata(connection)?;
796 }
797 if version < 31 {
798 migrate_subagent_sessions(connection)?;
799 }
800 if version < 32 {
801 migrate_zcode_harness_kind(connection)?;
802 }
803 if version < 33 {
809 connection.execute_batch(
810 "BEGIN IMMEDIATE;
811 ALTER TABLE sessions ADD COLUMN mjolnir_subagents INTEGER
812 CHECK(mjolnir_subagents IN (0, 1));
813 INSERT INTO schema_migrations(version, applied_at)
814 VALUES (33, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
815 PRAGMA user_version = 33;
816 COMMIT;",
817 )?;
818 }
819 let recorded: Option<i64> =
820 connection.query_row("SELECT max(version) FROM schema_migrations", [], |row| {
821 row.get(0)
822 })?;
823 if recorded != Some(SCHEMA_VERSION) {
824 bail!(
825 "Mjolnir database migration ledger {:?} does not match schema {}",
826 recorded,
827 SCHEMA_VERSION
828 );
829 }
830 ensure_client_session_state_schema(connection)?;
836 ensure_api_events_schema(connection)?;
837 Ok(())
838}
839
840fn migrate_compatibility_metadata(connection: &Connection) -> Result<()> {
844 let transaction = connection.unchecked_transaction()?;
845 transaction.execute_batch(
846 "CREATE TABLE schema_compatibility (
847 singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
848 minimum_compatible_version INTEGER NOT NULL CHECK(minimum_compatible_version >= 30)
849 ) STRICT;
850 INSERT INTO schema_compatibility(singleton, minimum_compatible_version) VALUES (1, 30);
851 INSERT INTO schema_migrations(version, applied_at)
852 VALUES (30, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
853 PRAGMA user_version = 30;",
854 )?;
855 transaction.commit()?;
856 Ok(())
857}
858
859fn migrate_subagent_sessions(connection: &Connection) -> Result<()> {
862 let transaction = connection.unchecked_transaction()?;
863 transaction.execute_batch(
864 "CREATE TABLE IF NOT EXISTS subagent_sessions (
865 child_session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
866 parent_session_id TEXT NOT NULL REFERENCES sessions(session_id),
867 request_key TEXT NOT NULL,
868 record_json TEXT NOT NULL CHECK(json_valid(record_json)),
869 CHECK(child_session_id <> parent_session_id),
870 UNIQUE(parent_session_id, request_key)
871 ) STRICT;
872 CREATE INDEX IF NOT EXISTS subagent_sessions_parent
873 ON subagent_sessions(parent_session_id, child_session_id);
874 UPDATE schema_compatibility SET minimum_compatible_version = 31
875 WHERE singleton = 1;
876 INSERT INTO schema_migrations(version, applied_at)
877 VALUES (31, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
878 PRAGMA user_version = 31;",
879 )?;
880 transaction.commit()?;
881 Ok(())
882}
883
884fn migrate_zcode_harness_kind(connection: &Connection) -> Result<()> {
895 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
896 let migration = (|| -> Result<()> {
897 let transaction = connection.unchecked_transaction()?;
898 for table in ["sessions", "hidden_native_sessions"] {
899 let sql: String = transaction.query_row(
900 "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
901 [table],
902 |row| row.get(0),
903 )?;
904 let (_, definition) = sql
905 .split_once('(')
906 .context("missing harness table definition")?;
907 if definition.contains("'muse','zcode')") {
908 continue;
909 }
910 ensure!(
911 definition.contains("'muse')"),
912 "unexpected {table} harness constraint"
913 );
914 let definition = definition.replace("'muse')", "'muse','zcode')");
915 let objects: Vec<String> = transaction
916 .prepare(
917 "SELECT sql FROM sqlite_schema WHERE tbl_name=?1
918 AND type IN ('index','trigger') AND sql IS NOT NULL",
919 )?
920 .query_map([table], |row| row.get(0))?
921 .collect::<rusqlite::Result<_>>()?;
922 transaction.execute_batch(&format!(
923 "CREATE TABLE {table}_zcode_v32 ({definition};
924 INSERT INTO {table}_zcode_v32 SELECT * FROM {table};
925 DROP TABLE {table};
926 ALTER TABLE {table}_zcode_v32 RENAME TO {table};"
927 ))?;
928 for object in objects {
929 transaction.execute_batch(&object)?;
930 }
931 }
932 ensure!(
933 !transaction
934 .prepare("PRAGMA foreign_key_check")?
935 .exists([])?,
936 "foreign key violation in ZCode migration"
937 );
938 transaction.execute_batch(
939 "UPDATE schema_compatibility SET minimum_compatible_version = 32
940 WHERE singleton = 1;
941 INSERT INTO schema_migrations(version, applied_at)
942 VALUES (32, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
943 PRAGMA user_version = 32;",
944 )?;
945 transaction.commit()?;
946 Ok(())
947 })();
948 let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
949 migration.context("migrate ZCode harness constraints")?;
950 restored.context("restore foreign key enforcement after ZCode migration")?;
951 Ok(())
952}
953
954pub(super) fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool> {
955 connection
956 .query_row(
957 "SELECT EXISTS(
958 SELECT 1 FROM pragma_table_info(?1)
959 WHERE name = ?2
960 )",
961 params![table, column],
962 |row| row.get(0),
963 )
964 .map_err(Into::into)
965}
966
967fn ensure_workspace_schema(connection: &Connection) -> Result<()> {
968 connection.execute_batch(
969 "CREATE TABLE IF NOT EXISTS workspaces (
970 workspace_id TEXT PRIMARY KEY CHECK(length(trim(workspace_id)) > 0),
971 name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 64),
972 name_key TEXT NOT NULL UNIQUE CHECK(length(trim(name_key)) BETWEEN 1 AND 64),
973 created_at TEXT NOT NULL,
974 last_opened_at TEXT NOT NULL
975 ) STRICT;
976 INSERT OR IGNORE INTO workspaces(
977 workspace_id, name, name_key, created_at, last_opened_at
978 ) VALUES (
979 'default', 'default', 'default',
980 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
981 strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
982 );",
983 )?;
984 if !table_has_column(connection, "session_contexts", "workspace_id")? {
985 connection.execute_batch(
986 "ALTER TABLE session_contexts
987 ADD COLUMN workspace_id TEXT NOT NULL DEFAULT 'default';",
988 )?;
989 }
990 connection.execute_batch(
991 "CREATE INDEX IF NOT EXISTS session_contexts_workspace
992 ON session_contexts(workspace_id, session_id);
993 CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_insert
994 BEFORE INSERT ON session_contexts
995 WHEN NOT EXISTS(
996 SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
997 )
998 BEGIN
999 SELECT RAISE(ABORT, 'unknown workspace');
1000 END;
1001 CREATE TRIGGER IF NOT EXISTS session_contexts_workspace_update
1002 BEFORE UPDATE OF workspace_id ON session_contexts
1003 WHEN NOT EXISTS(
1004 SELECT 1 FROM workspaces WHERE workspace_id = NEW.workspace_id
1005 )
1006 BEGIN
1007 SELECT RAISE(ABORT, 'unknown workspace');
1008 END;
1009 CREATE TABLE IF NOT EXISTS client_read_frontiers (
1010 client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
1011 workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
1012 session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
1013 through_event_ordinal INTEGER NOT NULL DEFAULT 0
1014 CHECK(through_event_ordinal >= 0),
1015 updated_at TEXT NOT NULL,
1016 PRIMARY KEY(client_id, workspace_id, session_id)
1017 ) STRICT;
1018 CREATE TABLE IF NOT EXISTS detached_drafts (
1019 draft_id TEXT PRIMARY KEY CHECK(length(trim(draft_id)) > 0),
1020 workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id),
1021 session_id TEXT REFERENCES session_contexts(session_id),
1022 source TEXT NOT NULL CHECK(length(trim(source)) > 0),
1023 owner_pid INTEGER CHECK(owner_pid IS NULL OR owner_pid > 0),
1024 saved_at TEXT NOT NULL,
1025 text TEXT NOT NULL CHECK(length(text) > 0),
1026 recovered_at TEXT
1027 ) STRICT;
1028 CREATE INDEX IF NOT EXISTS detached_drafts_workspace_recent
1029 ON detached_drafts(workspace_id, saved_at DESC);",
1030 )?;
1031 ensure_client_session_state_schema(connection)?;
1032 Ok(())
1033}
1034
1035fn ensure_client_session_state_schema(connection: &Connection) -> Result<()> {
1042 connection.execute_batch(
1043 "CREATE TABLE IF NOT EXISTS client_session_state (
1044 client_id TEXT NOT NULL CHECK(length(trim(client_id)) > 0),
1045 workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
1046 session_id TEXT NOT NULL REFERENCES session_contexts(session_id) ON DELETE CASCADE,
1047 draft TEXT NOT NULL DEFAULT '',
1048 updated_at TEXT NOT NULL,
1049 PRIMARY KEY(client_id, workspace_id, session_id)
1050 ) STRICT;
1051 CREATE INDEX IF NOT EXISTS client_session_state_age
1052 ON client_session_state(updated_at);",
1053 )?;
1054 Ok(())
1055}
1056
1057fn ensure_session_container_override_columns(connection: &Connection) -> Result<()> {
1060 for column in ["container_cpus", "container_memory"] {
1061 if !table_has_column(connection, "sessions", column)? {
1062 connection.execute_batch(&format!(
1063 "BEGIN IMMEDIATE;
1064 ALTER TABLE sessions ADD COLUMN {column} TEXT;
1065 COMMIT;"
1066 ))?;
1067 }
1068 }
1069 Ok(())
1070}
1071
1072fn migrate_turn_outcome_columns(connection: &Connection) -> Result<()> {
1083 let mut statements = String::from("BEGIN IMMEDIATE;\n");
1084 if !table_has_column(connection, "materialized_sessions", "active_turn_json")? {
1085 statements.push_str(
1086 "ALTER TABLE materialized_sessions ADD COLUMN active_turn_json TEXT
1087 CHECK(active_turn_json IS NULL OR json_valid(active_turn_json));\n",
1088 );
1089 }
1090 if !table_has_column(
1091 connection,
1092 "materialized_sessions",
1093 "last_turn_outcome_json",
1094 )? {
1095 statements.push_str(
1096 "ALTER TABLE materialized_sessions ADD COLUMN last_turn_outcome_json TEXT
1097 CHECK(last_turn_outcome_json IS NULL OR json_valid(last_turn_outcome_json));\n",
1098 );
1099 }
1100 if !table_has_column(
1101 connection,
1102 "materialized_queued_prompts",
1103 "accepted_ordinal",
1104 )? {
1105 statements.push_str(
1106 "ALTER TABLE materialized_queued_prompts ADD COLUMN accepted_ordinal INTEGER
1107 CHECK(accepted_ordinal IS NULL OR accepted_ordinal > 0);\n",
1108 );
1109 }
1110 statements.push_str(
1111 "CREATE TABLE IF NOT EXISTS api_idempotency (
1112 key TEXT PRIMARY KEY CHECK(length(trim(key)) BETWEEN 1 AND 128),
1113 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1114 created_at_ms INTEGER NOT NULL
1115 ) STRICT;
1116 INSERT INTO schema_migrations(version, applied_at)
1117 VALUES (28, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1118 PRAGMA user_version = 28;
1119 COMMIT;",
1120 );
1121 connection.execute_batch(&statements)?;
1122 Ok(())
1123}
1124
1125fn ensure_session_mount_read_only_column(connection: &Connection) -> Result<()> {
1126 if !table_has_column(connection, "session_mounts", "read_only")? {
1127 connection.execute_batch(
1128 "BEGIN IMMEDIATE;
1129 ALTER TABLE session_mounts ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0;
1130 COMMIT;",
1131 )?;
1132 }
1133 Ok(())
1134}
1135
1136fn ensure_materialized_elicitation_column(connection: &Connection) -> Result<()> {
1137 if !table_has_column(
1138 connection,
1139 "materialized_sessions",
1140 "pending_elicitations_json",
1141 )? {
1142 connection.execute_batch(
1143 "BEGIN IMMEDIATE;
1144 ALTER TABLE materialized_sessions
1145 ADD COLUMN pending_elicitations_json TEXT NOT NULL DEFAULT '[]';
1146 COMMIT;",
1147 )?;
1148 }
1149 Ok(())
1150}
1151
1152fn ensure_managed_worktree_column(connection: &Connection) -> Result<()> {
1153 if !table_has_column(connection, "sessions", "managed_worktree")? {
1154 connection.execute_batch(
1155 "BEGIN IMMEDIATE;
1156 ALTER TABLE sessions ADD COLUMN managed_worktree TEXT;
1157 COMMIT;",
1158 )?;
1159 }
1160 Ok(())
1161}
1162
1163fn ensure_relay_projection_schema(connection: &Connection) -> Result<()> {
1166 if table_has_column(connection, "sessions", "detached_after_event_ordinal")? {
1167 return Ok(());
1168 }
1169 connection.execute_batch(&format!(
1170 "BEGIN IMMEDIATE;
1171 ALTER TABLE session_checkpoints
1172 RENAME COLUMN event_sequence TO event_frontier;
1173 ALTER TABLE prompt_history
1174 RENAME COLUMN event_sequence TO event_ordinal;
1175 ALTER TABLE sessions ADD COLUMN detached_after_event_ordinal INTEGER NOT NULL
1176 DEFAULT 0 CHECK(detached_after_event_ordinal >= 0);
1177 CREATE TABLE materialized_sessions (
1178 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1179 applied_event_ordinal INTEGER NOT NULL DEFAULT 0 CHECK(applied_event_ordinal >= 0),
1180 applied_event_digest TEXT NOT NULL
1181 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1182 CHECK(length(applied_event_digest) = 64
1183 AND applied_event_digest NOT GLOB '*[^0-9a-f]*'),
1184 last_activity_at_ms INTEGER,
1185 execution_state TEXT NOT NULL DEFAULT 'idle'
1186 CHECK(execution_state IN ('idle','running','closing','closed')),
1187 running_started_at_ms INTEGER,
1188 session_title TEXT CHECK(session_title IS NULL OR length(trim(session_title)) > 0),
1189 configuration_json TEXT NOT NULL DEFAULT '{{}}',
1190 CHECK(
1191 (execution_state = 'running' AND running_started_at_ms IS NOT NULL)
1192 OR (execution_state != 'running' AND running_started_at_ms IS NULL)
1193 )
1194 ) STRICT;
1195 CREATE TABLE materialized_transcript_items (
1196 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1197 stable_id TEXT NOT NULL CHECK(length(trim(stable_id)) > 0),
1198 position INTEGER NOT NULL CHECK(position > 0),
1199 latest_content_event_ordinal INTEGER
1200 CHECK(latest_content_event_ordinal IS NULL
1201 OR latest_content_event_ordinal >= position),
1202 created_at_ms INTEGER NOT NULL,
1203 last_changed_at_ms INTEGER NOT NULL CHECK(last_changed_at_ms >= created_at_ms),
1204 body_json TEXT NOT NULL,
1205 PRIMARY KEY(session_id, stable_id)
1206 ) STRICT;
1207 CREATE INDEX materialized_transcript_position
1208 ON materialized_transcript_items(session_id, position, stable_id);
1209 CREATE TABLE materialized_queued_prompts (
1210 session_id TEXT NOT NULL REFERENCES materialized_sessions(session_id) ON DELETE CASCADE,
1211 ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
1212 command_id TEXT NOT NULL CHECK(length(trim(command_id)) > 0),
1213 content_json TEXT NOT NULL,
1214 queued_at_ms INTEGER NOT NULL,
1215 PRIMARY KEY(session_id, ordinal),
1216 UNIQUE(session_id, command_id)
1217 ) STRICT;
1218 INSERT INTO materialized_sessions(session_id)
1219 SELECT session_id FROM sessions;
1220 COMMIT;",
1221 ))?;
1222 Ok(())
1223}
1224
1225fn migrate_destroying_session_state(connection: &Connection) -> Result<()> {
1226 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1230 let migration = connection.execute_batch(
1231 "BEGIN IMMEDIATE;
1232 CREATE TABLE sessions_v7 (
1233 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1234 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1235 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi')),
1236 last_profile TEXT NOT NULL,
1237 target_template_id TEXT NOT NULL,
1238 state TEXT NOT NULL CHECK(state IN (
1239 'provisioning','running','disconnected','checkpointing','closing','destroying',
1240 'archived','lost','error','destroyed-with-data-loss'
1241 )),
1242 native_session_id TEXT,
1243 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1244 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1245 updated_at TEXT NOT NULL,
1246 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1247 CHECK(detached_after_event_ordinal >= 0),
1248 last_error TEXT,
1249 resource_allocation TEXT,
1250 last_checkpoint_error TEXT,
1251 project_directory BLOB,
1252 managed_worktree TEXT
1253 ) STRICT;
1254 INSERT INTO sessions_v7(
1255 session_id, title, harness_kind, last_profile, target_template_id, state,
1256 native_session_id, acp_session_title, session_title_override, updated_at,
1257 detached_after_event_ordinal, last_error, resource_allocation,
1258 last_checkpoint_error, project_directory, managed_worktree
1259 )
1260 SELECT
1261 session_id, title, harness_kind, last_profile, target_template_id, state,
1262 native_session_id, acp_session_title, session_title_override, updated_at,
1263 detached_after_event_ordinal, last_error, resource_allocation,
1264 last_checkpoint_error, project_directory, managed_worktree
1265 FROM sessions;
1266 DROP TABLE sessions;
1267 ALTER TABLE sessions_v7 RENAME TO sessions;
1268 INSERT INTO schema_migrations(version, applied_at)
1269 VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1270 PRAGMA user_version = 7;
1271 COMMIT;",
1272 );
1273 if migration.is_err()
1274 && let Err(error) = connection.execute_batch("ROLLBACK;")
1275 {
1276 tracing::warn!(%error, "could not roll back durable-destroying-session migration");
1277 }
1278 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1279 migration.context("migrate durable destroying session state")?;
1280 foreign_keys.context("restore foreign key enforcement after schema migration")?;
1281 let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1282 if statement.exists([])? {
1283 bail!("foreign key violation after migrating durable destroying session state");
1284 }
1285 Ok(())
1286}
1287
1288fn migrate_grok_harness_kind(connection: &Connection) -> Result<()> {
1293 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1294 let migration = connection.execute_batch(
1295 "BEGIN IMMEDIATE;
1296 CREATE TABLE sessions_v9 (
1297 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1298 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1299 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1300 last_profile TEXT NOT NULL,
1301 target_template_id TEXT NOT NULL,
1302 state TEXT NOT NULL CHECK(state IN (
1303 'provisioning','running','disconnected','checkpointing','closing','destroying',
1304 'archived','lost','error','destroyed-with-data-loss'
1305 )),
1306 native_session_id TEXT,
1307 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1308 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1309 updated_at TEXT NOT NULL,
1310 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1311 CHECK(detached_after_event_ordinal >= 0),
1312 last_error TEXT,
1313 resource_allocation TEXT,
1314 last_checkpoint_error TEXT,
1315 project_directory BLOB,
1316 managed_worktree TEXT,
1317 draft_input TEXT NOT NULL DEFAULT ''
1318 ) STRICT;
1319 INSERT INTO sessions_v9(
1320 session_id, title, harness_kind, last_profile, target_template_id, state,
1321 native_session_id, acp_session_title, session_title_override, updated_at,
1322 detached_after_event_ordinal, last_error, resource_allocation,
1323 last_checkpoint_error, project_directory, managed_worktree, draft_input
1324 )
1325 SELECT
1326 session_id, title, harness_kind, last_profile, target_template_id, state,
1327 native_session_id, acp_session_title, session_title_override, updated_at,
1328 detached_after_event_ordinal, last_error, resource_allocation,
1329 last_checkpoint_error, project_directory, managed_worktree, draft_input
1330 FROM sessions;
1331 DROP TABLE sessions;
1332 ALTER TABLE sessions_v9 RENAME TO sessions;
1333 INSERT INTO schema_migrations(version, applied_at)
1334 VALUES (9, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1335 PRAGMA user_version = 9;
1336 COMMIT;",
1337 );
1338 if migration.is_err()
1339 && let Err(error) = connection.execute_batch("ROLLBACK;")
1340 {
1341 tracing::warn!(%error, "could not roll back Grok harness migration");
1342 }
1343 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1344 migration.context("migrate sessions table for the Grok Build harness")?;
1345 foreign_keys.context("restore foreign key enforcement after schema migration")?;
1346 let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1347 if statement.exists([])? {
1348 bail!("foreign key violation after migrating the sessions harness list");
1349 }
1350 Ok(())
1351}
1352
1353fn migrate_stopped_session_state(connection: &Connection) -> Result<()> {
1359 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1360 let migration = connection.execute_batch(
1361 "BEGIN IMMEDIATE;
1362 CREATE TABLE sessions_v10 (
1363 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1364 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1365 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1366 last_profile TEXT NOT NULL,
1367 target_template_id TEXT NOT NULL,
1368 state TEXT NOT NULL CHECK(state IN (
1369 'provisioning','running','disconnected','checkpointing','closing','destroying',
1370 'stopped','lost','error','destroyed-with-data-loss'
1371 )),
1372 native_session_id TEXT,
1373 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1374 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1375 updated_at TEXT NOT NULL,
1376 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1377 CHECK(detached_after_event_ordinal >= 0),
1378 last_error TEXT,
1379 resource_allocation TEXT,
1380 last_checkpoint_error TEXT,
1381 project_directory BLOB,
1382 managed_worktree TEXT,
1383 draft_input TEXT NOT NULL DEFAULT '',
1384 container_cpus TEXT,
1385 container_memory TEXT,
1386 archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1387 ) STRICT;
1388 INSERT INTO sessions_v10(
1389 session_id, title, harness_kind, last_profile, target_template_id, state,
1390 native_session_id, acp_session_title, session_title_override, updated_at,
1391 detached_after_event_ordinal, last_error, resource_allocation,
1392 last_checkpoint_error, project_directory, managed_worktree, draft_input,
1393 container_cpus, container_memory
1394 )
1395 SELECT
1396 session_id, title, harness_kind, last_profile, target_template_id,
1397 CASE state WHEN 'archived' THEN 'stopped' ELSE state END,
1398 native_session_id, acp_session_title, session_title_override, updated_at,
1399 detached_after_event_ordinal, last_error, resource_allocation,
1400 last_checkpoint_error, project_directory, managed_worktree, draft_input,
1401 container_cpus, container_memory
1402 FROM sessions;
1403 DROP TABLE sessions;
1404 ALTER TABLE sessions_v10 RENAME TO sessions;
1405 CREATE TABLE hidden_native_sessions (
1406 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok')),
1407 native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1408 hidden_at TEXT NOT NULL,
1409 PRIMARY KEY(harness_kind, native_session_id)
1410 ) STRICT;
1411 INSERT INTO schema_migrations(version, applied_at)
1412 VALUES (10, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1413 PRAGMA user_version = 10;
1414 COMMIT;",
1415 );
1416 if migration.is_err()
1417 && let Err(error) = connection.execute_batch("ROLLBACK;")
1418 {
1419 tracing::warn!(%error, "could not roll back stopped-session migration");
1420 }
1421 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1422 migration.context("migrate sessions table for the stopped session state")?;
1423 foreign_keys.context("restore foreign key enforcement after schema migration")?;
1424 let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1425 if statement.exists([])? {
1426 bail!("foreign key violation after migrating the stopped session state");
1427 }
1428 Ok(())
1429}
1430
1431fn migrate_muse_harness_kind(connection: &Connection) -> Result<()> {
1440 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1441 let migration = (|| -> Result<()> {
1442 let transaction = connection.unchecked_transaction()?;
1443 for table in ["sessions", "hidden_native_sessions"] {
1444 let sql: String = transaction.query_row(
1445 "SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",
1446 [table],
1447 |row| row.get(0),
1448 )?;
1449 let (_, definition) = sql
1450 .split_once('(')
1451 .context("missing harness table definition")?;
1452 if definition.contains("'deepseek','muse')") {
1453 continue;
1454 }
1455 ensure!(
1456 definition.contains("'deepseek')"),
1457 "unexpected {table} harness constraint"
1458 );
1459 let definition = definition.replace("'deepseek')", "'deepseek','muse')");
1460 let objects: Vec<String> = transaction.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name=?1 AND type IN ('index','trigger') AND sql IS NOT NULL")?
1461 .query_map([table], |row| row.get(0))?.collect::<rusqlite::Result<_>>()?;
1462 transaction.execute_batch(&format!(
1463 "CREATE TABLE {table}_muse_v27 ({definition}; INSERT INTO {table}_muse_v27 SELECT * FROM {table}; DROP TABLE {table}; ALTER TABLE {table}_muse_v27 RENAME TO {table};"
1464 ))?;
1465 for object in objects {
1466 transaction.execute_batch(&object)?;
1467 }
1468 }
1469 ensure!(
1470 !transaction
1471 .prepare("PRAGMA foreign_key_check")?
1472 .exists([])?,
1473 "foreign key violation in Muse migration"
1474 );
1475 transaction.execute_batch("INSERT INTO schema_migrations(version, applied_at) VALUES (27, strftime('%Y-%m-%dT%H:%M:%fZ','now')); PRAGMA user_version = 27;")?;
1476 transaction.commit()?;
1477 Ok(())
1478 })();
1479 let restored = connection.execute_batch("PRAGMA foreign_keys = ON;");
1480 migration.context("migrate Muse harness constraints")?;
1481 restored.context("restore foreign key enforcement after Muse migration")?;
1482 Ok(())
1483}
1484
1485fn migrate_deepseek_harness_kind(connection: &Connection) -> Result<()> {
1488 connection.execute_batch("PRAGMA foreign_keys = OFF;")?;
1489 let migration = connection.execute_batch(
1490 "BEGIN IMMEDIATE;
1491 CREATE TABLE sessions_v11 (
1492 session_id TEXT PRIMARY KEY REFERENCES session_contexts(session_id),
1493 title TEXT NOT NULL CHECK(length(trim(title)) > 0),
1494 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1495 last_profile TEXT NOT NULL,
1496 target_template_id TEXT NOT NULL,
1497 state TEXT NOT NULL CHECK(state IN (
1498 'provisioning','running','disconnected','checkpointing','closing','destroying',
1499 'stopped','lost','error','destroyed-with-data-loss'
1500 )),
1501 native_session_id TEXT,
1502 acp_session_title TEXT CHECK(acp_session_title IS NULL OR length(trim(acp_session_title)) > 0),
1503 session_title_override TEXT CHECK(session_title_override IS NULL OR length(trim(session_title_override)) > 0),
1504 updated_at TEXT NOT NULL,
1505 detached_after_event_ordinal INTEGER NOT NULL DEFAULT 0
1506 CHECK(detached_after_event_ordinal >= 0),
1507 last_error TEXT,
1508 resource_allocation TEXT,
1509 last_checkpoint_error TEXT,
1510 project_directory BLOB,
1511 managed_worktree TEXT,
1512 draft_input TEXT NOT NULL DEFAULT '',
1513 container_cpus TEXT,
1514 container_memory TEXT,
1515 archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
1516 ) STRICT;
1517 INSERT INTO sessions_v11 SELECT * FROM sessions;
1518 DROP TABLE sessions;
1519 ALTER TABLE sessions_v11 RENAME TO sessions;
1520 ALTER TABLE hidden_native_sessions RENAME TO hidden_native_sessions_v10;
1521 CREATE TABLE hidden_native_sessions (
1522 harness_kind TEXT NOT NULL CHECK(harness_kind IN ('codex','claude','kimi','grok','deepseek')),
1523 native_session_id TEXT NOT NULL CHECK(length(trim(native_session_id)) > 0),
1524 hidden_at TEXT NOT NULL,
1525 PRIMARY KEY(harness_kind, native_session_id)
1526 ) STRICT;
1527 INSERT INTO hidden_native_sessions SELECT * FROM hidden_native_sessions_v10;
1528 DROP TABLE hidden_native_sessions_v10;
1529 INSERT INTO schema_migrations(version, applied_at)
1530 VALUES (11, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
1531 PRAGMA user_version = 11;
1532 COMMIT;",
1533 );
1534 if migration.is_err()
1535 && let Err(error) = connection.execute_batch("ROLLBACK;")
1536 {
1537 tracing::warn!(%error, "could not roll back DeepSeek harness migration");
1538 }
1539 let foreign_keys = connection.execute_batch("PRAGMA foreign_keys = ON;");
1540 migration.context("migrate sessions table for DeepSeek Harness")?;
1541 foreign_keys.context("restore foreign key enforcement after schema migration")?;
1542 let mut statement = connection.prepare("PRAGMA foreign_key_check")?;
1543 if statement.exists([])? {
1544 bail!("foreign key violation after migrating the DeepSeek Harness list");
1545 }
1546 Ok(())
1547}
1548
1549fn ensure_session_draft_input_column(connection: &Connection) -> Result<()> {
1553 if !table_has_column(connection, "sessions", "draft_input")? {
1554 connection.execute_batch(
1555 "BEGIN IMMEDIATE;
1556 ALTER TABLE sessions ADD COLUMN draft_input TEXT NOT NULL DEFAULT '';
1557 COMMIT;",
1558 )?;
1559 }
1560 Ok(())
1561}
1562
1563fn ensure_projection_digest_column(connection: &Connection) -> Result<()> {
1564 let present = connection.query_row(
1565 "SELECT EXISTS(
1566 SELECT 1 FROM pragma_table_info('materialized_sessions')
1567 WHERE name = 'applied_event_digest'
1568 )",
1569 [],
1570 |row| row.get::<_, bool>(0),
1571 )?;
1572 if !present {
1573 connection.execute_batch(&format!(
1574 "BEGIN IMMEDIATE;
1575 ALTER TABLE materialized_sessions ADD COLUMN applied_event_digest TEXT NOT NULL
1576 DEFAULT '{RELAY_EVENT_GENESIS_DIGEST}'
1577 CHECK(length(applied_event_digest) = 64
1578 AND applied_event_digest NOT GLOB '*[^0-9a-f]*');
1579 COMMIT;",
1580 ))?;
1581 }
1582 Ok(())
1583}
1584
1585fn ensure_api_events_schema(connection: &Connection) -> Result<()> {
1586 connection.execute_batch(
1587 "CREATE TABLE IF NOT EXISTS api_events (
1588 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1589 session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
1590 recorded_at_ms INTEGER NOT NULL,
1591 body TEXT NOT NULL CHECK(json_valid(body))
1592 ) STRICT;
1593 CREATE INDEX IF NOT EXISTS api_events_session ON api_events(session_id, seq);
1594 CREATE TABLE IF NOT EXISTS api_session_activity (
1595 session_id TEXT PRIMARY KEY REFERENCES sessions(session_id) ON DELETE CASCADE,
1596 body TEXT NOT NULL CHECK(json_valid(body))
1597 ) STRICT;
1598 CREATE TRIGGER IF NOT EXISTS api_session_error_updated
1599 AFTER UPDATE OF last_error ON sessions
1600 WHEN NEW.last_error IS NOT NULL AND NEW.last_error IS NOT OLD.last_error
1601 BEGIN
1602 INSERT INTO api_events(session_id, recorded_at_ms, body)
1603 VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1604 json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1605 END;
1606 CREATE TRIGGER IF NOT EXISTS api_session_error_inserted
1607 AFTER INSERT ON sessions WHEN NEW.last_error IS NOT NULL
1608 BEGIN
1609 INSERT INTO api_events(session_id, recorded_at_ms, body)
1610 VALUES (NEW.session_id, CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER),
1611 json_object('type', 'error', 'data', json_object('message', NEW.last_error, 'command_id', NULL)));
1612 END;"
1613 )?;
1614 Ok(())
1615}
1616
1617#[cfg(test)]
1618pub(super) fn advance_test_schema(path: &Path, revision: i64, minimum_compatible: i64) {
1619 let connection = Connection::open(path).unwrap();
1620 let transaction = connection.unchecked_transaction().unwrap();
1621 transaction
1622 .execute(
1623 "UPDATE schema_compatibility SET minimum_compatible_version = ?1",
1624 [minimum_compatible],
1625 )
1626 .unwrap();
1627 transaction
1628 .execute(
1629 "INSERT INTO schema_migrations(version, applied_at) VALUES (?1, 'test')",
1630 [revision],
1631 )
1632 .unwrap();
1633 transaction
1634 .pragma_update(None, "user_version", revision)
1635 .unwrap();
1636 transaction.commit().unwrap();
1637 forget_verified_schema(path);
1638}
1639
1640#[cfg(test)]
1641mod reader_tests {
1642 use super::*;
1643
1644 const MINIMUM_COMPATIBLE_VERSION: i64 = 32;
1648
1649 fn stamp_schema_version(path: &Path, version: i64) {
1652 if version > SCHEMA_VERSION {
1653 advance_test_schema(path, version, version);
1654 return;
1655 }
1656 let connection = Connection::open(path).unwrap();
1657 connection
1658 .execute_batch(&format!("PRAGMA user_version = {version};"))
1659 .unwrap();
1660 connection
1661 .execute(
1662 "DELETE FROM schema_migrations WHERE version > ?1",
1663 [version],
1664 )
1665 .unwrap();
1666 if version == 30 {
1667 connection
1668 .execute(
1669 "UPDATE schema_compatibility SET minimum_compatible_version = 30 WHERE singleton = 1",
1670 [],
1671 )
1672 .unwrap();
1673 }
1674 drop(connection);
1675 forget_verified_schema(path);
1676 }
1677
1678 #[test]
1679 fn older_readers_and_reopened_writers_preserve_a_compatible_future_schema() {
1680 let directory = tempfile::tempdir().unwrap();
1681 let path = directory.path().join("mj.sqlite3");
1682 let connection = open_writer(&path).unwrap();
1683 connection
1684 .execute_batch(
1685 "CREATE TABLE future_feature(value TEXT NOT NULL);
1686 INSERT INTO future_feature VALUES ('preserve me');",
1687 )
1688 .unwrap();
1689 drop(connection);
1690 advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1691
1692 let reader = open_reader_strict(&path).unwrap();
1693 assert_eq!(
1694 reader
1695 .query_row("SELECT value FROM future_feature", [], |row| row
1696 .get::<_, String>(0))
1697 .unwrap(),
1698 "preserve me"
1699 );
1700 assert!(reader.execute("DELETE FROM future_feature", []).is_err());
1701 drop(reader);
1702
1703 let raw = Connection::open(&path).unwrap();
1706 raw.execute_batch("DROP TRIGGER api_session_error_updated;")
1707 .unwrap();
1708 drop(raw);
1709 let writer = open_writer(&path).unwrap();
1710 assert!(!writer.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'api_session_error_updated')", [], |row| row.get::<_, bool>(0)).unwrap());
1711 assert_eq!(
1712 writer
1713 .query_row("SELECT value FROM future_feature", [], |row| row
1714 .get::<_, String>(0))
1715 .unwrap(),
1716 "preserve me"
1717 );
1718 let state = read_schema_state(&writer).unwrap();
1719 assert_eq!(state.revision, SCHEMA_VERSION + 1);
1720 assert_eq!(state.minimum_compatible, Some(SCHEMA_VERSION));
1721 }
1722
1723 #[test]
1724 fn invalid_compatibility_metadata_refuses_readers_and_writers() {
1725 for alteration in [
1726 "DROP TABLE schema_compatibility",
1727 "DELETE FROM schema_compatibility",
1728 "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET minimum_compatible_version = 0",
1729 "UPDATE schema_compatibility SET minimum_compatible_version = 99999",
1730 "PRAGMA ignore_check_constraints = ON; UPDATE schema_compatibility SET singleton = 2",
1731 "PRAGMA ignore_check_constraints = ON; INSERT INTO schema_compatibility VALUES (2, 30)",
1732 "DROP TABLE schema_compatibility; CREATE TABLE schema_compatibility(singleton, minimum_compatible_version); INSERT INTO schema_compatibility VALUES (1, 'invalid')",
1733 "DELETE FROM schema_migrations WHERE version = (SELECT max(version) FROM schema_migrations)",
1734 ] {
1735 for future in [false, true] {
1736 let directory = tempfile::tempdir().unwrap();
1737 let path = directory.path().join("mj.sqlite3");
1738 drop(open_writer(&path).unwrap());
1739 if future {
1740 advance_test_schema(&path, SCHEMA_VERSION + 1, SCHEMA_VERSION);
1741 }
1742 let raw = Connection::open(&path).unwrap();
1743 raw.execute_batch(alteration).unwrap();
1744 let before: i64 = raw
1745 .query_row("PRAGMA schema_version", [], |row| row.get(0))
1746 .unwrap();
1747 for error in [
1749 open_reader_strict(&path).unwrap_err(),
1750 open_writer(&path).unwrap_err(),
1751 ] {
1752 let mismatch = error.downcast_ref::<StoreSchemaMismatch>().unwrap();
1753 assert_eq!(
1754 mismatch.reason,
1755 StoreSchemaMismatchReason::InvalidCompatibilityMetadata,
1756 "{alteration}"
1757 );
1758 }
1759 forget_verified_schema(&path);
1760 assert!(open_writer(&path).is_err(), "{alteration}");
1761 let after: i64 = raw
1762 .query_row("PRAGMA schema_version", [], |row| row.get(0))
1763 .unwrap();
1764 assert_eq!(
1765 before, after,
1766 "a rejected open repaired schema: {alteration}"
1767 );
1768 }
1769 }
1770 }
1771
1772 #[test]
1773 fn compatibility_baseline_migration_is_atomic_and_retryable() {
1774 let directory = tempfile::tempdir().unwrap();
1775 let path = directory.path().join("mj.sqlite3");
1776 let connection = open_writer(&path).unwrap();
1777 let state = read_schema_state(&connection).unwrap();
1778 assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
1779 connection
1780 .execute_batch(
1781 "DROP TABLE schema_compatibility;
1782 ALTER TABLE sessions DROP COLUMN mjolnir_subagents;
1783 DELETE FROM schema_migrations WHERE version >= 30;
1784 PRAGMA user_version = 29;
1785 CREATE TRIGGER reject_baseline BEFORE INSERT ON schema_migrations
1786 WHEN NEW.version = 30 BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END;",
1787 )
1788 .unwrap();
1789 forget_verified_schema(&path);
1790 let error = migrate_schema(&connection).unwrap_err();
1791 assert!(error.to_string().contains("injected migration failure"));
1792 assert!(
1793 connection.is_autocommit(),
1794 "the failed migration left a transaction open"
1795 );
1796 assert_eq!(read_schema_state(&connection).unwrap().revision, 29);
1797 assert_eq!(
1798 connection
1799 .query_row("SELECT max(version) FROM schema_migrations", [], |row| row
1800 .get::<_, i64>(
1801 0
1802 ))
1803 .unwrap(),
1804 29
1805 );
1806 assert!(!connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name = 'schema_compatibility')", [], |row| row.get::<_, bool>(0)).unwrap());
1807 connection
1808 .execute_batch("DROP TRIGGER reject_baseline")
1809 .unwrap();
1810 drop(connection);
1811 let writer = open_writer(&path).unwrap();
1812 let state = read_schema_state(&writer).unwrap();
1813 assert_eq!(state.revision, SCHEMA_VERSION);
1814 assert_eq!(state.minimum_compatible, Some(MINIMUM_COMPATIBLE_VERSION));
1815 }
1816
1817 #[test]
1821 fn strict_reader_reports_a_newer_store_without_blaming_the_daemon() {
1822 let directory = tempfile::tempdir().unwrap();
1823 let path = directory.path().join("mj.sqlite3");
1824 drop(open_writer(&path).unwrap());
1825 stamp_schema_version(&path, SCHEMA_VERSION + 1);
1826
1827 let error = open_reader_strict(&path).unwrap_err();
1828
1829 let mismatch = error
1830 .chain()
1831 .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1832 .expect("the reader reports the mismatch as a typed cause");
1833 assert_eq!(mismatch.found, SCHEMA_VERSION + 1);
1834 assert_eq!(mismatch.supported, SCHEMA_VERSION);
1835 let message = mismatch.to_string();
1836 assert!(message.contains("upgrade Mjolnir"), "got {message}");
1837 assert!(
1838 !message.contains("start the Mjolnir daemon"),
1839 "got {message}"
1840 );
1841 }
1842
1843 #[test]
1846 fn strict_reader_keeps_the_migrate_advice_when_the_store_is_behind() {
1847 let directory = tempfile::tempdir().unwrap();
1848 let path = directory.path().join("mj.sqlite3");
1849 drop(open_writer(&path).unwrap());
1850 let raw = Connection::open(&path).unwrap();
1851 raw.execute_batch(&format!(
1852 "UPDATE schema_compatibility SET minimum_compatible_version = {0};
1853 DELETE FROM schema_migrations WHERE version > {0};
1854 PRAGMA user_version = {0};",
1855 SCHEMA_VERSION - 1
1856 ))
1857 .unwrap();
1858 drop(raw);
1859
1860 let error = open_reader_strict(&path).unwrap_err();
1861
1862 let mismatch = error
1863 .chain()
1864 .find_map(|cause| cause.downcast_ref::<StoreSchemaMismatch>())
1865 .expect("the reader reports the mismatch as a typed cause");
1866 assert_eq!(
1867 mismatch.to_string(),
1868 format!(
1869 "Mjolnir database schema {} is not the supported schema {SCHEMA_VERSION}; \
1870 start the Mjolnir daemon to migrate it",
1871 SCHEMA_VERSION - 1
1872 )
1873 );
1874 }
1875
1876 #[test]
1877 fn strict_reader_rejects_mutation() {
1878 let directory = tempfile::tempdir().unwrap();
1879 let path = directory.path().join("mj.sqlite3");
1880 drop(open_writer(&path).unwrap());
1881
1882 let reader = open_reader_strict(&path).unwrap();
1883 let error = reader
1884 .execute("CREATE TABLE forbidden(value TEXT)", [])
1885 .unwrap_err();
1886 assert!(
1887 matches!(
1888 error.sqlite_error_code(),
1889 Some(rusqlite::ErrorCode::ReadOnly)
1890 ),
1891 "unexpected mutation error: {error}"
1892 );
1893 }
1894}