1use std::collections::HashSet;
21use std::path::{Path, PathBuf};
22use std::time::Instant;
23
24use crate::backend::storage::store::Store;
25use crate::registry::{resolve_global_shared_root, resolve_shared_store_path};
26
27use super::{MigrationContext, MigrationScope, REGISTRY};
28
29fn emit(event: &str, id: &str, extra: &str) {
32 if extra.is_empty() {
33 println!("{{\"event\":\"{}\",\"id\":\"{}\"}}", event, id);
34 } else {
35 println!("{{\"event\":\"{}\",\"id\":\"{}\",{}}}", event, id, extra);
36 }
37}
38
39fn emit_summary(applied: usize, skipped: usize) {
40 println!(
41 "{{\"event\":\"complete\",\"applied\":{},\"skipped\":{}}}",
42 applied, skipped
43 );
44}
45
46fn resolve_home() -> Option<PathBuf> {
59 resolve_global_shared_root().and_then(|p| p.parent().map(Path::to_path_buf))
60}
61
62pub const VERIFY_FAILED_EXIT_CODE: i32 = 3;
71
72pub fn run_migrate_command(data_dir: &Path, dry_run: bool, list: bool, verify: bool) -> i32 {
76 let shared_store_path = match resolve_shared_store_path() {
80 Some(p) => p,
81 None => {
82 emit_summary(0, 0);
83 return 0;
84 }
85 };
86
87 let home = match resolve_home() {
88 Some(p) => p,
89 None => {
90 emit_summary(0, 0);
91 return 0;
92 }
93 };
94
95 if verify {
101 return cmd_verify(data_dir, &home, &shared_store_path);
102 }
103
104 if let Some(parent) = shared_store_path.parent() {
108 if let Err(e) = std::fs::create_dir_all(parent) {
109 eprintln!("migration: failed to create shared dir {}: {}", parent.display(), e);
110 return 1;
111 }
112 }
113
114 if list || dry_run {
120 let (shared_store, channel_store) = match open_stores_for_preview(&shared_store_path, data_dir) {
121 Ok(pair) => pair,
122 Err(msg) => {
123 eprintln!("migration: {}", msg);
124 return 1;
125 }
126 };
127 if list {
128 return cmd_list(&shared_store, channel_store.as_ref());
129 }
130 let pending = pending_of(REGISTRY, &shared_store, channel_store.as_ref());
131 if pending.is_empty() {
132 emit_summary(0, REGISTRY.len());
133 }
134 for m in &pending {
135 println!("pending: {} — {}", m.id(), m.description());
136 }
137 return 0;
138 }
139
140 let progress = ApplyProgress {
148 on_start: &|id, description| {
149 emit("migration_start", id, &format!("\"description\":\"{}\"", description));
150 },
151 on_done: &|id, ms| emit("migration_done", id, &format!("\"duration_ms\":{}", ms)),
152 };
153 match apply_pending(REGISTRY, &home, &shared_store_path, data_dir, Some(&progress)) {
154 Ok(outcome) => {
155 emit_summary(outcome.applied, outcome.skipped);
156 0
157 }
158 Err(msg) => {
159 write_error_log(&home, &msg);
160 eprintln!("{}", msg);
161 1
162 }
163 }
164}
165
166fn tracking_store<'a>(
169 scope: super::MigrationScope,
170 shared: &'a Store,
171 channel: Option<&'a Store>,
172) -> Option<&'a Store> {
173 match scope {
174 super::MigrationScope::Global => Some(shared),
175 super::MigrationScope::Channel => channel,
176 }
177}
178
179fn cmd_list(shared_store: &Store, channel_store: Option<&Store>) -> i32 {
180 let shared_applied = shared_store.migrations_list_applied().unwrap_or_default();
181 let channel_applied = channel_store
182 .and_then(|s| s.migrations_list_applied().ok())
183 .unwrap_or_default();
184 for m in REGISTRY.iter() {
185 let applied_ids = match m.scope() {
186 super::MigrationScope::Global => &shared_applied,
187 super::MigrationScope::Channel => &channel_applied,
188 };
189 let status = if applied_ids.contains(&m.id().to_string()) { "applied" } else { "pending" };
190 println!("{} [{}] [{}] — {}", m.id(), m.scope().as_str(), status, m.description());
191 }
192 0
193}
194
195fn backup_stores(home: &Path, shared_store_path: &Path, data_dir: &Path) -> std::io::Result<()> {
198 let version = env!("CARGO_PKG_VERSION");
199 let ts = std::time::SystemTime::now()
200 .duration_since(std::time::UNIX_EPOCH)
201 .map(|d| d.as_secs())
202 .unwrap_or(0);
203 let backup_dir = home
204 .join("shared")
205 .join("backups")
206 .join(format!("pre-migration-{}-{}", version, ts));
207 std::fs::create_dir_all(&backup_dir)?;
208
209 if shared_store_path.exists() {
211 std::fs::copy(shared_store_path, backup_dir.join("store.db"))?;
212 }
213
214 let objects_db = data_dir.join("db").join("objects.db");
216 if objects_db.exists() {
217 std::fs::copy(&objects_db, backup_dir.join("objects.db"))?;
218 }
219
220 prune_old_backups(home);
221 Ok(())
222}
223
224fn prune_old_backups(home: &Path) {
225 let backups_dir = home.join("shared").join("backups");
226 let cutoff = std::time::SystemTime::now()
227 .duration_since(std::time::UNIX_EPOCH)
228 .map(|d| d.as_secs())
229 .unwrap_or(0)
230 .saturating_sub(30 * 24 * 60 * 60); let Ok(entries) = std::fs::read_dir(&backups_dir) else { return };
233 for entry in entries.flatten() {
234 let path = entry.path();
235 if !path.is_dir() { continue; }
236 let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
237 if let Some(ts_str) = name.rsplit('-').next() {
239 if let Ok(ts) = ts_str.parse::<u64>() {
240 if ts < cutoff {
241 let _ = std::fs::remove_dir_all(&path);
242 }
243 }
244 }
245 }
246}
247
248pub fn run_pending_migrations(data_dir: &Path) -> Result<usize, String> {
265 let shared_store_path = match resolve_shared_store_path() {
266 Some(p) => p,
267 None => {
268 tracing::info!("run_pending_migrations: shared store path unresolvable — nothing to do");
269 return Ok(0);
270 }
271 };
272
273 let home = match resolve_home() {
274 Some(p) => p,
275 None => return Err("run_pending_migrations: cannot resolve global shared root".to_string()),
276 };
277
278 let progress = ApplyProgress {
294 on_start: &|id, description| {
295 eprintln!("{}", agentmux_common::srv_stderr::migration_begin_line(id, description));
296 },
297 on_done: &|id, ms| {
298 eprintln!("{}", agentmux_common::srv_stderr::migration_end_line(id, ms));
299 },
300 };
301
302 apply_pending(REGISTRY, &home, &shared_store_path, data_dir, Some(&progress)).map(|o| o.applied)
303}
304
305pub(super) const MIGRATION_LOCK_FILE: &str = "migrations.lock.db";
324
325const MIGRATION_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(30 * 60);
329
330pub(super) struct MigrationLock {
332 _conn: rusqlite::Connection,
333}
334
335impl MigrationLock {
336 pub(super) fn acquire(path: &Path, wait: std::time::Duration) -> Result<Self, String> {
340 let conn = rusqlite::Connection::open(path)
341 .map_err(|e| format!("open migration lock {}: {}", path.display(), e))?;
342 conn.busy_timeout(wait)
343 .map_err(|e| format!("migration lock busy_timeout: {}", e))?;
344 conn.execute_batch("CREATE TABLE IF NOT EXISTS lock (id INTEGER PRIMARY KEY); BEGIN IMMEDIATE;")
347 .map_err(|e| format!("acquire migration lock {} (another runner may still hold it): {}", path.display(), e))?;
348 Ok(Self { _conn: conn })
349 }
350}
351
352fn open_stores_for_preview(shared_store_path: &Path, data_dir: &Path) -> Result<(Store, Option<Store>), String> {
356 let shared_store = Store::open_shared(shared_store_path)
357 .map_err(|e| format!("open shared store: {}", e))?;
358 let channel_store_path = data_dir.join("db").join("objects.db");
359 if let Some(parent) = channel_store_path.parent() {
360 std::fs::create_dir_all(parent).map_err(|e| format!("create channel db dir: {}", e))?;
361 }
362 let channel_store = Store::open(&channel_store_path)
373 .map(Some)
374 .map_err(|e| format!("open channel store: {}", e))?;
375 Ok((shared_store, channel_store))
376}
377
378fn pending_of<'r>(
380 regs: &'r [&'r (dyn super::Migration + Sync)],
381 shared: &Store,
382 channel: Option<&Store>,
383) -> Vec<&'r (dyn super::Migration + Sync)> {
384 regs.iter()
385 .copied()
386 .filter(|m| {
387 tracking_store(m.scope(), shared, channel).map_or(false, |s| !s.migration_is_applied(m.id()))
388 })
389 .collect()
390}
391
392pub(super) struct ApplyProgress<'a> {
399 pub on_start: &'a dyn Fn(&str, &str),
400 pub on_done: &'a dyn Fn(&str, u64),
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
405pub(super) struct ApplyOutcome {
406 pub applied: usize,
407 pub skipped: usize,
408}
409
410pub(super) fn apply_pending(
420 regs: &[&(dyn super::Migration + Sync)],
421 home: &Path,
422 shared_store_path: &Path,
423 data_dir: &Path,
424 progress: Option<&ApplyProgress<'_>>,
425) -> Result<ApplyOutcome, String> {
426 if let Some(parent) = shared_store_path.parent() {
427 if let Err(e) = std::fs::create_dir_all(parent) {
428 return Err(format!("run_pending_migrations: create shared dir: {}", e));
429 }
430 }
431
432 let shared_lock_path = shared_store_path
441 .parent()
442 .map(|p| p.join(MIGRATION_LOCK_FILE))
443 .unwrap_or_else(|| PathBuf::from(MIGRATION_LOCK_FILE));
444 let _shared_lock = MigrationLock::acquire(&shared_lock_path, MIGRATION_LOCK_WAIT)
445 .map_err(|e| format!("run_pending_migrations: {}", e))?;
446 let channel_db_dir = data_dir.join("db");
447 if let Err(e) = std::fs::create_dir_all(&channel_db_dir) {
448 return Err(format!("run_pending_migrations: create channel db dir: {}", e));
449 }
450 let _channel_lock = MigrationLock::acquire(&channel_db_dir.join(MIGRATION_LOCK_FILE), MIGRATION_LOCK_WAIT)
451 .map_err(|e| format!("run_pending_migrations: {}", e))?;
452
453 let (shared_store, channel_store) = open_stores_for_preview(shared_store_path, data_dir)
454 .map_err(|e| format!("run_pending_migrations: {}", e))?;
455 let channel_store_path = channel_db_dir.join("objects.db");
456
457 let pending = pending_of(regs, &shared_store, channel_store.as_ref());
458 if pending.is_empty() {
459 return Ok(ApplyOutcome { applied: 0, skipped: regs.len() });
460 }
461 let skipped = regs.len() - pending.len();
462
463 let ctx = super::MigrationContext {
464 home: home.to_path_buf(),
465 data_dir: data_dir.to_path_buf(),
466 shared_store_path: shared_store_path.to_path_buf(),
467 channel_store_path: channel_store_path.clone(),
468 };
469
470 if let Err(e) = backup_stores(home, shared_store_path, data_dir) {
471 return Err(format!("run_pending_migrations: backup failed: {}", e));
472 }
473
474 let mut applied = 0;
475 for m in &pending {
476 let t = Instant::now();
477 tracing::info!(id = m.id(), description = m.description(), "run_pending_migrations: applying");
478 if let Some(p) = progress {
479 (p.on_start)(m.id(), m.description());
480 }
481 match m.up(&ctx) {
482 Ok(()) => {
483 let ms = t.elapsed().as_millis() as u64;
484 let scope = m.scope().as_str();
485 let tracking = tracking_store(m.scope(), &shared_store, channel_store.as_ref())
486 .ok_or_else(|| format!("run_pending_migrations: no tracking store for {} (channel store missing)", m.id()))?;
487 if let Err(e) = tracking.migration_mark_applied(m.id(), scope, ms) {
488 return Err(format!("run_pending_migrations: failed to record {} as applied: {}", m.id(), e));
489 }
490 tracing::info!(id = m.id(), duration_ms = ms, "run_pending_migrations: applied");
491 if let Some(p) = progress {
492 (p.on_done)(m.id(), ms);
493 }
494 applied += 1;
495 }
496 Err(e) => {
497 return Err(format!("run_pending_migrations: migration {} failed: {}", m.id(), e));
498 }
499 }
500 }
501
502 Ok(ApplyOutcome { applied, skipped })
503}
504
505#[derive(Debug)]
512pub enum PendingCountError {
513 SharedStoreUnresolvable,
516 SharedStoreUnreadable { path: PathBuf, error: String },
518 ChannelStoreUnreadable { path: PathBuf, error: String, global_pending: usize },
523}
524
525impl PendingCountError {
526 pub fn readable_pending(&self) -> usize {
536 match self {
537 Self::ChannelStoreUnreadable { global_pending, .. } => *global_pending,
538 Self::SharedStoreUnresolvable | Self::SharedStoreUnreadable { .. } => 0,
539 }
540 }
541}
542
543impl std::fmt::Display for PendingCountError {
544 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545 match self {
546 Self::SharedStoreUnresolvable => write!(f, "could not resolve the shared store path"),
547 Self::SharedStoreUnreadable { path, error } => {
548 write!(f, "failed to open the shared store at {}: {}", path.display(), error)
549 }
550 Self::ChannelStoreUnreadable { path, error, .. } => {
551 write!(f, "failed to open the channel store at {}: {}", path.display(), error)
552 }
553 }
554 }
555}
556
557pub fn try_count_pending_migrations(data_dir: &Path) -> Result<usize, PendingCountError> {
574 let shared_store_path =
575 resolve_shared_store_path().ok_or(PendingCountError::SharedStoreUnresolvable)?;
576 let shared_store = Store::open_shared(&shared_store_path).map_err(|e| {
577 PendingCountError::SharedStoreUnreadable {
578 path: shared_store_path.clone(),
579 error: e.to_string(),
580 }
581 })?;
582 let pending_in = |channel: Option<&Store>| {
583 REGISTRY
584 .iter()
585 .filter(|m| {
586 let tracking = tracking_store(m.scope(), &shared_store, channel);
587 tracking.map_or(false, |s| !s.migration_is_applied(m.id()))
588 })
589 .count()
590 };
591 let channel_store_path = data_dir.join("db").join("objects.db");
592 let channel_store = Store::open(&channel_store_path).map_err(|e| {
593 PendingCountError::ChannelStoreUnreadable {
594 path: channel_store_path.clone(),
595 error: e.to_string(),
596 global_pending: pending_in(None),
599 }
600 })?;
601 Ok(pending_in(Some(&channel_store)))
602}
603
604pub fn count_pending_migrations(data_dir: &Path) -> usize {
615 match try_count_pending_migrations(data_dir) {
616 Ok(n) => n,
617 Err(e) => {
618 let fallback = e.readable_pending();
622 tracing::warn!("count_pending_migrations: {} — reporting {}", e, fallback);
623 fallback
624 }
625 }
626}
627
628fn write_error_log(home: &Path, msg: &str) {
631 let log_dir = home.join("logs");
632 let _ = std::fs::create_dir_all(&log_dir);
633 let path = log_dir.join("migration-error.log");
634 let content = format!("{}\n", msg);
635 let _ = std::fs::write(path, content);
636}
637
638#[derive(Debug, Clone, PartialEq, Eq)]
655pub struct VerifyRecord {
656 pub id: &'static str,
657 pub scope: &'static str,
658 pub outcome: super::VerifyOutcome,
659}
660
661pub struct AppliedIds {
671 pub global: Result<HashSet<String>, String>,
672 pub channel: Result<HashSet<String>, String>,
673}
674
675impl AppliedIds {
676 fn for_scope(&self, scope: MigrationScope) -> &Result<HashSet<String>, String> {
677 match scope {
678 MigrationScope::Global => &self.global,
679 MigrationScope::Channel => &self.channel,
680 }
681 }
682}
683
684pub(super) fn open_readonly(path: &Path) -> Result<Option<rusqlite::Connection>, String> {
687 if !path.exists() {
688 return Ok(None);
689 }
690 rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
691 .map(Some)
692 .map_err(|e| format!("open {} read-only: {}", path.display(), e))
693}
694
695pub(super) fn read_applied_ids(path: &Path) -> Result<HashSet<String>, String> {
700 let Some(conn) = open_readonly(path)? else {
701 return Ok(HashSet::new());
702 };
703 let mut stmt = match conn.prepare("SELECT id FROM db_migrations") {
704 Ok(s) => s,
705 Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => {
706 return Ok(HashSet::new());
707 }
708 Err(e) => return Err(format!("read db_migrations in {}: {}", path.display(), e)),
709 };
710 stmt.query_map([], |row| row.get::<_, String>(0))
711 .and_then(|rows| rows.collect::<Result<HashSet<String>, _>>())
712 .map_err(|e| format!("read db_migrations in {}: {}", path.display(), e))
713}
714
715pub fn verify_applied(
719 regs: &[&(dyn super::Migration + Sync)],
720 ctx: &MigrationContext,
721 applied: &AppliedIds,
722) -> Vec<VerifyRecord> {
723 let mut out = Vec::new();
724 for m in regs {
725 let outcome = match applied.for_scope(m.scope()) {
726 Err(e) => super::VerifyOutcome::Error(format!("cannot read applied state: {}", e)),
727 Ok(ids) if ids.contains(m.id()) => m.verify(ctx),
728 Ok(_) => continue,
729 };
730 out.push(VerifyRecord { id: m.id(), scope: m.scope().as_str(), outcome });
731 }
732 out
733}
734
735pub fn verify_exit_code(records: &[VerifyRecord]) -> i32 {
738 if records.iter().any(|r| r.outcome.is_failure()) {
739 VERIFY_FAILED_EXIT_CODE
740 } else {
741 0
742 }
743}
744
745fn verify_event_line(r: &VerifyRecord) -> String {
749 let detail = serde_json::to_string(r.outcome.detail()).unwrap_or_else(|_| "\"\"".to_string());
750 format!(
751 "{{\"event\":\"migration_verify\",\"id\":\"{}\",\"scope\":\"{}\",\"status\":\"{}\",\"detail\":{}}}",
752 r.id,
753 r.scope,
754 r.outcome.label(),
755 detail
756 )
757}
758
759#[derive(Debug, Clone, serde::Serialize)]
772pub struct DoctorReport {
773 pub data_dir: String,
774 pub shared_store: String,
775 pub channel_store: String,
776 pub tracking_errors: Vec<String>,
780 pub migrations: Vec<DoctorRow>,
781 pub summary: DoctorSummary,
782 pub exit_code: i32,
785}
786
787#[derive(Debug, Clone, serde::Serialize)]
789pub struct DoctorRow {
790 pub id: &'static str,
791 pub scope: &'static str,
792 pub description: &'static str,
793 pub state: &'static str,
796 #[serde(skip_serializing_if = "Option::is_none")]
800 pub verify: Option<&'static str>,
801 #[serde(skip_serializing_if = "String::is_empty")]
803 pub detail: String,
804}
805
806#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
807pub struct DoctorSummary {
808 pub total: usize,
809 pub applied: usize,
810 pub pending: usize,
811 pub unknown: usize,
812 pub checked: usize,
815 pub ok: usize,
816 pub mismatch: usize,
817 pub error: usize,
818 pub not_verifiable: usize,
819}
820
821fn doctor_inputs(data_dir: &Path, home: &Path, shared_store_path: &Path) -> (MigrationContext, AppliedIds) {
824 let channel_store_path = data_dir.join("db").join("objects.db");
825 let ctx = MigrationContext {
826 home: home.to_path_buf(),
827 data_dir: data_dir.to_path_buf(),
828 shared_store_path: shared_store_path.to_path_buf(),
829 channel_store_path: channel_store_path.clone(),
830 };
831 let applied = AppliedIds {
832 global: read_applied_ids(shared_store_path),
833 channel: read_applied_ids(&channel_store_path),
834 };
835 (ctx, applied)
836}
837
838fn build_doctor_report(
842 regs: &[&(dyn super::Migration + Sync)],
843 ctx: &MigrationContext,
844 applied: &AppliedIds,
845 records: &[VerifyRecord],
846) -> DoctorReport {
847 let mut summary = DoctorSummary { total: regs.len(), ..Default::default() };
848 let mut migrations = Vec::with_capacity(regs.len());
849 for m in regs {
850 let record = records.iter().find(|r| r.id == m.id());
851 let state = match applied.for_scope(m.scope()) {
852 Err(_) => "unknown",
853 Ok(ids) if ids.contains(m.id()) => "applied",
854 Ok(_) => "pending",
855 };
856 match state {
857 "applied" => summary.applied += 1,
858 "pending" => summary.pending += 1,
859 _ => summary.unknown += 1,
860 }
861 let (verify, detail) = match record {
862 Some(r) => {
863 summary.checked += 1;
864 match r.outcome {
865 super::VerifyOutcome::Ok(_) => summary.ok += 1,
866 super::VerifyOutcome::Mismatch(_) => summary.mismatch += 1,
867 super::VerifyOutcome::Error(_) => summary.error += 1,
868 super::VerifyOutcome::NotVerifiable => summary.not_verifiable += 1,
869 }
870 (Some(r.outcome.label()), r.outcome.detail().to_string())
871 }
872 None => (None, String::new()),
873 };
874 migrations.push(DoctorRow {
875 id: m.id(),
876 scope: m.scope().as_str(),
877 description: m.description(),
878 state,
879 verify,
880 detail,
881 });
882 }
883 let tracking_errors = [&applied.global, &applied.channel]
884 .into_iter()
885 .filter_map(|r| r.as_ref().err().cloned())
886 .collect();
887 DoctorReport {
888 data_dir: ctx.data_dir.display().to_string(),
889 shared_store: ctx.shared_store_path.display().to_string(),
890 channel_store: ctx.channel_store_path.display().to_string(),
891 tracking_errors,
892 migrations,
893 summary,
894 exit_code: verify_exit_code(records),
895 }
896}
897
898pub fn doctor_report(
901 regs: &[&(dyn super::Migration + Sync)],
902 data_dir: &Path,
903 home: &Path,
904 shared_store_path: &Path,
905) -> DoctorReport {
906 let (ctx, applied) = doctor_inputs(data_dir, home, shared_store_path);
907 let records = verify_applied(regs, &ctx, &applied);
908 build_doctor_report(regs, &ctx, &applied, &records)
909}
910
911pub fn doctor_report_for_instance(data_dir: &Path) -> Result<DoctorReport, String> {
917 let shared_store_path = resolve_shared_store_path()
918 .ok_or_else(|| "shared store path is unresolvable (AGENTMUX_SHARED_DIR / home not set)".to_string())?;
919 let home = resolve_home().ok_or_else(|| "AgentMux home is unresolvable".to_string())?;
920 Ok(doctor_report(REGISTRY, data_dir, &home, &shared_store_path))
921}
922
923fn cmd_verify(data_dir: &Path, home: &Path, shared_store_path: &Path) -> i32 {
924 let (ctx, applied) = doctor_inputs(data_dir, home, shared_store_path);
925 let records = verify_applied(REGISTRY, &ctx, &applied);
926 for r in &records {
927 let detail = r.outcome.detail();
928 if detail.is_empty() {
929 println!("verify: {} [{}] [{}]", r.id, r.scope, r.outcome.label());
930 } else {
931 println!("verify: {} [{}] [{}] — {}", r.id, r.scope, r.outcome.label(), detail);
932 }
933 println!("{}", verify_event_line(r));
934 }
935 let report = build_doctor_report(REGISTRY, &ctx, &applied, &records);
936 let s = &report.summary;
937 println!(
938 "verify: {} applied checked — {} ok, {} mismatch, {} error, {} not verifiable (data dir {})",
939 s.checked,
940 s.ok,
941 s.mismatch,
942 s.error,
943 s.not_verifiable,
944 data_dir.display()
945 );
946 println!(
947 "{{\"event\":\"verify_complete\",\"checked\":{},\"ok\":{},\"mismatch\":{},\"error\":{},\"not_verifiable\":{}}}",
948 s.checked, s.ok, s.mismatch, s.error, s.not_verifiable
949 );
950 report.exit_code
951}
952
953pub(super) fn table_count(conn: &rusqlite::Connection, table: &str) -> Result<i64, String> {
959 match conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |row| row.get::<_, i64>(0)) {
960 Ok(n) => Ok(n),
961 Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => Ok(0),
962 Err(e) => Err(format!("count {}: {}", table, e)),
963 }
964}
965
966#[cfg(test)]
967mod lock_tests {
968 use super::*;
969 use crate::migrations::{Migration, MigrationError};
970 use std::sync::atomic::{AtomicUsize, Ordering};
971
972 #[test]
973 fn lock_is_exclusive_across_connections_and_released_on_drop() {
974 let dir = tempfile::tempdir().unwrap();
975 let path = dir.path().join(MIGRATION_LOCK_FILE);
976 let first = MigrationLock::acquire(&path, std::time::Duration::from_millis(100)).expect("first holder");
977 let err = MigrationLock::acquire(&path, std::time::Duration::from_millis(200))
979 .err()
980 .expect("second acquire must fail while the first is held");
981 assert!(err.contains("another runner may still hold it"), "{}", err);
982 drop(first);
983 MigrationLock::acquire(&path, std::time::Duration::from_millis(100)).expect("free after drop");
984 }
985
986 static UP_RUNS: AtomicUsize = AtomicUsize::new(0);
991 struct SlowOnce;
992 impl Migration for SlowOnce {
993 fn id(&self) -> &'static str { "9900_slow_once" }
994 fn scope(&self) -> MigrationScope { MigrationScope::Global }
995 fn description(&self) -> &'static str { "counts its own runs" }
996 fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> {
997 UP_RUNS.fetch_add(1, Ordering::SeqCst);
998 std::thread::sleep(std::time::Duration::from_millis(300));
999 Ok(())
1000 }
1001 }
1002 static SLOW_ONCE: SlowOnce = SlowOnce;
1003
1004 static CHANNEL_UP_RUNS: AtomicUsize = AtomicUsize::new(0);
1006 struct SlowOnceChannel;
1007 impl Migration for SlowOnceChannel {
1008 fn id(&self) -> &'static str { "9901_slow_once_channel" }
1009 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
1010 fn description(&self) -> &'static str { "counts its own runs (channel scope)" }
1011 fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> {
1012 CHANNEL_UP_RUNS.fetch_add(1, Ordering::SeqCst);
1013 std::thread::sleep(std::time::Duration::from_millis(300));
1014 Ok(())
1015 }
1016 }
1017 static SLOW_ONCE_CHANNEL: SlowOnceChannel = SlowOnceChannel;
1018
1019 #[test]
1020 fn runners_with_different_shared_stores_but_one_data_dir_still_serialize() {
1021 let dir = tempfile::tempdir().unwrap();
1026 let home = dir.path().to_path_buf();
1027 let data_dir = home.join("data");
1028 let shared_a = home.join("iso-a").join("shared").join("store.db");
1029 let shared_b = home.join("iso-b").join("shared").join("store.db");
1030 CHANNEL_UP_RUNS.store(0, Ordering::SeqCst);
1031
1032 let results: Vec<Result<ApplyOutcome, String>> = std::thread::scope(|s| {
1033 let handles: Vec<_> = [shared_a.clone(), shared_b.clone()]
1034 .into_iter()
1035 .map(|shared| {
1036 let (home, data_dir) = (home.clone(), data_dir.clone());
1037 s.spawn(move || {
1038 let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE_CHANNEL];
1039 apply_pending(®s, &home, &shared, &data_dir, None)
1040 })
1041 })
1042 .collect();
1043 handles.into_iter().map(|h| h.join().expect("runner thread")).collect()
1044 });
1045
1046 for r in &results {
1047 assert!(r.is_ok(), "both runners must succeed: {:?}", results);
1048 }
1049 let applied: usize = results.iter().map(|r| r.as_ref().unwrap().applied).sum();
1050 assert_eq!(applied, 1, "exactly one runner applied it: {:?}", results);
1051 assert_eq!(CHANNEL_UP_RUNS.load(Ordering::SeqCst), 1, "channel-scoped up() ran once despite two shared stores");
1052 }
1053
1054 #[test]
1055 fn previews_do_not_wait_on_the_migration_lock() {
1056 let dir = tempfile::tempdir().unwrap();
1060 let shared = dir.path().join("shared").join("store.db");
1061 std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
1062 let data_dir = dir.path().join("data");
1063 let _held = MigrationLock::acquire(&shared.parent().unwrap().join(MIGRATION_LOCK_FILE), std::time::Duration::from_millis(100))
1064 .expect("hold the lock");
1065
1066 let before = UP_RUNS.load(Ordering::SeqCst);
1067 let started = std::time::Instant::now();
1068 let (shared_store, channel_store) = open_stores_for_preview(&shared, &data_dir).expect("preview opens stores");
1069 let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE];
1070 let pending = pending_of(®s, &shared_store, channel_store.as_ref());
1071 assert_eq!(pending.len(), 1, "nothing applied yet, so the fake is pending");
1072 assert!(started.elapsed() < std::time::Duration::from_secs(5), "preview must not block on the lock");
1073 assert_eq!(UP_RUNS.load(Ordering::SeqCst), before, "preview never runs up()");
1074 }
1075
1076 #[test]
1077 fn two_concurrent_runners_on_one_data_dir_apply_a_migration_exactly_once() {
1078 let dir = tempfile::tempdir().unwrap();
1084 let home = dir.path().to_path_buf();
1085 let shared = home.join("shared").join("store.db");
1086 let data_dir = home.join("data");
1087 UP_RUNS.store(0, Ordering::SeqCst);
1088
1089 let results: Vec<Result<usize, String>> = std::thread::scope(|s| {
1090 let handles: Vec<_> = (0..2)
1091 .map(|_| {
1092 let (home, shared, data_dir) = (home.clone(), shared.clone(), data_dir.clone());
1093 s.spawn(move || {
1094 let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE];
1095 apply_pending(®s, &home, &shared, &data_dir, None).map(|o| o.applied)
1096 })
1097 })
1098 .collect();
1099 handles.into_iter().map(|h| h.join().expect("runner thread")).collect()
1100 });
1101
1102 for r in &results {
1103 assert!(r.is_ok(), "both runners must succeed: {:?}", results);
1104 }
1105 let applied: usize = results.iter().map(|r| *r.as_ref().unwrap()).sum();
1106 assert_eq!(applied, 1, "exactly one runner applied it: {:?}", results);
1107 assert_eq!(UP_RUNS.load(Ordering::SeqCst), 1, "up() body must execute exactly once");
1108
1109 let regs: Vec<&(dyn Migration + Sync)> = vec![&SLOW_ONCE];
1111 assert_eq!(apply_pending(®s, &home, &shared, &data_dir, None).map(|o| o.applied).unwrap(), 0);
1112 assert_eq!(UP_RUNS.load(Ordering::SeqCst), 1);
1113 }
1114}
1115
1116#[cfg(test)]
1117mod verify_tests {
1118 use super::*;
1119 use crate::migrations::{Migration, MigrationError, VerifyOutcome};
1120
1121 struct Fake {
1124 id: &'static str,
1125 scope: MigrationScope,
1126 outcome: VerifyOutcome,
1127 }
1128 impl Migration for Fake {
1129 fn id(&self) -> &'static str { self.id }
1130 fn scope(&self) -> MigrationScope { self.scope }
1131 fn description(&self) -> &'static str { "fake" }
1132 fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> { Ok(()) }
1133 fn verify(&self, _ctx: &MigrationContext) -> VerifyOutcome { self.outcome.clone() }
1134 }
1135
1136 struct Silent;
1138 impl Migration for Silent {
1139 fn id(&self) -> &'static str { "9998_silent" }
1140 fn scope(&self) -> MigrationScope { MigrationScope::Global }
1141 fn description(&self) -> &'static str { "no verify impl" }
1142 fn up(&self, _ctx: &MigrationContext) -> Result<(), MigrationError> { Ok(()) }
1143 }
1144
1145 fn ctx_in(dir: &tempfile::TempDir) -> MigrationContext {
1146 MigrationContext {
1147 home: dir.path().to_path_buf(),
1148 data_dir: dir.path().join("data"),
1149 shared_store_path: dir.path().join("shared").join("store.db"),
1150 channel_store_path: dir.path().join("data").join("db").join("objects.db"),
1151 }
1152 }
1153
1154 fn ids(list: &[&str]) -> Result<HashSet<String>, String> {
1155 Ok(list.iter().map(|s| s.to_string()).collect())
1156 }
1157
1158 #[test]
1159 fn only_applied_migrations_are_verified_and_unapplied_are_skipped() {
1160 let dir = tempfile::tempdir().unwrap();
1161 let applied = Fake { id: "9001_applied", scope: MigrationScope::Global, outcome: VerifyOutcome::Ok("fine".into()) };
1162 let pending = Fake { id: "9002_pending", scope: MigrationScope::Global, outcome: VerifyOutcome::Mismatch("would fail".into()) };
1163 let state = AppliedIds { global: ids(&["9001_applied"]), channel: ids(&[]) };
1164
1165 let regs: Vec<&(dyn Migration + Sync)> = vec![&applied, &pending];
1166 let records = verify_applied(®s, &ctx_in(&dir), &state);
1167 assert_eq!(records.len(), 1, "the pending one has nothing to verify yet");
1168 assert_eq!(records[0].id, "9001_applied");
1169 assert_eq!(records[0].outcome, VerifyOutcome::Ok("fine".into()));
1170 assert_eq!(verify_exit_code(&records), 0);
1171 }
1172
1173 #[test]
1174 fn channel_scoped_migrations_consult_the_channel_state_not_the_global_one() {
1175 let dir = tempfile::tempdir().unwrap();
1176 let m = Fake { id: "9003_channel", scope: MigrationScope::Channel, outcome: VerifyOutcome::Ok("ok".into()) };
1177 let regs: Vec<&(dyn Migration + Sync)> = vec![&m];
1178 let wrong = AppliedIds { global: ids(&["9003_channel"]), channel: ids(&[]) };
1181 assert!(verify_applied(®s, &ctx_in(&dir), &wrong).is_empty());
1182 let right = AppliedIds { global: ids(&[]), channel: ids(&["9003_channel"]) };
1183 assert_eq!(verify_applied(®s, &ctx_in(&dir), &right).len(), 1);
1184 }
1185
1186 #[test]
1187 fn unreadable_tracking_state_is_an_error_for_every_migration_of_that_scope() {
1188 let dir = tempfile::tempdir().unwrap();
1191 let g = Fake { id: "9004_global", scope: MigrationScope::Global, outcome: VerifyOutcome::Ok("never reached".into()) };
1192 let c = Fake { id: "9005_channel", scope: MigrationScope::Channel, outcome: VerifyOutcome::Ok("fine".into()) };
1193 let regs: Vec<&(dyn Migration + Sync)> = vec![&g, &c];
1194 let state = AppliedIds { global: Err("file is not a database".into()), channel: ids(&["9005_channel"]) };
1195 let records = verify_applied(®s, &ctx_in(&dir), &state);
1196 assert_eq!(records.len(), 2);
1197 assert!(matches!(&records[0].outcome, VerifyOutcome::Error(e) if e.contains("not a database")));
1198 assert_eq!(records[1].outcome, VerifyOutcome::Ok("fine".into()));
1199 assert_eq!(verify_exit_code(&records), VERIFY_FAILED_EXIT_CODE);
1200 }
1201
1202 #[test]
1203 fn a_migration_without_a_verify_impl_reports_not_verifiable_and_passes() {
1204 let dir = tempfile::tempdir().unwrap();
1205 let regs: Vec<&(dyn Migration + Sync)> = vec![&Silent];
1206 let state = AppliedIds { global: ids(&["9998_silent"]), channel: ids(&[]) };
1207 let records = verify_applied(®s, &ctx_in(&dir), &state);
1208 assert_eq!(records[0].outcome, VerifyOutcome::NotVerifiable);
1209 assert_eq!(verify_exit_code(&records), 0, "not verifiable is honest, not a failure");
1210 }
1211
1212 #[test]
1213 fn mismatch_or_error_fails_the_pass_with_a_code_distinct_from_run_failure_and_clap() {
1214 let mk = |o: VerifyOutcome| VerifyRecord { id: "x", scope: "global", outcome: o };
1215 assert_eq!(verify_exit_code(&[mk(VerifyOutcome::Ok("a".into())), mk(VerifyOutcome::NotVerifiable)]), 0);
1216 assert_eq!(verify_exit_code(&[mk(VerifyOutcome::Ok("a".into())), mk(VerifyOutcome::Mismatch("gone".into()))]), VERIFY_FAILED_EXIT_CODE);
1217 assert_eq!(verify_exit_code(&[mk(VerifyOutcome::Error("locked".into()))]), VERIFY_FAILED_EXIT_CODE);
1218 assert_ne!(VERIFY_FAILED_EXIT_CODE, 1, "1 = a migration run failed");
1219 assert_ne!(VERIFY_FAILED_EXIT_CODE, 2, "2 = clap usage error (bad flag)");
1220 }
1221
1222 #[test]
1223 fn read_applied_ids_reads_real_tracking_state_and_never_creates_a_store() {
1224 let dir = tempfile::tempdir().unwrap();
1225 let path = dir.path().join("shared").join("store.db");
1226 assert_eq!(read_applied_ids(&path).unwrap(), HashSet::new());
1228 assert!(!path.exists(), "verify must never create a database");
1229
1230 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1231 let store = Store::open_shared(&path).expect("open shared");
1232 store.migration_mark_applied("9010_a", "global", 1).unwrap();
1233 store.migration_mark_applied("9011_b", "global", 1).unwrap();
1234 drop(store);
1235 let got = read_applied_ids(&path).unwrap();
1236 assert!(got.contains("9010_a") && got.contains("9011_b"));
1237 assert_eq!(got.len(), 2);
1238 }
1239
1240 #[test]
1241 fn read_applied_ids_errors_on_a_file_that_is_not_a_database() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let path = dir.path().join("objects.db");
1244 std::fs::write(&path, b"definitely not sqlite\n").unwrap();
1245 let err = read_applied_ids(&path).unwrap_err();
1246 assert!(err.contains("objects.db"), "names the store: {}", err);
1247 }
1248
1249 #[test]
1250 fn table_count_treats_a_missing_table_as_zero_rows() {
1251 let dir = tempfile::tempdir().unwrap();
1252 let path = dir.path().join("objects.db");
1253 drop(Store::open(&path).expect("create channel store"));
1254 let conn = open_readonly(&path).unwrap().expect("exists");
1255 assert_eq!(table_count(&conn, "db_table_that_never_existed").unwrap(), 0);
1256 assert!(table_count(&conn, "db_migrations").is_ok());
1257 }
1258
1259 #[test]
1262 fn doctor_report_on_a_fresh_data_dir_lists_everything_pending_and_creates_nothing() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let data_dir = dir.path().join("data");
1265 let shared = dir.path().join("shared").join("store.db");
1266 let report = doctor_report(REGISTRY, &data_dir, dir.path(), &shared);
1267
1268 assert_eq!(report.migrations.len(), REGISTRY.len());
1269 assert!(report.migrations.iter().all(|r| r.state == "pending" && r.verify.is_none()));
1270 assert_eq!(report.summary.total, REGISTRY.len());
1271 assert_eq!(report.summary.pending, REGISTRY.len());
1272 assert_eq!(report.summary.checked, 0);
1273 assert!(report.tracking_errors.is_empty());
1274 assert_eq!(report.exit_code, 0);
1275 assert!(!shared.exists() && !data_dir.exists(), "doctor created files");
1277 let json = serde_json::to_value(&report).unwrap();
1279 assert_eq!(json["summary"]["pending"], REGISTRY.len());
1280 assert!(json["migrations"][0].get("verify").is_none(), "pending rows omit `verify`");
1281 }
1282
1283 #[test]
1284 fn doctor_report_reports_applied_rows_with_their_verdict_and_counts_them() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let data_dir = dir.path().join("data");
1287 let channel = data_dir.join("db").join("objects.db");
1288 std::fs::create_dir_all(channel.parent().unwrap()).unwrap();
1289 let shared = dir.path().join("shared").join("store.db");
1290 {
1291 let store = Store::open(&channel).expect("create channel store");
1294 store.migration_mark_applied("0007_agents_consolidate", "channel", 1).unwrap();
1295 }
1296 let report = doctor_report(REGISTRY, &data_dir, dir.path(), &shared);
1297 let row = report.migrations.iter().find(|r| r.id == "0007_agents_consolidate").unwrap();
1298 assert_eq!(row.state, "applied");
1299 assert_eq!(row.verify, Some("ok"));
1300 assert!(row.detail.contains("no legacy rows"), "{}", row.detail);
1301 assert_eq!(report.summary.applied, 1);
1302 assert_eq!(report.summary.checked, 1);
1303 assert_eq!(report.summary.ok, 1);
1304 assert_eq!(report.summary.pending, REGISTRY.len() - 1);
1305 assert_eq!(report.exit_code, 0);
1306 }
1307
1308 #[test]
1309 fn doctor_report_marks_a_scope_unknown_when_its_tracking_store_is_unreadable_and_exits_3() {
1310 let dir = tempfile::tempdir().unwrap();
1311 let data_dir = dir.path().join("data");
1312 let shared = dir.path().join("shared").join("store.db");
1313 std::fs::create_dir_all(shared.parent().unwrap()).unwrap();
1314 std::fs::write(&shared, b"definitely not sqlite\n").unwrap();
1315 let report = doctor_report(REGISTRY, &data_dir, dir.path(), &shared);
1316
1317 let global_rows: Vec<_> = report.migrations.iter().filter(|r| r.scope == "global").collect();
1318 assert!(!global_rows.is_empty());
1319 assert!(global_rows.iter().all(|r| r.state == "unknown" && r.verify == Some("error")));
1320 assert!(report.migrations.iter().filter(|r| r.scope == "channel").all(|r| r.state == "pending"));
1321 assert_eq!(report.summary.unknown, global_rows.len());
1322 assert_eq!(report.summary.error, global_rows.len());
1323 assert_eq!(report.tracking_errors.len(), 1, "{:?}", report.tracking_errors);
1325 assert!(report.tracking_errors[0].contains("store.db"));
1326 assert_eq!(report.exit_code, VERIFY_FAILED_EXIT_CODE);
1327 }
1328
1329 #[test]
1330 fn verify_event_line_is_valid_json_even_with_control_characters_in_detail() {
1331 let r = VerifyRecord {
1332 id: "9020_x",
1333 scope: "channel",
1334 outcome: VerifyOutcome::Error("line one\nline \"two\"\t\\end".into()),
1335 };
1336 let line = verify_event_line(&r);
1337 let parsed: serde_json::Value = serde_json::from_str(&line).expect("one JSON object per line");
1338 assert_eq!(parsed["event"], "migration_verify");
1339 assert_eq!(parsed["status"], "error");
1340 assert_eq!(parsed["detail"], "line one\nline \"two\"\t\\end");
1341 assert!(!line.contains('\n'), "must stay on one line");
1342 }
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347 use super::*;
1348
1349 use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
1356
1357 fn clear() {
1358 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1359 std::env::remove_var("AGENTMUX_SHARED_DIR");
1360 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1361 std::env::remove_var("AGENTMUX_INSTANCE_DIR");
1362 std::env::remove_var("AGENTMUX_CHANNEL");
1363 }
1364
1365 #[test]
1368 fn an_unreadable_shared_store_is_an_error_not_a_zero() {
1369 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1374 clear();
1375 let home = tempfile::tempdir().expect("tempdir");
1378 std::env::set_var("AGENTMUX_HOME_OVERRIDE", home.path());
1379 let data_dir = home.path().join("data");
1380
1381 let strict = try_count_pending_migrations(&data_dir);
1382 let lenient = count_pending_migrations(&data_dir);
1383 clear();
1384
1385 match strict {
1386 Err(PendingCountError::SharedStoreUnreadable { .. }) => {}
1387 Err(other) => panic!("expected SharedStoreUnreadable, got {other}"),
1388 Ok(n) => panic!("an unreadable shared store must not read as {n} pending"),
1389 }
1390 assert_eq!(lenient, 0, "the lenient wrapper still reports 0, as it always did");
1391 }
1392
1393 #[test]
1394 fn the_lenient_wrapper_keeps_reporting_the_global_half_it_could_read() {
1395 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1401 clear();
1402 let home = tempfile::tempdir().expect("tempdir");
1403 std::fs::create_dir_all(home.path().join("shared")).expect("shared dir");
1404 std::env::set_var("AGENTMUX_HOME_OVERRIDE", home.path());
1405 let data_dir = home.path().join("no-such-data-dir");
1407
1408 let strict = try_count_pending_migrations(&data_dir);
1409 let lenient = count_pending_migrations(&data_dir);
1410 let global_pending = match &strict {
1411 Err(PendingCountError::ChannelStoreUnreadable { global_pending, .. }) => *global_pending,
1412 other => {
1413 let msg = format!("precondition: expected ChannelStoreUnreadable, got {other:?}");
1414 clear();
1415 panic!("{msg}");
1416 }
1417 };
1418 clear();
1419
1420 assert_eq!(
1421 lenient, global_pending,
1422 "the lenient wrapper must keep reporting the readable global-scope count"
1423 );
1424 }
1425
1426 #[test]
1427 fn readable_pending_keeps_the_global_half_but_claims_nothing_otherwise() {
1428 assert_eq!(
1432 PendingCountError::ChannelStoreUnreadable {
1433 path: std::path::PathBuf::from("/tmp/objects.db"),
1434 error: "locked".into(),
1435 global_pending: 3,
1436 }
1437 .readable_pending(),
1438 3
1439 );
1440 assert_eq!(PendingCountError::SharedStoreUnresolvable.readable_pending(), 0);
1441 assert_eq!(
1442 PendingCountError::SharedStoreUnreadable {
1443 path: std::path::PathBuf::from("/tmp/store.db"),
1444 error: "locked".into(),
1445 }
1446 .readable_pending(),
1447 0
1448 );
1449 }
1450
1451 #[test]
1452 fn a_pending_count_error_says_which_store_and_why() {
1453 let e = PendingCountError::SharedStoreUnreadable {
1457 path: std::path::PathBuf::from("/tmp/store.db"),
1458 error: "disk I/O error".to_string(),
1459 };
1460 let msg = e.to_string();
1461 assert!(msg.contains("store.db"), "{msg}");
1462 assert!(msg.contains("disk I/O error"), "{msg}");
1463 }
1464
1465 #[test]
1474 fn home_is_invariant_to_isolated_auth() {
1475 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1476 clear();
1477 std::env::set_var("AGENTMUX_HOME_OVERRIDE", "/tmp/test-home");
1478
1479 let home_default = resolve_home().unwrap();
1480 assert_eq!(home_default, PathBuf::from("/tmp/test-home"));
1481
1482 let shared_store_path_default = resolve_shared_store_path().unwrap();
1483
1484 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1485 std::env::set_var("AGENTMUX_INSTANCE_DIR", "/tmp/test-home/dev/some-branch");
1486 let home_isolated = resolve_home().unwrap();
1487 let shared_store_path_isolated = resolve_shared_store_path().unwrap();
1488
1489 assert_eq!(
1490 home_default, home_isolated,
1491 "resolve_home() must be invariant to AGENTMUX_ISOLATED_AUTH/AGENTMUX_INSTANCE_DIR"
1492 );
1493 assert_ne!(
1497 shared_store_path_default, shared_store_path_isolated,
1498 "resolve_shared_store_path() must actually change under isolation"
1499 );
1500
1501 clear();
1502 }
1503
1504 #[test]
1509 fn home_and_store_are_invariant_to_the_channel_when_auth_is_shared() {
1510 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1511 clear();
1512 std::env::set_var("AGENTMUX_HOME_OVERRIDE", "/tmp/test-home");
1513
1514 let home_default = resolve_home().unwrap();
1515 let shared_store_path_default = resolve_shared_store_path().unwrap();
1516
1517 std::env::set_var("AGENTMUX_CHANNEL", "dev-some-branch");
1519 std::env::set_var("AGENTMUX_INSTANCE_DIR", "/tmp/test-home/dev/some-branch");
1520 let home_isolated = resolve_home().unwrap();
1521 let shared_store_path_isolated = resolve_shared_store_path().unwrap();
1522
1523 assert_eq!(
1524 home_default, home_isolated,
1525 "resolve_home() must not depend on the channel"
1526 );
1527 assert_eq!(
1528 shared_store_path_default, shared_store_path_isolated,
1529 "a non-stable channel alone must not move the shared store"
1530 );
1531
1532 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1534 assert_eq!(resolve_home().unwrap(), home_default);
1535 assert_ne!(resolve_shared_store_path().unwrap(), shared_store_path_default);
1536
1537 clear();
1538 }
1539}