agentmux_srv/migrations/
m0011_shared_store_backfill.rs1use crate::backend::storage::store::Store;
5use crate::backend::storage::muxbus::MuxBusCredentials;
6use crate::drone::storage::DroneStore;
7use crate::registry;
8use super::{Migration, MigrationContext, MigrationError, MigrationScope};
9
10pub struct M0011SharedStoreBackfill;
11
12impl Migration for M0011SharedStoreBackfill {
13 fn id(&self) -> &'static str { "0011_shared_store_backfill" }
14 fn scope(&self) -> MigrationScope { MigrationScope::Global }
15 fn description(&self) -> &'static str { "Seed shared store from per-channel objects.db files" }
16
17 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
18 let shared = Store::open_shared(&ctx.shared_store_path)
19 .map_err(|e| MigrationError(format!("shared_store_backfill: open shared: {}", e)))?;
20
21 let skip_accts = !shared.identity_list(None).map_err(|e| e.to_string()).map_err(MigrationError)?.is_empty();
22 let skip_mem_bundles = !shared.bundle_list().map_err(|e| e.to_string()).map_err(MigrationError)?.iter().all(|b| b.id == "blank");
23 let skip_drones = !shared.drone_list().map_err(|e| e.to_string()).map_err(MigrationError)?.is_empty();
24 let skip_links = !shared.agent_identity_list_all().map_err(|e| e.to_string()).map_err(MigrationError)?.is_empty();
25 let skip_muxbus = shared.muxbus_load().ok().flatten().is_some();
26
27 let mut sibling_stores: Vec<Store> = Vec::new();
32 if ctx.channel_store_path.exists() {
33 match Store::open_source_readonly(&ctx.channel_store_path) {
34 Ok(s) => sibling_stores.push(s),
35 Err(e) => tracing::debug!(path = %ctx.channel_store_path.display(), error = %e, "shared_store_backfill: skip current channel store"),
36 }
37 }
38 if !agentmux_common::isolated_auth_enabled() {
46 for path in registry::enumerate_objects_dbs(&ctx.home) {
47 if path == ctx.channel_store_path { continue; } match Store::open_source_readonly(&path) {
49 Ok(s) => sibling_stores.push(s),
50 Err(e) => tracing::debug!(path = %path.display(), error = %e, "shared_store_backfill: skip sibling"),
51 }
52 }
53 }
54
55 if !skip_accts {
57 for src in &sibling_stores {
58 for acct in src.identity_list(None).unwrap_or_default() {
59 let _ = shared.identity_upsert(&acct);
60 }
61 }
62 }
63
64 if !skip_mem_bundles {
69 for src in &sibling_stores {
70 for mem in src.bundle_list().unwrap_or_default().iter().filter(|b| b.id != "blank") {
71 let _ = shared.bundle_upsert(mem);
72 }
73 }
74 }
75 if !skip_drones {
76 for src in &sibling_stores {
77 for drone in src.drone_list().unwrap_or_default() {
78 let _ = shared.drone_upsert(&drone);
79 }
80 }
81 }
82 if !skip_links {
83 for src in &sibling_stores {
84 for link in src.agent_identity_list_all().unwrap_or_default() {
85 let _ = shared.agent_identity_link(&link.agent_id, &link.account_id, &link.provider);
86 }
87 }
88 }
89 if !skip_muxbus {
90 let best: Option<MuxBusCredentials> = sibling_stores.iter()
91 .filter_map(|src| src.muxbus_load().ok().flatten())
92 .max_by_key(|c| c.expires_at);
93 if let Some(creds) = best {
94 let _ = shared.muxbus_save(&creds);
95 }
96 }
97
98 Ok(())
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use crate::backend::storage::store::{IdentityAccount, SecretRef};
106
107 use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
114
115 fn clear() {
116 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
117 std::env::remove_var("AGENTMUX_CHANNEL");
121 }
122
123 fn make_account(id: &str) -> IdentityAccount {
124 IdentityAccount {
125 id: id.to_string(),
126 name: format!("acct-{id}"),
127 provider: "claude".to_string(),
128 kind: "oauth".to_string(),
129 display_name: String::new(),
130 secret_ref: SecretRef::OAuthConfigDir { dir: format!("/tmp/{id}") },
131 context: serde_json::json!({}),
132 status: "valid".to_string(),
133 created_at: 0,
134 updated_at: 0,
135 }
136 }
137
138 fn setup() -> (tempfile::TempDir, MigrationContext) {
144 let tmp = tempfile::TempDir::new().unwrap();
145 let home = tmp.path().to_path_buf();
146
147 let sibling_db = home.join("dev").join("other-branch").join("data").join("db").join("objects.db");
148 std::fs::create_dir_all(sibling_db.parent().unwrap()).unwrap();
149 let sibling_store = Store::open(&sibling_db).unwrap();
150 sibling_store.identity_upsert(&make_account("sibling-acct")).unwrap();
151
152 let channel_store_path = home.join("this-channel").join("data").join("db").join("objects.db");
153 std::fs::create_dir_all(channel_store_path.parent().unwrap()).unwrap();
154 Store::open(&channel_store_path).unwrap(); let shared_store_path = home.join("shared").join("store.db");
157 std::fs::create_dir_all(shared_store_path.parent().unwrap()).unwrap();
158
159 let ctx = MigrationContext {
160 home,
161 data_dir: tmp.path().join("this-channel").join("data"),
162 shared_store_path,
163 channel_store_path,
164 };
165 (tmp, ctx)
166 }
167
168 #[test]
169 fn backfills_sibling_accounts_when_not_isolated() {
170 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
171 clear();
172 let (_tmp, ctx) = setup();
173
174 M0011SharedStoreBackfill.up(&ctx).unwrap();
175
176 let shared = Store::open_shared(&ctx.shared_store_path).unwrap();
177 let ids: Vec<String> = shared.identity_list(None).unwrap().into_iter().map(|a| a.id).collect();
178 assert!(
179 ids.contains(&"sibling-acct".to_string()),
180 "default (non-isolated) backfill must pick up the sibling branch's real account, got: {ids:?}"
181 );
182 }
183
184 #[test]
185 fn skips_sibling_accounts_when_isolated() {
186 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
187 clear();
188 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
189 let (_tmp, ctx) = setup();
190
191 M0011SharedStoreBackfill.up(&ctx).unwrap();
192
193 let shared = Store::open_shared(&ctx.shared_store_path).unwrap();
194 let ids: Vec<String> = shared.identity_list(None).unwrap().into_iter().map(|a| a.id).collect();
195 assert!(
196 ids.is_empty(),
197 "isolated backfill must NOT pick up any other channel's real account, got: {ids:?}"
198 );
199 clear();
200 }
201}