From 0b609158d74d3802ac892305e5fee5d6cf116d17 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Mon, 3 Aug 2026 04:43:44 +0000 Subject: [PATCH] fix(pi): bind native and prompt recovery to revisions --- src-tauri/src/database/dao/prompts.rs | 210 +++++++++++++++++-------- src-tauri/src/pi_config/document.rs | 170 ++++++++++++++++++-- src-tauri/src/pi_config/gateway.rs | 76 ++++++++- src-tauri/src/pi_config/native.rs | 9 +- src-tauri/src/pi_config/shared_file.rs | 53 ++++++- src-tauri/src/prompt.rs | 2 +- src-tauri/src/proxy/pi_runtime.rs | 60 ++++++- src-tauri/src/services/pi_catalog.rs | 90 ++++++++++- src-tauri/src/services/prompt.rs | 200 +++++++++++++++++++---- src-tauri/src/services/proxy.rs | 49 +++++- 10 files changed, 778 insertions(+), 141 deletions(-) diff --git a/src-tauri/src/database/dao/prompts.rs b/src-tauri/src/database/dao/prompts.rs index 00a2db9aa..d0f0010fa 100644 --- a/src-tauri/src/database/dao/prompts.rs +++ b/src-tauri/src/database/dao/prompts.rs @@ -6,51 +6,106 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::prompt::Prompt; use indexmap::IndexMap; -use rusqlite::params; +use rusqlite::{params, Connection, Transaction}; + +fn query_prompts(conn: &Connection, app_type: &str) -> Result, AppError> { + let mut stmt = conn + .prepare( + "SELECT id, name, content, description, enabled, created_at, updated_at + FROM prompts WHERE app_type = ?1 + ORDER BY created_at ASC, id ASC", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + let prompt_iter = stmt + .query_map(params![app_type], |row| { + let id: String = row.get(0)?; + let name: String = row.get(1)?; + let content: String = row.get(2)?; + let description: Option = row.get(3)?; + let enabled: bool = row.get(4)?; + let created_at: Option = row.get(5)?; + let updated_at: Option = row.get(6)?; + + Ok(( + id.clone(), + Prompt { + id, + name, + content, + description, + enabled, + created_at, + updated_at, + }, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut prompts = IndexMap::new(); + for prompt_res in prompt_iter { + let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?; + prompts.insert(id, prompt); + } + Ok(prompts) +} + +fn validate_prompt_selection(prompts: &IndexMap) -> Result<(), AppError> { + if prompts.values().filter(|prompt| prompt.enabled).count() > 1 { + return Err(AppError::InvalidInput( + "at most one prompt may be enabled for an app".to_string(), + )); + } + Ok(()) +} + +fn replace_prompt_rows( + transaction: &Transaction<'_>, + app_type: &str, + prompts: &IndexMap, +) -> Result<(), AppError> { + transaction + .execute("DELETE FROM prompts WHERE app_type = ?1", [app_type]) + .map_err(|error| AppError::Database(error.to_string()))?; + let mut statement = transaction + .prepare( + "INSERT OR REPLACE INTO prompts ( + id, app_type, name, content, description, enabled, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + for prompt in prompts.values() { + statement + .execute(params![ + prompt.id, + app_type, + prompt.name, + prompt.content, + prompt.description, + prompt.enabled, + prompt.created_at, + prompt.updated_at, + ]) + .map_err(|error| AppError::Database(error.to_string()))?; + } + Ok(()) +} + +fn prompt_libraries_equal( + left: &IndexMap, + right: &IndexMap, +) -> bool { + left.len() == right.len() + && left + .iter() + .all(|(id, prompt)| right.get(id) == Some(prompt)) +} impl Database { /// 获取指定应用类型的所有提示词 pub fn get_prompts(&self, app_type: &str) -> Result, AppError> { let conn = lock_conn!(self.conn); - let mut stmt = conn - .prepare( - "SELECT id, name, content, description, enabled, created_at, updated_at - FROM prompts WHERE app_type = ?1 - ORDER BY created_at ASC, id ASC", - ) - .map_err(|e| AppError::Database(e.to_string()))?; - - let prompt_iter = stmt - .query_map(params![app_type], |row| { - let id: String = row.get(0)?; - let name: String = row.get(1)?; - let content: String = row.get(2)?; - let description: Option = row.get(3)?; - let enabled: bool = row.get(4)?; - let created_at: Option = row.get(5)?; - let updated_at: Option = row.get(6)?; - - Ok(( - id.clone(), - Prompt { - id, - name, - content, - description, - enabled, - created_at, - updated_at, - }, - )) - }) - .map_err(|e| AppError::Database(e.to_string()))?; - - let mut prompts = IndexMap::new(); - for prompt_res in prompt_iter { - let (id, prompt) = prompt_res.map_err(|e| AppError::Database(e.to_string()))?; - prompts.insert(id, prompt); - } - Ok(prompts) + query_prompts(&conn, app_type) } /// 保存提示词 @@ -85,41 +140,60 @@ impl Database { app_type: &str, prompts: &IndexMap, ) -> Result<(), AppError> { - if prompts.values().filter(|prompt| prompt.enabled).count() > 1 { - return Err(AppError::InvalidInput( - "at most one prompt may be enabled for an app".to_string(), - )); - } + validate_prompt_selection(prompts)?; let mut conn = lock_conn!(self.conn); let transaction = conn .transaction() .map_err(|error| AppError::Database(error.to_string()))?; + replace_prompt_rows(&transaction, app_type, prompts)?; transaction - .execute("DELETE FROM prompts WHERE app_type = ?1", [app_type]) + .commit() + .map_err(|error| AppError::Database(error.to_string())) + } + + /// Atomically publish a complete prompt library only while its full + /// before-image still matches. This is the database half of Pi's + /// native-file/portable-library compare-and-swap boundary. + pub(crate) fn compare_exchange_prompt_selection( + &self, + app_type: &str, + expected: &IndexMap, + replacement: &IndexMap, + ) -> Result<(), AppError> { + validate_prompt_selection(replacement)?; + self.compare_exchange_prompt_selection_unchecked(app_type, expected, replacement) + } + + /// Restore a captured before-image only if the database still contains the + /// exact attempted projection. The before-image may predate the current + /// single-selection invariant, so compensation must preserve it byte for + /// byte instead of refusing to restore legacy rows. + pub(crate) fn restore_prompt_selection_if_attempted( + &self, + app_type: &str, + attempted: &IndexMap, + before: &IndexMap, + ) -> Result<(), AppError> { + self.compare_exchange_prompt_selection_unchecked(app_type, attempted, before) + } + + fn compare_exchange_prompt_selection_unchecked( + &self, + app_type: &str, + expected: &IndexMap, + replacement: &IndexMap, + ) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let transaction = conn + .transaction() .map_err(|error| AppError::Database(error.to_string()))?; - { - let mut statement = transaction - .prepare( - "INSERT OR REPLACE INTO prompts ( - id, app_type, name, content, description, enabled, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - ) - .map_err(|error| AppError::Database(error.to_string()))?; - for prompt in prompts.values() { - statement - .execute(params![ - prompt.id, - app_type, - prompt.name, - prompt.content, - prompt.description, - prompt.enabled, - prompt.created_at, - prompt.updated_at, - ]) - .map_err(|error| AppError::Database(error.to_string()))?; - } + let observed = query_prompts(&transaction, app_type)?; + if !prompt_libraries_equal(&observed, expected) { + return Err(AppError::Conflict(format!( + "{app_type} prompt library changed since it was read" + ))); } + replace_prompt_rows(&transaction, app_type, replacement)?; transaction .commit() .map_err(|error| AppError::Database(error.to_string())) diff --git a/src-tauri/src/pi_config/document.rs b/src-tauri/src/pi_config/document.rs index 1ede570dd..11678fa15 100644 --- a/src-tauri/src/pi_config/document.rs +++ b/src-tauri/src/pi_config/document.rs @@ -13,6 +13,7 @@ use jsonc_parser::cst::{ use jsonc_parser::ParseOptions; use regex::Regex; use serde_json::Value; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; @@ -318,6 +319,10 @@ pub(super) fn read_pi_models_document(path: &Path) -> Result String { + format!("sha256:{:x}", Sha256::digest(raw_source.as_bytes())) +} + fn serialize_models_mutation( path: &Path, before: Option<&[u8]>, @@ -366,12 +371,13 @@ pub(crate) fn apply_pi_provider_patch( path: &Path, patch: &IndexMap>, ) -> Result<(), AppError> { - apply_pi_provider_patch_checked(path, None, patch).map(|_| ()) + apply_pi_provider_patch_checked(path, None, None, patch).map(|_| ()) } fn apply_pi_provider_patch_checked( path: &Path, expected: Option<&IndexMap>>, + expected_fingerprints: Option<&IndexMap>, patch: &IndexMap>, ) -> Result { let lock = path_lock(path)?; @@ -385,6 +391,9 @@ fn apply_pi_provider_patch_checked( if let Some(expected) = expected { ensure_provider_values_match(path, before.as_deref(), expected)?; } + if let Some(expected_fingerprints) = expected_fingerprints { + ensure_provider_fingerprints_match(path, before.as_deref(), expected_fingerprints)?; + } if before.is_none() && patch.values().all(Option::is_none) { return Ok(false); } @@ -472,6 +481,28 @@ fn ensure_provider_values_match( Ok(()) } +fn ensure_provider_fingerprints_match( + path: &Path, + bytes: Option<&[u8]>, + expected: &IndexMap, +) -> Result<(), AppError> { + let document = parse_pi_models_document(path, bytes)?; + for (provider_key, expected_fingerprint) in expected { + let observed = document + .providers() + .get(provider_key) + .map(|entry| pi_raw_provider_fingerprint(&entry.raw_source)); + if observed.as_deref() != Some(expected_fingerprint) { + return Err(AppError::Conflict(format!( + "Pi native provider '{provider_key}' changed since inspection \ + (expected raw fingerprint {expected_fingerprint}, observed {})", + observed.as_deref().unwrap_or("missing") + ))); + } + } + Ok(()) +} + fn provider_value_label(value: &Option) -> &'static str { if value.is_some() { "present" @@ -511,9 +542,12 @@ impl PiProviderPatchReceipt { /// concurrent edit of an owned key turns compensation into an explicit /// conflict instead of being overwritten. pub(crate) fn rollback(&self) -> Result<(), AppError> { - if let Err(error) = - apply_pi_provider_patch_checked(&self.path, Some(&self.attempted), &self.before.values) - { + if let Err(error) = apply_pi_provider_patch_checked( + &self.path, + Some(&self.attempted), + None, + &self.before.values, + ) { let observed = snapshot_pi_provider_values(&self.path, self.before.values.keys().cloned())?; if observed.values != self.before.values { @@ -559,6 +593,30 @@ pub(crate) fn verify_pi_provider_values( let lock = path_lock(path)?; let _guard = lock_path(&lock)?; #[cfg(test)] + run_before_provider_verify_hook(path)?; + let bytes = read_models_bytes(path)?; + ensure_provider_values_match(path, bytes.as_deref(), expected) +} + +/// Revalidate semantic exact-key values and raw entry fingerprints under one +/// path lock. Native import uses the stronger raw barrier because its ownership +/// token is the fingerprint returned by public inspection. +pub(crate) fn verify_pi_provider_preconditions( + path: &Path, + expected_values: &IndexMap>, + expected_fingerprints: &IndexMap, +) -> Result<(), AppError> { + let lock = path_lock(path)?; + let _guard = lock_path(&lock)?; + #[cfg(test)] + run_before_provider_verify_hook(path)?; + let bytes = read_models_bytes(path)?; + ensure_provider_values_match(path, bytes.as_deref(), expected_values)?; + ensure_provider_fingerprints_match(path, bytes.as_deref(), expected_fingerprints) +} + +#[cfg(test)] +fn run_before_provider_verify_hook(path: &Path) -> Result<(), AppError> { if let Some(replacement) = BEFORE_PROVIDER_VERIFY .lock() .map_err(|error| AppError::Lock(error.to_string()))? @@ -566,8 +624,7 @@ pub(crate) fn verify_pi_provider_values( { fs::write(path, replacement).map_err(|error| AppError::io(path, error))?; } - let bytes = read_models_bytes(path)?; - ensure_provider_values_match(path, bytes.as_deref(), expected) + Ok(()) } #[cfg(test)] @@ -603,9 +660,7 @@ fn failed_publish_after_conditional_compensation( attempted: &IndexMap>, publish_error: AppError, ) -> AppError { - if matches!(&publish_error, AppError::Conflict(_)) { - return publish_error; - } + let retryable_conflict = matches!(&publish_error, AppError::Conflict(_)); let observed = match snapshot_pi_provider_values(path, before.values.keys().cloned()) { Ok(observed) => observed, Err(observe_error) => { @@ -616,15 +671,23 @@ fn failed_publish_after_conditional_compensation( } }; if observed.values == before.values { - if let Err(sync_error) = sync_shared_file_parent(path) { - return AppError::Config(format!( - "Pi provider publication failed ({publish_error}); the previous exact-key state \ - is visible but directory durability could not be confirmed: {sync_error}" - )); + if !retryable_conflict { + if let Err(sync_error) = sync_shared_file_parent(path) { + return AppError::Config(format!( + "Pi provider publication failed ({publish_error}); the previous exact-key state \ + is visible but directory durability could not be confirmed: {sync_error}" + )); + } } return publish_error; } if observed.values != *attempted { + if retryable_conflict { + // A normal compare/exchange conflict must remain retryable. Its + // canonical value belongs to the concurrent writer; any displaced + // recovery artifact is already named in the lower-level error. + return publish_error; + } return AppError::Config(format!( "Pi provider publication failed ({publish_error}); the live exact-key state was \ superseded before conditional compensation and was preserved" @@ -653,6 +716,17 @@ pub(crate) fn apply_pi_provider_patch_with_receipt( path: &Path, before: &PiProviderValuesSnapshot, patch: &IndexMap>, +) -> Result { + apply_pi_provider_patch_with_receipt_and_fingerprints(path, before, None, patch) +} + +/// Publish an exact-key patch while atomically binding selected entries to the +/// raw inspection fingerprints which authorized an ownership claim. +pub(crate) fn apply_pi_provider_patch_with_receipt_and_fingerprints( + path: &Path, + before: &PiProviderValuesSnapshot, + expected_fingerprints: Option<&IndexMap>, + patch: &IndexMap>, ) -> Result { if patch .keys() @@ -663,7 +737,12 @@ pub(crate) fn apply_pi_provider_patch_with_receipt( )); } let attempted = attempted_provider_values(before, patch); - let changed = match apply_pi_provider_patch_checked(path, Some(&before.values), patch) { + let changed = match apply_pi_provider_patch_checked( + path, + Some(&before.values), + expected_fingerprints, + patch, + ) { Ok(changed) => changed, Err(error) => { return Err(failed_publish_after_conditional_compensation( @@ -976,6 +1055,67 @@ mod tests { assert_eq!(restored["providers"]["external"]["api"], "keep"); } + #[test] + fn failed_conflict_recovery_after_replace_compensates_attempted_provider_state() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write(&path, br#"{"providers":{"managed":{"api":"before"}}}"#).expect("seed"); + let before = + snapshot_pi_provider_values(&path, ["managed".to_string()]).expect("preflight"); + let external = br#"{"providers":{"managed":{"api":"external"}}}"#; + crate::pi_config::shared_file::replace_before_next_compare_exchange(&path, external); + crate::pi_config::shared_file::fail_next_rollback_restore_for_test(&path); + + let error = apply_pi_provider_patch_with_receipt( + &path, + &before, + &IndexMap::from([( + "managed".to_string(), + Some(serde_json::json!({"api": "attempted"})), + )]), + ) + .expect_err("an uncertain conflict must remain an error"); + assert!(matches!(error, AppError::Conflict(_))); + let restored = read_pi_models_document(&path).expect("restored document"); + assert_eq!(restored.providers()["managed"].value["api"], "before"); + assert!( + fs::read_dir(temp.path()) + .expect("recovery directory") + .filter_map(Result::ok) + .any(|entry| fs::read(entry.path()).ok().as_deref() == Some(external)), + "the displaced external version must remain recoverable" + ); + } + + #[test] + fn failed_conflict_recovery_after_delete_compensates_attempted_absence() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write(&path, br#"{"providers":{"managed":{"api":"before"}}}"#).expect("seed"); + let before = + snapshot_pi_provider_values(&path, ["managed".to_string()]).expect("preflight"); + let external = br#"{"providers":{"managed":{"api":"external"}}}"#; + crate::pi_config::shared_file::replace_before_next_compare_exchange(&path, external); + crate::pi_config::shared_file::fail_next_rollback_restore_for_test(&path); + + let error = apply_pi_provider_patch_with_receipt( + &path, + &before, + &IndexMap::from([("managed".to_string(), None)]), + ) + .expect_err("an uncertain delete conflict must remain an error"); + assert!(matches!(error, AppError::Conflict(_))); + let restored = read_pi_models_document(&path).expect("restored document"); + assert_eq!(restored.providers()["managed"].value["api"], "before"); + assert!( + fs::read_dir(temp.path()) + .expect("recovery directory") + .filter_map(Result::ok) + .any(|entry| fs::read(entry.path()).ok().as_deref() == Some(external)), + "the quarantined external version must remain recoverable" + ); + } + #[test] fn external_rename_during_patch_is_reparsed_before_owned_fields_change() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src-tauri/src/pi_config/gateway.rs b/src-tauri/src/pi_config/gateway.rs index 43ee35f63..2c733df0d 100644 --- a/src-tauri/src/pi_config/gateway.rs +++ b/src-tauri/src/pi_config/gateway.rs @@ -10,6 +10,7 @@ use super::composer::{ PiComposedHeader, PiComposedNativeModel, PiComposerStatus, PiNativeComposition, }; use http::{HeaderMap, HeaderName, HeaderValue}; +use std::fmt; use url::Url; /// Headers owned by HTTP framing, the proxy hop, or gateway-generated request @@ -153,11 +154,17 @@ pub(crate) struct PiGatewayAssessment { pub plans: Vec, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] struct DeferredHeaderValue { raw: String, } +impl fmt::Debug for DeferredHeaderValue { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DeferredHeaderValue()") + } +} + impl DeferredHeaderValue { fn new(raw: impl Into) -> Self { Self { raw: raw.into() } @@ -183,7 +190,7 @@ impl DeferredHeaderValue { } } -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct CandidateHeaderPlan { family: PiGatewayApiFamily, endpoint: Url, @@ -194,6 +201,24 @@ pub(crate) struct CandidateHeaderPlan { protocol_identity_predictable: bool, } +impl fmt::Debug for CandidateHeaderPlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CandidateHeaderPlan") + .field("family", &self.family) + .field("endpoint", &self.endpoint) + .field("credential", &"") + .field("auth_header", &self.auth_header) + .field("provider_headers", &self.provider_headers) + .field("model_headers", &self.model_headers) + .field( + "protocol_identity_predictable", + &self.protocol_identity_predictable, + ) + .finish() + } +} + #[derive(Debug, Clone)] struct PlannedHeader { name: HeaderName, @@ -202,7 +227,7 @@ struct PlannedHeader { class: ConfiguredHeaderClass, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct MaterializedCandidate { pub endpoint: Url, pub headers: HeaderMap, @@ -211,6 +236,24 @@ pub(crate) struct MaterializedCandidate { protocol_identity_predictable: bool, } +impl fmt::Debug for MaterializedCandidate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let header_names = self.headers.keys().collect::>(); + let protocol_header_names = self.protocol_headers.keys().collect::>(); + formatter + .debug_struct("MaterializedCandidate") + .field("endpoint", &self.endpoint) + .field("header_names", &header_names) + .field("family", &self.family) + .field("protocol_header_names", &protocol_header_names) + .field( + "protocol_identity_predictable", + &self.protocol_identity_predictable, + ) + .finish() + } +} + pub(crate) trait DeferredValueResolver { fn resolve(&self, expression: &str) -> Option; } @@ -773,6 +816,33 @@ mod tests { ); } + #[test] + fn sensitive_gateway_debug_output_redacts_credentials_and_header_values() { + let credential = "sk-debug-credential-never-log"; + let header_secret = "debug-header-value-never-log"; + let composition = composed(json!({ + "api": "openai-responses", + "baseUrl": "https://example.test/v1", + "apiKey": credential, + "headers": {"x-private": header_secret}, + "models": [{"id": "m"}] + })); + let assessment = assess_composition(&composition); + assert_eq!(assessment.capability, PiGatewayCapability::Proxyable); + let assessment_debug = format!("{assessment:?}"); + assert!(!assessment_debug.contains(credential)); + assert!(!assessment_debug.contains(header_secret)); + + let materialized = assessment.plans[0] + .materialize(&|_expression: &str| None) + .expect("literal plan materializes"); + let materialized_debug = format!("{materialized:?}"); + assert!(!materialized_debug.contains(credential)); + assert!(!materialized_debug.contains(header_secret)); + assert!(materialized_debug.contains("authorization")); + assert!(materialized_debug.contains("x-private")); + } + #[test] fn unknown_api_is_composed_but_direct_only() { let composition = composed(json!({ diff --git a/src-tauri/src/pi_config/native.rs b/src-tauri/src/pi_config/native.rs index 7ab49dce1..7d0df8d34 100644 --- a/src-tauri/src/pi_config/native.rs +++ b/src-tauri/src/pi_config/native.rs @@ -8,7 +8,7 @@ use super::composer::{ compose_explicit_custom_catalog, PiComposerReasonCode, PiComposerStatus, PiNativeComposition, }; -use super::document::{read_pi_models_document, PiRawProviderEntry}; +use super::document::{pi_raw_provider_fingerprint, read_pi_models_document, PiRawProviderEntry}; use super::gateway::{ assess_composition, PiGatewayAssessment, PiGatewayCapability, PiGatewayReasonCode, }; @@ -23,7 +23,6 @@ use super::raw_schema::{ use crate::config::get_home_dir; use crate::error::AppError; use serde_json::Value; -use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; use url::Url; @@ -292,7 +291,7 @@ fn analyze_native_entry( .get("name") .and_then(Value::as_str) .map(ToOwned::to_owned), - fingerprint: fingerprint(&entry.raw_source), + fingerprint: pi_raw_provider_fingerprint(&entry.raw_source), kind, raw_validity, managed_assessment: managed.assessment, @@ -711,10 +710,6 @@ fn escape_json_pointer(value: &str) -> String { value.replace('~', "~0").replace('/', "~1") } -fn fingerprint(raw_source: &str) -> String { - format!("sha256:{:x}", Sha256::digest(raw_source.as_bytes())) -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/pi_config/shared_file.rs b/src-tauri/src/pi_config/shared_file.rs index 7b3500046..7164de46b 100644 --- a/src-tauri/src/pi_config/shared_file.rs +++ b/src-tauri/src/pi_config/shared_file.rs @@ -26,6 +26,9 @@ static BEFORE_COMPARE_EXCHANGE: LazyLock> = #[cfg(test)] static BEFORE_ROLLBACK_EXCHANGE: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); +#[cfg(test)] +static FAIL_NEXT_ROLLBACK_RESTORE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); #[cfg(all(test, unix))] static FAIL_NEXT_PARENT_SYNC: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -251,13 +254,15 @@ fn replace_existing_if_equal( } run_before_rollback_exchange_hook(path)?; - let proposed_or_raced = restore_displaced(&displaced, path).map_err(|error| { - AppError::Conflict(format!( - "{label} changed during atomic replacement and could not be restored; \ + let proposed_or_raced = run_before_rollback_restore_hook(path) + .and_then(|()| restore_displaced(&displaced, path)) + .map_err(|error| { + AppError::Conflict(format!( + "{label} changed during atomic replacement and could not be restored; \ the displaced bytes remain at {}: {error}", - displaced.display() - )) - })?; + displaced.display() + )) + })?; let proposed_bytes = read_regular_bytes(&proposed_or_raced, max_bytes, label)?; if proposed_bytes.as_deref() == Some(replacement) { fs::remove_file(&proposed_or_raced) @@ -311,7 +316,8 @@ fn delete_existing_if_equal( return Ok(snapshot(None)); } - match rename_noreplace(&quarantine, path) { + match run_before_rollback_restore_hook(path).and_then(|()| rename_noreplace(&quarantine, path)) + { Ok(()) => { sync_parent( path.parent() @@ -700,6 +706,39 @@ fn replace_before_next_rollback_exchange(path: &Path, bytes: &[u8]) { .insert(path.to_path_buf(), bytes.to_vec()); } +#[cfg(test)] +pub(crate) fn fail_next_rollback_restore_for_test(path: &Path) { + let mut failures = FAIL_NEXT_ROLLBACK_RESTORE + .lock() + .expect("rollback restore test hook lock"); + failures + .entry(path.to_path_buf()) + .and_modify(|remaining| *remaining = remaining.saturating_add(1)) + .or_insert(1); +} + +#[cfg(test)] +fn run_before_rollback_restore_hook(path: &Path) -> std::io::Result<()> { + let mut failures = FAIL_NEXT_ROLLBACK_RESTORE + .lock() + .map_err(|error| std::io::Error::other(error.to_string()))?; + let Some(remaining) = failures.get_mut(path) else { + return Ok(()); + }; + *remaining = remaining.saturating_sub(1); + if *remaining == 0 { + failures.remove(path); + } + Err(std::io::Error::other( + "injected Pi rollback-restore failure", + )) +} + +#[cfg(not(test))] +fn run_before_rollback_restore_hook(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + #[cfg(test)] fn run_before_rollback_exchange_hook(path: &Path) -> Result<(), AppError> { run_file_replacement_hook(&BEFORE_ROLLBACK_EXCHANGE, path) diff --git a/src-tauri/src/prompt.rs b/src-tauri/src/prompt.rs index ce9b8b474..2bf080fea 100644 --- a/src-tauri/src/prompt.rs +++ b/src-tauri/src/prompt.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Prompt { pub id: String, pub name: String, diff --git a/src-tauri/src/proxy/pi_runtime.rs b/src-tauri/src/proxy/pi_runtime.rs index abb84cbc9..385d70399 100644 --- a/src-tauri/src/proxy/pi_runtime.rs +++ b/src-tauri/src/proxy/pi_runtime.rs @@ -21,6 +21,7 @@ use indexmap::IndexMap; use serde_json::{Map, Value}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap}; +use std::fmt; use std::io::Read; use std::process::{Command, Stdio}; use std::sync::{mpsc, Arc, RwLock}; @@ -52,8 +53,25 @@ struct PiRouteBinding { provider_id: String, } +#[derive(Clone)] +struct PiNativeProjectionWitness(IndexMap>); + +impl fmt::Debug for PiNativeProjectionWitness { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let keys = self + .0 + .iter() + .map(|(key, value)| (key, if value.is_some() { "present" } else { "absent" })) + .collect::>(); + formatter + .debug_tuple("PiNativeProjectionWitness") + .field(&keys) + .finish() + } +} + /// One immutable catalog matching a successfully published native projection. -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct PiRuntimeSnapshot { pub(crate) server_generation: u64, pub(crate) catalog_epoch: u64, @@ -63,13 +81,28 @@ pub(crate) struct PiRuntimeSnapshot { /// A fenced runtime may only be re-published while these values still /// match `models.json`; retaining the projection alongside the routes /// avoids reconstructing ownership from mutable database/settings state. - native_projection: IndexMap>, + native_projection: PiNativeProjectionWitness, app_config: AppProxyConfig, providers: HashMap, failover_ids: Vec, routes: HashMap, } +impl fmt::Debug for PiRuntimeSnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PiRuntimeSnapshot") + .field("server_generation", &self.server_generation) + .field("catalog_epoch", &self.catalog_epoch) + .field("gateway_token", &self.gateway_token) + .field("native_projection", &self.native_projection) + .field("provider_count", &self.providers.len()) + .field("failover_ids", &self.failover_ids) + .field("route_count", &self.routes.len()) + .finish_non_exhaustive() + } +} + #[derive(Debug, Clone)] pub(crate) struct PiRequestCandidate { pub(crate) provider_id: String, @@ -95,7 +128,6 @@ pub(crate) struct PiMaterializedAttempt { pub(crate) url: Url, } -#[derive(Debug)] pub(crate) struct PiRuntimeBuild { pub(crate) snapshot: Arc, /// Exact keys only. A direct-only provider deliberately keeps its original @@ -372,7 +404,7 @@ pub(crate) fn build_pi_runtime( server_generation, catalog_epoch, gateway_token, - native_projection: projection_patch.clone(), + native_projection: PiNativeProjectionWitness(projection_patch.clone()), app_config, providers, failover_ids, @@ -731,7 +763,7 @@ impl PiRuntimeStore { .unwrap_or_else(std::sync::PoisonError::into_inner) .current .as_ref() - .map(|snapshot| snapshot.native_projection.clone()) + .map(|snapshot| snapshot.native_projection.0.clone()) } pub(crate) async fn admission_guard( @@ -1154,6 +1186,24 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn native_projection_debug_exposes_only_key_presence() { + let secret = "runtime-native-secret-never-log"; + let witness = PiNativeProjectionWitness(IndexMap::from([ + ( + "managed".to_string(), + Some(json!({"apiKey": secret, "headers": {"x-private": secret}})), + ), + ("removed".to_string(), None), + ])); + let debug = format!("{witness:?}"); + assert!(!debug.contains(secret)); + assert!(debug.contains("managed")); + assert!(debug.contains("present")); + assert!(debug.contains("removed")); + assert!(debug.contains("absent")); + } + #[test] fn environment_resolution_matches_vendored_transport_oracle() { std::env::set_var("PI_RUNTIME_TEST_VALUE", "environment-secret"); diff --git a/src-tauri/src/services/pi_catalog.rs b/src-tauri/src/services/pi_catalog.rs index 0d99f04ca..8f9fb3a4a 100644 --- a/src-tauri/src/services/pi_catalog.rs +++ b/src-tauri/src/services/pi_catalog.rs @@ -95,6 +95,8 @@ pub(crate) struct PiCatalogMutationResult { native_defaults_receipt: Option, #[serde(skip)] native_patch_receipt: Option, + #[serde(skip)] + native_fingerprint_preconditions: IndexMap, } pub(crate) struct PiCatalogCoordinator; @@ -241,6 +243,11 @@ impl PiCatalogCoordinator { .ok() .and_then(|result| result.native_patch_receipt.as_ref()) .cloned(); + let native_fingerprint_preconditions = result + .as_ref() + .ok() + .map(|result| result.native_fingerprint_preconditions.clone()) + .unwrap_or_default(); let mut expected_native = snapshot.native.clone(); if let Some(receipt) = native_patch_receipt.as_ref() { for (provider_key, attempted) in receipt.attempted_values() { @@ -252,9 +259,11 @@ impl PiCatalogCoordinator { let reconcile = futures::executor::block_on( state .proxy_service - .reconcile_pi_runtime_at_epoch_with_native_precondition( + .reconcile_pi_runtime_at_epoch_with_native_claim_precondition( catalog_epoch, Some(&expected_native), + (!native_fingerprint_preconditions.is_empty()) + .then_some(&native_fingerprint_preconditions), ), ); match (result, reconcile) { @@ -892,7 +901,12 @@ impl PiCatalogCoordinator { NewProviderAggregate::from_input(PI_APP, input)?, provider_key, )?; - Ok(success(Some(provider_key.to_string()))) + Ok( + success(Some(provider_key.to_string())).with_native_fingerprint_precondition( + provider_key.to_string(), + inspection.diagnostic.fingerprint, + ), + ) } fn set_default( @@ -1227,6 +1241,7 @@ fn success(provider_id: Option) -> PiCatalogMutationResult { provider_id, native_defaults_receipt: None, native_patch_receipt: None, + native_fingerprint_preconditions: IndexMap::new(), } } @@ -1240,6 +1255,16 @@ impl PiCatalogMutationResult { self.native_patch_receipt = receipt; self } + + fn with_native_fingerprint_precondition( + mut self, + provider_key: String, + fingerprint: String, + ) -> Self { + self.native_fingerprint_preconditions + .insert(provider_key, fingerprint); + self + } } fn authority_error(authority: PiCatalogAuthority, message: impl std::fmt::Display) -> AppError { @@ -1425,6 +1450,67 @@ mod tests { Ok(()) } + #[test] + #[serial_test::serial] + fn native_import_revalidates_raw_fingerprint_after_semantically_equivalent_edit( + ) -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("tempdir"); + let _home = TestHome::install(temp.path())?; + let pi_dir = temp.path().join("pi-agent"); + std::fs::create_dir_all(&pi_dir).expect("Pi directory"); + configure_pi_directory(&pi_dir)?; + let state = AppState::new(Arc::new(Database::memory()?)); + let models_path = pi_dir.join("models.json"); + let original = br#"{ + "providers": { + "native": { + "name": "Native import", + "api": "openai-responses", + "baseUrl": "https://original.example/v1", + "apiKey": "literal-key", + "models": [{"id": "model-a", "name": "Model A"}] + } + } +}"#; + std::fs::write(&models_path, original).expect("write original"); + let fingerprint = inspect_pi_native_entry(&models_path, "native", &BTreeMap::new())? + .expect("native entry") + .diagnostic + .fingerprint; + let external = br#"{ + "providers": { + "native": { + // raw ownership changed while parsed values stayed identical + "name": "Native import", + "api": "openai-responses", + "baseUrl": "https://original.example/v1", + "apiKey": "literal-key", + "models": [ + {"id": "model-a", "name": "Model A"}, + ], + }, + }, +}"#; + crate::pi_config::document::replace_before_next_pi_provider_verify(&models_path, external); + + PiCatalogCoordinator::apply( + &state, + PiCatalogMutation::ImportNative { + provider_key: "native".to_string(), + expected_fingerprint: fingerprint, + }, + ) + .expect_err("a raw-only external edit must reject the ownership claim"); + assert!(state.db.get_provider_aggregate(PI_APP, "native")?.is_none()); + assert!(state.db.get_pi_projection("native")?.is_none()); + assert_eq!( + std::fs::read(&models_path).expect("external native file"), + external, + "raw external ownership must remain intact" + ); + Ok(()) + } + #[test] fn portable_sql_import_claims_only_an_absent_identity_key_and_is_idempotent( ) -> Result<(), AppError> { diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 7f0fc39ec..350e60efb 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -96,6 +96,11 @@ impl PromptService { pub fn delete_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { if matches!(app, AppType::Pi) { + let _switch_guard = futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ); let guard = lock_instruction_files()?; let prompts = state.db.get_prompts(AppType::Pi.as_str())?; let snapshot = @@ -108,7 +113,13 @@ impl PromptService { "无法删除 Pi 当前生效的提示词".to_string(), )); } - return state.db.delete_prompt(AppType::Pi.as_str(), id); + let mut after = prompts.clone(); + after.shift_remove(id); + return state.db.compare_exchange_prompt_selection( + AppType::Pi.as_str(), + &prompts, + &after, + ); } let prompts = state.db.get_prompts(app.as_str())?; @@ -200,6 +211,11 @@ impl PromptService { pub fn import_from_file(state: &AppState, app: AppType) -> Result { if matches!(app, AppType::Pi) { + let _switch_guard = futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ); let guard = lock_instruction_files()?; let snapshot = PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; @@ -258,24 +274,29 @@ impl PromptService { state: &AppState, app: AppType, ) -> Result { - // 幂等性保护:该应用已有提示词则跳过 - let existing = state.db.get_prompts(app.as_str())?; - if !existing.is_empty() { - return Ok(0); - } - if matches!(app, AppType::Pi) { + let _switch_guard = futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ); let guard = lock_instruction_files()?; + let existing = state.db.get_prompts(app.as_str())?; + if !existing.is_empty() { + return Ok(0); + } let snapshot = PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; if !snapshot.exists { return Ok(0); } let timestamp = get_unix_timestamp()?; - state.db.save_prompt( - app.as_str(), - &Prompt { - id: format!("auto-imported-{timestamp}"), + let mut after = existing.clone(); + let id = format!("auto-imported-{timestamp}"); + after.insert( + id.clone(), + Prompt { + id, name: format!( "Auto-imported Prompt {}", chrono::Local::now().format("%Y-%m-%d %H:%M") @@ -286,10 +307,19 @@ impl PromptService { created_at: Some(timestamp), updated_at: Some(timestamp), }, - )?; + ); + state + .db + .compare_exchange_prompt_selection(app.as_str(), &existing, &after)?; return Ok(1); } + // 幂等性保护:该应用已有提示词则跳过 + let existing = state.db.get_prompts(app.as_str())?; + if !existing.is_empty() { + return Ok(0); + } + let file_path = prompt_file_path(&app)?; // 检查文件是否存在 @@ -344,6 +374,11 @@ impl PromptService { /// counterpart is added. A missing file disables every row. The file is /// never created, replaced, or deleted by portable reconciliation. pub(crate) fn reconcile_pi_portable_import(state: &AppState) -> Result<(), AppError> { + let _switch_guard = futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ); let guard = lock_instruction_files()?; Self::reconcile_pi_native_under_guard(state.db.as_ref(), &guard) } @@ -365,15 +400,15 @@ impl PromptService { continue; } - db.save_prompt_selection(AppType::Pi.as_str(), &prompts)?; + db.compare_exchange_prompt_selection(AppType::Pi.as_str(), &original, &prompts)?; #[cfg(test)] - replace_pi_agents_after_reconcile_save_for_test(&snapshot.path)?; + replace_pi_agents_after_reconcile_save_for_test(db, &snapshot.path)?; let verified = match PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext) { Ok(verified) => verified, Err(error) => { - restore_pi_library_after_failed_reconcile(db, &original, &error)?; + restore_pi_library_after_failed_reconcile(db, &original, &prompts, &error)?; return Err(error); } }; @@ -383,6 +418,7 @@ impl PromptService { restore_pi_library_after_failed_reconcile( db, &original, + &prompts, &AppError::Conflict( "Pi AGENTS.md changed after prompt-library publication".to_string(), ), @@ -427,6 +463,11 @@ impl PromptService { } fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> { + let _switch_guard = futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ); let guard = lock_instruction_files()?; let before = state.db.get_prompts(AppType::Pi.as_str())?; let snapshot = @@ -454,9 +495,10 @@ impl PromptService { &snapshot.revision, &prompt.content, )?; - if let Err(error) = state - .db - .save_prompt_selection(AppType::Pi.as_str(), &prompts) + if let Err(error) = + state + .db + .compare_exchange_prompt_selection(AppType::Pi.as_str(), &before, &prompts) { restore_pi_prompt_file(&guard, &published, &snapshot)?; return Err(error); @@ -471,9 +513,10 @@ impl PromptService { PiPromptFileKind::GlobalContext, &snapshot.revision, )?; - if let Err(error) = state - .db - .save_prompt_selection(AppType::Pi.as_str(), &prompts) + if let Err(error) = + state + .db + .compare_exchange_prompt_selection(AppType::Pi.as_str(), &before, &prompts) { if removed { let missing = PiPromptFileService::read_under_guard( @@ -494,10 +537,15 @@ impl PromptService { state .db - .save_prompt_selection(AppType::Pi.as_str(), &prompts) + .compare_exchange_prompt_selection(AppType::Pi.as_str(), &before, &prompts) } fn enable_pi_prompt(state: &AppState, id: &str) -> Result<(), AppError> { + let _switch_guard = futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ); let guard = lock_instruction_files()?; let before = state.db.get_prompts(AppType::Pi.as_str())?; let target = before @@ -521,7 +569,11 @@ impl PromptService { for prompt in after.values_mut() { prompt.enabled = prompt.id == id; } - if let Err(error) = state.db.save_prompt_selection(AppType::Pi.as_str(), &after) { + if let Err(error) = + state + .db + .compare_exchange_prompt_selection(AppType::Pi.as_str(), &before, &after) + { restore_pi_prompt_file(&guard, &published, &snapshot)?; return Err(error); } @@ -534,7 +586,8 @@ impl PromptService { ) -> Result { let timestamp = get_unix_timestamp()?; let id = format!("imported-{timestamp}"); - let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?; + let before = state.db.get_prompts(AppType::Pi.as_str())?; + let mut prompts = before.clone(); for prompt in prompts.values_mut() { prompt.enabled = false; } @@ -559,7 +612,7 @@ impl PromptService { ); state .db - .save_prompt_selection(AppType::Pi.as_str(), &prompts)?; + .compare_exchange_prompt_selection(AppType::Pi.as_str(), &before, &prompts)?; Ok(id) } } @@ -670,13 +723,15 @@ fn build_pi_reconciled_library( fn restore_pi_library_after_failed_reconcile( db: &Database, original: &IndexMap, + attempted: &IndexMap, cause: &AppError, ) -> Result<(), AppError> { - db.save_prompt_selection(AppType::Pi.as_str(), original) + db.restore_prompt_selection_if_attempted(AppType::Pi.as_str(), attempted, 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}" + restore the previous portable library without overwriting a newer database \ + revision: {restore_error}" )) }) } @@ -687,7 +742,17 @@ static PI_RECONCILE_AFTER_SAVE_REPLACEMENTS: std::sync::LazyLock< > = 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> { +fn replace_pi_agents_after_reconcile_save_for_test( + db: &Database, + path: &str, +) -> Result<(), AppError> { + if let Some(replacement) = PI_RECONCILE_AFTER_SAVE_DB_REPLACEMENTS + .lock() + .map_err(|error| AppError::Lock(error.to_string()))? + .pop_front() + { + db.save_prompt_selection(AppType::Pi.as_str(), &replacement)?; + } let replacement = PI_RECONCILE_AFTER_SAVE_REPLACEMENTS .lock() .map_err(|error| AppError::Lock(error.to_string()))? @@ -698,6 +763,19 @@ fn replace_pi_agents_after_reconcile_save_for_test(path: &str) -> Result<(), App Ok(()) } +#[cfg(test)] +static PI_RECONCILE_AFTER_SAVE_DB_REPLACEMENTS: std::sync::LazyLock< + std::sync::Mutex>>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::VecDeque::new())); + +#[cfg(test)] +fn replace_pi_library_after_next_reconcile_save_for_test(replacement: IndexMap) { + PI_RECONCILE_AFTER_SAVE_DB_REPLACEMENTS + .lock() + .expect("Pi reconcile DB hook lock") + .push_back(replacement); +} + #[cfg(test)] fn replace_pi_agents_after_each_reconcile_save_for_test( replacements: impl IntoIterator, @@ -1192,4 +1270,70 @@ mod tests { "native-3" ); } + + #[test] + #[serial] + fn reconcile_compensation_preserves_a_concurrent_portable_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("before", "portable-before", true, 1), + ) + .expect("seed original library"); + + let imported = IndexMap::from([( + "restored-import".to_string(), + prompt("restored-import", "portable-restored", true, 2), + )]); + replace_pi_library_after_next_reconcile_save_for_test(imported.clone()); + replace_pi_agents_after_each_reconcile_save_for_test(["native-1"]); + + let error = PromptService::reconcile_pi_library(&state) + .expect_err("stale compensation must not overwrite a concurrent import"); + assert!( + error.to_string().contains("newer database revision"), + "the conflict must explain why compensation stopped: {error}" + ); + assert_eq!( + state + .db + .get_prompts(AppType::Pi.as_str()) + .expect("preserved imported library"), + imported + ); + assert_eq!( + std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("latest native"), + "native-1" + ); + } + + #[test] + fn prompt_selection_compensation_can_restore_an_exact_legacy_before_image() { + let db = Database::memory().expect("database"); + db.save_prompt(AppType::Pi.as_str(), &prompt("legacy-a", "a", true, 1)) + .expect("first legacy row"); + db.save_prompt(AppType::Pi.as_str(), &prompt("legacy-b", "b", true, 2)) + .expect("second legacy row"); + let before = db + .get_prompts(AppType::Pi.as_str()) + .expect("legacy before-image"); + let mut attempted = before.clone(); + attempted.get_mut("legacy-a").expect("first row").enabled = false; + + db.compare_exchange_prompt_selection(AppType::Pi.as_str(), &before, &attempted) + .expect("publish valid projection"); + db.restore_prompt_selection_if_attempted(AppType::Pi.as_str(), &attempted, &before) + .expect("restore exact legacy image"); + + assert_eq!( + db.get_prompts(AppType::Pi.as_str()) + .expect("restored library"), + before + ); + } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 3fee77315..1560b5b93 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -620,6 +620,20 @@ impl ProxyService { &self, catalog_epoch: u64, expected_native: Option<&crate::pi_config::document::PiProviderValuesSnapshot>, + ) -> Result, AppError> { + self.reconcile_pi_runtime_at_epoch_with_native_claim_precondition( + catalog_epoch, + expected_native, + None, + ) + .await + } + + pub(crate) async fn reconcile_pi_runtime_at_epoch_with_native_claim_precondition( + &self, + catalog_epoch: u64, + expected_native: Option<&crate::pi_config::document::PiProviderValuesSnapshot>, + expected_fingerprints: Option<&indexmap::IndexMap>, ) -> Result, AppError> { #[cfg(test)] if self.fail_next_pi_reconcile.swap(false, Ordering::AcqRel) { @@ -631,10 +645,19 @@ impl ProxyService { self.pi_runtime.close(catalog_epoch).await?; if let Some(expected_native) = expected_native { let models_path = crate::pi_config::native::get_pi_models_path()?; - crate::pi_config::document::verify_pi_provider_values( - &models_path, - &expected_native.values, - )?; + match expected_fingerprints { + Some(expected_fingerprints) => { + crate::pi_config::document::verify_pi_provider_preconditions( + &models_path, + &expected_native.values, + expected_fingerprints, + )?; + } + None => crate::pi_config::document::verify_pi_provider_values( + &models_path, + &expected_native.values, + )?, + } } return Ok(Vec::new()); } @@ -660,6 +683,21 @@ impl ProxyService { )?; let models_path = crate::pi_config::native::get_pi_models_path()?; let native_receipt = if build.projection_patch.is_empty() { + if let Some(expected_native) = expected_native { + match expected_fingerprints { + Some(expected_fingerprints) => { + crate::pi_config::document::verify_pi_provider_preconditions( + &models_path, + &expected_native.values, + expected_fingerprints, + )?; + } + None => crate::pi_config::document::verify_pi_provider_values( + &models_path, + &expected_native.values, + )?, + } + } None } else { let expected = match expected_native { @@ -690,9 +728,10 @@ impl ProxyService { )?, }; Some( - crate::pi_config::document::apply_pi_provider_patch_with_receipt( + crate::pi_config::document::apply_pi_provider_patch_with_receipt_and_fingerprints( &models_path, &expected, + expected_fingerprints, &build.projection_patch, )?, )