mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): reconcile native resources across directory moves
This commit is contained in:
@@ -722,6 +722,26 @@ impl ProxyService {
|
||||
return crate::settings::update_settings(next);
|
||||
}
|
||||
|
||||
let direct_patch = direct_pi_projection_patch(self.db.as_ref())?;
|
||||
let new_native_before = crate::pi_config::document::snapshot_pi_provider_values(
|
||||
&new_models_path,
|
||||
direct_patch.keys().cloned(),
|
||||
)?;
|
||||
for (provider_key, expected) in &direct_patch {
|
||||
if let Some(existing_value) = new_native_before
|
||||
.values
|
||||
.get(provider_key)
|
||||
.and_then(Option::as_ref)
|
||||
{
|
||||
if Some(existing_value) != expected.as_ref() {
|
||||
return Err(AppError::Conflict(format!(
|
||||
"Pi directory change would overwrite unowned provider key '{provider_key}' in {}",
|
||||
new_models_path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt operations and directory ownership share one sendable mutex.
|
||||
// Holding it across runtime publication prevents a prompt write from
|
||||
// committing against the new root while a failed directory move is
|
||||
@@ -731,6 +751,19 @@ impl ProxyService {
|
||||
|
||||
if !existing.pi_takeover_enabled {
|
||||
crate::settings::update_settings(next)?;
|
||||
if let Err(error) =
|
||||
crate::pi_config::document::apply_pi_provider_patch(&new_models_path, &direct_patch)
|
||||
{
|
||||
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
|
||||
let new_native_restored = crate::pi_config::document::restore_pi_provider_values(
|
||||
&new_models_path,
|
||||
&new_native_before,
|
||||
)
|
||||
.is_ok();
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to publish managed Pi providers in the new directory: {error}; rollback: settings={settings_restored}, native={new_native_restored}"
|
||||
)));
|
||||
}
|
||||
if let Err(error) =
|
||||
PromptService::reconcile_pi_native_under_guard(self.db.as_ref(), &prompt_guard)
|
||||
{
|
||||
@@ -739,8 +772,35 @@ impl ProxyService {
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
let new_native_restored = crate::pi_config::document::restore_pi_provider_values(
|
||||
&new_models_path,
|
||||
&new_native_before,
|
||||
)
|
||||
.is_ok();
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}"
|
||||
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}, native={new_native_restored}"
|
||||
)));
|
||||
}
|
||||
if let Err(error) =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(&self.db)
|
||||
{
|
||||
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
|
||||
let prompts_restored = self
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
let new_native_restored = crate::pi_config::document::restore_pi_provider_values(
|
||||
&new_models_path,
|
||||
&new_native_before,
|
||||
)
|
||||
.is_ok();
|
||||
let skills_restored = settings_restored
|
||||
&& crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(
|
||||
&self.db,
|
||||
)
|
||||
.is_ok();
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to reconcile Pi Skills in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}, native={new_native_restored}, skills={skills_restored}"
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
@@ -776,28 +836,69 @@ impl ProxyService {
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
let new_native_restored = crate::pi_config::document::restore_pi_provider_values(
|
||||
&new_models_path,
|
||||
&new_native_before,
|
||||
)
|
||||
.is_ok();
|
||||
let skills_restored = settings_restored
|
||||
&& crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(
|
||||
&self.db,
|
||||
)
|
||||
.is_ok();
|
||||
let runtime_restored = self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok();
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}, gateway={runtime_restored}"
|
||||
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}, native={new_native_restored}, skills={skills_restored}, gateway={runtime_restored}"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(error) = self.reconcile_pi_runtime_at_epoch(epoch).await {
|
||||
let new_direct_restored = self
|
||||
.restore_pi_direct_projection_at(&new_models_path)
|
||||
.is_ok();
|
||||
if let Err(error) =
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(&self.db)
|
||||
{
|
||||
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
|
||||
let prompts_restored = self
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
let new_native_restored = crate::pi_config::document::restore_pi_provider_values(
|
||||
&new_models_path,
|
||||
&new_native_before,
|
||||
)
|
||||
.is_ok();
|
||||
let skills_restored = settings_restored
|
||||
&& crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(
|
||||
&self.db,
|
||||
)
|
||||
.is_ok();
|
||||
let runtime_restored = self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok();
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to reconcile Pi Skills in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}, native={new_native_restored}, skills={skills_restored}, gateway={runtime_restored}"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(error) = self.reconcile_pi_runtime_at_epoch(epoch).await {
|
||||
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
|
||||
let prompts_restored = self
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
let new_native_restored = crate::pi_config::document::restore_pi_provider_values(
|
||||
&new_models_path,
|
||||
&new_native_before,
|
||||
)
|
||||
.is_ok();
|
||||
let skills_restored = settings_restored
|
||||
&& crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(
|
||||
&self.db,
|
||||
)
|
||||
.is_ok();
|
||||
let old_gateway_restored = if settings_restored {
|
||||
self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok()
|
||||
} else {
|
||||
self.pi_runtime.republish_current(epoch).await.is_ok()
|
||||
};
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to publish Pi in the new native directory: {error}; rollback: new_direct={new_direct_restored}, settings={settings_restored}, prompts={prompts_restored}, old_gateway={old_gateway_restored}"
|
||||
"failed to publish Pi in the new native directory: {error}; rollback: native={new_native_restored}, settings={settings_restored}, prompts={prompts_restored}, skills={skills_restored}, old_gateway={old_gateway_restored}"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -4001,6 +4102,10 @@ mod tests {
|
||||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
// Tests mutate the process-global settings cache after redirecting
|
||||
// the home directory. Restore the cache after the environment so
|
||||
// later serial tests do not inherit a dead temporary Pi override.
|
||||
let _ = crate::settings::reload_settings();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4123,6 +4228,33 @@ mod tests {
|
||||
crate::settings::update_settings(settings).expect("set old Pi directory");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("in-memory database"));
|
||||
let skill_source = crate::services::skill::SkillService::get_ssot_dir()
|
||||
.expect("SSOT")
|
||||
.join("directory-move");
|
||||
std::fs::create_dir_all(&skill_source).expect("skill source");
|
||||
std::fs::write(
|
||||
skill_source.join("SKILL.md"),
|
||||
"---\nname: directory-move\ndescription: directory move\n---\n",
|
||||
)
|
||||
.expect("skill manifest");
|
||||
db.save_skill(&crate::app_config::InstalledSkill {
|
||||
id: "local:directory-move".to_string(),
|
||||
name: "Directory move".to_string(),
|
||||
description: Some("directory move".to_string()),
|
||||
directory: "directory-move".to_string(),
|
||||
repo_owner: None,
|
||||
repo_name: None,
|
||||
repo_branch: None,
|
||||
readme_url: None,
|
||||
apps: crate::app_config::SkillApps::only(&AppType::Pi),
|
||||
installed_at: 1,
|
||||
content_hash: None,
|
||||
updated_at: 1,
|
||||
})
|
||||
.expect("save skill");
|
||||
crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(&db)
|
||||
.expect("deploy skill in old root");
|
||||
assert!(old_dir.join("skills").join("directory-move").exists());
|
||||
db.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&crate::prompt::Prompt {
|
||||
@@ -4223,6 +4355,18 @@ mod tests {
|
||||
std::fs::read_to_string(new_dir.join("AGENTS.md")).expect("new AGENTS"),
|
||||
"new-root-agents"
|
||||
);
|
||||
assert!(
|
||||
!old_dir.join("skills").join("directory-move").exists(),
|
||||
"verified stale Skill ownership must be cleaned after new deployment"
|
||||
);
|
||||
assert!(new_dir.join("skills").join("directory-move").exists());
|
||||
let deployments = db
|
||||
.get_pi_skill_deployments("local:directory-move")
|
||||
.expect("skill deployments");
|
||||
assert_eq!(deployments.len(), 1);
|
||||
assert!(deployments[0]
|
||||
.destination
|
||||
.contains(new_dir.to_string_lossy().as_ref()));
|
||||
|
||||
service
|
||||
.set_takeover_for_app("pi", false)
|
||||
@@ -4230,6 +4374,84 @@ mod tests {
|
||||
.expect("disable Pi takeover");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn changing_pi_directory_rejects_an_unowned_managed_key_before_side_effects() {
|
||||
let home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload isolated settings");
|
||||
let old_dir = home.dir.path().join("old-pi");
|
||||
let new_dir = home.dir.path().join("new-pi");
|
||||
std::fs::create_dir_all(&old_dir).expect("old Pi directory");
|
||||
std::fs::create_dir_all(&new_dir).expect("new Pi directory");
|
||||
|
||||
let managed = json!({
|
||||
"name": "Managed Pi",
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://managed.example/v1",
|
||||
"apiKey": "managed-key",
|
||||
"models": [{"id": "model-a", "name": "Model A"}]
|
||||
});
|
||||
let foreign = json!({
|
||||
"api": "anthropic-messages",
|
||||
"baseUrl": "https://foreign.example",
|
||||
"apiKey": "foreign-secret",
|
||||
"models": [{"id": "foreign-model"}]
|
||||
});
|
||||
let foreign_document = serde_json::to_vec_pretty(&json!({
|
||||
"providers": {"managed-pi": foreign}
|
||||
}))
|
||||
.expect("foreign models");
|
||||
std::fs::write(new_dir.join("models.json"), &foreign_document).expect("seed foreign key");
|
||||
|
||||
let mut settings = crate::settings::get_settings();
|
||||
settings.pi_config_dir = Some(old_dir.to_string_lossy().into_owned());
|
||||
settings.pi_takeover_enabled = false;
|
||||
crate::settings::update_settings(settings).expect("old Pi settings");
|
||||
let db = Arc::new(Database::memory().expect("database"));
|
||||
db.create_pi_catalog_provider(
|
||||
NewProviderAggregate::from_input(
|
||||
AppType::Pi.as_str(),
|
||||
ProviderMutationInput {
|
||||
id: "managed-pi".to_string(),
|
||||
name: "Managed Pi".to_string(),
|
||||
settings_config: managed,
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: Some(0),
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: Some("pi".to_string()),
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
},
|
||||
)
|
||||
.expect("aggregate"),
|
||||
"managed-pi",
|
||||
)
|
||||
.expect("managed provider");
|
||||
|
||||
let service = ProxyService::new(db);
|
||||
let switch_guard = service.lock_switch_for_app(AppType::Pi.as_str()).await;
|
||||
let existing = crate::settings::get_settings();
|
||||
let mut next = existing.clone();
|
||||
next.pi_config_dir = Some(new_dir.to_string_lossy().into_owned());
|
||||
let error = service
|
||||
.replace_settings_with_pi_directory_boundary_under_lock(&switch_guard, &existing, next)
|
||||
.await
|
||||
.expect_err("unowned target key must reject the directory move");
|
||||
assert!(error.to_string().contains("overwrite unowned provider key"));
|
||||
assert_eq!(
|
||||
crate::settings::get_settings().pi_config_dir,
|
||||
existing.pi_config_dir,
|
||||
"preflight rejection must not switch the native authority"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(new_dir.join("models.json")).expect("foreign models remain"),
|
||||
foreign_document
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn changing_pi_directory_without_takeover_reconciles_missing_agents_truth() {
|
||||
@@ -4247,6 +4469,35 @@ mod tests {
|
||||
crate::settings::update_settings(settings).expect("old directory settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("database"));
|
||||
let direct = json!({
|
||||
"name": "Managed direct Pi",
|
||||
"api": "openai-responses",
|
||||
"baseUrl": "https://direct.example/v1",
|
||||
"apiKey": "direct-key",
|
||||
"models": [{"id": "direct-model", "name": "Direct model"}]
|
||||
});
|
||||
db.create_pi_catalog_provider(
|
||||
NewProviderAggregate::from_input(
|
||||
AppType::Pi.as_str(),
|
||||
ProviderMutationInput {
|
||||
id: "managed-direct".to_string(),
|
||||
name: "Managed direct Pi".to_string(),
|
||||
settings_config: direct.clone(),
|
||||
website_url: None,
|
||||
category: None,
|
||||
created_at: None,
|
||||
sort_index: Some(0),
|
||||
notes: None,
|
||||
meta: None,
|
||||
icon: Some("pi".to_string()),
|
||||
icon_color: None,
|
||||
in_failover_queue: false,
|
||||
},
|
||||
)
|
||||
.expect("aggregate"),
|
||||
"managed-direct",
|
||||
)
|
||||
.expect("managed direct provider");
|
||||
db.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&crate::prompt::Prompt {
|
||||
@@ -4282,6 +4533,15 @@ mod tests {
|
||||
"old-only"
|
||||
);
|
||||
assert!(!new_dir.join("AGENTS.md").exists());
|
||||
let new_models: Value = serde_json::from_slice(
|
||||
&std::fs::read(new_dir.join("models.json")).expect("new direct models"),
|
||||
)
|
||||
.expect("parse new direct models");
|
||||
assert_eq!(
|
||||
new_models.pointer("/providers/managed-direct"),
|
||||
Some(&direct),
|
||||
"a direct-mode directory change must publish every managed provider in the new root"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user