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 for named **instances**.
5//!
6//! Runs at most once per `<registry_root>/.migrated_from_sqlite` marker;
7//! idempotent and read-only on every SQLite it touches.
8//!
9//! P0.3 re-roots the registry to the GLOBAL `~/.agentmux/shared/agents/
10//! registry/`, so this scan is generalized from "the current channel's
11//! per-version DBs" to **every channel and every dev branch on the machine**:
12//!
13//! ```text
14//!   <home>/channels/<ch>/versions/<v>/data/db/objects.db   (installed/portable)
15//!   <home>/dev/<branch>[/<sub>]/data/db/objects.db         (dev)
16//! ```
17//!
18//! **Workspace anchoring.** Agent workspaces live GLOBALLY at
19//! `<home>/agents/<name>` (verified on disk: real `working_directory` values
20//! are `~/.agentmux/agents/<name>`, e.g. `…/agents/mazs-0527n`), independent of
21//! channel/version. [`row_to_record`] therefore strips each row's
22//! `working_directory` against the global `<home>/agents` root FIRST, then
23//! falls back to this row's own source-channel agents dir (`channels/<ch>/agents`,
24//! or `<instance_dir>/agents` for dev) for any legacy row that genuinely lived
25//! in-channel — each source DB still carries that per-source dir. The two
26//! subtrees are disjoint, so the fallback never mis-maps a global workspace.
27//! The chosen base is stored absolute in the record, so a reader in ANY channel
28//! round-trips `source_agents_base.join(working_dir)` back to the real
29//! workspace. See `docs/specs/SPEC_CROSS_CHANNEL_AGENT_PERSISTENCE_2026-06-13.md` §11.5.
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use rusqlite::{Connection, OpenFlags};
35
36use super::schema::{NamedAgentRecord, NamedAgentRecordV1, MAX_SUPPORTED_SCHEMA};
37use super::store::{Registry, RegistryError};
38
39/// Outcome stats — surfaced in the marker file + the srv log.
40#[derive(Debug, Default, Clone, Copy)]
41pub struct MigrateStats {
42    /// Number of per-(channel,version) / per-dev-branch `objects.db` files
43    /// scanned (was "versions" when the scan was single-channel).
44    pub dbs_scanned: usize,
45    /// DBs that existed but couldn't be read (corrupt / locked). Counted and
46    /// skipped — they must NOT disable the whole cross-channel registry now
47    /// that the scan spans every channel (codex P1 on #1389).
48    pub dbs_skipped: usize,
49    pub rows_seen: usize,
50    pub records_written: usize,
51    pub records_skipped_existing: usize,
52    pub records_skipped_unmappable: usize,
53    /// True iff every DB read cleanly. Controls only whether the one-shot
54    /// **marker** is written: on any skipped DB the marker is deferred so a
55    /// future launch retries that (possibly transiently-unreadable) DB. It
56    /// does NOT gate registry attachment — `main.rs` attaches whenever the
57    /// migration runs, so one bad DB in an unrelated channel can't disable
58    /// cross-channel My Agents (the readable records are served now; the live
59    /// mirror backfills the current channel regardless).
60    pub complete: bool,
61}
62
63/// Marker filename. Lives in the registry root so the registry's existence
64/// implies the migration question has been asked at least once.
65const MARKER: &str = ".migrated_from_sqlite";
66
67/// Bumped when the migration's mapping logic changes in a way that must re-run
68/// on registries an older build already finalized. **v2** fixes the workspace
69/// anchor: agent workspaces live globally at `<home>/agents/<name>`, but v1
70/// stripped `working_directory` against the per-channel `channels/<ch>/agents`
71/// dir, so every global workspace came back "unmappable" (`row_to_record`
72/// returned `None`) and "My Agents" stayed empty in every channel. A legacy
73/// marker (no `migration_version:` line) reads as 0 and re-runs exactly once;
74/// `exists_anywhere()` keeps the re-run from duplicating already-written
75/// records.
76const MIGRATION_VERSION: u32 = 2;
77
78/// A per-(channel,version) / per-dev-branch SQLite source, paired with this
79/// source's own agents dir — the per-channel **fallback** anchor `row_to_record`
80/// uses when a row's (normally global) `working_directory` isn't under the
81/// primary `<home>/agents` root.
82struct SqliteSource {
83    db_path: PathBuf,
84    agents_root: PathBuf,
85}
86
87/// Scan every channel + dev `objects.db` under `home` and populate the shared
88/// registry. Skipped if the marker file exists. Never overwrites an existing
89/// registry record (idempotency + respect for newer-written data). The SQLite
90/// files are opened **read-only** — never modified.
91///
92/// On dedup conflicts (same `instance_id` in multiple versions/channels), the
93/// row with the latest `started_at` wins; any version expressing "forget"
94/// intent (`display_hidden`) is preserved as a tombstone.
95pub fn migrate_from_sqlite_once(
96    home: &Path,
97    registry: &Registry,
98) -> Result<MigrateStats, RegistryError> {
99    let marker_path = registry.root().join(MARKER);
100    if marker_migration_version(&marker_path) >= MIGRATION_VERSION {
101        // A prior run AT THE CURRENT logic version completed; treat as complete
102        // so callers attach the registry. An older-version (or legacy,
103        // no-version) marker falls through and re-runs once — `exists_anywhere`
104        // below keeps it from duplicating records already written.
105        return Ok(MigrateStats {
106            complete: true,
107            ..MigrateStats::default()
108        });
109    }
110
111    let mut stats = MigrateStats::default();
112    // Agent workspaces are created GLOBALLY at `<home>/agents/<name>` — verified
113    // on disk: instance `working_directory` values are `~/.agentmux/agents/<name>`
114    // (e.g. `…/agents/mazs-0527n`), NOT under any per-channel `channels/<ch>/agents`
115    // dir, and NOT under the P0.3-re-rooted registry's parent (`<home>/shared/
116    // agents`). `home` is `~/.agentmux` (main.rs derives it as the registry root's
117    // 3rd ancestor: registry → agents → shared → home). Anchor migrated records on
118    // this real workspace root so the reader (`agent_handlers.rs` reconstructs
119    // `source_agents_base.join(working_dir)`) resolves the actual workspace in
120    // EVERY channel. NB this deliberately differs from the live mirror, which
121    // strips against the per-channel `AGENTMUX_AGENTS_DIR` — that anchor never
122    // matched these global workspaces (an earlier draft of this fix wrongly used
123    // the registry's parent and would have left every row unmappable).
124    let global_agents_root = home.join("agents");
125    let (sources, enum_incomplete) = enumerate_sources(home);
126
127    let mut latest_by_id: HashMap<String, RowSnapshot> = HashMap::new();
128    // True iff any DB threw a non-transient-looking error OR a directory that
129    // should have been enumerable was unreadable. We use this to skip writing
130    // the marker so the next launch retries — otherwise a brief filesystem
131    // hiccup permanently omits those rows from the registry-backed dropdown.
132    let mut any_db_failed = enum_incomplete;
133
134    for src in sources {
135        stats.dbs_scanned += 1;
136        match read_named_rows(&src.db_path, &src.agents_root) {
137            Ok(rows) => {
138                for row in rows {
139                    stats.rows_seen += 1;
140                    let key = row.id.clone();
141                    match latest_by_id.get_mut(&key) {
142                        Some(existing) if existing.started_at >= row.started_at => {
143                            // Existing snapshot wins on started_at, but OR the
144                            // hidden flag — any version expressing "forget"
145                            // intent is preserved as a tombstone.
146                            existing.display_hidden =
147                                existing.display_hidden || row.display_hidden;
148                        }
149                        Some(existing) => {
150                            let merged_hidden =
151                                existing.display_hidden || row.display_hidden;
152                            *existing = row;
153                            existing.display_hidden = merged_hidden;
154                        }
155                        None => {
156                            latest_by_id.insert(key, row);
157                        }
158                    }
159                }
160            }
161            Err(e) => {
162                tracing::warn!(
163                    db = %src.db_path.display(),
164                    error = %e,
165                    "registry-migrate: DB unreadable — skipping this source; registry still attaches, marker deferred to retry"
166                );
167                stats.dbs_skipped += 1;
168                any_db_failed = true;
169            }
170        }
171    }
172
173    for (id, row) in latest_by_id {
174        // Check active AND retired — a record retired by a newer version's
175        // "Forget agent" must NOT be resurrected just because an older
176        // version's SQLite still lists it as visible.
177        if registry.exists_anywhere(&id) {
178            stats.records_skipped_existing += 1;
179            continue;
180        }
181        let display_hidden = row.display_hidden;
182        let Some(rec) = row_to_record(&row, &global_agents_root) else {
183            stats.records_skipped_unmappable += 1;
184            continue;
185        };
186        if let Err(e) = registry.upsert(&rec) {
187            tracing::warn!(
188                instance_id = %id,
189                error = %e,
190                "registry-migrate: upsert failed"
191            );
192            stats.records_skipped_unmappable += 1;
193            continue;
194        }
195        // Preserve pre-registry "forget" intent: if any version's SQLite had
196        // this row hidden, move the freshly-written registry file to retired/
197        // so the dropdown stays consistent with the user's prior soft-delete.
198        if display_hidden {
199            if let Err(e) = registry.retire(&id) {
200                tracing::warn!(
201                    instance_id = %id,
202                    error = %e,
203                    "registry-migrate: failed to retire migrated tombstone — record may surface as active"
204                );
205            }
206        }
207        stats.records_written += 1;
208    }
209
210    // `complete` is true only when every DB we encountered was readable. It
211    // gates ONLY the one-shot marker: on any skipped DB the marker is deferred
212    // so a future launch retries that source. It does NOT detach the registry —
213    // main.rs attaches whenever the migration returns Ok and serves the records
214    // that did read (codex P1 on #1389); see the field doc above.
215    stats.complete = !any_db_failed;
216    if stats.complete {
217        write_marker(&marker_path, &stats)?;
218    } else {
219        tracing::info!(
220            "registry-migrate: deferring marker write; one or more DBs were unreadable and will be retried next launch"
221        );
222    }
223    Ok(stats)
224}
225
226/// Marker for the one-shot `source_agents_base` backfill. Separate from
227/// [`MARKER`] so it runs exactly once even on registries the main migration
228/// already finalized before schema v3 existed.
229const SOURCE_BACKFILL_MARKER: &str = ".backfilled_source_bases";
230
231/// Outcome of [`backfill_source_bases_once`].
232#[derive(Debug, Default, Clone, Copy)]
233pub struct SourceBackfillStats {
234    pub dbs_scanned: usize,
235    pub records_updated: usize,
236    /// Records that lacked a source base but whose source DB wasn't found
237    /// (its channel was deleted) — left as-is; a relaunch's live mirror
238    /// backfills them.
239    pub records_unresolved: usize,
240    pub complete: bool,
241}
242
243/// One-shot backfill of `source_agents_base` onto registry records written
244/// **before** schema v3 — i.e. by P0.3b's global migration (#1389) or any
245/// pre-P0.4 live mirror.
246///
247/// The main [`migrate_from_sqlite_once`] short-circuits on `.migrated_from_sqlite`,
248/// so an already-migrated registry never re-runs `row_to_record` and its
249/// records keep `source_agents_base: None`. A cross-channel `listnamedagents`
250/// read then re-joins `working_dir` under the READER's own channel and resolves
251/// the wrong workspace / `--resume` cwd. This pass re-derives each record's
252/// source channel from the SQLite sources and sets **only** `source_agents_base`,
253/// preserving every live-mirror-enriched field (session_id, identity,
254/// timestamps) — it never blind-upserts the SQLite-derived record. Guarded by
255/// its own marker; idempotent; read-only on SQLite.
256pub fn backfill_source_bases_once(
257    home: &Path,
258    registry: &Registry,
259) -> Result<SourceBackfillStats, RegistryError> {
260    let marker = registry.root().join(SOURCE_BACKFILL_MARKER);
261    if marker.exists() {
262        return Ok(SourceBackfillStats {
263            complete: true,
264            ..Default::default()
265        });
266    }
267
268    let mut stats = SourceBackfillStats::default();
269
270    // Active records still missing a source base, keyed by id. We mutate these
271    // snapshots in place and re-upsert, so the existing session_id/identity/
272    // timestamps survive. (Retired/forgotten records are out of scope — a
273    // relaunch re-mirrors them with the current channel base.)
274    let mut pending: HashMap<String, NamedAgentRecord> = registry
275        .list_active()?
276        .into_iter()
277        .filter(|r| r.data.source_agents_base.is_none())
278        .map(|r| (r.data.instance_id.clone(), r))
279        .collect();
280
281    if pending.is_empty() {
282        // Fresh registry, or every record is already v3 — nothing to do.
283        std::fs::write(&marker, b"backfilled: 0\n")?;
284        stats.complete = true;
285        return Ok(stats);
286    }
287
288    let (sources, mut incomplete) = enumerate_sources(home);
289
290    // Dedup EXACTLY like migrate_from_sqlite_once: for an id present in more
291    // than one DB the latest-`started_at` row wins. These targets are pre-v3
292    // (base-less) records whose `working_dir` was stripped against a PER-CHANNEL
293    // dir by P0.3b / a pre-P0.4 live mirror, so re-anchor on the winning row's
294    // own channel — not whichever DB read_dir happened to return first
295    // (codex/reagent P2). (The main migration now strips global-first, but it
296    // always SETS a base, so its records are never base-less and never reach
297    // this backfill.)
298    let mut winners: HashMap<String, (i64, PathBuf)> = HashMap::new();
299    for src in sources {
300        stats.dbs_scanned += 1;
301        match read_named_rows(&src.db_path, &src.agents_root) {
302            Ok(rows) => {
303                for row in rows {
304                    if !pending.contains_key(&row.id) {
305                        continue;
306                    }
307                    match winners.get(&row.id) {
308                        Some((ts, _)) if *ts >= row.started_at => {}
309                        _ => {
310                            winners.insert(row.id.clone(), (row.started_at, row.agents_root));
311                        }
312                    }
313                }
314            }
315            Err(e) => {
316                tracing::warn!(
317                    db = %src.db_path.display(),
318                    error = %e,
319                    "source-base backfill: DB unreadable — will retry next launch"
320                );
321                incomplete = true;
322            }
323        }
324    }
325
326    // Apply the winning channel's agents dir to each record (only the source
327    // base; everything else is preserved from the existing record).
328    for (id, (_, agents_root)) in winners {
329        let Some(mut rec) = pending.remove(&id) else {
330            continue;
331        };
332        rec.data.source_agents_base = Some(agents_root.to_string_lossy().to_string());
333        rec.schema_version = rec.data.min_schema_version();
334        if let Err(e) = registry.upsert(&rec) {
335            tracing::warn!(
336                instance_id = %id,
337                error = %e,
338                "source-base backfill: upsert failed — will retry next launch"
339            );
340            pending.insert(id, rec);
341            incomplete = true;
342        } else {
343            stats.records_updated += 1;
344        }
345    }
346
347    // Records still pending have no locatable source DB (channel removed); a
348    // relaunch's live mirror backfills them. Count but don't block.
349    stats.records_unresolved = pending.len();
350
351    // Only finalize when every DB read cleanly — otherwise a transient failure
352    // would permanently strand records that DO have a readable source DB.
353    stats.complete = !incomplete;
354    if stats.complete {
355        std::fs::write(
356            &marker,
357            format!(
358                "backfilled: {}\nunresolved: {}\n",
359                stats.records_updated, stats.records_unresolved
360            ),
361        )?;
362    }
363    Ok(stats)
364}
365
366/// Enumerate every per-(channel,version) and per-dev-branch `objects.db` under
367/// `home`, pairing each with its own agents dir (the per-source fallback anchor;
368/// the primary anchor in `row_to_record` is the global `<home>/agents`).
369///
370/// Returns `(sources, incomplete)`. `incomplete` is true if any directory that
371/// *should* be enumerable failed to read for a reason other than "doesn't
372/// exist" (permissions, a transient FS/network-home hiccup, a non-dir in the
373/// way). The caller treats that like an unreadable DB and DEFERS the one-shot
374/// marker so a future launch retries — otherwise a transiently-unreadable
375/// `versions/` (or `dev/`) would silently finalize the migration and omit that
376/// tree's named agents forever (codex P2 on #1389).
377fn enumerate_sources(home: &Path) -> (Vec<SqliteSource>, bool) {
378    let mut out = Vec::new();
379    let mut incomplete = false;
380
381    // Installed/portable: home/channels/<ch>/versions/<v>/data/db/objects.db,
382    // paired with home/channels/<ch>/agents as the per-source FALLBACK anchor
383    // (the primary anchor in row_to_record is the global <home>/agents).
384    if let Some(rd) = read_dir_tracking(&home.join("channels"), &mut incomplete) {
385        for ch in rd.flatten() {
386            let ch_dir = ch.path();
387            if !ch_dir.is_dir() {
388                continue;
389            }
390            let agents_root = ch_dir.join("agents");
391            if let Some(vrd) = read_dir_tracking(&ch_dir.join("versions"), &mut incomplete) {
392                for v in vrd.flatten() {
393                    let db = v.path().join("data").join("db").join("objects.db");
394                    if db.is_file() {
395                        out.push(SqliteSource {
396                            db_path: db,
397                            agents_root: agents_root.clone(),
398                        });
399                    }
400                }
401            }
402        }
403    }
404
405    // Dev: home/dev/<branch>[/<sub>]/data/db/objects.db, paired with
406    // <instance_dir>/agents (instance_dir = the dir that holds `data`) as the
407    // per-source FALLBACK anchor.
408    collect_dev_sources(&home.join("dev"), &mut out, &mut incomplete);
409
410    (out, incomplete)
411}
412
413/// `read_dir` that distinguishes "absent" (fine — nothing to scan) from
414/// "present but unreadable" (sets `incomplete` so the marker defers). Returns
415/// `None` in both error cases; the iterator otherwise.
416fn read_dir_tracking(path: &Path, incomplete: &mut bool) -> Option<std::fs::ReadDir> {
417    match std::fs::read_dir(path) {
418        Ok(rd) => Some(rd),
419        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
420        Err(_) => {
421            *incomplete = true;
422            None
423        }
424    }
425}
426
427/// Locate dev instance dirs (those with `data/db/objects.db`) and anchor each
428/// on its sibling `agents/` dir. The dev layout is at most two levels under
429/// `dev/`: `dev/<branch>/data/...` (older single-level layout) or
430/// `dev/<branch>/<sub-hash>/data/...`. We check exactly those depths and never
431/// descend into an instance's own subdirs — so an agent workspace
432/// (`<instance>/agents/<slug>/`) that holds a nested AgentMux `objects.db` is
433/// never mistaken for a source (reagent P2), and a dev branch whose slug
434/// happens to equal an internal dir name like `data`/`agents` is still scanned
435/// (no name-based skip-list — codex P2).
436fn collect_dev_sources(dev_root: &Path, out: &mut Vec<SqliteSource>, incomplete: &mut bool) {
437    let Some(branches) = read_dir_tracking(dev_root, incomplete) else {
438        return;
439    };
440    for branch in branches.flatten() {
441        let bdir = branch.path();
442        if !bdir.is_dir() {
443            continue;
444        }
445        // Depth 1: the branch dir is itself the instance dir (older layout).
446        // If so it is a leaf — do NOT descend into its agents/ etc.
447        if push_if_instance(&bdir, out) {
448            continue;
449        }
450        // Depth 2: each child (the sub-hash dir) may be the instance dir.
451        if let Some(subs) = read_dir_tracking(&bdir, incomplete) {
452            for sub in subs.flatten() {
453                let sdir = sub.path();
454                if sdir.is_dir() {
455                    push_if_instance(&sdir, out);
456                }
457            }
458        }
459    }
460}
461
462/// Push `dir` as a source iff it holds `data/db/objects.db`. Returns whether it
463/// did (so the caller can treat an instance dir as a leaf).
464fn push_if_instance(dir: &Path, out: &mut Vec<SqliteSource>) -> bool {
465    let db = dir.join("data").join("db").join("objects.db");
466    if db.is_file() {
467        out.push(SqliteSource {
468            db_path: db,
469            agents_root: dir.join("agents"),
470        });
471        true
472    } else {
473        false
474    }
475}
476
477/// Read the `migration_version:` line from an existing marker. Returns 0 when
478/// the marker is absent, unreadable, or predates versioning (a legacy
479/// stats-only marker has no such line) — so a logic bump, or any pre-versioning
480/// marker, re-runs the migration exactly once.
481fn marker_migration_version(path: &Path) -> u32 {
482    let Ok(body) = std::fs::read_to_string(path) else {
483        return 0;
484    };
485    for line in body.lines() {
486        if let Some(v) = line.strip_prefix("migration_version:") {
487            return v.trim().parse().unwrap_or(0);
488        }
489    }
490    0
491}
492
493fn write_marker(path: &Path, stats: &MigrateStats) -> std::io::Result<()> {
494    let now = chrono::Utc::now().to_rfc3339();
495    let body = format!(
496        "migration_version: {MIGRATION_VERSION}\n\
497         migrated_at: {now}\n\
498         dbs_scanned: {}\n\
499         dbs_skipped: {}\n\
500         rows_seen: {}\n\
501         records_written: {}\n\
502         records_skipped_existing: {}\n\
503         records_skipped_unmappable: {}\n",
504        stats.dbs_scanned,
505        stats.dbs_skipped,
506        stats.rows_seen,
507        stats.records_written,
508        stats.records_skipped_existing,
509        stats.records_skipped_unmappable,
510    );
511    std::fs::write(path, body)
512}
513
514struct RowSnapshot {
515    id: String,
516    instance_name: String,
517    definition_id: String,
518    identity_id: String,
519    memory_id: String,
520    working_directory: String,
521    /// This row's own source-channel agents dir (`channels/<ch>/agents`, or
522    /// `<instance_dir>/agents` for dev) — the **fallback** anchor.
523    /// `row_to_record` strips `working_directory` against the global
524    /// `<home>/agents` root first and uses this only for a legacy row that
525    /// genuinely lived in-channel. Travels with the row through dedup so any
526    /// such fallback strips against the right channel.
527    agents_root: PathBuf,
528    started_at: i64,
529    created_at: i64,
530    display_hidden: bool,
531}
532
533/// True iff the error is SQLite reporting "this column/table doesn't exist in
534/// this DB's schema." Distinguishes a pre-v8 DB (skip silently — those agents
535/// weren't named, so wouldn't appear in the dropdown anyway) from corruption
536/// (caller logs + continues + defers the marker).
537///
538/// rusqlite reports prepare-time schema mismatches as
539/// `Error::SqlInputError { msg, sql, offset }` and runtime errors as
540/// `Error::SqliteFailure(_, Some(msg))`. Both shapes carry the canonical
541/// SQLite phrases, but only message inspection distinguishes them from other
542/// failures with the same error code.
543fn is_missing_column_or_table(e: &rusqlite::Error) -> bool {
544    let msg = match e {
545        rusqlite::Error::SqliteFailure(_, Some(msg)) => msg.as_str(),
546        rusqlite::Error::SqlInputError { msg, .. } => msg.as_str(),
547        _ => return false,
548    };
549    msg.starts_with("no such column") || msg.starts_with("no such table")
550}
551
552fn read_named_rows(
553    db_path: &Path,
554    agents_root: &Path,
555) -> Result<Vec<RowSnapshot>, rusqlite::Error> {
556    let conn = Connection::open_with_flags(
557        db_path,
558        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
559    )?;
560    // Older schemas (pre-v8) lack `instance_name` / `working_directory`
561    // columns. Suppress ONLY the specific "no such column/table" errors —
562    // broader SqliteFailures (corruption, locked, etc.) must surface so the
563    // caller can log + continue with the next DB. Include hidden rows — the
564    // caller turns them into retired/ tombstones so a pre-registry "Forget
565    // agent" intent survives migration even if another version still has the
566    // row visible.
567    let mut stmt = match conn.prepare(
568        "SELECT id, instance_name, definition_id, identity_id, memory_id,
569                working_directory, started_at, created_at, display_hidden
570         FROM db_agent_instances
571         WHERE instance_name <> ''
572           AND parent_instance_id = ''",
573    ) {
574        Ok(s) => s,
575        Err(e) if is_missing_column_or_table(&e) => return Ok(Vec::new()),
576        Err(e) => return Err(e),
577    };
578    let iter = stmt.query_map([], |row| {
579        Ok(RowSnapshot {
580            id: row.get(0)?,
581            instance_name: row.get(1)?,
582            definition_id: row.get(2)?,
583            identity_id: row.get(3)?,
584            memory_id: row.get(4)?,
585            working_directory: row.get(5)?,
586            agents_root: agents_root.to_path_buf(),
587            started_at: row.get(6)?,
588            created_at: row.get(7)?,
589            display_hidden: row.get::<_, i64>(8)? != 0,
590        })
591    })?;
592    iter.collect()
593}
594
595fn row_to_record(row: &RowSnapshot, global_agents_root: &Path) -> Option<NamedAgentRecord> {
596    let abs = std::path::Path::new(&row.working_directory);
597    // Agent workspaces live GLOBALLY at `<home>/agents/<name>` (verified: real
598    // `working_directory` values are `~/.agentmux/agents/<name>`), so anchor on
599    // that global workspace root — `base` is stored absolute in the record, so
600    // the reader reconstructs `base.join(working_dir)` correctly in any channel.
601    // Fall back to THIS row's own source-channel agents dir for any legacy row
602    // whose workspace genuinely lived in-channel (`channels/<ch>/agents`). A
603    // workspace under NEITHER root is skipped (e.g. a user cwd like
604    // `~/projects/foo`), matching the live mirror's relative_workdir.
605    let (rel, base): (&Path, &Path) = abs
606        .strip_prefix(global_agents_root)
607        .ok()
608        .map(|r| (r, global_agents_root))
609        .or_else(|| {
610            abs.strip_prefix(row.agents_root.as_path())
611                .ok()
612                .map(|r| (r, row.agents_root.as_path()))
613        })?;
614    let rel_str = rel.to_string_lossy().to_string();
615    if rel_str.is_empty() || rel_str == "." {
616        return None;
617    }
618    let data = NamedAgentRecordV1 {
619        instance_id: row.id.clone(),
620        instance_name: row.instance_name.clone(),
621        definition_id: row.definition_id.clone(),
622        identity_id: empty_to_none(&row.identity_id),
623        memory_id: empty_to_none(&row.memory_id),
624        // The legacy rows don't carry session_id through this consolidation
625        // path; live mirroring (registry_mirror.rs) populates it on the next
626        // launch/update. The record is still stamped v3 because
627        // source_agents_base is set below — so a pre-v3 reader skips it (the
628        // only such readers of the global registry are pre-P0.4 builds).
629        session_id: None,
630        working_dir: rel_str,
631        // v3: anchor on the global agents root (or, for a legacy in-channel
632        // workspace, that channel's agents dir) so a reader in ANY channel
633        // reconstructs the absolute working_directory correctly (P0.4), not by
634        // re-joining under its own channel's agents dir.
635        source_agents_base: Some(base.to_string_lossy().to_string()),
636        created_at_ms: row.created_at,
637        last_launched_at_ms: row.started_at,
638        // We don't know what version originally inserted these rows. Tag them
639        // so post-migration audits can tell. The migration never overwrites a
640        // record (exists_anywhere skip) so these stay.
641        created_by_version: "(legacy)".to_string(),
642        last_launched_by_version: "(legacy)".to_string(),
643    };
644    Some(NamedAgentRecord {
645        schema_version: data.min_schema_version(),
646        data,
647    })
648}
649
650fn empty_to_none(s: &str) -> Option<String> {
651    if s.is_empty() {
652        None
653    } else {
654        Some(s.to_string())
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use rusqlite::params;
662
663    /// Build a per-version SQLite at `<version_dir>/data/db/objects.db` with a
664    /// minimal `db_agent_instances` schema and the given rows.
665    fn make_db_at(version_dir: &Path, rows: &[(&str, &str, i64, &str, bool)]) {
666        let db_dir = version_dir.join("data").join("db");
667        std::fs::create_dir_all(&db_dir).unwrap();
668        let conn = Connection::open(db_dir.join("objects.db")).unwrap();
669        conn.execute_batch(
670            "CREATE TABLE db_agent_instances (
671                id TEXT PRIMARY KEY,
672                definition_id TEXT NOT NULL DEFAULT '',
673                parent_instance_id TEXT NOT NULL DEFAULT '',
674                block_id TEXT NOT NULL DEFAULT '',
675                session_id TEXT NOT NULL DEFAULT '',
676                status TEXT NOT NULL DEFAULT 'running',
677                github_context TEXT NOT NULL DEFAULT '',
678                started_at INTEGER NOT NULL DEFAULT 0,
679                ended_at INTEGER NOT NULL DEFAULT 0,
680                created_at INTEGER NOT NULL DEFAULT 0,
681                identity_id TEXT NOT NULL DEFAULT '',
682                memory_id TEXT NOT NULL DEFAULT '',
683                instance_name TEXT NOT NULL DEFAULT '',
684                working_directory TEXT NOT NULL DEFAULT '',
685                display_hidden INTEGER NOT NULL DEFAULT 0
686            );",
687        )
688        .unwrap();
689        for (id, name, started_at, working_directory, hidden) in rows {
690            conn.execute(
691                "INSERT INTO db_agent_instances
692                    (id, definition_id, instance_name, working_directory, started_at, created_at, display_hidden)
693                 VALUES (?1, 'claude-code', ?2, ?3, ?4, ?4, ?5)",
694                params![id, name, working_directory, started_at, if *hidden { 1_i64 } else { 0_i64 }],
695            )
696            .unwrap();
697        }
698    }
699
700    /// The agents dir for a channel under `home`.
701    fn channel_agents(home: &Path, channel: &str) -> PathBuf {
702        home.join("channels").join(channel).join("agents")
703    }
704
705    /// Build a channel/version DB with the given (id, name, started_at, wd) rows.
706    fn make_channel_db(
707        home: &Path,
708        channel: &str,
709        version: &str,
710        rows: &[(&str, &str, i64, &str)],
711    ) {
712        let rows: Vec<_> = rows.iter().map(|(a, b, c, d)| (*a, *b, *c, *d, false)).collect();
713        let v_dir = home
714            .join("channels")
715            .join(channel)
716            .join("versions")
717            .join(version);
718        make_db_at(&v_dir, &rows);
719    }
720
721    fn make_channel_db_with_hidden(
722        home: &Path,
723        channel: &str,
724        version: &str,
725        rows: &[(&str, &str, i64, &str, bool)],
726    ) {
727        let v_dir = home
728            .join("channels")
729            .join(channel)
730            .join("versions")
731            .join(version);
732        make_db_at(&v_dir, rows);
733    }
734
735    fn fresh_home() -> (tempfile::TempDir, Registry) {
736        let home = tempfile::tempdir().unwrap();
737        // Registry rooted at the GLOBAL shared location, mirroring production.
738        let reg = Registry::open(
739            home.path()
740                .join("shared")
741                .join("agents")
742                .join("registry"),
743        )
744        .unwrap();
745        (home, reg)
746    }
747
748    #[test]
749    fn migrate_with_no_channels_writes_marker_and_no_rows() {
750        let (home, reg) = fresh_home();
751        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
752        assert_eq!(stats.dbs_scanned, 0);
753        assert_eq!(stats.records_written, 0);
754        assert!(reg.root().join(MARKER).exists());
755    }
756
757    #[test]
758    fn migrate_is_idempotent() {
759        let (home, reg) = fresh_home();
760        // Empty home — marker gets written on first call.
761        migrate_from_sqlite_once(home.path(), &reg).unwrap();
762        // Add a channel DB AFTER the marker — second run must NOT pick it up.
763        let wd = channel_agents(home.path(), "stable").join("demo-1");
764        std::fs::create_dir_all(&wd).unwrap();
765        make_channel_db(
766            home.path(),
767            "stable",
768            "0.33.821",
769            &[("inst-1", "demo", 100, &wd.to_string_lossy())],
770        );
771        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
772        assert_eq!(
773            stats.records_written, 0,
774            "marker must short-circuit subsequent runs"
775        );
776        assert!(reg.list_active().unwrap().is_empty());
777    }
778
779    #[test]
780    fn migrate_writes_one_record_per_unique_id() {
781        let (home, reg) = fresh_home();
782        let agents = channel_agents(home.path(), "stable");
783        let wd_a = agents.join("demo-a");
784        let wd_b = agents.join("demo-b");
785        std::fs::create_dir_all(&wd_a).unwrap();
786        std::fs::create_dir_all(&wd_b).unwrap();
787        make_channel_db(
788            home.path(),
789            "stable",
790            "0.33.821",
791            &[
792                ("inst-a", "demoA", 100, &wd_a.to_string_lossy()),
793                ("inst-b", "demoB", 200, &wd_b.to_string_lossy()),
794            ],
795        );
796
797        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
798        assert_eq!(stats.rows_seen, 2);
799        assert_eq!(stats.records_written, 2);
800        assert_eq!(reg.list_active().unwrap().len(), 2);
801    }
802
803    #[test]
804    fn migrate_anchors_each_row_on_its_own_channel() {
805        // Per-channel FALLBACK path: two agents in two DIFFERENT channels, each
806        // working_directory absolute under its OWN channel's agents dir and NOT
807        // under the global `<home>/agents` root. `row_to_record` falls back to
808        // each row's own source-channel agents dir, so both map with the correct
809        // relative slug — neither dropped as "unmappable" because the other
810        // channel's agents root differs.
811        let (home, reg) = fresh_home();
812        let wd_a = channel_agents(home.path(), "stable").join("alpha");
813        let wd_b = channel_agents(home.path(), "local-main-b28b7a").join("beta");
814        std::fs::create_dir_all(&wd_a).unwrap();
815        std::fs::create_dir_all(&wd_b).unwrap();
816        make_channel_db(
817            home.path(),
818            "stable",
819            "0.44.2",
820            &[("inst-a", "Alpha", 100, &wd_a.to_string_lossy())],
821        );
822        make_channel_db(
823            home.path(),
824            "local-main-b28b7a",
825            "0.44.2",
826            &[("inst-b", "Beta", 200, &wd_b.to_string_lossy())],
827        );
828
829        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
830        assert_eq!(stats.dbs_scanned, 2);
831        assert_eq!(stats.rows_seen, 2);
832        assert_eq!(stats.records_skipped_unmappable, 0, "per-channel anchoring");
833        assert_eq!(stats.records_written, 2);
834        let mut recs = reg.list_active().unwrap();
835        recs.sort_by(|a, b| a.data.instance_id.cmp(&b.data.instance_id));
836        assert_eq!(recs[0].data.working_dir, "alpha");
837        assert_eq!(recs[1].data.working_dir, "beta");
838        // P0.4: each record records its OWN source channel agents base, so a
839        // reader in any channel reconstructs the absolute path against the
840        // right channel (not its own). The record is therefore schema v3.
841        assert_eq!(
842            recs[0].data.source_agents_base.as_deref(),
843            Some(channel_agents(home.path(), "stable").to_string_lossy().as_ref())
844        );
845        assert_eq!(
846            recs[1].data.source_agents_base.as_deref(),
847            Some(
848                channel_agents(home.path(), "local-main-b28b7a")
849                    .to_string_lossy()
850                    .as_ref()
851            )
852        );
853        assert_eq!(recs[0].schema_version, 3);
854    }
855
856    #[test]
857    fn migrate_anchors_global_workspace_not_per_channel() {
858        // Production reality (verified on disk): agent workspaces live at the
859        // GLOBAL `<home>/agents/<name>` — e.g. `~/.agentmux/agents/qooma-0612g`,
860        // NOT under any channel's `channels/<ch>/agents` dir, and NOT under the
861        // re-rooted registry's parent (`<home>/shared/agents`). v1 stripped
862        // against `channels/<ch>/agents` and dropped every such row as
863        // "unmappable" → "My Agents" empty everywhere. The fix anchors on
864        // `<home>/agents`, so the row migrates and reconstructs in any channel.
865        let (home, reg) = fresh_home();
866        let global_agents = home.path().join("agents"); // the REAL workspace root
867        // Guard against the earlier wrong fix: the workspace root is NOT the
868        // registry's parent (here `<home>/shared/agents`). If someone re-anchors
869        // on `registry.root().parent()`, this row goes unmappable and the asserts
870        // below fail.
871        assert_ne!(
872            global_agents.as_path(),
873            reg.root().parent().unwrap(),
874            "workspace root must differ from the re-rooted registry's parent"
875        );
876        let wd = global_agents.join("qooma-0612g");
877        std::fs::create_dir_all(&wd).unwrap();
878        // The row lives in a CHANNEL's SQLite, but its working_directory points
879        // at the GLOBAL workspace — the actual on-disk shape.
880        make_channel_db(
881            home.path(),
882            "stable",
883            "0.44.2",
884            &[("inst-q", "Qooma", 100, &wd.to_string_lossy())],
885        );
886
887        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
888        assert_eq!(stats.rows_seen, 1);
889        assert_eq!(
890            stats.records_skipped_unmappable, 0,
891            "a global workspace must NOT be unmappable"
892        );
893        assert_eq!(stats.records_written, 1);
894        let recs = reg.list_active().unwrap();
895        assert_eq!(recs.len(), 1);
896        assert_eq!(recs[0].data.working_dir, "qooma-0612g");
897        assert_eq!(
898            recs[0].data.source_agents_base.as_deref(),
899            Some(global_agents.to_string_lossy().as_ref()),
900            "anchored on the GLOBAL workspace root <home>/agents, not the channel"
901        );
902        assert_eq!(recs[0].schema_version, 3);
903    }
904
905    #[test]
906    fn migrate_legacy_marker_reruns_then_settles() {
907        // A v1 build left a stats-only marker (no `migration_version:` line)
908        // after writing 0 records for a global workspace it judged unmappable.
909        // The fixed build must read that marker as version 0, re-run once, and
910        // capture the row — then settle (a second run is a no-op).
911        let (home, reg) = fresh_home();
912        let global_agents = home.path().join("agents"); // the REAL workspace root
913        let wd = global_agents.join("naki");
914        std::fs::create_dir_all(&wd).unwrap();
915        make_channel_db(
916            home.path(),
917            "stable",
918            "0.44.2",
919            &[("inst-n", "Naki", 100, &wd.to_string_lossy())],
920        );
921        // Simulate the legacy finalized marker: stats-only, no version line.
922        std::fs::write(
923            reg.root().join(MARKER),
924            b"migrated_at: 2026-06-10T00:00:00Z\nrecords_written: 0\n",
925        )
926        .unwrap();
927
928        // Re-run: legacy marker → version 0 < MIGRATION_VERSION → re-runs.
929        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
930        assert_eq!(
931            stats.records_written, 1,
932            "legacy marker must trigger a one-time re-run"
933        );
934        assert_eq!(reg.list_active().unwrap().len(), 1);
935        assert_eq!(
936            marker_migration_version(&reg.root().join(MARKER)),
937            MIGRATION_VERSION,
938            "marker upgraded to the current version"
939        );
940
941        // Settle: second run sees the current-version marker → no-op.
942        let again = migrate_from_sqlite_once(home.path(), &reg).unwrap();
943        assert_eq!(again.records_written, 0, "settles after the single re-run");
944    }
945
946    #[test]
947    fn migrate_picks_latest_started_at_on_dedup() {
948        let (home, reg) = fresh_home();
949        let wd = channel_agents(home.path(), "stable").join("demo");
950        std::fs::create_dir_all(&wd).unwrap();
951        // Same instance_id in two versions of the same channel, different
952        // started_at.
953        make_channel_db(
954            home.path(),
955            "stable",
956            "0.33.800",
957            &[("inst-1", "demo", 100, &wd.to_string_lossy())],
958        );
959        make_channel_db(
960            home.path(),
961            "stable",
962            "0.33.821",
963            &[("inst-1", "demo", 200, &wd.to_string_lossy())],
964        );
965
966        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
967        assert_eq!(stats.rows_seen, 2);
968        assert_eq!(stats.records_written, 1);
969        let recs = reg.list_active().unwrap();
970        assert_eq!(recs.len(), 1);
971        assert_eq!(recs[0].data.last_launched_at_ms, 200);
972    }
973
974    #[test]
975    fn migrate_handles_dev_layout() {
976        // Dev instance lives at home/dev/<branch>/<sub>/data/db/objects.db with
977        // its workspace under the sibling home/dev/<branch>/<sub>/agents (NOT the
978        // global <home>/agents root). The recursive dev walk must find it and the
979        // per-source fallback must strip against that sibling agents dir.
980        let (home, reg) = fresh_home();
981        let inst_dir = home.path().join("dev").join("mybranch").join("69d7a34a");
982        let wd = inst_dir.join("agents").join("devagent");
983        std::fs::create_dir_all(&wd).unwrap();
984        make_db_at(
985            &inst_dir,
986            &[("inst-dev", "DevAgent", 100, &wd.to_string_lossy(), false)],
987        );
988
989        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
990        assert_eq!(stats.dbs_scanned, 1);
991        assert_eq!(stats.records_written, 1);
992        let recs = reg.list_active().unwrap();
993        assert_eq!(recs.len(), 1);
994        assert_eq!(recs[0].data.working_dir, "devagent");
995    }
996
997    #[test]
998    fn migrate_dev_ignores_nested_agent_workspace_db() {
999        // An agent running inside a dev instance can create its OWN nested
1000        // AgentMux data dir under <instance>/agents/<slug>/. The dev walk must
1001        // NOT descend into agents/ and pick that nested objects.db up as a
1002        // migration source (reagent P2 on #1389).
1003        let (home, reg) = fresh_home();
1004        let inst_dir = home.path().join("dev").join("mybranch").join("sub");
1005        // NO instance-level DB — only a nested one inside an agent workspace.
1006        let nested_inst = inst_dir.join("agents").join("nestedmux");
1007        let nested_wd = nested_inst.join("agents").join("inner");
1008        std::fs::create_dir_all(&nested_wd).unwrap();
1009        make_db_at(
1010            &nested_inst,
1011            &[("inst-nested", "Nested", 100, &nested_wd.to_string_lossy(), false)],
1012        );
1013
1014        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1015        assert_eq!(
1016            stats.dbs_scanned, 0,
1017            "must not descend into agent workspaces under dev/"
1018        );
1019        assert!(reg.list_active().unwrap().is_empty());
1020    }
1021
1022    #[test]
1023    fn migrate_dev_picks_instance_db_and_ignores_nested() {
1024        // With an instance-level DB present, the walk stops at the instance dir
1025        // (leaf) and never descends into its agents/ — so a nested workspace DB
1026        // is ignored even when the real instance DB exists.
1027        let (home, reg) = fresh_home();
1028        let inst_dir = home.path().join("dev").join("mybranch").join("sub");
1029        let wd = inst_dir.join("agents").join("realagent");
1030        std::fs::create_dir_all(&wd).unwrap();
1031        make_db_at(
1032            &inst_dir,
1033            &[("inst-real", "Real", 100, &wd.to_string_lossy(), false)],
1034        );
1035        let nested_inst = inst_dir.join("agents").join("nestedmux");
1036        let nested_wd = nested_inst.join("agents").join("inner");
1037        std::fs::create_dir_all(&nested_wd).unwrap();
1038        make_db_at(
1039            &nested_inst,
1040            &[("inst-nested", "Nested", 200, &nested_wd.to_string_lossy(), false)],
1041        );
1042
1043        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1044        assert_eq!(stats.dbs_scanned, 1, "only the instance-level dev DB is a source");
1045        let recs = reg.list_active().unwrap();
1046        assert_eq!(recs.len(), 1);
1047        assert_eq!(recs[0].data.instance_id, "inst-real");
1048        assert_eq!(recs[0].data.working_dir, "realagent");
1049    }
1050
1051    #[test]
1052    fn migrate_dev_branch_named_like_internal_dir_is_scanned() {
1053        // A git branch can legitimately be named "data"/"agents"/etc. The dev
1054        // scan must NOT skip it (no name-based filter at the branch level —
1055        // codex P2 on #1389).
1056        let (home, reg) = fresh_home();
1057        let inst_dir = home.path().join("dev").join("data").join("sub");
1058        let wd = inst_dir.join("agents").join("a1");
1059        std::fs::create_dir_all(&wd).unwrap();
1060        make_db_at(
1061            &inst_dir,
1062            &[("inst-d", "D", 100, &wd.to_string_lossy(), false)],
1063        );
1064
1065        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1066        assert_eq!(stats.dbs_scanned, 1, "branch named 'data' must still be scanned");
1067        assert_eq!(reg.list_active().unwrap().len(), 1);
1068    }
1069
1070    #[test]
1071    fn migrate_defers_marker_when_versions_dir_is_unreadable() {
1072        // A channel's versions/ that is present but unreadable (here: a FILE in
1073        // its place, which makes read_dir fail with a non-NotFound error) must
1074        // defer the marker so the channel is retried — not be silently treated
1075        // as empty (codex P2 on #1389).
1076        let (home, reg) = fresh_home();
1077        let ch = home.path().join("channels").join("stable");
1078        std::fs::create_dir_all(&ch).unwrap();
1079        std::fs::write(ch.join("versions"), b"not a directory").unwrap();
1080
1081        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1082        assert!(
1083            !stats.complete,
1084            "an unreadable versions/ must defer the marker"
1085        );
1086        assert!(
1087            !reg.root().join(MARKER).exists(),
1088            "marker deferred so the channel is retried next launch"
1089        );
1090    }
1091
1092    #[test]
1093    fn migrate_skips_when_registry_already_has_record() {
1094        let (home, reg) = fresh_home();
1095        let wd = channel_agents(home.path(), "stable").join("demo");
1096        std::fs::create_dir_all(&wd).unwrap();
1097        // Pre-existing registry record (e.g. the live mirror already wrote it).
1098        reg.upsert(&NamedAgentRecord {
1099            schema_version: MAX_SUPPORTED_SCHEMA,
1100            data: NamedAgentRecordV1 {
1101                instance_id: "inst-1".to_string(),
1102                instance_name: "preexisting".to_string(),
1103                definition_id: "claude-code".to_string(),
1104                identity_id: None,
1105                memory_id: None,
1106                session_id: None,
1107                working_dir: "demo".to_string(),
1108                source_agents_base: None,
1109                created_at_ms: 50,
1110                last_launched_at_ms: 500,
1111                created_by_version: "0.33.823".to_string(),
1112                last_launched_by_version: "0.33.823".to_string(),
1113            },
1114        })
1115        .unwrap();
1116        // Legacy SQLite row with the SAME instance_id but older data.
1117        make_channel_db(
1118            home.path(),
1119            "stable",
1120            "0.33.821",
1121            &[("inst-1", "legacyname", 100, &wd.to_string_lossy())],
1122        );
1123
1124        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1125        assert_eq!(stats.records_skipped_existing, 1);
1126        assert_eq!(stats.records_written, 0);
1127        // Pre-existing record stays — name is "preexisting", not "legacyname".
1128        let recs = reg.list_active().unwrap();
1129        assert_eq!(recs.len(), 1);
1130        assert_eq!(recs[0].data.instance_name, "preexisting");
1131    }
1132
1133    #[test]
1134    fn migrate_skips_when_record_is_retired() {
1135        // A user "Forgot" an agent (its registry file is in retired/). Another
1136        // version's SQLite still has display_hidden=0 for that id. Migration
1137        // must NOT resurrect the row into active/.
1138        let (home, reg) = fresh_home();
1139        let wd = channel_agents(home.path(), "stable").join("demo");
1140        std::fs::create_dir_all(&wd).unwrap();
1141
1142        let retired_record = NamedAgentRecord {
1143            schema_version: MAX_SUPPORTED_SCHEMA,
1144            data: NamedAgentRecordV1 {
1145                instance_id: "inst-1".to_string(),
1146                instance_name: "demo".to_string(),
1147                definition_id: "claude-code".to_string(),
1148                identity_id: None,
1149                memory_id: None,
1150                session_id: None,
1151                working_dir: "demo".to_string(),
1152                source_agents_base: None,
1153                created_at_ms: 50,
1154                last_launched_at_ms: 50,
1155                created_by_version: "0.33.823".to_string(),
1156                last_launched_by_version: "0.33.823".to_string(),
1157            },
1158        };
1159        reg.upsert(&retired_record).unwrap();
1160        reg.retire("inst-1").unwrap();
1161        assert!(reg.list_active().unwrap().is_empty());
1162        assert!(reg.exists_anywhere("inst-1"));
1163
1164        make_channel_db(
1165            home.path(),
1166            "stable",
1167            "0.33.821",
1168            &[("inst-1", "demo", 100, &wd.to_string_lossy())],
1169        );
1170
1171        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1172        assert_eq!(stats.rows_seen, 1);
1173        assert_eq!(stats.records_skipped_existing, 1);
1174        assert_eq!(stats.records_written, 0);
1175        assert!(reg.list_active().unwrap().is_empty());
1176        assert!(reg.root().join("retired").join("inst-1.json").exists());
1177    }
1178
1179    #[test]
1180    fn migrate_silently_skips_pre_v8_schema() {
1181        // An older SQLite (pre-v8) lacks the `instance_name` column. rusqlite
1182        // reports this as `Error::SqlInputError` during prepare. The migrator
1183        // must treat it as "nothing to migrate from this version" — NOT a real
1184        // DB failure that defers the marker.
1185        let (home, reg) = fresh_home();
1186        let db_dir = home
1187            .path()
1188            .join("channels")
1189            .join("stable")
1190            .join("versions")
1191            .join("0.33.643")
1192            .join("data")
1193            .join("db");
1194        std::fs::create_dir_all(&db_dir).unwrap();
1195        let conn = Connection::open(db_dir.join("objects.db")).unwrap();
1196        conn.execute_batch(
1197            "CREATE TABLE db_agent_instances (
1198                id TEXT PRIMARY KEY,
1199                definition_id TEXT NOT NULL DEFAULT '',
1200                parent_instance_id TEXT NOT NULL DEFAULT '',
1201                started_at INTEGER NOT NULL DEFAULT 0,
1202                created_at INTEGER NOT NULL DEFAULT 0
1203            );",
1204        )
1205        .unwrap();
1206        drop(conn);
1207
1208        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1209        assert_eq!(stats.dbs_scanned, 1);
1210        assert_eq!(stats.rows_seen, 0);
1211        assert!(stats.complete, "pre-v8 schema must not block the marker");
1212        assert!(reg.root().join(MARKER).exists());
1213    }
1214
1215    #[test]
1216    fn migrate_writes_legacy_hidden_row_as_tombstone() {
1217        // Pre-registry "Forget agent" intent must survive migration: a
1218        // single-version row with display_hidden=1 should land in retired/.
1219        let (home, reg) = fresh_home();
1220        let wd = channel_agents(home.path(), "stable").join("forgotten");
1221        std::fs::create_dir_all(&wd).unwrap();
1222        make_channel_db_with_hidden(
1223            home.path(),
1224            "stable",
1225            "0.33.821",
1226            &[("inst-1", "forgotten", 100, &wd.to_string_lossy(), true)],
1227        );
1228
1229        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1230        assert_eq!(stats.records_written, 1);
1231        assert!(
1232            reg.list_active().unwrap().is_empty(),
1233            "hidden legacy row must NOT appear active"
1234        );
1235        assert!(
1236            reg.root().join("retired").join("inst-1.json").exists(),
1237            "hidden legacy row must be migrated as retired tombstone"
1238        );
1239    }
1240
1241    #[test]
1242    fn migrate_preserves_forget_intent_across_versions() {
1243        // Same id in two versions: one hides it (Forget), the other still has
1244        // it visible. The "forget" must win — registry tombstone, not active.
1245        let (home, reg) = fresh_home();
1246        let wd = channel_agents(home.path(), "stable").join("toggled");
1247        std::fs::create_dir_all(&wd).unwrap();
1248        make_channel_db_with_hidden(
1249            home.path(),
1250            "stable",
1251            "0.33.800",
1252            &[("inst-1", "toggled", 100, &wd.to_string_lossy(), false)],
1253        );
1254        make_channel_db_with_hidden(
1255            home.path(),
1256            "stable",
1257            "0.33.821",
1258            &[("inst-1", "toggled", 200, &wd.to_string_lossy(), true)],
1259        );
1260
1261        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1262        assert_eq!(stats.records_written, 1);
1263        assert!(
1264            reg.list_active().unwrap().is_empty(),
1265            "hidden intent in any version must propagate to registry tombstone"
1266        );
1267        assert!(reg.root().join("retired").join("inst-1.json").exists());
1268    }
1269
1270    #[test]
1271    fn migrate_defers_marker_on_unreadable_db() {
1272        // A briefly-unreadable DB during startup must NOT bake "permanently
1273        // skip" into the marker. Marker is only written when every DB read.
1274        let (home, reg) = fresh_home();
1275        let wd = channel_agents(home.path(), "stable").join("demo");
1276        std::fs::create_dir_all(&wd).unwrap();
1277        make_channel_db(
1278            home.path(),
1279            "stable",
1280            "0.33.821",
1281            &[("inst-good", "demo", 100, &wd.to_string_lossy())],
1282        );
1283        // Bad DB — looks like a SQLite file but is corrupt.
1284        let bad_db_dir = home
1285            .path()
1286            .join("channels")
1287            .join("stable")
1288            .join("versions")
1289            .join("0.33.800")
1290            .join("data")
1291            .join("db");
1292        std::fs::create_dir_all(&bad_db_dir).unwrap();
1293        std::fs::write(bad_db_dir.join("objects.db"), b"not actually sqlite").unwrap();
1294
1295        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1296        assert_eq!(stats.records_written, 1, "good DB still migrated");
1297        assert_eq!(stats.dbs_skipped, 1, "bad DB counted, not fatal");
1298        assert!(
1299            !reg.root().join(MARKER).exists(),
1300            "marker deferred (retry) when a DB was unreadable — but the registry still attaches (see main.rs); good records are already written"
1301        );
1302        // The good record is present regardless of the deferred marker — a bad
1303        // unrelated DB must not hide cross-channel agents (codex P1 on #1389).
1304        assert_eq!(reg.list_active().unwrap().len(), 1);
1305
1306        // Next launch retries; the good row is idempotency-skipped, and the
1307        // bad DB now reads (replaced with a valid file under the same wd).
1308        std::fs::remove_file(bad_db_dir.join("objects.db")).unwrap();
1309        make_channel_db(
1310            home.path(),
1311            "stable",
1312            "0.33.800",
1313            &[("inst-other", "demo", 50, &wd.to_string_lossy())],
1314        );
1315        let stats2 = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1316        assert!(stats2.complete, "complete flag set on clean retry");
1317        assert!(
1318            reg.root().join(MARKER).exists(),
1319            "marker written on the retry once all DBs read successfully"
1320        );
1321        assert_eq!(stats2.records_skipped_existing, 1);
1322        assert_eq!(stats2.records_written, 1);
1323    }
1324
1325    #[test]
1326    fn migrate_skips_unmappable_working_dirs() {
1327        let (home, reg) = fresh_home();
1328        // Working dir is OUTSIDE any channel agents root — unmappable.
1329        let outside = home.path().join("not_under_agents").join("foo");
1330        make_channel_db(
1331            home.path(),
1332            "stable",
1333            "0.33.821",
1334            &[("inst-x", "demo", 100, &outside.to_string_lossy())],
1335        );
1336
1337        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1338        assert_eq!(stats.rows_seen, 1);
1339        assert_eq!(stats.records_skipped_unmappable, 1);
1340        assert_eq!(stats.records_written, 0);
1341    }
1342
1343    #[test]
1344    fn migrate_tolerates_missing_or_corrupt_dbs() {
1345        let (home, reg) = fresh_home();
1346        // Channel/version dir with no DB file.
1347        std::fs::create_dir_all(
1348            home.path()
1349                .join("channels")
1350                .join("stable")
1351                .join("versions")
1352                .join("0.33.700"),
1353        )
1354        .unwrap();
1355        // Channel/version dir with corrupt DB.
1356        let db_dir = home
1357            .path()
1358            .join("channels")
1359            .join("stable")
1360            .join("versions")
1361            .join("0.33.701")
1362            .join("data")
1363            .join("db");
1364        std::fs::create_dir_all(&db_dir).unwrap();
1365        std::fs::write(db_dir.join("objects.db"), b"not a sqlite file").unwrap();
1366
1367        let stats = migrate_from_sqlite_once(home.path(), &reg).unwrap();
1368        // Only one had a *file*, and it failed to read — no panic, no rows.
1369        // Marker deferred so the next launch retries; the bad DB is counted.
1370        assert_eq!(stats.dbs_scanned, 1);
1371        assert_eq!(stats.dbs_skipped, 1);
1372        assert_eq!(stats.records_written, 0);
1373        assert!(
1374            !reg.root().join(MARKER).exists(),
1375            "marker deferred on unreadable DB"
1376        );
1377    }
1378
1379    // ---- source_agents_base backfill (P0.4) ----
1380
1381    fn seed_pre_v3_record(reg: &Registry, id: &str, session: Option<&str>) {
1382        reg.upsert(&NamedAgentRecord {
1383            schema_version: if session.is_some() { 2 } else { 1 },
1384            data: NamedAgentRecordV1 {
1385                instance_id: id.to_string(),
1386                instance_name: "demo".to_string(),
1387                definition_id: "claude-code".to_string(),
1388                identity_id: Some("ident-1".to_string()),
1389                memory_id: None,
1390                session_id: session.map(|s| s.to_string()),
1391                working_dir: "demo".to_string(),
1392                source_agents_base: None,
1393                created_at_ms: 10,
1394                last_launched_at_ms: 20,
1395                created_by_version: "0.43.0".to_string(),
1396                last_launched_by_version: "0.43.0".to_string(),
1397            },
1398        })
1399        .unwrap();
1400    }
1401
1402    #[test]
1403    fn backfill_sets_source_base_preserving_session_and_identity() {
1404        let (home, reg) = fresh_home();
1405        // Simulate a registry the P0.3b migration already finalized.
1406        std::fs::write(reg.root().join(MARKER), b"x").unwrap();
1407        seed_pre_v3_record(&reg, "inst-1", Some("sess-keep"));
1408        // Its source channel's SQLite still exists.
1409        let wd = channel_agents(home.path(), "stable").join("demo");
1410        std::fs::create_dir_all(&wd).unwrap();
1411        make_channel_db(
1412            home.path(),
1413            "stable",
1414            "0.44.2",
1415            &[("inst-1", "demo", 100, &wd.to_string_lossy())],
1416        );
1417
1418        let stats = backfill_source_bases_once(home.path(), &reg).unwrap();
1419        assert_eq!(stats.records_updated, 1);
1420        assert!(stats.complete);
1421        let recs = reg.list_active().unwrap();
1422        assert_eq!(recs.len(), 1);
1423        let r = &recs[0].data;
1424        // Source base now points at the SOURCE channel agents dir...
1425        assert_eq!(
1426            r.source_agents_base.as_deref(),
1427            Some(channel_agents(home.path(), "stable").to_string_lossy().as_ref())
1428        );
1429        // ...and the live-mirror-enriched fields survived (not clobbered).
1430        assert_eq!(r.session_id.as_deref(), Some("sess-keep"));
1431        assert_eq!(r.identity_id.as_deref(), Some("ident-1"));
1432        assert_eq!(recs[0].schema_version, 3);
1433        assert!(reg.root().join(SOURCE_BACKFILL_MARKER).exists());
1434    }
1435
1436    #[test]
1437    fn backfill_is_idempotent_via_marker() {
1438        let (home, reg) = fresh_home();
1439        // Empty registry → nothing pending → marker written.
1440        let s1 = backfill_source_bases_once(home.path(), &reg).unwrap();
1441        assert_eq!(s1.records_updated, 0);
1442        assert!(s1.complete);
1443        assert!(reg.root().join(SOURCE_BACKFILL_MARKER).exists());
1444        // A record added AFTER the marker must not be picked up (short-circuit).
1445        seed_pre_v3_record(&reg, "inst-late", None);
1446        let s2 = backfill_source_bases_once(home.path(), &reg).unwrap();
1447        assert_eq!(s2.records_updated, 0, "marker short-circuits subsequent runs");
1448    }
1449
1450    #[test]
1451    fn backfill_anchors_on_latest_started_at_channel() {
1452        // An id present in two channels must anchor source_agents_base on the
1453        // SAME (latest-started_at) channel the migration's working_dir came
1454        // from — not whichever DB the filesystem returned first.
1455        let (home, reg) = fresh_home();
1456        std::fs::write(reg.root().join(MARKER), b"x").unwrap();
1457        seed_pre_v3_record(&reg, "inst-dup", None);
1458        let wda = channel_agents(home.path(), "chan-a").join("demo");
1459        let wdb = channel_agents(home.path(), "chan-b").join("demo");
1460        std::fs::create_dir_all(&wda).unwrap();
1461        std::fs::create_dir_all(&wdb).unwrap();
1462        // chan-a older (100), chan-b newer (200) — chan-b must win.
1463        make_channel_db(
1464            home.path(),
1465            "chan-a",
1466            "0.1",
1467            &[("inst-dup", "demo", 100, &wda.to_string_lossy())],
1468        );
1469        make_channel_db(
1470            home.path(),
1471            "chan-b",
1472            "0.1",
1473            &[("inst-dup", "demo", 200, &wdb.to_string_lossy())],
1474        );
1475
1476        let stats = backfill_source_bases_once(home.path(), &reg).unwrap();
1477        assert_eq!(stats.records_updated, 1);
1478        let recs = reg.list_active().unwrap();
1479        assert_eq!(
1480            recs[0].data.source_agents_base.as_deref(),
1481            Some(channel_agents(home.path(), "chan-b").to_string_lossy().as_ref()),
1482            "anchors on the latest-started_at channel (chan-b)"
1483        );
1484    }
1485
1486    #[test]
1487    fn backfill_counts_unresolved_when_source_db_gone() {
1488        let (home, reg) = fresh_home();
1489        std::fs::write(reg.root().join(MARKER), b"x").unwrap();
1490        seed_pre_v3_record(&reg, "inst-orphan", None);
1491        // No channels/ at all — the source channel is gone.
1492        let stats = backfill_source_bases_once(home.path(), &reg).unwrap();
1493        assert_eq!(stats.records_updated, 0);
1494        assert_eq!(stats.records_unresolved, 1);
1495        assert!(stats.complete, "no DB failure → marker written");
1496        // Record stays None; the handler falls back to the current channel.
1497        let recs = reg.list_active().unwrap();
1498        assert!(recs[0].data.source_agents_base.is_none());
1499    }
1500}