1use std::sync::Arc;
28
29use crate::backend::storage::store::Store;
30use crate::registry::{resolve_shared_definitions_dir, DefinitionStore};
31
32use super::m0021_backfill_agent_bundles::resolve_bundle_store;
33use super::{Migration, MigrationContext, MigrationError, MigrationScope};
34
35pub struct M0035AgentOwnsItsProvider;
36
37impl Migration for M0035AgentOwnsItsProvider {
38 fn id(&self) -> &'static str { "0035_agent_owns_its_provider" }
39 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
40 fn description(&self) -> &'static str {
41 "Copy each bound bundle's provider onto its agent, where it differs"
42 }
43
44 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
45 if !ctx.channel_store_path.exists() {
46 return Ok(());
47 }
48 let mstore = Arc::new(
49 Store::open(&ctx.channel_store_path)
50 .map_err(|e| MigrationError(format!("agent_owns_its_provider: open mstore: {e}")))?,
51 );
52 if let Some(def_dir) = resolve_shared_definitions_dir() {
55 match DefinitionStore::open(def_dir) {
56 Ok(def_store) => mstore.set_def_registry(Arc::new(def_store)),
57 Err(e) => tracing::warn!(error = %e, "agent_owns_its_provider: global def registry unavailable, updating local rows only"),
58 }
59 }
60 let bundle_store = resolve_bundle_store(ctx, &mstore);
61
62 let defs = mstore
63 .agent_def_list()
64 .map_err(|e| MigrationError(format!("agent_owns_its_provider: list defs: {e}")))?;
65 for def in defs {
66 if def.memory_id.is_empty() {
67 continue;
68 }
69 let bundle_provider = match bundle_store.bundle_get(&def.memory_id) {
70 Ok(Some(b)) => b.provider,
71 Ok(None) => continue,
72 Err(e) => {
73 tracing::warn!(agent_id = %def.id, error = %e, "agent_owns_its_provider: bundle unreadable, agent left as is");
74 continue;
75 }
76 };
77 if bundle_provider.is_empty() || bundle_provider == def.provider {
78 continue;
79 }
80 let mut updated = def.clone();
81 updated.provider = bundle_provider.clone();
82 let applied = mstore
83 .agent_def_update(&mut updated)
84 .map_err(|e| MigrationError(format!("agent_owns_its_provider: update {}: {e}", def.id)))?;
85 if applied {
86 tracing::info!(
87 agent_id = %def.id,
88 from = %def.provider,
89 to = %bundle_provider,
90 "agent_owns_its_provider: agent now carries the provider it runs with"
91 );
92 }
93 }
94 Ok(())
95 }
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101 use crate::backend::storage::agents::AgentDefinition;
102 use crate::backend::storage::store::Bundle;
103 use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_GUARD;
106
107 fn with_isolated_home<T>(f: impl FnOnce() -> T) -> T {
108 let _guard = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
109 let home_tmp = tempfile::tempdir().unwrap();
110 std::env::set_var("AGENTMUX_HOME_OVERRIDE", home_tmp.path().to_str().unwrap());
111 let result = f();
112 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
113 result
114 }
115
116 fn ctx_for(channel_path: &std::path::Path, shared_path: &std::path::Path) -> MigrationContext {
117 MigrationContext {
118 home: std::env::temp_dir(),
119 data_dir: std::env::temp_dir(),
120 shared_store_path: shared_path.to_path_buf(),
121 channel_store_path: channel_path.to_path_buf(),
122 }
123 }
124
125 fn agent(id: &str, provider: &str, memory_id: &str) -> AgentDefinition {
126 AgentDefinition {
127 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
128 id: id.to_string(),
129 slug: String::new(),
130 name: id.to_string(),
131 icon: String::new(),
132 provider: provider.to_string(),
133 description: String::new(),
134 working_directory: String::new(),
135 shell: String::new(),
136 provider_flags: String::new(),
137 auto_start: 0,
138 restart_on_crash: 0,
139 idle_timeout_minutes: 0,
140 created_at: 1,
141 agent_type: "host".to_string(),
142 environment: String::new(),
143 agent_bus_id: String::new(),
144 is_seeded: 0,
145 accounts: String::new(),
146 parent_id: String::new(),
147 branch_label: String::new(),
148 updated_at: 1,
149 user_hidden: 0,
150 container_image: String::new(),
151 container_volumes: "[]".to_string(),
152 container_name: String::new(),
153 use_ambient_login: 0,
154 auto_continue_enabled: 0,
155 model_vendor_base_url: String::new(),
156 memory_id: memory_id.to_string(),
157 }
158 }
159
160 fn bundle(id: &str, provider: &str) -> Bundle {
161 Bundle {
162 id: id.to_string(),
163 name: id.to_string(),
164 description: String::new(),
165 is_blank: false,
166 is_global: false,
167 provider: provider.to_string(),
168 model: String::new(),
169 instructions: String::new(),
170 instructions_by_provider: "{}".to_string(),
171 context_files: "[]".to_string(),
172 mcp_servers: "[]".to_string(),
173 skills: "[]".to_string(),
174 sort_order: 0,
175 created_at: 0,
176 updated_at: 0,
177 is_system: false,
178 }
179 }
180
181 #[test]
182 fn each_agent_takes_the_provider_it_runs_with_and_bundles_are_untouched() {
183 with_isolated_home(|| {
184 let dir = tempfile::tempdir().unwrap();
185 let channel = dir.path().join("objects.db");
186 let shared = dir.path().join("shared.db");
187 {
188 let mstore = Store::open(&channel).unwrap();
189 let shared_store = Store::open_shared(&shared).unwrap();
190 for (id, provider) in [("b-drift", "claude"), ("b-empty", "claude"), ("b-same", "codex"), ("b-hint-less", "")] {
191 shared_store.bundle_upsert(&bundle(id, provider)).unwrap();
192 }
193 mstore.agent_def_insert(&mut agent("drifted", "codex", "b-drift")).unwrap();
195 mstore.agent_def_insert(&mut agent("empty", "", "b-empty")).unwrap();
197 mstore.agent_def_insert(&mut agent("same", "codex", "b-same")).unwrap();
198 mstore.agent_def_insert(&mut agent("hint-less", "gemini", "b-hint-less")).unwrap();
199 mstore.agent_def_insert(&mut agent("unbound", "gemini", "")).unwrap();
200 }
201
202 let ctx = ctx_for(&channel, &shared);
203 M0035AgentOwnsItsProvider.up(&ctx).unwrap();
204 M0035AgentOwnsItsProvider.up(&ctx).unwrap();
206
207 let mstore = Store::open(&channel).unwrap();
208 let provider = |id: &str| mstore.agent_def_get(id).unwrap().unwrap().provider;
209 assert_eq!(provider("drifted"), "claude");
210 assert_eq!(provider("empty"), "claude");
211 assert_eq!(provider("same"), "codex");
212 assert_eq!(provider("hint-less"), "gemini");
213 assert_eq!(provider("unbound"), "gemini");
214
215 let shared_store = Store::open_shared(&shared).unwrap();
216 assert_eq!(shared_store.bundle_get("b-drift").unwrap().unwrap().provider, "claude", "bundle rows are kept");
217 });
218 }
219
220 #[test]
221 fn a_missing_channel_store_is_a_no_op() {
222 let dir = tempfile::tempdir().unwrap();
223 let ctx = ctx_for(&dir.path().join("absent.db"), &dir.path().join("shared.db"));
224 M0035AgentOwnsItsProvider.up(&ctx).unwrap();
225 }
226}