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.
This commit is contained in:
lzt404 committed 2026-09-11 16:25:14 +08:00
1 parent 60a3683976
commit 69cc19679c
5 files changed
+415 -53

No files matched your search

+5 -45
View File
@@ -7,6 +7,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
type Images = Vec<(PathBuf, Option<Vec<u8>>)>;
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<HistoryDifference>,
pub(crate) revision: String,
pub(crate) differences: Vec<HistoryDifference>,
}
pub(crate) fn history_paths(client: &str, home: &Path) -> Result<Vec<PathBuf>, 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<HistoryPreview, String> {
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<AgentConfigActionResult, String> {
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::<AgentConfigStatusCache>().clear()?;
Ok(result)
}
pub(crate) fn import_legacy_history(
client: &str,
home: &Path,
+213
View File
@@ -0,0 +1,213 @@
use super::*;
struct CoreRestore {
before: String,
after: String,
}
pub(crate) struct RestorePlan {
paths: Vec<PathBuf>,
pub(crate) preview: HistoryPreview,
local_revision: String,
before: Images,
after: Images,
version: HistoryVersion,
core: Option<CoreRestore>,
}
fn local_restore_plan(client: &str, home: &Path, id: &str) -> Result<RestorePlan, String> {
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<Option<Vec<AgentModelOption>>, 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::<Vec<_>>();
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::<serde_norway::Value>(&before).map_err(|e| e.to_string())?;
let target =
serde_norway::from_str::<serde_norway::Value>(&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<RestorePlan, String> {
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<AgentConfigActionResult, String> {
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<HistoryPreview, String> {
let home = app.path().home_dir().map_err(|e| e.to_string())?;
let config = app.state::<GuiConfigState>().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<AgentConfigActionResult, String> {
let home = app.path().home_dir().map_err(|e| e.to_string())?;
let config = app.state::<GuiConfigState>().snapshot()?;
let plan = prepare_restore_plan(&config, &client, &home, &id).await?;
let result = execute_restore_plan(&config, plan, &revision).await?;
app.state::<AgentConfigStatusCache>().clear()?;
Ok(result)
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub(crate) fn validate_model_alias_revision(
Ok(())
}
fn model_alias_config_revision(content: &str) -> Result<String, String> {
pub(crate) fn model_alias_config_revision(content: &str) -> Result<String, String> {
let document = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?;
let mut value = serde_json::to_value(document).map_err(|error| error.to_string())?;
+17 -7
View File
@@ -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<T>(
config: &GuiConfigFile,
current: &str,
updated: &str,
commit: impl FnOnce() -> Result<T, String>,
) -> Result<T, 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)?;
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(
+179
View File
@@ -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(&current, 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(&current));
}