1use std::collections::{HashMap, HashSet};
36use std::path::{Path, PathBuf};
37
38use rusqlite::{Connection, OpenFlags};
39
40use super::def_schema::{
41 DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DEF_MAX_SUPPORTED_SCHEMA,
42};
43use super::def_store::{DefStoreError, DefinitionStore};
44
45const MARKER: &str = ".migrated_definitions";
46
47const MIGRATION_VERSION: u32 = 2;
57
58const DEF_COLUMNS: &[(&str, &str)] = &[
63 ("id", "''"),
64 ("slug", "''"),
65 ("name", "''"),
66 ("icon", "''"),
67 ("provider", "''"),
68 ("description", "''"),
69 ("working_directory", "''"),
70 ("shell", "''"),
71 ("provider_flags", "''"),
72 ("auto_start", "0"),
73 ("restart_on_crash", "0"),
74 ("idle_timeout_minutes", "0"),
75 ("created_at", "0"),
76 ("agent_type", "'host'"),
77 ("environment", "''"),
78 ("agent_bus_id", "''"),
79 ("is_seeded", "0"),
80 ("accounts", "''"),
81 ("parent_id", "''"),
82 ("branch_label", "''"),
83 ("updated_at", "0"),
84 ("user_hidden", "0"),
85 ("container_image", "''"),
86 ("container_volumes", "'[]'"),
87 ("container_name", "''"),
88 ("use_ambient_login", "0"),
89 ("auto_continue_enabled", "0"),
90];
91
92#[derive(Debug, Default, Clone, Copy)]
94pub struct DefMigrateStats {
95 pub dbs_scanned: usize,
96 pub dbs_skipped: usize,
97 pub rows_seen: usize,
98 pub records_written: usize,
99 pub records_skipped_existing: usize,
100}
101
102pub fn migrate_definitions_global_once(
107 home: &Path,
108 store: &DefinitionStore,
109) -> Result<DefMigrateStats, DefStoreError> {
110 let mut stats = DefMigrateStats::default();
111 let marker = store.root().join(MARKER);
112 if marker_version(&marker) >= MIGRATION_VERSION {
113 return Ok(stats);
114 }
115
116 let mut best: HashMap<String, (DefinitionRecord, i64)> = HashMap::new();
122 for db in collect_scan_dbs(home) {
123 stats.dbs_scanned += 1;
124 match read_user_definitions(&db) {
125 Ok(recs) => {
126 for (rec, freshness) in recs {
127 stats.rows_seen += 1;
128 let id = rec.data.id.clone();
129 let keep = match best.get(&id) {
130 Some((_, f)) => freshness > *f,
131 None => true,
132 };
133 if keep {
134 best.insert(id, (rec, freshness));
135 }
136 }
137 }
138 Err(e) => {
139 stats.dbs_skipped += 1;
140 tracing::warn!(
141 db = %db.display(),
142 error = %e,
143 "def migrate: skipping unreadable/incompatible objects.db"
144 );
145 }
146 }
147 }
148 for (id, (rec, freshness)) in best {
149 match store.get(&id) {
169 Ok(Some(existing)) => {
170 if freshness <= existing.data.updated_at {
171 stats.records_skipped_existing += 1;
172 continue;
173 }
174 }
176 Ok(None) => {}
177 Err(e) => {
178 tracing::warn!(def_id = %id, error = %e, "def migrate: get failed; skipping");
179 continue;
180 }
181 }
182 match store.upsert(&rec) {
183 Ok(()) => stats.records_written += 1,
184 Err(e) => {
185 tracing::warn!(error = %e, "def migrate: upsert into global store failed")
186 }
187 }
188 }
189
190 write_marker(&marker, MIGRATION_VERSION)?;
196 Ok(stats)
197}
198
199fn marker_version(marker: &Path) -> u32 {
202 std::fs::read_to_string(marker)
203 .ok()
204 .and_then(|s| s.trim().parse::<u32>().ok())
205 .unwrap_or(0)
206}
207
208fn collect_scan_dbs(home: &Path) -> Vec<PathBuf> {
213 let mut dbs = Vec::new();
214 let mut add = |dir: &Path| {
215 let db = dir.join("data").join("db").join("objects.db");
216 if db.is_file() {
217 dbs.push(db);
218 }
219 };
220 for ch in dir_subdirs(&home.join("channels")) {
222 for v in dir_subdirs(&ch.join("versions")) {
223 add(&v);
224 }
225 }
226 for br in dir_subdirs(&home.join("dev")) {
228 add(&br);
229 for sub in dir_subdirs(&br) {
230 add(&sub);
231 }
232 }
233 dbs
234}
235
236fn read_user_definitions(db: &Path) -> Result<Vec<(DefinitionRecord, i64)>, rusqlite::Error> {
239 let conn = Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
240 let present = present_columns(&conn, "db_agent_definitions")?;
246 if present.is_empty() {
247 return Ok(Vec::new());
249 }
250 let select_list = DEF_COLUMNS
251 .iter()
252 .map(|(name, default)| {
253 if present.contains(*name) {
254 (*name).to_string()
255 } else {
256 format!("{default} AS {name}")
257 }
258 })
259 .collect::<Vec<_>>()
260 .join(", ");
261 let where_clause = if present.contains("is_seeded") {
264 " WHERE is_seeded = 0"
265 } else {
266 ""
267 };
268 let sql = format!("SELECT {select_list} FROM db_agent_definitions{where_clause}");
269 let defs: Vec<DefinitionRecordV1> = {
270 let mut stmt = conn.prepare(&sql)?;
271 let rows = stmt.query_map([], |row| {
272 Ok(DefinitionRecordV1 {
273 id: row.get(0)?,
274 slug: row.get(1)?,
275 name: row.get(2)?,
276 icon: row.get(3)?,
277 provider: row.get(4)?,
278 description: row.get(5)?,
279 working_directory: row.get(6)?,
280 shell: row.get(7)?,
281 provider_flags: row.get(8)?,
282 auto_start: row.get(9)?,
283 restart_on_crash: row.get(10)?,
284 idle_timeout_minutes: row.get(11)?,
285 created_at: row.get(12)?,
286 agent_type: row.get(13)?,
287 environment: row.get(14)?,
288 agent_bus_id: row.get(15)?,
289 is_seeded: row.get(16)?,
290 accounts: row.get(17)?,
291 parent_id: row.get(18)?,
292 branch_label: row.get(19)?,
293 updated_at: row.get(20)?,
294 user_hidden: row.get(21)?,
295 container_image: row.get(22)?,
296 container_volumes: row.get(23)?,
297 container_name: row.get(24)?,
298 use_ambient_login: row.get(25)?,
299 auto_continue_enabled: row.get(26)?,
300 content: Vec::new(),
301 skills: Vec::new(),
302 })
303 })?;
304 rows.collect::<Result<Vec<_>, _>>()?
305 };
306
307 let mut out = Vec::with_capacity(defs.len());
308 for mut d in defs {
309 d.content = tolerate_missing_table(read_content(&conn, &d.id))?;
313 d.skills = tolerate_missing_table(read_skills(&conn, &d.id))?;
314 let content_max = max_ts(
315 &conn,
316 "SELECT MAX(updated_at) FROM db_agent_content WHERE agent_id = ?1",
317 &d.id,
318 )?;
319 let skill_max = max_ts(
320 &conn,
321 "SELECT MAX(created_at) FROM db_agent_skills WHERE agent_id = ?1",
322 &d.id,
323 )?;
324 let freshness = d.updated_at.max(content_max).max(skill_max);
325 out.push((
326 DefinitionRecord {
327 schema_version: DEF_MAX_SUPPORTED_SCHEMA,
328 data: d,
329 },
330 freshness,
331 ));
332 }
333 Ok(out)
334}
335
336fn is_missing_table(e: &rusqlite::Error) -> bool {
340 matches!(e, rusqlite::Error::SqliteFailure(_, Some(msg)) if msg.contains("no such table"))
341}
342
343fn tolerate_missing_table<T: Default>(r: Result<T, rusqlite::Error>) -> Result<T, rusqlite::Error> {
344 match r {
345 Ok(v) => Ok(v),
346 Err(ref e) if is_missing_table(e) => Ok(T::default()),
347 Err(e) => Err(e),
348 }
349}
350
351fn max_ts(conn: &Connection, sql: &str, agent_id: &str) -> Result<i64, rusqlite::Error> {
354 match conn.query_row(sql, [agent_id], |row| row.get::<_, Option<i64>>(0)) {
355 Ok(v) => Ok(v.unwrap_or(0)),
356 Err(ref e) if is_missing_table(e) => Ok(0),
357 Err(e) => Err(e),
358 }
359}
360
361fn read_content(conn: &Connection, agent_id: &str) -> Result<Vec<DefContentBlob>, rusqlite::Error> {
362 let mut stmt =
363 conn.prepare("SELECT content_type, content FROM db_agent_content WHERE agent_id = ?1")?;
364 let rows = stmt.query_map([agent_id], |row| {
365 Ok(DefContentBlob {
366 content_type: row.get(0)?,
367 content: row.get(1)?,
368 })
369 })?;
370 rows.collect()
371}
372
373fn read_skills(conn: &Connection, agent_id: &str) -> Result<Vec<DefSkillBlob>, rusqlite::Error> {
374 let mut stmt = conn.prepare(
375 "SELECT id, name, trigger, skill_type, description, content
376 FROM db_agent_skills WHERE agent_id = ?1",
377 )?;
378 let rows = stmt.query_map([agent_id], |row| {
379 Ok(DefSkillBlob {
380 id: row.get(0)?,
381 name: row.get(1)?,
382 trigger: row.get(2)?,
383 skill_type: row.get(3)?,
384 description: row.get(4)?,
385 content: row.get(5)?,
386 })
387 })?;
388 rows.collect()
389}
390
391fn dir_subdirs(dir: &Path) -> Vec<PathBuf> {
392 let Ok(rd) = std::fs::read_dir(dir) else {
393 return Vec::new();
394 };
395 rd.flatten()
396 .map(|e| e.path())
397 .filter(|p| p.is_dir())
398 .collect()
399}
400
401fn write_marker(path: &Path, version: u32) -> Result<(), DefStoreError> {
402 std::fs::write(path, version.to_string().as_bytes())?;
403 Ok(())
404}
405
406fn present_columns(conn: &Connection, table: &str) -> Result<HashSet<String>, rusqlite::Error> {
409 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
410 let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
411 rows.collect()
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use rusqlite::params;
418
419 fn create_full_schema(db: &Path) {
421 let conn = Connection::open(db).unwrap();
422 conn.execute_batch(
423 "CREATE TABLE db_agent_definitions (
424 id TEXT, slug TEXT, name TEXT, icon TEXT, provider TEXT, description TEXT,
425 working_directory TEXT, shell TEXT, provider_flags TEXT, auto_start INTEGER,
426 restart_on_crash INTEGER, idle_timeout_minutes INTEGER, created_at INTEGER,
427 agent_type TEXT, environment TEXT, agent_bus_id TEXT, is_seeded INTEGER,
428 accounts TEXT, parent_id TEXT, branch_label TEXT, updated_at INTEGER,
429 user_hidden INTEGER, container_image TEXT, container_volumes TEXT, container_name TEXT);
430 CREATE TABLE db_agent_content (agent_id TEXT, content_type TEXT, content TEXT, updated_at INTEGER);
431 CREATE TABLE db_agent_skills (id TEXT, agent_id TEXT, name TEXT, trigger TEXT, skill_type TEXT, description TEXT, content TEXT, created_at INTEGER);",
432 )
433 .unwrap();
434 }
435
436 fn db_at(dir: PathBuf) -> PathBuf {
437 std::fs::create_dir_all(&dir).unwrap();
438 let db = dir.join("objects.db");
439 create_full_schema(&db);
440 db
441 }
442
443 fn make_channel_db(home: &Path, channel: &str, version: &str) -> PathBuf {
444 db_at(home
445 .join("channels")
446 .join(channel)
447 .join("versions")
448 .join(version)
449 .join("data")
450 .join("db"))
451 }
452
453 fn insert_user_agent(db: &Path, id: &str, name: &str, updated_at: i64) {
454 let conn = Connection::open(db).unwrap();
455 conn.execute(
456 "INSERT INTO db_agent_definitions (id, slug, name, icon, provider, description,
457 working_directory, shell, provider_flags, auto_start, restart_on_crash,
458 idle_timeout_minutes, created_at, agent_type, environment, agent_bus_id, is_seeded,
459 accounts, parent_id, branch_label, updated_at, user_hidden, container_image,
460 container_volumes, container_name)
461 VALUES (?1, ?1, ?2, '✦', 'claude', '', '', '', '', 0, 0, 0, 1, 'host', '', '', 0,
462 '', '', '', ?3, 0, '', '[]', '')",
463 params![id, name, updated_at],
464 )
465 .unwrap();
466 conn.execute(
467 "INSERT INTO db_agent_content (agent_id, content_type, content, updated_at)
468 VALUES (?1, 'agentmd', 'instructions', 1)",
469 params![id],
470 )
471 .unwrap();
472 conn.execute(
473 "INSERT INTO db_agent_skills (id, agent_id, name, trigger, skill_type, description, content, created_at)
474 VALUES ('sk1', ?1, 'greet', '', 'prompt', '', '', 1)",
475 params![id],
476 )
477 .unwrap();
478 }
479
480 fn store_at(home: &Path) -> DefinitionStore {
481 DefinitionStore::open(home.join("shared").join("agents").join("definitions")).unwrap()
482 }
483
484 #[test]
485 fn migrates_user_agents_with_content_and_skills_and_is_idempotent() {
486 let tmp = tempfile::tempdir().unwrap();
487 let home = tmp.path();
488 let db = make_channel_db(home, "local-a-aaa", "0.44.1");
489 insert_user_agent(&db, "agent-1", "Maks", 100);
490 let store = store_at(home);
491
492 let stats = migrate_definitions_global_once(home, &store).unwrap();
493 assert_eq!(stats.records_written, 1);
494 let rec = store.get("agent-1").unwrap().unwrap();
495 assert_eq!(rec.data.name, "Maks");
496 assert_eq!(rec.data.content.len(), 1, "content backfilled");
497 assert_eq!(rec.data.skills.len(), 1, "skills backfilled");
498
499 let stats2 = migrate_definitions_global_once(home, &store).unwrap();
501 assert_eq!(stats2.dbs_scanned, 0);
502 assert_eq!(stats2.records_written, 0);
503 }
504
505 #[test]
506 fn skips_seeded_and_dedups_by_latest_freshness() {
507 let tmp = tempfile::tempdir().unwrap();
508 let home = tmp.path();
509 let db_a = make_channel_db(home, "ch-a", "0.44.1");
511 insert_user_agent(&db_a, "dup", "Old", 100);
512 let db_b = make_channel_db(home, "ch-b", "0.44.1");
513 insert_user_agent(&db_b, "dup", "New", 200);
514 Connection::open(&db_a)
516 .unwrap()
517 .execute(
518 "INSERT INTO db_agent_definitions (id, slug, name, provider, is_seeded, container_volumes)
519 VALUES ('tpl', 'tpl', 'Claude', 'claude', 1, '[]')",
520 [],
521 )
522 .unwrap();
523
524 let store = store_at(home);
525 let stats = migrate_definitions_global_once(home, &store).unwrap();
526 assert_eq!(
527 store.get("dup").unwrap().unwrap().data.name,
528 "New",
529 "latest freshness wins across channels"
530 );
531 assert!(store.get("tpl").unwrap().is_none(), "seeded template not migrated");
532 assert_eq!(stats.records_written, 1);
533 }
534
535 #[test]
536 fn does_not_downgrade_an_existing_global_record() {
537 let tmp = tempfile::tempdir().unwrap();
538 let home = tmp.path();
539 let db = make_channel_db(home, "ch-a", "0.44.1");
541 insert_user_agent(&db, "dup", "OldName", 100);
542 let store = store_at(home);
543 store
546 .upsert(&DefinitionRecord {
547 schema_version: DEF_MAX_SUPPORTED_SCHEMA,
548 data: DefinitionRecordV1 {
549 id: "dup".to_string(),
550 name: "NewName".to_string(),
551 provider: "claude".to_string(),
552 updated_at: 500,
553 ..Default::default()
554 },
555 })
556 .unwrap();
557
558 let stats = migrate_definitions_global_once(home, &store).unwrap();
559 assert_eq!(
560 store.get("dup").unwrap().unwrap().data.name,
561 "NewName",
562 "migration must NOT downgrade the newer global record"
563 );
564 assert_eq!(stats.records_written, 0);
565 assert_eq!(stats.records_skipped_existing, 1);
566 }
567
568 #[test]
569 fn refreshes_a_stale_global_record_on_rerun() {
570 let tmp = tempfile::tempdir().unwrap();
571 let home = tmp.path();
572 let db = make_channel_db(home, "ch-a", "0.44.1");
574 insert_user_agent(&db, "dup", "FreshName", 300);
575 let store = store_at(home);
576 store
580 .upsert(&DefinitionRecord {
581 schema_version: DEF_MAX_SUPPORTED_SCHEMA,
582 data: DefinitionRecordV1 {
583 id: "dup".to_string(),
584 name: "StaleName".to_string(),
585 provider: "claude".to_string(),
586 updated_at: 50,
587 ..Default::default()
588 },
589 })
590 .unwrap();
591
592 let stats = migrate_definitions_global_once(home, &store).unwrap();
593 let rec = store.get("dup").unwrap().unwrap();
594 assert_eq!(rec.data.name, "FreshName", "stale global record must be refreshed from the fresher scan");
595 assert_eq!(rec.data.content.len(), 1, "content backfilled on refresh");
596 assert_eq!(stats.records_written, 1);
597 assert_eq!(stats.records_skipped_existing, 0);
598 }
599
600 #[test]
601 fn winner_selection_includes_content_freshness() {
602 let tmp = tempfile::tempdir().unwrap();
603 let home = tmp.path();
604 let db_a = make_channel_db(home, "ch-a", "0.44.2");
606 insert_user_agent(&db_a, "dup", "FromA", 300);
607 let db_b = make_channel_db(home, "ch-b", "0.44.1");
610 insert_user_agent(&db_b, "dup", "FromB", 200);
611 Connection::open(&db_b)
612 .unwrap()
613 .execute(
614 "UPDATE db_agent_content SET content = 'fresh', updated_at = 900 WHERE agent_id = 'dup'",
615 [],
616 )
617 .unwrap();
618
619 let store = store_at(home);
620 migrate_definitions_global_once(home, &store).unwrap();
621 let rec = store.get("dup").unwrap().unwrap();
622 assert_eq!(rec.data.name, "FromB", "copy with freshest content wins");
623 assert_eq!(rec.data.content[0].content, "fresh");
624 }
625
626 #[test]
627 fn migrates_db_missing_container_columns() {
628 let tmp = tempfile::tempdir().unwrap();
632 let home = tmp.path();
633 let db_dir = home
634 .join("channels")
635 .join("old-ch")
636 .join("versions")
637 .join("0.40.0")
638 .join("data")
639 .join("db");
640 std::fs::create_dir_all(&db_dir).unwrap();
641 let db = db_dir.join("objects.db");
642 let conn = Connection::open(&db).unwrap();
643 conn.execute_batch(
644 "CREATE TABLE db_agent_definitions (
645 id TEXT, slug TEXT, name TEXT, icon TEXT, provider TEXT, description TEXT,
646 working_directory TEXT, shell TEXT, provider_flags TEXT, auto_start INTEGER,
647 restart_on_crash INTEGER, idle_timeout_minutes INTEGER, created_at INTEGER,
648 agent_type TEXT, environment TEXT, agent_bus_id TEXT, is_seeded INTEGER,
649 accounts TEXT, parent_id TEXT, branch_label TEXT, updated_at INTEGER,
650 user_hidden INTEGER);",
651 )
652 .unwrap();
653 conn.execute(
656 "INSERT INTO db_agent_definitions VALUES
657 ('old-1','old-1','OldAgent','✦','claude','','','','',0,0,0,1,
658 'host','','',0,'','','',50,0)",
659 [],
660 )
661 .unwrap();
662 drop(conn);
663
664 let store = store_at(home);
665 let stats = migrate_definitions_global_once(home, &store).unwrap();
666 assert_eq!(stats.dbs_skipped, 0, "old-schema DB must not be skipped");
667 assert_eq!(stats.records_written, 1, "old-schema agent migrated");
668 let rec = store.get("old-1").unwrap().unwrap();
669 assert_eq!(rec.data.name, "OldAgent");
670 assert_eq!(rec.data.container_volumes, "[]", "missing column defaulted");
671 }
672
673 #[test]
674 fn scans_dev_branch_agents() {
675 let tmp = tempfile::tempdir().unwrap();
677 let home = tmp.path();
678 let db = db_at(
679 home.join("dev")
680 .join("main")
681 .join("abc123")
682 .join("data")
683 .join("db"),
684 );
685 insert_user_agent(&db, "dev-1", "DevAgent", 100);
686
687 let store = store_at(home);
688 let stats = migrate_definitions_global_once(home, &store).unwrap();
689 assert_eq!(stats.records_written, 1, "dev-branch agent migrated");
690 assert_eq!(store.get("dev-1").unwrap().unwrap().data.name, "DevAgent");
691 }
692
693 #[test]
694 fn legacy_text_marker_triggers_rerun_then_settles() {
695 let tmp = tempfile::tempdir().unwrap();
698 let home = tmp.path();
699 let db = make_channel_db(home, "ch", "0.44.1");
700 insert_user_agent(&db, "a1", "A", 100);
701 let store = store_at(home);
702 std::fs::write(store.root().join(MARKER), b"migrated\n").unwrap();
703
704 let stats = migrate_definitions_global_once(home, &store).unwrap();
705 assert_eq!(stats.records_written, 1, "legacy text marker must re-run");
706 assert!(store.get("a1").unwrap().is_some());
707
708 let stats2 = migrate_definitions_global_once(home, &store).unwrap();
709 assert_eq!(stats2.dbs_scanned, 0, "versioned marker → idempotent after");
710 }
711}