diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index f184c62d2..7f0fc39ec 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -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::>(); - 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::>(); 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, + 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, + snapshot: &PiPromptFileSnapshot, +) -> bool { + let persisted_enabled = prompts + .iter() + .filter_map(|(id, prompt)| prompt.enabled.then_some(id.as_str())) + .collect::>(); + let matched = preferred_pi_prompt_match(prompts, snapshot); + persisted_enabled != matched.into_iter().collect::>() + || (snapshot.exists && matched.is_none()) +} + +fn reject_pi_prompt_mutation_during_drift( + prompts: &IndexMap, + 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, + snapshot: &PiPromptFileSnapshot, +) -> IndexMap { + 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, + 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::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, +) { + *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" + ); + } }