1use std::path::Path;
43
44use rusqlite::{params, Connection};
45use tracing::{info, warn};
46
47use super::error::StoreError;
48
49pub const CONSOLIDATE_MARKER: &str = "migration_agents_consolidate_v1.flag";
52
53#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
55pub struct ConsolidateStats {
56 pub templates_inserted: usize,
57 pub user_defs_inserted: usize,
58 pub instances_as_clone_inserted: usize,
59 pub instances_folded_into_def: usize,
60 pub instances_skipped_continuation: usize,
61 pub instances_skipped_no_definition: usize,
62 pub instances_collision_warned: usize,
63 pub already_done: bool,
64}
65
66pub fn consolidate_looks_incomplete(conn: &Connection) -> Result<bool, StoreError> {
93 let defs = table_row_count_if_exists(conn, "db_agent_definitions")?;
94 let insts = table_row_count_if_exists(conn, "db_agent_instances")?;
95 let agents: i64 = conn.query_row("SELECT COUNT(*) FROM db_agents", [], |r| r.get(0))?;
96 Ok((defs > 0 || insts > 0) && agents == 0)
97}
98
99fn table_row_count_if_exists(conn: &Connection, table: &str) -> Result<i64, StoreError> {
103 let exists: bool = conn.query_row(
104 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
105 [table],
106 |row| row.get(0),
107 )?;
108 if !exists {
109 return Ok(0);
110 }
111 Ok(conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?)
112}
113
114pub fn run_consolidate_migration(
124 conn: &mut Connection,
125 data_dir: Option<&Path>,
126) -> Result<ConsolidateStats, StoreError> {
127 if let Some(dir) = data_dir {
131 let marker = dir.join(CONSOLIDATE_MARKER);
132 if marker.exists() {
133 if !consolidate_looks_incomplete(conn)? {
134 return Ok(ConsolidateStats {
135 already_done: true,
136 ..Default::default()
137 });
138 }
139 warn!(
140 marker = %marker.display(),
141 "agents_consolidate: marker present but db_agents is empty while source tables have rows — \
142 treating as stale and re-running instead of trusting it",
143 );
144 }
145 }
146
147 let defs_exist: bool = conn.query_row(
155 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_agent_definitions')",
156 [],
157 |row| row.get(0),
158 )?;
159 if !defs_exist {
160 return Ok(ConsolidateStats::default());
161 }
162
163 let tx = conn.transaction()?;
168
169 let mut stats = ConsolidateStats::default();
170
171 {
173 let mut def_stmt = tx.prepare(
174 "SELECT id, name, icon, provider, description,
175 working_directory, shell, provider_flags, auto_start,
176 restart_on_crash, idle_timeout_minutes, created_at,
177 agent_type, environment, agent_bus_id, is_seeded,
178 accounts, parent_id, branch_label, updated_at, slug
179 FROM db_agent_definitions",
180 )?;
181 let rows = def_stmt.query_map([], |row| {
182 Ok(DefRow {
183 id: row.get(0)?,
184 name: row.get(1)?,
185 icon: row.get(2)?,
186 provider: row.get(3)?,
187 description: row.get(4)?,
188 working_directory: row.get(5)?,
189 shell: row.get(6)?,
190 provider_flags: row.get(7)?,
191 auto_start: row.get(8)?,
192 restart_on_crash: row.get(9)?,
193 idle_timeout_minutes: row.get(10)?,
194 created_at: row.get(11)?,
195 agent_type: row.get(12)?,
196 environment: row.get(13)?,
197 agent_bus_id: row.get(14)?,
198 is_seeded: row.get(15)?,
199 accounts: row.get(16)?,
200 parent_id: row.get(17)?,
201 branch_label: row.get(18)?,
202 updated_at: row.get(19)?,
203 slug: row.get(20)?,
204 })
205 })?;
206 let mut defs: Vec<DefRow> = Vec::new();
207 for r in rows {
208 defs.push(r?);
209 }
210 drop(def_stmt);
211 for def in &defs {
212 let is_template = if def.is_seeded == 1 { 1_i64 } else { 0_i64 };
213 let parent_template_id = if def.is_seeded == 1 {
214 String::new()
215 } else {
216 def.parent_id.clone()
217 };
218 tx.execute(
223 "INSERT OR REPLACE INTO db_agents (
224 id, name, icon, description,
225 is_template, parent_template_id,
226 provider, provider_flags, shell, environment,
227 agent_type, agent_bus_id, accounts,
228 auto_start, restart_on_crash, idle_timeout_minutes,
229 slug, branch_label,
230 identity_id, memory_id, working_directory, github_context,
231 instance_name,
232 created_at, updated_at, is_seeded, user_hidden
233 ) VALUES (
234 ?1, ?2, ?3, ?4,
235 ?5, ?6,
236 ?7, ?8, ?9, ?10,
237 ?11, ?12, ?13,
238 ?14, ?15, ?16,
239 ?17, ?18,
240 '', '', ?19, '',
241 '',
242 ?20, ?21, ?22, 0
243 )",
244 params![
245 def.id,
246 def.name,
247 def.icon,
248 def.description,
249 is_template,
250 parent_template_id,
251 def.provider,
252 def.provider_flags,
253 def.shell,
254 def.environment,
255 def.agent_type,
256 def.agent_bus_id,
257 def.accounts,
258 def.auto_start,
259 def.restart_on_crash,
260 def.idle_timeout_minutes,
261 def.slug,
262 def.branch_label,
263 def.working_directory,
264 def.created_at,
265 def.updated_at,
266 def.is_seeded,
267 ],
268 )?;
269 if def.is_seeded == 1 {
270 stats.templates_inserted += 1;
271 } else {
272 stats.user_defs_inserted += 1;
273 }
274 }
275 }
276
277 let insts_exist: bool = tx.query_row(
288 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_agent_instances')",
289 [],
290 |row| row.get(0),
291 )?;
292 let inst_rows: Vec<InstanceRow> = if !insts_exist {
293 Vec::new()
294 } else {
295 let mut stmt = tx.prepare(
296 "SELECT i.id, i.definition_id, i.parent_instance_id,
297 i.instance_name, i.identity_id, i.memory_id,
298 i.working_directory, i.github_context,
299 i.created_at, i.display_hidden,
300 d.is_seeded, d.name, d.icon, d.description,
301 d.provider, d.provider_flags, d.shell, d.environment,
302 d.agent_type, d.agent_bus_id, d.accounts,
303 d.auto_start, d.restart_on_crash, d.idle_timeout_minutes,
304 d.slug, d.branch_label
305 FROM db_agent_instances i
306 LEFT JOIN db_agent_definitions d ON d.id = i.definition_id
307 ORDER BY i.created_at DESC",
308 )?;
309 let iter = stmt.query_map([], |row| {
310 Ok(InstanceRow {
311 id: row.get(0)?,
312 definition_id: row.get(1)?,
313 parent_instance_id: row.get(2)?,
314 instance_name: row.get(3)?,
315 identity_id: row.get(4)?,
316 memory_id: row.get(5)?,
317 working_directory: row.get(6)?,
318 github_context: row.get(7)?,
319 created_at: row.get(8)?,
320 display_hidden: row.get::<_, i64>(9)? != 0,
321 def_is_seeded: row.get::<_, Option<i64>>(10)?.unwrap_or(0),
322 def_name: row.get::<_, Option<String>>(11)?.unwrap_or_default(),
323 def_icon: row.get::<_, Option<String>>(12)?.unwrap_or_default(),
324 def_description: row.get::<_, Option<String>>(13)?.unwrap_or_default(),
325 def_provider: row.get::<_, Option<String>>(14)?.unwrap_or_default(),
326 def_provider_flags: row.get::<_, Option<String>>(15)?.unwrap_or_default(),
327 def_shell: row.get::<_, Option<String>>(16)?.unwrap_or_default(),
328 def_environment: row.get::<_, Option<String>>(17)?.unwrap_or_default(),
329 def_agent_type: row
330 .get::<_, Option<String>>(18)?
331 .unwrap_or_else(|| "standalone".to_string()),
332 def_agent_bus_id: row.get::<_, Option<String>>(19)?.unwrap_or_default(),
333 def_accounts: row.get::<_, Option<String>>(20)?.unwrap_or_default(),
334 def_auto_start: row.get::<_, Option<i64>>(21)?.unwrap_or(0),
335 def_restart_on_crash: row.get::<_, Option<i64>>(22)?.unwrap_or(0),
336 def_idle_timeout_minutes: row.get::<_, Option<i64>>(23)?.unwrap_or(0),
337 def_slug: row.get::<_, Option<String>>(24)?.unwrap_or_default(),
338 def_branch_label: row.get::<_, Option<String>>(25)?.unwrap_or_default(),
339 def_present: row.get::<_, Option<i64>>(10)?.is_some(),
340 })
341 })?;
342 let mut out = Vec::new();
343 for r in iter {
344 out.push(r?);
345 }
346 out
347 };
348
349 let mut folded: std::collections::HashSet<String> = std::collections::HashSet::new();
353
354 for inst in &inst_rows {
355 if !inst.parent_instance_id.is_empty() {
356 stats.instances_skipped_continuation += 1;
357 continue;
358 }
359 if !inst.def_present {
360 stats.instances_skipped_no_definition += 1;
364 warn!(
365 instance_id = %inst.id,
366 definition_id = %inst.definition_id,
367 "agents_consolidate: instance has no definition; skipping",
368 );
369 continue;
370 }
371 if inst.def_is_seeded == 1 {
372 let name = if inst.instance_name.is_empty() {
375 inst.def_name.clone()
376 } else {
377 inst.instance_name.clone()
378 };
379 tx.execute(
380 "INSERT OR REPLACE INTO db_agents (
381 id, name, icon, description,
382 is_template, parent_template_id,
383 provider, provider_flags, shell, environment,
384 agent_type, agent_bus_id, accounts,
385 auto_start, restart_on_crash, idle_timeout_minutes,
386 slug, branch_label,
387 identity_id, memory_id, working_directory, github_context,
388 instance_name,
389 created_at, updated_at, is_seeded, user_hidden
390 ) VALUES (
391 ?1, ?2, ?3, ?4,
392 0, ?5,
393 ?6, ?7, ?8, ?9,
394 ?10, ?11, ?12,
395 ?13, ?14, ?15,
396 ?16, ?17,
397 ?18, ?19, ?20, ?21,
398 ?22,
399 ?23, ?23, 0, ?24
400 )",
401 params![
402 inst.id,
403 name,
404 inst.def_icon,
405 inst.def_description,
406 inst.definition_id,
407 inst.def_provider,
408 inst.def_provider_flags,
409 inst.def_shell,
410 inst.def_environment,
411 inst.def_agent_type,
412 inst.def_agent_bus_id,
413 inst.def_accounts,
414 inst.def_auto_start,
415 inst.def_restart_on_crash,
416 inst.def_idle_timeout_minutes,
417 inst.def_slug,
418 inst.def_branch_label,
419 inst.identity_id,
420 inst.memory_id,
421 inst.working_directory,
422 inst.github_context,
423 inst.instance_name,
424 inst.created_at,
425 if inst.display_hidden { 1_i64 } else { 0_i64 },
426 ],
427 )?;
428 stats.instances_as_clone_inserted += 1;
429 } else {
430 if folded.contains(&inst.definition_id) {
435 stats.instances_collision_warned += 1;
436 warn!(
437 instance_id = %inst.id,
438 definition_id = %inst.definition_id,
439 "agents_consolidate: multiple instances on one user-cloned def; keeping most-recent bindings",
440 );
441 continue;
442 }
443 let name = if inst.instance_name.is_empty() {
444 inst.def_name.clone()
445 } else {
446 inst.instance_name.clone()
447 };
448 tx.execute(
465 "UPDATE db_agents SET
466 name = ?1,
467 identity_id = ?2,
468 memory_id = ?3,
469 working_directory = ?4,
470 github_context = ?5,
471 instance_name = ?6,
472 user_hidden = ?7,
473 updated_at = MAX(updated_at, ?8)
474 WHERE id = ?9 AND is_template = 0",
475 params![
476 name,
477 inst.identity_id,
478 inst.memory_id,
479 inst.working_directory,
480 inst.github_context,
481 inst.instance_name,
482 if inst.display_hidden { 1_i64 } else { 0_i64 },
483 inst.created_at,
484 inst.definition_id,
485 ],
486 )?;
487 folded.insert(inst.definition_id.clone());
488 stats.instances_folded_into_def += 1;
489 }
490 }
491
492 tx.commit()?;
493
494 if let Some(dir) = data_dir {
497 let marker = dir.join(CONSOLIDATE_MARKER);
498 if let Err(e) = std::fs::write(&marker, b"phase3a") {
499 warn!(
503 error = %e,
504 marker = %marker.display(),
505 "agents_consolidate: failed to write marker; next startup will redo backfill",
506 );
507 }
508 }
509
510 info!(
511 templates_inserted = stats.templates_inserted,
512 user_defs_inserted = stats.user_defs_inserted,
513 instances_as_clone_inserted = stats.instances_as_clone_inserted,
514 instances_folded_into_def = stats.instances_folded_into_def,
515 instances_skipped_continuation = stats.instances_skipped_continuation,
516 instances_skipped_no_definition = stats.instances_skipped_no_definition,
517 instances_collision_warned = stats.instances_collision_warned,
518 "agents_consolidate: backfill completed",
519 );
520 Ok(stats)
521}
522
523
524struct DefRow {
527 id: String,
528 name: String,
529 icon: String,
530 provider: String,
531 description: String,
532 working_directory: String,
533 shell: String,
534 provider_flags: String,
535 auto_start: i64,
536 restart_on_crash: i64,
537 idle_timeout_minutes: i64,
538 created_at: i64,
539 agent_type: String,
540 environment: String,
541 agent_bus_id: String,
542 is_seeded: i64,
543 accounts: String,
544 parent_id: String,
545 branch_label: String,
546 updated_at: i64,
547 slug: String,
548}
549
550struct InstanceRow {
553 id: String,
554 definition_id: String,
555 parent_instance_id: String,
556 instance_name: String,
557 identity_id: String,
558 memory_id: String,
559 working_directory: String,
560 github_context: String,
561 created_at: i64,
562 display_hidden: bool,
563 def_present: bool,
564 def_is_seeded: i64,
565 def_name: String,
566 def_icon: String,
567 def_description: String,
568 def_provider: String,
569 def_provider_flags: String,
570 def_shell: String,
571 def_environment: String,
572 def_agent_type: String,
573 def_agent_bus_id: String,
574 def_accounts: String,
575 def_auto_start: i64,
576 def_restart_on_crash: i64,
577 def_idle_timeout_minutes: i64,
578 def_slug: String,
579 def_branch_label: String,
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::backend::storage::migrations::run_object_schema;
586
587 fn fresh_conn() -> Connection {
597 let conn = Connection::open_in_memory().unwrap();
598 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
599 run_object_schema(&conn).unwrap();
600 conn.execute_batch(
601 "CREATE TABLE db_agent_definitions (
602 id TEXT PRIMARY KEY,
603 slug TEXT NOT NULL DEFAULT '',
604 name TEXT NOT NULL,
605 icon TEXT NOT NULL DEFAULT '✦',
606 provider TEXT NOT NULL,
607 description TEXT NOT NULL DEFAULT '',
608 working_directory TEXT NOT NULL DEFAULT '',
609 shell TEXT NOT NULL DEFAULT '',
610 provider_flags TEXT NOT NULL DEFAULT '',
611 auto_start INTEGER NOT NULL DEFAULT 0,
612 restart_on_crash INTEGER NOT NULL DEFAULT 0,
613 idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
614 agent_type TEXT NOT NULL DEFAULT 'standalone',
615 environment TEXT NOT NULL DEFAULT '',
616 agent_bus_id TEXT NOT NULL DEFAULT '',
617 is_seeded INTEGER NOT NULL DEFAULT 0,
618 accounts TEXT NOT NULL DEFAULT '',
619 parent_id TEXT NOT NULL DEFAULT '',
620 branch_label TEXT NOT NULL DEFAULT '',
621 created_at INTEGER NOT NULL DEFAULT 0,
622 updated_at INTEGER NOT NULL DEFAULT 0,
623 user_hidden INTEGER NOT NULL DEFAULT 0
624 );
625 CREATE TABLE db_agent_instances (
626 id TEXT PRIMARY KEY,
627 definition_id TEXT NOT NULL,
628 parent_instance_id TEXT NOT NULL DEFAULT '',
629 block_id TEXT NOT NULL DEFAULT '',
630 session_id TEXT NOT NULL DEFAULT '',
631 status TEXT NOT NULL DEFAULT 'running',
632 github_context TEXT NOT NULL DEFAULT '',
633 identity_id TEXT NOT NULL DEFAULT '',
634 memory_id TEXT NOT NULL DEFAULT '',
635 instance_name TEXT NOT NULL DEFAULT '',
636 working_directory TEXT NOT NULL DEFAULT '',
637 display_hidden INTEGER NOT NULL DEFAULT 0,
638 started_at INTEGER NOT NULL DEFAULT 0,
639 ended_at INTEGER NOT NULL DEFAULT 0,
640 created_at INTEGER NOT NULL DEFAULT 0,
641 FOREIGN KEY (definition_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
642 );",
643 )
644 .unwrap();
645 conn
646 }
647
648 fn insert_def(
649 conn: &Connection,
650 id: &str,
651 name: &str,
652 is_seeded: i64,
653 parent_id: &str,
654 ) {
655 conn.execute(
656 "INSERT INTO db_agent_definitions
657 (id, slug, name, icon, provider, description, working_directory, shell,
658 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
659 created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
660 parent_id, branch_label, updated_at)
661 VALUES (?1, ?2, ?3, '✦', 'claude', 'desc', '', 'bash',
662 '', 0, 0, 0,
663 ?4, 'standalone', '', '', ?5, '',
664 ?6, '', ?4)",
665 params![id, id, name, 1000_i64, is_seeded, parent_id],
666 )
667 .unwrap();
668 }
669
670 fn insert_instance(
671 conn: &Connection,
672 id: &str,
673 definition_id: &str,
674 instance_name: &str,
675 identity_id: &str,
676 memory_id: &str,
677 working_directory: &str,
678 created_at: i64,
679 display_hidden: bool,
680 ) {
681 conn.execute(
682 "INSERT INTO db_agent_instances
683 (id, definition_id, parent_instance_id, block_id, session_id, status,
684 github_context, started_at, ended_at, created_at, identity_id, memory_id,
685 instance_name, working_directory, display_hidden)
686 VALUES (?1, ?2, '', '', '', 'running', '', ?3, 0, ?3, ?4, ?5, ?6, ?7, ?8)",
687 params![
688 id,
689 definition_id,
690 created_at,
691 identity_id,
692 memory_id,
693 instance_name,
694 working_directory,
695 if display_hidden { 1_i64 } else { 0_i64 },
696 ],
697 )
698 .unwrap();
699 }
700
701 fn count_agents(conn: &Connection, where_clause: &str) -> i64 {
702 let sql = format!("SELECT COUNT(*) FROM db_agents WHERE {where_clause}");
703 conn.query_row(&sql, [], |row| row.get(0)).unwrap()
704 }
705
706 #[test]
707 fn round_trip_template_user_clone_and_instance_lifecycle() {
708 let mut conn = fresh_conn();
709
710 insert_def(&conn, "tpl-1", "Coder", 1, "");
716 insert_def(&conn, "tpl-2", "Reviewer", 1, "");
717 insert_def(&conn, "def-u1", "Maks-the-Coder", 0, "tpl-1");
718 insert_instance(&conn, "inst-A", "tpl-1", "Maks", "id-1", "mem-1", "/wd/a", 100, false);
719 insert_instance(&conn, "inst-B", "tpl-2", "", "", "", "/wd/b", 200, false);
720 insert_instance(&conn, "inst-C", "def-u1", "Custom", "id-2", "mem-2", "/wd/c", 300, false);
721
722 let stats = run_consolidate_migration(&mut conn, None).unwrap();
723 assert_eq!(stats.templates_inserted, 2);
724 assert_eq!(stats.user_defs_inserted, 1);
725 assert_eq!(stats.instances_as_clone_inserted, 2);
726 assert_eq!(stats.instances_folded_into_def, 1);
727 assert_eq!(stats.instances_skipped_continuation, 0);
728
729 assert_eq!(count_agents(&conn, "is_template = 1"), 2);
731 for tpl in &["tpl-1", "tpl-2"] {
732 let parent: String = conn
733 .query_row(
734 "SELECT parent_template_id FROM db_agents WHERE id = ?1",
735 params![tpl],
736 |r| r.get(0),
737 )
738 .unwrap();
739 assert_eq!(parent, "");
740 }
741
742 assert_eq!(count_agents(&conn, "is_template = 0"), 3);
744
745 let (parent, name, identity): (String, String, String) = conn
747 .query_row(
748 "SELECT parent_template_id, name, identity_id FROM db_agents WHERE id = 'inst-A'",
749 [],
750 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
751 )
752 .unwrap();
753 assert_eq!(parent, "tpl-1");
754 assert_eq!(name, "Maks");
755 assert_eq!(identity, "id-1");
756
757 let name: String = conn
759 .query_row(
760 "SELECT name FROM db_agents WHERE id = 'inst-B'",
761 [],
762 |r| r.get(0),
763 )
764 .unwrap();
765 assert_eq!(name, "Reviewer");
766
767 let (name, identity, memory): (String, String, String) = conn
769 .query_row(
770 "SELECT name, identity_id, memory_id FROM db_agents WHERE id = 'def-u1'",
771 [],
772 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
773 )
774 .unwrap();
775 assert_eq!(name, "Custom"); assert_eq!(identity, "id-2");
777 assert_eq!(memory, "mem-2");
778 }
779
780 #[test]
781 fn multiple_instances_on_one_user_clone_keeps_most_recent_bindings() {
782 let mut conn = fresh_conn();
783 insert_def(&conn, "tpl-1", "Coder", 1, "");
784 insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
785 insert_instance(&conn, "inst-old", "def-u1", "Old", "id-OLD", "mem-OLD", "/wd/old", 100, false);
787 insert_instance(&conn, "inst-new", "def-u1", "New", "id-RECENT", "mem-RECENT", "/wd/new", 999, false);
788
789 let stats = run_consolidate_migration(&mut conn, None).unwrap();
790 assert_eq!(stats.instances_collision_warned, 1);
791
792 let identity: String = conn
793 .query_row(
794 "SELECT identity_id FROM db_agents WHERE id = 'def-u1'",
795 [],
796 |r| r.get(0),
797 )
798 .unwrap();
799 assert_eq!(identity, "id-RECENT", "most-recent instance's bindings must win");
800 }
801
802 #[test]
803 fn fold_into_user_clone_stamps_updated_at_from_instance_created_at() {
804 let mut conn = fresh_conn();
813 insert_def(&conn, "tpl-1", "Coder", 1, "");
816 insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
817 insert_instance(
819 &conn, "inst-1", "def-u1", "Maks", "id-1", "mem-1", "/wd/m", 5000, false,
820 );
821
822 run_consolidate_migration(&mut conn, None).unwrap();
823
824 let updated_at: i64 = conn
825 .query_row(
826 "SELECT updated_at FROM db_agents WHERE id = 'def-u1'",
827 [],
828 |r| r.get(0),
829 )
830 .unwrap();
831 assert_eq!(
832 updated_at, 5000,
833 "folded user-clone row's updated_at must reflect the instance's launch time, \
834 not the def's edit time, so ORDER BY updated_at picks the right row"
835 );
836 }
837
838 #[test]
839 fn fold_into_user_clone_keeps_higher_updated_at() {
840 let mut conn = fresh_conn();
844 insert_def(&conn, "tpl-1", "Coder", 1, "");
845 conn.execute(
847 "UPDATE db_agent_definitions SET updated_at = 8000 WHERE id = 'tpl-1'",
848 [],
849 )
850 .unwrap();
851 insert_def(&conn, "def-u1", "User Coder", 0, "tpl-1");
852 conn.execute(
853 "UPDATE db_agent_definitions SET updated_at = 8000 WHERE id = 'def-u1'",
854 [],
855 )
856 .unwrap();
857 insert_instance(
859 &conn, "inst-1", "def-u1", "Maks", "id-1", "mem-1", "/wd/m", 3000, false,
860 );
861
862 run_consolidate_migration(&mut conn, None).unwrap();
863
864 let updated_at: i64 = conn
865 .query_row(
866 "SELECT updated_at FROM db_agents WHERE id = 'def-u1'",
867 [],
868 |r| r.get(0),
869 )
870 .unwrap();
871 assert_eq!(
872 updated_at, 8000,
873 "later def edit beats older instance launch — MAX(updated_at, inst.created_at)"
874 );
875 }
876
877 #[test]
878 fn marker_short_circuits_second_run() {
879 let mut conn = fresh_conn();
880 insert_def(&conn, "tpl-1", "Coder", 1, "");
881 let tmp = tempfile::tempdir().unwrap();
882
883 let stats1 = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
884 assert_eq!(stats1.templates_inserted, 1);
885 assert!(!stats1.already_done);
886 assert!(tmp.path().join(CONSOLIDATE_MARKER).exists());
887
888 insert_def(&conn, "tpl-2", "Reviewer", 1, "");
891 let stats2 = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
892 assert!(stats2.already_done);
893 assert_eq!(stats2.templates_inserted, 0);
894 assert_eq!(count_agents(&conn, "is_template = 1"), 1);
897 }
898
899 #[test]
900 fn skips_continuation_rows() {
901 let mut conn = fresh_conn();
902 insert_def(&conn, "tpl-1", "Coder", 1, "");
903 insert_instance(&conn, "inst-A", "tpl-1", "Original", "", "", "/wd/a", 100, false);
904 conn.execute(
906 "INSERT INTO db_agent_instances
907 (id, definition_id, parent_instance_id, block_id, session_id, status,
908 github_context, started_at, ended_at, created_at, identity_id, memory_id,
909 instance_name, working_directory, display_hidden)
910 VALUES ('inst-cont', 'tpl-1', 'inst-A', '', '', 'running', '', 200, 0, 200,
911 '', '', 'Original', '/wd/a', 0)",
912 [],
913 )
914 .unwrap();
915
916 let stats = run_consolidate_migration(&mut conn, None).unwrap();
917 assert_eq!(stats.instances_skipped_continuation, 1);
918 assert_eq!(count_agents(&conn, "1 = 1"), 2);
920 assert_eq!(count_agents(&conn, "id = 'inst-cont'"), 0);
921 }
922
923 #[test]
924 fn empty_database_is_clean_noop() {
925 let mut conn = fresh_conn();
926 let stats = run_consolidate_migration(&mut conn, None).unwrap();
927 assert_eq!(stats, ConsolidateStats::default());
928 assert_eq!(count_agents(&conn, "1 = 1"), 0);
929 }
930
931 #[test]
932 fn preserves_hidden_flag_into_user_hidden() {
933 let mut conn = fresh_conn();
934 insert_def(&conn, "tpl-1", "Coder", 1, "");
935 insert_instance(&conn, "inst-H", "tpl-1", "Hidden", "", "", "/wd/h", 100, true);
936 run_consolidate_migration(&mut conn, None).unwrap();
937 let hidden: i64 = conn
938 .query_row(
939 "SELECT user_hidden FROM db_agents WHERE id = 'inst-H'",
940 [],
941 |r| r.get(0),
942 )
943 .unwrap();
944 assert_eq!(hidden, 1);
945 }
946
947 #[test]
948 fn definition_working_directory_survives_the_pass_1_2_backfill() {
949 let mut conn = fresh_conn();
959 conn.execute(
960 "INSERT INTO db_agent_definitions
961 (id, slug, name, icon, provider, description, working_directory, shell,
962 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
963 created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
964 parent_id, branch_label, updated_at)
965 VALUES ('tpl-wd', 'tpl-wd', 'Coder', '✦', 'claude', 'desc',
966 '/home/user/my-project', 'bash',
967 '', 0, 0, 0,
968 1000, 'standalone', '', '', 1, '',
969 '', '', 1000)",
970 [],
971 )
972 .unwrap();
973
974 run_consolidate_migration(&mut conn, None).unwrap();
975
976 let working_directory: String = conn
977 .query_row(
978 "SELECT working_directory FROM db_agents WHERE id = 'tpl-wd'",
979 [],
980 |r| r.get(0),
981 )
982 .unwrap();
983 assert_eq!(
984 working_directory, "/home/user/my-project",
985 "a definition's working_directory must survive Pass 1/2 consolidation \
986 even when it was never instantiated (no instance row to fold bindings from)"
987 );
988 }
989
990 #[test]
993 fn consolidate_looks_incomplete_is_false_on_a_fresh_db() {
994 let conn = fresh_conn();
995 assert!(!consolidate_looks_incomplete(&conn).unwrap());
997 }
998
999 #[test]
1005 fn consolidate_looks_incomplete_tolerates_both_tables_being_physically_absent() {
1006 let conn = Connection::open_in_memory().unwrap();
1007 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
1008 run_object_schema(&conn).unwrap();
1009 assert!(!consolidate_looks_incomplete(&conn).unwrap());
1010 }
1011
1012 #[test]
1020 fn run_consolidate_migration_tolerates_both_tables_being_physically_absent() {
1021 let mut conn = Connection::open_in_memory().unwrap();
1022 conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
1023 run_object_schema(&conn).unwrap();
1024 let stats = run_consolidate_migration(&mut conn, None).unwrap();
1025 assert_eq!(stats, ConsolidateStats::default());
1026 }
1027
1028 #[test]
1029 fn consolidate_looks_incomplete_is_false_once_backfilled() {
1030 let mut conn = fresh_conn();
1031 insert_def(&conn, "tpl-1", "Coder", 1, "");
1032 run_consolidate_migration(&mut conn, None).unwrap();
1033 assert!(!consolidate_looks_incomplete(&conn).unwrap());
1034 }
1035
1036 #[test]
1037 fn consolidate_looks_incomplete_is_true_when_source_has_rows_but_db_agents_is_empty() {
1038 let conn = fresh_conn();
1039 insert_def(&conn, "tpl-1", "Coder", 1, "");
1042 assert!(consolidate_looks_incomplete(&conn).unwrap());
1043 }
1044
1045 #[test]
1046 fn stale_marker_does_not_block_a_real_backfill() {
1047 let tmp = tempfile::TempDir::new().unwrap();
1053 std::fs::write(tmp.path().join(CONSOLIDATE_MARKER), b"phase3a").unwrap();
1054
1055 let mut conn = fresh_conn();
1056 insert_def(&conn, "tpl-1", "Coder", 1, "");
1057 assert_eq!(count_agents(&conn, "1=1"), 0, "db_agents must start empty for this test to be meaningful");
1058
1059 let stats = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
1060 assert!(!stats.already_done, "a stale marker must not short-circuit as already_done");
1061 assert_eq!(stats.templates_inserted, 1);
1062 assert_eq!(count_agents(&conn, "1=1"), 1, "db_agents must actually get populated, not silently skipped");
1063 }
1064
1065 #[test]
1066 fn genuinely_completed_marker_still_short_circuits() {
1067 let tmp = tempfile::TempDir::new().unwrap();
1071 std::fs::write(tmp.path().join(CONSOLIDATE_MARKER), b"phase3a").unwrap();
1072
1073 let mut conn = fresh_conn();
1074 insert_def(&conn, "tpl-1", "Coder", 1, "");
1075 run_consolidate_migration(&mut conn, None).unwrap(); assert_eq!(count_agents(&conn, "1=1"), 1);
1077
1078 let stats = run_consolidate_migration(&mut conn, Some(tmp.path())).unwrap();
1079 assert!(stats.already_done, "a marker backed by real data must still short-circuit");
1080 assert_eq!(count_agents(&conn, "1=1"), 1, "must not double-insert");
1081 }
1082}