From 69cc19679c71c15f8099692cda047d65852f4725 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Thu, 10 Sep 2026 20:36:36 +0800 Subject: [PATCH] fix(agents): restore desktop core routes with configuration history Include CPA model aliases in Desktop history previews and revision checks. Restore routes and local history through a serialized transaction, rolling core changes back when local restoration fails. Reject legacy fixed-route snapshots without mapping metadata. Validation: cargo test --locked tests::alias_save (13 passed), including successful route restoration, stale previews, local conflicts, core write failure, and rollback. --- src-tauri/src/agents/history.rs | 50 +----- src-tauri/src/agents/history/restore.rs | 213 ++++++++++++++++++++++++ src-tauri/src/core_config/alias_edit.rs | 2 +- src-tauri/src/core_config/alias_save.rs | 24 ++- src-tauri/src/tests/alias_save.rs | 179 ++++++++++++++++++++ 5 files changed, 415 insertions(+), 53 deletions(-) create mode 100644 src-tauri/src/agents/history/restore.rs diff --git a/src-tauri/src/agents/history.rs b/src-tauri/src/agents/history.rs index 4b0306e..7b66ed1 100644 --- a/src-tauri/src/agents/history.rs +++ b/src-tauri/src/agents/history.rs @@ -7,6 +7,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); type Images = Vec<(PathBuf, Option>)>; +mod restore; +pub(crate) use restore::*; + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct HistoryFile { @@ -60,8 +63,8 @@ pub(crate) struct HistoryDifference { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct HistoryPreview { - revision: String, - differences: Vec, + pub(crate) revision: String, + pub(crate) differences: Vec, } pub(crate) fn history_paths(client: &str, home: &Path) -> Result, String> { @@ -884,49 +887,6 @@ pub(crate) fn list_agent_config_history( }) } -#[tauri::command] -pub(crate) fn preview_agent_config_history( - app: tauri::AppHandle, - client: String, - id: String, -) -> Result { - let home = app.path().home_dir().map_err(|e| e.to_string())?; - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "配置文件锁已损坏")?; - Ok(preview(&client, &history_paths(&client, &home)?, &id)?.0) -} - -#[tauri::command] -pub(crate) fn restore_agent_config_history( - app: tauri::AppHandle, - client: String, - id: String, - revision: String, -) -> Result { - let home = app.path().home_dir().map_err(|e| e.to_string())?; - let _guard = AGENT_CONFIG_FILE_LOCK - .lock() - .map_err(|_| "配置文件锁已损坏")?; - let paths = history_paths(&client, &home)?; - let (preview, before, after) = preview(&client, &paths, &id)?; - if preview.revision != revision { - return Err("预览后配置发生变化,请重新选择历史版本".into()); - } - let version = read_version(&client, &paths, &id)?; - let result = commit_history_with_mappings( - &client, - &paths, - &before, - &after, - "restore", - version.model, - version.mappings, - )?; - app.state::().clear()?; - Ok(result) -} - pub(crate) fn import_legacy_history( client: &str, home: &Path, diff --git a/src-tauri/src/agents/history/restore.rs b/src-tauri/src/agents/history/restore.rs new file mode 100644 index 0000000..fc40d56 --- /dev/null +++ b/src-tauri/src/agents/history/restore.rs @@ -0,0 +1,213 @@ +use super::*; + +struct CoreRestore { + before: String, + after: String, +} + +pub(crate) struct RestorePlan { + paths: Vec, + pub(crate) preview: HistoryPreview, + local_revision: String, + before: Images, + after: Images, + version: HistoryVersion, + core: Option, +} + +fn local_restore_plan(client: &str, home: &Path, id: &str) -> Result { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let paths = history_paths(client, home)?; + let (preview, before, after) = preview(client, &paths, id)?; + let version = read_version(client, &paths, id)?; + Ok(RestorePlan { + paths, + local_revision: preview.revision.clone(), + preview, + before, + after, + version, + core: None, + }) +} + +fn desktop_restore_models(plan: &RestorePlan) -> Result>, String> { + if plan.version.client != "claude-desktop" { + return Ok(None); + } + let (path, bytes) = &plan.after[2]; + let profile = parse(path, text(bytes.as_deref())?)?; + let names = profile + .get("inferenceModels") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|model| model.get("name").and_then(Value::as_str)) + .collect::>(); + let routes = [ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + ]; + if !names + .iter() + .any(|name| routes.iter().any(|route| route.eq_ignore_ascii_case(name))) + { + return Ok(None); + } + let mappings = + plan.version.mappings.as_ref().ok_or( + "此历史版本缺少 Claude Desktop 模型映射,无法安全恢复内核路由,请重新配置模型", + )?; + Ok(Some( + routes + .into_iter() + .zip([&mappings.opus, &mappings.sonnet, &mappings.haiku]) + .map(|(route, source)| AgentModelOption { + name: source.clone(), + alias: None, + is_alias: source != route + && names.iter().any(|name| name.eq_ignore_ascii_case(source)), + context_window: None, + }) + .collect(), + )) +} + +fn attach_core_restore( + plan: &mut RestorePlan, + before: String, + after: String, +) -> Result<(), String> { + let original = + serde_norway::from_str::(&before).map_err(|e| e.to_string())?; + let target = + serde_norway::from_str::(&after).map_err(|e| e.to_string())?; + for route in [ + CLAUDE_DESKTOP_OPUS_MODEL_ID, + CLAUDE_DESKTOP_SONNET_MODEL_ID, + CLAUDE_DESKTOP_HAIKU_MODEL_ID, + ] { + let source = |root: &serde_norway::Value| { + root.as_mapping() + .and_then(|root| configured_model_client_identity(root, route)) + .map(|(source, _)| source) + }; + let old = source(&original); + let new = source(&target); + if old != new { + plan.preview.differences.push(HistoryDifference { + file: "CPA/config.yaml".into(), + field: format!("modelMappings.{route}"), + before: old.unwrap_or_else(|| "—".into()), + after: new.unwrap_or_else(|| "—".into()), + }); + } + } + plan.preview.revision = sha256_bytes( + format!( + "{}:{}:{}", + plan.local_revision, + model_alias_config_revision(&before)?, + model_alias_config_revision(&after)? + ) + .as_bytes(), + ); + plan.core = Some(CoreRestore { before, after }); + Ok(()) +} + +pub(crate) async fn prepare_restore_plan( + config: &GuiConfigFile, + client: &str, + home: &Path, + id: &str, +) -> Result { + let mut plan = local_restore_plan(client, home, id)?; + if let Some(models) = desktop_restore_models(&plan)? { + let mappings = plan.version.mappings.as_ref().ok_or("缺少历史模型映射")?; + let before = fetch_management_config_yaml(config).await?; + let after = match ensure_claude_desktop_model_aliases_in_yaml(&before, mappings, &models) { + Ok(after) => after, + Err(_) => { + let definitions = fetch_oauth_model_definitions(config).await; + ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + &before, + mappings, + &models, + &definitions, + )? + } + }; + attach_core_restore(&mut plan, before, after)?; + } + Ok(plan) +} + +pub(crate) async fn execute_restore_plan( + config: &GuiConfigFile, + plan: RestorePlan, + revision: &str, +) -> Result { + if plan.preview.revision != revision { + return Err("预览后配置发生变化,请重新选择历史版本".into()); + } + let commit = || { + let _guard = AGENT_CONFIG_FILE_LOCK + .lock() + .map_err(|_| "配置文件锁已损坏")?; + let latest = preview(&plan.version.client, &plan.paths, &plan.version.id)?.0; + let version = read_version(&plan.version.client, &plan.paths, &plan.version.id)?; + if latest.revision != plan.local_revision + || serde_json::to_vec(&version).map_err(|e| e.to_string())? + != serde_json::to_vec(&plan.version).map_err(|e| e.to_string())? + { + return Err("恢复期间配置或历史发生变化,请重新选择历史版本".into()); + } + commit_history_with_mappings( + &plan.version.client, + &plan.paths, + &plan.before, + &plan.after, + "restore", + plan.version.model.clone(), + plan.version.mappings.clone(), + ) + }; + match &plan.core { + Some(core) => { + commit_management_alias_config_changes(config, &core.before, &core.after, commit).await + } + None => commit(), + } +} + +#[tauri::command] +pub(crate) async fn preview_agent_config_history( + app: tauri::AppHandle, + client: String, + id: String, +) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + let config = app.state::().snapshot()?; + Ok(prepare_restore_plan(&config, &client, &home, &id) + .await? + .preview) +} + +#[tauri::command] +pub(crate) async fn restore_agent_config_history( + app: tauri::AppHandle, + client: String, + id: String, + revision: String, +) -> Result { + let home = app.path().home_dir().map_err(|e| e.to_string())?; + let config = app.state::().snapshot()?; + let plan = prepare_restore_plan(&config, &client, &home, &id).await?; + let result = execute_restore_plan(&config, plan, &revision).await?; + app.state::().clear()?; + Ok(result) +} diff --git a/src-tauri/src/core_config/alias_edit.rs b/src-tauri/src/core_config/alias_edit.rs index 6d4b8e2..28b2b58 100644 --- a/src-tauri/src/core_config/alias_edit.rs +++ b/src-tauri/src/core_config/alias_edit.rs @@ -25,7 +25,7 @@ pub(crate) fn validate_model_alias_revision( Ok(()) } -fn model_alias_config_revision(content: &str) -> Result { +pub(crate) fn model_alias_config_revision(content: &str) -> Result { let document = serde_norway::from_str::(content) .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; let mut value = serde_json::to_value(document).map_err(|error| error.to_string())?; diff --git a/src-tauri/src/core_config/alias_save.rs b/src-tauri/src/core_config/alias_save.rs index 6f6939f..d4d1c72 100644 --- a/src-tauri/src/core_config/alias_save.rs +++ b/src-tauri/src/core_config/alias_save.rs @@ -10,12 +10,22 @@ pub(crate) async fn put_management_alias_config_changes( current: &str, updated: &str, ) -> Result<(), String> { - static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - let _guard = SAVE_LOCK.lock().await; let changes = management_alias_config_changes(current, updated)?; if changes.oauth_model_aliases.is_none() && !changes.update_config_yaml { return Ok(()); } + commit_management_alias_config_changes(config, current, updated, || Ok(())).await +} + +pub(crate) async fn commit_management_alias_config_changes( + config: &GuiConfigFile, + current: &str, + updated: &str, + commit: impl FnOnce() -> Result, +) -> Result { + static SAVE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _guard = SAVE_LOCK.lock().await; + let changes = management_alias_config_changes(current, updated)?; let latest = fetch_management_config_yaml(config).await?; if !alias_config_is_unchanged(current, &latest)? { return Err("配置已变化,请关闭编辑器并刷新后重试".to_string()); @@ -27,16 +37,16 @@ pub(crate) async fn put_management_alias_config_changes( if changes.update_config_yaml { put_management_config_yaml(config, updated).await?; } - Ok::<_, String>(()) + commit() } .await; - if let Err(error) = result { - return Err(match restore_management_alias_config(config, current, updated).await { + match result { + Ok(value) => Ok(value), + Err(error) => Err(match restore_management_alias_config(config, current, updated).await { Ok(()) => format!("保存失败,已恢复原配置,可重试:{error}"), Err(restore_error) => format!("保存失败:{error};自动恢复失败,配置可能已部分写入,请关闭编辑器并刷新检查:{restore_error}"), - }); + }), } - Ok(()) } async fn restore_management_alias_config( diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index faf8cce..4a4e4ee 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -294,3 +294,182 @@ async fn alias_save_can_retry_after_restoration_reformats_yaml() { let (persisted, _) = core.finish(); assert_eq!(persisted, yaml_json(&updated)); } + +struct DesktopRestoreFixture { + home: PathBuf, + id: String, + core_a: String, + core_b: String, +} + +impl DesktopRestoreFixture { + fn new() -> Self { + let home = super::support::agent_test_home("desktop-restore"); + let models = super::support::test_agent_models(&["model-a", "model-b"]); + let initial = "openai-compatibility:\n - name: provider\n models: [{name: model-a}, {name: model-b}]\n"; + let mut id = None; + let mut core_versions = Vec::new(); + for name in ["model-a", "model-b"] { + let mappings = ClaudeDesktopModelMappings::all(name); + core_versions.push( + ensure_claude_desktop_model_aliases_in_yaml(initial, &mappings, &models).unwrap(), + ); + let result = apply_agent_configuration_with_oauth( + AgentClient::ClaudeDesktop, + &home, + 8317, + "test-key", + name, + AgentConfigurationOptions { + models: &models, + codex_catalog: None, + oauth_configuration: false, + claude_code_model_mappings: None, + claude_desktop_model_mappings: Some(&mappings), + }, + ) + .unwrap(); + if name == "model-a" { + id = result.history_version; + } + } + Self { + home, + id: id.unwrap(), + core_a: core_versions.remove(0), + core_b: core_versions.remove(0), + } + } +} + +impl Drop for DesktopRestoreFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.home); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn desktop_history_restore_updates_core_routes_and_mapping_metadata() { + let fixture = DesktopRestoreFixture::new(); + let core = MockCore::new(&fixture.core_b, Failure::None); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) + .await + .unwrap(); + assert!(!plan.preview.differences.is_empty()); + let revision = plan.preview.revision.clone(); + execute_restore_plan(&core.config, plan, &revision) + .await + .unwrap(); + assert_eq!( + current_desktop_history_mappings(&fixture.home) + .unwrap() + .opus, + "model-a" + ); + let (persisted, _) = core.finish(); + assert_eq!(persisted, yaml_json(&fixture.core_a)); +} + +#[tokio::test(flavor = "current_thread")] +async fn desktop_history_restore_rejects_core_changes_since_preview() { + let fixture = DesktopRestoreFixture::new(); + let core = MockCore::new(&fixture.core_b, Failure::None); + let preview = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) + .await + .unwrap(); + let changed = format!("{}debug: true\n", fixture.core_b); + put_management_alias_config_changes(&core.config, &fixture.core_b, &changed) + .await + .unwrap(); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) + .await + .unwrap(); + assert!( + execute_restore_plan(&core.config, plan, &preview.preview.revision) + .await + .unwrap_err() + .contains("预览后") + ); + assert_eq!( + current_desktop_history_mappings(&fixture.home) + .unwrap() + .opus, + "model-b" + ); + assert_eq!(core.finish().0, yaml_json(&changed)); +} + +#[tokio::test(flavor = "current_thread")] +async fn desktop_history_restore_rolls_back_core_when_local_files_change() { + let fixture = DesktopRestoreFixture::new(); + let core = MockCore::new(&fixture.core_b, Failure::None); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) + .await + .unwrap(); + let revision = plan.preview.revision.clone(); + let paths = history_paths("claude-desktop", &fixture.home).unwrap(); + fs::write(&paths[0], r#"{"deploymentMode":"3p","external":true}"#).unwrap(); + let before = history_images(&paths).unwrap(); + assert!(execute_restore_plan(&core.config, plan, &revision) + .await + .unwrap_err() + .contains("已恢复原配置")); + assert_eq!(history_images(&paths).unwrap(), before); + assert_eq!( + current_desktop_history_mappings(&fixture.home) + .unwrap() + .opus, + "model-b" + ); + assert_eq!(core.finish().0, yaml_json(&fixture.core_b)); +} + +#[tokio::test(flavor = "current_thread")] +async fn desktop_history_restore_does_not_write_local_files_when_core_save_fails() { + let fixture = DesktopRestoreFixture::new(); + let core = MockCore::new(&fixture.core_b, Failure::YamlAfterWrite); + let plan = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) + .await + .unwrap(); + let revision = plan.preview.revision.clone(); + let paths = history_paths("claude-desktop", &fixture.home).unwrap(); + let before = history_images(&paths).unwrap(); + assert!(execute_restore_plan(&core.config, plan, &revision) + .await + .is_err()); + assert_eq!(history_images(&paths).unwrap(), before); + assert_eq!( + current_desktop_history_mappings(&fixture.home) + .unwrap() + .opus, + "model-b" + ); + assert_eq!(core.finish().0, yaml_json(&fixture.core_b)); +} + +#[tokio::test(flavor = "current_thread")] +async fn alias_transaction_rolls_back_when_followup_commit_fails() { + let core = MockCore::new(CURRENT, Failure::None); + let updated = CURRENT.replace("my-alias", "renamed"); + let result = commit_management_alias_config_changes(&core.config, CURRENT, &updated, || { + Err::<(), _>("local history write failed".into()) + }) + .await; + assert!(result.unwrap_err().contains("local history write failed")); + assert_eq!(core.finish().0, yaml_json(CURRENT)); +} + +#[tokio::test(flavor = "current_thread")] +async fn alias_transaction_checks_core_before_a_local_only_commit() { + let current = format!("{CURRENT}debug: true\n"); + let core = MockCore::new(¤t, Failure::None); + let committed = AtomicBool::new(false); + let result = commit_management_alias_config_changes(&core.config, CURRENT, CURRENT, || { + committed.store(true, Ordering::SeqCst); + Ok(()) + }) + .await; + assert!(result.is_err()); + assert!(!committed.load(Ordering::SeqCst)); + assert_eq!(core.finish().0, yaml_json(¤t)); +}