agentmux_srv\registry/
migrate.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One-shot SQLite → file-registry migration. Runs at most once per
5//! `<root>/.migrated_from_sqlite` marker; idempotent and read-only on
6//! every SQLite it touches. See SPEC §8.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use rusqlite::{Connection, OpenFlags};
12
13use super::schema::{NamedAgentRecord, NamedAgentRecordV1, MAX_SUPPORTED_SCHEMA};
14use super::store::{Registry, RegistryError};
15
16/// Outcome stats — surfaced in the marker file + the srv log.
17#[derive(Debug, Default, Clone, Copy)]
18pub struct MigrateStats {
19    pub versions_scanned: usize,
20    pub rows_seen: usize,
21    pub records_written: usize,
22    pub records_skipped_existing: usize,
23    pub records_skipped_unmappable: usize,
24    /// True iff every per-version DB read cleanly. Callers gate
25    /// registry attachment on this — partial migrations leave the
26    /// registry detached so reads keep falling back to SQLite, and
27    /// the next launch retries (no marker written when `false`).
28    pub complete: bool,
29}
30
31/// Marker filename. Lives in the registry root so the registry's
32/// existence implies the migration question has been asked at least
33/// once.
34const MARKER: &str = ".migrated_from_sqlite";
35
36/// Scan every per-version `data/db/objects.db` and populate the
37/// shared registry. Skipped if the marker file exists. Never
38/// overwrites an existing registry record (idempotency + respect
39/// for newer-written data). The SQLite files are opened **read-only**
40/// — never modified.
41///
42/// On dedup conflicts (same `instance_id` in multiple versions), the
43/// row with the latest `started_at` wins.
44pub fn migrate_from_sqlite_once(
45    shared_home: &Path,
46    registry: &Registry,
47) -> Result<MigrateStats, RegistryError> {
48    let marker_path = registry.root().join(MARKER);
49    if marker_path.exists() {
50        // Marker present ⇒ a prior run completed; treat as complete
51        // so callers attach the registry.
52        return Ok(MigrateStats {
53            complete: true,
54            ..MigrateStats::default()
55        });
56    }
57
58    let mut stats = MigrateStats::default();
59    let agents_root = registry.agents_root().ok_or_else(|| {
60        RegistryError::Io(std::io::Error::new(
61            std::io::ErrorKind::InvalidInput,
62            "registry root has no parent",
63        ))
64    })?;
65
66    let versions_root = shared_home.join("versions");
67    if !versions_root.is_dir() {
68        stats.complete = true;
69        write_marker(&marker_path, &stats)?;
70        return Ok(stats);
71    }
72
73    let mut latest_by_id: HashMap<String, RowSnapshot> = HashMap::new();
74    // True iff any per-version DB threw a non-transient-looking error.
75    // We use this to skip writing the marker so the next launch
76    // retries — otherwise a brief filesystem hiccup permanently
77    // omits those rows from the registry-backed dropdown.
78    let mut any_db_failed = false;
79
80    for entry in std::fs::read_dir(&versions_root)? {
81        let v_dir = entry?.path();
82        if !v_dir.is_dir() {
83            continue;
84        }
85        let db_path = v_dir.join("data").join("db").join("objects.db");
86        if !db_path.is_file() {
87            continue;
88        }
89        stats.versions_scanned += 1;
90
91        match read_named_rows(&db_path) {
92            Ok(rows) => {
93                for row in rows {
94                    stats.rows_seen += 1;
95                    let key = row.id.clone();
96                    match latest_by_id.get_mut(&key) {
97                        Some(existing) if existing.started_at >= row.started_at => {
98                            // Existing snapshot wins on started_at, but
99                            // OR the hidden flag — any version expressing
100                            // "forget" intent is preserved as a tombstone.
101                            existing.display_hidden =
102                                existing.display_hidden || row.display_hidden;
103                        }
104                        Some(existing) => {
105                            let merged_hidden =
106                                existing.display_hidden || row.display_hidden;
107                            *existing = row;
108                            existing.display_hidden = merged_hidden;
109                        }
110                        None => {
111                            latest_by_id.insert(key, row);
112                        }
113                    }
114                }
115            }
116            Err(e) => {
117                tracing::warn!(
118                    db = %db_path.display(),
119                    error = %e,
120                    "registry-migrate: per-version DB unreadable — will retry on next launch"
121                );
122                any_db_failed = true;
123            }
124        }
125    }
126
127    for (id, row) in latest_by_id {
128        // Check active AND retired — a record retired by a newer
129        // version's "Forget agent" must NOT be resurrected just
130        // because an older version's SQLite still lists it as
131        // visible.
132        if registry.exists_anywhere(&id) {
133            stats.records_skipped_existing += 1;
134            continue;
135        }
136        let display_hidden = row.display_hidden;
137        let Some(rec) = row_to_record(&row, agents_root) else {
138            stats.records_skipped_unmappable += 1;
139            continue;
140        };
141        if let Err(e) = registry.upsert(&rec) {
142            tracing::warn!(
143                instance_id = %id,
144                error = %e,
145                "registry-migrate: upsert failed"
146            );
147            stats.records_skipped_unmappable += 1;
148            continue;
149        }
150        // Preserve pre-registry "forget" intent: if any version's
151        // SQLite had this row hidden, move the freshly-written
152        // registry file to retired/ so the dropdown stays consistent
153        // with the user's prior soft-delete.
154        if display_hidden {
155            if let Err(e) = registry.retire(&id) {
156                tracing::warn!(
157                    instance_id = %id,
158                    error = %e,
159                    "registry-migrate: failed to retire migrated tombstone — record may surface as active"
160                );
161            }
162        }
163        stats.records_written += 1;
164    }
165
166    // Only finalize the migration when every DB we encountered was
167    // readable. On any per-DB error, defer the marker so a future
168    // launch retries the migration AND signal `complete = false` so
169    // main.rs leaves the registry detached for this session (reads
170    // fall back to SQLite — preferred over serving a partial view).
171    stats.complete = !any_db_failed;
172    if stats.complete {
173        write_marker(&marker_path, &stats)?;
174    } else {
175        tracing::info!(
176            "registry-migrate: deferring marker write; one or more per-version DBs were unreadable and will be retried next launch"
177        );
178    }
179    Ok(stats)
180}
181
182fn write_marker(path: &Path, stats: &MigrateStats) -> std::io::Result<()> {
183    let now = chrono::Utc::now().to_rfc3339();
184    let body = format!(
185        "migrated_at: {now}\n\
186         versions_scanned: {}\n\
187         rows_seen: {}\n\
188         records_written: {}\n\
189         records_skipped_existing: {}\n\
190         records_skipped_unmappable: {}\n",
191        stats.versions_scanned,
192        stats.rows_seen,
193        stats.records_written,
194        stats.records_skipped_existing,
195        stats.records_skipped_unmappable,
196    );
197    std::fs::write(path, body)
198}
199
200struct RowSnapshot {
201    id: String,
202    instance_name: String,
203    definition_id: String,
204    identity_id: String,
205    memory_id: String,
206    working_directory: String,
207    started_at: i64,
208    created_at: i64,
209    display_hidden: bool,
210}
211
212/// True iff the error is SQLite reporting "this column/table doesn't
213/// exist in this DB's schema." Distinguishes a pre-v8 DB (skip
214/// silently — those agents weren't named, so wouldn't appear in the
215/// dropdown anyway) from corruption (caller logs + continues +
216/// defers the marker).
217///
218/// rusqlite reports prepare-time schema mismatches as
219/// `Error::SqlInputError { msg, sql, offset }` and runtime errors as
220/// `Error::SqliteFailure(_, Some(msg))`. Both shapes carry the
221/// canonical SQLite phrases, but only message inspection
222/// distinguishes them from other failures with the same error code.
223fn is_missing_column_or_table(e: &rusqlite::Error) -> bool {
224    let msg = match e {
225        rusqlite::Error::SqliteFailure(_, Some(msg)) => msg.as_str(),
226        rusqlite::Error::SqlInputError { msg, .. } => msg.as_str(),
227        _ => return false,
228    };
229    msg.starts_with("no such column") || msg.starts_with("no such table")
230}
231
232fn read_named_rows(db_path: &Path) -> Result<Vec<RowSnapshot>, rusqlite::Error> {
233    let conn = Connection::open_with_flags(
234        db_path,
235        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
236    )?;
237    // Older schemas (pre-v8) lack `instance_name` / `working_directory`
238    // columns. Suppress ONLY the specific "no such column/table"
239    // errors — broader SqliteFailures (corruption, locked, etc.) must
240    // surface so the caller can log + continue with the next DB.
241    // Include hidden rows — the caller turns them into retired/
242    // tombstones so a pre-registry "Forget agent" intent survives
243    // migration even if another version still has the row visible.
244    let mut stmt = match conn.prepare(
245        "SELECT id, instance_name, definition_id, identity_id, memory_id,
246                working_directory, started_at, created_at, display_hidden
247         FROM db_agent_instances
248         WHERE instance_name <> ''
249           AND parent_instance_id = ''",
250    ) {
251        Ok(s) => s,
252        Err(e) if is_missing_column_or_table(&e) => return Ok(Vec::new()),
253        Err(e) => return Err(e),
254    };
255    let iter = stmt.query_map([], |row| {
256        Ok(RowSnapshot {
257            id: row.get(0)?,
258            instance_name: row.get(1)?,
259            definition_id: row.get(2)?,
260            identity_id: row.get(3)?,
261            memory_id: row.get(4)?,
262            working_directory: row.get(5)?,
263            started_at: row.get(6)?,
264            created_at: row.get(7)?,
265            display_hidden: row.get::<_, i64>(8)? != 0,
266        })
267    })?;
268    iter.collect()
269}
270
271fn row_to_record(row: &RowSnapshot, agents_root: &Path) -> Option<NamedAgentRecord> {
272    let abs = std::path::Path::new(&row.working_directory);
273    let rel = abs.strip_prefix(agents_root).ok()?;
274    let rel_str = rel.to_string_lossy().to_string();
275    if rel_str.is_empty() || rel_str == "." {
276        return None;
277    }
278    Some(NamedAgentRecord {
279        schema_version: MAX_SUPPORTED_SCHEMA,
280        data: NamedAgentRecordV1 {
281            instance_id: row.id.clone(),
282            instance_name: row.instance_name.clone(),
283            definition_id: row.definition_id.clone(),
284            identity_id: empty_to_none(&row.identity_id),
285            memory_id: empty_to_none(&row.memory_id),
286            working_dir: rel_str,
287            created_at_ms: row.created_at,
288            last_launched_at_ms: row.started_at,
289            // We don't know what version originally inserted these rows.
290            // Tag them so post-migration audits can tell. PR C will
291            // never overwrite a record so these stay forever.
292            created_by_version: "(legacy)".to_string(),
293            last_launched_by_version: "(legacy)".to_string(),
294        },
295    })
296}
297
298fn empty_to_none(s: &str) -> Option<String> {
299    if s.is_empty() {
300        None
301    } else {
302        Some(s.to_string())
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use rusqlite::params;
310
311    /// Build a per-version SQLite at `<version_dir>/data/db/objects.db`
312    /// with a minimal `db_agent_instances` schema and the given rows.
313    fn make_version_db(version_dir: &Path, rows: &[(&str, &str, i64, &str)]) {
314        let rows: Vec<_> = rows.iter().map(|(a, b, c, d)| (*a, *b, *c, *d, false)).collect();
315        make_version_db_with_hidden(version_dir, &rows);
316    }
317
318    /// Variant that lets each row carry an explicit `display_hidden`
319    /// flag. Used by tests that exercise tombstone propagation.
320    fn make_version_db_with_hidden(
321        version_dir: &Path,
322        rows: &[(&str, &str, i64, &str, bool)],
323    ) {
324        let db_path = version_dir.join("data").join("db");
325        std::fs::create_dir_all(&db_path).unwrap();
326        let db_path = db_path.join("objects.db");
327        let conn = Connection::open(&db_path).unwrap();
328        conn.execute_batch(
329            "CREATE TABLE db_agent_instances (
330                id TEXT PRIMARY KEY,
331                definition_id TEXT NOT NULL DEFAULT '',
332                parent_instance_id TEXT NOT NULL DEFAULT '',
333                block_id TEXT NOT NULL DEFAULT '',
334                session_id TEXT NOT NULL DEFAULT '',
335                status TEXT NOT NULL DEFAULT 'running',
336                github_context TEXT NOT NULL DEFAULT '',
337                started_at INTEGER NOT NULL DEFAULT 0,
338                ended_at INTEGER NOT NULL DEFAULT 0,
339                created_at INTEGER NOT NULL DEFAULT 0,
340                identity_id TEXT NOT NULL DEFAULT '',
341                memory_id TEXT NOT NULL DEFAULT '',
342                instance_name TEXT NOT NULL DEFAULT '',
343                working_directory TEXT NOT NULL DEFAULT '',
344                display_hidden INTEGER NOT NULL DEFAULT 0
345            );",
346        )
347        .unwrap();
348        for (id, name, started_at, working_directory, hidden) in rows {
349            conn.execute(
350                "INSERT INTO db_agent_instances
351                    (id, definition_id, instance_name, working_directory, started_at, created_at, display_hidden)
352                 VALUES (?1, 'claude-code', ?2, ?3, ?4, ?4, ?5)",
353                params![id, name, working_directory, started_at, if *hidden { 1_i64 } else { 0_i64 }],
354            )
355            .unwrap();
356        }
357    }
358
359    fn fresh_home() -> (tempfile::TempDir, Registry) {
360        let home = tempfile::tempdir().unwrap();
361        let reg = Registry::open(home.path().join("agents").join("registry")).unwrap();
362        (home, reg)
363    }
364
365    #[test]
366    fn migrate_with_no_versions_dir_writes_marker_and_no_rows() {
367        let (home, reg) = fresh_home();
368        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
369        assert_eq!(stats.versions_scanned, 0);
370        assert_eq!(stats.records_written, 0);
371        assert!(reg.root().join(MARKER).exists());
372    }
373
374    #[test]
375    fn migrate_is_idempotent() {
376        let (home, reg) = fresh_home();
377        // Empty home — marker gets written on first call.
378        migrate_from_sqlite_once(home.path(), &reg).unwrap();
379        // Add a version DB AFTER the marker — second run must NOT pick it up.
380        let agents_root = home.path().join("agents");
381        let v_dir = home.path().join("versions").join("0.33.821");
382        let wd = agents_root.join("demo-1");
383        std::fs::create_dir_all(&wd).unwrap();
384        make_version_db(
385            &v_dir,
386            &[("inst-1", "demo", 100, &wd.to_string_lossy())],
387        );
388        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
389        assert_eq!(
390            stats.records_written, 0,
391            "marker must short-circuit subsequent runs"
392        );
393        assert!(reg.list_active().unwrap().is_empty());
394    }
395
396    #[test]
397    fn migrate_writes_one_record_per_unique_id() {
398        let (home, reg) = fresh_home();
399        let agents_root = home.path().join("agents");
400        std::fs::create_dir_all(&agents_root).unwrap();
401        let wd_a = agents_root.join("demo-a");
402        let wd_b = agents_root.join("demo-b");
403        std::fs::create_dir_all(&wd_a).unwrap();
404        std::fs::create_dir_all(&wd_b).unwrap();
405        let v_dir = home.path().join("versions").join("0.33.821");
406        make_version_db(
407            &v_dir,
408            &[
409                ("inst-a", "demoA", 100, &wd_a.to_string_lossy()),
410                ("inst-b", "demoB", 200, &wd_b.to_string_lossy()),
411            ],
412        );
413
414        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
415        assert_eq!(stats.rows_seen, 2);
416        assert_eq!(stats.records_written, 2);
417        assert_eq!(reg.list_active().unwrap().len(), 2);
418    }
419
420    #[test]
421    fn migrate_picks_latest_started_at_on_dedup() {
422        let (home, reg) = fresh_home();
423        let agents_root = home.path().join("agents");
424        std::fs::create_dir_all(&agents_root).unwrap();
425        let wd = agents_root.join("demo");
426        std::fs::create_dir_all(&wd).unwrap();
427        // Same instance_id in two versions, different started_at.
428        let v1 = home.path().join("versions").join("0.33.800");
429        let v2 = home.path().join("versions").join("0.33.821");
430        make_version_db(&v1, &[("inst-1", "demo", 100, &wd.to_string_lossy())]);
431        make_version_db(&v2, &[("inst-1", "demo", 200, &wd.to_string_lossy())]);
432
433        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
434        assert_eq!(stats.rows_seen, 2);
435        assert_eq!(stats.records_written, 1);
436        let recs = reg.list_active().unwrap();
437        assert_eq!(recs.len(), 1);
438        assert_eq!(recs[0].data.last_launched_at_ms, 200);
439    }
440
441    #[test]
442    fn migrate_skips_when_registry_already_has_record() {
443        let (home, reg) = fresh_home();
444        let agents_root = home.path().join("agents");
445        std::fs::create_dir_all(&agents_root).unwrap();
446        let wd = agents_root.join("demo");
447        std::fs::create_dir_all(&wd).unwrap();
448        // Pre-existing registry record (e.g. PR A already wrote it).
449        reg.upsert(&NamedAgentRecord {
450            schema_version: MAX_SUPPORTED_SCHEMA,
451            data: NamedAgentRecordV1 {
452                instance_id: "inst-1".to_string(),
453                instance_name: "preexisting".to_string(),
454                definition_id: "claude-code".to_string(),
455                identity_id: None,
456                memory_id: None,
457                working_dir: "demo".to_string(),
458                created_at_ms: 50,
459                last_launched_at_ms: 500,
460                created_by_version: "0.33.823".to_string(),
461                last_launched_by_version: "0.33.823".to_string(),
462            },
463        })
464        .unwrap();
465        // Legacy SQLite row with the SAME instance_id but older data.
466        let v_dir = home.path().join("versions").join("0.33.821");
467        make_version_db(&v_dir, &[("inst-1", "legacyname", 100, &wd.to_string_lossy())]);
468
469        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
470        assert_eq!(stats.records_skipped_existing, 1);
471        assert_eq!(stats.records_written, 0);
472        // Pre-existing record stays — name is "preexisting", not "legacyname".
473        let recs = reg.list_active().unwrap();
474        assert_eq!(recs.len(), 1);
475        assert_eq!(recs[0].data.instance_name, "preexisting");
476    }
477
478    #[test]
479    fn migrate_skips_when_record_is_retired() {
480        // A user "Forgot" an agent (its registry file is in retired/).
481        // Another version's SQLite still has display_hidden=0 for that
482        // id. Migration must NOT resurrect the row into active/.
483        let (home, reg) = fresh_home();
484        let agents_root = home.path().join("agents");
485        std::fs::create_dir_all(&agents_root).unwrap();
486        let wd = agents_root.join("demo");
487        std::fs::create_dir_all(&wd).unwrap();
488
489        // Pre-tombstone a retired record.
490        let retired_record = NamedAgentRecord {
491            schema_version: MAX_SUPPORTED_SCHEMA,
492            data: NamedAgentRecordV1 {
493                instance_id: "inst-1".to_string(),
494                instance_name: "demo".to_string(),
495                definition_id: "claude-code".to_string(),
496                identity_id: None,
497                memory_id: None,
498                working_dir: "demo".to_string(),
499                created_at_ms: 50,
500                last_launched_at_ms: 50,
501                created_by_version: "0.33.823".to_string(),
502                last_launched_by_version: "0.33.823".to_string(),
503            },
504        };
505        reg.upsert(&retired_record).unwrap();
506        reg.retire("inst-1").unwrap();
507        assert!(reg.list_active().unwrap().is_empty());
508        assert!(reg.exists_anywhere("inst-1"));
509
510        // Legacy SQLite still has display_hidden=0 for the same id.
511        let v_dir = home.path().join("versions").join("0.33.821");
512        make_version_db(&v_dir, &[("inst-1", "demo", 100, &wd.to_string_lossy())]);
513
514        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
515        assert_eq!(stats.rows_seen, 1);
516        assert_eq!(stats.records_skipped_existing, 1);
517        assert_eq!(stats.records_written, 0);
518        // Tombstone must remain in retired/, NOT moved to active.
519        assert!(reg.list_active().unwrap().is_empty());
520        assert!(reg.root().join("retired").join("inst-1.json").exists());
521    }
522
523    #[test]
524    fn migrate_silently_skips_pre_v8_schema() {
525        // Real production case: an older SQLite (pre-v8) lacks the
526        // `instance_name` column. rusqlite reports this as
527        // `Error::SqlInputError` during prepare. The migrator must
528        // treat it as "nothing to migrate from this version" — NOT
529        // as a real DB failure that defers the marker.
530        let (home, reg) = fresh_home();
531        // Build a per-version DB with the OLD schema (no
532        // instance_name / working_directory / display_hidden cols).
533        let v_dir = home.path().join("versions").join("0.33.643");
534        let db_dir = v_dir.join("data").join("db");
535        std::fs::create_dir_all(&db_dir).unwrap();
536        let db_path = db_dir.join("objects.db");
537        let conn = Connection::open(&db_path).unwrap();
538        conn.execute_batch(
539            "CREATE TABLE db_agent_instances (
540                id TEXT PRIMARY KEY,
541                definition_id TEXT NOT NULL DEFAULT '',
542                parent_instance_id TEXT NOT NULL DEFAULT '',
543                started_at INTEGER NOT NULL DEFAULT 0,
544                created_at INTEGER NOT NULL DEFAULT 0
545            );",
546        )
547        .unwrap();
548        drop(conn);
549
550        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
551        assert_eq!(stats.versions_scanned, 1);
552        assert_eq!(stats.rows_seen, 0);
553        assert!(stats.complete, "pre-v8 schema must not block the marker");
554        assert!(reg.root().join(MARKER).exists());
555    }
556
557    #[test]
558    fn migrate_writes_legacy_hidden_row_as_tombstone() {
559        // Pre-registry "Forget agent" intent must survive migration:
560        // a single-version row with display_hidden=1 should land in
561        // retired/, not active/.
562        let (home, reg) = fresh_home();
563        let agents_root = home.path().join("agents");
564        std::fs::create_dir_all(&agents_root).unwrap();
565        let wd = agents_root.join("forgotten");
566        std::fs::create_dir_all(&wd).unwrap();
567        let v_dir = home.path().join("versions").join("0.33.821");
568        make_version_db_with_hidden(
569            &v_dir,
570            &[("inst-1", "forgotten", 100, &wd.to_string_lossy(), true)],
571        );
572
573        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
574        assert_eq!(stats.records_written, 1);
575        assert!(reg.list_active().unwrap().is_empty(),
576            "hidden legacy row must NOT appear active");
577        assert!(reg.root().join("retired").join("inst-1.json").exists(),
578            "hidden legacy row must be migrated as retired tombstone");
579    }
580
581    #[test]
582    fn migrate_preserves_forget_intent_across_versions() {
583        // Same id in two versions: one hides it (Forget), the other
584        // still has it visible. The "forget" must win — registry
585        // tombstone, not active record.
586        let (home, reg) = fresh_home();
587        let agents_root = home.path().join("agents");
588        std::fs::create_dir_all(&agents_root).unwrap();
589        let wd = agents_root.join("toggled");
590        std::fs::create_dir_all(&wd).unwrap();
591        let v1 = home.path().join("versions").join("0.33.800");
592        let v2 = home.path().join("versions").join("0.33.821");
593        // v1 has it visible; v2 has it hidden (user later Forgot it).
594        make_version_db_with_hidden(
595            &v1,
596            &[("inst-1", "toggled", 100, &wd.to_string_lossy(), false)],
597        );
598        make_version_db_with_hidden(
599            &v2,
600            &[("inst-1", "toggled", 200, &wd.to_string_lossy(), true)],
601        );
602
603        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
604        assert_eq!(stats.records_written, 1);
605        assert!(reg.list_active().unwrap().is_empty(),
606            "hidden intent in any version must propagate to registry tombstone");
607        assert!(reg.root().join("retired").join("inst-1.json").exists());
608    }
609
610    #[test]
611    fn migrate_defers_marker_on_unreadable_db() {
612        // A briefly-unreadable per-version DB during startup must NOT
613        // bake "permanently skip" into the marker. Marker is only
614        // written when every DB read succeeded.
615        let (home, reg) = fresh_home();
616        // Good DB.
617        let agents_root = home.path().join("agents");
618        std::fs::create_dir_all(&agents_root).unwrap();
619        let wd = agents_root.join("demo");
620        std::fs::create_dir_all(&wd).unwrap();
621        let good_v = home.path().join("versions").join("0.33.821");
622        make_version_db(&good_v, &[("inst-good", "demo", 100, &wd.to_string_lossy())]);
623        // Bad DB — looks like a SQLite file but is corrupt.
624        let bad_v = home.path().join("versions").join("0.33.800");
625        let bad_db_dir = bad_v.join("data").join("db");
626        std::fs::create_dir_all(&bad_db_dir).unwrap();
627        std::fs::write(bad_db_dir.join("objects.db"), b"not actually sqlite").unwrap();
628
629        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
630        assert_eq!(stats.records_written, 1, "good DB still migrated");
631        assert!(
632            !reg.root().join(MARKER).exists(),
633            "marker MUST NOT be written when any DB was unreadable"
634        );
635
636        // Next launch retries the migration; the good rows are
637        // idempotency-skipped (exists_anywhere), and the bad DB now
638        // works (simulate by replacing with a valid file pointing at
639        // the same `wd` so we don't need a second working-dir
640        // fixture).
641        std::fs::remove_file(bad_db_dir.join("objects.db")).unwrap();
642        make_version_db(&bad_v, &[("inst-other", "demo", 50, &wd.to_string_lossy())]);
643        let stats2 = migrate_from_sqlite_once(home.path(), &reg).unwrap();
644        assert!(stats2.complete, "complete flag set on clean retry");
645        assert!(
646            reg.root().join(MARKER).exists(),
647            "marker written on the retry once all DBs read successfully"
648        );
649        // Retry: 2 rows seen, 1 new written (inst-other), 1 skipped existing (inst-good).
650        assert_eq!(stats2.records_skipped_existing, 1);
651        assert_eq!(stats2.records_written, 1);
652    }
653
654    #[test]
655    fn migrate_skips_unmappable_working_dirs() {
656        let (home, reg) = fresh_home();
657        // Working dir is OUTSIDE the agents root — unmappable.
658        let v_dir = home.path().join("versions").join("0.33.821");
659        let outside = home.path().join("not_under_agents").join("foo");
660        make_version_db(&v_dir, &[("inst-x", "demo", 100, &outside.to_string_lossy())]);
661
662        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
663        assert_eq!(stats.rows_seen, 1);
664        assert_eq!(stats.records_skipped_unmappable, 1);
665        assert_eq!(stats.records_written, 0);
666    }
667
668    #[test]
669    fn migrate_tolerates_missing_or_corrupt_dbs() {
670        let (home, reg) = fresh_home();
671        // Version dir with no DB file.
672        std::fs::create_dir_all(home.path().join("versions").join("0.33.700")).unwrap();
673        // Version dir with corrupt DB.
674        let bad_v = home.path().join("versions").join("0.33.701");
675        let db_dir = bad_v.join("data").join("db");
676        std::fs::create_dir_all(&db_dir).unwrap();
677        std::fs::write(db_dir.join("objects.db"), b"not a sqlite file").unwrap();
678
679        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
680        // Both version dirs scanned; only one had a *file*, and it
681        // failed to read — no panic, no rows migrated. Marker is
682        // deferred so the next launch retries; see the dedicated
683        // `migrate_defers_marker_on_unreadable_db` test.
684        assert_eq!(stats.versions_scanned, 1);
685        assert_eq!(stats.records_written, 0);
686        assert!(
687            !reg.root().join(MARKER).exists(),
688            "marker deferred on unreadable DB"
689        );
690    }
691}