mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(pi): reconcile prompt authority without partial state
This commit is contained in:
@@ -95,6 +95,21 @@ impl PromptService {
|
||||
}
|
||||
|
||||
pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> {
|
||||
if matches!(app, AppType::Pi) {
|
||||
let guard = lock_instruction_files()?;
|
||||
let prompts = state.db.get_prompts(AppType::Pi.as_str())?;
|
||||
let snapshot =
|
||||
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
|
||||
reject_pi_prompt_mutation_during_drift(&prompts, &snapshot, id, None)?;
|
||||
if prompts.get(id).is_some_and(|prompt| prompt.enabled)
|
||||
|| preferred_pi_prompt_match(&prompts, &snapshot) == Some(id)
|
||||
{
|
||||
return Err(AppError::InvalidInput(
|
||||
"无法删除 Pi 当前生效的提示词".to_string(),
|
||||
));
|
||||
}
|
||||
return state.db.delete_prompt(AppType::Pi.as_str(), id);
|
||||
}
|
||||
let prompts = state.db.get_prompts(app.as_str())?;
|
||||
|
||||
if let Some(prompt) = prompts.get(id) {
|
||||
@@ -338,60 +353,40 @@ impl PromptService {
|
||||
guard: &PiInstructionFileGuard,
|
||||
) -> Result<(), AppError> {
|
||||
const MAX_EXTERNAL_RETRIES: usize = 3;
|
||||
let original = db.get_prompts(AppType::Pi.as_str())?;
|
||||
|
||||
for _ in 0..MAX_EXTERNAL_RETRIES {
|
||||
let snapshot =
|
||||
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
||||
let mut prompts = db.get_prompts(AppType::Pi.as_str())?;
|
||||
for prompt in prompts.values_mut() {
|
||||
prompt.enabled = false;
|
||||
}
|
||||
|
||||
if snapshot.exists {
|
||||
let active_id = prompts
|
||||
.iter()
|
||||
.find_map(|(id, prompt)| {
|
||||
(prompt.content == snapshot.content).then(|| id.clone())
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
let digest = format!("{:x}", Sha256::digest(snapshot.content.as_bytes()));
|
||||
let base = format!("native-{digest}");
|
||||
let mut id = base.clone();
|
||||
let mut suffix = 1_u32;
|
||||
while prompts.contains_key(&id) {
|
||||
id = format!("{base}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
prompts.insert(
|
||||
id.clone(),
|
||||
Prompt {
|
||||
id: id.clone(),
|
||||
name: "Imported from Pi AGENTS.md".to_string(),
|
||||
content: snapshot.content.clone(),
|
||||
description: Some(
|
||||
"Device-local native state preserved during portable import"
|
||||
.to_string(),
|
||||
),
|
||||
enabled: false,
|
||||
created_at: Some(timestamp),
|
||||
updated_at: Some(timestamp),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
prompts
|
||||
.get_mut(&active_id)
|
||||
.expect("selected Pi prompt is present")
|
||||
.enabled = true;
|
||||
let prompts = build_pi_reconciled_library(&original, &snapshot);
|
||||
let precommit =
|
||||
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
||||
if precommit.revision != snapshot.revision {
|
||||
continue;
|
||||
}
|
||||
|
||||
db.save_prompt_selection(AppType::Pi.as_str(), &prompts)?;
|
||||
#[cfg(test)]
|
||||
replace_pi_agents_after_reconcile_save_for_test(&snapshot.path)?;
|
||||
let verified =
|
||||
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
||||
match PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)
|
||||
{
|
||||
Ok(verified) => verified,
|
||||
Err(error) => {
|
||||
restore_pi_library_after_failed_reconcile(db, &original, &error)?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if verified.revision == snapshot.revision {
|
||||
return Ok(());
|
||||
}
|
||||
restore_pi_library_after_failed_reconcile(
|
||||
db,
|
||||
&original,
|
||||
&AppError::Conflict(
|
||||
"Pi AGENTS.md changed after prompt-library publication".to_string(),
|
||||
),
|
||||
)?;
|
||||
}
|
||||
|
||||
Err(AppError::Conflict(
|
||||
@@ -410,13 +405,8 @@ impl PromptService {
|
||||
.iter()
|
||||
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let matched_prompt_id = if snapshot.exists {
|
||||
prompts
|
||||
.iter()
|
||||
.find_map(|(id, prompt)| (prompt.content == snapshot.content).then(|| id.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let matched_prompt_id =
|
||||
preferred_pi_prompt_match(&prompts, &snapshot).map(ToOwned::to_owned);
|
||||
let expected_enabled = matched_prompt_id.iter().cloned().collect::<Vec<_>>();
|
||||
let needs_reconciliation = persisted_enabled != expected_enabled
|
||||
|| (snapshot.exists && matched_prompt_id.is_none());
|
||||
@@ -438,15 +428,17 @@ impl PromptService {
|
||||
|
||||
fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> {
|
||||
let guard = lock_instruction_files()?;
|
||||
let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?;
|
||||
let previous = prompts.insert(prompt.id.clone(), prompt.clone());
|
||||
let before = state.db.get_prompts(AppType::Pi.as_str())?;
|
||||
let snapshot =
|
||||
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
|
||||
reject_pi_prompt_mutation_during_drift(&before, &snapshot, &prompt.id, Some(&prompt))?;
|
||||
let mut prompts = before.clone();
|
||||
let previous = prompts.insert(prompt.id.clone(), prompt.clone());
|
||||
let current_enabled = previous
|
||||
.as_ref()
|
||||
.filter(|candidate| candidate.enabled)
|
||||
.or_else(|| {
|
||||
prompts
|
||||
before
|
||||
.values()
|
||||
.find(|candidate| candidate.id != prompt.id && candidate.enabled)
|
||||
});
|
||||
@@ -572,6 +564,150 @@ impl PromptService {
|
||||
}
|
||||
}
|
||||
|
||||
fn preferred_pi_prompt_match<'a>(
|
||||
prompts: &'a IndexMap<String, Prompt>,
|
||||
snapshot: &PiPromptFileSnapshot,
|
||||
) -> Option<&'a str> {
|
||||
if !snapshot.exists {
|
||||
return None;
|
||||
}
|
||||
prompts
|
||||
.iter()
|
||||
.find_map(|(id, prompt)| {
|
||||
(prompt.enabled && prompt.content == snapshot.content).then_some(id.as_str())
|
||||
})
|
||||
.or_else(|| {
|
||||
prompts.iter().find_map(|(id, prompt)| {
|
||||
(prompt.content == snapshot.content).then_some(id.as_str())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn pi_library_needs_reconciliation(
|
||||
prompts: &IndexMap<String, Prompt>,
|
||||
snapshot: &PiPromptFileSnapshot,
|
||||
) -> bool {
|
||||
let persisted_enabled = prompts
|
||||
.iter()
|
||||
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
let matched = preferred_pi_prompt_match(prompts, snapshot);
|
||||
persisted_enabled != matched.into_iter().collect::<Vec<_>>()
|
||||
|| (snapshot.exists && matched.is_none())
|
||||
}
|
||||
|
||||
fn reject_pi_prompt_mutation_during_drift(
|
||||
prompts: &IndexMap<String, Prompt>,
|
||||
snapshot: &PiPromptFileSnapshot,
|
||||
target_id: &str,
|
||||
replacement: Option<&Prompt>,
|
||||
) -> Result<(), AppError> {
|
||||
if !pi_library_needs_reconciliation(prompts, snapshot) {
|
||||
return Ok(());
|
||||
}
|
||||
let target_is_persisted_active = prompts.get(target_id).is_some_and(|prompt| prompt.enabled);
|
||||
let target_is_live_match = preferred_pi_prompt_match(prompts, snapshot) == Some(target_id);
|
||||
let replacement_claims_live = replacement.is_some_and(|prompt| {
|
||||
prompt.enabled || (snapshot.exists && prompt.content == snapshot.content)
|
||||
});
|
||||
if target_is_persisted_active || target_is_live_match || replacement_claims_live {
|
||||
return Err(AppError::Conflict(
|
||||
"Pi AGENTS.md and the prompt library disagree; reconcile native truth before changing \
|
||||
an active prompt"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_pi_reconciled_library(
|
||||
original: &IndexMap<String, Prompt>,
|
||||
snapshot: &PiPromptFileSnapshot,
|
||||
) -> IndexMap<String, Prompt> {
|
||||
let mut prompts = original.clone();
|
||||
for prompt in prompts.values_mut() {
|
||||
prompt.enabled = false;
|
||||
}
|
||||
if !snapshot.exists {
|
||||
return prompts;
|
||||
}
|
||||
|
||||
let active_id = preferred_pi_prompt_match(original, snapshot)
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
let digest = format!("{:x}", Sha256::digest(snapshot.content.as_bytes()));
|
||||
let base = format!("native-{digest}");
|
||||
let mut id = base.clone();
|
||||
let mut suffix = 1_u32;
|
||||
while prompts.contains_key(&id) {
|
||||
id = format!("{base}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
prompts.insert(
|
||||
id.clone(),
|
||||
Prompt {
|
||||
id: id.clone(),
|
||||
name: "Imported from Pi AGENTS.md".to_string(),
|
||||
content: snapshot.content.clone(),
|
||||
description: Some(
|
||||
"Device-local native state preserved during portable import".to_string(),
|
||||
),
|
||||
enabled: false,
|
||||
created_at: Some(timestamp),
|
||||
updated_at: Some(timestamp),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
prompts
|
||||
.get_mut(&active_id)
|
||||
.expect("selected Pi prompt is present")
|
||||
.enabled = true;
|
||||
prompts
|
||||
}
|
||||
|
||||
fn restore_pi_library_after_failed_reconcile(
|
||||
db: &Database,
|
||||
original: &IndexMap<String, Prompt>,
|
||||
cause: &AppError,
|
||||
) -> Result<(), AppError> {
|
||||
db.save_prompt_selection(AppType::Pi.as_str(), original)
|
||||
.map_err(|restore_error| {
|
||||
AppError::Config(format!(
|
||||
"Pi prompt reconciliation lost its native revision ({cause}) and failed to \
|
||||
restore the previous portable library: {restore_error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
static PI_RECONCILE_AFTER_SAVE_REPLACEMENTS: std::sync::LazyLock<
|
||||
std::sync::Mutex<std::collections::VecDeque<String>>,
|
||||
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
|
||||
#[cfg(test)]
|
||||
fn replace_pi_agents_after_reconcile_save_for_test(path: &str) -> Result<(), AppError> {
|
||||
let replacement = PI_RECONCILE_AFTER_SAVE_REPLACEMENTS
|
||||
.lock()
|
||||
.map_err(|error| AppError::Lock(error.to_string()))?
|
||||
.pop_front();
|
||||
if let Some(replacement) = replacement {
|
||||
std::fs::write(path, replacement).map_err(|error| AppError::io(path, error))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn replace_pi_agents_after_each_reconcile_save_for_test(
|
||||
replacements: impl IntoIterator<Item = &'static str>,
|
||||
) {
|
||||
*PI_RECONCILE_AFTER_SAVE_REPLACEMENTS
|
||||
.lock()
|
||||
.expect("Pi reconcile hook lock") =
|
||||
replacements.into_iter().map(ToOwned::to_owned).collect();
|
||||
}
|
||||
|
||||
fn ensure_pi_library_projection_matches(
|
||||
snapshot: &PiPromptFileSnapshot,
|
||||
current_enabled: Option<&Prompt>,
|
||||
@@ -641,6 +777,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt(id: &str, content: &str, enabled: bool, timestamp: i64) -> Prompt {
|
||||
Prompt {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
content: content.to_string(),
|
||||
description: None,
|
||||
enabled,
|
||||
created_at: Some(timestamp),
|
||||
updated_at: Some(timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn public_pi_import_reconciles_an_active_empty_agents_file() {
|
||||
@@ -909,4 +1057,139 @@ mod tests {
|
||||
.all(|prompt| !prompt.enabled));
|
||||
assert!(!temp.path().join("AGENTS.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn duplicate_prompt_content_prefers_the_persisted_enabled_identity() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
|
||||
std::fs::write(temp.path().join("AGENTS.md"), "same-content").expect("seed AGENTS.md");
|
||||
let state = AppState::new(Arc::new(Database::memory().expect("database")));
|
||||
state
|
||||
.db
|
||||
.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&prompt("first", "same-content", false, 1),
|
||||
)
|
||||
.expect("first duplicate");
|
||||
state
|
||||
.db
|
||||
.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&prompt("persisted-active", "same-content", true, 2),
|
||||
)
|
||||
.expect("active duplicate");
|
||||
|
||||
let effective = PromptService::get_prompts(&state, AppType::Pi).expect("effective prompts");
|
||||
assert!(!effective["first"].enabled);
|
||||
assert!(effective["persisted-active"].enabled);
|
||||
let status = PromptService::get_pi_library_status(&state).expect("status");
|
||||
assert_eq!(
|
||||
status.matched_prompt_id.as_deref(),
|
||||
Some("persisted-active")
|
||||
);
|
||||
assert!(!status.needs_reconciliation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn external_prompt_drift_blocks_deleting_or_disabling_the_live_match() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
|
||||
std::fs::write(temp.path().join("AGENTS.md"), "external-live").expect("seed AGENTS.md");
|
||||
let state = AppState::new(Arc::new(Database::memory().expect("database")));
|
||||
state
|
||||
.db
|
||||
.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&prompt("stale-active", "stale-content", true, 1),
|
||||
)
|
||||
.expect("stale prompt");
|
||||
state
|
||||
.db
|
||||
.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&prompt("live-match", "external-live", false, 2),
|
||||
)
|
||||
.expect("live match");
|
||||
let before = serde_json::to_value(
|
||||
state
|
||||
.db
|
||||
.get_prompts(AppType::Pi.as_str())
|
||||
.expect("before prompts"),
|
||||
)
|
||||
.expect("serialize before");
|
||||
|
||||
assert!(matches!(
|
||||
PromptService::delete_prompt(&state, AppType::Pi, "live-match"),
|
||||
Err(AppError::Conflict(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
PromptService::upsert_prompt(
|
||||
&state,
|
||||
AppType::Pi,
|
||||
"live-match",
|
||||
prompt("live-match", "external-live", false, 3),
|
||||
),
|
||||
Err(AppError::Conflict(_))
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
state
|
||||
.db
|
||||
.get_prompts(AppType::Pi.as_str())
|
||||
.expect("after prompts")
|
||||
)
|
||||
.expect("serialize after"),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("live remains"),
|
||||
"external-live"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn repeated_external_reconcile_races_restore_the_original_library() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
|
||||
std::fs::write(temp.path().join("AGENTS.md"), "native-0").expect("seed AGENTS.md");
|
||||
let state = AppState::new(Arc::new(Database::memory().expect("database")));
|
||||
state
|
||||
.db
|
||||
.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&prompt("portable", "portable-content", true, 1),
|
||||
)
|
||||
.expect("portable prompt");
|
||||
let before = serde_json::to_value(
|
||||
state
|
||||
.db
|
||||
.get_prompts(AppType::Pi.as_str())
|
||||
.expect("before prompts"),
|
||||
)
|
||||
.expect("serialize before");
|
||||
replace_pi_agents_after_each_reconcile_save_for_test(["native-1", "native-2", "native-3"]);
|
||||
|
||||
assert!(matches!(
|
||||
PromptService::reconcile_pi_library(&state),
|
||||
Err(AppError::Conflict(_))
|
||||
));
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
state
|
||||
.db
|
||||
.get_prompts(AppType::Pi.as_str())
|
||||
.expect("restored prompts")
|
||||
)
|
||||
.expect("serialize restored"),
|
||||
before,
|
||||
"a failed reconcile must leave no imported rows or selection changes"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("latest native"),
|
||||
"native-3"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user