diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 7734d6a84..b5b5c85db 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -25,7 +25,7 @@ pub async fn export_config_to_file( let db = state.db.clone(); tauri::async_runtime::spawn_blocking(move || { let target_path = PathBuf::from(&filePath); - db.export_sql(&target_path)?; + db.export_portable_sql(&target_path)?; Ok::<_, AppError>(json!({ "success": true, "message": "SQL exported successfully", @@ -56,11 +56,12 @@ pub async fn import_config_from_file( .map_err(|error| format!("导入前恢复 Pi 直连投影失败: {error}"))?; let import_path = filePath.clone(); - let import_result = - tauri::async_runtime::spawn_blocking(move || db.import_sql(&PathBuf::from(import_path))) - .await - .map_err(|error| AppError::Message(format!("SQL import task failed: {error}"))) - .and_then(|result| result); + let import_result = tauri::async_runtime::spawn_blocking(move || { + db.import_portable_sql(&PathBuf::from(import_path)) + }) + .await + .map_err(|error| AppError::Message(format!("SQL import task failed: {error}"))) + .and_then(|result| result); let backup_id = match import_result { Ok(backup_id) => backup_id, Err(error) => { diff --git a/src-tauri/src/commands/sync_support.rs b/src-tauri/src/commands/sync_support.rs index 4c685e194..c94167ef5 100644 --- a/src-tauri/src/commands/sync_support.rs +++ b/src-tauri/src/commands/sync_support.rs @@ -6,10 +6,39 @@ use crate::store::AppState; use serde_json::{json, Value}; pub(crate) fn run_post_import_sync(app_state: &AppState) -> Result<(), AppError> { - PromptService::reconcile_pi_portable_import(app_state)?; - ProviderService::sync_current_to_live(app_state)?; - settings::reload_settings()?; - Ok(()) + // Provider synchronization reopens/reconciles Pi's runtime admission after + // the pre-import boundary closed it. Run that recovery first, then execute + // every remaining independent projection even if one of them fails. + run_post_import_steps( + || ProviderService::sync_current_to_live(app_state), + || PromptService::reconcile_pi_portable_import(app_state), + settings::reload_settings, + ) +} + +fn run_post_import_steps( + live_sync: impl FnOnce() -> Result<(), AppError>, + prompt_sync: impl FnOnce() -> Result<(), AppError>, + settings_reload: impl FnOnce() -> Result<(), AppError>, +) -> Result<(), AppError> { + let mut failures = Vec::new(); + for (stage, result) in [ + ("live", live_sync()), + ("pi_prompt", prompt_sync()), + ("settings", settings_reload()), + ] { + if let Err(error) = result { + failures.push(format!("{stage}={error}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(AppError::Config(format!( + "post-import reconciliation incomplete: {}", + failures.join("; ") + ))) + } } fn post_sync_warning(err: E) -> String { @@ -53,8 +82,9 @@ pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option, + skill_deployments: Vec, +} + +impl Database { + /// Export a user-portable SQL backup without device-local ownership rows. + pub(crate) fn export_portable_sql_string(&self) -> Result { + strip_device_local_insert_statements(&self.export_sql_string()?) + } + + /// Export a cloud-sync SQL snapshot without device-local ownership rows. + pub(crate) fn export_portable_sql_string_for_sync(&self) -> Result { + strip_device_local_insert_statements(&self.export_sql_string_for_sync()?) + } + + pub(crate) fn export_portable_sql(&self, target_path: &Path) -> Result<(), AppError> { + let dump = self.export_portable_sql_string()?; + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?; + } + crate::config::atomic_write(target_path, dump.as_bytes()) + } + + /// Import a user-portable SQL backup while retaining this device's evidence. + pub(crate) fn import_portable_sql(&self, source_path: &Path) -> Result { + let local = self.capture_pi_device_local_state()?; + let backup_id = self.import_sql(source_path)?; + self.replace_pi_device_local_state(&local)?; + Ok(backup_id) + } + + /// Import a cloud-sync snapshot while retaining this device's evidence. + pub(crate) fn import_portable_sql_string_for_sync( + &self, + sql: &str, + ) -> Result { + let local = self.capture_pi_device_local_state()?; + let backup_id = self.import_sql_string_for_sync(sql)?; + self.replace_pi_device_local_state(&local)?; + Ok(backup_id) + } + + fn capture_pi_device_local_state(&self) -> Result { + let conn = lock_conn!(self.conn); + + let projections = { + let mut statement = conn + .prepare( + "SELECT provider_id, provider_key, created_at, updated_at + FROM pi_provider_projections + ORDER BY provider_id", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + let rows = statement + .query_map([], |row| { + Ok(PiProviderProjection { + provider_id: row.get(0)?, + provider_key: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }) + .map_err(|error| AppError::Database(error.to_string()))?; + rows.collect::, _>>() + .map_err(|error| AppError::Database(error.to_string()))? + }; + + let skill_deployments = { + let mut statement = conn + .prepare( + "SELECT skill_id, destination, destination_key, method, + source_identity, deployed_digest, created_at, updated_at + FROM skill_deployments + WHERE app_type = 'pi' + ORDER BY skill_id, destination_key", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + let rows = statement + .query_map([], |row| { + let method = row.get::<_, String>(3)?; + let method = method.parse().map_err(|error: AppError| { + rusqlite::Error::FromSqlConversionFailure( + 3, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + Ok(SkillDeployment { + skill_id: row.get(0)?, + destination: row.get(1)?, + destination_key: row.get(2)?, + method, + source_identity: row.get(4)?, + deployed_digest: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + }) + .map_err(|error| AppError::Database(error.to_string()))?; + rows.collect::, _>>() + .map_err(|error| AppError::Database(error.to_string()))? + }; + + Ok(PiDeviceLocalState { + projections, + skill_deployments, + }) + } + + fn replace_pi_device_local_state(&self, local: &PiDeviceLocalState) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let transaction = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + transaction + .execute("DELETE FROM pi_provider_projections", []) + .map_err(|error| AppError::Database(error.to_string()))?; + transaction + .execute("DELETE FROM skill_deployments WHERE app_type = 'pi'", []) + .map_err(|error| AppError::Database(error.to_string()))?; + + for projection in &local.projections { + transaction + .execute( + "INSERT INTO pi_provider_projections + (provider_id, provider_key, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + projection.provider_id, + projection.provider_key, + projection.created_at, + projection.updated_at + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + } + for deployment in &local.skill_deployments { + transaction + .execute( + "INSERT INTO skill_deployments ( + app_type, skill_id, destination, destination_key, method, + source_identity, deployed_digest, created_at, updated_at + ) VALUES ('pi', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + deployment.skill_id, + deployment.destination, + deployment.destination_key, + deployment.method.as_str(), + deployment.source_identity, + deployment.deployed_digest, + deployment.created_at, + deployment.updated_at + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + } + transaction + .commit() + .map_err(|error| AppError::Database(error.to_string())) + } +} + +/// Remove complete INSERT statements for the two device-local tables from SQL +/// generated by `Database::dump_sql`. Values may contain quotes, semicolons, or +/// newlines, so line filtering is insufficient; statement boundaries are found +/// only outside SQLite single-quoted literals. Unknown output fails closed. +fn strip_device_local_insert_statements(sql: &str) -> Result { + let bytes = sql.as_bytes(); + let mut output = String::with_capacity(sql.len()); + let mut statement_start = 0; + let mut cursor = 0; + let mut in_string = false; + + while cursor < bytes.len() { + match bytes[cursor] { + b'\'' if in_string && bytes.get(cursor + 1) == Some(&b'\'') => { + cursor += 2; + continue; + } + b'\'' => in_string = !in_string, + b';' if !in_string => { + let statement_end = cursor + 1; + let statement = &sql[statement_start..statement_end]; + if !DEVICE_LOCAL_INSERT_PREFIXES + .iter() + .any(|prefix| statement.trim_start().starts_with(prefix)) + { + output.push_str(statement); + } + statement_start = statement_end; + } + _ => {} + } + cursor += 1; + } + + if in_string { + return Err(AppError::Config( + "portable SQL export ended inside a quoted value".to_string(), + )); + } + output.push_str(&sql[statement_start..]); + if DEVICE_LOCAL_INSERT_PREFIXES + .iter() + .any(|prefix| output.contains(prefix)) + { + return Err(AppError::Config( + "portable SQL export retained device-local Pi ownership rows".to_string(), + )); + } + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn deployment(skill_id: &str, destination_key: &str) -> SkillDeployment { + SkillDeployment { + skill_id: skill_id.to_string(), + destination: format!("/device/{destination_key}"), + destination_key: destination_key.to_string(), + method: SkillDeploymentMethod::Copy, + source_identity: format!("path:/source/{skill_id};digest:sha256:one"), + deployed_digest: Some("sha256:one".to_string()), + created_at: 10, + updated_at: 20, + } + } + + fn seed_portable_provider(db: &Database) -> Result<(), AppError> { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO providers + (id, app_type, name, settings_config, meta, is_current) + VALUES ('portable-sentinel', 'codex', 'Portable sentinel', '{}', '{}', 0)", + [], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + } + + #[test] + fn portable_sql_scrubber_handles_multiline_quoted_values() -> Result<(), AppError> { + let sql = concat!( + "-- CC Switch SQLite 导出\n", + "CREATE TABLE \"pi_provider_projections\" (value TEXT);\n", + "INSERT INTO \"pi_provider_projections\" (value) VALUES ('one;\n", + "two ''quoted''');\n", + "CREATE TABLE \"providers\" (value TEXT);\n", + "INSERT INTO \"providers\" (value) VALUES ('portable;\nvalue');\n", + "COMMIT;\n", + ); + + let scrubbed = strip_device_local_insert_statements(sql)?; + assert!(!scrubbed.contains("INSERT INTO \"pi_provider_projections\"")); + assert!(scrubbed.contains("CREATE TABLE \"pi_provider_projections\"")); + assert!(scrubbed.contains("INSERT INTO \"providers\"")); + assert!(scrubbed.contains("'portable;\nvalue'")); + Ok(()) + } + + #[test] + fn portable_exports_keep_schema_but_omit_device_evidence() -> Result<(), AppError> { + let db = Database::memory()?; + db.claim_pi_projection_key("local-provider", "local-key")?; + db.save_pi_skill_deployment(&deployment("local-skill", "local-destination"))?; + + for exported in [ + db.export_portable_sql_string()?, + db.export_portable_sql_string_for_sync()?, + ] { + assert!(exported.contains("CREATE TABLE pi_provider_projections")); + assert!(exported.contains("CREATE TABLE skill_deployments")); + assert!(!exported.contains("INSERT INTO \"pi_provider_projections\"")); + assert!(!exported.contains("INSERT INTO \"skill_deployments\"")); + } + Ok(()) + } + + #[test] + fn sync_import_discards_remote_evidence_and_restores_local_evidence() -> Result<(), AppError> { + let remote = Database::memory()?; + seed_portable_provider(&remote)?; + remote.claim_pi_projection_key("remote-provider", "shared-key")?; + remote.save_pi_skill_deployment(&deployment("remote-skill", "remote-destination"))?; + // Model an older remote snapshot created before portable row scrubbing. + let remote_sql = remote.export_sql_string()?; + + let local = Database::memory()?; + local.claim_pi_projection_key("local-provider", "shared-key")?; + local.save_pi_skill_deployment(&deployment("local-skill", "local-destination"))?; + local.import_portable_sql_string_for_sync(&remote_sql)?; + + assert_eq!( + local + .get_pi_projection_for_key("shared-key")? + .map(|projection| projection.provider_id), + Some("local-provider".to_string()) + ); + assert!(local.get_pi_projection("remote-provider")?.is_none()); + assert_eq!( + local.get_pi_skill_deployments("local-skill")?, + vec![deployment("local-skill", "local-destination")] + ); + assert!(local.get_pi_skill_deployments("remote-skill")?.is_empty()); + Ok(()) + } + + #[test] + fn manual_sql_import_preserves_local_evidence() -> Result<(), AppError> { + let remote = Database::memory()?; + seed_portable_provider(&remote)?; + remote.claim_pi_projection_key("remote-provider", "remote-key")?; + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("portable.sql"); + fs::write(&path, remote.export_sql_string()?).expect("write SQL backup"); + + let local = Database::memory()?; + local.claim_pi_projection_key("local-provider", "local-key")?; + local.import_portable_sql(&path)?; + + assert!(local.get_pi_projection("remote-provider")?.is_none()); + assert_eq!( + local + .get_pi_projection("local-provider")? + .map(|projection| projection.provider_key), + Some("local-key".to_string()) + ); + Ok(()) + } +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 565cce52d..f1b8ba99f 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -56,6 +56,16 @@ mod tests { Some("SOUL.md") ); } + + #[test] + fn pi_prompt_file_uses_agents_md() { + let path = prompt_file_path(&AppType::Pi).expect("Pi prompt path"); + + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("AGENTS.md") + ); + } } fn get_base_dir_with_fallback( diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 67dc866b5..ee7bb9dda 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -1289,20 +1289,20 @@ fn sync_current_provider_for_app_respecting_takeover( /// /// For additive mode apps (OpenCode), all providers are synced instead of just the current one. pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { - // Sync providers based on mode - for app_type in AppType::all() { - if matches!(app_type, AppType::Pi) { - crate::services::pi_catalog::PiCatalogCoordinator::reconcile_portable_import(state)?; - continue; - } + // Pi's portable-import boundary closes runtime admission before replacing + // SQLite. Recover it first so an unrelated application's broken live file + // cannot strand Pi in that closed state. + crate::services::pi_catalog::PiCatalogCoordinator::reconcile_portable_import(state)?; + + // Preserve the existing fail-fast behavior for all other provider views. + for app_type in AppType::all().filter(|app_type| !matches!(app_type, AppType::Pi)) { if app_type.is_additive_mode() { // Provider rename and every additive live mutation share this - // per-app lock. Acquire it before reading the catalog so a key + // per-app lock. Acquire it before reading the catalog so a key // cannot be renamed after this sync captured a stale provider map. let _guard = futures::executor::block_on( state.proxy_service.lock_switch_for_app(app_type.as_str()), ); - // Additive mode: sync ALL providers sync_all_providers_to_live(state, &app_type)?; } else { // Switch mode: sync only current provider. During proxy takeover, @@ -1312,20 +1312,28 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { } } - // MCP sync(best-effort 逐应用投影,内部已聚合失败)。错误暂存到 - // Skill 同步之后再返回:MCP 的失败不该跳过 Skill 同步,但调用方 - //(配置导入 / 云同步恢复)需要知道结果不完整。 - let mcp_result = McpService::sync_all_enabled(state); + let mut failures = Vec::new(); + if let Err(error) = McpService::sync_all_enabled(state) { + failures.push(format!("mcp={error}")); + } - // Skill sync + // Continue through all apps so one collision cannot hide unrelated Skills. for app_type in AppType::all() { - if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) { - log::warn!("同步 Skill 到 {app_type:?} 失败: {e}"); - // Continue syncing other apps, don't abort + if let Err(error) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) + { + log::warn!("同步 Skill 到 {app_type:?} 失败: {error}"); + failures.push(format!("skill:{}={error}", app_type.as_str())); } } - mcp_result + if failures.is_empty() { + Ok(()) + } else { + Err(AppError::Config(format!( + "live synchronization incomplete: {}", + failures.join("; ") + ))) + } } /// Read current live settings for an app type diff --git a/src-tauri/src/services/skill_deployment.rs b/src-tauri/src/services/skill_deployment.rs index d669e7c04..83a56b0a2 100644 --- a/src-tauri/src/services/skill_deployment.rs +++ b/src-tauri/src/services/skill_deployment.rs @@ -153,6 +153,7 @@ impl PiSkillDeploymentService { _guard: &MutexGuard<'static, ()>, db: &Arc, ) -> Result<(), AppError> { + let mut failures = Vec::new(); for skill in db.get_all_installed_skills()?.values() { // Portable sync and old databases may contain a poisoned directory // name. Reject it before any path join, but do not let that inert @@ -166,9 +167,22 @@ impl PiSkillDeploymentService { ); continue; } - reconcile_skill_unlocked(db, skill)?; + if let Err(error) = reconcile_skill_unlocked(db, skill) { + log::warn!( + "Pi Skill '{}' could not be reconciled; continuing with independent Skills: {error}", + skill.id + ); + failures.push(format!("{}={error}", skill.id)); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(AppError::Config(format!( + "Pi Skill reconciliation incomplete: {}", + failures.join("; ") + ))) } - Ok(()) } pub(crate) fn remove_before_uninstall_under_guard( @@ -1350,4 +1364,72 @@ mod tests { assert!(status.effectively_discovered); assert!(status.issue.is_some_and(|issue| issue.contains("previous"))); } + + #[test] + #[serial_test::serial] + fn reconcile_all_reports_failures_after_deploying_independent_skills() { + struct EnvGuard { + key: &'static str, + previous: Option, + } + impl EnvGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + if self.key == "CC_SWITCH_TEST_HOME" { + let _ = crate::settings::reload_settings(); + } + } + } + + let temp = tempfile::tempdir().expect("tempdir"); + let _home = EnvGuard::set("CC_SWITCH_TEST_HOME", temp.path()); + crate::settings::reload_settings().expect("reload settings"); + let pi_root = temp.path().join("pi"); + let _pi_root = EnvGuard::set("PI_CODING_AGENT_DIR", &pi_root); + let ssot = SkillService::get_ssot_dir().expect("SSOT"); + let good_source = ssot.join("good"); + fs::create_dir_all(&good_source).expect("good source"); + fs::write( + good_source.join("SKILL.md"), + "---\nname: good\ndescription: independent skill\n---\n", + ) + .expect("manifest"); + + let db = Arc::new(Database::memory().expect("database")); + for (id, directory) in [("local:bad", "missing"), ("local:good", "good")] { + db.save_skill(&InstalledSkill { + id: id.to_string(), + name: directory.to_string(), + description: Some("reconciliation test".to_string()), + directory: directory.to_string(), + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps: SkillApps::only(&AppType::Pi), + installed_at: 1, + content_hash: None, + updated_at: 1, + }) + .expect("save skill"); + } + + let error = PiSkillDeploymentService::reconcile_all(&db) + .expect_err("missing source must remain visible"); + assert!(error.to_string().contains("local:bad")); + assert!( + fs::symlink_metadata(pi_root.join("skills").join("good")).is_ok(), + "one invalid Skill must not hide an independent valid deployment" + ); + } } diff --git a/src-tauri/src/services/sync_protocol.rs b/src-tauri/src/services/sync_protocol.rs index c4096e21e..4de9bb148 100644 --- a/src-tauri/src/services/sync_protocol.rs +++ b/src-tauri/src/services/sync_protocol.rs @@ -106,7 +106,7 @@ pub(crate) fn build_local_snapshot( db: &crate::database::Database, ) -> Result { // Export database to SQL string - let sql_string = db.export_sql_string_for_sync()?; + let sql_string = db.export_portable_sql_string_for_sync()?; let db_sql = sql_string.into_bytes(); // Pack skills into deterministic ZIP @@ -323,7 +323,7 @@ pub(crate) fn apply_snapshot( // Replace skills first, then import database; roll back skills on DB failure. restore_skills_zip(skills_zip)?; - if let Err(db_err) = db.import_sql_string_for_sync(sql_str) { + if let Err(db_err) = db.import_portable_sql_string_for_sync(sql_str) { if let Err(rollback_err) = restore_skills_from_backup(&skills_backup) { return Err(localized( "sync.db_import_and_rollback_failed",