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];
89
90/// Outcome stats — surfaced in the srv log.
91#[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
100/// Scan every channel AND dev-branch `objects.db` under `home` for user agents
101/// and backfill them into the global definition `store`. Re-runs once per
102/// [`MIGRATION_VERSION`] bump (marker in `store.root()`); read-only on every
103/// scanned SQLite, and tolerant of older schemas (missing columns).
104pub 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    // Dedup across channels/versions/dev-branches: keep the copy with the
115    // highest FRESHNESS = max(def.updated_at, content.updated_at,
116    // skill.created_at). `agent_content_set` bumps the content row's timestamp
117    // without touching the definition row, so comparing definition timestamps
118    // alone could keep a stale content/skills snapshot. (codex P1.)
119    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        // Decide whether to write based on freshness vs any existing global
148        // record:
149        //   - absent              → backfill (new agent).
150        //   - present & OLDER      → refresh. A broken earlier pass may have
151        //     written a stale or content-less copy globally while a fresher
152        //     channel/dev DB holds the real one; the recovery re-run must
153        //     correct it. (codex P1.)
154        //   - present & not-older  → skip. The live write-mirror advances the
155        //     global record without touching any channel's SQLite, so a
156        //     channel-DB copy can be staler than what's already global — never
157        //     downgrade it. (reagent P2.)
158        // `upsert` additionally refuses to resurrect a tombstoned id.
159        //
160        // Freshness comparison: the scanned side is max(def, content, skill)
161        // timestamps; the in-memory global record only carries `updated_at`
162        // (its content/skill blobs are timestamp-less), so we compare against
163        // that. The asymmetry biases toward refreshing on a recovery run, which
164        // is the safe direction, and still never downgrades a genuinely-newer
165        // global `updated_at`.
166        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                // else: scanned copy is strictly fresher → fall through to upsert.
173            }
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    // Record the migration version. A DB skipped this pass (genuinely
189    // corrupt/locked — now rare since v2 tolerates schema drift) still goes
190    // global on its next edit via the live write-mirror (P0.2b), and a future
191    // MIGRATION_VERSION bump re-scans everything — so nothing is permanently
192    // lost and we never loop on a permanent failure.
193    write_marker(&marker, MIGRATION_VERSION)?;
194    Ok(stats)
195}
196
197/// Migration version recorded in the marker; `0` when absent or unparseable
198/// (the legacy `migrated` text → 0 → re-run).
199fn 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
206/// Every `…/data/db/objects.db` under `home/channels/*/versions/*` AND
207/// `home/dev/<branch>[/<sub>]`. The instance migration scans both
208/// (`migrate.rs::enumerate_sources`); the definition migration must too, or
209/// dev-branch agents (e.g. agents created via `task dev`) never go global.
210fn 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    // Installed/portable: channels/<ch>/versions/<v>/data/db/objects.db
219    for ch in dir_subdirs(&home.join("channels")) {
220        for v in dir_subdirs(&ch.join("versions")) {
221            add(&v);
222        }
223    }
224    // Dev: dev/<branch>[/<sub>]/data/db/objects.db (both layouts).
225    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
234/// Read all `is_seeded=0` definitions from `db` with their content + skills,
235/// each paired with a freshness timestamp for cross-copy winner selection.
236fn 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    // Schema-resilient: introspect the columns present and substitute a default
239    // literal for any absent on an older DB, so a missing column degrades a
240    // single field rather than skipping the whole DB (the original bug — older
241    // DBs lacked `container_image`/`_volumes`/`_name`). The column order/count
242    // stays fixed, so the index-based row mapper below remains valid.
243    let present = present_columns(&conn, "db_agent_definitions")?;
244    if present.is_empty() {
245        // No db_agent_definitions table here → no user definitions.
246        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    // Filter seeded templates only when the column exists (pre-seeded-template
260    // DBs have no templates, so every row is a user agent).
261    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        // Content/skills tables are absent on pre-v2 DBs — tolerate THAT, but
306        // propagate a genuine error (lock/corrupt) so the whole DB is skipped
307        // rather than silently writing an instruction-less record. (codex P2.)
308        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
332/// True iff the error is SQLite "no such table" (a pre-v2 DB missing
333/// db_agent_content / db_agent_skills) — the only failure we treat as "no
334/// rows" rather than a reason to skip the DB.
335fn 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
347/// `MAX(col)` over a per-agent table; `0` when the table is missing (pre-v2)
348/// or there are no rows. Other errors propagate (→ DB skipped).
349fn 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
402/// Column names present in `table` (empty set if the table doesn't exist —
403/// `PRAGMA table_info` on a missing table yields no rows, not an error).
404fn 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    /// Create the current (v2) schema at `db`.
416    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        // Second run is a no-op (marker present).
496        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        // Same id in two channels with different updated_at → freshest wins.
506        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        // A seeded template — must NOT migrate.
511        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        // The channel DB has the OLD copy.
536        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        // The global store already has a NEWER copy (e.g. the live mirror
540        // advanced it via a cross-channel edit that never touched the DB).
541        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        // The channel DB holds a FRESHER copy (updated_at=300).
569        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        // The global store holds a STALE copy a broken earlier pass wrote
573        // (older updated_at, e.g. content-less). The recovery re-run must
574        // REFRESH it from the fresher scan rather than skip it. (codex P1.)
575        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        // Copy A: newer DEFINITION timestamp, old content.
601        let db_a = make_channel_db(home, "ch-a", "0.44.2");
602        insert_user_agent(&db_a, "dup", "FromA", 300);
603        // Copy B: older definition timestamp, but content edited later
604        // (agent_content_set bumps content.updated_at, not the def row).
605        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        // F1: an older DB without container_image/_volumes/_name (and without
625        // the content/skills tables) must NOT be skipped — the missing columns
626        // degrade to defaults.
627        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        // All 22 present columns populated (real old DBs are NOT NULL); only
650        // the container_* columns are absent from the schema entirely.
651        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        // F2: agents in dev/<branch>/<sub>/ must be migrated too.
672        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        // F3/F4: the pre-v2 marker (literal "migrated") parses as version 0 →
692        // re-runs once; afterward the versioned marker makes it idempotent.
693        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}