Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src-tauri/src/services/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&current_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)?;
Expand Down Expand Up @@ -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"));
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/services/provider/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 93 additions & 10 deletions src-tauri/src/services/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<SwitchResult, AppError> {
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<SwitchResult, AppError> {
// Check if provider exists
let providers = state.db.get_all_providers(app_type.as_str())?;
let _provider = providers
Expand Down Expand Up @@ -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(),
Expand Down
Loading