Skip to main content

agentmux_srv/
adoption.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Carry what channels kept to themselves into the shared store.
5//!
6//! Auth is shared by every channel now (`SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md`),
7//! but until 2026-10-03 every non-`stable` channel isolated it, so real data sits
8//! in `channels/<ch>/identity-store.db` files the shared store has never seen. On
9//! the first shared boot this pass copies it across, read-only on the source:
10//!
11//! | What | From | Why |
12//! |---|---|---|
13//! | Global Memory (global, non-system bundles) and their versions | every channel store, newest wins | each channel kept its own; the same entry written twice is one entry |
14//! | Native memory and its versions | every channel store, newest wins | the shared store has none; the entries are scattered over older channels |
15//! | Accounts | this channel's own isolated store and the newest same-branch predecessor | a login belongs to the build that made it; adopting every channel's would flood Connectors → Accounts with test accounts |
16//!
17//! Everything else is left alone. Non-global bundles in particular are mostly
18//! per-agent bundles each channel made for itself (4,740 distinct ones across the
19//! owner's 46 stores).
20//!
21//! **Idempotent, and deletions stick.** A ledger (`adoption-ledger.json` in the
22//! shared dir; a file, because a schema change to the shared store would stop an
23//! older build opening it) records the keys already considered and each source's
24//! stamp. An item deleted afterwards is not put back, and a source that has not
25//! changed is not reopened. An account row keeps its stored `OAuthConfigDir`
26//! pointing at the source channel's folder: the spawn path uses the stored dir,
27//! so the login works at once, with no copy of the credentials.
28
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::{Path, PathBuf};
31use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
32
33use rusqlite::types::Value;
34use rusqlite::{params_from_iter, Connection, OpenFlags};
35use serde::{Deserialize, Serialize};
36
37use crate::backend::storage::store::Store;
38
39const LEDGER_FILE: &str = "adoption-ledger.json";
40static TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
41/// Longest a boot waits for the pass; what is left is picked up next boot.
42pub(crate) const BOOT_BUDGET: Duration = Duration::from_secs(8);
43
44/// What a source file looked like when it was last fully processed: the database
45/// and its write-ahead log (a WAL-mode database's newest writes live there).
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47struct Stamp {
48    modified_ms: u64,
49    size: u64,
50    wal_modified_ms: u64,
51    wal_size: u64,
52}
53
54#[derive(Debug, Default, Serialize, Deserialize)]
55struct Ledger {
56    sources: BTreeMap<String, Stamp>,
57    keys: BTreeSet<String>,
58}
59
60/// Counts for the boot log and the tests.
61#[derive(Debug, Default, Clone, PartialEq, Eq)]
62pub(crate) struct Report {
63    pub sources_read: usize,
64    pub sources_unchanged: usize,
65    pub sources_failed: usize,
66    pub global_memory_added: usize,
67    pub global_memory_updated: usize,
68    pub native_memory_added: usize,
69    pub native_memory_updated: usize,
70    pub accounts_added: usize,
71    /// Rows the target refused (a name already taken by another id, a missing
72    /// required column): skipped, not fatal.
73    pub rows_refused: usize,
74    /// Rows left out because they were adopted once and deleted since.
75    pub deleted_stay_deleted: usize,
76    pub budget_exhausted: bool,
77}
78
79impl Report {
80    pub(crate) fn changed_anything(&self) -> bool {
81        self.global_memory_added
82            + self.global_memory_updated
83            + self.native_memory_added
84            + self.native_memory_updated
85            + self.accounts_added
86            > 0
87    }
88}
89
90pub(crate) struct Inputs<'a> {
91    /// The true global root (`~/.agentmux`).
92    pub home: &'a Path,
93    /// The directory holding `adoption-ledger.json` (`<home>/shared`).
94    pub shared_dir: &'a Path,
95    /// This channel's id (`AGENTMUX_CHANNEL`).
96    pub channel: &'a str,
97    /// This channel's own directory (`AGENTMUX_INSTANCE_DIR`), if known.
98    pub instance_dir: Option<&'a Path>,
99    /// The shared store's file: never a source.
100    pub target_path: &'a Path,
101    pub budget: Duration,
102}
103
104/// The sources for one pass. The predecessor is the newest other build of this
105/// branch (same `local-<branch>-<hash>-` prefix) whose store holds an account.
106#[derive(Debug, Default)]
107pub(crate) struct Sources {
108    /// Every channel's identity store, newest first.
109    pub all: Vec<PathBuf>,
110    /// This channel's own store and its predecessor's, for accounts.
111    pub accounts: Vec<PathBuf>,
112}
113
114fn modified(p: &Path) -> Option<SystemTime> {
115    std::fs::metadata(p).and_then(|m| m.modified()).ok()
116}
117
118fn same_file(a: &Path, b: &Path) -> bool {
119    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
120        (Ok(x), Ok(y)) => x == y,
121        _ => a == b,
122    }
123}
124
125/// `local-<branch>-<hash6>-<8 hex>` → its prefix up to and including the last `-`.
126/// A per-build channel's name ends in an 8-hex build suffix; the part before it
127/// names the branch, so another build of the same branch shares the prefix.
128fn build_prefix(channel: &str) -> Option<&str> {
129    let (prefix, suffix) = channel.rsplit_once('-')?;
130    let is_build = suffix.len() == 8 && suffix.bytes().all(|b| b.is_ascii_hexdigit());
131    (channel.starts_with("local-") && is_build).then(|| &channel[..prefix.len() + 1])
132}
133
134pub(crate) fn discover_sources(inp: &Inputs) -> Sources {
135    let mut found: Vec<(SystemTime, PathBuf)> = Vec::new();
136    let mut push = |p: PathBuf| {
137        if p.is_file() && !same_file(&p, inp.target_path) && !found.iter().any(|(_, q)| same_file(q, &p)) {
138            if let Some(t) = modified(&p) {
139                found.push((t, p));
140            }
141        }
142    };
143    if let Ok(rd) = std::fs::read_dir(inp.home.join("channels")) {
144        for e in rd.flatten() {
145            push(e.path().join("identity-store.db"));
146        }
147    }
148    // `task dev` instance folders (`dev/<branch>/<clone>/`) are deliberately NOT
149    // scanned: every agent's dev builds make one, hundreds on a busy host, and
150    // their stores are throwaway test data. A dev channel's OWN store is still
151    // a source, through `instance_dir` below.
152    if let Some(dir) = inp.instance_dir {
153        push(dir.join("identity-store.db"));
154    }
155    // Newest first, so the first copy of an entry seen is the one that wins and
156    // an older copy is only a fallback for what is still missing.
157    found.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
158
159    let mut accounts = Vec::new();
160    if let Some(dir) = inp.instance_dir {
161        let own = dir.join("identity-store.db");
162        if own.is_file() && !same_file(&own, inp.target_path) {
163            accounts.push(own);
164        }
165    }
166    if let Some(prefix) = build_prefix(inp.channel) {
167        let own_name = inp.channel;
168        let predecessor = found
169            .iter()
170            .filter(|(_, p)| {
171                p.parent()
172                    .and_then(|d| d.file_name())
173                    .and_then(|n| n.to_str())
174                    .is_some_and(|n| n != own_name && n.starts_with(prefix) && build_prefix(n).is_some())
175            })
176            // The build you upgraded from is the newest one that holds a login:
177            // a build that was only opened and closed has nothing to carry.
178            .filter(|(_, p)| has_accounts(p))
179            .max_by_key(|(t, _)| *t)
180            .map(|(_, p)| p.clone());
181        if let Some(p) = predecessor {
182            if !accounts.iter().any(|q| same_file(q, &p)) {
183                accounts.push(p);
184            }
185        }
186    }
187    Sources { all: found.into_iter().map(|(_, p)| p).collect(), accounts }
188}
189
190/// Whether the store at `path` holds at least one account.
191fn has_accounts(path: &Path) -> bool {
192    open_source(path)
193        .ok()
194        .and_then(|c| c.query_row("SELECT COUNT(*) FROM db_accounts", [], |r| r.get::<_, i64>(0)).ok())
195        .is_some_and(|n| n > 0)
196}
197
198fn ms(t: Option<SystemTime>) -> u64 {
199    t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_millis() as u64)
200}
201
202fn stamp_of(path: &Path) -> Stamp {
203    let wal = PathBuf::from(format!("{}-wal", path.display()));
204    Stamp {
205        modified_ms: ms(modified(path)),
206        size: std::fs::metadata(path).map_or(0, |m| m.len()),
207        wal_modified_ms: ms(modified(&wal)),
208        wal_size: std::fs::metadata(&wal).map_or(0, |m| m.len()),
209    }
210}
211
212fn load_ledger(shared_dir: &Path) -> Ledger {
213    match std::fs::read_to_string(shared_dir.join(LEDGER_FILE)) {
214        Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
215            // Keep the unreadable file for inspection instead of overwriting it.
216            let aside = shared_dir.join(format!("{LEDGER_FILE}.corrupt"));
217            let _ = std::fs::rename(shared_dir.join(LEDGER_FILE), &aside);
218            tracing::warn!(error = %e, kept = %aside.display(), "adoption: ledger unreadable, starting a new one");
219            Ledger::default()
220        }),
221        Err(_) => Ledger::default(),
222    }
223}
224
225/// Write the ledger. Several channels share one machine now, so two boots can
226/// run at once: the file on disk is read again and merged first (keys only ever
227/// get added, so a union loses nothing another boot recorded), and the temp
228/// file is unique so two writers never interleave in one file.
229fn save_ledger(shared_dir: &Path, ledger: &Ledger) {
230    let mut merged = std::fs::read_to_string(shared_dir.join(LEDGER_FILE))
231        .ok()
232        .and_then(|s| serde_json::from_str::<Ledger>(&s).ok())
233        .unwrap_or_default();
234    merged.keys.extend(ledger.keys.iter().cloned());
235    for (k, v) in &ledger.sources {
236        merged.sources.insert(k.clone(), v.clone());
237    }
238    let n = TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
239    let tmp = shared_dir.join(format!("{LEDGER_FILE}.{}.{n}.tmp", std::process::id()));
240    let ledger = &merged;
241    let result = serde_json::to_vec(ledger)
242        .map_err(|e| e.to_string())
243        .and_then(|b| std::fs::write(&tmp, b).map_err(|e| e.to_string()))
244        .and_then(|_| std::fs::rename(&tmp, shared_dir.join(LEDGER_FILE)).map_err(|e| e.to_string()));
245    if result.is_err() {
246        let _ = std::fs::remove_file(&tmp);
247    }
248    if let Err(e) = result {
249        tracing::warn!(error = %e, "adoption: could not save the ledger; the next boot repeats the pass");
250    }
251}
252
253fn open_source(path: &Path) -> rusqlite::Result<Connection> {
254    let c = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX)?;
255    c.busy_timeout(Duration::from_millis(500))?;
256    Ok(c)
257}
258
259fn table_cols(c: &Connection, table: &str) -> Vec<String> {
260    let Ok(mut stmt) = c.prepare(&format!("PRAGMA table_info({table})")) else { return Vec::new() };
261    stmt.query_map([], |r| r.get::<_, String>(1)).map(|rows| rows.flatten().collect()).unwrap_or_default()
262}
263
264fn quote_ident(s: &str) -> String {
265    format!("\"{}\"", s.replace('"', "\"\""))
266}
267
268fn quote_lit(s: &str) -> String {
269    format!("'{}'", s.replace('\'', "''"))
270}
271
272fn value_text(v: &Value) -> String {
273    match v {
274        Value::Null => String::new(),
275        Value::Integer(i) => i.to_string(),
276        Value::Real(f) => f.to_string(),
277        Value::Text(t) => t.clone(),
278        Value::Blob(b) => format!("blob{}", b.len()),
279    }
280}
281
282/// How one table is copied.
283struct Spec<'a> {
284    table: &'static str,
285    /// Ledger key prefix.
286    cat: &'static str,
287    key: &'a [&'static str],
288    /// An INTEGER column; a source row with a larger value replaces the target's.
289    newer: Option<&'static str>,
290    /// A WHERE clause over the source table.
291    filter: String,
292}
293
294#[derive(Default)]
295struct Copied {
296    added: Vec<Vec<String>>,
297    updated: Vec<Vec<String>>,
298    refused: usize,
299    deleted_stay_deleted: usize,
300}
301
302/// Copy `spec`'s rows from `src` into `dst`. Only columns both tables have are
303/// copied, so a source from an older or newer schema works.
304fn copy_table(src: &Connection, dst: &Connection, spec: &Spec, ledger: &mut Ledger) -> Copied {
305    let mut out = Copied::default();
306    let (sc, dc) = (table_cols(src, spec.table), table_cols(dst, spec.table));
307    if sc.is_empty() || dc.is_empty() || spec.key.iter().any(|k| !sc.contains(&k.to_string()) || !dc.contains(&k.to_string())) {
308        return out;
309    }
310    let cols: Vec<&String> = dc.iter().filter(|c| sc.contains(c)).collect();
311    let col_list = cols.iter().map(|c| quote_ident(c)).collect::<Vec<_>>().join(", ");
312    let sql = format!("SELECT {col_list} FROM {} WHERE {}", spec.table, spec.filter);
313    let rows: Vec<Vec<Value>> = match src.prepare(&sql).and_then(|mut s| {
314        let n = cols.len();
315        s.query_map([], |r| (0..n).map(|i| r.get::<_, Value>(i)).collect::<rusqlite::Result<Vec<_>>>())
316            .map(|it| it.flatten().collect())
317    }) {
318        Ok(r) => r,
319        Err(_) => return out,
320    };
321    let idx = |name: &str| cols.iter().position(|c| c.as_str() == name);
322    let key_idx: Vec<usize> = spec.key.iter().filter_map(|k| idx(k)).collect();
323    let where_key = spec.key.iter().enumerate().map(|(i, k)| format!("{} = ?{}", quote_ident(k), i + 1)).collect::<Vec<_>>().join(" AND ");
324    let placeholders = (1..=cols.len()).map(|i| format!("?{i}")).collect::<Vec<_>>().join(", ");
325    let insert_sql = format!("INSERT INTO {} ({col_list}) VALUES ({placeholders})", spec.table);
326    for row in rows {
327        let key_vals: Vec<Value> = key_idx.iter().map(|&i| row[i].clone()).collect();
328        let key_text: Vec<String> = key_vals.iter().map(value_text).collect();
329        let ledger_key = format!("{}:{}", spec.cat, key_text.join("|"));
330        let existing: Option<Value> = match spec.newer {
331            Some(n) => dst
332                .query_row(&format!("SELECT {} FROM {} WHERE {where_key}", quote_ident(n), spec.table), params_from_iter(key_vals.iter()), |r| r.get::<_, Value>(0))
333                .ok(),
334            None => dst
335                .query_row(&format!("SELECT 1 FROM {} WHERE {where_key}", spec.table), params_from_iter(key_vals.iter()), |r| r.get::<_, Value>(0))
336                .ok(),
337        };
338        match existing {
339            None => {
340                if ledger.keys.contains(&ledger_key) {
341                    out.deleted_stay_deleted += 1;
342                    continue;
343                }
344                match dst.execute(&insert_sql, params_from_iter(row.iter())) {
345                    Ok(_) => {
346                        ledger.keys.insert(ledger_key);
347                        out.added.push(key_text);
348                    }
349                    Err(_) => out.refused += 1,
350                }
351            }
352            Some(have) => {
353                ledger.keys.insert(ledger_key);
354                let (Some(n), Value::Integer(have_n)) = (spec.newer, &have) else { continue };
355                let Some(ni) = idx(n) else { continue };
356                let Value::Integer(src_n) = &row[ni] else { continue };
357                if src_n > have_n {
358                    let set_cols: Vec<usize> = (0..cols.len()).filter(|i| !key_idx.contains(i)).collect();
359                    if set_cols.is_empty() {
360                        continue;
361                    }
362                    let set = set_cols.iter().enumerate().map(|(j, &i)| format!("{} = ?{}", quote_ident(cols[i]), spec.key.len() + j + 1)).collect::<Vec<_>>().join(", ");
363                    let sql = format!("UPDATE {} SET {set} WHERE {where_key}", spec.table);
364                    let params: Vec<Value> = key_vals.iter().cloned().chain(set_cols.iter().map(|&i| row[i].clone())).collect();
365                    match dst.execute(&sql, params_from_iter(params.iter())) {
366                        Ok(_) => out.updated.push(key_text),
367                        Err(_) => out.refused += 1,
368                    }
369                }
370            }
371        }
372    }
373    out
374}
375
376fn in_list(values: impl Iterator<Item = String>) -> Option<String> {
377    let v: Vec<String> = values.map(|s| quote_lit(&s)).collect();
378    (!v.is_empty()).then(|| v.join(", "))
379}
380
381/// Global Memory and its versions, from one source.
382fn adopt_global_memory(src: &Connection, dst: &Connection, ledger: &mut Ledger, r: &mut Report) {
383    let has_system = table_cols(src, "db_bundles").iter().any(|c| c == "is_system");
384    let filter = if has_system { "is_global = 1 AND COALESCE(is_system, 0) = 0" } else { "is_global = 1" };
385    let c = copy_table(src, dst, &Spec { table: "db_bundles", cat: "gm", key: &["id"], newer: Some("updated_at"), filter: filter.into() }, ledger);
386    r.global_memory_added += c.added.len();
387    r.global_memory_updated += c.updated.len();
388    r.rows_refused += c.refused;
389    r.deleted_stay_deleted += c.deleted_stay_deleted;
390    let ids = c.added.iter().chain(c.updated.iter()).map(|k| k[0].clone());
391    if let Some(list) = in_list(ids) {
392        let v = copy_table(src, dst, &Spec { table: "db_bundle_versions", cat: "gmv", key: &["id"], newer: None, filter: format!("bundle_id IN ({list})") }, ledger);
393        r.rows_refused += v.refused;
394    }
395}
396
397/// Native memory and its versions, from one source.
398fn adopt_native_memory(src: &Connection, dst: &Connection, ledger: &mut Ledger, r: &mut Report) {
399    let c = copy_table(src, dst, &Spec { table: "db_agent_native_memory", cat: "nm", key: &["agent_id", "filename"], newer: Some("updated_at"), filter: "1 = 1".into() }, ledger);
400    r.native_memory_added += c.added.len();
401    r.native_memory_updated += c.updated.len();
402    r.rows_refused += c.refused;
403    r.deleted_stay_deleted += c.deleted_stay_deleted;
404    let pairs = c.added.iter().chain(c.updated.iter()).map(|k| format!("{}|{}", k[0], k[1]));
405    if let Some(list) = in_list(pairs) {
406        let v = copy_table(src, dst, &Spec { table: "db_agent_native_memory_versions", cat: "nmv", key: &["id"], newer: None, filter: format!("(agent_id || '|' || filename) IN ({list})") }, ledger);
407        r.rows_refused += v.refused;
408    }
409}
410
411/// Accounts, from one source. Per-agent cloud credentials are not adopted:
412/// they belong to the MuxBus account the source channel signed in as, which
413/// need not be this channel's, and they are provisioned again on first use.
414fn adopt_accounts(src: &Connection, dst: &Connection, ledger: &mut Ledger, r: &mut Report) {
415    let a = copy_table(src, dst, &Spec { table: "db_accounts", cat: "acct", key: &["id"], newer: None, filter: "1 = 1".into() }, ledger);
416    r.accounts_added += a.added.len();
417    r.rows_refused += a.refused;
418    r.deleted_stay_deleted += a.deleted_stay_deleted;
419}
420
421/// Run one pass into `target`. Never fails the boot: a source that cannot be
422/// read is counted and skipped, and a pass out of budget resumes next boot.
423pub(crate) fn adopt(target: &Store, inp: &Inputs) -> Report {
424    let started = Instant::now();
425    let mut report = Report::default();
426    let sources = discover_sources(inp);
427    let mut ledger = load_ledger(inp.shared_dir);
428    let conn = match target.conn().lock() {
429        Ok(c) => c,
430        Err(e) => e.into_inner(),
431    };
432    for path in &sources.all {
433        if started.elapsed() > inp.budget {
434            report.budget_exhausted = true;
435            break;
436        }
437        let key = path.display().to_string();
438        let stamp = stamp_of(path);
439        let for_accounts = sources.accounts.iter().any(|a| same_file(a, path));
440        // A source already processed in this role, unchanged since, is skipped.
441        // The role is part of the key so a source that later becomes a
442        // predecessor is still read for accounts.
443        let ledger_key = format!("{key}#{}", if for_accounts { "all+accounts" } else { "all" });
444        if ledger.sources.get(&ledger_key) == Some(&stamp) {
445            report.sources_unchanged += 1;
446            continue;
447        }
448        let src = match open_source(path) {
449            Ok(c) => c,
450            Err(e) => {
451                tracing::debug!(source = %path.display(), error = %e, "adoption: cannot open a source");
452                report.sources_failed += 1;
453                continue;
454            }
455        };
456        if conn.execute_batch("BEGIN IMMEDIATE").is_err() {
457            report.sources_failed += 1;
458            continue;
459        }
460        // Keys are recorded as rows go in; if the transaction does not commit
461        // the rows are gone, so the keys must go too. Otherwise they would
462        // read as "adopted, then deleted" and the rows would never come back.
463        let keys_before = ledger.keys.clone();
464        let report_before = report.clone();
465        adopt_global_memory(&src, &conn, &mut ledger, &mut report);
466        adopt_native_memory(&src, &conn, &mut ledger, &mut report);
467        if for_accounts {
468            adopt_accounts(&src, &conn, &mut ledger, &mut report);
469        }
470        if conn.execute_batch("COMMIT").is_err() {
471            let _ = conn.execute_batch("ROLLBACK");
472            ledger.keys = keys_before;
473            report = report_before;
474            report.sources_failed += 1;
475            continue;
476        }
477        ledger.sources.insert(ledger_key, stamp);
478        report.sources_read += 1;
479    }
480    drop(conn);
481    save_ledger(inp.shared_dir, &ledger);
482    report
483}
484
485/// The boot entry point, for a channel running with shared auth.
486pub(crate) fn run_at_boot(target: &Store, target_path: &Path) {
487    let Some(shared_dir) = crate::registry::resolve_global_shared_root() else { return };
488    let Some(home) = shared_dir.parent().map(Path::to_path_buf) else { return };
489    let channel = crate::backend::reactive::registry::local_channel_id();
490    let instance_dir = std::env::var_os("AGENTMUX_INSTANCE_DIR").map(PathBuf::from);
491    let inputs = Inputs {
492        home: &home,
493        shared_dir: &shared_dir,
494        channel: &channel,
495        instance_dir: instance_dir.as_deref(),
496        target_path,
497        budget: BOOT_BUDGET,
498    };
499    let started = Instant::now();
500    let r = adopt(target, &inputs);
501    if r.changed_anything() || r.sources_failed > 0 || r.budget_exhausted {
502        tracing::info!(
503            sources_read = r.sources_read,
504            sources_unchanged = r.sources_unchanged,
505            sources_failed = r.sources_failed,
506            global_memory = r.global_memory_added + r.global_memory_updated,
507            native_memory = r.native_memory_added + r.native_memory_updated,
508            accounts = r.accounts_added,
509            refused = r.rows_refused,
510            kept_deleted = r.deleted_stay_deleted,
511            budget_exhausted = r.budget_exhausted,
512            elapsed_ms = started.elapsed().as_millis() as u64,
513            "adoption: carried earlier per-channel auth state into the shared store"
514        );
515    } else {
516        tracing::debug!(sources_unchanged = r.sources_unchanged, "adoption: nothing new to carry over");
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use std::fs;
524
525    /// A shared-schema store at `path` (parents created).
526    fn store_at(path: &Path) -> Store {
527        fs::create_dir_all(path.parent().unwrap()).unwrap();
528        Store::open_shared(path).unwrap()
529    }
530
531    fn sql(s: &Store, q: &str) {
532        s.conn().lock().unwrap().execute_batch(q).unwrap();
533    }
534
535    fn count(s: &Store, q: &str) -> i64 {
536        s.conn().lock().unwrap().query_row(q, [], |r| r.get(0)).unwrap()
537    }
538
539    fn text(s: &Store, q: &str) -> String {
540        s.conn().lock().unwrap().query_row(q, [], |r| r.get(0)).unwrap()
541    }
542
543    fn bundle(s: &Store, id: &str, name: &str, global: bool, system: bool, updated: i64, body: &str) {
544        sql(
545            s,
546            &format!(
547                "INSERT INTO db_bundles (id, name, is_global, is_system, updated_at, instructions) \
548                 VALUES ('{id}', '{name}', {}, {}, {updated}, '{body}'); \
549                 INSERT INTO db_bundle_versions (id, bundle_id, name, instructions, content_hash) \
550                 VALUES ('v-{id}-{updated}', '{id}', '{name}', '{body}', 'h{updated}');",
551                global as i32, system as i32
552            ),
553        );
554    }
555
556    fn native(s: &Store, agent: &str, file: &str, updated: i64, body: &str) {
557        sql(
558            s,
559            &format!(
560                "INSERT INTO db_agent_native_memory (agent_id, filename, content, updated_at) \
561                 VALUES ('{agent}', '{file}', '{body}', {updated}); \
562                 INSERT INTO db_agent_native_memory_versions (id, agent_id, filename, content, content_hash, created_at) \
563                 VALUES ('nv-{agent}-{file}-{updated}', '{agent}', '{file}', '{body}', 'h{updated}', {updated});"
564            ),
565        );
566    }
567
568    fn account(s: &Store, id: &str) {
569        sql(
570            s,
571            &format!(
572                "INSERT INTO db_accounts (id, name, provider, kind, secret_ref) \
573                 VALUES ('{id}', 'acct-{id}', 'claude', 'oauth', '{{\"type\":\"OAuthConfigDir\",\"dir\":\"/old/{id}\"}}')"
574            ),
575        );
576    }
577
578    fn credential(s: &Store, agent: &str) {
579        sql(s, &format!("INSERT INTO db_agent_credentials (agent_id, client_id) VALUES ('{agent}', 'client-{agent}')"));
580    }
581
582    struct Fixture {
583        _tmp: tempfile::TempDir,
584        home: PathBuf,
585        shared: PathBuf,
586        target_path: PathBuf,
587        target: Store,
588    }
589
590    impl Fixture {
591        fn new() -> Self {
592            let tmp = tempfile::tempdir().unwrap();
593            let home = tmp.path().to_path_buf();
594            let shared = home.join("shared");
595            let target_path = shared.join("store.db");
596            let target = store_at(&target_path);
597            Fixture { _tmp: tmp, home, shared, target_path, target }
598        }
599
600        fn channel_store(&self, channel: &str) -> Store {
601            store_at(&self.home.join("channels").join(channel).join("identity-store.db"))
602        }
603
604        fn store_path(&self, channel: &str) -> PathBuf {
605            self.home.join("channels").join(channel).join("identity-store.db")
606        }
607
608        fn run(&self, channel: &str) -> Report {
609            let instance = self.home.join("channels").join(channel);
610            let inputs = Inputs {
611                home: &self.home,
612                shared_dir: &self.shared,
613                channel,
614                instance_dir: Some(&instance),
615                target_path: &self.target_path,
616                budget: Duration::from_secs(30),
617            };
618            adopt(&self.target, &inputs)
619        }
620    }
621
622    fn set_age(path: &Path, secs_ago: u64) {
623        let f = fs::OpenOptions::new().write(true).open(path).unwrap();
624        f.set_modified(SystemTime::now() - Duration::from_secs(secs_ago)).unwrap();
625    }
626
627    #[test]
628    fn a_build_channel_names_its_branch_prefix() {
629        assert_eq!(build_prefix("local-main-b28b7a-051fbf53"), Some("local-main-b28b7a-"));
630        assert_eq!(
631            build_prefix("local-agent1-release-patch-bump-abe85c-306df097"),
632            Some("local-agent1-release-patch-bump-abe85c-")
633        );
634        // not a per-build channel: no 8-hex suffix, or not a local- build
635        assert_eq!(build_prefix("stable"), None);
636        assert_eq!(build_prefix("dev-some-branch"), None);
637        assert_eq!(build_prefix("local-main-b28b7a-xyz"), None);
638        assert_eq!(build_prefix("custom-channel-051fbf53"), None);
639    }
640
641    #[test]
642    fn the_predecessor_is_the_newest_other_build_of_the_same_branch() {
643        let f = Fixture::new();
644        for ch in ["local-main-b28b7a-00000001", "local-main-b28b7a-00000002", "local-main-b28b7a-00000003", "local-other-111111-00000004"] {
645            account(&f.channel_store(ch), &format!("acct-in-{ch}"));
646        }
647        for (ch, age) in [
648            ("local-main-b28b7a-00000001", 900),
649            ("local-main-b28b7a-00000002", 500),
650            ("local-main-b28b7a-00000003", 10),
651            ("local-other-111111-00000004", 100),
652        ] {
653            set_age(&f.store_path(ch), age);
654        }
655        let instance = f.home.join("channels/local-main-b28b7a-00000003");
656        let inputs = Inputs {
657            home: &f.home,
658            shared_dir: &f.shared,
659            channel: "local-main-b28b7a-00000003",
660            instance_dir: Some(&instance),
661            target_path: &f.target_path,
662            budget: Duration::from_secs(5),
663        };
664        let s = discover_sources(&inputs);
665        let names: Vec<String> =
666            s.accounts.iter().map(|p| p.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned()).collect();
667        // its own store, then the newest OTHER build of the same branch (not the other branch's, not the older one)
668        assert_eq!(names, ["local-main-b28b7a-00000003", "local-main-b28b7a-00000002"]);
669        // every channel store is a source for memory, newest first, and the target is never one
670        assert_eq!(s.all.len(), 4);
671        assert!(s.all.windows(2).all(|w| modified(&w[0]) >= modified(&w[1])));
672        assert!(!s.all.iter().any(|p| same_file(p, &f.target_path)));
673    }
674
675    #[test]
676    fn a_build_that_holds_no_login_is_passed_over_as_the_predecessor() {
677        let f = Fixture::new();
678        // the newest other build was only opened and closed; the one before it holds the login
679        let old = f.channel_store("local-main-b28b7a-00000001");
680        account(&old, "acct-real");
681        f.channel_store("local-main-b28b7a-00000002");
682        f.channel_store("local-main-b28b7a-00000003");
683        set_age(&f.store_path("local-main-b28b7a-00000001"), 900);
684        set_age(&f.store_path("local-main-b28b7a-00000002"), 100);
685        let instance = f.home.join("channels/local-main-b28b7a-00000003");
686        let inputs = Inputs {
687            home: &f.home,
688            shared_dir: &f.shared,
689            channel: "local-main-b28b7a-00000003",
690            instance_dir: Some(&instance),
691            target_path: &f.target_path,
692            budget: Duration::from_secs(5),
693        };
694        let s = discover_sources(&inputs);
695        let names: Vec<String> =
696            s.accounts.iter().map(|p| p.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned()).collect();
697        assert_eq!(names, ["local-main-b28b7a-00000003", "local-main-b28b7a-00000001"]);
698        let r = adopt(&f.target, &inputs);
699        assert_eq!(r.accounts_added, 1, "{r:?}");
700    }
701
702    #[test]
703    fn global_memory_is_unioned_newest_wins_and_leaves_the_rest_behind() {
704        let f = Fixture::new();
705        let a = f.channel_store("local-main-b28b7a-0000000a");
706        let b = f.channel_store("local-main-b28b7a-0000000b");
707        set_age(&f.store_path("local-main-b28b7a-0000000a"), 200);
708        set_age(&f.store_path("local-main-b28b7a-0000000b"), 100);
709        bundle(&a, "gm-x", "GitHub and AWS access", true, false, 10, "old text");
710        bundle(&a, "junk-1", "Camper - ABF", false, false, 10, "a per-agent bundle");
711        bundle(&b, "gm-x", "GitHub and AWS access", true, false, 20, "newer text");
712        bundle(&b, "gm-y", "Use gh-agent", true, false, 5, "second entry");
713        bundle(&b, "sys-1", "System global", true, true, 5, "seeded by the app");
714
715        let r = f.run("local-main-b28b7a-0000000c");
716        assert_eq!(r.global_memory_added, 2, "{r:?}");
717        // (a fresh shared store seeds its own `blank` bundle, which is not ours to count)
718        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_bundles WHERE id <> 'blank'"), 2);
719        assert_eq!(
720            text(&f.target, "SELECT instructions FROM db_bundles WHERE id = 'gm-x'"),
721            "newer text",
722            "the newest copy wins"
723        );
724        assert_eq!(
725            count(&f.target, "SELECT COUNT(*) FROM db_bundles WHERE id IN ('junk-1', 'sys-1')"),
726            0,
727            "non-global and system bundles stay behind"
728        );
729        assert!(
730            count(&f.target, "SELECT COUNT(*) FROM db_bundle_versions WHERE bundle_id = 'gm-x'") >= 1,
731            "versions come along"
732        );
733        // read-only on the sources
734        assert_eq!(count(&a, "SELECT COUNT(*) FROM db_bundles WHERE id = 'gm-x'"), 1);
735        assert_eq!(count(&a, "SELECT COUNT(*) FROM db_bundles WHERE id <> 'blank'"), 2, "nothing was removed from the source");
736    }
737
738    #[test]
739    fn native_memory_is_unioned_by_agent_and_file_newest_wins() {
740        let f = Fixture::new();
741        let a = f.channel_store("local-main-b28b7a-0000000a");
742        let b = f.channel_store("local-main-b28b7a-0000000b");
743        set_age(&f.store_path("local-main-b28b7a-0000000a"), 200);
744        native(&a, "agent-1", "notes.md", 5, "older notes");
745        native(&a, "agent-1", "only-in-a.md", 5, "a own");
746        native(&b, "agent-1", "notes.md", 9, "newer notes");
747        native(&b, "agent-2", "other.md", 1, "b own");
748
749        let r = f.run("local-main-b28b7a-0000000c");
750        assert_eq!(r.native_memory_added, 3, "{r:?}");
751        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_agent_native_memory"), 3);
752        assert_eq!(
753            text(&f.target, "SELECT content FROM db_agent_native_memory WHERE agent_id='agent-1' AND filename='notes.md'"),
754            "newer notes"
755        );
756        assert!(count(&f.target, "SELECT COUNT(*) FROM db_agent_native_memory_versions") >= 3);
757    }
758
759    #[test]
760    fn accounts_come_only_from_this_channel_and_its_predecessor_and_credentials_not_at_all() {
761        let f = Fixture::new();
762        let own = f.channel_store("local-main-b28b7a-0000000c");
763        let pred = f.channel_store("local-main-b28b7a-0000000b");
764        let stranger = f.channel_store("local-main-b28b7a-0000000a");
765        let other_branch = f.channel_store("local-other-111111-0000000d");
766        set_age(&f.store_path("local-main-b28b7a-0000000a"), 300);
767        set_age(&f.store_path("local-main-b28b7a-0000000b"), 100);
768        set_age(&f.store_path("local-other-111111-0000000d"), 50);
769        account(&own, "acct-own");
770        credential(&own, "agent-own");
771        account(&pred, "acct-pred");
772        credential(&pred, "agent-pred");
773        account(&stranger, "acct-stranger");
774        credential(&stranger, "agent-stranger");
775        account(&other_branch, "acct-other-branch");
776
777        let r = f.run("local-main-b28b7a-0000000c");
778        assert_eq!(r.accounts_added, 2, "{r:?}");
779        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_agent_credentials"), 0, "credentials are not adopted");
780        let ids = text(&f.target, "SELECT group_concat(id) FROM (SELECT id FROM db_accounts ORDER BY id)");
781        assert_eq!(ids, "acct-own,acct-pred");
782        // the stored folder still points at the source channel, so the login works at once
783        assert!(text(&f.target, "SELECT secret_ref FROM db_accounts WHERE id='acct-pred'").contains("/old/acct-pred"));
784    }
785
786    #[test]
787    fn a_second_pass_reads_nothing_and_changes_nothing() {
788        let f = Fixture::new();
789        let a = f.channel_store("local-main-b28b7a-0000000a");
790        bundle(&a, "gm-x", "Entry", true, false, 10, "text");
791        native(&a, "agent-1", "n.md", 1, "x");
792        let first = f.run("local-main-b28b7a-0000000c");
793        assert!(first.changed_anything() && first.sources_read >= 1);
794        let second = f.run("local-main-b28b7a-0000000c");
795        assert!(!second.changed_anything(), "{second:?}");
796        assert_eq!(second.sources_read, 0, "an unchanged source is not reopened: {second:?}");
797        assert!(second.sources_unchanged >= 1);
798    }
799
800    #[test]
801    fn something_deleted_after_adoption_is_not_put_back() {
802        let f = Fixture::new();
803        let pred = f.channel_store("local-main-b28b7a-0000000b");
804        f.channel_store("local-main-b28b7a-0000000c");
805        account(&pred, "acct-pred");
806        bundle(&pred, "gm-x", "Entry", true, false, 10, "text");
807        native(&pred, "agent-1", "n.md", 1, "x");
808        let r = f.run("local-main-b28b7a-0000000c");
809        assert_eq!((r.accounts_added, r.global_memory_added, r.native_memory_added), (1, 1, 1), "{r:?}");
810
811        // the owner deletes all three from the shared store...
812        sql(
813            &f.target,
814            "DELETE FROM db_accounts; DELETE FROM db_bundle_versions; DELETE FROM db_bundles; DELETE FROM db_agent_native_memory;",
815        );
816        // ...and the source changes (the old build is still running and writes something)
817        native(&pred, "agent-9", "later.md", 2, "new unrelated entry");
818        set_age(&f.store_path("local-main-b28b7a-0000000b"), 1);
819        let r2 = f.run("local-main-b28b7a-0000000c");
820        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_accounts"), 0, "{r2:?}");
821        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_bundles"), 0);
822        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_agent_native_memory WHERE agent_id = 'agent-1'"), 0);
823        assert_eq!(
824            count(&f.target, "SELECT COUNT(*) FROM db_agent_native_memory WHERE agent_id = 'agent-9'"),
825            1,
826            "genuinely new data still arrives"
827        );
828        assert!(r2.deleted_stay_deleted >= 3, "{r2:?}");
829    }
830
831    #[test]
832    fn a_name_the_target_already_has_under_another_id_is_refused_not_fatal() {
833        let f = Fixture::new();
834        bundle(&f.target, "mine", "Same name", true, false, 1, "target own");
835        let a = f.channel_store("local-main-b28b7a-0000000a");
836        bundle(&a, "theirs", "Same name", true, false, 99, "source copy");
837        bundle(&a, "gm-ok", "Different name", true, false, 1, "fine");
838        let r = f.run("local-main-b28b7a-0000000c");
839        assert_eq!(r.rows_refused, 1, "{r:?}");
840        assert_eq!(r.global_memory_added, 1);
841        assert_eq!(text(&f.target, "SELECT instructions FROM db_bundles WHERE name='Same name'"), "target own");
842    }
843
844    #[test]
845    fn a_source_with_an_older_schema_is_still_read() {
846        let f = Fixture::new();
847        // an old per-channel store: no is_system column, fewer bundle columns, no versions table
848        let dir = f.home.join("channels/local-main-b28b7a-0000000a");
849        fs::create_dir_all(&dir).unwrap();
850        let old = Connection::open(dir.join("identity-store.db")).unwrap();
851        old.execute_batch(
852            "CREATE TABLE db_bundles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, is_global INTEGER NOT NULL DEFAULT 0, instructions TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0);
853             INSERT INTO db_bundles (id, name, is_global, instructions, updated_at) VALUES ('gm-old', 'Old entry', 1, 'from an old build', 3);
854             CREATE TABLE db_agent_native_memory (agent_id TEXT NOT NULL, filename TEXT NOT NULL, content TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (agent_id, filename));
855             INSERT INTO db_agent_native_memory VALUES ('agent-1', 'f.md', 'old memory', 3);",
856        )
857        .unwrap();
858        drop(old);
859        let r = f.run("local-main-b28b7a-0000000c");
860        assert_eq!((r.global_memory_added, r.native_memory_added), (1, 1), "{r:?}");
861        assert_eq!(text(&f.target, "SELECT instructions FROM db_bundles WHERE id='gm-old'"), "from an old build");
862    }
863
864    #[test]
865    fn a_broken_source_is_counted_and_the_pass_carries_on() {
866        let f = Fixture::new();
867        let dir = f.home.join("channels/local-main-b28b7a-0000000a");
868        fs::create_dir_all(&dir).unwrap();
869        fs::write(dir.join("identity-store.db"), b"this is not a sqlite database at all").unwrap();
870        let good = f.channel_store("local-main-b28b7a-0000000b");
871        bundle(&good, "gm-x", "Entry", true, false, 1, "text");
872        let r = f.run("local-main-b28b7a-0000000c");
873        assert_eq!(r.global_memory_added, 1, "{r:?}");
874    }
875
876    #[test]
877    fn the_shared_store_is_never_read_as_a_source() {
878        let f = Fixture::new();
879        // a store at a channel path that IS the target
880        bundle(&f.target, "gm-own", "Already shared", true, false, 1, "x");
881        let inputs = Inputs {
882            home: &f.home,
883            shared_dir: &f.shared,
884            channel: "local-main-b28b7a-0000000c",
885            instance_dir: None,
886            target_path: &f.target_path,
887            budget: Duration::from_secs(5),
888        };
889        // the target lives under shared/, outside channels/, and is excluded even if listed
890        let s = discover_sources(&inputs);
891        assert!(s.all.is_empty() && s.accounts.is_empty());
892        let r = adopt(&f.target, &inputs);
893        assert_eq!(r.sources_read, 0, "{r:?}");
894        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_bundles WHERE id <> 'blank'"), 1);
895    }
896
897    #[test]
898    fn a_source_whose_commit_fails_leaves_no_ledger_trace_and_is_retried() {
899        let f = Fixture::new();
900        let a = f.channel_store("local-main-b28b7a-0000000a");
901        bundle(&a, "gm-x", "Entry", true, false, 10, "text");
902        // A deferred foreign key violated by a trigger makes COMMIT (not the INSERT) fail.
903        sql(
904            &f.target,
905            "CREATE TABLE fk_parent (id TEXT PRIMARY KEY);
906             CREATE TABLE fk_child (pid TEXT REFERENCES fk_parent(id) DEFERRABLE INITIALLY DEFERRED);
907             CREATE TRIGGER break_commit AFTER INSERT ON db_bundles BEGIN INSERT INTO fk_child VALUES ('missing'); END;",
908        );
909        let r = f.run("local-main-b28b7a-0000000c");
910        assert_eq!(r.sources_failed, 1, "{r:?}");
911        assert_eq!(r.global_memory_added, 0, "a rolled-back source counts nothing: {r:?}");
912        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_bundles WHERE id <> 'blank'"), 0);
913
914        // The keys must not have been recorded, or the retry would read them as "deleted".
915        sql(&f.target, "DROP TRIGGER break_commit");
916        let again = f.run("local-main-b28b7a-0000000c");
917        assert_eq!(again.global_memory_added, 1, "{again:?}");
918        assert_eq!(again.deleted_stay_deleted, 0, "{again:?}");
919        assert_eq!(count(&f.target, "SELECT COUNT(*) FROM db_bundles WHERE id = 'gm-x'"), 1);
920    }
921
922    #[test]
923    fn two_boots_saving_the_ledger_merge_instead_of_overwriting() {
924        let f = Fixture::new();
925        let mut one = Ledger::default();
926        one.keys.insert("gm:one".into());
927        let mut two = Ledger::default();
928        two.keys.insert("gm:two".into());
929        save_ledger(&f.shared, &one);
930        // the second boot never saw the first one's keys
931        save_ledger(&f.shared, &two);
932        let saved = load_ledger(&f.shared);
933        assert!(saved.keys.contains("gm:one") && saved.keys.contains("gm:two"), "{:?}", saved.keys);
934        let leftovers: Vec<_> = fs::read_dir(&f.shared)
935            .unwrap()
936            .flatten()
937            .map(|e| e.file_name().to_string_lossy().into_owned())
938            .filter(|n| n.ends_with(".tmp"))
939            .collect();
940        assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
941    }
942
943    #[test]
944    fn an_unreadable_ledger_is_kept_aside_not_overwritten() {
945        let f = Fixture::new();
946        fs::write(f.shared.join(LEDGER_FILE), b"{ this is not json").unwrap();
947        let ledger = load_ledger(&f.shared);
948        assert!(ledger.keys.is_empty());
949        assert!(f.shared.join(format!("{LEDGER_FILE}.corrupt")).is_file(), "the unreadable file is kept for inspection");
950    }
951
952    /// Phase 0 of `SPEC_SHARED_AUTH_ACROSS_CHANNELS_2026_10_03.md`: run the real
953    /// pass over a real `~/.agentmux` into a throwaway store and print what it
954    /// would carry over, counts only. The sources are opened read-only and the
955    /// target is a temp file, so nothing real is written.
956    ///
957    /// ```text
958    /// AGENTMUX_ADOPTION_DRYRUN_HOME=~/.agentmux
959    /// AGENTMUX_ADOPTION_DRYRUN_CHANNEL=local-main-b28b7a-ffffffff
960    /// cargo test -p agentmux-srv --bins -- --ignored --nocapture adoption_dry_run
961    /// ```
962    #[test]
963    #[ignore = "reads a real home; run by hand"]
964    fn adoption_dry_run_against_a_real_home() {
965        let Some(home) = std::env::var_os("AGENTMUX_ADOPTION_DRYRUN_HOME").map(PathBuf::from) else {
966            eprintln!("AGENTMUX_ADOPTION_DRYRUN_HOME is not set; nothing to do");
967            return;
968        };
969        let channel = std::env::var("AGENTMUX_ADOPTION_DRYRUN_CHANNEL").unwrap_or_else(|_| "local-main-b28b7a-ffffffff".into());
970        let tmp = tempfile::tempdir().unwrap();
971        let shared = tmp.path().join("shared");
972        let target_path = shared.join("store.db");
973        let target = store_at(&target_path);
974        let instance = tmp.path().join("this-channel");
975        let inputs = Inputs { home: &home, shared_dir: &shared, channel: &channel, instance_dir: Some(&instance), target_path: &target_path, budget: Duration::from_secs(120) };
976        let src = discover_sources(&inputs);
977        eprintln!("sources: {} (accounts from {} of them)", src.all.len(), src.accounts.len());
978        for p in &src.accounts {
979            eprintln!("  accounts from: {}", p.parent().and_then(|d| d.file_name()).unwrap().to_string_lossy());
980        }
981        let started = Instant::now();
982        let r = adopt(&target, &inputs);
983        eprintln!("{r:#?}
984elapsed: {} ms", started.elapsed().as_millis());
985        eprintln!(
986            "target now holds: {} global memory, {} native memory, {} accounts, {} agent credentials",
987            count(&target, "SELECT COUNT(*) FROM db_bundles WHERE is_global = 1 AND is_system = 0"),
988            count(&target, "SELECT COUNT(*) FROM db_agent_native_memory"),
989            count(&target, "SELECT COUNT(*) FROM db_accounts"),
990            count(&target, "SELECT COUNT(*) FROM db_agent_credentials"),
991        );
992    }
993}