fix(pi): reconcile native resources across directory moves

This commit is contained in:
SaladDay
2026-08-03 02:08:44 +00:00
parent 969cdaed5b
commit 5360061bb6
4 changed files with 631 additions and 66 deletions
+267 -7
View File
@@ -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]