1use rusqlite::Connection;
20use tracing::warn;
21
22use super::error::StoreError;
23
24pub const OBJECT_SCHEMA_VERSION: i64 = 4;
34pub const FILESTORE_SCHEMA_VERSION: i64 = 1;
36pub const SAGA_LOG_SCHEMA_VERSION: i64 = 1;
38
39const WSTORE_OTYPES: &[&str] = &[
41 "client",
42 "window",
43 "workspace",
44 "tab",
45 "layout",
46 "block",
47 "temp",
48];
49
50const LEGACY_TABLE_RENAMES: &[(&str, &str)] = &[
55 ("db_forge_agents", "db_agent_definitions"),
56 ("db_forge_content", "db_agent_content"),
57 ("db_forge_skills", "db_agent_skills"),
58 ("db_forge_history", "db_agent_history"),
59 ("db_forge_agent_identities", "db_agent_identity_links"),
60 ("db_identities", "db_identity_bundles"),
61 ("db_memories", "db_memory_bundles"),
62];
63
64const LEGACY_INDEX_DROPS: &[&str] = &[
69 "idx_forge_agents_slug",
70 "idx_forge_history_agent_date",
71 "idx_forge_agent_identities_account",
72 "idx_identities_is_blank",
73 "idx_memories_is_blank",
74];
75
76const DEAD_TABLE_DROPS: &[&str] = &[
81 "db_workflow_definitions",
82 "db_workflow_runs",
83 "db_v10_migrated_legacy_defs",
84 "db_v10_migrated_legacy_runs",
85];
86
87pub fn run_object_schema(conn: &Connection) -> Result<(), StoreError> {
104 adopt_legacy_table_names(conn)?;
105
106 for otype in WSTORE_OTYPES {
108 conn.execute_batch(&format!(
109 "CREATE TABLE IF NOT EXISTS db_{otype} (
110 oid TEXT PRIMARY KEY,
111 version INTEGER NOT NULL DEFAULT 1,
112 data TEXT NOT NULL
113 );"
114 ))?;
115 }
116
117 conn.execute_batch(
119 "CREATE TABLE IF NOT EXISTS db_agent_definitions (
120 id TEXT PRIMARY KEY,
121 slug TEXT NOT NULL DEFAULT '',
122 name TEXT NOT NULL,
123 icon TEXT NOT NULL DEFAULT '✦',
124 provider TEXT NOT NULL,
125 description TEXT NOT NULL DEFAULT '',
126 working_directory TEXT NOT NULL DEFAULT '',
127 shell TEXT NOT NULL DEFAULT '',
128 provider_flags TEXT NOT NULL DEFAULT '',
129 auto_start INTEGER NOT NULL DEFAULT 0,
130 restart_on_crash INTEGER NOT NULL DEFAULT 0,
131 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
132 agent_type TEXT NOT NULL DEFAULT 'standalone',
133 environment TEXT NOT NULL DEFAULT '',
134 agent_bus_id TEXT NOT NULL DEFAULT '',
135 is_seeded INTEGER NOT NULL DEFAULT 0,
136 accounts TEXT NOT NULL DEFAULT '',
137 parent_id TEXT NOT NULL DEFAULT '',
138 branch_label TEXT NOT NULL DEFAULT '',
139 created_at INTEGER NOT NULL DEFAULT 0,
140 updated_at INTEGER NOT NULL DEFAULT 0,
141 user_hidden INTEGER NOT NULL DEFAULT 0
142 );
143 CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_definitions_slug
144 ON db_agent_definitions(slug);
145
146 CREATE TABLE IF NOT EXISTS db_agent_content (
147 agent_id TEXT NOT NULL,
148 content_type TEXT NOT NULL,
149 content TEXT NOT NULL DEFAULT '',
150 updated_at INTEGER NOT NULL DEFAULT 0,
151 PRIMARY KEY (agent_id, content_type),
152 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
153 );
154
155 CREATE TABLE IF NOT EXISTS db_agent_skills (
156 id TEXT PRIMARY KEY,
157 agent_id TEXT NOT NULL,
158 name TEXT NOT NULL,
159 trigger TEXT NOT NULL DEFAULT '',
160 skill_type TEXT NOT NULL DEFAULT 'prompt',
161 description TEXT NOT NULL DEFAULT '',
162 content TEXT NOT NULL DEFAULT '',
163 created_at INTEGER NOT NULL DEFAULT 0,
164 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
165 );
166
167 CREATE TABLE IF NOT EXISTS db_agent_history (
168 id INTEGER PRIMARY KEY AUTOINCREMENT,
169 agent_id TEXT NOT NULL,
170 session_date TEXT NOT NULL,
171 entry TEXT NOT NULL,
172 timestamp INTEGER NOT NULL DEFAULT 0,
173 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
174 );
175 CREATE INDEX IF NOT EXISTS idx_agent_history_agent_date
176 ON db_agent_history(agent_id, session_date);
177
178 CREATE TABLE IF NOT EXISTS db_identity_accounts (
179 id TEXT PRIMARY KEY,
180 name TEXT NOT NULL,
181 provider TEXT NOT NULL,
182 kind TEXT NOT NULL,
183 display_name TEXT NOT NULL DEFAULT '',
184 secret_ref TEXT NOT NULL,
185 context TEXT NOT NULL DEFAULT '{}',
186 status TEXT NOT NULL DEFAULT 'unknown',
187 created_at INTEGER NOT NULL DEFAULT 0,
188 updated_at INTEGER NOT NULL DEFAULT 0
189 );
190 CREATE INDEX IF NOT EXISTS idx_identity_accounts_provider
191 ON db_identity_accounts(provider);
192
193 CREATE TABLE IF NOT EXISTS db_agent_identity_links (
194 agent_id TEXT NOT NULL,
195 account_id TEXT NOT NULL,
196 provider TEXT NOT NULL,
197 PRIMARY KEY (agent_id, provider),
198 FOREIGN KEY (agent_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE,
199 FOREIGN KEY (account_id) REFERENCES db_identity_accounts(id) ON DELETE CASCADE
200 );
201 CREATE INDEX IF NOT EXISTS idx_agent_identity_links_account
202 ON db_agent_identity_links(account_id);
203
204 CREATE TABLE IF NOT EXISTS db_identity_bundles (
205 id TEXT PRIMARY KEY,
206 name TEXT NOT NULL UNIQUE,
207 description TEXT NOT NULL DEFAULT '',
208 is_blank INTEGER NOT NULL DEFAULT 0,
209 created_at INTEGER NOT NULL DEFAULT 0,
210 updated_at INTEGER NOT NULL DEFAULT 0
211 );
212 CREATE INDEX IF NOT EXISTS idx_identity_bundles_is_blank
213 ON db_identity_bundles(is_blank);
214
215 CREATE TABLE IF NOT EXISTS db_identity_bindings (
216 identity_id TEXT NOT NULL,
217 provider TEXT NOT NULL,
218 account_id TEXT NOT NULL,
219 PRIMARY KEY (identity_id, provider),
220 FOREIGN KEY (identity_id) REFERENCES db_identity_bundles(id) ON DELETE CASCADE,
221 FOREIGN KEY (account_id) REFERENCES db_identity_accounts(id) ON DELETE CASCADE
222 );
223 CREATE INDEX IF NOT EXISTS idx_identity_bindings_account
224 ON db_identity_bindings(account_id);
225
226 CREATE TABLE IF NOT EXISTS db_memory_bundles (
227 id TEXT PRIMARY KEY,
228 name TEXT NOT NULL UNIQUE,
229 description TEXT NOT NULL DEFAULT '',
230 is_blank INTEGER NOT NULL DEFAULT 0,
231 provider TEXT NOT NULL DEFAULT '',
232 model TEXT NOT NULL DEFAULT '',
233 instructions TEXT NOT NULL DEFAULT '',
234 context_files TEXT NOT NULL DEFAULT '[]',
235 mcp_servers TEXT NOT NULL DEFAULT '[]',
236 skills TEXT NOT NULL DEFAULT '[]',
237 created_at INTEGER NOT NULL DEFAULT 0,
238 updated_at INTEGER NOT NULL DEFAULT 0
239 );
240 CREATE INDEX IF NOT EXISTS idx_memory_bundles_is_blank
241 ON db_memory_bundles(is_blank);
242
243 CREATE TABLE IF NOT EXISTS db_agent_instances (
244 id TEXT PRIMARY KEY,
245 definition_id TEXT NOT NULL,
246 parent_instance_id TEXT NOT NULL DEFAULT '',
247 block_id TEXT NOT NULL DEFAULT '',
248 session_id TEXT NOT NULL DEFAULT '',
249 status TEXT NOT NULL DEFAULT 'running',
250 github_context TEXT NOT NULL DEFAULT '',
251 identity_id TEXT NOT NULL DEFAULT '',
252 memory_id TEXT NOT NULL DEFAULT '',
253 instance_name TEXT NOT NULL DEFAULT '',
254 working_directory TEXT NOT NULL DEFAULT '',
255 display_hidden INTEGER NOT NULL DEFAULT 0,
256 started_at INTEGER NOT NULL DEFAULT 0,
257 ended_at INTEGER NOT NULL DEFAULT 0,
258 created_at INTEGER NOT NULL DEFAULT 0,
259 FOREIGN KEY (definition_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
260 );
261 CREATE INDEX IF NOT EXISTS idx_agent_instances_definition
262 ON db_agent_instances(definition_id);
263 CREATE INDEX IF NOT EXISTS idx_agent_instances_block
264 ON db_agent_instances(block_id);
265 CREATE INDEX IF NOT EXISTS idx_agent_instances_status
266 ON db_agent_instances(status);
267 CREATE INDEX IF NOT EXISTS idx_agent_instances_parent
268 ON db_agent_instances(parent_instance_id);
269 CREATE INDEX IF NOT EXISTS idx_agent_instances_name_recent
270 ON db_agent_instances(instance_name, started_at DESC)
271 WHERE display_hidden = 0 AND instance_name != '';
272
273 -- Phase 3a consolidation: `db_agents` collapses `db_agent_definitions`
274 -- + `db_agent_instances` into one table per
275 -- `docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md`.
276 -- WRITE-ONLY in 3a: every old-table mutation dual-writes here, but
277 -- every read still hits the old tables. Phase 3b migrates readers,
278 -- Phase 3c drops the old tables. Column names align with the live
279 -- `db_agent_definitions` shape (provider, working_directory, …) —
280 -- NOT the inline draft in the spec (provider_id, cmd, cmd_args, …) —
281 -- because the existing storage layer never grew the cmd-template
282 -- columns the spec sketched; carrying the names we actually have
283 -- avoids inventing data we don't store. See the PR body for the
284 -- field-by-field mapping.
285 CREATE TABLE IF NOT EXISTS db_agents (
286 id TEXT PRIMARY KEY,
287 name TEXT NOT NULL,
288 icon TEXT NOT NULL DEFAULT '',
289 description TEXT NOT NULL DEFAULT '',
290
291 -- Template vs user agent
292 is_template INTEGER NOT NULL DEFAULT 0,
293 parent_template_id TEXT NOT NULL DEFAULT '',
294
295 -- Provider/cmd config (was on definition; named to match the
296 -- live `db_agent_definitions` columns).
297 provider TEXT NOT NULL,
298 provider_flags TEXT NOT NULL DEFAULT '',
299 shell TEXT NOT NULL DEFAULT '',
300 environment TEXT NOT NULL DEFAULT '',
301 agent_type TEXT NOT NULL DEFAULT 'standalone',
302 agent_bus_id TEXT NOT NULL DEFAULT '',
303 accounts TEXT NOT NULL DEFAULT '',
304 auto_start INTEGER NOT NULL DEFAULT 0,
305 restart_on_crash INTEGER NOT NULL DEFAULT 0,
306 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
307 slug TEXT NOT NULL DEFAULT '',
308 branch_label TEXT NOT NULL DEFAULT '',
309
310 -- Bindings (was on instance — only meaningful when is_template=0).
311 -- For template rows these stay empty.
312 identity_id TEXT NOT NULL DEFAULT '',
313 memory_id TEXT NOT NULL DEFAULT '',
314 working_directory TEXT NOT NULL DEFAULT '',
315 github_context TEXT NOT NULL DEFAULT '',
316 instance_name TEXT NOT NULL DEFAULT '',
317
318 -- Provenance
319 created_at INTEGER NOT NULL DEFAULT 0,
320 updated_at INTEGER NOT NULL DEFAULT 0,
321 is_seeded INTEGER NOT NULL DEFAULT 0,
322 user_hidden INTEGER NOT NULL DEFAULT 0
323 );
324 CREATE INDEX IF NOT EXISTS idx_agents_is_template
325 ON db_agents(is_template);
326 CREATE INDEX IF NOT EXISTS idx_agents_parent_template_id
327 ON db_agents(parent_template_id);
328 CREATE INDEX IF NOT EXISTS idx_agents_is_seeded
329 ON db_agents(is_seeded);
330
331 CREATE TABLE IF NOT EXISTS db_drone_definitions (
332 id TEXT PRIMARY KEY,
333 name TEXT NOT NULL,
334 description TEXT NOT NULL DEFAULT '',
335 graph TEXT NOT NULL DEFAULT '{\"nodes\":[],\"edges\":[]}',
336 viewport TEXT NOT NULL DEFAULT '{\"x\":0,\"y\":0,\"zoom\":1}',
337 created_at INTEGER NOT NULL DEFAULT 0,
338 updated_at INTEGER NOT NULL DEFAULT 0
339 );
340 CREATE INDEX IF NOT EXISTS idx_drone_definitions_updated
341 ON db_drone_definitions(updated_at DESC);
342
343 CREATE TABLE IF NOT EXISTS db_drone_runs (
344 id TEXT PRIMARY KEY,
345 drone_id TEXT NOT NULL,
346 status TEXT NOT NULL DEFAULT 'running',
347 started_at INTEGER NOT NULL DEFAULT 0,
348 ended_at INTEGER NOT NULL DEFAULT 0,
349 block_states TEXT NOT NULL DEFAULT '{}',
350 output TEXT NOT NULL DEFAULT '',
351 error TEXT NOT NULL DEFAULT '',
352 FOREIGN KEY (drone_id) REFERENCES db_drone_definitions(id) ON DELETE CASCADE
353 );
354 CREATE INDEX IF NOT EXISTS idx_drone_runs_drone_started
355 ON db_drone_runs(drone_id, started_at DESC);
356 CREATE INDEX IF NOT EXISTS idx_drone_runs_status
357 ON db_drone_runs(status);",
358 )?;
359
360 for stmt in &[
373 "ALTER TABLE db_agent_definitions ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0",
374 "ALTER TABLE db_agent_definitions ADD COLUMN user_hidden INTEGER NOT NULL DEFAULT 0",
375 ] {
376 if let Err(e) = conn.execute_batch(stmt) {
377 let msg = e.to_string();
378 if !msg.contains("duplicate column") {
379 return Err(e.into());
380 }
381 }
382 }
383
384 conn.execute_batch(
389 "INSERT OR IGNORE INTO db_identity_bundles
390 (id, name, description, is_blank, created_at, updated_at)
391 VALUES ('blank', '__blank__', 'No credentials — use ambient', 1, 0, 0);
392
393 INSERT OR IGNORE INTO db_memory_bundles
394 (id, name, description, is_blank, created_at, updated_at)
395 VALUES ('blank', '__blank__', 'Vanilla CLI — no instructions, no context', 1, 0, 0);",
396 )?;
397
398 Ok(())
399}
400
401fn adopt_legacy_table_names(conn: &Connection) -> Result<(), StoreError> {
411 for (legacy, current) in LEGACY_TABLE_RENAMES {
412 let legacy_exists: i64 = conn.query_row(
413 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
414 [legacy],
415 |row| row.get(0),
416 )?;
417 if legacy_exists == 0 {
418 continue;
419 }
420 let current_exists: i64 = conn.query_row(
421 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
422 [current],
423 |row| row.get(0),
424 )?;
425 if current_exists == 1 {
426 warn!(
435 legacy_table = *legacy,
436 current_table = *current,
437 "objects.db has both a legacy table and its de-forged \
438 replacement — this only happens after a downgrade to a \
439 pre-flatten build; the legacy table is left untouched \
440 for manual recovery and is otherwise unused",
441 );
442 } else {
443 conn.execute_batch(&format!("ALTER TABLE {legacy} RENAME TO {current};"))?;
444 }
445 }
446
447 for idx in LEGACY_INDEX_DROPS {
450 conn.execute_batch(&format!("DROP INDEX IF EXISTS {idx};"))?;
451 }
452
453 for table in DEAD_TABLE_DROPS {
455 conn.execute_batch(&format!("DROP TABLE IF EXISTS {table};"))?;
456 }
457
458 Ok(())
459}
460
461pub fn run_filestore_migrations(conn: &Connection) -> Result<(), StoreError> {
465 conn.execute_batch(
466 "CREATE TABLE IF NOT EXISTS db_wave_file (
467 zoneid TEXT NOT NULL,
468 name TEXT NOT NULL,
469 size INTEGER NOT NULL DEFAULT 0,
470 createdts INTEGER NOT NULL DEFAULT 0,
471 modts INTEGER NOT NULL DEFAULT 0,
472 opts TEXT NOT NULL DEFAULT '{}',
473 meta TEXT NOT NULL DEFAULT '{}',
474 PRIMARY KEY (zoneid, name)
475 );
476
477 CREATE TABLE IF NOT EXISTS db_file_data (
478 zoneid TEXT NOT NULL,
479 name TEXT NOT NULL,
480 partidx INTEGER NOT NULL,
481 data BLOB NOT NULL,
482 PRIMARY KEY (zoneid, name, partidx)
483 );",
484 )?;
485 Ok(())
486}
487
488pub fn run_saga_log_migrations(conn: &Connection) -> Result<(), StoreError> {
492 conn.execute_batch(
493 "CREATE TABLE IF NOT EXISTS saga (
494 saga_id INTEGER PRIMARY KEY,
495 name TEXT NOT NULL,
496 state TEXT NOT NULL,
497 started_at INTEGER NOT NULL,
498 terminal_at INTEGER,
499 failure_reason TEXT,
500 input_json TEXT NOT NULL
501 );
502
503 CREATE TABLE IF NOT EXISTS saga_step (
504 saga_id INTEGER NOT NULL REFERENCES saga(saga_id),
505 step_index INTEGER NOT NULL,
506 name TEXT NOT NULL,
507 state TEXT NOT NULL,
508 cmd_json TEXT NOT NULL,
509 output_json TEXT,
510 started_at INTEGER NOT NULL,
511 ended_at INTEGER,
512 PRIMARY KEY (saga_id, step_index)
513 );
514
515 CREATE INDEX IF NOT EXISTS saga_state_idx
516 ON saga(state) WHERE state IN ('running', 'compensating');
517 CREATE INDEX IF NOT EXISTS saga_terminal_idx
518 ON saga(terminal_at);",
519 )?;
520 Ok(())
521}
522
523pub fn check_schema_compat(
554 conn: &Connection,
555 current: i64,
556 db_label: &str,
557) -> Result<(), StoreError> {
558 let found: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
559 if found > current {
560 warn!(
561 db = db_label,
562 found, expected = current,
563 "database user_version is newer than this build — refusing \
564 to open. Upgrade AgentMux or switch channels.",
565 );
566 return Err(StoreError::SchemaTooNew {
567 db: db_label.to_string(),
568 found,
569 expected: current,
570 });
571 }
572 Ok(())
573}
574
575pub fn stamp_version(conn: &Connection, current: i64) -> Result<(), StoreError> {
589 conn.execute_batch(&format!("PRAGMA user_version = {current};"))?;
590 Ok(())
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 const EXPECTED_TABLES: &[&str] = &[
599 "db_client",
600 "db_window",
601 "db_workspace",
602 "db_tab",
603 "db_layout",
604 "db_block",
605 "db_temp",
606 "db_agent_definitions",
607 "db_agent_content",
608 "db_agent_skills",
609 "db_agent_history",
610 "db_identity_accounts",
611 "db_agent_identity_links",
612 "db_identity_bundles",
613 "db_identity_bindings",
614 "db_memory_bundles",
615 "db_agent_instances",
616 "db_agents",
617 "db_drone_definitions",
618 "db_drone_runs",
619 ];
620
621 fn table_exists(conn: &Connection, name: &str) -> bool {
622 let count: i64 = conn
623 .query_row(
624 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?1",
625 [name],
626 |row| row.get(0),
627 )
628 .unwrap();
629 count == 1
630 }
631
632 fn index_exists(conn: &Connection, name: &str) -> bool {
633 let count: i64 = conn
634 .query_row(
635 "SELECT count(*) FROM sqlite_master WHERE type='index' AND name=?1",
636 [name],
637 |row| row.get(0),
638 )
639 .unwrap();
640 count == 1
641 }
642
643 #[test]
644 fn test_object_schema_creates_all_tables_and_singletons() {
645 let conn = Connection::open_in_memory().unwrap();
646 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
647 run_object_schema(&conn).unwrap();
648
649 for table in EXPECTED_TABLES {
650 assert!(table_exists(&conn, table), "{table} should exist");
651 }
652 for idx in &[
654 "idx_agent_definitions_slug",
655 "idx_agent_history_agent_date",
656 "idx_agent_identity_links_account",
657 "idx_identity_accounts_provider",
658 "idx_identity_bundles_is_blank",
659 "idx_identity_bindings_account",
660 "idx_memory_bundles_is_blank",
661 "idx_agent_instances_definition",
662 "idx_agent_instances_name_recent",
663 "idx_agents_is_template",
664 "idx_agents_parent_template_id",
665 "idx_agents_is_seeded",
666 "idx_drone_definitions_updated",
667 "idx_drone_runs_status",
668 ] {
669 assert!(index_exists(&conn, idx), "{idx} should exist");
670 }
671
672 let id_blank: i64 = conn
674 .query_row(
675 "SELECT count(*) FROM db_identity_bundles WHERE id='blank' AND is_blank=1",
676 [],
677 |row| row.get(0),
678 )
679 .unwrap();
680 assert_eq!(id_blank, 1, "blank Identity singleton should be seeded");
681 let mem_blank: i64 = conn
682 .query_row(
683 "SELECT count(*) FROM db_memory_bundles WHERE id='blank' AND is_blank=1",
684 [],
685 |row| row.get(0),
686 )
687 .unwrap();
688 assert_eq!(mem_blank, 1, "blank Memory singleton should be seeded");
689 }
690
691 #[test]
692 fn test_object_schema_idempotent() {
693 let conn = Connection::open_in_memory().unwrap();
694 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
695 run_object_schema(&conn).unwrap();
696 run_object_schema(&conn).unwrap(); let id_count: i64 = conn
700 .query_row("SELECT count(*) FROM db_identity_bundles", [], |r| r.get(0))
701 .unwrap();
702 assert_eq!(id_count, 1);
703 }
704
705 #[test]
706 fn test_object_schema_omits_dead_tables() {
707 let conn = Connection::open_in_memory().unwrap();
708 run_object_schema(&conn).unwrap();
709 for dead in DEAD_TABLE_DROPS {
710 assert!(!table_exists(&conn, dead), "{dead} must not be created");
711 }
712 for (legacy, _) in LEGACY_TABLE_RENAMES {
714 assert!(
715 !table_exists(&conn, legacy),
716 "legacy {legacy} must not be created by the flat schema"
717 );
718 }
719 }
720
721 #[test]
722 fn test_adopt_legacy_renames_forge_tables() {
723 let conn = Connection::open_in_memory().unwrap();
726 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
727 conn.execute_batch(
728 "CREATE TABLE db_forge_agents (
729 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
730 icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
731 description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
732 shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
733 auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
734 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
735 agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
736 agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
737 accounts TEXT NOT NULL DEFAULT '',
738 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
739 created_at INTEGER NOT NULL DEFAULT 0
740 );
741 CREATE UNIQUE INDEX idx_forge_agents_slug ON db_forge_agents(slug);
742 INSERT INTO db_forge_agents (id, slug, name, provider)
743 VALUES ('a1', 'coder', 'Coder', 'claude');
744
745 CREATE TABLE db_workflow_definitions (id TEXT PRIMARY KEY);",
746 )
747 .unwrap();
748
749 run_object_schema(&conn).unwrap();
750
751 assert!(table_exists(&conn, "db_agent_definitions"));
753 assert!(!table_exists(&conn, "db_forge_agents"));
754 let name: String = conn
755 .query_row(
756 "SELECT name FROM db_agent_definitions WHERE id='a1'",
757 [],
758 |r| r.get(0),
759 )
760 .unwrap();
761 assert_eq!(name, "Coder");
762 assert!(!index_exists(&conn, "idx_forge_agents_slug"));
764 assert!(index_exists(&conn, "idx_agent_definitions_slug"));
765 assert!(!table_exists(&conn, "db_workflow_definitions"));
767 }
768
769 #[test]
770 fn test_adopt_legacy_is_noop_on_fresh_db() {
771 let conn = Connection::open_in_memory().unwrap();
772 run_object_schema(&conn).unwrap();
773 run_object_schema(&conn).unwrap();
776 assert!(table_exists(&conn, "db_agent_definitions"));
777 assert!(!table_exists(&conn, "db_forge_agents"));
778 }
779
780 #[test]
781 fn test_adopt_legacy_both_tables_present_is_non_destructive() {
782 let conn = Connection::open_in_memory().unwrap();
787 run_object_schema(&conn).unwrap(); conn.execute_batch(
789 "CREATE TABLE db_forge_agents (id TEXT PRIMARY KEY, name TEXT NOT NULL);
790 INSERT INTO db_forge_agents (id, name) VALUES ('downgrade-era', 'Recover Me');",
791 )
792 .unwrap();
793
794 run_object_schema(&conn).unwrap();
795
796 assert!(table_exists(&conn, "db_forge_agents"));
798 let name: String = conn
799 .query_row(
800 "SELECT name FROM db_forge_agents WHERE id='downgrade-era'",
801 [],
802 |r| r.get(0),
803 )
804 .unwrap();
805 assert_eq!(name, "Recover Me");
806 assert!(table_exists(&conn, "db_agent_definitions"));
808 }
809
810 #[test]
811 fn test_adopt_legacy_fk_cascade_survives_rename() {
812 let conn = Connection::open_in_memory().unwrap();
814 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
815 conn.execute_batch(
816 "CREATE TABLE db_forge_agents (
817 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
818 icon TEXT NOT NULL DEFAULT '✦', provider TEXT NOT NULL,
819 description TEXT NOT NULL DEFAULT '', working_directory TEXT NOT NULL DEFAULT '',
820 shell TEXT NOT NULL DEFAULT '', provider_flags TEXT NOT NULL DEFAULT '',
821 auto_start INTEGER NOT NULL DEFAULT 0, restart_on_crash INTEGER NOT NULL DEFAULT 0,
822 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
823 agent_type TEXT NOT NULL DEFAULT 'standalone', environment TEXT NOT NULL DEFAULT '',
824 agent_bus_id TEXT NOT NULL DEFAULT '', is_seeded INTEGER NOT NULL DEFAULT 0,
825 accounts TEXT NOT NULL DEFAULT '',
826 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
827 created_at INTEGER NOT NULL DEFAULT 0
828 );
829 CREATE TABLE db_forge_content (
830 agent_id TEXT NOT NULL, content_type TEXT NOT NULL,
831 content TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0,
832 PRIMARY KEY (agent_id, content_type),
833 FOREIGN KEY (agent_id) REFERENCES db_forge_agents(id) ON DELETE CASCADE
834 );
835 INSERT INTO db_forge_agents (id, name, provider) VALUES ('a1', 'Coder', 'claude');
836 INSERT INTO db_forge_content (agent_id, content_type, content)
837 VALUES ('a1', 'soul', 'hello');",
838 )
839 .unwrap();
840
841 run_object_schema(&conn).unwrap();
842
843 conn.execute("DELETE FROM db_agent_definitions WHERE id='a1'", [])
844 .unwrap();
845 let remaining: i64 = conn
846 .query_row(
847 "SELECT count(*) FROM db_agent_content WHERE agent_id='a1'",
848 [],
849 |r| r.get(0),
850 )
851 .unwrap();
852 assert_eq!(
853 remaining, 0,
854 "FK cascade must survive the forge→agent table rename"
855 );
856 }
857
858 #[test]
859 fn test_user_hidden_column_present_on_fresh_db() {
860 let conn = Connection::open_in_memory().unwrap();
865 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
866 run_object_schema(&conn).unwrap();
867
868 conn.execute_batch(
871 "INSERT INTO db_agent_definitions (id, name, provider)
872 VALUES ('a-fresh', 'Fresh', 'claude');",
873 )
874 .unwrap();
875 let hidden: i64 = conn
876 .query_row(
877 "SELECT user_hidden FROM db_agent_definitions WHERE id='a-fresh'",
878 [],
879 |r| r.get(0),
880 )
881 .unwrap();
882 assert_eq!(hidden, 0);
883 }
884
885 #[test]
886 fn test_user_hidden_column_added_to_existing_db_via_alter() {
887 let conn = Connection::open_in_memory().unwrap();
892 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
893 conn.execute_batch(
894 "CREATE TABLE db_agent_definitions (
895 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '',
896 name TEXT NOT NULL, icon TEXT NOT NULL DEFAULT '✦',
897 provider TEXT NOT NULL,
898 description TEXT NOT NULL DEFAULT '',
899 working_directory TEXT NOT NULL DEFAULT '',
900 shell TEXT NOT NULL DEFAULT '',
901 provider_flags TEXT NOT NULL DEFAULT '',
902 auto_start INTEGER NOT NULL DEFAULT 0,
903 restart_on_crash INTEGER NOT NULL DEFAULT 0,
904 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
905 agent_type TEXT NOT NULL DEFAULT 'standalone',
906 environment TEXT NOT NULL DEFAULT '',
907 agent_bus_id TEXT NOT NULL DEFAULT '',
908 is_seeded INTEGER NOT NULL DEFAULT 0,
909 accounts TEXT NOT NULL DEFAULT '',
910 parent_id TEXT NOT NULL DEFAULT '',
911 branch_label TEXT NOT NULL DEFAULT '',
912 created_at INTEGER NOT NULL DEFAULT 0
913 );
914 INSERT INTO db_agent_definitions (id, name, provider, is_seeded)
915 VALUES ('pre-existing', 'Old Template', 'claude', 1);",
916 )
917 .unwrap();
918
919 run_object_schema(&conn).unwrap();
920 run_object_schema(&conn).unwrap();
923
924 let hidden: i64 = conn
925 .query_row(
926 "SELECT user_hidden FROM db_agent_definitions WHERE id='pre-existing'",
927 [],
928 |r| r.get(0),
929 )
930 .unwrap();
931 assert_eq!(
932 hidden, 0,
933 "ALTER must default existing rows to 0 (visible), never to 1",
934 );
935 }
936
937 #[test]
938 fn stamp_version_writes_pragma() {
939 let conn = Connection::open_in_memory().unwrap();
940 stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
941 let v: i64 = conn
942 .query_row("PRAGMA user_version", [], |r| r.get(0))
943 .unwrap();
944 assert_eq!(v, OBJECT_SCHEMA_VERSION);
945 }
946
947 #[test]
948 fn check_schema_compat_refuses_newer_db_without_writing() {
949 let conn = Connection::open_in_memory().unwrap();
958 conn.execute_batch("PRAGMA user_version = 99;").unwrap();
959 let err = check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")
960 .expect_err("expected refusal");
961 match err {
962 StoreError::SchemaTooNew { db, found, expected } => {
963 assert_eq!(db, "objects.db");
964 assert_eq!(found, 99);
965 assert_eq!(expected, OBJECT_SCHEMA_VERSION);
966 }
967 other => panic!("expected SchemaTooNew, got {other:?}"),
968 }
969 let v: i64 = conn
977 .query_row("PRAGMA user_version", [], |r| r.get(0))
978 .unwrap();
979 assert_eq!(v, 99, "rejected DB must not have its user_version stamp overwritten");
980 }
981
982 #[test]
983 fn check_schema_compat_accepts_equal_or_lower_without_writing() {
984 let conn = Connection::open_in_memory().unwrap();
992 conn.execute_batch(&format!("PRAGMA user_version = {};", OBJECT_SCHEMA_VERSION))
993 .unwrap();
994 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
995 let v: i64 = conn
996 .query_row("PRAGMA user_version", [], |r| r.get(0))
997 .unwrap();
998 assert_eq!(v, OBJECT_SCHEMA_VERSION);
999
1000 let conn = Connection::open_in_memory().unwrap();
1004 conn.execute_batch("PRAGMA user_version = 1;").unwrap();
1005 check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db").unwrap();
1006 let v: i64 = conn
1007 .query_row("PRAGMA user_version", [], |r| r.get(0))
1008 .unwrap();
1009 assert_eq!(v, 1, "check_schema_compat must not write to user_version");
1010
1011 stamp_version(&conn, OBJECT_SCHEMA_VERSION).unwrap();
1013 let v: i64 = conn
1014 .query_row("PRAGMA user_version", [], |r| r.get(0))
1015 .unwrap();
1016 assert_eq!(v, OBJECT_SCHEMA_VERSION);
1017 }
1018
1019 #[test]
1020 fn test_filestore_migrations_idempotent() {
1021 let conn = Connection::open_in_memory().unwrap();
1022 run_filestore_migrations(&conn).unwrap();
1023 run_filestore_migrations(&conn).unwrap();
1024 assert!(table_exists(&conn, "db_wave_file"));
1025 assert!(table_exists(&conn, "db_file_data"));
1026 }
1027
1028 #[test]
1029 fn test_saga_log_migrations_idempotent() {
1030 let conn = Connection::open_in_memory().unwrap();
1031 run_saga_log_migrations(&conn).unwrap();
1032 run_saga_log_migrations(&conn).unwrap();
1033 assert!(table_exists(&conn, "saga"));
1034 assert!(table_exists(&conn, "saga_step"));
1035 }
1036}