agentmux_srv/migrations/
m0012_dedup_identity_accounts.rs1use std::collections::{HashMap, HashSet};
22
23use crate::backend::storage::store::Store;
24use super::{Migration, MigrationContext, MigrationError, MigrationScope};
25
26pub struct M0012DedupIdentityAccounts;
27
28impl Migration for M0012DedupIdentityAccounts {
29 fn id(&self) -> &'static str { "0012_dedup_identity_accounts" }
30 fn scope(&self) -> MigrationScope { MigrationScope::Global }
31 fn description(&self) -> &'static str {
32 "Deduplicate identity accounts sharing the same provider and credential"
33 }
34
35 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
36 let shared = Store::open_shared(&ctx.shared_store_path)
37 .map_err(|e| MigrationError(format!("dedup_identity: open shared store: {}", e)))?;
38
39 let accounts = shared.identity_list(None)
40 .map_err(|e| MigrationError(format!("dedup_identity: list accounts: {}", e)))?;
41
42 let mut groups: HashMap<(String, String), Vec<usize>> = HashMap::new();
46 for (i, acct) in accounts.iter().enumerate() {
47 let ref_key = serde_json::to_string(&acct.secret_ref)
48 .unwrap_or_else(|_| acct.id.clone());
49 groups.entry((acct.provider.clone(), ref_key)).or_default().push(i);
50 }
51
52 let has_dupes = groups.values().any(|v| v.len() > 1);
53 if !has_dupes {
54 tracing::info!("dedup_identity_accounts: no duplicates found");
55 return Ok(());
56 }
57
58 let agent_links = shared.agent_identity_list_all()
64 .map_err(|e| MigrationError(format!("dedup_identity: list agent links: {}", e)))?;
65
66 let bound_ids: HashSet<&str> = agent_links.iter().map(|l| l.account_id.as_str()).collect();
67
68 let mut deleted = 0usize;
69
70 for ((provider, _), indices) in &groups {
71 if indices.len() <= 1 {
72 continue;
73 }
74
75 let mut sorted: Vec<&_> = indices.iter().map(|&i| &accounts[i]).collect();
77 sorted.sort_by(|a, b| {
78 let a_bound = bound_ids.contains(a.id.as_str());
79 let b_bound = bound_ids.contains(b.id.as_str());
80 b_bound.cmp(&a_bound).then_with(|| a.created_at.cmp(&b.created_at))
81 });
82
83 let canonical_id = &sorted[0].id;
84 let dupe_ids: HashSet<&str> = sorted[1..].iter().map(|a| a.id.as_str()).collect();
85
86 for link in &agent_links {
88 if &link.provider == provider && dupe_ids.contains(link.account_id.as_str()) {
89 shared.agent_identity_link(&link.agent_id, canonical_id, provider)
90 .map_err(|e| MigrationError(format!(
91 "dedup_identity: relink agent {}: {}", link.agent_id, e
92 )))?;
93 }
94 }
95
96 for dupe_id in &dupe_ids {
98 shared.identity_delete(dupe_id)
99 .map_err(|e| MigrationError(format!("dedup_identity: delete {}: {}", dupe_id, e)))?;
100 deleted += 1;
101 }
102 }
103
104 tracing::info!(deleted, "dedup_identity_accounts: complete");
105 Ok(())
106 }
107}