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:
1 parent
60a3683976
commit
69cc19679c
5 files changed
+415
-53
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user