From 1ea8eb6889837c31a3f95fa10195ecd34d0ec19a Mon Sep 17 00:00:00 2001 From: zhushihao <11657413+zhushihao@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:24:03 +0800 Subject: [PATCH] fix(codex): sync active profile provider after switch --- src-tauri/src/services/profile.rs | 85 +++++++++++++++++++ src-tauri/src/services/provider/live.rs | 2 + src-tauri/src/services/provider/mod.rs | 103 +++++++++++++++++++++--- 3 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/services/profile.rs b/src-tauri/src/services/profile.rs index ffda27d2..2a206c17 100644 --- a/src-tauri/src/services/profile.rs +++ b/src-tauri/src/services/profile.rs @@ -310,6 +310,48 @@ impl ProfileService { Ok(profile) } + /// Keep the active project's provider slot aligned with a successful + /// device-level provider switch. + /// + /// Only the provider slot is refreshed. MCP, Skills and Prompt snapshots are + /// intentionally preserved, and profiles with no captured scope remain + /// untouched. + pub fn update_current_provider_snapshot( + state: &AppState, + scope: ProfileScope, + provider_id: &str, + ) -> Result<(), AppError> { + let Some(current_id) = state.db.get_current_profile_id(scope.as_str())? else { + return Ok(()); + }; + let mut profile = state + .db + .get_profile(¤t_id)? + .ok_or_else(|| AppError::InvalidInput(format!("Profile not found: {current_id}")))?; + let mut payload: ProfilePayload = serde_json::from_str(&profile.payload) + .map_err(|e| AppError::Config(format!("解析 profile payload 失败: {e}")))?; + + let mut changed = false; + for app in scope.apps() { + if let Some(slot) = payload.providers.get_mut(app) { + if slot.is_some() && slot.as_deref() != Some(provider_id) { + *slot = Some(provider_id.to_string()); + changed = true; + } + } + } + + if !changed { + return Ok(()); + } + + profile.payload = serde_json::to_string(&payload) + .map_err(|e| AppError::Config(format!("序列化 profile payload 失败: {e}")))?; + profile.updated_at = Some(chrono::Utc::now().timestamp()); + state.db.save_profile(&profile)?; + Ok(()) + } + /// 删除项目;若删除的是某分组当前激活项目,一并清除该分组的激活标记 pub fn delete(state: &AppState, id: &str) -> Result<(), AppError> { state.db.delete_profile(id)?; @@ -533,6 +575,49 @@ mod tests { assert_eq!(back, payload); } + #[test] + fn update_current_provider_snapshot_preserves_other_scope_slots() { + let db = std::sync::Arc::new(Database::memory().expect("memory db")); + let state = AppState::new(db.clone()); + let profile_id = "codex-profile"; + db.save_profile(&Profile { + id: profile_id.to_string(), + name: "Codex".to_string(), + payload: serde_json::json!({ + "providers": {"codex": "router-personal"}, + "mcp": {"codex": ["computer-use"]}, + "skills": {"codex": ["local:code-review"]}, + "prompts": {"codex": "codex-prompt"} + }) + .to_string(), + sort_order: None, + created_at: Some(1), + updated_at: Some(1), + }) + .expect("save active profile"); + db.set_current_profile_id("codex", Some(profile_id)) + .expect("activate profile"); + + ProfileService::update_current_provider_snapshot( + &state, + ProfileScope::Codex, + "router-company", + ) + .expect("refresh provider snapshot"); + + let payload: ProfilePayload = serde_json::from_str( + &db.get_profile(profile_id) + .expect("read profile") + .expect("profile exists") + .payload, + ) + .expect("parse profile payload"); + assert_eq!(payload.providers.codex.as_deref(), Some("router-company")); + assert_eq!(payload.mcp.codex, Some(ids(&["computer-use"]))); + assert_eq!(payload.skills.codex, Some(ids(&["local:code-review"]))); + assert_eq!(payload.prompts.codex.as_deref(), Some("codex-prompt")); + } + #[test] fn profile_apply_stages_new_codex_projection_owner_before_switch() { let db = std::sync::Arc::new(Database::memory().expect("memory db")); diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 8a4b180b..1c615be8 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -1421,6 +1421,8 @@ fn sync_current_provider_for_app_respecting_takeover( return Ok(()); }; + super::ProviderService::sync_active_profile_provider_snapshot(state, app_type, &provider.id)?; + if matches!(app_type, AppType::Codex) && provider .settings_config diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 6bb8d3b5..ca07bf5f 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1022,6 +1022,64 @@ mod tests { }); } + #[test] + #[serial] + fn syncing_active_profile_repairs_stale_project_provider() { + with_test_home(|state, _| { + for id in ["router-personal", "router-company"] { + state + .db + .save_provider( + AppType::Codex.as_str(), + &Provider::with_id(id.to_string(), id.to_string(), json!({}), None), + ) + .expect("save router"); + } + let profile_id = "codex-profile"; + state + .db + .save_profile(&crate::database::Profile { + id: profile_id.to_string(), + name: "Codex".to_string(), + payload: json!({ + "providers": {"codex": "router-personal"}, + "mcp": {"codex": ["computer-use"]} + }) + .to_string(), + sort_order: None, + created_at: Some(1), + updated_at: Some(1), + }) + .expect("save active profile"); + state + .db + .set_current_profile_id("codex", Some(profile_id)) + .expect("activate profile"); + + ProviderService::sync_active_profile_provider_snapshot( + state, + &AppType::Codex, + "router-company", + ) + .expect("repair stale profile provider"); + + let profile = state + .db + .get_profile(profile_id) + .expect("read profile") + .expect("profile exists"); + let payload: Value = serde_json::from_str(&profile.payload).expect("parse payload"); + assert_eq!( + payload.pointer("/providers/codex"), + Some(&json!("router-company")) + ); + assert_eq!( + payload.pointer("/mcp/codex/0"), + Some(&json!("computer-use")) + ); + }); + } + #[test] #[serial] fn schema_v2_subagent_initialization_uses_provider_owned_catalog() { @@ -4787,6 +4845,7 @@ impl ProviderService { if !Self::is_codex_schema_v2_router(app_type, provider) { return Ok(Vec::new()); } + Self::sync_active_profile_provider_snapshot(state, app_type, &provider.id)?; let status = crate::codex_multirouter::projection::ensure_codex_multirouter_projection( state.db.as_ref(), &provider.id, @@ -4882,6 +4941,21 @@ impl ProviderService { }) } + pub(crate) fn sync_active_profile_provider_snapshot( + state: &AppState, + app_type: &AppType, + provider_id: &str, + ) -> Result<(), AppError> { + let Some(scope) = crate::services::profile::ProfileScope::for_app(app_type) else { + return Ok(()); + }; + crate::services::profile::ProfileService::update_current_provider_snapshot( + state, + scope, + provider_id, + ) + } + /// Persist one V2 capability document without replacing the surrounding Provider record. /// The DAO acquires an IMMEDIATE write boundary, merges the focused field into the latest /// settings, and invokes the full compiler validation closure before committing. @@ -5165,17 +5239,24 @@ impl ProviderService { /// Switch to a provider /// - /// Switch flow: - /// 1. Validate target provider exists - /// 2. Check if proxy takeover mode is active AND proxy server is running - /// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels - /// 4. If normal mode: - /// a. **Backfill mechanism**: Backfill current live config to current provider - /// b. Update local settings current_provider_xxx (device-level) - /// c. Update database is_current (as default for new devices) - /// d. Write target provider config to live files - /// e. Sync MCP configuration + /// After the provider switch succeeds, keep the active project snapshot in + /// sync so Codex projection ownership cannot be shadowed by a stale profile. pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result { + let mut result = Self::switch_inner(state, app_type.clone(), id)?; + if let Err(error) = Self::sync_active_profile_provider_snapshot(state, &app_type, id) { + log::warn!("供应商切换成功,但当前项目快照同步失败: {error}"); + result + .warnings + .push("profile_provider_snapshot_sync_failed".to_string()); + } + Ok(result) + } + + fn switch_inner( + state: &AppState, + app_type: AppType, + id: &str, + ) -> Result { // Check if provider exists let providers = state.db.get_all_providers(app_type.as_str())?; let _provider = providers @@ -5647,6 +5728,8 @@ impl ProviderService { return Ok(()); }; + Self::sync_active_profile_provider_snapshot(state, &app_type, &provider.id)?; + if Self::is_codex_schema_v2_router(&app_type, provider) { crate::codex_multirouter::projection::ensure_codex_multirouter_projection( state.db.as_ref(),