Skip to main content

agentmux_srv/registry/
def_migrate.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Global migration: backfill user-agent DEFINITIONS (with their content +
5//! skills) from every channel's per-version `objects.db` — and every `dev/`
6//! branch DB — into the GLOBAL definition store, so EXISTING agents become
7//! cross-channel without waiting for the next edit. Read-only on every scanned
8//! SQLite.
9//!
10//! **Version-gated, re-runnable marker** (not strictly one-shot): the
11//! `.migrated_definitions` marker in the store root records the
12//! [`MIGRATION_VERSION`] that last ran. A marker older than the current version
13//! — including the legacy `migrated` text, which parses as version 0 — re-runs
14//! the scan ONCE, so users whose earlier pass was incomplete recover
15//! automatically on the next launch. Bump [`MIGRATION_VERSION`] whenever the
16//! scan logic changes to trigger a one-time re-run for everyone.
17//!
18//! On a re-run, an agent already in the global store is refreshed only when the
19//! scanned copy is strictly fresher than the global record (so a broken earlier
20//! pass's stale/content-less copy is corrected) — never downgraded below what
21//! the live write-mirror has since advanced, and never resurrected if
22//! tombstoned. (codex P1 / reagent P2 #1391.)
23//!
24//! Cross-channel agent persistence, P0.2d
25//! (`docs/specs/SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md`).
26//!
27//! Resilience mirrors `scripts/import-agents.sh`: a single unreadable / locked
28//! / old-schema `objects.db` is skipped with a warning, never aborting the
29//! pass. The marker is written after a pass even if some DBs were skipped — a
30//! transiently-skipped DB's agents still go global on their next edit via the
31//! live write-mirror (P0.2b), and a future [`MIGRATION_VERSION`] bump re-scans
32//! everything — so nothing is permanently lost and the migration never loops
33//! forever on a permanent old-schema failure.
34
35use 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
47/// Migration-logic version. Bump whenever the scan changes (new roots, schema
48/// handling, etc.) so existing users re-run the migration ONCE: the marker
49/// stores this number; a marker older than this re-runs. The pre-v2 marker
50/// held the literal text `migrated`, which parses as version 0 → re-runs,
51/// recovering everyone whose first pass was incomplete.
52///
53/// v2 — schema-resilient column handling (missing columns no longer skip a
54/// whole DB), scans `dev/` branches too, and a recoverable versioned marker.
55/// See `docs/analysis/ANALYSIS_CROSS_CHANNEL_AGENT_RETENTION_2026_06_13.md`.
56const MIGRATION_VERSION: u32 = 2;
57
58/// `db_agent_definitions` columns in the order the row mapper expects, paired
59/// with a default SQL literal used when a column is absent on an older DB.
60/// Substituting `<default> AS <col>` keeps the SELECT's column count + order
61/// fixed (so index-based `row.get` stays valid) while tolerating schema drift.
62const 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/// Outcome stats — surfaced in the srv log.
93#[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
102/// Scan every channel AND dev-branch `objects.db` under `home` for user agents
103/// and backfill them into the global definition `store`. Re-runs once per
104/// [`MIGRATION_VERSION`] bump (marker in `store.root()`); read-only on every
105/// scanned SQLite, and tolerant of older schemas (missing columns).
106pub 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    // Dedup across channels/versions/dev-branches: keep the copy with the
117    // highest FRESHNESS = max(def.updated_at, content.updated_at,
118    // skill.created_at). `agent_content_set` bumps the content row's timestamp
119    // without touching the definition row, so comparing definition timestamps
120    // alone could keep a stale content/skills snapshot. (codex P1.)
121    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        // Decide whether to write based on freshness vs any existing global
150        // record:
151        //   - absent              → backfill (new agent).
152        //   - present & OLDER      → refresh. A broken earlier pass may have
153        //     written a stale or content-less copy globally while a fresher
154        //     channel/dev DB holds the real one; the recovery re-run must
155        //     correct it. (codex P1.)
156        //   - present & not-older  → skip. The live write-mirror advances the
157        //     global record without touching any channel's SQLite, so a
158        //     channel-DB copy can be staler than what's already global — never
159        //     downgrade it. (reagent P2.)
160        // `upsert` additionally refuses to resurrect a tombstoned id.
161        //
162        // Freshness comparison: the scanned side is max(def, content, skill)
163        // timestamps; the in-memory global record only carries `updated_at`
164        // (its content/skill blobs are timestamp-less), so we compare against
165        // that. The asymmetry biases toward refreshing on a recovery run, which
166        // is the safe direction, and still never downgrades a genuinely-newer
167        // global `updated_at`.
168        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                // else: scanned copy is strictly fresher → fall through to upsert.
175            }
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    // Record the migration version. A DB skipped this pass (genuinely
191    // corrupt/locked — now rare since v2 tolerates schema drift) still goes
192    // global on its next edit via the live write-mirror (P0.2b), and a future
193    // MIGRATION_VERSION bump re-scans everything — so nothing is permanently
194    // lost and we never loop on a permanent failure.
195    write_marker(&marker, MIGRATION_VERSION)?;
196    Ok(stats)
197}
198
199/// Migration version recorded in the marker; `0` when absent or unparseable
200/// (the legacy `migrated` text → 0 → re-run).
201fn 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
208/// Every `…/data/db/objects.db` under `home/channels/*/versions/*` AND
209/// `home/dev/<branch>[/<sub>]`. The instance migration scans both
210/// (`migrate.rs::enumerate_sources`); the definition migration must too, or
211/// dev-branch agents (e.g. agents created via `task dev`) never go global.
212fn 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    // Installed/portable: channels/<ch>/versions/<v>/data/db/objects.db
221    for ch in dir_subdirs(&home.join("channels")) {
222        for v in dir_subdirs(&ch.join("versions")) {
223            add(&v);
224        }
225    }
226    // Dev: dev/<branch>[/<sub>]/data/db/objects.db (both layouts).
227    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
236/// Read all `is_seeded=0` definitions from `db` with their content + skills,
237/// each paired with a freshness timestamp for cross-copy winner selection.
238fn 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    // Schema-resilient: introspect the columns present and substitute a default
241    // literal for any absent on an older DB, so a missing column degrades a
242    // single field rather than skipping the whole DB (the original bug — older
243    // DBs lacked `container_image`/`_volumes`/`_name`). The column order/count
244    // stays fixed, so the index-based row mapper below remains valid.
245    let present = present_columns(&conn, "db_agent_definitions")?;
246    if present.is_empty() {
247        // No db_agent_definitions table here → no user definitions.
248        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    // Filter seeded templates only when the column exists (pre-seeded-template
262    // DBs have no templates, so every row is a user agent).
263    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        // Content/skills tables are absent on pre-v2 DBs — tolerate THAT, but
310        // propagate a genuine error (lock/corrupt) so the whole DB is skipped
311        // rather than silently writing an instruction-less record. (codex P2.)
312        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
336/// True iff the error is SQLite "no such table" (a pre-v2 DB missing
337/// db_agent_content / db_agent_skills) — the only failure we treat as "no
338/// rows" rather than a reason to skip the DB.
339fn 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
351/// `MAX(col)` over a per-agent table; `0` when the table is missing (pre-v2)
352/// or there are no rows. Other errors propagate (→ DB skipped).
353fn 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
406/// Column names present in `table` (empty set if the table doesn't exist —
407/// `PRAGMA table_info` on a missing table yields no rows, not an error).
408fn 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    /// Create the current (v2) schema at `db`.
420    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        // Second run is a no-op (marker present).
500        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        // Same id in two channels with different updated_at → freshest wins.
510        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        // A seeded template — must NOT migrate.
515        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        // The channel DB has the OLD copy.
540        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        // The global store already has a NEWER copy (e.g. the live mirror
544        // advanced it via a cross-channel edit that never touched the DB).
545        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        // The channel DB holds a FRESHER copy (updated_at=300).
573        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        // The global store holds a STALE copy a broken earlier pass wrote
577        // (older updated_at, e.g. content-less). The recovery re-run must
578        // REFRESH it from the fresher scan rather than skip it. (codex P1.)
579        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        // Copy A: newer DEFINITION timestamp, old content.
605        let db_a = make_channel_db(home, "ch-a", "0.44.2");
606        insert_user_agent(&db_a, "dup", "FromA", 300);
607        // Copy B: older definition timestamp, but content edited later
608        // (agent_content_set bumps content.updated_at, not the def row).
609        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        // F1: an older DB without container_image/_volumes/_name (and without
629        // the content/skills tables) must NOT be skipped — the missing columns
630        // degrade to defaults.
631        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        // All 22 present columns populated (real old DBs are NOT NULL); only
654        // the container_* columns are absent from the schema entirely.
655        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        // F2: agents in dev/<branch>/<sub>/ must be migrated too.
676        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        // F3/F4: the pre-v2 marker (literal "migrated") parses as version 0 →
696        // re-runs once; afterward the versioned marker makes it idempotent.
697        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}