mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
fix(pi): keep device ownership local during portable sync
Route manual SQL, WebDAV, and S3 through one Pi portable-state boundary. Portable exports omit native projection and Skill deployment evidence, while imports retain the receiving device's evidence before runtime reconciliation. Recover Pi before independent post-import projections and report aggregated Skill reconciliation failures without hiding valid Skills.
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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<E: std::fmt::Display>(err: E) -> String {
|
||||
@@ -53,8 +82,9 @@ pub(crate) fn success_payload_with_warning(backup_id: String, warning: Option<St
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{attach_warning, post_sync_warning_from_result};
|
||||
use super::{attach_warning, post_sync_warning_from_result, run_post_import_steps};
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[test]
|
||||
fn post_sync_warning_from_result_returns_none_on_success() {
|
||||
@@ -92,4 +122,29 @@ mod tests {
|
||||
Some("post sync warning")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_import_steps_recover_live_first_and_do_not_short_circuit() {
|
||||
let calls = RefCell::new(Vec::new());
|
||||
let error = run_post_import_steps(
|
||||
|| {
|
||||
calls.borrow_mut().push("live");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("prompt");
|
||||
Err(crate::error::AppError::Config("invalid AGENTS.md".into()))
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("settings");
|
||||
Err(crate::error::AppError::Config("reload failed".into()))
|
||||
},
|
||||
)
|
||||
.expect_err("independent failures must be reported");
|
||||
|
||||
assert_eq!(*calls.borrow(), ["live", "prompt", "settings"]);
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("pi_prompt="));
|
||||
assert!(message.contains("settings="));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod failover;
|
||||
pub mod mcp;
|
||||
pub(crate) mod pi_catalog;
|
||||
pub(crate) mod pi_portable_state;
|
||||
pub mod pi_projections;
|
||||
pub mod profiles;
|
||||
pub mod prompts;
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Portable SQL boundary for Pi's device-local ownership evidence.
|
||||
//!
|
||||
//! Provider and Skill configuration is portable. Exact `models.json` claims and
|
||||
//! native Skill deployment receipts are not: they describe files on one device
|
||||
//! and must never become ownership proof on another. This adapter keeps that
|
||||
//! policy outside the frozen generic backup/restore implementation while giving
|
||||
//! manual SQL, WebDAV, and S3 one shared boundary.
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::database::SkillDeploymentMethod;
|
||||
use crate::database::{lock_conn, Database, PiProviderProjection, SkillDeployment};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::params;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const DEVICE_LOCAL_INSERT_PREFIXES: &[&str] = &[
|
||||
"INSERT INTO \"pi_provider_projections\"",
|
||||
"INSERT INTO \"skill_deployments\"",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PiDeviceLocalState {
|
||||
projections: Vec<PiProviderProjection>,
|
||||
skill_deployments: Vec<SkillDeployment>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Export a user-portable SQL backup without device-local ownership rows.
|
||||
pub(crate) fn export_portable_sql_string(&self) -> Result<String, AppError> {
|
||||
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<String, AppError> {
|
||||
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<String, AppError> {
|
||||
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<String, AppError> {
|
||||
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<PiDeviceLocalState, AppError> {
|
||||
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::<Result<Vec<_>, _>>()
|
||||
.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::<Result<Vec<_>, _>>()
|
||||
.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<String, AppError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -153,6 +153,7 @@ impl PiSkillDeploymentService {
|
||||
_guard: &MutexGuard<'static, ()>,
|
||||
db: &Arc<Database>,
|
||||
) -> 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<std::ffi::OsString>,
|
||||
}
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ pub(crate) fn build_local_snapshot(
|
||||
db: &crate::database::Database,
|
||||
) -> Result<LocalSnapshot, AppError> {
|
||||
// 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",
|
||||
|
||||
Reference in New Issue
Block a user