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