1use 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);
41pub(crate) const BOOT_BUDGET: Duration = Duration::from_secs(8);
43
44#[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#[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 pub rows_refused: usize,
74 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 pub home: &'a Path,
93 pub shared_dir: &'a Path,
95 pub channel: &'a str,
97 pub instance_dir: Option<&'a Path>,
99 pub target_path: &'a Path,
101 pub budget: Duration,
102}
103
104#[derive(Debug, Default)]
107pub(crate) struct Sources {
108 pub all: Vec<PathBuf>,
110 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
125fn 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 if let Some(dir) = inp.instance_dir {
153 push(dir.join("identity-store.db"));
154 }
155 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 .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
190fn 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 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
225fn 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
282struct Spec<'a> {
284 table: &'static str,
285 cat: &'static str,
287 key: &'a [&'static str],
288 newer: Option<&'static str>,
290 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
302fn 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
381fn 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
397fn 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
411fn 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
421pub(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 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 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
485pub(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 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 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 assert_eq!(names, ["local-main-b28b7a-00000003", "local-main-b28b7a-00000002"]);
669 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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}