1use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use rusqlite::{Connection, OpenFlags};
35
36use super::schema::{NamedAgentRecord, NamedAgentRecordV1, MAX_SUPPORTED_SCHEMA};
37use super::store::{Registry, RegistryError};
38
39#[derive(Debug, Default, Clone, Copy)]
41pub struct MigrateStats {
42 pub dbs_scanned: usize,
45 pub dbs_skipped: usize,
49 pub rows_seen: usize,
50 pub records_written: usize,
51 pub records_skipped_existing: usize,
52 pub records_skipped_unmappable: usize,
53 pub complete: bool,
61}
62
63const MARKER: &str = ".migrated_from_sqlite";
66
67const MIGRATION_VERSION: u32 = 2;
77
78struct SqliteSource {
83 db_path: PathBuf,
84 agents_root: PathBuf,
85}
86
87pub fn migrate_from_sqlite_once(
96 home: &Path,
97 registry: &Registry,
98) -> Result<MigrateStats, RegistryError> {
99 let marker_path = registry.root().join(MARKER);
100 if marker_migration_version(&marker_path) >= MIGRATION_VERSION {
101 return Ok(MigrateStats {
106 complete: true,
107 ..MigrateStats::default()
108 });
109 }
110
111 let mut stats = MigrateStats::default();
112 let global_agents_root = home.join("agents");
125 let (sources, enum_incomplete) = enumerate_sources(home);
126
127 let mut latest_by_id: HashMap<String, RowSnapshot> = HashMap::new();
128 let mut any_db_failed = enum_incomplete;
133
134 for src in sources {
135 stats.dbs_scanned += 1;
136 match read_named_rows(&src.db_path, &src.agents_root) {
137 Ok(rows) => {
138 for row in rows {
139 stats.rows_seen += 1;
140 let key = row.id.clone();
141 match latest_by_id.get_mut(&key) {
142 Some(existing) if existing.started_at >= row.started_at => {
143 existing.display_hidden =
147 existing.display_hidden || row.display_hidden;
148 }
149 Some(existing) => {
150 let merged_hidden =
151 existing.display_hidden || row.display_hidden;
152 *existing = row;
153 existing.display_hidden = merged_hidden;
154 }
155 None => {
156 latest_by_id.insert(key, row);
157 }
158 }
159 }
160 }
161 Err(e) => {
162 tracing::warn!(
163 db = %src.db_path.display(),
164 error = %e,
165 "registry-migrate: DB unreadable — skipping this source; registry still attaches, marker deferred to retry"
166 );
167 stats.dbs_skipped += 1;
168 any_db_failed = true;
169 }
170 }
171 }
172
173 for (id, row) in latest_by_id {
174 if registry.exists_anywhere(&id) {
178 stats.records_skipped_existing += 1;
179 continue;
180 }
181 let display_hidden = row.display_hidden;
182 let Some(rec) = row_to_record(&row, &global_agents_root) else {
183 stats.records_skipped_unmappable += 1;
184 continue;
185 };
186 if let Err(e) = registry.upsert(&rec) {
187 tracing::warn!(
188 instance_id = %id,
189 error = %e,
190 "registry-migrate: upsert failed"
191 );
192 stats.records_skipped_unmappable += 1;
193 continue;
194 }
195 if display_hidden {
199 if let Err(e) = registry.retire(&id) {
200 tracing::warn!(
201 instance_id = %id,
202 error = %e,
203 "registry-migrate: failed to retire migrated tombstone — record may surface as active"
204 );
205 }
206 }
207 stats.records_written += 1;
208 }
209
210 stats.complete = !any_db_failed;
216 if stats.complete {
217 write_marker(&marker_path, &stats)?;
218 } else {
219 tracing::info!(
220 "registry-migrate: deferring marker write; one or more DBs were unreadable and will be retried next launch"
221 );
222 }
223 Ok(stats)
224}
225
226const SOURCE_BACKFILL_MARKER: &str = ".backfilled_source_bases";
230
231#[derive(Debug, Default, Clone, Copy)]
233pub struct SourceBackfillStats {
234 pub dbs_scanned: usize,
235 pub records_updated: usize,
236 pub records_unresolved: usize,
240 pub complete: bool,
241}
242
243pub fn backfill_source_bases_once(
257 home: &Path,
258 registry: &Registry,
259) -> Result<SourceBackfillStats, RegistryError> {
260 let marker = registry.root().join(SOURCE_BACKFILL_MARKER);
261 if marker.exists() {
262 return Ok(SourceBackfillStats {
263 complete: true,
264 ..Default::default()
265 });
266 }
267
268 let mut stats = SourceBackfillStats::default();
269
270 let mut pending: HashMap<String, NamedAgentRecord> = registry
275 .list_active()?
276 .into_iter()
277 .filter(|r| r.data.source_agents_base.is_none())
278 .map(|r| (r.data.instance_id.clone(), r))
279 .collect();
280
281 if pending.is_empty() {
282 std::fs::write(&marker, b"backfilled: 0\n")?;
284 stats.complete = true;
285 return Ok(stats);
286 }
287
288 let (sources, mut incomplete) = enumerate_sources(home);
289
290 let mut winners: HashMap<String, (i64, PathBuf)> = HashMap::new();
299 for src in sources {
300 stats.dbs_scanned += 1;
301 match read_named_rows(&src.db_path, &src.agents_root) {
302 Ok(rows) => {
303 for row in rows {
304 if !pending.contains_key(&row.id) {
305 continue;
306 }
307 match winners.get(&row.id) {
308 Some((ts, _)) if *ts >= row.started_at => {}
309 _ => {
310 winners.insert(row.id.clone(), (row.started_at, row.agents_root));
311 }
312 }
313 }
314 }
315 Err(e) => {
316 tracing::warn!(
317 db = %src.db_path.display(),
318 error = %e,
319 "source-base backfill: DB unreadable — will retry next launch"
320 );
321 incomplete = true;
322 }
323 }
324 }
325
326 for (id, (_, agents_root)) in winners {
329 let Some(mut rec) = pending.remove(&id) else {
330 continue;
331 };
332 rec.data.source_agents_base = Some(agents_root.to_string_lossy().to_string());
333 rec.schema_version = rec.data.min_schema_version();
334 if let Err(e) = registry.upsert(&rec) {
335 tracing::warn!(
336 instance_id = %id,
337 error = %e,
338 "source-base backfill: upsert failed — will retry next launch"
339 );
340 pending.insert(id, rec);
341 incomplete = true;
342 } else {
343 stats.records_updated += 1;
344 }
345 }
346
347 stats.records_unresolved = pending.len();
350
351 stats.complete = !incomplete;
354 if stats.complete {
355 std::fs::write(
356 &marker,
357 format!(
358 "backfilled: {}\nunresolved: {}\n",
359 stats.records_updated, stats.records_unresolved
360 ),
361 )?;
362 }
363 Ok(stats)
364}
365
366fn enumerate_sources(home: &Path) -> (Vec<SqliteSource>, bool) {
378 let mut out = Vec::new();
379 let mut incomplete = false;
380
381 if let Some(rd) = read_dir_tracking(&home.join("channels"), &mut incomplete) {
385 for ch in rd.flatten() {
386 let ch_dir = ch.path();
387 if !ch_dir.is_dir() {
388 continue;
389 }
390 let agents_root = ch_dir.join("agents");
391 if let Some(vrd) = read_dir_tracking(&ch_dir.join("versions"), &mut incomplete) {
392 for v in vrd.flatten() {
393 let db = v.path().join("data").join("db").join("objects.db");
394 if db.is_file() {
395 out.push(SqliteSource {
396 db_path: db,
397 agents_root: agents_root.clone(),
398 });
399 }
400 }
401 }
402 }
403 }
404
405 collect_dev_sources(&home.join("dev"), &mut out, &mut incomplete);
409
410 (out, incomplete)
411}
412
413fn read_dir_tracking(path: &Path, incomplete: &mut bool) -> Option<std::fs::ReadDir> {
417 match std::fs::read_dir(path) {
418 Ok(rd) => Some(rd),
419 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
420 Err(_) => {
421 *incomplete = true;
422 None
423 }
424 }
425}
426
427fn collect_dev_sources(dev_root: &Path, out: &mut Vec<SqliteSource>, incomplete: &mut bool) {
437 let Some(branches) = read_dir_tracking(dev_root, incomplete) else {
438 return;
439 };
440 for branch in branches.flatten() {
441 let bdir = branch.path();
442 if !bdir.is_dir() {
443 continue;
444 }
445 if push_if_instance(&bdir, out) {
448 continue;
449 }
450 if let Some(subs) = read_dir_tracking(&bdir, incomplete) {
452 for sub in subs.flatten() {
453 let sdir = sub.path();
454 if sdir.is_dir() {
455 push_if_instance(&sdir, out);
456 }
457 }
458 }
459 }
460}
461
462fn push_if_instance(dir: &Path, out: &mut Vec<SqliteSource>) -> bool {
465 let db = dir.join("data").join("db").join("objects.db");
466 if db.is_file() {
467 out.push(SqliteSource {
468 db_path: db,
469 agents_root: dir.join("agents"),
470 });
471 true
472 } else {
473 false
474 }
475}
476
477fn marker_migration_version(path: &Path) -> u32 {
482 let Ok(body) = std::fs::read_to_string(path) else {
483 return 0;
484 };
485 for line in body.lines() {
486 if let Some(v) = line.strip_prefix("migration_version:") {
487 return v.trim().parse().unwrap_or(0);
488 }
489 }
490 0
491}
492
493fn write_marker(path: &Path, stats: &MigrateStats) -> std::io::Result<()> {
494 let now = chrono::Utc::now().to_rfc3339();
495 let body = format!(
496 "migration_version: {MIGRATION_VERSION}\n\
497 migrated_at: {now}\n\
498 dbs_scanned: {}\n\
499 dbs_skipped: {}\n\
500 rows_seen: {}\n\
501 records_written: {}\n\
502 records_skipped_existing: {}\n\
503 records_skipped_unmappable: {}\n",
504 stats.dbs_scanned,
505 stats.dbs_skipped,
506 stats.rows_seen,
507 stats.records_written,
508 stats.records_skipped_existing,
509 stats.records_skipped_unmappable,
510 );
511 std::fs::write(path, body)
512}
513
514struct RowSnapshot {
515 id: String,
516 instance_name: String,
517 definition_id: String,
518 identity_id: String,
519 memory_id: String,
520 working_directory: String,
521 agents_root: PathBuf,
528 started_at: i64,
529 created_at: i64,
530 display_hidden: bool,
531}
532
533fn is_missing_column_or_table(e: &rusqlite::Error) -> bool {
544 let msg = match e {
545 rusqlite::Error::SqliteFailure(_, Some(msg)) => msg.as_str(),
546 rusqlite::Error::SqlInputError { msg, .. } => msg.as_str(),
547 _ => return false,
548 };
549 msg.starts_with("no such column") || msg.starts_with("no such table")
550}
551
552fn read_named_rows(
553 db_path: &Path,
554 agents_root: &Path,
555) -> Result<Vec<RowSnapshot>, rusqlite::Error> {
556 let conn = Connection::open_with_flags(
557 db_path,
558 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
559 )?;
560 let mut stmt = match conn.prepare(
568 "SELECT id, instance_name, definition_id, identity_id, memory_id,
569 working_directory, started_at, created_at, display_hidden
570 FROM db_agent_instances
571 WHERE instance_name <> ''
572 AND parent_instance_id = ''",
573 ) {
574 Ok(s) => s,
575 Err(e) if is_missing_column_or_table(&e) => return Ok(Vec::new()),
576 Err(e) => return Err(e),
577 };
578 let iter = stmt.query_map([], |row| {
579 Ok(RowSnapshot {
580 id: row.get(0)?,
581 instance_name: row.get(1)?,
582 definition_id: row.get(2)?,
583 identity_id: row.get(3)?,
584 memory_id: row.get(4)?,
585 working_directory: row.get(5)?,
586 agents_root: agents_root.to_path_buf(),
587 started_at: row.get(6)?,
588 created_at: row.get(7)?,
589 display_hidden: row.get::<_, i64>(8)? != 0,
590 })
591 })?;
592 iter.collect()
593}
594
595fn row_to_record(row: &RowSnapshot, global_agents_root: &Path) -> Option<NamedAgentRecord> {
596 let abs = std::path::Path::new(&row.working_directory);
597 let (rel, base): (&Path, &Path) = abs
606 .strip_prefix(global_agents_root)
607 .ok()
608 .map(|r| (r, global_agents_root))
609 .or_else(|| {
610 abs.strip_prefix(row.agents_root.as_path())
611 .ok()
612 .map(|r| (r, row.agents_root.as_path()))
613 })?;
614 let rel_str = rel.to_string_lossy().to_string();
615 if rel_str.is_empty() || rel_str == "." {
616 return None;
617 }
618 let data = NamedAgentRecordV1 {
619 instance_id: row.id.clone(),
620 instance_name: row.instance_name.clone(),
621 definition_id: row.definition_id.clone(),
622 identity_id: empty_to_none(&row.identity_id),
623 memory_id: empty_to_none(&row.memory_id),
624 session_id: None,
630 working_dir: rel_str,
631 source_agents_base: Some(base.to_string_lossy().to_string()),
636 created_at_ms: row.created_at,
637 last_launched_at_ms: row.started_at,
638 created_by_version: "(legacy)".to_string(),
642 last_launched_by_version: "(legacy)".to_string(),
643 };
644 Some(NamedAgentRecord {
645 schema_version: data.min_schema_version(),
646 data,
647 })
648}
649
650fn empty_to_none(s: &str) -> Option<String> {
651 if s.is_empty() {
652 None
653 } else {
654 Some(s.to_string())
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use rusqlite::params;
662
663 fn make_db_at(version_dir: &Path, rows: &[(&str, &str, i64, &str, bool)]) {
666 let db_dir = version_dir.join("data").join("db");
667 std::fs::create_dir_all(&db_dir).unwrap();
668 let conn = Connection::open(db_dir.join("objects.db")).unwrap();
669 conn.execute_batch(
670 "CREATE TABLE db_agent_instances (
671 id TEXT PRIMARY KEY,
672 definition_id TEXT NOT NULL DEFAULT '',
673 parent_instance_id TEXT NOT NULL DEFAULT '',
674 block_id TEXT NOT NULL DEFAULT '',
675 session_id TEXT NOT NULL DEFAULT '',
676 status TEXT NOT NULL DEFAULT 'running',
677 github_context TEXT NOT NULL DEFAULT '',
678 started_at INTEGER NOT NULL DEFAULT 0,
679 ended_at INTEGER NOT NULL DEFAULT 0,
680 created_at INTEGER NOT NULL DEFAULT 0,
681 identity_id TEXT NOT NULL DEFAULT '',
682 memory_id TEXT NOT NULL DEFAULT '',
683 instance_name TEXT NOT NULL DEFAULT '',
684 working_directory TEXT NOT NULL DEFAULT '',
685 display_hidden INTEGER NOT NULL DEFAULT 0
686 );",
687 )
688 .unwrap();
689 for (id, name, started_at, working_directory, hidden) in rows {
690 conn.execute(
691 "INSERT INTO db_agent_instances
692 (id, definition_id, instance_name, working_directory, started_at, created_at, display_hidden)
693 VALUES (?1, 'claude-code', ?2, ?3, ?4, ?4, ?5)",
694 params![id, name, working_directory, started_at, if *hidden { 1_i64 } else { 0_i64 }],
695 )
696 .unwrap();
697 }
698 }
699
700 fn channel_agents(home: &Path, channel: &str) -> PathBuf {
702 home.join("channels").join(channel).join("agents")
703 }
704
705 fn make_channel_db(
707 home: &Path,
708 channel: &str,
709 version: &str,
710 rows: &[(&str, &str, i64, &str)],
711 ) {
712 let rows: Vec<_> = rows.iter().map(|(a, b, c, d)| (*a, *b, *c, *d, false)).collect();
713 let v_dir = home
714 .join("channels")
715 .join(channel)
716 .join("versions")
717 .join(version);
718 make_db_at(&v_dir, &rows);
719 }
720
721 fn make_channel_db_with_hidden(
722 home: &Path,
723 channel: &str,
724 version: &str,
725 rows: &[(&str, &str, i64, &str, bool)],
726 ) {
727 let v_dir = home
728 .join("channels")
729 .join(channel)
730 .join("versions")
731 .join(version);
732 make_db_at(&v_dir, rows);
733 }
734
735 fn fresh_home() -> (tempfile::TempDir, Registry) {
736 let home = tempfile::tempdir().unwrap();
737 let reg = Registry::open(
739 home.path()
740 .join("shared")
741 .join("agents")
742 .join("registry"),
743 )
744 .unwrap();
745 (home, reg)
746 }
747
748 #[test]
749 fn migrate_with_no_channels_writes_marker_and_no_rows() {
750 let (home, reg) = fresh_home();
751 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
752 assert_eq!(stats.dbs_scanned, 0);
753 assert_eq!(stats.records_written, 0);
754 assert!(reg.root().join(MARKER).exists());
755 }
756
757 #[test]
758 fn migrate_is_idempotent() {
759 let (home, reg) = fresh_home();
760 migrate_from_sqlite_once(home.path(), ®).unwrap();
762 let wd = channel_agents(home.path(), "stable").join("demo-1");
764 std::fs::create_dir_all(&wd).unwrap();
765 make_channel_db(
766 home.path(),
767 "stable",
768 "0.33.821",
769 &[("inst-1", "demo", 100, &wd.to_string_lossy())],
770 );
771 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
772 assert_eq!(
773 stats.records_written, 0,
774 "marker must short-circuit subsequent runs"
775 );
776 assert!(reg.list_active().unwrap().is_empty());
777 }
778
779 #[test]
780 fn migrate_writes_one_record_per_unique_id() {
781 let (home, reg) = fresh_home();
782 let agents = channel_agents(home.path(), "stable");
783 let wd_a = agents.join("demo-a");
784 let wd_b = agents.join("demo-b");
785 std::fs::create_dir_all(&wd_a).unwrap();
786 std::fs::create_dir_all(&wd_b).unwrap();
787 make_channel_db(
788 home.path(),
789 "stable",
790 "0.33.821",
791 &[
792 ("inst-a", "demoA", 100, &wd_a.to_string_lossy()),
793 ("inst-b", "demoB", 200, &wd_b.to_string_lossy()),
794 ],
795 );
796
797 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
798 assert_eq!(stats.rows_seen, 2);
799 assert_eq!(stats.records_written, 2);
800 assert_eq!(reg.list_active().unwrap().len(), 2);
801 }
802
803 #[test]
804 fn migrate_anchors_each_row_on_its_own_channel() {
805 let (home, reg) = fresh_home();
812 let wd_a = channel_agents(home.path(), "stable").join("alpha");
813 let wd_b = channel_agents(home.path(), "local-main-b28b7a").join("beta");
814 std::fs::create_dir_all(&wd_a).unwrap();
815 std::fs::create_dir_all(&wd_b).unwrap();
816 make_channel_db(
817 home.path(),
818 "stable",
819 "0.44.2",
820 &[("inst-a", "Alpha", 100, &wd_a.to_string_lossy())],
821 );
822 make_channel_db(
823 home.path(),
824 "local-main-b28b7a",
825 "0.44.2",
826 &[("inst-b", "Beta", 200, &wd_b.to_string_lossy())],
827 );
828
829 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
830 assert_eq!(stats.dbs_scanned, 2);
831 assert_eq!(stats.rows_seen, 2);
832 assert_eq!(stats.records_skipped_unmappable, 0, "per-channel anchoring");
833 assert_eq!(stats.records_written, 2);
834 let mut recs = reg.list_active().unwrap();
835 recs.sort_by(|a, b| a.data.instance_id.cmp(&b.data.instance_id));
836 assert_eq!(recs[0].data.working_dir, "alpha");
837 assert_eq!(recs[1].data.working_dir, "beta");
838 assert_eq!(
842 recs[0].data.source_agents_base.as_deref(),
843 Some(channel_agents(home.path(), "stable").to_string_lossy().as_ref())
844 );
845 assert_eq!(
846 recs[1].data.source_agents_base.as_deref(),
847 Some(
848 channel_agents(home.path(), "local-main-b28b7a")
849 .to_string_lossy()
850 .as_ref()
851 )
852 );
853 assert_eq!(recs[0].schema_version, 3);
854 }
855
856 #[test]
857 fn migrate_anchors_global_workspace_not_per_channel() {
858 let (home, reg) = fresh_home();
866 let global_agents = home.path().join("agents"); assert_ne!(
872 global_agents.as_path(),
873 reg.root().parent().unwrap(),
874 "workspace root must differ from the re-rooted registry's parent"
875 );
876 let wd = global_agents.join("qooma-0612g");
877 std::fs::create_dir_all(&wd).unwrap();
878 make_channel_db(
881 home.path(),
882 "stable",
883 "0.44.2",
884 &[("inst-q", "Qooma", 100, &wd.to_string_lossy())],
885 );
886
887 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
888 assert_eq!(stats.rows_seen, 1);
889 assert_eq!(
890 stats.records_skipped_unmappable, 0,
891 "a global workspace must NOT be unmappable"
892 );
893 assert_eq!(stats.records_written, 1);
894 let recs = reg.list_active().unwrap();
895 assert_eq!(recs.len(), 1);
896 assert_eq!(recs[0].data.working_dir, "qooma-0612g");
897 assert_eq!(
898 recs[0].data.source_agents_base.as_deref(),
899 Some(global_agents.to_string_lossy().as_ref()),
900 "anchored on the GLOBAL workspace root <home>/agents, not the channel"
901 );
902 assert_eq!(recs[0].schema_version, 3);
903 }
904
905 #[test]
906 fn migrate_legacy_marker_reruns_then_settles() {
907 let (home, reg) = fresh_home();
912 let global_agents = home.path().join("agents"); let wd = global_agents.join("naki");
914 std::fs::create_dir_all(&wd).unwrap();
915 make_channel_db(
916 home.path(),
917 "stable",
918 "0.44.2",
919 &[("inst-n", "Naki", 100, &wd.to_string_lossy())],
920 );
921 std::fs::write(
923 reg.root().join(MARKER),
924 b"migrated_at: 2026-06-10T00:00:00Z\nrecords_written: 0\n",
925 )
926 .unwrap();
927
928 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
930 assert_eq!(
931 stats.records_written, 1,
932 "legacy marker must trigger a one-time re-run"
933 );
934 assert_eq!(reg.list_active().unwrap().len(), 1);
935 assert_eq!(
936 marker_migration_version(®.root().join(MARKER)),
937 MIGRATION_VERSION,
938 "marker upgraded to the current version"
939 );
940
941 let again = migrate_from_sqlite_once(home.path(), ®).unwrap();
943 assert_eq!(again.records_written, 0, "settles after the single re-run");
944 }
945
946 #[test]
947 fn migrate_picks_latest_started_at_on_dedup() {
948 let (home, reg) = fresh_home();
949 let wd = channel_agents(home.path(), "stable").join("demo");
950 std::fs::create_dir_all(&wd).unwrap();
951 make_channel_db(
954 home.path(),
955 "stable",
956 "0.33.800",
957 &[("inst-1", "demo", 100, &wd.to_string_lossy())],
958 );
959 make_channel_db(
960 home.path(),
961 "stable",
962 "0.33.821",
963 &[("inst-1", "demo", 200, &wd.to_string_lossy())],
964 );
965
966 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
967 assert_eq!(stats.rows_seen, 2);
968 assert_eq!(stats.records_written, 1);
969 let recs = reg.list_active().unwrap();
970 assert_eq!(recs.len(), 1);
971 assert_eq!(recs[0].data.last_launched_at_ms, 200);
972 }
973
974 #[test]
975 fn migrate_handles_dev_layout() {
976 let (home, reg) = fresh_home();
981 let inst_dir = home.path().join("dev").join("mybranch").join("69d7a34a");
982 let wd = inst_dir.join("agents").join("devagent");
983 std::fs::create_dir_all(&wd).unwrap();
984 make_db_at(
985 &inst_dir,
986 &[("inst-dev", "DevAgent", 100, &wd.to_string_lossy(), false)],
987 );
988
989 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
990 assert_eq!(stats.dbs_scanned, 1);
991 assert_eq!(stats.records_written, 1);
992 let recs = reg.list_active().unwrap();
993 assert_eq!(recs.len(), 1);
994 assert_eq!(recs[0].data.working_dir, "devagent");
995 }
996
997 #[test]
998 fn migrate_dev_ignores_nested_agent_workspace_db() {
999 let (home, reg) = fresh_home();
1004 let inst_dir = home.path().join("dev").join("mybranch").join("sub");
1005 let nested_inst = inst_dir.join("agents").join("nestedmux");
1007 let nested_wd = nested_inst.join("agents").join("inner");
1008 std::fs::create_dir_all(&nested_wd).unwrap();
1009 make_db_at(
1010 &nested_inst,
1011 &[("inst-nested", "Nested", 100, &nested_wd.to_string_lossy(), false)],
1012 );
1013
1014 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1015 assert_eq!(
1016 stats.dbs_scanned, 0,
1017 "must not descend into agent workspaces under dev/"
1018 );
1019 assert!(reg.list_active().unwrap().is_empty());
1020 }
1021
1022 #[test]
1023 fn migrate_dev_picks_instance_db_and_ignores_nested() {
1024 let (home, reg) = fresh_home();
1028 let inst_dir = home.path().join("dev").join("mybranch").join("sub");
1029 let wd = inst_dir.join("agents").join("realagent");
1030 std::fs::create_dir_all(&wd).unwrap();
1031 make_db_at(
1032 &inst_dir,
1033 &[("inst-real", "Real", 100, &wd.to_string_lossy(), false)],
1034 );
1035 let nested_inst = inst_dir.join("agents").join("nestedmux");
1036 let nested_wd = nested_inst.join("agents").join("inner");
1037 std::fs::create_dir_all(&nested_wd).unwrap();
1038 make_db_at(
1039 &nested_inst,
1040 &[("inst-nested", "Nested", 200, &nested_wd.to_string_lossy(), false)],
1041 );
1042
1043 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1044 assert_eq!(stats.dbs_scanned, 1, "only the instance-level dev DB is a source");
1045 let recs = reg.list_active().unwrap();
1046 assert_eq!(recs.len(), 1);
1047 assert_eq!(recs[0].data.instance_id, "inst-real");
1048 assert_eq!(recs[0].data.working_dir, "realagent");
1049 }
1050
1051 #[test]
1052 fn migrate_dev_branch_named_like_internal_dir_is_scanned() {
1053 let (home, reg) = fresh_home();
1057 let inst_dir = home.path().join("dev").join("data").join("sub");
1058 let wd = inst_dir.join("agents").join("a1");
1059 std::fs::create_dir_all(&wd).unwrap();
1060 make_db_at(
1061 &inst_dir,
1062 &[("inst-d", "D", 100, &wd.to_string_lossy(), false)],
1063 );
1064
1065 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1066 assert_eq!(stats.dbs_scanned, 1, "branch named 'data' must still be scanned");
1067 assert_eq!(reg.list_active().unwrap().len(), 1);
1068 }
1069
1070 #[test]
1071 fn migrate_defers_marker_when_versions_dir_is_unreadable() {
1072 let (home, reg) = fresh_home();
1077 let ch = home.path().join("channels").join("stable");
1078 std::fs::create_dir_all(&ch).unwrap();
1079 std::fs::write(ch.join("versions"), b"not a directory").unwrap();
1080
1081 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1082 assert!(
1083 !stats.complete,
1084 "an unreadable versions/ must defer the marker"
1085 );
1086 assert!(
1087 !reg.root().join(MARKER).exists(),
1088 "marker deferred so the channel is retried next launch"
1089 );
1090 }
1091
1092 #[test]
1093 fn migrate_skips_when_registry_already_has_record() {
1094 let (home, reg) = fresh_home();
1095 let wd = channel_agents(home.path(), "stable").join("demo");
1096 std::fs::create_dir_all(&wd).unwrap();
1097 reg.upsert(&NamedAgentRecord {
1099 schema_version: MAX_SUPPORTED_SCHEMA,
1100 data: NamedAgentRecordV1 {
1101 instance_id: "inst-1".to_string(),
1102 instance_name: "preexisting".to_string(),
1103 definition_id: "claude-code".to_string(),
1104 identity_id: None,
1105 memory_id: None,
1106 session_id: None,
1107 working_dir: "demo".to_string(),
1108 source_agents_base: None,
1109 created_at_ms: 50,
1110 last_launched_at_ms: 500,
1111 created_by_version: "0.33.823".to_string(),
1112 last_launched_by_version: "0.33.823".to_string(),
1113 },
1114 })
1115 .unwrap();
1116 make_channel_db(
1118 home.path(),
1119 "stable",
1120 "0.33.821",
1121 &[("inst-1", "legacyname", 100, &wd.to_string_lossy())],
1122 );
1123
1124 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1125 assert_eq!(stats.records_skipped_existing, 1);
1126 assert_eq!(stats.records_written, 0);
1127 let recs = reg.list_active().unwrap();
1129 assert_eq!(recs.len(), 1);
1130 assert_eq!(recs[0].data.instance_name, "preexisting");
1131 }
1132
1133 #[test]
1134 fn migrate_skips_when_record_is_retired() {
1135 let (home, reg) = fresh_home();
1139 let wd = channel_agents(home.path(), "stable").join("demo");
1140 std::fs::create_dir_all(&wd).unwrap();
1141
1142 let retired_record = NamedAgentRecord {
1143 schema_version: MAX_SUPPORTED_SCHEMA,
1144 data: NamedAgentRecordV1 {
1145 instance_id: "inst-1".to_string(),
1146 instance_name: "demo".to_string(),
1147 definition_id: "claude-code".to_string(),
1148 identity_id: None,
1149 memory_id: None,
1150 session_id: None,
1151 working_dir: "demo".to_string(),
1152 source_agents_base: None,
1153 created_at_ms: 50,
1154 last_launched_at_ms: 50,
1155 created_by_version: "0.33.823".to_string(),
1156 last_launched_by_version: "0.33.823".to_string(),
1157 },
1158 };
1159 reg.upsert(&retired_record).unwrap();
1160 reg.retire("inst-1").unwrap();
1161 assert!(reg.list_active().unwrap().is_empty());
1162 assert!(reg.exists_anywhere("inst-1"));
1163
1164 make_channel_db(
1165 home.path(),
1166 "stable",
1167 "0.33.821",
1168 &[("inst-1", "demo", 100, &wd.to_string_lossy())],
1169 );
1170
1171 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1172 assert_eq!(stats.rows_seen, 1);
1173 assert_eq!(stats.records_skipped_existing, 1);
1174 assert_eq!(stats.records_written, 0);
1175 assert!(reg.list_active().unwrap().is_empty());
1176 assert!(reg.root().join("retired").join("inst-1.json").exists());
1177 }
1178
1179 #[test]
1180 fn migrate_silently_skips_pre_v8_schema() {
1181 let (home, reg) = fresh_home();
1186 let db_dir = home
1187 .path()
1188 .join("channels")
1189 .join("stable")
1190 .join("versions")
1191 .join("0.33.643")
1192 .join("data")
1193 .join("db");
1194 std::fs::create_dir_all(&db_dir).unwrap();
1195 let conn = Connection::open(db_dir.join("objects.db")).unwrap();
1196 conn.execute_batch(
1197 "CREATE TABLE db_agent_instances (
1198 id TEXT PRIMARY KEY,
1199 definition_id TEXT NOT NULL DEFAULT '',
1200 parent_instance_id TEXT NOT NULL DEFAULT '',
1201 started_at INTEGER NOT NULL DEFAULT 0,
1202 created_at INTEGER NOT NULL DEFAULT 0
1203 );",
1204 )
1205 .unwrap();
1206 drop(conn);
1207
1208 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1209 assert_eq!(stats.dbs_scanned, 1);
1210 assert_eq!(stats.rows_seen, 0);
1211 assert!(stats.complete, "pre-v8 schema must not block the marker");
1212 assert!(reg.root().join(MARKER).exists());
1213 }
1214
1215 #[test]
1216 fn migrate_writes_legacy_hidden_row_as_tombstone() {
1217 let (home, reg) = fresh_home();
1220 let wd = channel_agents(home.path(), "stable").join("forgotten");
1221 std::fs::create_dir_all(&wd).unwrap();
1222 make_channel_db_with_hidden(
1223 home.path(),
1224 "stable",
1225 "0.33.821",
1226 &[("inst-1", "forgotten", 100, &wd.to_string_lossy(), true)],
1227 );
1228
1229 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1230 assert_eq!(stats.records_written, 1);
1231 assert!(
1232 reg.list_active().unwrap().is_empty(),
1233 "hidden legacy row must NOT appear active"
1234 );
1235 assert!(
1236 reg.root().join("retired").join("inst-1.json").exists(),
1237 "hidden legacy row must be migrated as retired tombstone"
1238 );
1239 }
1240
1241 #[test]
1242 fn migrate_preserves_forget_intent_across_versions() {
1243 let (home, reg) = fresh_home();
1246 let wd = channel_agents(home.path(), "stable").join("toggled");
1247 std::fs::create_dir_all(&wd).unwrap();
1248 make_channel_db_with_hidden(
1249 home.path(),
1250 "stable",
1251 "0.33.800",
1252 &[("inst-1", "toggled", 100, &wd.to_string_lossy(), false)],
1253 );
1254 make_channel_db_with_hidden(
1255 home.path(),
1256 "stable",
1257 "0.33.821",
1258 &[("inst-1", "toggled", 200, &wd.to_string_lossy(), true)],
1259 );
1260
1261 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1262 assert_eq!(stats.records_written, 1);
1263 assert!(
1264 reg.list_active().unwrap().is_empty(),
1265 "hidden intent in any version must propagate to registry tombstone"
1266 );
1267 assert!(reg.root().join("retired").join("inst-1.json").exists());
1268 }
1269
1270 #[test]
1271 fn migrate_defers_marker_on_unreadable_db() {
1272 let (home, reg) = fresh_home();
1275 let wd = channel_agents(home.path(), "stable").join("demo");
1276 std::fs::create_dir_all(&wd).unwrap();
1277 make_channel_db(
1278 home.path(),
1279 "stable",
1280 "0.33.821",
1281 &[("inst-good", "demo", 100, &wd.to_string_lossy())],
1282 );
1283 let bad_db_dir = home
1285 .path()
1286 .join("channels")
1287 .join("stable")
1288 .join("versions")
1289 .join("0.33.800")
1290 .join("data")
1291 .join("db");
1292 std::fs::create_dir_all(&bad_db_dir).unwrap();
1293 std::fs::write(bad_db_dir.join("objects.db"), b"not actually sqlite").unwrap();
1294
1295 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1296 assert_eq!(stats.records_written, 1, "good DB still migrated");
1297 assert_eq!(stats.dbs_skipped, 1, "bad DB counted, not fatal");
1298 assert!(
1299 !reg.root().join(MARKER).exists(),
1300 "marker deferred (retry) when a DB was unreadable — but the registry still attaches (see main.rs); good records are already written"
1301 );
1302 assert_eq!(reg.list_active().unwrap().len(), 1);
1305
1306 std::fs::remove_file(bad_db_dir.join("objects.db")).unwrap();
1309 make_channel_db(
1310 home.path(),
1311 "stable",
1312 "0.33.800",
1313 &[("inst-other", "demo", 50, &wd.to_string_lossy())],
1314 );
1315 let stats2 = migrate_from_sqlite_once(home.path(), ®).unwrap();
1316 assert!(stats2.complete, "complete flag set on clean retry");
1317 assert!(
1318 reg.root().join(MARKER).exists(),
1319 "marker written on the retry once all DBs read successfully"
1320 );
1321 assert_eq!(stats2.records_skipped_existing, 1);
1322 assert_eq!(stats2.records_written, 1);
1323 }
1324
1325 #[test]
1326 fn migrate_skips_unmappable_working_dirs() {
1327 let (home, reg) = fresh_home();
1328 let outside = home.path().join("not_under_agents").join("foo");
1330 make_channel_db(
1331 home.path(),
1332 "stable",
1333 "0.33.821",
1334 &[("inst-x", "demo", 100, &outside.to_string_lossy())],
1335 );
1336
1337 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1338 assert_eq!(stats.rows_seen, 1);
1339 assert_eq!(stats.records_skipped_unmappable, 1);
1340 assert_eq!(stats.records_written, 0);
1341 }
1342
1343 #[test]
1344 fn migrate_tolerates_missing_or_corrupt_dbs() {
1345 let (home, reg) = fresh_home();
1346 std::fs::create_dir_all(
1348 home.path()
1349 .join("channels")
1350 .join("stable")
1351 .join("versions")
1352 .join("0.33.700"),
1353 )
1354 .unwrap();
1355 let db_dir = home
1357 .path()
1358 .join("channels")
1359 .join("stable")
1360 .join("versions")
1361 .join("0.33.701")
1362 .join("data")
1363 .join("db");
1364 std::fs::create_dir_all(&db_dir).unwrap();
1365 std::fs::write(db_dir.join("objects.db"), b"not a sqlite file").unwrap();
1366
1367 let stats = migrate_from_sqlite_once(home.path(), ®).unwrap();
1368 assert_eq!(stats.dbs_scanned, 1);
1371 assert_eq!(stats.dbs_skipped, 1);
1372 assert_eq!(stats.records_written, 0);
1373 assert!(
1374 !reg.root().join(MARKER).exists(),
1375 "marker deferred on unreadable DB"
1376 );
1377 }
1378
1379 fn seed_pre_v3_record(reg: &Registry, id: &str, session: Option<&str>) {
1382 reg.upsert(&NamedAgentRecord {
1383 schema_version: if session.is_some() { 2 } else { 1 },
1384 data: NamedAgentRecordV1 {
1385 instance_id: id.to_string(),
1386 instance_name: "demo".to_string(),
1387 definition_id: "claude-code".to_string(),
1388 identity_id: Some("ident-1".to_string()),
1389 memory_id: None,
1390 session_id: session.map(|s| s.to_string()),
1391 working_dir: "demo".to_string(),
1392 source_agents_base: None,
1393 created_at_ms: 10,
1394 last_launched_at_ms: 20,
1395 created_by_version: "0.43.0".to_string(),
1396 last_launched_by_version: "0.43.0".to_string(),
1397 },
1398 })
1399 .unwrap();
1400 }
1401
1402 #[test]
1403 fn backfill_sets_source_base_preserving_session_and_identity() {
1404 let (home, reg) = fresh_home();
1405 std::fs::write(reg.root().join(MARKER), b"x").unwrap();
1407 seed_pre_v3_record(®, "inst-1", Some("sess-keep"));
1408 let wd = channel_agents(home.path(), "stable").join("demo");
1410 std::fs::create_dir_all(&wd).unwrap();
1411 make_channel_db(
1412 home.path(),
1413 "stable",
1414 "0.44.2",
1415 &[("inst-1", "demo", 100, &wd.to_string_lossy())],
1416 );
1417
1418 let stats = backfill_source_bases_once(home.path(), ®).unwrap();
1419 assert_eq!(stats.records_updated, 1);
1420 assert!(stats.complete);
1421 let recs = reg.list_active().unwrap();
1422 assert_eq!(recs.len(), 1);
1423 let r = &recs[0].data;
1424 assert_eq!(
1426 r.source_agents_base.as_deref(),
1427 Some(channel_agents(home.path(), "stable").to_string_lossy().as_ref())
1428 );
1429 assert_eq!(r.session_id.as_deref(), Some("sess-keep"));
1431 assert_eq!(r.identity_id.as_deref(), Some("ident-1"));
1432 assert_eq!(recs[0].schema_version, 3);
1433 assert!(reg.root().join(SOURCE_BACKFILL_MARKER).exists());
1434 }
1435
1436 #[test]
1437 fn backfill_is_idempotent_via_marker() {
1438 let (home, reg) = fresh_home();
1439 let s1 = backfill_source_bases_once(home.path(), ®).unwrap();
1441 assert_eq!(s1.records_updated, 0);
1442 assert!(s1.complete);
1443 assert!(reg.root().join(SOURCE_BACKFILL_MARKER).exists());
1444 seed_pre_v3_record(®, "inst-late", None);
1446 let s2 = backfill_source_bases_once(home.path(), ®).unwrap();
1447 assert_eq!(s2.records_updated, 0, "marker short-circuits subsequent runs");
1448 }
1449
1450 #[test]
1451 fn backfill_anchors_on_latest_started_at_channel() {
1452 let (home, reg) = fresh_home();
1456 std::fs::write(reg.root().join(MARKER), b"x").unwrap();
1457 seed_pre_v3_record(®, "inst-dup", None);
1458 let wda = channel_agents(home.path(), "chan-a").join("demo");
1459 let wdb = channel_agents(home.path(), "chan-b").join("demo");
1460 std::fs::create_dir_all(&wda).unwrap();
1461 std::fs::create_dir_all(&wdb).unwrap();
1462 make_channel_db(
1464 home.path(),
1465 "chan-a",
1466 "0.1",
1467 &[("inst-dup", "demo", 100, &wda.to_string_lossy())],
1468 );
1469 make_channel_db(
1470 home.path(),
1471 "chan-b",
1472 "0.1",
1473 &[("inst-dup", "demo", 200, &wdb.to_string_lossy())],
1474 );
1475
1476 let stats = backfill_source_bases_once(home.path(), ®).unwrap();
1477 assert_eq!(stats.records_updated, 1);
1478 let recs = reg.list_active().unwrap();
1479 assert_eq!(
1480 recs[0].data.source_agents_base.as_deref(),
1481 Some(channel_agents(home.path(), "chan-b").to_string_lossy().as_ref()),
1482 "anchors on the latest-started_at channel (chan-b)"
1483 );
1484 }
1485
1486 #[test]
1487 fn backfill_counts_unresolved_when_source_db_gone() {
1488 let (home, reg) = fresh_home();
1489 std::fs::write(reg.root().join(MARKER), b"x").unwrap();
1490 seed_pre_v3_record(®, "inst-orphan", None);
1491 let stats = backfill_source_bases_once(home.path(), ®).unwrap();
1493 assert_eq!(stats.records_updated, 0);
1494 assert_eq!(stats.records_unresolved, 1);
1495 assert!(stats.complete, "no DB failure → marker written");
1496 let recs = reg.list_active().unwrap();
1498 assert!(recs[0].data.source_agents_base.is_none());
1499 }
1500}