From 7609ff799ef9b8021aa9a9a5e634cdde117bc9af Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sun, 2 Aug 2026 22:52:26 +0000 Subject: [PATCH] feat(pi): add native catalog and gateway data plane --- src-tauri/src/app_config.rs | 65 +- src-tauri/src/commands/config.rs | 14 + src-tauri/src/commands/failover.rs | 173 +++ src-tauri/src/commands/import_export.rs | 104 +- src-tauri/src/commands/misc.rs | 41 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/pi.rs | 76 + src-tauri/src/commands/prompt.rs | 50 + src-tauri/src/commands/proxy.rs | 58 + src-tauri/src/commands/s3_sync.rs | 36 +- src-tauri/src/commands/settings.rs | 61 +- src-tauri/src/commands/skill.rs | 9 + src-tauri/src/commands/sync_support.rs | 12 +- src-tauri/src/commands/webdav_sync.rs | 36 +- src-tauri/src/config.rs | 128 +- src-tauri/src/database/dao/mod.rs | 1 + src-tauri/src/database/dao/pi_catalog.rs | 200 +++ src-tauri/src/database/dao/prompts.rs | 47 + src-tauri/src/database/dao/provider_write.rs | 89 +- src-tauri/src/database/dao/providers.rs | 28 + .../src/database/dao/skill_deployments.rs | 108 +- src-tauri/src/database/dao/skills.rs | 29 +- src-tauri/src/database/mod.rs | 2 + src-tauri/src/deeplink/mod.rs | 4 + src-tauri/src/deeplink/parser.rs | 31 +- src-tauri/src/deeplink/provider.rs | 40 + src-tauri/src/deeplink/tests.rs | 65 + src-tauri/src/lib.rs | 60 +- src-tauri/src/pi_config/document.rs | 309 +++- src-tauri/src/pi_config/gateway.rs | 229 ++- src-tauri/src/pi_config/mod.rs | 10 +- src-tauri/src/pi_config/native.rs | 43 +- src-tauri/src/pi_config/native_settings.rs | 300 ++++ src-tauri/src/pi_config/shared_file.rs | 232 +++ src-tauri/src/prompt_files.rs | 7 +- src-tauri/src/provider.rs | 4 + src-tauri/src/proxy/handler_config.rs | 7 + src-tauri/src/proxy/handlers.rs | 12 +- src-tauri/src/proxy/mod.rs | 2 + src-tauri/src/proxy/pi_handler.rs | 1323 +++++++++++++++++ src-tauri/src/proxy/pi_runtime.rs | 952 ++++++++++++ src-tauri/src/proxy/provider_router.rs | 16 +- src-tauri/src/proxy/providers/mod.rs | 12 +- src-tauri/src/proxy/response_processor.rs | 21 +- src-tauri/src/proxy/server.rs | 22 + src-tauri/src/proxy/types.rs | 1 + src-tauri/src/proxy/usage/calculator.rs | 52 +- src-tauri/src/proxy/usage/logger.rs | 31 +- src-tauri/src/proxy/usage/mod.rs | 3 + src-tauri/src/proxy/usage/semantics.rs | 32 + src-tauri/src/services/config.rs | 4 + src-tauri/src/services/mcp.rs | 48 +- src-tauri/src/services/mod.rs | 3 + src-tauri/src/services/pi_catalog.rs | 1171 +++++++++++++++ src-tauri/src/services/pi_prompt_files.rs | 373 +++++ src-tauri/src/services/prompt.rs | 448 ++++++ src-tauri/src/services/provider/live.rs | 26 + src-tauri/src/services/provider/mod.rs | 349 ++++- src-tauri/src/services/proxy.rs | 925 +++++++++++- src-tauri/src/services/skill.rs | 462 +++++- src-tauri/src/services/skill_deployment.rs | 969 ++++++++++++ src-tauri/src/services/sql_helpers.rs | 37 +- src-tauri/src/services/stream_check.rs | 56 + src-tauri/src/services/usage_stats.rs | 25 +- src-tauri/src/session_manager/mod.rs | 10 +- .../src/session_manager/providers/mod.rs | 1 + src-tauri/src/session_manager/providers/pi.rs | 731 +++++++++ src-tauri/src/session_manager/terminal/mod.rs | 2 +- src-tauri/src/settings.rs | 275 +++- src-tauri/src/store.rs | 1 + 70 files changed, 10738 insertions(+), 367 deletions(-) create mode 100644 src-tauri/src/commands/pi.rs create mode 100644 src-tauri/src/database/dao/pi_catalog.rs create mode 100644 src-tauri/src/pi_config/native_settings.rs create mode 100644 src-tauri/src/pi_config/shared_file.rs create mode 100644 src-tauri/src/proxy/pi_handler.rs create mode 100644 src-tauri/src/proxy/pi_runtime.rs create mode 100644 src-tauri/src/proxy/usage/semantics.rs create mode 100644 src-tauri/src/services/pi_catalog.rs create mode 100644 src-tauri/src/services/pi_prompt_files.rs create mode 100644 src-tauri/src/services/skill_deployment.rs create mode 100644 src-tauri/src/session_manager/providers/pi.rs diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 74eac3f84..ae3e6fb2f 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -32,6 +32,7 @@ impl McpApps { AppType::OpenCode => self.opencode, AppType::OpenClaw => false, // OpenClaw doesn't support MCP AppType::Hermes => self.hermes, + AppType::Pi => false, // Pi core has no native MCP registry. AppType::ClaudeDesktop => false, } } @@ -46,6 +47,7 @@ impl McpApps { AppType::OpenCode => self.opencode = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore AppType::Hermes => self.hermes = enabled, + AppType::Pi => {} // Pi core has no native MCP registry. AppType::ClaudeDesktop => {} // Claude Desktop 3P provider config doesn't support MCP here } } @@ -100,6 +102,8 @@ pub struct SkillApps { pub opencode: bool, #[serde(default)] pub hermes: bool, + #[serde(default)] + pub pi: bool, } impl SkillApps { @@ -112,6 +116,7 @@ impl SkillApps { AppType::GrokBuild => self.grokbuild, AppType::OpenCode => self.opencode, AppType::Hermes => self.hermes, + AppType::Pi => self.pi, AppType::OpenClaw => false, // OpenClaw doesn't support Skills AppType::ClaudeDesktop => false, } @@ -126,6 +131,7 @@ impl SkillApps { AppType::GrokBuild => self.grokbuild = enabled, AppType::OpenCode => self.opencode = enabled, AppType::Hermes => self.hermes = enabled, + AppType::Pi => self.pi = enabled, AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore AppType::ClaudeDesktop => {} // Claude Desktop 3P profiles don't use CC Switch skill sync } @@ -152,6 +158,9 @@ impl SkillApps { if self.hermes { apps.push(AppType::Hermes); } + if self.pi { + apps.push(AppType::Pi); + } apps } @@ -163,6 +172,7 @@ impl SkillApps { && !self.grokbuild && !self.opencode && !self.hermes + && !self.pi } /// 仅启用指定应用(其他应用设为禁用) @@ -357,6 +367,8 @@ pub struct PromptRoot { pub openclaw: PromptConfig, #[serde(default)] pub hermes: PromptConfig, + #[serde(default)] + pub pi: PromptConfig, } use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; @@ -381,6 +393,7 @@ pub enum AppType { OpenCode, OpenClaw, Hermes, + Pi, } impl AppType { @@ -394,6 +407,7 @@ impl AppType { AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", AppType::Hermes => "hermes", + AppType::Pi => "pi", } } @@ -419,6 +433,7 @@ impl AppType { AppType::OpenCode, AppType::OpenClaw, AppType::Hermes, + AppType::Pi, ] .into_iter() } @@ -438,10 +453,11 @@ impl FromStr for AppType { "opencode" => Ok(AppType::OpenCode), "openclaw" => Ok(AppType::OpenClaw), "hermes" => Ok(AppType::Hermes), + "pi" => Ok(AppType::Pi), other => Err(AppError::localized( "unsupported_app", - format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes。"), - format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes."), + format!("不支持的应用标识: '{other}'。可选值: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi。"), + format!("Unsupported app id: '{other}'. Allowed: claude, claude-desktop, codex, gemini, grokbuild, opencode, openclaw, hermes, pi."), )), } } @@ -467,6 +483,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub hermes: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi: Option, } impl CommonConfigSnippets { @@ -481,6 +500,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), AppType::Hermes => self.hermes.as_ref(), + AppType::Pi => self.pi.as_ref(), } } @@ -495,6 +515,7 @@ impl CommonConfigSnippets { AppType::OpenCode => self.opencode = snippet, AppType::OpenClaw => self.openclaw = snippet, AppType::Hermes => self.hermes = snippet, + AppType::Pi => self.pi = snippet, } } } @@ -539,6 +560,7 @@ impl Default for MultiAppConfig { apps.insert("opencode".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); apps.insert("hermes".to_string(), ProviderManager::default()); + apps.insert("pi".to_string(), ProviderManager::default()); Self { version: 2, @@ -626,6 +648,12 @@ impl MultiAppConfig { .insert("gemini".to_string(), ProviderManager::default()); updated = true; } + if !config.apps.contains_key("pi") { + config + .apps + .insert("pi".to_string(), ProviderManager::default()); + updated = true; + } // 执行 MCP 迁移(v3.6.x → v3.7.0) let migrated = config.migrate_mcp_to_unified()?; @@ -691,34 +719,6 @@ impl MultiAppConfig { } } - /// 获取指定客户端的 MCP 配置(不可变引用) - pub fn mcp_for(&self, app: &AppType) -> &McpConfig { - match app { - AppType::Claude => &self.mcp.claude, - AppType::ClaudeDesktop => &self.mcp.claude_desktop, - AppType::Codex => &self.mcp.codex, - AppType::Gemini => &self.mcp.gemini, - AppType::GrokBuild => &self.mcp.grokbuild, - AppType::OpenCode => &self.mcp.opencode, - AppType::OpenClaw => &self.mcp.openclaw, - AppType::Hermes => &self.mcp.hermes, - } - } - - /// 获取指定客户端的 MCP 配置(可变引用) - pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig { - match app { - AppType::Claude => &mut self.mcp.claude, - AppType::ClaudeDesktop => &mut self.mcp.claude_desktop, - AppType::Codex => &mut self.mcp.codex, - AppType::Gemini => &mut self.mcp.gemini, - AppType::GrokBuild => &mut self.mcp.grokbuild, - AppType::OpenCode => &mut self.mcp.opencode, - AppType::OpenClaw => &mut self.mcp.openclaw, - AppType::Hermes => &mut self.mcp.hermes, - } - } - /// 创建默认配置并自动导入已存在的提示词文件 fn default_with_auto_import() -> Result { log::info!("首次启动,创建默认配置并检测提示词文件"); @@ -733,6 +733,7 @@ impl MultiAppConfig { Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?; Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Pi)?; Ok(config) } @@ -757,6 +758,7 @@ impl MultiAppConfig { || !self.prompts.opencode.prompts.is_empty() || !self.prompts.openclaw.prompts.is_empty() || !self.prompts.hermes.prompts.is_empty() + || !self.prompts.pi.prompts.is_empty() { return Ok(false); } @@ -772,6 +774,7 @@ impl MultiAppConfig { AppType::OpenCode, AppType::OpenClaw, AppType::Hermes, + AppType::Pi, ] { // 复用已有的单应用导入逻辑 if Self::auto_import_prompt_if_exists(self, app)? { @@ -846,6 +849,7 @@ impl MultiAppConfig { AppType::OpenCode => &mut config.prompts.opencode.prompts, AppType::OpenClaw => &mut config.prompts.openclaw.prompts, AppType::Hermes => &mut config.prompts.hermes.prompts, + AppType::Pi => &mut config.prompts.pi.prompts, }; prompts.insert(id, prompt); @@ -889,6 +893,7 @@ impl MultiAppConfig { AppType::OpenCode => &self.mcp.opencode.servers, AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip AppType::Hermes => continue, // Hermes didn't exist in v3.6.x, skip + AppType::Pi => continue, // Pi didn't exist in v3.6.x, skip }; for (id, entry) in old_servers { diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index d09377162..6cbf0e128 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -135,6 +135,18 @@ pub async fn get_config_status( Ok(ConfigStatus { exists, path }) } + AppType::Pi => { + let config_path = + crate::pi_config::native::get_pi_models_path().map_err(|e| e.to_string())?; + let path = crate::pi_config::native::get_pi_agent_dir() + .map_err(|e| e.to_string())? + .to_string_lossy() + .to_string(); + Ok(ConfigStatus { + exists: config_path.exists(), + path, + }) + } } } @@ -156,6 +168,7 @@ pub async fn get_config_dir(app: String) -> Result { AppType::OpenCode => crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), AppType::Hermes => crate::hermes_config::get_hermes_dir(), + AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?, }; Ok(dir.to_string_lossy().to_string()) @@ -174,6 +187,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), AppType::Hermes => crate::hermes_config::get_hermes_dir(), + AppType::Pi => crate::pi_config::native::get_pi_agent_dir().map_err(|e| e.to_string())?, }; if !config_dir.exists() { diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index a0c40ce66..dab762cfe 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -2,6 +2,7 @@ //! //! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段) +use crate::app_config::AppType; use crate::database::FailoverQueueItem; use crate::provider::Provider; use crate::store::AppState; @@ -39,6 +40,50 @@ pub async fn add_to_failover_queue( app_type: String, provider_id: String, ) -> Result<(), String> { + if app_type == "pi" { + let _guard = state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()) + .await; + if state + .db + .get_provider_aggregate("pi", &provider_id) + .map_err(|error| error.to_string())? + .is_none() + { + return Err(format!("Pi provider does not exist: {provider_id}")); + } + let was_member = state + .db + .is_in_failover_queue("pi", &provider_id) + .map_err(|error| error.to_string())?; + let epoch = state.proxy_service.begin_pi_catalog_mutation().await; + if let Err(error) = state.db.add_to_failover_queue("pi", &provider_id) { + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await; + return Err(error.to_string()); + } + if let Err(error) = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await + { + if !was_member { + let _ = state.db.remove_from_failover_queue("pi", &provider_id); + } + let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await; + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(rollback_epoch) + .await; + return Err(format!( + "Pi failover queue changed but runtime publication failed: {error}" + )); + } + return Ok(()); + } state .db .add_to_failover_queue(&app_type, &provider_id) @@ -52,6 +97,42 @@ pub async fn remove_from_failover_queue( app_type: String, provider_id: String, ) -> Result<(), String> { + if app_type == "pi" { + let _guard = state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()) + .await; + let was_member = state + .db + .is_in_failover_queue("pi", &provider_id) + .map_err(|error| error.to_string())?; + let epoch = state.proxy_service.begin_pi_catalog_mutation().await; + if let Err(error) = state.db.remove_from_failover_queue("pi", &provider_id) { + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await; + return Err(error.to_string()); + } + if let Err(error) = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await + { + if was_member { + let _ = state.db.add_to_failover_queue("pi", &provider_id); + } + let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await; + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(rollback_epoch) + .await; + return Err(format!( + "Pi failover queue changed but runtime publication failed: {error}" + )); + } + return Ok(()); + } state .db .remove_from_failover_queue(&app_type, &provider_id) @@ -64,6 +145,9 @@ pub async fn get_auto_failover_enabled( state: tauri::State<'_, AppState>, app_type: String, ) -> Result { + if app_type == "pi" { + return Ok(crate::settings::get_pi_proxy_settings().auto_failover_enabled); + } state .db .get_proxy_config_for_app(&app_type) @@ -86,6 +170,10 @@ pub async fn set_auto_failover_enabled( "[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}" ); + if app_type == "pi" { + return set_pi_auto_failover_enabled(&app, state.inner(), enabled).await; + } + // 读取当前配置 let mut config = state .db @@ -180,3 +268,88 @@ pub async fn set_auto_failover_enabled( Ok(()) } + +async fn set_pi_auto_failover_enabled( + app: &tauri::AppHandle, + state: &AppState, + enabled: bool, +) -> Result<(), String> { + let _guard = state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()) + .await; + let previous_config = crate::settings::get_pi_proxy_settings(); + if enabled && !crate::settings::pi_takeover_enabled() { + return Err("Pi gateway takeover must be enabled before failover".to_string()); + } + + let mut auto_added = None; + if enabled + && state + .db + .get_failover_queue("pi") + .map_err(|error| error.to_string())? + .is_empty() + { + let current = + crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + "Pi failover queue is empty and no current provider is selected".to_string() + })?; + state + .db + .add_to_failover_queue("pi", ¤t) + .map_err(|error| error.to_string())?; + auto_added = Some(current); + } + + let mut next = previous_config.clone(); + next.auto_failover_enabled = enabled; + let epoch = state.proxy_service.begin_pi_catalog_mutation().await; + if let Err(error) = crate::settings::update_pi_proxy_settings(next) { + if let Some(provider_id) = auto_added { + let _ = state.db.remove_from_failover_queue("pi", &provider_id); + } + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await; + return Err(error.to_string()); + } + if let Err(error) = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await + { + let _ = crate::settings::update_pi_proxy_settings(previous_config); + if let Some(provider_id) = auto_added { + let _ = state.db.remove_from_failover_queue("pi", &provider_id); + } + let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await; + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(rollback_epoch) + .await; + return Err(format!( + "Pi failover preference changed but runtime publication failed: {error}" + )); + } + + let _ = app.emit( + "provider-switched", + serde_json::json!({ + "appType": "pi", + "providerId": + crate::services::pi_catalog::PiCatalogCoordinator::current_native_provider(state) + .map_err(|error| error.to_string())?, + "source": "failoverPreferenceChanged" + }), + ); + if let Ok(new_menu) = crate::tray::create_tray_menu(app, state) { + if let Some(tray) = app.tray_by_id(crate::tray::TRAY_ID) { + let _ = tray.set_menu(Some(new_menu)); + } + } + Ok(()) +} diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 048935b90..7734d6a84 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -44,26 +44,56 @@ pub async fn import_config_from_file( state: State<'_, AppState>, ) -> Result { let db = state.db.clone(); - let db_for_sync = db.clone(); - tauri::async_runtime::spawn_blocking(move || { - let path_buf = PathBuf::from(&filePath); - let backup_id = db.import_sql(&path_buf)?; - let warning = post_sync_warning_from_result(Ok(run_post_import_sync(db_for_sync))); - if let Some(msg) = warning.as_ref() { - log::warn!("[Import] post-import sync warning: {msg}"); + let app_state = state.inner().clone(); + let pi_guard = app_state + .proxy_service + .lock_switch_for_app(crate::app_config::AppType::Pi.as_str()) + .await; + app_state + .proxy_service + .prepare_pi_portable_import_under_lock(&pi_guard) + .await + .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 backup_id = match import_result { + Ok(backup_id) => backup_id, + Err(error) => { + let recovery = app_state + .proxy_service + .recover_pi_after_aborted_portable_import_under_lock(&pi_guard) + .await; + return Err(match recovery { + Ok(()) => error.to_string(), + Err(recovery) => { + format!("{error}; Pi gateway recovery after aborted import failed: {recovery}") + } + }); } - Ok::<_, AppError>(success_payload_with_warning(backup_id, warning)) - }) - .await - .map_err(|e| format!("导入配置失败: {e}"))? - .map_err(|e: AppError| e.to_string()) + }; + drop(pi_guard); + + let sync_state = app_state.clone(); + let warning = post_sync_warning_from_result( + tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state)) + .await + .map_err(|error| error.to_string()), + ); + if let Some(msg) = warning.as_ref() { + log::warn!("[Import] post-import sync warning: {msg}"); + } + Ok(success_payload_with_warning(backup_id, warning)) } #[tauri::command] pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result { - let db = state.db.clone(); + let app_state = state.inner().clone(); tauri::async_runtime::spawn_blocking(move || { - let app_state = AppState::new(db); ProviderService::sync_current_to_live(&app_state)?; Ok::<_, AppError>(json!({ "success": true, @@ -154,10 +184,50 @@ pub async fn restore_db_backup( filename: String, ) -> Result { let db = state.db.clone(); - tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename)) + let app_state = state.inner().clone(); + let pi_guard = app_state + .proxy_service + .lock_switch_for_app(crate::app_config::AppType::Pi.as_str()) + .await; + app_state + .proxy_service + .prepare_pi_portable_import_under_lock(&pi_guard) .await - .map_err(|e| format!("Restore failed: {e}"))? - .map_err(|e: AppError| e.to_string()) + .map_err(|error| format!("Restore preparation failed: {error}"))?; + + let restore_result = + tauri::async_runtime::spawn_blocking(move || db.restore_from_backup(&filename)) + .await + .map_err(|error| AppError::Message(format!("Restore task failed: {error}"))) + .and_then(|result| result); + let restored = match restore_result { + Ok(restored) => restored, + Err(error) => { + let recovery = app_state + .proxy_service + .recover_pi_after_aborted_portable_import_under_lock(&pi_guard) + .await; + return Err(match recovery { + Ok(()) => error.to_string(), + Err(recovery) => { + format!("{error}; Pi gateway recovery after aborted restore failed: {recovery}") + } + }); + } + }; + drop(pi_guard); + + let sync_state = app_state.clone(); + match tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + log::warn!("[Restore] database restored but post-restore sync failed: {error}"); + } + Err(error) => { + log::warn!("[Restore] database restored but post-restore sync task failed: {error}"); + } + } + Ok(restored) } /// Rename a database backup file diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 14dacba3b..02fdc4fab 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -111,8 +111,8 @@ pub struct ToolVersion { wsl_distro: Option, } -const VALID_TOOLS: [&str; 7] = [ - "claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes", +const VALID_TOOLS: [&str; 8] = [ + "claude", "codex", "gemini", "grok", "opencode", "openclaw", "hermes", "pi", ]; #[derive(Debug, Clone, serde::Deserialize)] @@ -428,6 +428,7 @@ fn tool_display_name(tool: &str) -> &'static str { "opencode" => "OpenCode", "openclaw" => "OpenClaw", "hermes" => "Hermes", + "pi" => "Pi", _ => "Unknown", } } @@ -508,6 +509,7 @@ fn npm_install_command_for(tool: &str) -> Option<&'static str> { "grok" => Some("npm i -g @xai-official/grok@latest"), "opencode" => Some("npm i -g opencode-ai@latest"), "openclaw" => Some("npm i -g openclaw@latest"), + "pi" => Some("npm i -g @earendil-works/pi-coding-agent@latest"), _ => None, } } @@ -802,6 +804,9 @@ async fn get_single_tool_version_impl( } "openclaw" => fetch_npm_latest_for_tool(&client, "openclaw", tool, local).await, "hermes" => fetch_pypi_latest_version(&client, "hermes-agent").await, + "pi" => { + fetch_npm_latest_for_tool(&client, "@earendil-works/pi-coding-agent", tool, local).await + } _ => None, }; @@ -1999,6 +2004,7 @@ fn npm_package_for(tool: &str) -> Option<&'static str> { "grok" => Some("@xai-official/grok"), "opencode" => Some("opencode-ai"), "openclaw" => Some("openclaw"), + "pi" => Some("@earendil-works/pi-coding-agent"), _ => None, } } @@ -2650,6 +2656,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option { "opencode" => crate::settings::get_opencode_override_dir(), "openclaw" => crate::settings::get_openclaw_override_dir(), "hermes" => crate::settings::get_hermes_override_dir(), + "pi" => crate::settings::get_pi_override_dir(), _ => None, }?; @@ -3787,6 +3794,24 @@ mod tests { ); } + #[test] + fn pi_lifecycle_metadata_matches_pinned_distribution() { + let requested = vec!["unsupported".to_string(), "pi".to_string()]; + assert_eq!(normalize_requested_tools(&requested), vec!["pi"]); + assert_eq!(tool_display_name("pi"), "Pi"); + assert_eq!( + npm_package_for("pi"), + Some("@earendil-works/pi-coding-agent") + ); + assert_eq!( + npm_install_command_for("pi"), + Some("npm i -g @earendil-works/pi-coding-agent@latest") + ); + // The verified distribution exposes `pi --version`, but no updater + // contract is assumed; upgrades stay on the package-manager path. + assert_eq!(official_update_args("pi"), None); + } + #[test] fn test_compare_semver() { use std::cmp::Ordering; @@ -5129,6 +5154,13 @@ mod tests { assert_eq!(cmd, "npm i -g openclaw@latest"); } + #[test] + fn pi_install_uses_the_verified_pinned_package() { + let cmd = install_command_for("pi"); + assert_eq!(cmd, "npm i -g @earendil-works/pi-coding-agent@latest"); + assert!(!cmd.contains("||")); + } + #[test] fn update_fallbacks_use_official_cli_only_when_supported() { assert_eq!( @@ -5158,6 +5190,11 @@ mod tests { static_fallback_command("openclaw"), "openclaw update --yes || npm i -g openclaw@latest" ); + assert_eq!( + static_fallback_command("pi"), + "npm i -g @earendil-works/pi-coding-agent@latest" + ); + assert!(!static_fallback_command("pi").contains("pi update")); } #[test] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 08e270919..b289bf7c7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -17,6 +17,7 @@ mod misc; mod model_fetch; mod omo; mod openclaw; +mod pi; mod plugin; mod profile; mod prompt; @@ -53,6 +54,7 @@ pub use misc::*; pub use model_fetch::*; pub use omo::*; pub use openclaw::*; +pub(crate) use pi::*; pub use plugin::*; pub use profile::*; pub use prompt::*; diff --git a/src-tauri/src/commands/pi.rs b/src-tauri/src/commands/pi.rs new file mode 100644 index 000000000..4dadc7059 --- /dev/null +++ b/src-tauri/src/commands/pi.rs @@ -0,0 +1,76 @@ +use crate::pi_config::model::PiNativeDiagnostic; +use crate::pi_config::native_settings::{read_pi_native_defaults, PiNativeDefaults}; +use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation}; +use crate::session_manager::providers::pi::PiSessionDiscovery; +use crate::store::AppState; +use tauri::State; + +/// Read-only diagnostics come exclusively from the Pre-C certified inspection +/// service. This command does not infer manageability or gateway status. +#[tauri::command] +pub(crate) fn get_pi_native_catalog( + state: State<'_, AppState>, +) -> Result, String> { + PiCatalogCoordinator::inspect_native(state.inner()).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub(crate) fn import_pi_native_provider( + state: State<'_, AppState>, + #[allow(non_snake_case)] providerKey: String, + #[allow(non_snake_case)] expectedFingerprint: String, +) -> Result { + let result = PiCatalogCoordinator::apply( + state.inner(), + PiCatalogMutation::ImportNative { + provider_key: providerKey, + expected_fingerprint: expectedFingerprint, + }, + ) + .map_err(|error| error.to_string())?; + result + .provider_id + .ok_or_else(|| "Pi import did not return a provider id".to_string()) +} + +#[tauri::command] +pub(crate) fn set_pi_default_model( + state: State<'_, AppState>, + #[allow(non_snake_case)] providerId: String, + #[allow(non_snake_case)] modelId: String, +) -> Result { + PiCatalogCoordinator::apply( + state.inner(), + PiCatalogMutation::SetDefault { + provider_id: providerId, + model_id: modelId, + }, + ) + .map(|_| true) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub(crate) fn get_pi_native_defaults() -> Result { + read_pi_native_defaults().map_err(|error| error.to_string()) +} + +#[tauri::command] +pub(crate) fn get_pi_session_discovery() -> PiSessionDiscovery { + crate::session_manager::providers::pi::session_discovery() +} + +/// Explicitly rotate the device-local gateway bearer and republish every +/// managed Pi projection. Existing Pi processes must restart because they +/// retain the previous projected credential in memory. +#[tauri::command] +pub(crate) async fn reset_pi_gateway_credential( + state: State<'_, AppState>, +) -> Result { + state + .proxy_service + .rotate_pi_gateway_token() + .await + .map(|()| true) + .map_err(|error| error.to_string()) +} diff --git a/src-tauri/src/commands/prompt.rs b/src-tauri/src/commands/prompt.rs index 20bd9f2a3..38cd13d65 100644 --- a/src-tauri/src/commands/prompt.rs +++ b/src-tauri/src/commands/prompt.rs @@ -5,6 +5,10 @@ use tauri::State; use crate::app_config::AppType; use crate::prompt::Prompt; +use crate::services::pi_prompt_files::{ + PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate, + PiPromptTemplateService, +}; use crate::services::PromptService; use crate::store::AppState; @@ -62,3 +66,49 @@ pub async fn get_current_prompt_file_content(app: String) -> Result Result { + PiPromptFileService::read(kind).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn replace_pi_prompt_file( + kind: PiPromptFileKind, + #[allow(non_snake_case)] expectedRevision: String, + content: String, +) -> Result { + PiPromptFileService::replace(kind, &expectedRevision, &content) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn delete_pi_prompt_file( + kind: PiPromptFileKind, + #[allow(non_snake_case)] expectedRevision: String, +) -> Result { + PiPromptFileService::delete(kind, &expectedRevision).map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn list_pi_prompt_templates() -> Result, String> { + PiPromptTemplateService::list().map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn upsert_pi_prompt_template( + slug: String, + #[allow(non_snake_case)] expectedRevision: String, + content: String, +) -> Result { + PiPromptTemplateService::upsert(&slug, &expectedRevision, &content) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn delete_pi_prompt_template( + slug: String, + #[allow(non_snake_case)] expectedRevision: String, +) -> Result { + PiPromptTemplateService::delete(&slug, &expectedRevision).map_err(|error| error.to_string()) +} diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 05bc08ae4..73e0cb63b 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -26,6 +26,7 @@ pub async fn stop_proxy_server(state: tauri::State<'_, AppState>) -> Result<(), || takeover.grokbuild || takeover.opencode || takeover.openclaw + || takeover.pi { return Err( "仍有应用处于代理接管状态,请先在设置中关闭对应应用接管后再停止本地路由。".to_string(), @@ -120,6 +121,9 @@ pub async fn get_proxy_config_for_app( state: tauri::State<'_, AppState>, app_type: String, ) -> Result { + if app_type == "pi" { + return Ok(crate::settings::get_pi_app_proxy_config()); + } let db = &state.db; db.get_proxy_config_for_app(&app_type) .await @@ -138,6 +142,60 @@ pub async fn update_proxy_config_for_app( let app_type = config.app_type.clone(); let circuit_config = CircuitBreakerConfig::from(&config); + if app_type == "pi" { + let _guard = state + .proxy_service + .lock_switch_for_app(crate::app_config::AppType::Pi.as_str()) + .await; + let previous = crate::settings::get_pi_proxy_settings(); + if config.enabled != crate::settings::pi_takeover_enabled() { + return Err( + "Pi enabled state is owned by set_proxy_takeover_for_app, not proxy config" + .to_string(), + ); + } + let next = crate::settings::PiProxySettings { + auto_failover_enabled: config.auto_failover_enabled, + max_retries: config.max_retries, + streaming_first_byte_timeout: config.streaming_first_byte_timeout, + streaming_idle_timeout: config.streaming_idle_timeout, + non_streaming_timeout: config.non_streaming_timeout, + circuit_failure_threshold: config.circuit_failure_threshold, + circuit_success_threshold: config.circuit_success_threshold, + circuit_timeout_seconds: config.circuit_timeout_seconds, + circuit_error_rate_threshold: config.circuit_error_rate_threshold, + circuit_min_requests: config.circuit_min_requests, + }; + let epoch = state.proxy_service.begin_pi_catalog_mutation().await; + if let Err(error) = crate::settings::update_pi_proxy_settings(next) { + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await; + return Err(error.to_string()); + } + if let Err(error) = state + .proxy_service + .reconcile_pi_runtime_at_epoch(epoch) + .await + { + let _ = crate::settings::update_pi_proxy_settings(previous); + let rollback_epoch = state.proxy_service.begin_pi_catalog_mutation().await; + let _ = state + .proxy_service + .reconcile_pi_runtime_at_epoch(rollback_epoch) + .await; + return Err(format!( + "Pi proxy config changed but runtime publication failed: {error}" + )); + } + state + .proxy_service + .update_circuit_breaker_config_for_app(&app_type, circuit_config) + .await?; + return Ok(()); + } + db.update_proxy_config_for_app(config) .await .map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/s3_sync.rs b/src-tauri/src/commands/s3_sync.rs index 462b20439..738e4e326 100644 --- a/src-tauri/src/commands/s3_sync.rs +++ b/src-tauri/src/commands/s3_sync.rs @@ -107,18 +107,44 @@ pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result #[tauri::command] pub async fn s3_sync_download(state: State<'_, AppState>) -> Result { let db = state.db.clone(); - let db_for_sync = db.clone(); + let app_state = state.inner().clone(); let mut settings = require_enabled_s3_settings()?; let _auto_sync_suppression = crate::services::s3_auto_sync::AutoSyncSuppressionGuard::new(); + let pi_guard = app_state + .proxy_service + .lock_switch_for_app(crate::app_config::AppType::Pi.as_str()) + .await; + app_state + .proxy_service + .prepare_pi_portable_import_under_lock(&pi_guard) + .await + .map_err(|error| format!("S3 下载前恢复 Pi 直连投影失败: {error}"))?; let sync_result = run_with_s3_lock(s3_sync_service::download(&db, &mut settings)).await; - let mut result = map_sync_result(sync_result, |error| { - persist_sync_error(&mut settings, error, "manual") - })?; + let mut result = match sync_result { + Ok(result) => result, + Err(error) => { + persist_sync_error(&mut settings, &error, "manual"); + let recovery = app_state + .proxy_service + .recover_pi_after_aborted_portable_import_under_lock(&pi_guard) + .await; + return Err(match recovery { + Ok(()) => error.to_string(), + Err(recovery) => { + format!( + "{error}; Pi gateway recovery after aborted S3 download failed: {recovery}" + ) + } + }); + } + }; + drop(pi_guard); // Post-download sync is best-effort: snapshot restore has already succeeded. + let sync_state = app_state.clone(); let warning = post_sync_warning_from_result( - tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync)) + tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state)) .await .map_err(|e| e.to_string()), ); diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index f6cdb8111..855848db0 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -48,6 +48,13 @@ fn merge_settings_for_save( // 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来, // 重新开启时被"复活"的标记挡住而漏迁。 incoming.local_migrations = existing.local_migrations.clone(); + // Pi gateway credential is an installation secret. Settings IPC can + // neither observe it (frontend projection clears it) nor mutate it. + incoming.pi_gateway_token = existing.pi_gateway_token.clone(); + // Pi proxy behavior is committed through the proxy commands so a generic + // settings round-trip cannot bypass the switch/epoch publication boundary. + incoming.pi_takeover_enabled = existing.pi_takeover_enabled; + incoming.pi_proxy = existing.pi_proxy.clone(); incoming } @@ -63,12 +70,29 @@ pub async fn save_settings( state: tauri::State<'_, crate::store::AppState>, settings: crate::settings::AppSettings, ) -> Result { + // The frontend settings projection intentionally cannot mutate Pi's + // takeover bit or gateway secret. Serialize the read/merge/write with Pi + // catalog mutations so a concurrent toggle cannot be overwritten by a + // stale full-settings payload. + let pi_guard = state + .proxy_service + .lock_switch_for_app(crate::app_config::AppType::Pi.as_str()) + .await; let existing = crate::settings::get_settings(); let merged = merge_settings_for_save(settings, &existing); let unify_codex_changed = merged.unify_codex_session_history != existing.unify_codex_session_history; let unify_codex_enabled = merged.unify_codex_session_history; - crate::settings::update_settings(merged).map_err(|e| e.to_string())?; + state + .proxy_service + .replace_settings_with_pi_directory_boundary_under_lock( + &pi_guard, + &existing, + merged.clone(), + ) + .await + .map_err(|e| e.to_string())?; + drop(pi_guard); // 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置, // 不必等下一次切换才生效。 @@ -82,7 +106,18 @@ pub async fn save_settings( crate::services::provider::reapply_current_codex_official_live(state.inner()) { log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}"); - if let Err(rollback_err) = crate::settings::update_settings(existing) { + let pi_guard = state + .proxy_service + .lock_switch_for_app(crate::app_config::AppType::Pi.as_str()) + .await; + let current = crate::settings::get_settings(); + if let Err(rollback_err) = state + .proxy_service + .replace_settings_with_pi_directory_boundary_under_lock( + &pi_guard, ¤t, existing, + ) + .await + { log::error!("回滚统一会话开关设置失败: {rollback_err}"); } return Err(format!( @@ -618,6 +653,28 @@ mod tests { assert!(merged.local_migrations.is_none()); } + + #[test] + fn save_settings_cannot_bypass_pi_gateway_publication_ownership() { + let existing = AppSettings { + pi_takeover_enabled: true, + pi_proxy: crate::settings::PiProxySettings { + max_retries: 7, + ..crate::settings::PiProxySettings::default() + }, + ..AppSettings::default() + }; + let incoming = AppSettings { + pi_takeover_enabled: false, + pi_proxy: crate::settings::PiProxySettings::default(), + ..AppSettings::default() + }; + + let merged = merge_settings_for_save(incoming, &existing); + + assert!(merged.pi_takeover_enabled); + assert_eq!(merged.pi_proxy.max_retries, 7); + } } /// 获取开机自启状态 diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 6ac90ac54..44f41f55b 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -11,7 +11,9 @@ use crate::services::skill::{ SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, SkillsShSearchResult, }; +use crate::services::skill_deployment::{PiSkillDeploymentService, SkillAppStatus}; use crate::store::AppState; +use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; use tauri::State; @@ -32,6 +34,13 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result, +) -> Result, String> { + PiSkillDeploymentService::inspect_all(&app_state.db).map_err(|error| error.to_string()) +} + #[tauri::command] pub fn get_skill_backups() -> Result, String> { SkillService::list_backups().map_err(|e| e.to_string()) diff --git a/src-tauri/src/commands/sync_support.rs b/src-tauri/src/commands/sync_support.rs index 00793b3b8..4c685e194 100644 --- a/src-tauri/src/commands/sync_support.rs +++ b/src-tauri/src/commands/sync_support.rs @@ -1,15 +1,13 @@ -use serde_json::{json, Value}; -use std::sync::Arc; - -use crate::database::Database; use crate::error::AppError; use crate::services::provider::ProviderService; +use crate::services::PromptService; use crate::settings; use crate::store::AppState; +use serde_json::{json, Value}; -pub(crate) fn run_post_import_sync(db: Arc) -> Result<(), AppError> { - let app_state = AppState::new(db); - ProviderService::sync_current_to_live(&app_state)?; +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(()) } diff --git a/src-tauri/src/commands/webdav_sync.rs b/src-tauri/src/commands/webdav_sync.rs index 31879d13b..553d89827 100644 --- a/src-tauri/src/commands/webdav_sync.rs +++ b/src-tauri/src/commands/webdav_sync.rs @@ -115,18 +115,44 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result) -> Result { let db = state.db.clone(); - let db_for_sync = db.clone(); + let app_state = state.inner().clone(); let mut settings = require_enabled_webdav_settings()?; let _auto_sync_suppression = crate::services::webdav_auto_sync::AutoSyncSuppressionGuard::new(); + let pi_guard = app_state + .proxy_service + .lock_switch_for_app(crate::app_config::AppType::Pi.as_str()) + .await; + app_state + .proxy_service + .prepare_pi_portable_import_under_lock(&pi_guard) + .await + .map_err(|error| format!("WebDAV 下载前恢复 Pi 直连投影失败: {error}"))?; let sync_result = run_with_webdav_lock(webdav_sync_service::download(&db, &mut settings)).await; - let mut result = map_sync_result(sync_result, |error| { - persist_sync_error(&mut settings, error, "manual") - })?; + let mut result = match sync_result { + Ok(result) => result, + Err(error) => { + persist_sync_error(&mut settings, &error, "manual"); + let recovery = app_state + .proxy_service + .recover_pi_after_aborted_portable_import_under_lock(&pi_guard) + .await; + return Err(match recovery { + Ok(()) => error.to_string(), + Err(recovery) => { + format!( + "{error}; Pi gateway recovery after aborted WebDAV download failed: {recovery}" + ) + } + }); + } + }; + drop(pi_guard); // Post-download sync is best-effort: snapshot restore has already succeeded. + let sync_state = app_state.clone(); let warning = post_sync_warning_from_result( - tauri::async_runtime::spawn_blocking(move || run_post_import_sync(db_for_sync)) + tauri::async_runtime::spawn_blocking(move || run_post_import_sync(&sync_state)) .await .map_err(|e| e.to_string()), ); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ed4686522..e0c68d048 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -295,6 +295,20 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { /// 原子写入:写入临时文件后 rename 替换,避免半写状态 pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { + atomic_write_durable(path, data, None) +} + +/// Durable same-directory atomic replacement. +/// +/// Existing permissions are preserved. `new_file_mode` controls only a newly +/// created Unix file (settings and other local secrets pass `0o600`). The +/// temporary file is created exclusively, synced before replacement, and the +/// containing directory is synced afterwards on Unix. +pub(crate) fn atomic_write_durable( + path: &Path, + data: &[u8], + new_file_mode: Option, +) -> Result<(), AppError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } @@ -302,51 +316,95 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { let parent = path .parent() .ok_or_else(|| AppError::Config("无效的路径".to_string()))?; - let mut tmp = parent.to_path_buf(); let file_name = path .file_name() .ok_or_else(|| AppError::Config("无效的文件名".to_string()))? .to_string_lossy() .to_string(); - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - tmp.push(format!("{file_name}.tmp.{ts}")); + let tmp = parent.join(format!( + ".{file_name}.{}.tmp", + uuid::Uuid::new_v4().simple() + )); - { - let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?; - f.write_all(data).map_err(|e| AppError::io(&tmp, e))?; - f.flush().map_err(|e| AppError::io(&tmp, e))?; - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = fs::metadata(path) { - let perm = meta.permissions().mode(); - let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm)); + let result = (|| -> Result<(), AppError> { + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(new_file_mode.unwrap_or(0o666)); } - } + let mut file = options + .open(&tmp) + .map_err(|error| AppError::io(&tmp, error))?; + file.write_all(data) + .map_err(|error| AppError::io(&tmp, error))?; + file.flush().map_err(|error| AppError::io(&tmp, error))?; + file.sync_all().map_err(|error| AppError::io(&tmp, error))?; + drop(file); - #[cfg(windows)] - { - // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性) - if path.exists() { - let _ = fs::remove_file(path); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(path) + .map(|metadata| metadata.permissions().mode()) + .unwrap_or_else(|_| new_file_mode.unwrap_or(0o666)); + fs::set_permissions(&tmp, fs::Permissions::from_mode(mode)) + .map_err(|error| AppError::io(&tmp, error))?; } - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { - context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), - source: e, - })?; - } - #[cfg(not(windows))] - { - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { - context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), - source: e, - })?; + replace_file_atomically(&tmp, path)?; + #[cfg(unix)] + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| AppError::io(parent, error))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +#[cfg(not(windows))] +fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> { + fs::rename(temp_path, path).map_err(|source| AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", + temp_path.display(), + path.display() + ), + source, + }) +} + +#[cfg(windows)] +fn replace_file_atomically(temp_path: &Path, path: &Path) -> Result<(), AppError> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source: Vec = temp_path.as_os_str().encode_wide().chain(Some(0)).collect(); + let destination: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + // SAFETY: both buffers are NUL-terminated and remain alive for the + // duration of this synchronous Win32 call. + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + return Err(AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", + temp_path.display(), + path.display() + ), + source: std::io::Error::last_os_error(), + }); } Ok(()) } diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index c0ef811bd..15d174143 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -4,6 +4,7 @@ pub mod failover; pub mod mcp; +pub(crate) mod pi_catalog; pub mod pi_projections; pub mod profiles; pub mod prompts; diff --git a/src-tauri/src/database/dao/pi_catalog.rs b/src-tauri/src/database/dao/pi_catalog.rs new file mode 100644 index 000000000..5d33d96d9 --- /dev/null +++ b/src-tauri/src/database/dao/pi_catalog.rs @@ -0,0 +1,200 @@ +//! Transactional database half of Pi catalog coordination. +//! +//! Provider row/endpoint SQL remains owned by the certified provider-write +//! primitives. This module only composes those primitives with Pi's exact-key +//! ownership ledger in one SQLite transaction. + +use super::pi_projections::PiProviderProjection; +use super::provider_write::{ + insert_endpoint, insert_row, restore_provider_aggregate_on_tx, NewEndpoint, + NewProviderAggregate, ProviderKey, ProviderRowUpdate, +}; +use super::providers::delete_provider_on_tx; +use crate::database::{lock_conn, Database}; +use crate::error::AppError; +use crate::provider::{ProviderAggregate, ProviderMutationInput}; +use rusqlite::params; + +impl Database { + pub(crate) fn create_pi_catalog_provider( + &self, + input: NewProviderAggregate, + provider_key: &str, + ) -> Result { + if input.key.app_type() != "pi" || provider_key.trim().is_empty() { + return Err(AppError::InvalidInput( + "Pi catalog create requires app_type=pi and a non-empty native key".to_string(), + )); + } + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + insert_row( + &tx, + &input.key, + &input.row.content, + input.row.created_at, + input.sort_index, + false, + input.in_failover_queue, + )?; + for endpoint in &input.initial_endpoints { + insert_endpoint(&tx, &input.key, endpoint)?; + } + let now = chrono::Utc::now().timestamp_millis(); + tx.execute( + "INSERT INTO pi_provider_projections + (provider_id, provider_key, created_at, updated_at) + VALUES (?1, ?2, ?3, ?3)", + params![input.key.id(), provider_key, now], + ) + .map_err(|error| match &error { + rusqlite::Error::SqliteFailure(code, _) + if matches!( + code.extended_code, + rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY + | rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE + ) => + { + AppError::Conflict(format!( + "Pi native provider key '{provider_key}' is already claimed" + )) + } + _ => AppError::Database(error.to_string()), + })?; + tx.commit() + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(PiProviderProjection { + provider_id: input.key.id().to_string(), + provider_key: provider_key.to_string(), + created_at: now, + updated_at: now, + }) + } + + pub(crate) fn update_pi_catalog_provider( + &self, + key: &ProviderKey, + row: &ProviderRowUpdate, + ) -> Result<(), AppError> { + if key.app_type() != "pi" { + return Err(AppError::InvalidInput( + "Pi catalog update requires app_type=pi".to_string(), + )); + } + self.update_provider(key, row) + } + + pub(crate) fn delete_pi_catalog_provider( + &self, + provider_id: &str, + ) -> Result, AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + let projection = tx + .query_row( + "SELECT provider_id, provider_key, created_at, updated_at + FROM pi_provider_projections + WHERE provider_id = ?1", + [provider_id], + |row| { + Ok(PiProviderProjection { + provider_id: row.get(0)?, + provider_key: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + }) + }, + ) + .optional() + .map_err(|error| AppError::Database(error.to_string()))?; + delete_provider_on_tx(&tx, "pi", provider_id)?; + tx.execute( + "DELETE FROM pi_provider_projections WHERE provider_id = ?1", + [provider_id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + tx.commit() + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(projection) + } + + pub(crate) fn restore_pi_catalog_provider( + &self, + aggregate: &ProviderAggregate, + was_current: bool, + projection: Option<&PiProviderProjection>, + ) -> Result<(), AppError> { + let key = ProviderKey::new("pi", aggregate.provider.id.clone())?; + let mut input = provider_mutation_input(aggregate); + if let Some(meta) = input.meta.as_mut() { + meta.custom_endpoints.clear(); + } + let row = ProviderRowUpdate::from_input(&input)?; + let endpoints = aggregate + .endpoints + .values() + .cloned() + .map(NewEndpoint::try_from) + .collect::, _>>()?; + + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + restore_provider_aggregate_on_tx( + &tx, + &key, + &row, + aggregate.provider.created_at, + aggregate.provider.sort_index, + was_current, + aggregate.provider.in_failover_queue, + &endpoints, + )?; + tx.execute( + "DELETE FROM pi_provider_projections WHERE provider_id = ?1", + [key.id()], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if let Some(projection) = projection { + tx.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()))?; + } + tx.commit() + .map_err(|error| AppError::Database(error.to_string())) + } +} + +fn provider_mutation_input(aggregate: &ProviderAggregate) -> ProviderMutationInput { + let provider = &aggregate.provider; + ProviderMutationInput { + id: provider.id.clone(), + name: provider.name.clone(), + settings_config: provider.settings_config.clone(), + website_url: provider.website_url.clone(), + category: provider.category.clone(), + created_at: provider.created_at, + sort_index: provider.sort_index, + notes: provider.notes.clone(), + meta: provider.meta.clone(), + icon: provider.icon.clone(), + icon_color: provider.icon_color.clone(), + in_failover_queue: provider.in_failover_queue, + } +} + +use rusqlite::OptionalExtension; diff --git a/src-tauri/src/database/dao/prompts.rs b/src-tauri/src/database/dao/prompts.rs index 1c274504f..5fba96693 100644 --- a/src-tauri/src/database/dao/prompts.rs +++ b/src-tauri/src/database/dao/prompts.rs @@ -75,6 +75,53 @@ impl Database { Ok(()) } + /// Persist a complete prompt-library selection atomically. + /// + /// Pi projects the single enabled row into AGENTS.md. A sequence of + /// individual `save_prompt` calls can expose two enabled rows (or none) to + /// concurrent readers, so selection changes use one SQLite transaction. + pub(crate) fn save_prompt_selection( + &self, + 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(), + )); + } + 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()))?; + } + } + transaction + .commit() + .map_err(|error| AppError::Database(error.to_string())) + } + /// 删除提示词 pub fn delete_prompt(&self, app_type: &str, id: &str) -> Result<(), AppError> { let conn = lock_conn!(self.conn); diff --git a/src-tauri/src/database/dao/provider_write.rs b/src-tauri/src/database/dao/provider_write.rs index 3ddab2ac2..e42454ec2 100644 --- a/src-tauri/src/database/dao/provider_write.rs +++ b/src-tauri/src/database/dao/provider_write.rs @@ -37,14 +37,14 @@ impl ProviderKey { #[derive(Debug, Clone)] pub struct ProviderRowUpdate { - name: String, - settings_config: Value, - website_url: Option, - category: Option, - notes: Option, - meta: ProviderMeta, - icon: Option, - icon_color: Option, + pub(super) name: String, + pub(super) settings_config: Value, + pub(super) website_url: Option, + pub(super) category: Option, + pub(super) notes: Option, + pub(super) meta: ProviderMeta, + pub(super) icon: Option, + pub(super) icon_color: Option, } impl ProviderRowUpdate { @@ -71,15 +71,15 @@ impl ProviderRowUpdate { #[derive(Debug, Clone)] pub struct ProviderRowCreate { - content: ProviderRowUpdate, - created_at: Option, + pub(super) content: ProviderRowUpdate, + pub(super) created_at: Option, } #[derive(Debug, Clone)] pub struct NewEndpoint { - url: String, - added_at: Option, - last_used: Option, + pub(super) url: String, + pub(super) added_at: Option, + pub(super) last_used: Option, } impl NewEndpoint { @@ -116,11 +116,11 @@ impl TryFrom for NewEndpoint { #[derive(Debug, Clone)] pub struct NewProviderAggregate { - key: ProviderKey, - row: ProviderRowCreate, - sort_index: Option, - in_failover_queue: bool, - initial_endpoints: Vec, + pub(super) key: ProviderKey, + pub(super) row: ProviderRowCreate, + pub(super) sort_index: Option, + pub(super) in_failover_queue: bool, + pub(super) initial_endpoints: Vec, } impl NewProviderAggregate { @@ -219,7 +219,7 @@ fn encode_row(row: &ProviderRowUpdate) -> Result<(String, String), AppError> { Ok((settings_config, meta)) } -fn insert_row( +pub(super) fn insert_row( tx: &Transaction<'_>, key: &ProviderKey, row: &ProviderRowUpdate, @@ -272,7 +272,7 @@ fn insert_row( Ok(()) } -fn insert_endpoint( +pub(super) fn insert_endpoint( tx: &Transaction<'_>, key: &ProviderKey, endpoint: &NewEndpoint, @@ -357,7 +357,7 @@ pub(super) fn restore_provider_aggregate_on_tx( Ok(()) } -fn update_row( +pub(super) fn update_row( tx: &Transaction<'_>, key: &ProviderKey, row: &ProviderRowUpdate, @@ -618,23 +618,38 @@ impl Database { pub(crate) fn update_provider_sort_index( &self, - key: &ProviderKey, - sort_index: usize, + updates: &[(ProviderKey, usize)], ) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - if conn - .execute( - "UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3", - params![sort_index, key.id, key.app_type], - ) - .map_err(|error| AppError::Database(error.to_string()))? - != 1 - { - return Err(AppError::NotFound(format!( - "provider '{}/{}'", - key.app_type, key.id - ))); + let mut seen = std::collections::HashSet::with_capacity(updates.len()); + for (key, _) in updates { + if !seen.insert((key.app_type().to_string(), key.id().to_string())) { + return Err(AppError::InvalidInput(format!( + "duplicate provider sort update for '{}/{}'", + key.app_type(), + key.id() + ))); + } } - Ok(()) + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + for (key, sort_index) in updates { + if tx + .execute( + "UPDATE providers SET sort_index = ?1 WHERE id = ?2 AND app_type = ?3", + params![sort_index, key.id, key.app_type], + ) + .map_err(|error| AppError::Database(error.to_string()))? + != 1 + { + return Err(AppError::NotFound(format!( + "provider '{}/{}'", + key.app_type, key.id + ))); + } + } + tx.commit() + .map_err(|error| AppError::Database(error.to_string())) } } diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index a53d18723..401a4e63b 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -6,6 +6,24 @@ use indexmap::IndexMap; use rusqlite::{params, OptionalExtension, Row}; use std::collections::{HashMap, HashSet}; +pub(super) fn delete_provider_on_tx( + tx: &rusqlite::Transaction<'_>, + app_type: &str, + id: &str, +) -> Result<(), AppError> { + if tx + .execute( + "DELETE FROM providers WHERE id = ?1 AND app_type = ?2", + params![id, app_type], + ) + .map_err(|error| AppError::Database(error.to_string()))? + != 1 + { + return Err(AppError::NotFound(format!("provider '{app_type}/{id}'"))); + } + Ok(()) +} + pub(super) struct StoredProviderRow { id: String, name: String, @@ -330,6 +348,16 @@ impl Database { Ok(()) } + pub(crate) fn clear_current_provider_for_app(&self, app_type: &str) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "UPDATE providers SET is_current = 0 WHERE app_type = ?1", + params![app_type], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + } + pub fn set_omo_provider_current( &self, app_type: &str, diff --git a/src-tauri/src/database/dao/skill_deployments.rs b/src-tauri/src/database/dao/skill_deployments.rs index a5890a52f..5add7533e 100644 --- a/src-tauri/src/database/dao/skill_deployments.rs +++ b/src-tauri/src/database/dao/skill_deployments.rs @@ -112,6 +112,18 @@ impl Database { pub(crate) fn save_pi_skill_deployment( &self, deployment: &SkillDeployment, + ) -> Result<(), AppError> { + self.save_pi_skill_deployment_with_desired(deployment, None) + } + + /// Commit ledger evidence and, for a user toggle, the desired Pi bit in + /// the same SQLite transaction. Filesystem publication happens before + /// this point; a failed transaction is therefore safe to compensate by + /// restoring the staged destination without exposing split DB authority. + pub(crate) fn save_pi_skill_deployment_with_desired( + &self, + deployment: &SkillDeployment, + desired_enabled: Option, ) -> Result<(), AppError> { if deployment.skill_id.trim().is_empty() || deployment.destination.trim().is_empty() @@ -122,9 +134,13 @@ impl Database { "Pi Skill deployment identity fields must be non-empty".to_string(), )); } - let conn = lock_conn!(self.conn); - conn.execute( - "INSERT INTO skill_deployments ( + let mut conn = lock_conn!(self.conn); + let transaction = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + 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) @@ -134,19 +150,35 @@ impl Database { source_identity = excluded.source_identity, deployed_digest = excluded.deployed_digest, updated_at = excluded.updated_at", - 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()))?; - Ok(()) + 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()))?; + if let Some(desired_enabled) = desired_enabled { + let changed = transaction + .execute( + "UPDATE skills SET enabled_pi = ?1 WHERE id = ?2", + params![desired_enabled, deployment.skill_id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if changed != 1 { + return Err(AppError::Conflict(format!( + "Pi Skill '{}' disappeared before deployment commit", + deployment.skill_id + ))); + } + } + transaction + .commit() + .map_err(|error| AppError::Database(error.to_string())) } pub(crate) fn delete_pi_skill_deployment( @@ -154,14 +186,44 @@ impl Database { skill_id: &str, destination_key: &str, ) -> Result { - let conn = lock_conn!(self.conn); - conn.execute( - "DELETE FROM skill_deployments + self.delete_pi_skill_deployment_with_desired(skill_id, destination_key, None) + } + + pub(crate) fn delete_pi_skill_deployment_with_desired( + &self, + skill_id: &str, + destination_key: &str, + desired_enabled: Option, + ) -> Result { + let mut conn = lock_conn!(self.conn); + let transaction = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + if let Some(desired_enabled) = desired_enabled { + let changed = transaction + .execute( + "UPDATE skills SET enabled_pi = ?1 WHERE id = ?2", + params![desired_enabled, skill_id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if changed != 1 { + return Err(AppError::Conflict(format!( + "Pi Skill '{skill_id}' disappeared before deployment cleanup" + ))); + } + } + let removed = transaction + .execute( + "DELETE FROM skill_deployments WHERE app_type = 'pi' AND skill_id = ?1 AND destination_key = ?2", - params![skill_id, destination_key], - ) - .map(|count| count == 1) - .map_err(|error| AppError::Database(error.to_string())) + params![skill_id, destination_key], + ) + .map_err(|error| AppError::Database(error.to_string()))? + == 1; + transaction + .commit() + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(removed) } } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index edb7afefa..1f075336f 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -23,7 +23,8 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at + enabled_opencode, enabled_hermes, enabled_pi, + installed_at, content_hash, updated_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -46,10 +47,11 @@ impl Database { grokbuild: row.get(11)?, opencode: row.get(12)?, hermes: row.get(13)?, + pi: row.get(14)?, }, - installed_at: row.get(14)?, - content_hash: row.get(15)?, - updated_at: row.get::<_, i64>(16).unwrap_or(0), + installed_at: row.get(15)?, + content_hash: row.get(16)?, + updated_at: row.get::<_, i64>(17).unwrap_or(0), }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -69,7 +71,8 @@ impl Database { .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at + enabled_opencode, enabled_hermes, enabled_pi, + installed_at, content_hash, updated_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -91,10 +94,11 @@ impl Database { grokbuild: row.get(11)?, opencode: row.get(12)?, hermes: row.get(13)?, + pi: row.get(14)?, }, - installed_at: row.get(14)?, - content_hash: row.get(15)?, - updated_at: row.get::<_, i64>(16).unwrap_or(0), + installed_at: row.get(15)?, + content_hash: row.get(16)?, + updated_at: row.get::<_, i64>(17).unwrap_or(0), }) }); @@ -112,8 +116,8 @@ impl Database { "INSERT INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes, - installed_at, content_hash, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17) + enabled_pi, installed_at, content_hash, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, @@ -146,6 +150,7 @@ impl Database { skill.apps.grokbuild, skill.apps.opencode, skill.apps.hermes, + skill.apps.pi, skill.installed_at, skill.content_hash, skill.updated_at, @@ -177,8 +182,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7", - params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6, enabled_pi = ?7 WHERE id = ?8", + params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, apps.pi, id], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 532cfef43..e1cfe8826 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -37,6 +37,7 @@ mod schema; mod tests; // DAO 类型导出供外部使用 +pub(crate) use dao::pi_projections::PiProviderProjection; pub use dao::provider_write::{ NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider, }; @@ -48,6 +49,7 @@ pub(crate) use dao::proxy::{ validate_cost_multiplier, validate_pricing_source, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE, }; +pub(crate) use dao::skill_deployments::{SkillDeployment, SkillDeploymentMethod}; pub use dao::FailoverQueueItem; pub use dao::Profile; diff --git a/src-tauri/src/deeplink/mod.rs b/src-tauri/src/deeplink/mod.rs index 6bee98878..a89c39208 100644 --- a/src-tauri/src/deeplink/mod.rs +++ b/src-tauri/src/deeplink/mod.rs @@ -66,6 +66,10 @@ pub struct DeepLinkImportRequest { /// Optional model name #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// Native API identifier. Pi provider links require this explicitly; + /// cc-switch never infers a protocol from a URL or model name. + #[serde(skip_serializing_if = "Option::is_none")] + pub api: Option, /// Optional notes/description #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, diff --git a/src-tauri/src/deeplink/parser.rs b/src-tauri/src/deeplink/parser.rs index ee854ff4f..d6c8ea02b 100644 --- a/src-tauri/src/deeplink/parser.rs +++ b/src-tauri/src/deeplink/parser.rs @@ -81,10 +81,10 @@ fn parse_provider_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" + "claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'" ))); } @@ -116,6 +116,7 @@ fn parse_provider_deeplink( // Extract optional fields let model = params.get("model").cloned(); + let api = params.get("api").cloned(); let notes = params.get("notes").cloned(); let haiku_model = params.get("haikuModel").cloned(); let sonnet_model = params.get("sonnetModel").cloned(); @@ -127,6 +128,24 @@ fn parse_provider_deeplink( let config = params.get("config").cloned(); let config_format = params.get("configFormat").cloned(); let config_url = params.get("configUrl").cloned(); + if app == "pi" { + if model.as_deref().is_none_or(|value| value.trim().is_empty()) { + return Err(AppError::InvalidInput( + "Pi provider deep links require a non-empty 'model' parameter".to_string(), + )); + } + if api.as_deref().is_none_or(|value| value.trim().is_empty()) { + return Err(AppError::InvalidInput( + "Pi provider deep links require an explicit non-empty 'api' parameter".to_string(), + )); + } + if config.is_some() || config_url.is_some() { + return Err(AppError::InvalidInput( + "Pi provider deep links use explicit endpoint/api/model fields; embedded or remote config payloads are not supported" + .to_string(), + )); + } + } let enabled = params.get("enabled").and_then(|v| v.parse::().ok()); // Extract usage script fields (v3.9+) @@ -153,6 +172,7 @@ fn parse_provider_deeplink( api_key, icon, model, + api, notes, haiku_model, sonnet_model, @@ -190,10 +210,10 @@ fn parse_prompt_deeplink( // Validate app type if !matches!( app.as_str(), - "claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" + "claude" | "codex" | "gemini" | "grokbuild" | "opencode" | "openclaw" | "hermes" | "pi" ) { return Err(AppError::InvalidInput(format!( - "Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', or 'hermes', got '{app}'" + "Invalid app type: must be 'claude', 'codex', 'gemini', 'grokbuild', 'opencode', 'openclaw', 'hermes', or 'pi', got '{app}'" ))); } @@ -225,6 +245,7 @@ fn parse_prompt_deeplink( endpoint: None, api_key: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -298,6 +319,7 @@ fn parse_mcp_deeplink( endpoint: None, api_key: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -353,6 +375,7 @@ fn parse_skill_deeplink( endpoint: None, api_key: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index 4ab1fd13e..2b13f2646 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -160,6 +160,7 @@ pub(crate) fn build_provider_from_request( AppType::OpenCode => build_opencode_settings(request), AppType::OpenClaw => build_additive_app_settings(request), AppType::Hermes => build_hermes_settings(request), + AppType::Pi => build_pi_settings(request)?, }; // Build usage script configuration if provided @@ -591,6 +592,45 @@ fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { json!(config) } +/// Pi deep links intentionally carry one explicit model, endpoint and native +/// API identifier. Map only that closed subset; richer Pi catalogs use native +/// inspection/import or the Pi editor. No URL/model heuristic may invent the +/// protocol or model identity. +fn build_pi_settings(request: &DeepLinkImportRequest) -> Result { + let endpoint = get_primary_endpoint(request); + let model = request + .model + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::InvalidInput( + "Pi provider deep links require a non-empty model identifier".to_string(), + ) + })?; + let api = request + .api + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::InvalidInput( + "Pi provider deep links require an explicit API identifier".to_string(), + ) + })?; + + Ok(json!({ + "name": request.name, + "baseUrl": endpoint, + "apiKey": request.api_key, + "api": api, + "models": [{ + "id": model, + "name": model + }] + })) +} + // ============================================================================= // Config Merge Logic // ============================================================================= diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 49fcea1d1..62eced364 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -89,6 +89,61 @@ fn test_parse_deeplink_with_notes() { assert_eq!(request.notes, Some("Test notes".to_string())); } +#[test] +fn pi_provider_deeplink_requires_and_preserves_explicit_native_identity() { + use super::provider::build_provider_from_request; + + let request = parse_deeplink_url( + "ccswitch://v1/import?resource=provider&app=pi&name=Pi%20Provider&homepage=https%3A%2F%2Fexample.com&endpoint=https%3A%2F%2Fapi.example.com%2Fv1&apiKey=sk-test&model=opaque-model&api=future-native-api", + ) + .expect("parse explicit Pi provider link"); + assert_eq!(request.app.as_deref(), Some("pi")); + assert_eq!(request.api.as_deref(), Some("future-native-api")); + assert_eq!(request.model.as_deref(), Some("opaque-model")); + + let provider = build_provider_from_request(&AppType::Pi, &request).expect("build Pi provider"); + assert_eq!( + provider.settings_config, + serde_json::json!({ + "name": "Pi Provider", + "baseUrl": "https://api.example.com/v1", + "apiKey": "sk-test", + "api": "future-native-api", + "models": [{ + "id": "opaque-model", + "name": "opaque-model" + }] + }), + "deeplinks must not invent a model, protocol, capability, pricing, or limit field" + ); +} + +#[test] +fn pi_provider_deeplink_rejects_implicit_model_or_protocol() { + let missing_api = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&model=opaque-model"; + assert!(parse_deeplink_url(missing_api) + .expect_err("Pi api must be explicit") + .to_string() + .contains("'api'")); + + let missing_model = "ccswitch://v1/import?resource=provider&app=pi&name=Pi&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&api=openai-responses"; + assert!(parse_deeplink_url(missing_model) + .expect_err("Pi model must be explicit") + .to_string() + .contains("'model'")); +} + +#[test] +fn pi_prompt_deeplink_is_accepted_by_the_shared_prompt_path() { + let content = BASE64_STANDARD.encode("Pinned Pi AGENTS content"); + let url = format!( + "ccswitch://v1/import?resource=prompt&app=pi&name=Pi%20AGENTS&content={content}&enabled=false" + ); + let request = parse_deeplink_url(&url).expect("parse Pi prompt deeplink"); + assert_eq!(request.app.as_deref(), Some("pi")); + assert_eq!(request.content.as_deref(), Some(content.as_str())); +} + #[test] fn test_parse_grokbuild_provider() { use super::provider::build_provider_from_request; @@ -210,6 +265,7 @@ fn test_build_gemini_provider_with_model() { api_key: Some("test-api-key".to_string()), icon: None, model: Some("gemini-2.0-flash".to_string()), + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -263,6 +319,7 @@ fn test_build_gemini_provider_without_model() { api_key: Some("test-api-key".to_string()), icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -309,6 +366,7 @@ fn test_deeplink_usage_script_does_not_copy_provider_credentials() { api_key: Some("sk-main".to_string()), icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -356,6 +414,7 @@ fn usage_script_request(code: &str, usage_enabled: Option) -> DeepLinkImpo api_key: Some("sk-main".to_string()), icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -439,6 +498,7 @@ fn test_deeplink_usage_script_omits_explicit_credentials_that_match_provider() { api_key: Some("sk-main".to_string()), icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -487,6 +547,7 @@ fn test_deeplink_usage_script_preserves_distinct_usage_credentials() { api_key: Some("sk-main".to_string()), icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -540,6 +601,7 @@ fn test_parse_and_merge_config_claude() { api_key: None, icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -663,6 +725,7 @@ fn test_parse_and_merge_config_url_override() { api_key: Some("sk-new".to_string()), // URL param should override icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, @@ -726,6 +789,7 @@ fn test_build_claude_provider_preserves_custom_env_fields() { icon: None, // URL param: must win over the same key in config (haiku-from-config) model: Some("main-model".to_string()), + api: None, notes: None, haiku_model: Some("haiku-from-url".to_string()), sonnet_model: None, @@ -781,6 +845,7 @@ fn test_build_claude_provider_without_config_unchanged() { api_key: Some("sk".to_string()), icon: None, model: None, + api: None, notes: None, haiku_model: None, sonnet_model: None, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72fa77cf1..c1718a17a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -951,6 +951,7 @@ pub fn run() { crate::app_config::AppType::OpenCode, crate::app_config::AppType::OpenClaw, crate::app_config::AppType::Hermes, + crate::app_config::AppType::Pi, ] { match crate::services::prompt::PromptService::import_from_file_on_first_launch( &app_state, @@ -1329,6 +1330,12 @@ pub fn run() { commands::remove_provider_from_live_config, commands::switch_provider, commands::import_default_config, + commands::get_pi_native_catalog, + commands::import_pi_native_provider, + commands::set_pi_default_model, + commands::get_pi_native_defaults, + commands::get_pi_session_discovery, + commands::reset_pi_gateway_credential, commands::get_claude_desktop_status, commands::get_claude_desktop_default_routes, commands::import_claude_desktop_providers_from_claude, @@ -1413,6 +1420,12 @@ pub fn run() { commands::enable_prompt, commands::import_prompt_from_file, commands::get_current_prompt_file_content, + commands::get_pi_prompt_file, + commands::replace_pi_prompt_file, + commands::delete_pi_prompt_file, + commands::list_pi_prompt_templates, + commands::upsert_pi_prompt_template, + commands::delete_pi_prompt_template, // Profile management (项目配置方案) commands::list_profiles, commands::create_profile, @@ -1467,6 +1480,7 @@ pub fn run() { commands::restore_env_backup, // Skill management (v3.10.0+ unified) commands::get_installed_skills, + commands::get_pi_skill_statuses, commands::get_skill_backups, commands::delete_skill_backup, commands::install_skill_unified, @@ -1832,7 +1846,11 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { } }; let live_taken_over = proxy_service.detect_takeover_in_live_configs(); - let needs_restore = has_backups || live_taken_over; + let needs_restore = cleanup_before_exit_needed( + has_backups, + live_taken_over, + crate::settings::pi_takeover_enabled(), + ); if needs_restore { log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)..."); @@ -1856,6 +1874,14 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { } } +fn cleanup_before_exit_needed( + has_live_backups: bool, + legacy_live_taken_over: bool, + pi_takeover_enabled: bool, +) -> bool { + has_live_backups || legacy_live_taken_over || pi_takeover_enabled +} + /// 主动从系统托盘移除托盘图标。 /// /// `std::process::exit` 会绕过 Tauri 运行时,触发不了 `TrayIcon::drop()`, @@ -1886,7 +1912,10 @@ pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) { /// 则自动启动代理服务并接管对应应用的 Live 配置。 const PROXY_STARTUP_APP_TYPES: [&str; 4] = ["claude", "codex", "gemini", "grokbuild"]; -async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static str> { +async fn enabled_proxy_apps_on_startup( + db: &database::Database, + pi_takeover_enabled: bool, +) -> Vec<&'static str> { let mut apps = Vec::new(); for app_type in PROXY_STARTUP_APP_TYPES { if db @@ -1897,12 +1926,16 @@ async fn enabled_proxy_apps_on_startup(db: &database::Database) -> Vec<&'static apps.push(app_type); } } + if pi_takeover_enabled { + apps.push("pi"); + } apps } async fn restore_proxy_state_on_startup(state: &store::AppState) { // 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取) - let apps_to_restore = enabled_proxy_apps_on_startup(&state.db).await; + let apps_to_restore = + enabled_proxy_apps_on_startup(&state.db, crate::settings::pi_takeover_enabled()).await; if apps_to_restore.is_empty() { log::debug!("启动时无需恢复代理状态"); @@ -2225,9 +2258,9 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! { #[cfg(test)] mod tests { use super::{ - classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log, - redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows, - ExitRequestAction, + classify_exit_request, cleanup_before_exit_needed, enabled_proxy_apps_on_startup, + redact_url_for_log, redact_url_for_log_with_secrets, redact_url_origin_for_log, + runtime_log_level_allows, ExitRequestAction, }; use crate::database::Database; @@ -2349,8 +2382,21 @@ mod tests { .await .expect("enable Grok Build proxy config"); - let apps = enabled_proxy_apps_on_startup(&db).await; + let apps = enabled_proxy_apps_on_startup(&db, false).await; assert_eq!(apps, vec!["grokbuild"]); } + + #[tokio::test] + async fn startup_restore_republishes_persisted_pi_takeover() { + let db = Database::memory().expect("initialize database"); + let apps = enabled_proxy_apps_on_startup(&db, true).await; + assert_eq!(apps, vec!["pi"]); + } + + #[test] + fn process_exit_cleanup_includes_pi_takeover_without_legacy_live_backups() { + assert!(cleanup_before_exit_needed(false, false, true)); + assert!(!cleanup_before_exit_needed(false, false, false)); + } } diff --git a/src-tauri/src/pi_config/document.rs b/src-tauri/src/pi_config/document.rs index 46f0aba1b..d94a339a3 100644 --- a/src-tauri/src/pi_config/document.rs +++ b/src-tauri/src/pi_config/document.rs @@ -6,17 +6,23 @@ use crate::error::AppError; use indexmap::IndexMap; -use jsonc_parser::cst::{CstContainerNode, CstNode, CstObject, CstObjectProp, CstRootNode}; +use jsonc_parser::cst::{ + CstArray, CstContainerNode, CstInputValue, CstLeafNode, CstNode, CstObject, CstObjectProp, + CstRootNode, +}; use jsonc_parser::ParseOptions; use regex::Regex; use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::fs::{self, File, Metadata, OpenOptions}; use std::io::{Read, Take}; -use std::path::Path; -use std::sync::LazyLock; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; const MAX_PI_MODELS_BYTES: u64 = 8 * 1024 * 1024; const EMPTY_MODELS_DOCUMENT: &str = "{\"providers\":{}}"; +const MAX_MUTATION_ATTEMPTS: usize = 3; static PI_JSON_LINE_COMMENTS: LazyLock = LazyLock::new(|| { Regex::new(r#""(?:\\.|[^"\\])*"|//[^\n]*"#).expect("Pi JSON line-comment regex must compile") @@ -25,6 +31,23 @@ static PI_JSON_TRAILING_COMMAS: LazyLock = LazyLock::new(|| { Regex::new(r#""(?:\\.|[^"\\])*"|,(\s*[}\]])"#) .expect("Pi JSON trailing-comma regex must compile") }); +static PATH_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn path_lock(path: &Path) -> Result>, AppError> { + let mut locks = PATH_LOCKS + .lock() + .map_err(|error| AppError::Config(format!("Pi path-lock registry is poisoned: {error}")))?; + Ok(locks + .entry(path.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone()) +} + +fn lock_path(lock: &Mutex<()>) -> Result, AppError> { + lock.lock() + .map_err(|error| AppError::Config(format!("Pi config path lock is poisoned: {error}"))) +} #[derive(Debug, Clone)] pub(super) struct PiRawProviderEntry { @@ -111,6 +134,117 @@ fn cst_object(node: CstNode, path: &Path, label: &str) -> Result CstInputValue { + match value { + Value::Null => CstInputValue::Null, + Value::Bool(value) => CstInputValue::Bool(*value), + Value::Number(value) => CstInputValue::Number(value.to_string()), + Value::String(value) => CstInputValue::String(value.clone()), + Value::Array(values) => CstInputValue::Array(values.iter().map(cst_input).collect()), + Value::Object(values) => CstInputValue::Object( + values + .iter() + .map(|(key, value)| (key.clone(), cst_input(value))) + .collect(), + ), + } +} + +fn replace_cst_node(node: CstNode, replacement: &Value) -> Result<(), AppError> { + let replacement = cst_input(replacement); + let replaced = match node { + CstNode::Container(CstContainerNode::Array(node)) => node.replace_with(replacement), + CstNode::Container(CstContainerNode::Object(node)) => node.replace_with(replacement), + CstNode::Leaf(CstLeafNode::BooleanLit(node)) => node.replace_with(replacement), + CstNode::Leaf(CstLeafNode::NullKeyword(node)) => node.replace_with(replacement), + CstNode::Leaf(CstLeafNode::NumberLit(node)) => node.replace_with(replacement), + CstNode::Leaf(CstLeafNode::StringLit(node)) => node.replace_with(replacement), + CstNode::Leaf(CstLeafNode::WordLit(node)) => node.replace_with(replacement), + CstNode::Container(CstContainerNode::Root(_)) + | CstNode::Container(CstContainerNode::ObjectProp(_)) + | CstNode::Leaf(CstLeafNode::Token(_)) + | CstNode::Leaf(CstLeafNode::Whitespace(_)) + | CstNode::Leaf(CstLeafNode::Newline(_)) + | CstNode::Leaf(CstLeafNode::Comment(_)) => None, + }; + replaced.map(|_| ()).ok_or_else(|| { + AppError::Config("Pi models.json CST became disconnected during update".to_string()) + }) +} + +fn patch_cst_object( + object: &CstObject, + before: &serde_json::Map, + after: &serde_json::Map, +) -> Result<(), AppError> { + for key in before.keys().filter(|key| !after.contains_key(*key)) { + let matching = object + .properties() + .into_iter() + .filter(|property| cst_property_name(property).as_deref() == Some(key.as_str())) + .collect::>(); + for property in matching.into_iter().rev() { + property.remove(); + } + } + + for (key, after_value) in after { + if let Some(before_value) = before.get(key) { + let property = last_cst_property(object, key).ok_or_else(|| { + AppError::Config(format!( + "Pi models.json CST is missing existing property '{key}'" + )) + })?; + let value = property.value().ok_or_else(|| { + AppError::Config(format!("Pi models.json CST property '{key}' has no value")) + })?; + patch_cst_node(value, before_value, after_value)?; + } else { + object.append(key, cst_input(after_value)); + } + } + Ok(()) +} + +fn patch_cst_array(array: &CstArray, before: &[Value], after: &[Value]) -> Result<(), AppError> { + let elements = array.elements(); + if elements.len() != before.len() { + return Err(AppError::Config( + "Pi models.json CST array does not match its parsed value".to_string(), + )); + } + + for (index, (before_value, after_value)) in before.iter().zip(after).enumerate() { + patch_cst_node(elements[index].clone(), before_value, after_value)?; + } + for element in elements.into_iter().skip(after.len()).rev() { + element.remove(); + } + for value in after.iter().skip(before.len()) { + array.append(cst_input(value)); + } + Ok(()) +} + +fn patch_cst_node(node: CstNode, before: &Value, after: &Value) -> Result<(), AppError> { + if before == after { + return Ok(()); + } + match (&node, before, after) { + ( + CstNode::Container(CstContainerNode::Object(object)), + Value::Object(before), + Value::Object(after), + ) => patch_cst_object(object, before, after), + ( + CstNode::Container(CstContainerNode::Array(array)), + Value::Array(before), + Value::Array(after), + ) => patch_cst_array(array, before, after), + _ => replace_cst_node(node, after), + } +} + fn parse_models_source(path: &Path, source: &str) -> Result { let document: Value = serde_json::from_str(&strip_pi_json_comments(source)) .map_err(|error| AppError::json(path, error))?; @@ -256,6 +390,116 @@ pub(super) fn read_pi_models_document(path: &Path) -> Result) -> Option<[u8; 32]> { + bytes.map(|bytes| Sha256::digest(bytes).into()) +} + +fn serialize_models_mutation( + path: &Path, + before: Option<&[u8]>, + mutator: &impl Fn(&mut Value) -> Result<(), AppError>, +) -> Result, AppError> { + if let Some(bytes) = before { + let source = std::str::from_utf8(bytes) + .map_err(|error| jsonc_error(path, format!("file is not UTF-8: {error}")))?; + let mut document: Value = serde_json::from_str(&strip_pi_json_comments(source)) + .map_err(|error| AppError::json(path, error))?; + let root = CstRootNode::parse(source, &pi_models_parse_options()) + .map_err(|error| jsonc_error(path, error))?; + if root.to_serde_value().as_ref() != Some(&document) { + return Err(jsonc_error(path, "CST does not match Pi's parsed document")); + } + let original = document.clone(); + mutator(&mut document)?; + let root_value = root + .value() + .ok_or_else(|| jsonc_error(path, "document must contain a JSON value"))?; + patch_cst_node(root_value, &original, &document)?; + if root.to_serde_value().as_ref() != Some(&document) { + return Err(AppError::Config( + "Pi models.json CST update did not produce the requested document".to_string(), + )); + } + return Ok(root.to_string().into_bytes()); + } + + let mut document: Value = serde_json::from_str(EMPTY_MODELS_DOCUMENT) + .expect("empty Pi models document is valid JSON"); + mutator(&mut document)?; + let mut serialized = serde_json::to_vec_pretty(&document) + .map_err(|source| AppError::JsonSerialize { source })?; + serialized.push(b'\n'); + Ok(serialized) +} + +/// Patch only the explicitly named provider keys in Pi's shared models.json. +/// +/// Unknown root fields, unowned provider entries, comments, and formatting are +/// preserved by the CST patch. An optimistic fingerprint check prevents a +/// Pi/user write observed before replacement from being silently overwritten. +pub(crate) fn apply_pi_provider_patch( + path: &Path, + patch: &IndexMap>, +) -> Result<(), AppError> { + let lock = path_lock(path)?; + let _guard = lock_path(&lock)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?; + } + + for _ in 0..MAX_MUTATION_ATTEMPTS { + let before = read_models_bytes(path)?; + let observed = fingerprint(before.as_deref()); + let serialized = serialize_models_mutation(path, before.as_deref(), &|document| { + let providers = document + .as_object_mut() + .and_then(|root| root.get_mut("providers")) + .and_then(Value::as_object_mut) + .ok_or_else(|| jsonc_error(path, "root must contain a providers object"))?; + for (provider_key, replacement) in patch { + match replacement { + Some(value) => { + providers.insert(provider_key.clone(), value.clone()); + } + None => { + providers.remove(provider_key); + } + } + } + Ok(()) + })?; + + let current = read_models_bytes(path)?; + if fingerprint(current.as_deref()) != observed { + continue; + } + crate::config::atomic_write(path, &serialized)?; + return Ok(()); + } + + Err(AppError::Conflict(format!( + "Pi models file changed concurrently too many times: {}", + path.display() + ))) +} + +pub(crate) fn current_pi_provider_values( + path: &Path, + provider_keys: impl IntoIterator, +) -> Result>, AppError> { + let document = read_pi_models_document(path)?; + Ok(provider_keys + .into_iter() + .map(|key| { + let value = document + .providers() + .get(&key) + .map(|entry| entry.value.clone()); + (key, value) + }) + .collect()) +} + #[cfg(test)] mod tests { use super::*; @@ -288,6 +532,65 @@ mod tests { assert!(entry.raw_source.contains("\"model//literal\"")); } + #[test] + fn exact_key_patch_preserves_unowned_entries_comments_and_root_fields() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write( + &path, + r#"{ + "theme": "native", + "providers": { + // user-owned + "native": {"models": [{"id": "native"}]}, + "managed": {"models": [{"id": "old"}]} + } +} +"#, + ) + .expect("write"); + let patch = IndexMap::from([( + "managed".to_string(), + Some(serde_json::json!({"models": [{"id": "new"}]})), + )]); + + apply_pi_provider_patch(&path, &patch).expect("patch"); + + let saved = fs::read_to_string(&path).expect("read"); + assert!(saved.contains("// user-owned")); + assert!(saved.contains("\"theme\": \"native\"")); + let document = read_pi_models_document(&path).expect("parse"); + assert_eq!( + document.providers()["native"].value["models"][0]["id"], + "native" + ); + assert_eq!( + document.providers()["managed"].value["models"][0]["id"], + "new" + ); + } + + #[test] + fn exact_key_delete_does_not_delete_same_content_sibling() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + fs::write( + &path, + r#"{"providers":{ + "managed":{"models":[{"id":"same"}]}, + "native":{"models":[{"id":"same"}]} +}}"#, + ) + .expect("write"); + let patch = IndexMap::from([("managed".to_string(), None)]); + + apply_pi_provider_patch(&path, &patch).expect("delete"); + + let document = read_pi_models_document(&path).expect("parse"); + assert!(!document.providers().contains_key("managed")); + assert!(document.providers().contains_key("native")); + } + #[test] fn parses_javascript_overflow_without_hiding_sibling_entries() { let document = parse_models_source( diff --git a/src-tauri/src/pi_config/gateway.rs b/src-tauri/src/pi_config/gateway.rs index a932177ef..48df99ebd 100644 --- a/src-tauri/src/pi_config/gateway.rs +++ b/src-tauri/src/pi_config/gateway.rs @@ -81,7 +81,7 @@ fn configured_header_class(name: &HeaderName) -> ConfiguredHeaderClass { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(super) enum PiGatewayApiFamily { +pub(crate) enum PiGatewayApiFamily { AnthropicMessages, OpenAiCompletions, OpenAiResponses, @@ -96,7 +96,7 @@ impl PiGatewayApiFamily { Self::GoogleGenerativeAi, ]; - pub(super) const fn as_str(self) -> &'static str { + pub(crate) const fn as_str(self) -> &'static str { match self { Self::AnthropicMessages => "anthropic-messages", Self::OpenAiCompletions => "openai-completions", @@ -113,14 +113,14 @@ impl PiGatewayApiFamily { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PiGatewayCapability { +pub(crate) enum PiGatewayCapability { Proxyable, DirectOnly, Unknown, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PiGatewayReasonCode { +pub(crate) enum PiGatewayReasonCode { UnsupportedFamily, UnsupportedCredentialKind, InvalidEndpoint, @@ -132,13 +132,13 @@ pub(super) enum PiGatewayReasonCode { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct PiGatewayReason { +pub(crate) struct PiGatewayReason { pub code: PiGatewayReasonCode, pub json_pointer: String, } #[derive(Debug, Clone)] -pub(super) struct PiGatewayAssessment { +pub(crate) struct PiGatewayAssessment { pub capability: PiGatewayCapability, pub reasons: Vec, pub plans: Vec, @@ -175,7 +175,7 @@ impl DeferredHeaderValue { } #[derive(Debug, Clone)] -pub(super) struct CandidateHeaderPlan { +pub(crate) struct CandidateHeaderPlan { family: PiGatewayApiFamily, endpoint: Url, credential: DeferredHeaderValue, @@ -194,7 +194,7 @@ struct PlannedHeader { } #[derive(Debug, Clone)] -pub(super) struct MaterializedCandidate { +pub(crate) struct MaterializedCandidate { pub endpoint: Url, pub headers: HeaderMap, family: PiGatewayApiFamily, @@ -202,7 +202,7 @@ pub(super) struct MaterializedCandidate { protocol_identity_predictable: bool, } -pub(super) trait DeferredValueResolver { +pub(crate) trait DeferredValueResolver { fn resolve(&self, expression: &str) -> Option; } @@ -219,6 +219,7 @@ impl CandidateHeaderPlan { fn build( model: &PiComposedNativeModel, model_index: usize, + allow_anthropic_oauth: bool, ) -> Result> { let mut reasons = Vec::new(); let Some(family) = PiGatewayApiFamily::parse(model.api.as_str()) else { @@ -256,6 +257,7 @@ impl CandidateHeaderPlan { }); } else if family == PiGatewayApiFamily::AnthropicMessages && is_anthropic_oauth_credential(credential) + && !allow_anthropic_oauth { reasons.push(PiGatewayReason { code: PiGatewayReasonCode::UnsupportedCredentialKind, @@ -303,6 +305,37 @@ impl CandidateHeaderPlan { pub(super) fn materialize( &self, resolver: &impl DeferredValueResolver, + ) -> Result { + self.materialize_with_policy(resolver, false) + } + + pub(crate) fn materialize_for_runtime( + &self, + resolver: &impl DeferredValueResolver, + ) -> Result { + self.materialize_with_policy(resolver, true) + } + + pub(crate) fn with_endpoint(&self, endpoint: &str) -> Result { + let endpoint = Url::parse(endpoint).map_err(|_| PiGatewayReason { + code: PiGatewayReasonCode::InvalidEndpoint, + json_pointer: "/customEndpoints".to_string(), + })?; + if !matches!(endpoint.scheme(), "http" | "https") || endpoint.host().is_none() { + return Err(PiGatewayReason { + code: PiGatewayReasonCode::InvalidEndpoint, + json_pointer: "/customEndpoints".to_string(), + }); + } + let mut candidate = self.clone(); + candidate.endpoint = endpoint; + Ok(candidate) + } + + fn materialize_with_policy( + &self, + resolver: &impl DeferredValueResolver, + allow_anthropic_oauth: bool, ) -> Result { // A new map is allocated for every candidate. No value from a prior // candidate can survive failover. @@ -311,6 +344,7 @@ impl CandidateHeaderPlan { let credential = self.credential.materialize(resolver, "/apiKey")?; if self.family == PiGatewayApiFamily::AnthropicMessages && credential.to_str().is_ok_and(is_anthropic_oauth_credential) + && !allow_anthropic_oauth { return Err(PiGatewayReason { code: PiGatewayReasonCode::UnsupportedCredentialKind, @@ -318,7 +352,23 @@ impl CandidateHeaderPlan { }); } let bearer_credential = credential.clone(); + let anthropic_oauth = self.family == PiGatewayApiFamily::AnthropicMessages + && credential.to_str().is_ok_and(is_anthropic_oauth_credential); let (auth_name, auth_value) = match self.family { + PiGatewayApiFamily::AnthropicMessages if anthropic_oauth => { + let credential = credential.to_str().map_err(|_| PiGatewayReason { + code: PiGatewayReasonCode::InvalidHeaderValue, + json_pointer: "/apiKey".to_string(), + })?; + let bearer = + HeaderValue::from_str(&format!("Bearer {credential}")).map_err(|_| { + PiGatewayReason { + code: PiGatewayReasonCode::InvalidHeaderValue, + json_pointer: "/apiKey".to_string(), + } + })?; + (HeaderName::from_static("authorization"), bearer) + } PiGatewayApiFamily::AnthropicMessages => { (HeaderName::from_static("x-api-key"), credential) } @@ -350,6 +400,15 @@ impl CandidateHeaderPlan { let value = HeaderValue::from_static("2023-06-01"); protocol_headers.insert(name.clone(), value.clone()); headers.insert(name, value); + let beta = if anthropic_oauth { + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14" + } else { + "interleaved-thinking-2025-05-14" + }; + let name = HeaderName::from_static("anthropic-beta"); + let value = HeaderValue::from_static(beta); + protocol_headers.insert(name.clone(), value.clone()); + headers.insert(name, value); } // Provider headers are part of provider auth resolution. Pinned SDKs @@ -386,6 +445,31 @@ impl CandidateHeaderPlan { &mut protocol_headers, )?; + // Main-project OAuth transport is a completed policy boundary, not a + // partial SDK-header overlay. A configured auth header may override + // synthesized auth for ordinary credentials (matching pinned Pi), but + // an Anthropic OAuth credential is always transported as that exact + // Bearer and never alongside x-api-key. + if anthropic_oauth { + headers.remove(HeaderName::from_static("x-api-key")); + let credential = bearer_credential.to_str().map_err(|_| PiGatewayReason { + code: PiGatewayReasonCode::InvalidHeaderValue, + json_pointer: "/apiKey".to_string(), + })?; + let bearer = HeaderValue::from_str(&format!("Bearer {credential}")).map_err(|_| { + PiGatewayReason { + code: PiGatewayReasonCode::InvalidHeaderValue, + json_pointer: "/apiKey".to_string(), + } + })?; + headers.insert(HeaderName::from_static("authorization"), bearer); + let beta = HeaderValue::from_static( + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14", + ); + headers.insert(HeaderName::from_static("anthropic-beta"), beta.clone()); + protocol_headers.insert(HeaderName::from_static("anthropic-beta"), beta); + } + let host = authority_header(&self.endpoint).ok_or_else(|| PiGatewayReason { code: PiGatewayReasonCode::InvalidEndpoint, json_pointer: "/baseUrl".to_string(), @@ -460,11 +544,33 @@ fn apply_planned_headers( } impl MaterializedCandidate { - pub(super) fn failover_protocol_identity(&self) -> Option<(PiGatewayApiFamily, &HeaderMap)> { + pub(crate) fn family(&self) -> PiGatewayApiFamily { + self.family + } + + pub(crate) fn failover_protocol_identity(&self) -> Option<(PiGatewayApiFamily, &HeaderMap)> { // Auth, tenant and arbitrary custom headers are deliberately excluded. self.protocol_identity_predictable .then_some((self.family, &self.protocol_headers)) } + + pub(crate) fn family_name(&self) -> &'static str { + self.family.as_str() + } +} + +impl CandidateHeaderPlan { + pub(crate) fn family(&self) -> PiGatewayApiFamily { + self.family + } + + pub(crate) fn protocol_identity_is_predictable(&self) -> bool { + self.protocol_identity_predictable + } + + pub(crate) fn endpoint(&self) -> &Url { + &self.endpoint + } } pub(super) fn assess_composition(composition: &PiNativeComposition) -> PiGatewayAssessment { @@ -478,7 +584,43 @@ pub(super) fn assess_composition(composition: &PiNativeComposition) -> PiGateway let mut plans = Vec::with_capacity(composition.models.len()); let mut reasons = Vec::new(); for (index, model) in composition.models.iter().enumerate() { - match CandidateHeaderPlan::build(model, index) { + match CandidateHeaderPlan::build(model, index, false) { + Ok(plan) => plans.push(plan), + Err(mut model_reasons) => reasons.append(&mut model_reasons), + } + } + if reasons.is_empty() && plans.len() == composition.models.len() { + PiGatewayAssessment { + capability: PiGatewayCapability::Proxyable, + reasons, + plans, + } + } else { + PiGatewayAssessment { + capability: PiGatewayCapability::DirectOnly, + reasons, + plans: Vec::new(), + } + } +} + +/// Main-project data plane assessment. The certified Pre-C assessment remains +/// unchanged and honestly reports Anthropic OAuth as DirectOnly; this entry +/// point becomes reachable only with the complete OAuth transport policy. +pub(crate) fn assess_composition_for_runtime( + composition: &PiNativeComposition, +) -> PiGatewayAssessment { + if composition.status != PiComposerStatus::Composed { + return PiGatewayAssessment { + capability: PiGatewayCapability::Unknown, + reasons: Vec::new(), + plans: Vec::new(), + }; + } + let mut plans = Vec::with_capacity(composition.models.len()); + let mut reasons = Vec::new(); + for (index, model) in composition.models.iter().enumerate() { + match CandidateHeaderPlan::build(model, index, true) { Ok(plan) => plans.push(plan), Err(mut model_reasons) => reasons.append(&mut model_reasons), } @@ -499,10 +641,14 @@ pub(super) fn assess_composition(composition: &PiNativeComposition) -> PiGateway } fn authority_header(url: &Url) -> Option { - let host = url.host_str()?; + let host = match url.host()? { + url::Host::Domain(value) => value.to_string(), + url::Host::Ipv4(value) => value.to_string(), + url::Host::Ipv6(value) => format!("[{value}]"), + }; let authority = match url.port() { Some(port) => format!("{host}:{port}"), - None => host.to_string(), + None => host, }; HeaderValue::from_str(&authority).ok() } @@ -941,6 +1087,63 @@ mod tests { } } + #[test] + fn candidate_host_header_preserves_ipv6_authority_brackets() { + let composition = composed(json!({ + "api": "openai-responses", + "baseUrl": "http://[::1]:8443/v1", + "apiKey": "secret", + "models": [{"id": "m"}] + })); + let materialized = assess_composition(&composition) + .plans + .remove(0) + .materialize(&|_expression: &str| None) + .expect("IPv6 endpoint"); + assert_eq!( + materialized.headers[&HeaderName::from_static("host")], + "[::1]:8443" + ); + } + + #[test] + fn completed_runtime_oauth_policy_forces_bearer_beta_and_no_x_api_key() { + let composition = composed(json!({ + "api": "anthropic-messages", + "baseUrl": "https://candidate.example", + "apiKey": "prefix-sk-ant-oat01-token-suffix", + "headers": { + "authorization": "Bearer configured", + "x-api-key": "configured-api-key", + "anthropic-beta": "configured-beta" + }, + "models": [{"id": "m"}] + })); + let certified = assess_composition(&composition); + assert_eq!(certified.capability, PiGatewayCapability::DirectOnly); + assert_eq!( + certified.reasons[0].code, + PiGatewayReasonCode::UnsupportedCredentialKind + ); + + let mut runtime = assess_composition_for_runtime(&composition); + assert_eq!(runtime.capability, PiGatewayCapability::Proxyable); + let materialized = runtime + .plans + .remove(0) + .materialize_for_runtime(&|_expression: &str| None) + .expect("the complete main-project OAuth policy is proxyable"); + assert_eq!( + materialized.headers[&HeaderName::from_static("authorization")], + "Bearer prefix-sk-ant-oat01-token-suffix" + ); + assert!(materialized.headers.get("x-api-key").is_none()); + assert_eq!( + materialized.headers[&HeaderName::from_static("anthropic-beta")], + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14" + ); + } + #[test] fn deferred_values_replay_actual_pinned_pi_transport_results() { let oracle: Value = diff --git a/src-tauri/src/pi_config/mod.rs b/src-tauri/src/pi_config/mod.rs index d8f0ac931..9d7ec41f7 100644 --- a/src-tauri/src/pi_config/mod.rs +++ b/src-tauri/src/pi_config/mod.rs @@ -7,14 +7,16 @@ use indexmap::IndexMap; use serde_json::{Map, Value}; -mod composer; -mod document; -mod gateway; +pub(crate) mod composer; +pub(crate) mod document; +pub(crate) mod gateway; pub(crate) mod model; pub(crate) mod native; #[cfg(test)] mod native_inspection_certification; -mod raw_schema; +pub(crate) mod native_settings; +pub(crate) mod raw_schema; +pub(crate) mod shared_file; const PI_COMPAT_NESTED_SPREAD_KEYS: [&str; 3] = [ "openRouterRouting", diff --git a/src-tauri/src/pi_config/native.rs b/src-tauri/src/pi_config/native.rs index 884ad09ba..7ab49dce1 100644 --- a/src-tauri/src/pi_config/native.rs +++ b/src-tauri/src/pi_config/native.rs @@ -156,6 +156,26 @@ pub(crate) fn inspect_pi_native_entry( PiNativeInspectionService::inspect_entry(path, provider_key, managed_claims) } +/// Compose a database-authoritative managed provider through the same raw and +/// composer layers used by native inspection. Runtime construction must not +/// reimplement inheritance or field semantics. +pub(crate) fn compose_managed_pi_provider( + provider_key: &str, + config: &PiManagedProviderConfig, +) -> Result { + validate_pi_managed_provider(config) + .map_err(|error| AppError::InvalidInput(error.to_string()))?; + let value = + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?; + let raw = evaluate_provider_value(&value); + let provider = raw.valid_provider.as_ref().ok_or_else(|| { + AppError::Config(format!( + "managed Pi provider '{provider_key}' did not pass the pinned raw schema" + )) + })?; + Ok(compose_explicit_custom_catalog(provider_key, provider)) +} + fn normalize_pi_agent_dir(value: &str, home: &Path) -> Result { if value == "~" { return Ok(home.to_path_buf()); @@ -180,7 +200,15 @@ fn normalize_pi_agent_dir(value: &str, home: &Path) -> Result Ok(PathBuf::from(value)) } -pub(crate) fn get_pi_agent_dir() -> Result { +pub(crate) fn get_pi_agent_dir_for_override( + override_dir: Option<&str>, +) -> Result { + if let Some(override_dir) = override_dir + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(crate::settings::resolve_override_path(override_dir)); + } let Some(raw) = std::env::var_os("PI_CODING_AGENT_DIR") else { return Ok(get_home_dir().join(".pi").join("agent")); }; @@ -190,6 +218,19 @@ pub(crate) fn get_pi_agent_dir() -> Result { normalize_pi_agent_dir(&raw.to_string_lossy(), &get_home_dir()) } +pub(crate) fn get_pi_agent_dir() -> Result { + if let Some(override_dir) = crate::settings::get_pi_override_dir() { + return Ok(override_dir); + } + get_pi_agent_dir_for_override(None) +} + +pub(crate) fn get_pi_models_path_for_override( + override_dir: Option<&str>, +) -> Result { + Ok(get_pi_agent_dir_for_override(override_dir)?.join("models.json")) +} + pub(crate) fn get_pi_models_path() -> Result { Ok(get_pi_agent_dir()?.join("models.json")) } diff --git a/src-tauri/src/pi_config/native_settings.rs b/src-tauri/src/pi_config/native_settings.rs new file mode 100644 index 000000000..f5edc3b09 --- /dev/null +++ b/src-tauri/src/pi_config/native_settings.rs @@ -0,0 +1,300 @@ +//! Exact-field access to Pi's shared `settings.json`. +//! +//! cc-switch owns only `defaultProvider` and `defaultModel`. Every other field +//! remains Pi/user-owned and survives each mutation unchanged. + +use crate::error::AppError; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::fs::{self, File, Metadata, OpenOptions}; +use std::io::{Read, Take}; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; + +const MAX_PI_SETTINGS_BYTES: u64 = 1024 * 1024; +const MAX_WRITE_ATTEMPTS: usize = 3; +static SETTINGS_WRITE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiNativeDefaults { + #[serde(skip_serializing_if = "Option::is_none")] + pub default_provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_dir: Option, +} + +pub(crate) fn get_pi_settings_path() -> Result { + Ok(super::native::get_pi_agent_dir()?.join("settings.json")) +} + +pub(crate) fn read_pi_native_defaults() -> Result { + read_pi_native_defaults_at(&get_pi_settings_path()?) +} + +pub(crate) fn read_pi_native_defaults_at(path: &Path) -> Result { + let document = read_settings_document(path)?; + let root = document.as_object().ok_or_else(|| { + AppError::Config(format!( + "Pi settings root must be an object: {}", + path.display() + )) + })?; + Ok(PiNativeDefaults { + default_provider: optional_string(root, "defaultProvider", path)?, + default_model: optional_string(root, "defaultModel", path)?, + session_dir: optional_string(root, "sessionDir", path)?, + }) +} + +pub(crate) fn set_pi_native_default( + provider_key: &str, + model_id: &str, +) -> Result { + if provider_key.trim().is_empty() || model_id.trim().is_empty() { + return Err(AppError::InvalidInput( + "Pi default provider and model must be non-empty".to_string(), + )); + } + mutate_settings_document(&get_pi_settings_path()?, |root| { + root.insert( + "defaultProvider".to_string(), + Value::String(provider_key.to_string()), + ); + root.insert( + "defaultModel".to_string(), + Value::String(model_id.to_string()), + ); + Ok(()) + })?; + read_pi_native_defaults() +} + +/// Restore the two fields owned by cc-switch without touching Pi-owned +/// settings. This is intentionally narrower than replacing settings.json and +/// is used by catalog compensation after a later authority step fails. +pub(crate) fn replace_pi_native_defaults( + defaults: &PiNativeDefaults, +) -> Result { + mutate_settings_document(&get_pi_settings_path()?, |root| { + set_optional_string( + root, + "defaultProvider", + defaults.default_provider.as_deref(), + ); + set_optional_string(root, "defaultModel", defaults.default_model.as_deref()); + Ok(()) + })?; + read_pi_native_defaults() +} + +fn set_optional_string(root: &mut Map, key: &str, value: Option<&str>) { + match value { + Some(value) => { + root.insert(key.to_string(), Value::String(value.to_string())); + } + None => { + root.remove(key); + } + } +} + +fn optional_string( + root: &Map, + key: &str, + path: &Path, +) -> Result, AppError> { + match root.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(AppError::Config(format!( + "Pi settings field '{key}' must be a string: {}", + path.display() + ))), + } +} + +fn mutate_settings_document( + path: &Path, + mut mutator: impl FnMut(&mut Map) -> Result<(), AppError>, +) -> Result<(), AppError> { + let _guard = SETTINGS_WRITE_LOCK + .lock() + .map_err(|error| AppError::Config(format!("Pi settings lock is poisoned: {error}")))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?; + } + + for _ in 0..MAX_WRITE_ATTEMPTS { + let before = read_regular_bytes(path, MAX_PI_SETTINGS_BYTES)?; + let observed = fingerprint(before.as_deref()); + let mut document = match before.as_deref() { + Some(bytes) => { + serde_json::from_slice(bytes).map_err(|error| AppError::json(path, error))? + } + None => Value::Object(Map::new()), + }; + let root = document.as_object_mut().ok_or_else(|| { + AppError::Config(format!( + "Pi settings root must be an object: {}", + path.display() + )) + })?; + mutator(root)?; + let mut serialized = serde_json::to_vec_pretty(&document) + .map_err(|source| AppError::JsonSerialize { source })?; + serialized.push(b'\n'); + + let current = read_regular_bytes(path, MAX_PI_SETTINGS_BYTES)?; + if fingerprint(current.as_deref()) != observed { + continue; + } + crate::config::atomic_write(path, &serialized)?; + return Ok(()); + } + + Err(AppError::Conflict(format!( + "Pi settings changed concurrently too many times: {}", + path.display() + ))) +} + +fn read_settings_document(path: &Path) -> Result { + match read_regular_bytes(path, MAX_PI_SETTINGS_BYTES)? { + Some(bytes) => serde_json::from_slice(&bytes).map_err(|error| AppError::json(path, error)), + None => Ok(Value::Object(Map::new())), + } +} + +fn fingerprint(bytes: Option<&[u8]>) -> Option<[u8; 32]> { + bytes.map(|bytes| Sha256::digest(bytes).into()) +} + +#[cfg(unix)] +fn open_read_only(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) +} + +#[cfg(not(unix))] +fn open_read_only(path: &Path) -> std::io::Result { + OpenOptions::new().read(true).open(path) +} + +#[cfg(unix)] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + left.len() == right.len() && left.modified().ok() == right.modified().ok() +} + +fn read_limited( + mut reader: Take<&mut File>, + path: &Path, + max_bytes: u64, +) -> Result, AppError> { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|error| AppError::io(path, error))?; + if bytes.len() as u64 > max_bytes { + return Err(AppError::Config(format!( + "Pi settings exceeds {max_bytes} bytes: {}", + path.display() + ))); + } + Ok(bytes) +} + +fn read_regular_bytes(path: &Path, max_bytes: u64) -> Result>, AppError> { + let initial = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(AppError::io(path, error)), + }; + if !initial.file_type().is_file() || initial.len() > max_bytes { + return Err(AppError::Config(format!( + "Pi settings must be a bounded regular file: {}", + path.display() + ))); + } + let mut file = open_read_only(path).map_err(|error| AppError::io(path, error))?; + let opened = file.metadata().map_err(|error| AppError::io(path, error))?; + let bytes = read_limited(file.by_ref().take(max_bytes + 1), path, max_bytes)?; + let completed = file.metadata().map_err(|error| AppError::io(path, error))?; + let current = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; + if !current.file_type().is_file() + || !same_file(&opened, &completed) + || !same_file(&completed, ¤t) + || opened.len() != bytes.len() as u64 + || completed.len() != bytes.len() as u64 + || current.len() != bytes.len() as u64 + || opened.modified().ok() != completed.modified().ok() + { + return Err(AppError::Conflict(format!( + "Pi settings changed during read: {}", + path.display() + ))); + } + Ok(Some(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn default_patch_preserves_every_unowned_field() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("settings.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "theme": "custom", + "packages": ["npm:foreign"], + "sessionDir": "/tmp/pi-sessions", + "defaultProvider": "old", + "defaultModel": "old-model" + })) + .expect("serialize"), + ) + .expect("write"); + + mutate_settings_document(&path, |root| { + root.insert("defaultProvider".into(), json!("managed")); + root.insert("defaultModel".into(), json!("model")); + Ok(()) + }) + .expect("mutate"); + + let saved: Value = serde_json::from_slice(&fs::read(&path).expect("read")).expect("parse"); + assert_eq!(saved["theme"], "custom"); + assert_eq!(saved["packages"], json!(["npm:foreign"])); + assert_eq!(saved["sessionDir"], "/tmp/pi-sessions"); + assert_eq!(saved["defaultProvider"], "managed"); + assert_eq!(saved["defaultModel"], "model"); + } + + #[cfg(unix)] + #[test] + fn settings_symlink_is_rejected() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("target.json"); + let path = temp.path().join("settings.json"); + fs::write(&target, "{}").expect("target"); + symlink(&target, &path).expect("symlink"); + assert!(read_pi_native_defaults_at(&path).is_err()); + } +} diff --git a/src-tauri/src/pi_config/shared_file.rs b/src-tauri/src/pi_config/shared_file.rs new file mode 100644 index 000000000..56ccde3f5 --- /dev/null +++ b/src-tauri/src/pi_config/shared_file.rs @@ -0,0 +1,232 @@ +//! Reusable safety boundary for exact Pi-owned/shared files. +//! +//! Callers choose an exact path and size limit. This layer supplies bounded +//! regular-file reads, symlink rejection, optimistic revisions, per-path +//! process locking, durable atomic replacement, and compare-before-delete. + +use crate::error::AppError; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs::{self, File, Metadata, OpenOptions}; +use std::io::{Read, Take}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; + +static FILE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SharedFileSnapshot { + pub revision: String, + pub bytes: Option>, +} + +impl SharedFileSnapshot { + pub(crate) fn exists(&self) -> bool { + self.bytes.is_some() + } +} + +pub(crate) fn read_shared_file( + path: &Path, + max_bytes: u64, + label: &str, +) -> Result { + let bytes = read_regular_bytes(path, max_bytes, label)?; + Ok(SharedFileSnapshot { + revision: revision(bytes.as_deref()), + bytes, + }) +} + +pub(crate) fn replace_shared_file( + path: &Path, + expected_revision: &str, + bytes: &[u8], + max_bytes: u64, + new_file_mode: Option, + label: &str, +) -> Result { + if bytes.len() as u64 > max_bytes { + return Err(AppError::InvalidInput(format!( + "{label} exceeds the {max_bytes}-byte limit" + ))); + } + let lock = path_lock(path)?; + let _guard = lock + .lock() + .map_err(|error| AppError::Config(format!("Pi file lock is poisoned: {error}")))?; + let current = read_shared_file(path, max_bytes, label)?; + ensure_revision(path, expected_revision, ¤t.revision)?; + crate::config::atomic_write_durable(path, bytes, new_file_mode)?; + read_shared_file(path, max_bytes, label) +} + +pub(crate) fn delete_shared_file( + path: &Path, + expected_revision: &str, + max_bytes: u64, + label: &str, +) -> Result { + let lock = path_lock(path)?; + let _guard = lock + .lock() + .map_err(|error| AppError::Config(format!("Pi file lock is poisoned: {error}")))?; + let current = read_shared_file(path, max_bytes, label)?; + ensure_revision(path, expected_revision, ¤t.revision)?; + if !current.exists() { + return Ok(false); + } + fs::remove_file(path).map_err(|error| AppError::io(path, error))?; + #[cfg(unix)] + if let Some(parent) = path.parent() { + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| AppError::io(parent, error))?; + } + Ok(true) +} + +fn ensure_revision(path: &Path, expected: &str, actual: &str) -> Result<(), AppError> { + if expected == actual { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "Pi file changed since it was read: {}", + path.display() + ))) + } +} + +fn path_lock(path: &Path) -> Result>, AppError> { + let mut locks = FILE_LOCKS + .lock() + .map_err(|error| AppError::Config(format!("Pi file-lock registry is poisoned: {error}")))?; + Ok(locks + .entry(path.to_path_buf()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone()) +} + +fn revision(bytes: Option<&[u8]>) -> String { + bytes.map_or_else( + || "missing".to_string(), + |bytes| format!("sha256:{:x}", Sha256::digest(bytes)), + ) +} + +#[cfg(unix)] +fn open_read_only(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) +} + +#[cfg(not(unix))] +fn open_read_only(path: &Path) -> std::io::Result { + OpenOptions::new().read(true).open(path) +} + +#[cfg(unix)] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() +} + +#[cfg(not(unix))] +fn same_file(left: &Metadata, right: &Metadata) -> bool { + left.len() == right.len() && left.modified().ok() == right.modified().ok() +} + +fn read_limited( + mut reader: Take<&mut File>, + path: &Path, + max_bytes: u64, +) -> Result, AppError> { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .map_err(|error| AppError::io(path, error))?; + if bytes.len() as u64 > max_bytes { + return Err(AppError::InvalidInput(format!( + "Pi file exceeds the {max_bytes}-byte limit: {}", + path.display() + ))); + } + Ok(bytes) +} + +fn read_regular_bytes( + path: &Path, + max_bytes: u64, + label: &str, +) -> Result>, AppError> { + let initial = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(AppError::io(path, error)), + }; + if !initial.file_type().is_file() || initial.len() > max_bytes { + return Err(AppError::InvalidInput(format!( + "{label} must be a bounded regular file: {}", + path.display() + ))); + } + let mut file = open_read_only(path).map_err(|error| AppError::io(path, error))?; + let opened = file.metadata().map_err(|error| AppError::io(path, error))?; + let bytes = read_limited(file.by_ref().take(max_bytes + 1), path, max_bytes)?; + let completed = file.metadata().map_err(|error| AppError::io(path, error))?; + let current = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?; + if !current.file_type().is_file() + || !same_file(&opened, &completed) + || !same_file(&completed, ¤t) + || opened.len() != bytes.len() as u64 + || completed.len() != bytes.len() as u64 + || current.len() != bytes.len() as u64 + || opened.modified().ok() != completed.modified().ok() + { + return Err(AppError::Conflict(format!( + "{label} changed during read: {}", + path.display() + ))); + } + Ok(Some(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compare_and_replace_distinguishes_missing_and_content_revisions() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("shared.md"); + let missing = read_shared_file(&path, 1024, "test").expect("missing"); + assert_eq!(missing.revision, "missing"); + let written = replace_shared_file(&path, "missing", b"one", 1024, Some(0o600), "test") + .expect("create"); + assert!(written.revision.starts_with("sha256:")); + assert!(replace_shared_file(&path, "missing", b"two", 1024, None, "test").is_err()); + let replaced = replace_shared_file(&path, &written.revision, b"two", 1024, None, "test") + .expect("replace"); + assert_eq!(replaced.bytes.as_deref(), Some(b"two".as_slice())); + assert!(delete_shared_file(&path, &written.revision, 1024, "test").is_err()); + assert!(delete_shared_file(&path, &replaced.revision, 1024, "test").expect("delete")); + } + + #[cfg(unix)] + #[test] + fn symlink_targets_fail_closed() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("target"); + let path = temp.path().join("shared"); + fs::write(&target, b"secret").expect("target"); + symlink(&target, &path).expect("symlink"); + assert!(read_shared_file(&path, 1024, "test").is_err()); + assert!(replace_shared_file(&path, "missing", b"overwrite", 1024, None, "test").is_err()); + assert_eq!(fs::read(&target).expect("target remains"), b"secret"); + } +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 6d4a44aae..aab207e23 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -26,6 +26,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::OpenCode => get_opencode_dir(), AppType::OpenClaw => get_openclaw_dir(), AppType::Hermes => crate::hermes_config::get_hermes_dir(), + AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?, AppType::ClaudeDesktop => unreachable!("handled above"), }; @@ -33,7 +34,11 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Claude => "CLAUDE.md", AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", - AppType::GrokBuild | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md", + AppType::GrokBuild + | AppType::OpenCode + | AppType::OpenClaw + | AppType::Hermes + | AppType::Pi => "AGENTS.md", AppType::ClaudeDesktop => unreachable!("handled above"), }; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 71e006169..f52c8bfbf 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -335,6 +335,10 @@ impl Provider { str_at(settings.get("base_url")), str_at(settings.get("api_key")), ), + AppType::Pi => ( + str_at(settings.get("baseUrl")), + str_at(settings.get("apiKey")), + ), // OpenClaw (openclaw.json) flattens credentials at the top level, camelCase. AppType::OpenClaw => ( str_at(settings.get("baseUrl")), diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs index 2e3855a67..b5d2c0ad5 100644 --- a/src-tauri/src/proxy/handler_config.rs +++ b/src-tauri/src/proxy/handler_config.rs @@ -4,6 +4,7 @@ use crate::app_config::AppType; use crate::proxy::usage::parser::TokenUsage; +use crate::proxy::usage::InputTokenSemantics; use serde_json::Value; /// 使用量解析器类型别名 @@ -31,6 +32,8 @@ pub struct UsageParserConfig { pub model_extractor: StreamModelExtractor, /// 流式 usage 事件预过滤器 pub stream_event_filter: Option, + /// Semantics of `TokenUsage::input_tokens` produced by these parsers. + pub input_token_semantics: InputTokenSemantics, /// 应用类型字符串(用于日志记录) pub app_type_str: &'static str, } @@ -141,6 +144,7 @@ pub const CLAUDE_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { response_parser: TokenUsage::from_claude_response, model_extractor: claude_model_extractor, stream_event_filter: Some(claude_stream_usage_event_filter), + input_token_semantics: InputTokenSemantics::FreshExcludesCache, app_type_str: "claude", }; @@ -150,6 +154,7 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { response_parser: TokenUsage::from_openai_response, model_extractor: openai_model_extractor, stream_event_filter: Some(openai_stream_usage_event_filter), + input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets, app_type_str: "codex", }; @@ -159,6 +164,7 @@ pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { response_parser: TokenUsage::from_codex_response_auto, model_extractor: codex_auto_model_extractor, stream_event_filter: Some(codex_stream_usage_event_filter), + input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets, app_type_str: "codex", }; @@ -168,6 +174,7 @@ pub const GEMINI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { response_parser: TokenUsage::from_gemini_response, model_extractor: gemini_model_extractor, stream_event_filter: Some(gemini_stream_usage_event_filter), + input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets, app_type_str: "gemini", }; diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index bb2606486..8981994c7 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -39,7 +39,7 @@ use super::{ server::ProxyState, sse::{strip_sse_field, take_sse_block}, types::*, - usage::parser::TokenUsage, + usage::{parser::TokenUsage, InputTokenSemantics}, ProxyError, }; use crate::app_config::AppType; @@ -338,6 +338,7 @@ async fn write_claude_usage_log(state: &ProxyState, log: ClaudeUsageLog) { &log.model, &log.request_model, &log.outbound_model, + InputTokenSemantics::FreshExcludesCache, log.usage, log.latency_ms, None, @@ -465,6 +466,7 @@ async fn handle_claude_transform( &model, &request_model, &outbound_model, + InputTokenSemantics::FreshExcludesCache, usage, latency_ms, first_token_ms, @@ -1133,6 +1135,7 @@ async fn handle_codex_responses_namespace_restore( &model, &request_model, &outbound_model, + InputTokenSemantics::TotalIncludesCacheBuckets, usage, latency_ms, None, @@ -1245,6 +1248,7 @@ async fn handle_codex_chat_to_responses_transform( &model, &request_model, &outbound_model, + InputTokenSemantics::TotalIncludesCacheBuckets, usage, latency_ms, first_token_ms, @@ -1366,6 +1370,7 @@ async fn handle_codex_chat_to_responses_transform( &model, &request_model, &outbound_model, + InputTokenSemantics::TotalIncludesCacheBuckets, usage, latency_ms, None, @@ -1531,6 +1536,7 @@ async fn handle_codex_anthropic_to_responses_transform( &model, &request_model, &outbound_model, + InputTokenSemantics::TotalIncludesCacheBuckets, usage, latency_ms, None, @@ -1618,6 +1624,7 @@ fn build_codex_anthropic_sse_response( &model, &request_model, &outbound_model, + InputTokenSemantics::TotalIncludesCacheBuckets, usage, latency_ms, first_token_ms, @@ -2590,6 +2597,7 @@ fn log_forward_error( is_streaming, Some(ctx.session_id.clone()), None, + InputTokenSemantics::FreshExcludesCache, ) { log::warn!("记录失败请求日志失败: {e}"); } @@ -2607,6 +2615,7 @@ async fn log_usage( model: &str, request_model: &str, outbound_model: &str, + input_token_semantics: InputTokenSemantics, usage: TokenUsage, latency_ms: u64, first_token_ms: Option, @@ -2640,6 +2649,7 @@ async fn log_usage( model.to_string(), request_model.to_string(), pricing_model.to_string(), + input_token_semantics, usage, multiplier, latency_ms, diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index d1dc85808..1e4de3033 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -21,6 +21,8 @@ pub(crate) mod json_canonical; pub mod log_codes; pub mod media_sanitizer; pub mod model_mapper; +pub(crate) mod pi_handler; +pub(crate) mod pi_runtime; pub mod provider_router; pub mod providers; pub mod response_processor; diff --git a/src-tauri/src/proxy/pi_handler.rs b/src-tauri/src/proxy/pi_handler.rs new file mode 100644 index 000000000..3797ddc04 --- /dev/null +++ b/src-tauri/src/proxy/pi_handler.rs @@ -0,0 +1,1323 @@ +//! Native Pi gateway transport. +//! +//! Pi's SDK has already serialized the request before it reaches this route. +//! The handler therefore preserves method, path, query, and body bytes; it only +//! replaces gateway/client transport headers with candidate-local material +//! and selects a wire-compatible failover target from one immutable lease. + +use super::pi_runtime::{ + infer_family, PiMaterializedAttempt, PiRequestCandidate, PiRuntimeSnapshot, +}; +use super::server::ProxyState; +use super::usage::{InputTokenSemantics, TokenUsage, UsageLogger}; +use super::ProxyError; +use crate::database::PRICING_SOURCE_REQUEST; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::response::Response; +use bytes::Bytes; +use futures::{stream::BoxStream, StreamExt}; +use http::header::{ + AUTHORIZATION, CONNECTION, CONTENT_LENGTH, HOST, PROXY_AUTHENTICATE, PROXY_AUTHORIZATION, TE, + TRAILER, TRANSFER_ENCODING, UPGRADE, +}; +use http::{HeaderMap, StatusCode}; +use http_body_util::BodyExt; +use serde_json::Value; +use std::time::{Duration, Instant}; + +const USAGE_CAPTURE_LIMIT: usize = 4 * 1024 * 1024; +const SSE_PREFLIGHT_LIMIT: usize = 1024 * 1024; + +pub(crate) async fn handle_pi_native( + State(state): State, + Path((route_token, wildcard_path)): Path<(String, String)>, + request: axum::extract::Request, +) -> Result { + let forwarded_path = format!("/{}", wildcard_path.trim_start_matches('/')); + let family = infer_family(&forwarded_path).ok_or_else(|| { + ProxyError::InvalidRequest(format!( + "unsupported Pi native gateway path: {forwarded_path}" + )) + })?; + let snapshot = state + .pi_runtime + .lease(state.pi_server_generation) + .ok_or(ProxyError::NoAvailableProvider)?; + authenticate_gateway(&snapshot, family, request.headers())?; + + let (parts, body) = request.into_parts(); + let method = parts.method; + let uri = parts.uri; + let path_and_query = uri.query().map_or_else( + || forwarded_path.clone(), + |query| format!("{forwarded_path}?{query}"), + ); + let incoming_headers = parts.headers; + let body = body + .collect() + .await + .map_err(|error| ProxyError::InvalidRequest(format!("failed to read Pi request: {error}")))? + .to_bytes(); + let request_json = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice::(&body).map_err(|error| { + ProxyError::InvalidRequest(format!("Pi request body is not valid JSON: {error}")) + })? + }; + let model_id = request_model(family, &forwarded_path, &request_json)?; + let route = snapshot + .route(&route_token, family, &model_id) + .map_err(|error| ProxyError::ConfigError(error.to_string()))?; + let admission = state + .pi_runtime + .admission_guard(state.pi_server_generation, &snapshot) + .await + .ok_or(ProxyError::NoAvailableProvider)?; + drop(admission); + + let is_streaming = request_is_streaming(&uri, &incoming_headers, &request_json); + let max_attempts = (route.app_config.max_retries as usize) + .saturating_add(1) + .min(route.candidates.len()); + let attempts = route + .candidates + .into_iter() + .take(max_attempts) + .collect::>(); + let request_headers = filtered_incoming_headers(&incoming_headers); + let started = Instant::now(); + let session_id = + crate::proxy::extract_session_id(&incoming_headers, &request_json, "pi").session_id; + let mut protocol_anchor = ProtocolAnchor::Unset; + let mut last_error = None; + record_request_start(&state).await; + + let mut index = 0; + while index < attempts.len() { + let provider_id = attempts[index].provider_id.clone(); + let provider_end = attempts[index..] + .iter() + .position(|candidate| candidate.provider_id != provider_id) + .map_or(attempts.len(), |offset| index + offset); + let permit = state + .provider_router + .allow_provider_request(&provider_id, "pi") + .await; + if !permit.allowed { + last_error = Some(format!("Pi provider '{provider_id}' circuit is open")); + index = provider_end; + continue; + } + + let mut saw_health_failure = false; + let mut reached_network = false; + for (offset, candidate) in attempts[index..provider_end].iter().cloned().enumerate() { + let attempt_index = index + offset; + let materialized = match materialize_candidate(candidate, path_and_query.clone()).await + { + Ok(candidate) => candidate, + Err(error) => { + last_error = Some(error.to_string()); + continue; + } + }; + let protocol_identity = materialized + .transport + .failover_protocol_identity() + .map(|(family, headers)| (family.as_str().to_string(), headers.clone())); + if !protocol_identity_allows_attempt( + &mut protocol_anchor, + protocol_identity, + materialized.is_failover, + ) { + continue; + } + + let outgoing_headers = + merge_candidate_headers(&request_headers, &materialized.transport.headers); + let timeout_seconds = if is_streaming { + route.app_config.streaming_first_byte_timeout + } else { + route.app_config.non_streaming_timeout + }; + let send = crate::proxy::http_client::get() + .request(method.clone(), materialized.url.clone()) + .headers(outgoing_headers) + .body(body.clone()) + .send(); + reached_network = true; + let response = match if timeout_seconds > 0 { + tokio::time::timeout(Duration::from_secs(u64::from(timeout_seconds)), send) + .await + .map_err(|_| ()) + } else { + Ok(send.await) + } { + Ok(Ok(response)) => response, + Ok(Err(error)) => { + saw_health_failure = true; + last_error = Some(if error.is_timeout() { + "Pi upstream request timed out".to_string() + } else { + "Pi upstream request failed before response".to_string() + }); + continue; + } + Err(()) => { + saw_health_failure = true; + last_error = Some("Pi upstream response-header timeout".to_string()); + continue; + } + }; + + let status = response.status(); + let is_last_attempt = attempt_index + 1 == attempts.len(); + if retryable_status(status) && !is_last_attempt { + saw_health_failure = true; + last_error = Some(format!("Pi upstream returned retryable status {status}")); + continue; + } + let selected_is_failover = materialized.is_failover; + match prepare_response( + state.clone(), + response, + materialized, + route.catalog_epoch, + model_id.clone(), + session_id.clone(), + started, + is_streaming, + route.app_config.streaming_first_byte_timeout, + route.app_config.streaming_idle_timeout, + route.app_config.non_streaming_timeout, + permit.used_half_open_permit, + selected_is_failover, + ) + .await + { + Ok(prepared) => { + if !prepared.finalization_deferred { + let health_success = !retryable_status(status); + record_provider_result( + &state, + route.catalog_epoch, + &provider_id, + permit.used_half_open_permit, + health_success, + (!health_success).then(|| format!("Pi upstream returned {status}")), + ) + .await; + record_request_finish( + &state, + status.is_success(), + selected_is_failover, + (!status.is_success()) + .then(|| format!("Pi upstream returned {status}")), + ) + .await; + } + return Ok(prepared.response); + } + Err(ProxyError::ForwardFailed(error)) | Err(ProxyError::Timeout(error)) => { + saw_health_failure = true; + last_error = Some(error); + continue; + } + Err(error) => { + release_or_record_provider( + &state, + route.catalog_epoch, + &provider_id, + permit.used_half_open_permit, + reached_network, + saw_health_failure, + Some(error.to_string()), + ) + .await; + record_request_finish(&state, false, false, Some(error.to_string())).await; + return Err(error); + } + } + } + + release_or_record_provider( + &state, + route.catalog_epoch, + &provider_id, + permit.used_half_open_permit, + reached_network, + saw_health_failure, + last_error.clone(), + ) + .await; + index = provider_end; + } + + let error = + last_error.unwrap_or_else(|| "no wire-compatible Pi candidate was available".to_string()); + record_request_finish(&state, false, false, Some(error.clone())).await; + Err(ProxyError::ForwardFailed(error)) +} + +#[derive(Debug)] +enum ProtocolAnchor { + Unset, + Predictable((String, HeaderMap)), + Ineligible, +} + +fn protocol_identity_allows_attempt( + anchor: &mut ProtocolAnchor, + candidate: Option<(String, HeaderMap)>, + is_failover: bool, +) -> bool { + match (&*anchor, candidate) { + (ProtocolAnchor::Unset, _) if is_failover => false, + (ProtocolAnchor::Unset, Some(candidate)) => { + *anchor = ProtocolAnchor::Predictable(candidate); + true + } + (ProtocolAnchor::Unset, None) => { + // A protocol !command may be used for the first direct attempt, + // but its result cannot safely authorize endpoint or provider + // replay because the command is evaluated again per attempt. + *anchor = ProtocolAnchor::Ineligible; + true + } + (ProtocolAnchor::Predictable(primary), Some(candidate)) => primary == &candidate, + (ProtocolAnchor::Predictable(_), None) | (ProtocolAnchor::Ineligible, _) => false, + } +} + +async fn materialize_candidate( + candidate: PiRequestCandidate, + path_and_query: String, +) -> Result { + tokio::task::spawn_blocking(move || candidate.materialize(&path_and_query)) + .await + .map_err(|error| { + ProxyError::Internal(format!("Pi candidate materialization task failed: {error}")) + })? + .map_err(|error| ProxyError::ConfigError(error.to_string())) +} + +async fn record_provider_result( + state: &ProxyState, + catalog_epoch: u64, + provider_id: &str, + used_half_open_permit: bool, + success: bool, + error: Option, +) { + let Some(_guard) = state.pi_runtime.writeback_guard(catalog_epoch).await else { + state + .provider_router + .release_permit_neutral(provider_id, "pi", used_half_open_permit) + .await; + return; + }; + if let Err(record_error) = state + .provider_router + .record_result(provider_id, "pi", used_half_open_permit, success, error) + .await + { + log::warn!("failed to update Pi provider health: {record_error}"); + } +} + +#[allow(clippy::too_many_arguments)] +async fn release_or_record_provider( + state: &ProxyState, + catalog_epoch: u64, + provider_id: &str, + used_half_open_permit: bool, + reached_network: bool, + saw_health_failure: bool, + error: Option, +) { + if reached_network && saw_health_failure { + record_provider_result( + state, + catalog_epoch, + provider_id, + used_half_open_permit, + false, + error, + ) + .await; + } else { + state + .provider_router + .release_permit_neutral(provider_id, "pi", used_half_open_permit) + .await; + } +} + +async fn record_request_start(state: &ProxyState) { + let mut status = state.status.write().await; + status.total_requests = status.total_requests.saturating_add(1); + status.last_request_at = Some(chrono::Utc::now().to_rfc3339()); +} + +async fn record_request_finish( + state: &ProxyState, + success: bool, + used_failover: bool, + error: Option, +) { + let mut status = state.status.write().await; + if success { + status.success_requests = status.success_requests.saturating_add(1); + status.last_error = None; + } else { + status.failed_requests = status.failed_requests.saturating_add(1); + status.last_error = error; + } + if used_failover { + status.failover_count = status.failover_count.saturating_add(1); + } + if status.total_requests > 0 { + status.success_rate = + (status.success_requests as f32 / status.total_requests as f32) * 100.0; + } +} + +fn authenticate_gateway( + snapshot: &PiRuntimeSnapshot, + family: crate::pi_config::gateway::PiGatewayApiFamily, + headers: &HeaderMap, +) -> Result<(), ProxyError> { + let value = match family { + crate::pi_config::gateway::PiGatewayApiFamily::AnthropicMessages => headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + crate::pi_config::gateway::PiGatewayApiFamily::GoogleGenerativeAi => headers + .get("x-goog-api-key") + .and_then(|value| value.to_str().ok()), + crate::pi_config::gateway::PiGatewayApiFamily::OpenAiCompletions + | crate::pi_config::gateway::PiGatewayApiFamily::OpenAiResponses => headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")), + } + .ok_or_else(|| ProxyError::AuthError("missing Pi gateway credential".to_string()))?; + if !snapshot.token_matches(value) { + return Err(ProxyError::AuthError( + "invalid Pi gateway credential".to_string(), + )); + } + Ok(()) +} + +fn request_model( + family: crate::pi_config::gateway::PiGatewayApiFamily, + path: &str, + body: &Value, +) -> Result { + if family == crate::pi_config::gateway::PiGatewayApiFamily::GoogleGenerativeAi { + let encoded = path + .strip_prefix("/models/") + .and_then(|rest| rest.split(':').next()) + .filter(|model| !model.is_empty()) + .ok_or_else(|| { + ProxyError::InvalidRequest("Pi Google request has no model in its path".to_string()) + })?; + return percent_decode(encoded).ok_or_else(|| { + ProxyError::InvalidRequest("Pi Google model path has invalid escaping".to_string()) + }); + } + body.get("model") + .and_then(Value::as_str) + .filter(|model| !model.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + ProxyError::InvalidRequest("Pi request body has no model identifier".to_string()) + }) +} + +fn percent_decode(value: &str) -> Option { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + decoded.push(bytes[index]); + index += 1; + continue; + } + let high = *bytes.get(index + 1)?; + let low = *bytes.get(index + 2)?; + decoded.push(hex(high)? << 4 | hex(low)?); + index += 3; + } + String::from_utf8(decoded).ok() +} + +fn hex(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn request_is_streaming(uri: &http::Uri, headers: &HeaderMap, body: &Value) -> bool { + body.get("stream").and_then(Value::as_bool).unwrap_or(false) + || uri + .query() + .is_some_and(|query| query.split('&').any(|part| part == "alt=sse")) + || headers + .get(http::header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("text/event-stream")) +} + +fn filtered_incoming_headers(headers: &HeaderMap) -> HeaderMap { + let mut filtered = HeaderMap::new(); + for (name, value) in headers { + let lower = name.as_str(); + if matches!( + *name, + HOST | CONTENT_LENGTH + | CONNECTION + | TRANSFER_ENCODING + | TE + | TRAILER + | UPGRADE + | AUTHORIZATION + | PROXY_AUTHENTICATE + | PROXY_AUTHORIZATION + ) || matches!( + lower, + "x-api-key" + | "x-goog-api-key" + | "anthropic-version" + | "anthropic-beta" + | "openai-beta" + | "openai-version" + | "forwarded" + | "x-forwarded-for" + | "x-forwarded-host" + | "x-forwarded-port" + | "x-forwarded-proto" + | "x-real-ip" + ) || lower.starts_with("proxy-") + { + continue; + } + filtered.append(name.clone(), value.clone()); + } + filtered +} + +fn merge_candidate_headers(incoming: &HeaderMap, candidate: &HeaderMap) -> HeaderMap { + let mut merged = incoming.clone(); + for (name, value) in candidate { + merged.insert(name.clone(), value.clone()); + } + merged.remove(CONTENT_LENGTH); + merged.remove(TRANSFER_ENCODING); + merged +} + +fn retryable_status(status: StatusCode) -> bool { + if !status.is_client_error() && !status.is_server_error() { + return false; + } + !matches!( + status, + StatusCode::BAD_REQUEST + | StatusCode::METHOD_NOT_ALLOWED + | StatusCode::NOT_ACCEPTABLE + | StatusCode::PAYLOAD_TOO_LARGE + | StatusCode::URI_TOO_LONG + | StatusCode::UNSUPPORTED_MEDIA_TYPE + | StatusCode::UNPROCESSABLE_ENTITY + | StatusCode::NOT_IMPLEMENTED + ) +} + +struct PreparedPiResponse { + response: Response, + finalization_deferred: bool, +} + +struct PiStreamFinalization { + state: ProxyState, + candidate: PiMaterializedAttempt, + catalog_epoch: u64, + request_model: String, + session_id: String, + started: Instant, + is_streaming: bool, + status: StatusCode, + content_is_sse: bool, + used_half_open_permit: bool, + selected_is_failover: bool, +} + +enum PiStreamTermination { + Complete { captured: Option> }, + UpstreamFailure { message: String }, + DownstreamDropped, +} + +struct PiStreamDisposition { + provider_success: Option, + provider_error: Option, + request_success: bool, + request_error: Option, +} + +fn pi_stream_disposition( + status: StatusCode, + termination: &PiStreamTermination, +) -> PiStreamDisposition { + match termination { + PiStreamTermination::Complete { .. } => { + let provider_success = !retryable_status(status); + PiStreamDisposition { + provider_success: Some(provider_success), + provider_error: (!provider_success) + .then(|| format!("Pi upstream returned {status}")), + request_success: status.is_success(), + request_error: (!status.is_success()) + .then(|| format!("Pi upstream returned {status}")), + } + } + PiStreamTermination::UpstreamFailure { message } => PiStreamDisposition { + provider_success: Some(false), + provider_error: Some(message.clone()), + request_success: false, + request_error: Some(message.clone()), + }, + PiStreamTermination::DownstreamDropped => PiStreamDisposition { + // A downstream cancellation says nothing about upstream health. + provider_success: None, + provider_error: None, + request_success: false, + request_error: Some( + "Pi downstream client closed before the upstream stream completed".to_string(), + ), + }, + } +} + +struct PiStreamFinalizer { + pending: Option, +} + +impl PiStreamFinalizer { + fn new(finalization: PiStreamFinalization) -> Self { + Self { + pending: Some(finalization), + } + } + + fn finish(mut self, termination: PiStreamTermination) { + if let Some(finalization) = self.pending.take() { + finalization.spawn(termination); + } + } +} + +impl Drop for PiStreamFinalizer { + fn drop(&mut self) { + if let Some(finalization) = self.pending.take() { + finalization.spawn(PiStreamTermination::DownstreamDropped); + } + } +} + +impl PiStreamFinalization { + fn spawn(self, termination: PiStreamTermination) { + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + log::error!("Pi stream finalization lost because no Tokio runtime is available"); + return; + }; + runtime.spawn(async move { + self.apply(termination).await; + }); + } + + async fn apply(self, termination: PiStreamTermination) { + let disposition = pi_stream_disposition(self.status, &termination); + if let Some(provider_success) = disposition.provider_success { + record_provider_result( + &self.state, + self.catalog_epoch, + &self.candidate.provider_id, + self.used_half_open_permit, + provider_success, + disposition.provider_error, + ) + .await; + } else { + self.state + .provider_router + .release_permit_neutral( + &self.candidate.provider_id, + "pi", + self.used_half_open_permit, + ) + .await; + } + record_request_finish( + &self.state, + disposition.request_success, + self.selected_is_failover, + disposition.request_error.clone(), + ) + .await; + + match termination { + PiStreamTermination::Complete { captured } => { + if let Some(_guard) = self + .state + .pi_runtime + .writeback_guard(self.catalog_epoch) + .await + { + log_pi_usage( + &self.state, + &self.candidate, + &self.request_model, + &self.session_id, + self.started, + self.is_streaming, + self.status, + self.content_is_sse, + captured.as_deref(), + ) + .await; + } + } + PiStreamTermination::UpstreamFailure { message } => { + if let Some(_guard) = self + .state + .pi_runtime + .writeback_guard(self.catalog_epoch) + .await + { + log_pi_stream_error(&self, StatusCode::BAD_GATEWAY.as_u16(), &message); + } + } + PiStreamTermination::DownstreamDropped => { + if let Some(_guard) = self + .state + .pi_runtime + .writeback_guard(self.catalog_epoch) + .await + { + if let Some(message) = disposition.request_error.as_deref() { + log_pi_stream_error(&self, 499, message); + } + } + } + } + } +} + +fn log_pi_stream_error(finalization: &PiStreamFinalization, status_code: u16, message: &str) { + let logging_enabled = finalization + .state + .config + .try_read() + .map(|config| config.enable_logging) + .unwrap_or(true); + if !logging_enabled { + return; + } + let logger = UsageLogger::new(&finalization.state.db); + if let Err(error) = logger.log_error_with_context( + uuid::Uuid::new_v4().to_string(), + finalization.candidate.provider_id.clone(), + "pi".to_string(), + finalization.request_model.clone(), + status_code, + message.to_string(), + finalization.started.elapsed().as_millis() as u64, + finalization.is_streaming, + (!finalization.session_id.is_empty()).then(|| finalization.session_id.clone()), + Some(finalization.candidate.transport.family_name().to_string()), + InputTokenSemantics::for_pi_family(finalization.candidate.transport.family()), + ) { + log::warn!("failed to record Pi gateway stream error: {error}"); + } +} + +#[allow(clippy::too_many_arguments)] +async fn prepare_response( + state: ProxyState, + response: reqwest::Response, + candidate: PiMaterializedAttempt, + catalog_epoch: u64, + request_model: String, + session_id: String, + started: Instant, + is_streaming: bool, + first_semantic_timeout_seconds: u32, + streaming_idle_timeout_seconds: u32, + non_streaming_timeout_seconds: u32, + used_half_open_permit: bool, + selected_is_failover: bool, +) -> Result { + let status = response.status(); + let headers = filtered_response_headers(response.headers()); + let content_is_sse = response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/event-stream")); + if !is_streaming && !content_is_sse { + let read = response.bytes(); + let bytes = if non_streaming_timeout_seconds > 0 { + tokio::time::timeout( + Duration::from_secs(u64::from(non_streaming_timeout_seconds)), + read, + ) + .await + .map_err(|_| { + ProxyError::Timeout( + "Pi non-streaming response body exceeded its timeout".to_string(), + ) + })? + .map_err(|error| { + ProxyError::ForwardFailed(format!("Pi upstream response body failed: {error}")) + })? + } else { + read.await.map_err(|error| { + ProxyError::ForwardFailed(format!("Pi upstream response body failed: {error}")) + })? + }; + if let Some(_guard) = state.pi_runtime.writeback_guard(catalog_epoch).await { + state.current_providers.write().await.insert( + "pi".to_string(), + ( + candidate.provider_id.clone(), + candidate.provider_name.clone(), + ), + ); + log_pi_usage( + &state, + &candidate, + &request_model, + &session_id, + started, + false, + status, + false, + Some(&bytes), + ) + .await; + } + let mut builder = Response::builder().status(status); + *builder.headers_mut().ok_or_else(|| { + ProxyError::Internal("failed to build Pi response headers".to_string()) + })? = headers; + let response = builder.body(Body::from(bytes)).map_err(|error| { + ProxyError::Internal(format!("failed to build Pi response: {error}")) + })?; + return Ok(PreparedPiResponse { + response, + finalization_deferred: false, + }); + } + + let mut stream = response.bytes_stream().boxed(); + let prefix = if content_is_sse || (is_streaming && status.is_success()) { + preflight_sse( + &mut stream, + first_semantic_timeout_seconds, + SSE_PREFLIGHT_LIMIT, + ) + .await? + } else { + Vec::new() + }; + if let Some(_guard) = state.pi_runtime.writeback_guard(catalog_epoch).await { + state.current_providers.write().await.insert( + "pi".to_string(), + ( + candidate.provider_id.clone(), + candidate.provider_name.clone(), + ), + ); + } + let body_stream = logged_body_stream( + state, + stream, + prefix, + candidate, + catalog_epoch, + request_model, + session_id, + started, + is_streaming || content_is_sse, + status, + content_is_sse, + streaming_idle_timeout_seconds, + used_half_open_permit, + selected_is_failover, + ); + let mut builder = Response::builder().status(status); + *builder + .headers_mut() + .ok_or_else(|| ProxyError::Internal("failed to build Pi response headers".to_string()))? = + headers; + let response = builder + .body(Body::from_stream(body_stream)) + .map_err(|error| ProxyError::Internal(format!("failed to build Pi response: {error}")))?; + Ok(PreparedPiResponse { + response, + finalization_deferred: true, + }) +} + +fn filtered_response_headers(headers: &HeaderMap) -> HeaderMap { + let mut filtered = HeaderMap::new(); + for (name, value) in headers { + if matches!( + *name, + CONNECTION + | CONTENT_LENGTH + | TRANSFER_ENCODING + | TE + | TRAILER + | UPGRADE + | PROXY_AUTHENTICATE + | PROXY_AUTHORIZATION + ) { + continue; + } + filtered.append(name.clone(), value.clone()); + } + filtered +} + +async fn preflight_sse( + stream: &mut BoxStream<'static, Result>, + timeout_seconds: u32, + byte_limit: usize, +) -> Result, ProxyError> { + let deadline = (timeout_seconds > 0) + .then(|| tokio::time::Instant::now() + Duration::from_secs(u64::from(timeout_seconds))); + let mut chunks = Vec::new(); + let mut buffer = Vec::new(); + loop { + let next = match deadline { + Some(deadline) => tokio::time::timeout_at(deadline, stream.next()) + .await + .map_err(|_| { + ProxyError::Timeout( + "Pi SSE produced no semantic event before the first-event timeout" + .to_string(), + ) + })?, + None => stream.next().await, + }; + let chunk = next + .ok_or_else(|| { + ProxyError::ForwardFailed( + "Pi SSE ended before its first semantic event".to_string(), + ) + })? + .map_err(|_| { + ProxyError::ForwardFailed( + "Pi SSE failed before its first semantic event".to_string(), + ) + })?; + if buffer.len().saturating_add(chunk.len()) > byte_limit { + return Err(ProxyError::ForwardFailed( + "Pi SSE prelude exceeded the bounded commit fence".to_string(), + )); + } + buffer.extend_from_slice(&chunk); + chunks.push(chunk); + if contains_semantic_sse_event(&buffer) { + return Ok(chunks); + } + } +} + +fn contains_semantic_sse_event(bytes: &[u8]) -> bool { + let text = String::from_utf8_lossy(bytes).replace("\r\n", "\n"); + text.split("\n\n").any(|block| { + block.lines().any(|line| { + line.strip_prefix("data:") + .is_some_and(|data| !data.trim().is_empty()) + }) + }) +} + +#[allow(clippy::too_many_arguments)] +fn logged_body_stream( + state: ProxyState, + mut stream: BoxStream<'static, Result>, + prefix: Vec, + candidate: PiMaterializedAttempt, + catalog_epoch: u64, + request_model: String, + session_id: String, + started: Instant, + is_streaming: bool, + status: StatusCode, + content_is_sse: bool, + streaming_idle_timeout_seconds: u32, + used_half_open_permit: bool, + selected_is_failover: bool, +) -> impl futures::Stream> + Send + 'static { + // Construct the guard before the generator is polled. Axum may drop a + // response body without ever polling it when the client disconnects after + // headers, and the HalfOpen permit must still be released in that case. + let finalizer = PiStreamFinalizer::new(PiStreamFinalization { + state, + candidate, + catalog_epoch, + request_model, + session_id, + started, + is_streaming, + status, + content_is_sse, + used_half_open_permit, + selected_is_failover, + }); + async_stream::stream! { + let mut captured = Vec::new(); + let mut capture_open = true; + for chunk in prefix { + capture_usage_bytes(&mut captured, &mut capture_open, &chunk); + yield Ok(chunk); + } + loop { + let next = if streaming_idle_timeout_seconds > 0 { + match tokio::time::timeout( + Duration::from_secs(u64::from(streaming_idle_timeout_seconds)), + stream.next(), + ) + .await + { + Ok(next) => next, + Err(_) => { + let message = "Pi upstream stream exceeded its idle timeout".to_string(); + finalizer.finish(PiStreamTermination::UpstreamFailure { + message: message.clone(), + }); + yield Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + message, + )); + return; + } + } + } else { + stream.next().await + }; + let Some(result) = next else { + break; + }; + match result { + Ok(chunk) => { + capture_usage_bytes(&mut captured, &mut capture_open, &chunk); + yield Ok(chunk); + } + Err(error) => { + let message = format!("Pi upstream response failed: {error}"); + finalizer.finish(PiStreamTermination::UpstreamFailure { + message: message.clone(), + }); + yield Err(std::io::Error::other(message)); + return; + } + } + } + finalizer.finish(PiStreamTermination::Complete { + captured: capture_open.then_some(captured), + }); + } +} + +fn capture_usage_bytes(captured: &mut Vec, capture_open: &mut bool, chunk: &[u8]) { + if !*capture_open { + return; + } + if captured.len().saturating_add(chunk.len()) > USAGE_CAPTURE_LIMIT { + captured.clear(); + *capture_open = false; + return; + } + captured.extend_from_slice(chunk); +} + +#[allow(clippy::too_many_arguments)] +async fn log_pi_usage( + state: &ProxyState, + candidate: &PiMaterializedAttempt, + request_model: &str, + session_id: &str, + started: Instant, + is_streaming: bool, + status: StatusCode, + content_is_sse: bool, + captured: Option<&[u8]>, +) { + let logging_enabled = state + .config + .try_read() + .map(|config| config.enable_logging) + .unwrap_or(true); + if !logging_enabled { + return; + } + let usage = captured + .and_then(|bytes| parse_usage(candidate.transport.family_name(), bytes, content_is_sse)) + .unwrap_or_default(); + let response_model = usage + .model + .clone() + .unwrap_or_else(|| request_model.to_string()); + let logger = UsageLogger::new(&state.db); + let input_token_semantics = InputTokenSemantics::for_pi_family(candidate.transport.family()); + if !status.is_success() { + let _ = logger.log_error( + uuid::Uuid::new_v4().to_string(), + candidate.provider_id.clone(), + "pi".to_string(), + response_model, + status.as_u16(), + format!("Pi upstream returned {status}"), + started.elapsed().as_millis() as u64, + input_token_semantics, + ); + return; + } + let (multiplier, pricing_source) = logger + .resolve_pricing_config(&candidate.provider_id, "pi") + .await; + let pricing_model = if pricing_source == PRICING_SOURCE_REQUEST { + request_model.to_string() + } else { + response_model.clone() + }; + let request_id = usage.dedup_request_id(Some(("pi", candidate.provider_id.as_str()))); + if let Err(error) = logger.log_with_calculation( + request_id, + candidate.provider_id.clone(), + "pi".to_string(), + response_model, + request_model.to_string(), + pricing_model, + input_token_semantics, + usage, + multiplier, + started.elapsed().as_millis() as u64, + None, + status.as_u16(), + (!session_id.is_empty()).then(|| session_id.to_string()), + Some(candidate.transport.family_name().to_string()), + is_streaming, + ) { + log::warn!("failed to record Pi gateway usage: {error}"); + } +} + +fn parse_usage(family: &str, bytes: &[u8], is_sse: bool) -> Option { + if is_sse { + let events = sse_json_events(bytes); + return match family { + "anthropic-messages" => TokenUsage::from_claude_stream_events(&events), + "openai-completions" => TokenUsage::from_openai_stream_events(&events), + "openai-responses" => TokenUsage::from_codex_stream_events_auto(&events), + "google-generative-ai" => TokenUsage::from_gemini_stream_chunks(&events), + _ => None, + }; + } + let body = serde_json::from_slice::(bytes).ok()?; + match family { + "anthropic-messages" => TokenUsage::from_claude_response(&body), + "openai-completions" => TokenUsage::from_openai_response(&body), + "openai-responses" => TokenUsage::from_codex_response_auto(&body), + "google-generative-ai" => TokenUsage::from_gemini_response(&body), + _ => None, + } +} + +fn sse_json_events(bytes: &[u8]) -> Vec { + let text = String::from_utf8_lossy(bytes).replace("\r\n", "\n"); + text.split("\n\n") + .flat_map(str::lines) + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim) + .filter(|data| !data.is_empty() && *data != "[DONE]") + .filter_map(|data| serde_json::from_str(data).ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_policy_matches_pi_contract_matrix() { + for status in [ + StatusCode::UNAUTHORIZED, + StatusCode::FORBIDDEN, + StatusCode::NOT_FOUND, + StatusCode::REQUEST_TIMEOUT, + StatusCode::CONFLICT, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::IM_A_TEAPOT, + StatusCode::BAD_GATEWAY, + ] { + assert!(retryable_status(status), "{status}"); + } + for status in [ + StatusCode::BAD_REQUEST, + StatusCode::METHOD_NOT_ALLOWED, + StatusCode::NOT_ACCEPTABLE, + StatusCode::PAYLOAD_TOO_LARGE, + StatusCode::URI_TOO_LONG, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + StatusCode::UNPROCESSABLE_ENTITY, + StatusCode::NOT_IMPLEMENTED, + ] { + assert!(!retryable_status(status), "{status}"); + } + } + + #[test] + fn sse_comments_do_not_cross_the_commit_fence() { + assert!(!contains_semantic_sse_event( + b": keep-alive\n\n: another\n\n" + )); + assert!(contains_semantic_sse_event( + b": keep-alive\n\nevent: message_start\ndata: {\"type\":\"message_start\"}\n\n" + )); + } + + #[test] + fn gateway_header_filter_removes_client_auth_and_protocol_identity() { + let mut incoming = HeaderMap::new(); + incoming.insert( + AUTHORIZATION, + http::HeaderValue::from_static("Bearer gateway"), + ); + incoming.insert("x-api-key", http::HeaderValue::from_static("gateway")); + incoming.insert( + "anthropic-version", + http::HeaderValue::from_static("client-version"), + ); + incoming.insert("x-request-local", http::HeaderValue::from_static("kept")); + let filtered = filtered_incoming_headers(&incoming); + assert!(filtered.get(AUTHORIZATION).is_none()); + assert!(filtered.get("x-api-key").is_none()); + assert!(filtered.get("anthropic-version").is_none()); + assert_eq!(filtered["x-request-local"], "kept"); + } + + #[test] + fn percent_decoding_is_strict_utf8() { + assert_eq!( + percent_decode("gemini%2D2.5").as_deref(), + Some("gemini-2.5") + ); + assert!(percent_decode("%ZZ").is_none()); + assert!(percent_decode("%ff").is_none()); + } + + #[test] + fn failover_cannot_become_the_protocol_anchor_after_primary_materialization_fails() { + let mut anchor = ProtocolAnchor::Unset; + let mut candidate_headers = HeaderMap::new(); + candidate_headers.insert( + "anthropic-version", + http::HeaderValue::from_static("candidate-only"), + ); + let candidate = Some(("anthropic-messages".to_string(), candidate_headers)); + + assert!(!protocol_identity_allows_attempt( + &mut anchor, + candidate, + true + )); + assert!(matches!(anchor, ProtocolAnchor::Unset)); + } + + #[test] + fn protocol_anchor_blocks_replay_when_identity_is_unpredictable_or_changes() { + let mut unpredictable = ProtocolAnchor::Unset; + assert!(protocol_identity_allows_attempt( + &mut unpredictable, + None, + false + )); + assert!(!protocol_identity_allows_attempt( + &mut unpredictable, + None, + false + )); + assert!(!protocol_identity_allows_attempt( + &mut unpredictable, + None, + true + )); + + let identity = Some(("openai-responses".to_string(), HeaderMap::new())); + let mut predictable = ProtocolAnchor::Unset; + assert!(protocol_identity_allows_attempt( + &mut predictable, + identity.clone(), + false + )); + assert!(protocol_identity_allows_attempt( + &mut predictable, + identity, + false + )); + let mut changed_headers = HeaderMap::new(); + changed_headers.insert("openai-version", http::HeaderValue::from_static("changed")); + assert!(!protocol_identity_allows_attempt( + &mut predictable, + Some(("openai-responses".to_string(), changed_headers)), + false + )); + } + + #[test] + fn streaming_health_waits_for_the_terminal_outcome() { + let complete = pi_stream_disposition( + StatusCode::OK, + &PiStreamTermination::Complete { + captured: Some(Vec::new()), + }, + ); + assert_eq!(complete.provider_success, Some(true)); + assert!(complete.request_success); + + let truncated = pi_stream_disposition( + StatusCode::OK, + &PiStreamTermination::UpstreamFailure { + message: "truncated".to_string(), + }, + ); + assert_eq!(truncated.provider_success, Some(false)); + assert!(!truncated.request_success); + assert_eq!(truncated.provider_error.as_deref(), Some("truncated")); + + let client_drop = + pi_stream_disposition(StatusCode::OK, &PiStreamTermination::DownstreamDropped); + assert_eq!(client_drop.provider_success, None); + assert!(!client_drop.request_success); + + let non_retryable_error = pi_stream_disposition( + StatusCode::BAD_REQUEST, + &PiStreamTermination::Complete { captured: None }, + ); + assert_eq!(non_retryable_error.provider_success, Some(true)); + assert!(!non_retryable_error.request_success); + } +} diff --git a/src-tauri/src/proxy/pi_runtime.rs b/src-tauri/src/proxy/pi_runtime.rs new file mode 100644 index 000000000..82c1610ef --- /dev/null +++ b/src-tauri/src/proxy/pi_runtime.rs @@ -0,0 +1,952 @@ +//! Immutable Pi gateway catalog and native projection planning. +//! +//! The database remains the managed provider authority. A snapshot is built +//! from complete provider aggregates, exact-key ownership claims, and a stable +//! device token; only after the matching `models.json` patch succeeds is that +//! snapshot published for request admission. + +use crate::database::Database; +use crate::error::AppError; +use crate::pi_config::composer::PiComposedNativeModel; +use crate::pi_config::gateway::{ + assess_composition_for_runtime, CandidateHeaderPlan, MaterializedCandidate, PiGatewayApiFamily, + PiGatewayCapability, PiGatewayReason, +}; +use crate::pi_config::model::PiManagedProviderConfig; +use crate::pi_config::native::compose_managed_pi_provider; +use crate::provider::ProviderAggregate; +use crate::proxy::types::AppProxyConfig; +use crate::settings::GatewayToken; +use indexmap::IndexMap; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::io::Read; +use std::process::{Command, Stdio}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, RwLock, +}; +use std::time::{Duration, Instant}; +use tokio::sync::{OwnedRwLockReadGuard, RwLock as AsyncRwLock}; +use url::Url; + +const PI_APP: &str = "pi"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +const COMMAND_OUTPUT_LIMIT: u64 = 1024 * 1024; + +#[derive(Debug, Clone)] +struct PiRuntimeModel { + provider_id: String, + provider_name: String, + family: PiGatewayApiFamily, + wire_profile: Vec, + plan: CandidateHeaderPlan, + endpoints: Vec, +} + +#[derive(Debug, Clone)] +struct PiRuntimeProvider { + models: HashMap, +} + +#[derive(Debug, Clone)] +struct PiRouteBinding { + provider_id: String, +} + +/// One immutable catalog matching a successfully published native projection. +#[derive(Debug, Clone)] +pub(crate) struct PiRuntimeSnapshot { + pub(crate) server_generation: u64, + pub(crate) catalog_epoch: u64, + gateway_token: GatewayToken, + app_config: AppProxyConfig, + providers: HashMap, + failover_ids: Vec, + routes: HashMap, +} + +#[derive(Debug, Clone)] +pub(crate) struct PiRequestCandidate { + pub(crate) provider_id: String, + pub(crate) provider_name: String, + pub(crate) family: PiGatewayApiFamily, + pub(crate) plan: CandidateHeaderPlan, + pub(crate) is_failover: bool, +} + +#[derive(Debug, Clone)] +pub(crate) struct PiRequestRoute { + pub(crate) catalog_epoch: u64, + pub(crate) app_config: AppProxyConfig, + pub(crate) candidates: Vec, +} + +#[derive(Debug)] +pub(crate) struct PiMaterializedAttempt { + pub(crate) provider_id: String, + pub(crate) provider_name: String, + pub(crate) is_failover: bool, + pub(crate) transport: MaterializedCandidate, + 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 + /// database projection while proxyable siblings point at the gateway. + pub(crate) projection_patch: IndexMap>, + pub(crate) direct_only_provider_ids: Vec, +} + +impl PiRuntimeSnapshot { + pub(crate) fn token_matches(&self, candidate: &str) -> bool { + self.gateway_token.constant_time_eq(candidate) + } + + pub(crate) fn route( + &self, + route_token: &str, + family: PiGatewayApiFamily, + model_id: &str, + ) -> Result { + let binding = self + .routes + .get(route_token) + .ok_or_else(|| AppError::NotFound("unknown Pi gateway provider route".to_string()))?; + let primary = self + .providers + .get(&binding.provider_id) + .and_then(|provider| provider.models.get(model_id)) + .filter(|model| model.family == family) + .ok_or_else(|| { + AppError::InvalidInput(format!( + "Pi provider '{}' does not expose model '{model_id}' for {}", + binding.provider_id, + family.as_str() + )) + })?; + + let mut candidates = expand_model_attempts(primary, false)?; + if self.app_config.auto_failover_enabled { + for provider_id in &self.failover_ids { + if provider_id == &binding.provider_id { + continue; + } + let Some(candidate) = self + .providers + .get(provider_id) + .and_then(|provider| provider.models.get(model_id)) + else { + continue; + }; + if candidate.family != family + || candidate.wire_profile != primary.wire_profile + || !candidate.plan.protocol_identity_is_predictable() + { + continue; + } + candidates.extend(expand_model_attempts(candidate, true)?); + } + } + + Ok(PiRequestRoute { + catalog_epoch: self.catalog_epoch, + app_config: self.app_config.clone(), + candidates, + }) + } +} + +fn expand_model_attempts( + model: &PiRuntimeModel, + is_failover: bool, +) -> Result, AppError> { + let mut plans = Vec::with_capacity(model.endpoints.len().saturating_add(1)); + plans.push(model.plan.clone()); + for endpoint in &model.endpoints { + let plan = model.plan.with_endpoint(endpoint).map_err(gateway_reason)?; + if !plans + .iter() + .any(|existing| existing.endpoint() == plan.endpoint()) + { + plans.push(plan); + } + } + Ok(plans + .into_iter() + .map(|plan| PiRequestCandidate { + provider_id: model.provider_id.clone(), + provider_name: model.provider_name.clone(), + family: model.family, + plan, + is_failover, + }) + .collect()) +} + +impl PiRequestCandidate { + pub(crate) fn materialize( + self, + forwarded_path_and_query: &str, + ) -> Result { + let resolver_failure = std::cell::Cell::new(false); + let transport = self + .plan + .materialize_for_runtime(&|expression: &str| { + let resolved = resolve_pi_config_value(expression); + resolver_failure.set(resolver_failure.get() || resolved.is_err()); + resolved.ok() + }) + .map_err(|reason| { + if resolver_failure.get() { + AppError::Config( + "failed to resolve a deferred Pi gateway credential or header".to_string(), + ) + } else { + gateway_reason(reason) + } + })?; + let url = build_family_url(self.family, &transport.endpoint, forwarded_path_and_query)?; + Ok(PiMaterializedAttempt { + provider_id: self.provider_id, + provider_name: self.provider_name, + is_failover: self.is_failover, + transport, + url, + }) + } +} + +fn gateway_reason(reason: PiGatewayReason) -> AppError { + AppError::Config(format!( + "Pi gateway candidate rejected at {}: {:?}", + reason.json_pointer, reason.code + )) +} + +/// Build the immutable runtime and its exact native projection in one pass. +pub(crate) fn build_pi_runtime( + db: &Database, + server_generation: u64, + catalog_epoch: u64, + gateway_origin: &Url, + gateway_token: GatewayToken, + app_config: AppProxyConfig, +) -> Result { + if catalog_epoch % 2 != 0 { + return Err(AppError::Config( + "Pi runtime publication requires an even catalog epoch".to_string(), + )); + } + let aggregates = db.get_all_provider_aggregates(PI_APP)?; + let manifest = db.get_pi_projection_manifest()?; + if aggregates.len() != manifest.len() + || aggregates + .keys() + .any(|provider_id| !manifest.contains_key(provider_id)) + { + return Err(AppError::Conflict( + "Pi provider aggregates and exact-key ownership claims diverged".to_string(), + )); + } + + let mut providers = HashMap::new(); + let mut routes = HashMap::new(); + let mut projection_patch = IndexMap::new(); + let mut direct_only_provider_ids = Vec::new(); + for (provider_id, aggregate) in aggregates { + let projection = manifest.get(&provider_id).ok_or_else(|| { + AppError::Conflict(format!( + "Pi provider '{provider_id}' has no exact-key claim" + )) + })?; + let config = decode_managed_config(&aggregate)?; + let composition = compose_managed_pi_provider(&projection.provider_key, &config)?; + let assessment = assess_composition_for_runtime(&composition); + if assessment.capability != PiGatewayCapability::Proxyable + || assessment.plans.len() != composition.models.len() + { + projection_patch.insert( + projection.provider_key.clone(), + Some(serde_json::to_value(&config).map_err(|source| { + AppError::Config(format!( + "failed to serialize direct-only Pi provider: {source}" + )) + })?), + ); + direct_only_provider_ids.push(provider_id); + continue; + } + + let token = pi_route_token(&provider_id, &projection.provider_key); + let local_base = gateway_origin + .join(&format!("pi/{token}")) + .map_err(|error| AppError::Config(format!("invalid Pi gateway origin: {error}")))?; + let endpoints = aggregate.endpoints.keys().cloned().collect::>(); + let mut models = HashMap::new(); + for ((model, plan), expected) in composition + .models + .iter() + .zip(assessment.plans) + .zip(config.models.iter()) + { + if model.id != expected.id { + return Err(AppError::Config(format!( + "Pi composer changed managed model order for '{provider_id}'" + ))); + } + let family = plan.family(); + let runtime_model = runtime_model(&aggregate, model, family, plan, endpoints.clone())?; + if models.insert(model.id.clone(), runtime_model).is_some() { + return Err(AppError::Conflict(format!( + "duplicate Pi model '{}' in provider '{provider_id}'", + model.id + ))); + } + } + if routes + .insert( + token, + PiRouteBinding { + provider_id: provider_id.clone(), + }, + ) + .is_some() + { + return Err(AppError::Conflict( + "Pi gateway route digest collision".to_string(), + )); + } + let projected = project_config_for_gateway(&config, &local_base, &gateway_token)?; + projection_patch.insert(projection.provider_key.clone(), Some(projected)); + providers.insert(provider_id, PiRuntimeProvider { models }); + } + + let failover_ids = db + .get_failover_queue(PI_APP)? + .into_iter() + .map(|item| item.provider_id) + .collect(); + Ok(PiRuntimeBuild { + snapshot: Arc::new(PiRuntimeSnapshot { + server_generation, + catalog_epoch, + gateway_token, + app_config, + providers, + failover_ids, + routes, + }), + projection_patch, + direct_only_provider_ids, + }) +} + +pub(crate) fn direct_pi_projection_patch( + db: &Database, +) -> Result>, AppError> { + let aggregates = db.get_all_provider_aggregates(PI_APP)?; + let manifest = db.get_pi_projection_manifest()?; + if aggregates.len() != manifest.len() { + return Err(AppError::Conflict( + "Pi provider aggregates and exact-key claims diverged".to_string(), + )); + } + let mut patch = IndexMap::new(); + for (provider_id, aggregate) in aggregates { + let projection = manifest.get(&provider_id).ok_or_else(|| { + AppError::Conflict(format!( + "Pi provider '{provider_id}' has no exact-key claim" + )) + })?; + let config = decode_managed_config(&aggregate)?; + patch.insert( + projection.provider_key.clone(), + Some(serde_json::to_value(config).map_err(|source| { + AppError::Config(format!("failed to serialize Pi provider: {source}")) + })?), + ); + } + Ok(patch) +} + +/// Render one managed provider for an in-progress catalog mutation. This is +/// the same planning boundary used by the full runtime build, so the +/// coordinator never carries a second notion of "proxyable". +pub(crate) fn project_managed_pi_config( + provider_id: &str, + provider_key: &str, + config: &PiManagedProviderConfig, + gateway_origin: &Url, + gateway_token: &GatewayToken, +) -> Result { + let composition = compose_managed_pi_provider(provider_key, config)?; + let assessment = assess_composition_for_runtime(&composition); + if assessment.capability != PiGatewayCapability::Proxyable + || assessment.plans.len() != composition.models.len() + { + return serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source }); + } + let token = pi_route_token(provider_id, provider_key); + let local_base = gateway_origin + .join(&format!("pi/{token}")) + .map_err(|error| AppError::Config(format!("invalid Pi gateway origin: {error}")))?; + project_config_for_gateway(config, &local_base, gateway_token) +} + +fn decode_managed_config( + aggregate: &ProviderAggregate, +) -> Result { + serde_json::from_value(aggregate.provider.settings_config.clone()).map_err(|error| { + AppError::Config(format!( + "managed Pi provider '{}' is invalid: {error}", + aggregate.provider.id + )) + }) +} + +fn runtime_model( + aggregate: &ProviderAggregate, + model: &PiComposedNativeModel, + family: PiGatewayApiFamily, + plan: CandidateHeaderPlan, + endpoints: Vec, +) -> Result { + Ok(PiRuntimeModel { + provider_id: aggregate.provider.id.clone(), + provider_name: aggregate.provider.name.clone(), + family, + wire_profile: canonical_wire_profile(model)?, + plan, + endpoints, + }) +} + +fn canonical_wire_profile(model: &PiComposedNativeModel) -> Result, AppError> { + let mut profile = Map::new(); + profile.insert("reasoning".to_string(), Value::Bool(model.reasoning)); + profile.insert( + "thinkingLevelMap".to_string(), + model.thinking_level_map.clone().unwrap_or(Value::Null), + ); + profile.insert("input".to_string(), model.input.clone()); + profile.insert("contextWindow".to_string(), model.context_window.clone()); + profile.insert("maxTokens".to_string(), model.max_tokens.clone()); + profile.insert( + "compat".to_string(), + model.compat.clone().unwrap_or(Value::Null), + ); + profile.insert( + "providerExtra".to_string(), + serde_json::to_value(&model.provider_extra) + .map_err(|source| AppError::JsonSerialize { source })?, + ); + profile.insert( + "modelExtra".to_string(), + serde_json::to_value(&model.model_extra) + .map_err(|source| AppError::JsonSerialize { source })?, + ); + profile.insert( + "overrideExtra".to_string(), + serde_json::to_value(&model.override_extra) + .map_err(|source| AppError::JsonSerialize { source })?, + ); + serde_json::to_vec(&canonical_json(&Value::Object(profile))) + .map_err(|source| AppError::JsonSerialize { source }) +} + +fn canonical_json(value: &Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + Value::Object(values) => { + let sorted = values + .iter() + .map(|(key, value)| (key.clone(), canonical_json(value))) + .collect::>(); + Value::Object(sorted.into_iter().collect()) + } + scalar => scalar.clone(), + } +} + +fn project_config_for_gateway( + config: &PiManagedProviderConfig, + local_base: &Url, + gateway_token: &GatewayToken, +) -> Result { + let mut value = + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source })?; + let root = value + .as_object_mut() + .ok_or_else(|| AppError::Config("Pi provider projection is not an object".to_string()))?; + root.insert( + "apiKey".to_string(), + Value::String(gateway_token.expose().to_string()), + ); + root.remove("headers"); + root.remove("authHeader"); + root.remove("oauth"); + let provider_has_base = root.contains_key("baseUrl"); + if provider_has_base { + root.insert( + "baseUrl".to_string(), + Value::String(local_base.as_str().trim_end_matches('/').to_string()), + ); + } + let models = root + .get_mut("models") + .and_then(Value::as_array_mut) + .ok_or_else(|| AppError::Config("Pi provider projection has no models".to_string()))?; + for model in models { + let object = model + .as_object_mut() + .ok_or_else(|| AppError::Config("Pi model projection is not an object".to_string()))?; + object.remove("headers"); + if object.contains_key("baseUrl") || !provider_has_base { + object.insert( + "baseUrl".to_string(), + Value::String(local_base.as_str().trim_end_matches('/').to_string()), + ); + } + } + if let Some(overrides) = root + .get_mut("modelOverrides") + .and_then(Value::as_object_mut) + { + for model_override in overrides.values_mut() { + if let Some(object) = model_override.as_object_mut() { + object.remove("headers"); + } + } + } + Ok(value) +} + +fn pi_route_token(provider_id: &str, provider_key: &str) -> String { + let mut digest = Sha256::new(); + digest.update(b"cc-switch:pi-route:v2\0"); + digest.update(provider_id.as_bytes()); + digest.update([0]); + digest.update(provider_key.as_bytes()); + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Process-local publication point. Odd epochs close admission; an even +/// snapshot is leased by `Arc`, so requests already admitted keep a coherent +/// catalog while a replacement is prepared. +#[derive(Debug, Default)] +pub(crate) struct PiRuntimeStore { + current: RwLock>>, + catalog_epoch: AtomicU64, + epoch_gate: Arc>, +} + +impl PiRuntimeStore { + pub(crate) async fn begin_mutation(&self) -> u64 { + let _guard = self.epoch_gate.write().await; + let current = self.catalog_epoch.load(Ordering::Acquire); + let odd = if current % 2 == 0 { + current.saturating_add(1) + } else { + current + }; + self.catalog_epoch.store(odd, Ordering::Release); + odd.saturating_add(1) + } + + pub(crate) async fn publish(&self, snapshot: Arc) -> Result<(), AppError> { + if snapshot.catalog_epoch % 2 != 0 { + return Err(AppError::Config( + "cannot publish an odd Pi catalog epoch".to_string(), + )); + } + let _guard = self.epoch_gate.write().await; + let epoch = snapshot.catalog_epoch; + *self + .current + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(snapshot); + self.catalog_epoch.store(epoch, Ordering::Release); + Ok(()) + } + + pub(crate) async fn close(&self, even_epoch: u64) -> Result<(), AppError> { + if even_epoch % 2 != 0 { + return Err(AppError::Config( + "Pi admission close requires an even terminal epoch".to_string(), + )); + } + let _guard = self.epoch_gate.write().await; + *self + .current + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + self.catalog_epoch.store(even_epoch, Ordering::Release); + Ok(()) + } + + pub(crate) async fn republish_current(&self, even_epoch: u64) -> Result { + let current = self + .current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .cloned(); + let Some(current) = current else { + self.close(even_epoch).await?; + return Ok(false); + }; + let mut next = (*current).clone(); + next.catalog_epoch = even_epoch; + self.publish(Arc::new(next)).await?; + Ok(true) + } + + pub(crate) fn lease(&self, server_generation: u64) -> Option> { + let epoch = self.catalog_epoch.load(Ordering::Acquire); + if epoch % 2 != 0 { + return None; + } + let snapshot = self + .current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .filter(|snapshot| { + snapshot.server_generation == server_generation && snapshot.catalog_epoch == epoch + }) + .cloned()?; + (self.catalog_epoch.load(Ordering::Acquire) == epoch).then_some(snapshot) + } + + pub(crate) async fn admission_guard( + self: &Arc, + server_generation: u64, + snapshot: &Arc, + ) -> Option> { + let guard = self.epoch_gate.clone().read_owned().await; + let current = self + .current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|current| { + snapshot.catalog_epoch % 2 == 0 + && self.catalog_epoch.load(Ordering::Acquire) == snapshot.catalog_epoch + && current.server_generation == server_generation + && Arc::ptr_eq(current, snapshot) + }); + current.then_some(guard) + } + + pub(crate) async fn writeback_guard( + self: &Arc, + expected_epoch: u64, + ) -> Option> { + let guard = self.epoch_gate.clone().read_owned().await; + (expected_epoch % 2 == 0 && self.catalog_epoch.load(Ordering::Acquire) == expected_epoch) + .then_some(guard) + } +} + +pub(crate) fn infer_family(path: &str) -> Option { + if path == "/v1/messages" { + Some(PiGatewayApiFamily::AnthropicMessages) + } else if path == "/chat/completions" { + Some(PiGatewayApiFamily::OpenAiCompletions) + } else if matches!(path, "/responses" | "/responses/compact") { + Some(PiGatewayApiFamily::OpenAiResponses) + } else if path.starts_with("/models/") || path == "/models" { + Some(PiGatewayApiFamily::GoogleGenerativeAi) + } else { + None + } +} + +fn build_family_url( + family: PiGatewayApiFamily, + base: &Url, + path_and_query: &str, +) -> Result { + let (path, query) = path_and_query + .split_once('?') + .map_or((path_and_query, None), |(path, query)| (path, Some(query))); + if infer_family(path) != Some(family) { + return Err(AppError::InvalidInput(format!( + "Pi gateway path '{path}' does not match {}", + family.as_str() + ))); + } + let mut url = base.clone(); + let base_path = base.path().trim_end_matches('/'); + let suffix = path.trim_start_matches('/'); + let combined = if base_path.is_empty() || base_path == "/" { + format!("/{suffix}") + } else { + format!("{base_path}/{suffix}") + }; + url.set_path(&combined); + url.set_query(query); + url.set_fragment(None); + Ok(url) +} + +fn resolve_pi_config_value(expression: &str) -> Result { + if let Some(command) = expression.strip_prefix('!') { + return execute_config_command(command); + } + expand_environment(expression) +} + +fn expand_environment(input: &str) -> Result { + const ESCAPED_DOLLAR: char = '\u{e000}'; + const ESCAPED_BANG: char = '\u{e001}'; + let chars = input.chars().collect::>(); + let mut output = String::new(); + let mut index = 0; + while index < chars.len() { + if chars[index] != '$' { + output.push(chars[index]); + index += 1; + continue; + } + if chars.get(index + 1) == Some(&'$') { + output.push(ESCAPED_DOLLAR); + index += 2; + continue; + } + if chars.get(index + 1) == Some(&'!') { + output.push(ESCAPED_BANG); + index += 2; + continue; + } + let (name, next) = if chars.get(index + 1) == Some(&'{') { + let Some(end) = chars[index + 2..].iter().position(|value| *value == '}') else { + return Err("unterminated Pi environment expression".to_string()); + }; + let end = index + 2 + end; + (chars[index + 2..end].iter().collect::(), end + 1) + } else { + let mut end = index + 1; + while end < chars.len() && (chars[end] == '_' || chars[end].is_ascii_alphanumeric()) { + end += 1; + } + if end == index + 1 { + output.push('$'); + index += 1; + continue; + } + (chars[index + 1..end].iter().collect::(), end) + }; + if name.is_empty() + || !name + .chars() + .next() + .is_some_and(|value| value == '_' || value.is_ascii_alphabetic()) + { + return Err("invalid Pi environment variable name".to_string()); + } + let value = std::env::var(&name) + .map_err(|_| format!("Pi environment variable '{name}' is unavailable"))?; + output.push_str(&value); + index = next; + } + Ok(output + .replace(ESCAPED_DOLLAR, "$") + .replace(ESCAPED_BANG, "!")) +} + +fn execute_config_command(script: &str) -> Result { + if script.trim().is_empty() { + return Err("empty Pi config command".to_string()); + } + let mut command = if cfg!(windows) { + let mut command = Command::new("cmd"); + command.args(["/D", "/S", "/C", script]); + command + } else { + let mut command = Command::new("/bin/sh"); + command.args(["-c", script]); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + command + }; + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to start Pi config command: {error}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "failed to capture Pi config command stdout".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "failed to capture Pi config command stderr".to_string())?; + let stdout_reader = std::thread::spawn(move || read_bounded(stdout)); + let stderr_reader = std::thread::spawn(move || read_bounded(stderr)); + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() < COMMAND_TIMEOUT => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + terminate_command_tree(&mut child); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err("Pi config command timed out".to_string()); + } + Err(error) => { + terminate_command_tree(&mut child); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(format!("failed to wait for Pi config command: {error}")); + } + } + }; + let stdout = stdout_reader + .join() + .map_err(|_| "Pi config command stdout reader panicked".to_string())??; + let stderr = stderr_reader + .join() + .map_err(|_| "Pi config command stderr reader panicked".to_string())??; + if !status.success() { + return Err(format!( + "Pi config command exited unsuccessfully: {}", + String::from_utf8_lossy(&stderr).trim() + )); + } + String::from_utf8(stdout) + .map(|value| value.trim().to_string()) + .map_err(|_| "Pi config command output is not UTF-8".to_string()) +} + +fn read_bounded(reader: impl Read) -> Result, String> { + let mut output = Vec::new(); + reader + .take(COMMAND_OUTPUT_LIMIT + 1) + .read_to_end(&mut output) + .map_err(|error| format!("failed to read Pi config command output: {error}"))?; + if output.len() as u64 > COMMAND_OUTPUT_LIMIT { + return Err("Pi config command output exceeded 1 MiB".to_string()); + } + Ok(output) +} + +fn terminate_command_tree(child: &mut std::process::Child) { + #[cfg(unix)] + unsafe { + let _ = libc::kill(-(child.id() as i32), libc::SIGKILL); + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn environment_resolution_matches_vendored_transport_oracle() { + std::env::set_var("PI_RUNTIME_TEST_VALUE", "environment-secret"); + assert_eq!( + resolve_pi_config_value("prefix-${PI_RUNTIME_TEST_VALUE}-suffix").unwrap(), + "prefix-environment-secret-suffix" + ); + assert_eq!( + resolve_pi_config_value("$$literal-$!bang").unwrap(), + "$literal-!bang" + ); + std::env::remove_var("PI_RUNTIME_TEST_VALUE"); + } + + #[test] + fn command_resolution_matches_vendored_transport_oracle() { + #[cfg(unix)] + assert_eq!( + resolve_pi_config_value("!printf pi-command-value").unwrap(), + "pi-command-value" + ); + } + + #[test] + fn family_url_builders_preserve_candidate_origin_and_base_path() { + let base = Url::parse("https://candidate.example:8443/root/v1").unwrap(); + let url = build_family_url( + PiGatewayApiFamily::OpenAiResponses, + &base, + "/responses?stream=true", + ) + .unwrap(); + assert_eq!( + url.as_str(), + "https://candidate.example:8443/root/v1/responses?stream=true" + ); + assert!( + build_family_url(PiGatewayApiFamily::AnthropicMessages, &base, "/responses").is_err() + ); + } + + #[test] + fn wire_profile_is_key_order_insensitive_but_array_and_unknown_sensitive() { + fn model(extra: Value, input: Value) -> PiComposedNativeModel { + PiComposedNativeModel { + id: "m".to_string(), + name: "M".to_string(), + api: crate::pi_config::raw_schema::PiRawApiId::new("openai-responses".to_string()) + .unwrap(), + provider: "p".to_string(), + base_url: "https://example.test/v1".to_string(), + reasoning: false, + thinking_level_map: None, + input, + cost: json!({"input": 1}), + context_window: json!(1000), + max_tokens: json!(100), + headers: BTreeMap::new(), + provider_headers: Vec::new(), + model_headers: Vec::new(), + compat: None, + api_key: Some("secret".to_string()), + oauth: None, + auth_header: false, + provider_extra: serde_json::from_value(extra).unwrap(), + model_extra: BTreeMap::new(), + override_extra: BTreeMap::new(), + } + } + let first = model(json!({"z": 1, "a": {"b": 2, "a": 1}}), json!(["text"])); + let reordered = model(json!({"a": {"a": 1, "b": 2}, "z": 1}), json!(["text"])); + assert_eq!( + canonical_wire_profile(&first).unwrap(), + canonical_wire_profile(&reordered).unwrap() + ); + let changed = model( + json!({"z": 1, "a": {"b": 2, "a": 1}}), + json!(["image", "text"]), + ); + assert_ne!( + canonical_wire_profile(&first).unwrap(), + canonical_wire_profile(&changed).unwrap() + ); + } +} diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 2baa11fa6..b552ec229 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -29,6 +29,16 @@ impl ProviderRouter { } } + async fn app_proxy_config( + &self, + app_type: &str, + ) -> Result { + if app_type == AppType::Pi.as_str() { + return Ok(crate::settings::get_pi_app_proxy_config()); + } + self.db.get_proxy_config_for_app(app_type).await + } + /// 选择可用的供应商(支持故障转移) /// /// 返回按优先级排序的可用供应商列表: @@ -40,7 +50,7 @@ impl ProviderRouter { let mut circuit_open_count = 0usize; // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) - let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { + let auto_failover_enabled = match self.app_proxy_config(app_type).await { Ok(config) => config.auto_failover_enabled, Err(e) => { log::error!("[{app_type}] 读取 proxy_config 失败: {e},默认禁用故障转移"); @@ -132,7 +142,7 @@ impl ProviderRouter { error_msg: Option, ) -> Result<(), AppError> { // 1. 按应用独立获取熔断器配置 - let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await { + let failure_threshold = match self.app_proxy_config(app_type).await { Ok(app_config) => app_config.circuit_failure_threshold, Err(_) => 5, // 默认值 }; @@ -251,7 +261,7 @@ impl ProviderRouter { let app_type = key.split(':').next().unwrap_or("claude"); // 按应用独立读取熔断器配置 - let config = match self.db.get_proxy_config_for_app(app_type).await { + let config = match self.app_proxy_config(app_type).await { Ok(app_config) => crate::proxy::circuit_breaker::CircuitBreakerConfig { failure_threshold: app_config.circuit_failure_threshold, success_threshold: app_config.circuit_success_threshold, diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 625e1d0bb..ab0f4b35a 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -205,7 +205,11 @@ impl ProviderType { ProviderType::Gemini } AppType::GrokBuild => ProviderType::Codex, - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => ProviderType::Codex, + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => { + // Generic callers cannot infer Pi's wire family from AppType; + // the dedicated Pi runtime routes by effective model API. + ProviderType::Codex + } } } @@ -259,7 +263,11 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::GrokBuild => Box::new(CodexAdapter::new()), - AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Box::new(CodexAdapter::new()), + AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::Pi => { + // Pi requests use the dedicated per-model adapter path. Keep the + // generic fallback deterministic for non-routing utilities. + Box::new(CodexAdapter::new()) + } } } diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 5402a43f7..bf40d5495 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -254,11 +254,11 @@ pub async fn handle_non_streaming( spawn_log_usage( state, ctx, + parser_config.input_token_semantics, usage, &model, &ctx.request_model, status.as_u16(), - false, ); } else { let model = json_value @@ -271,11 +271,11 @@ pub async fn handle_non_streaming( spawn_log_usage( state, ctx, + parser_config.input_token_semantics, TokenUsage::default(), &model, &ctx.request_model, status.as_u16(), - false, ); log::debug!( "[{}] 未能解析 usage 信息,跳过记录", @@ -291,11 +291,11 @@ pub async fn handle_non_streaming( spawn_log_usage( state, ctx, + parser_config.input_token_semantics, TokenUsage::default(), ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model), &ctx.request_model, status.as_u16(), - false, ); } } else { @@ -488,6 +488,7 @@ pub(crate) fn create_usage_collector( let start_time = ctx.start_time; let stream_parser = parser_config.stream_parser; let model_extractor = parser_config.model_extractor; + let input_token_semantics = parser_config.input_token_semantics; let session_id = ctx.session_id.clone(); Some(SseUsageCollector::new( @@ -512,6 +513,7 @@ pub(crate) fn create_usage_collector( &model, &request_model, &outbound_model, + input_token_semantics, usage, latency_ms, first_token_ms, @@ -538,6 +540,7 @@ pub(crate) fn create_usage_collector( &model, &request_model, &outbound_model, + input_token_semantics, TokenUsage::default(), latency_ms, first_token_ms, @@ -557,11 +560,11 @@ pub(crate) fn create_usage_collector( fn spawn_log_usage( state: &ProxyState, ctx: &RequestContext, + input_token_semantics: super::usage::InputTokenSemantics, usage: TokenUsage, model: &str, request_model: &str, status_code: u16, - is_streaming: bool, ) { // Check enable_logging before spawning the log task if let Ok(config) = state.config.try_read() { @@ -591,10 +594,11 @@ fn spawn_log_usage( &model, &request_model, &outbound_model, + input_token_semantics, usage, latency_ms, None, - is_streaming, + false, status_code, Some(session_id), ) @@ -624,6 +628,7 @@ async fn log_usage_internal( model: &str, request_model: &str, outbound_model: &str, + input_token_semantics: super::usage::InputTokenSemantics, usage: TokenUsage, latency_ms: u64, first_token_ms: Option, @@ -661,6 +666,7 @@ async fn log_usage_internal( model.to_string(), request_model.to_string(), pricing_model.to_string(), + input_token_semantics, usage, multiplier, latency_ms, @@ -1001,6 +1007,8 @@ mod tests { codex_chat_history: Arc::new(CodexChatHistoryStore::default()), app_handle: None, failover_manager: Arc::new(FailoverSwitchManager::new(db)), + pi_runtime: Arc::new(crate::proxy::pi_runtime::PiRuntimeStore::default()), + pi_server_generation: 0, } } @@ -1072,6 +1080,7 @@ mod tests { "resp-model", "req-model", "req-model", + crate::proxy::usage::InputTokenSemantics::FreshExcludesCache, usage, 10, None, @@ -1142,6 +1151,7 @@ mod tests { "resp-model", "req-model", "outbound-model", + crate::proxy::usage::InputTokenSemantics::FreshExcludesCache, usage, 10, None, @@ -1222,6 +1232,7 @@ mod tests { "resp-model", "req-model", "req-model", + crate::proxy::usage::InputTokenSemantics::FreshExcludesCache, usage, 10, None, diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index 1d9bdc37c..2cc8baa71 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -12,6 +12,7 @@ use super::{ failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv, + pi_runtime::PiRuntimeStore, provider_router::ProviderRouter, providers::{codex_chat_history::CodexChatHistoryStore, gemini_shadow::GeminiShadowStore}, types::*, @@ -48,6 +49,11 @@ pub struct ProxyState { pub app_handle: Option, /// 故障转移切换管理器 pub failover_manager: Arc, + /// Immutable Pi catalog publication point shared with `ProxyService`. + pub pi_runtime: Arc, + /// Listener instance identity. A runtime built for an older listener can + /// never admit requests through this state. + pub pi_server_generation: u64, } /// 代理HTTP服务器 @@ -57,6 +63,7 @@ pub struct ProxyServer { shutdown_tx: Arc>>>, /// 服务器任务句柄,用于等待服务器实际关闭 server_handle: Arc>>>, + pi_server_generation: u64, } impl ProxyServer { @@ -64,6 +71,8 @@ impl ProxyServer { config: ProxyConfig, db: Arc, app_handle: Option, + pi_runtime: Arc, + pi_server_generation: u64, ) -> Self { // 创建共享的 ProviderRouter(熔断器状态将跨所有请求保持) let provider_router = Arc::new(ProviderRouter::new(db.clone())); @@ -81,6 +90,8 @@ impl ProxyServer { codex_chat_history: Arc::new(CodexChatHistoryStore::default()), app_handle, failover_manager, + pi_runtime, + pi_server_generation, }; Self { @@ -88,9 +99,14 @@ impl ProxyServer { state, shutdown_tx: Arc::new(RwLock::new(None)), server_handle: Arc::new(RwLock::new(None)), + pi_server_generation, } } + pub(crate) fn pi_server_generation(&self) -> u64 { + self.pi_server_generation + } + pub async fn start(&self) -> Result { // 检查是否已在运行 if self.shutdown_tx.read().await.is_some() { @@ -364,6 +380,12 @@ impl ProxyServer { .route("/gemini/v1beta/*path", any(handlers::handle_gemini)) // Gemini 的 GA 版本也叫 /v1,给原 SDK 留一条出口 .route("/gemini/v1/*path", any(handlers::handle_gemini)) + // Pi native SDK requests retain their family-specific path below + // the opaque provider route token. + .route( + "/pi/:route_token/*path", + any(super::pi_handler::handle_pi_native), + ) // 提高默认请求体大小限制(避免 413 Payload Too Large) .layer(DefaultBodyLimit::max(200 * 1024 * 1024)) .with_state(self.state.clone()) diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index 358e68822..768c74f2f 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -116,6 +116,7 @@ pub struct ProxyTakeoverStatus { pub grokbuild: bool, pub opencode: bool, pub openclaw: bool, + pub pi: bool, } /// Provider健康状态 diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index 0defaeda1..6615d0030 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -3,6 +3,7 @@ //! 使用高精度 Decimal 类型避免浮点数精度问题 use super::parser::TokenUsage; +use super::semantics::InputTokenSemantics; use rust_decimal::Decimal; use std::str::FromStr; @@ -46,13 +47,17 @@ impl CostCalculator { pricing: &ModelPricing, cost_multiplier: Decimal, ) -> CostBreakdown { - Self::calculate_with_cache_semantics(usage, pricing, cost_multiplier, false) + Self::calculate_with_input_semantics( + InputTokenSemantics::FreshExcludesCache, + usage, + pricing, + cost_multiplier, + ) } - /// 按 app_type 选择输入 token 语义后计算成本。 - /// - /// Codex/OpenAI Responses 与 Gemini 的输入 token 字段包含 cache read 部分; - /// Claude/Anthropic 的 input_tokens 已经是 fresh input。 + /// Compatibility helper for existing callers. Live request paths use + /// [`Self::calculate_with_input_semantics`] so product app ownership never + /// stands in for the actual response parser/wire family. pub fn calculate_for_app( app_type: &str, usage: &TokenUsage, @@ -61,32 +66,37 @@ impl CostCalculator { ) -> CostBreakdown { let input_includes_cache_read = crate::services::sql_helpers::is_cache_inclusive_app(app_type); - Self::calculate_with_cache_semantics( + Self::calculate_with_input_semantics( + if input_includes_cache_read { + InputTokenSemantics::TotalIncludesCacheBuckets + } else { + InputTokenSemantics::FreshExcludesCache + }, usage, pricing, cost_multiplier, - input_includes_cache_read, ) } - fn calculate_with_cache_semantics( + pub fn calculate_with_input_semantics( + input_semantics: InputTokenSemantics, usage: &TokenUsage, pricing: &ModelPricing, cost_multiplier: Decimal, - input_includes_cache_read: bool, ) -> CostBreakdown { let million = Decimal::from(1_000_000); // OpenAI/Gemini 风格的 input_tokens 包含缓存读取和写入,需要扣除后再按输入价计费; // Claude/Anthropic 风格的 input_tokens 已经是 fresh input,不能再次扣减。 - let billable_input_tokens = if input_includes_cache_read { - usage - .input_tokens - .saturating_sub(usage.cache_read_tokens) - .saturating_sub(usage.cache_creation_tokens) - } else { - usage.input_tokens - }; + let billable_input_tokens = + if input_semantics == InputTokenSemantics::TotalIncludesCacheBuckets { + usage + .input_tokens + .saturating_sub(usage.cache_read_tokens) + .saturating_sub(usage.cache_creation_tokens) + } else { + usage.input_tokens + }; // 各项基础成本(不含倍率) let input_cost = @@ -112,13 +122,15 @@ impl CostCalculator { } } - pub fn try_calculate_for_app( - app_type: &str, + pub fn try_calculate_with_input_semantics( + input_semantics: InputTokenSemantics, usage: &TokenUsage, pricing: Option<&ModelPricing>, cost_multiplier: Decimal, ) -> Option { - pricing.map(|p| Self::calculate_for_app(app_type, usage, p, cost_multiplier)) + pricing.map(|pricing| { + Self::calculate_with_input_semantics(input_semantics, usage, pricing, cost_multiplier) + }) } } diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 7b6362ff3..9f5670ffc 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -2,9 +2,9 @@ use super::calculator::{CostBreakdown, CostCalculator, ModelPricing}; use super::parser::TokenUsage; +use super::semantics::InputTokenSemantics; use crate::database::{Database, PRICING_SOURCE_REQUEST, PRICING_SOURCE_RESPONSE}; use crate::error::AppError; -use crate::services::sql_helpers::{INPUT_TOKEN_SEMANTICS_FRESH, INPUT_TOKEN_SEMANTICS_TOTAL}; use crate::services::usage_stats::{find_model_pricing_row, is_placeholder_pricing_model}; use rusqlite::OptionalExtension; use rust_decimal::Decimal; @@ -72,6 +72,9 @@ pub struct RequestLog { /// 用 model/request_model 猜——路由接管下三者可能各不相同。 /// 错误行(未计价)为空字符串。 pub pricing_model: String, + /// Copied from the response parser/wire family at request admission. + /// Product app ownership is intentionally not consulted at write time. + pub input_token_semantics: InputTokenSemantics, pub usage: TokenUsage, pub cost: Option, pub latency_ms: u64, @@ -121,12 +124,7 @@ impl<'a> UsageLogger<'a> { }; let created_at = chrono::Utc::now().timestamp(); - let input_token_semantics = - if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) { - INPUT_TOKEN_SEMANTICS_TOTAL - } else { - INPUT_TOKEN_SEMANTICS_FRESH - }; + let input_token_semantics = log.input_token_semantics.stored_value(); let semantic = UsageSemantic::from_log(log, input_token_semantics); let existing = Self::load_existing_semantic(&conn, &log.request_id)?; @@ -266,6 +264,7 @@ impl<'a> UsageLogger<'a> { status_code: u16, error_message: String, latency_ms: u64, + input_token_semantics: InputTokenSemantics, ) -> Result<(), AppError> { let request_model = model.clone(); let log = RequestLog { @@ -276,6 +275,7 @@ impl<'a> UsageLogger<'a> { request_model, // 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行) pricing_model: String::new(), + input_token_semantics, usage: TokenUsage::default(), cost: None, latency_ms, @@ -307,6 +307,7 @@ impl<'a> UsageLogger<'a> { is_streaming: bool, session_id: Option, provider_type: Option, + input_token_semantics: InputTokenSemantics, ) -> Result<(), AppError> { let request_model = model.clone(); let log = RequestLog { @@ -317,6 +318,7 @@ impl<'a> UsageLogger<'a> { request_model, // 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行) pricing_model: String::new(), + input_token_semantics, usage: TokenUsage::default(), cost: None, latency_ms, @@ -451,6 +453,7 @@ impl<'a> UsageLogger<'a> { model: String, request_model: String, pricing_model: String, + input_token_semantics: InputTokenSemantics, usage: TokenUsage, cost_multiplier: Decimal, latency_ms: u64, @@ -471,8 +474,8 @@ impl<'a> UsageLogger<'a> { log::warn!("[USG-002] 模型定价未找到,成本将记录为 0: {pricing_model}"); } - let cost = CostCalculator::try_calculate_for_app( - &app_type, + let cost = CostCalculator::try_calculate_with_input_semantics( + input_token_semantics, &usage, pricing.as_ref(), cost_multiplier, @@ -485,6 +488,7 @@ impl<'a> UsageLogger<'a> { model, request_model, pricing_model, + input_token_semantics, usage, cost, latency_ms, @@ -513,6 +517,7 @@ mod tests { model: "gpt-5.6".to_string(), request_model: "gpt-5.6".to_string(), pricing_model: "gpt-5.6".to_string(), + input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets, usage: TokenUsage { input_tokens, output_tokens: 5, @@ -566,6 +571,7 @@ mod tests { "test-model".to_string(), "req-model".to_string(), "test-model".to_string(), + InputTokenSemantics::FreshExcludesCache, usage, Decimal::from(1), 100, @@ -751,6 +757,7 @@ mod tests { 500, "Internal Server Error".to_string(), 50, + InputTokenSemantics::FreshExcludesCache, )?; // 验证错误记录已插入 @@ -778,6 +785,7 @@ mod tests { model: "grok-4.5".to_string(), request_model: "grok-4.5".to_string(), pricing_model: String::new(), + input_token_semantics: InputTokenSemantics::TotalIncludesCacheBuckets, usage: TokenUsage::default(), cost: None, latency_ms: 1, @@ -798,7 +806,10 @@ mod tests { [], |row| row.get(0), )?; - assert_eq!(semantics, INPUT_TOKEN_SEMANTICS_TOTAL); + assert_eq!( + semantics, + InputTokenSemantics::TotalIncludesCacheBuckets.stored_value() + ); Ok(()) } } diff --git a/src-tauri/src/proxy/usage/mod.rs b/src-tauri/src/proxy/usage/mod.rs index 08ef51018..9ea2628f1 100644 --- a/src-tauri/src/proxy/usage/mod.rs +++ b/src-tauri/src/proxy/usage/mod.rs @@ -5,6 +5,7 @@ pub mod calculator; pub mod logger; pub mod parser; +pub mod semantics; // 仅导出内部使用的类型,避免未使用警告 #[allow(unused_imports)] @@ -13,3 +14,5 @@ pub use calculator::{CostBreakdown, CostCalculator, ModelPricing}; pub use logger::{RequestLog, UsageLogger}; #[allow(unused_imports)] pub use parser::TokenUsage; +#[allow(unused_imports)] +pub use semantics::InputTokenSemantics; diff --git a/src-tauri/src/proxy/usage/semantics.rs b/src-tauri/src/proxy/usage/semantics.rs new file mode 100644 index 000000000..8190edfa8 --- /dev/null +++ b/src-tauri/src/proxy/usage/semantics.rs @@ -0,0 +1,32 @@ +//! Input-token semantics carried with every live proxy request. +//! +//! `app_type` is a product/UI ownership dimension. It must not decide whether +//! an upstream's input count already contains cache buckets: Pi can route the +//! same logical app through four different wire families. + +use crate::pi_config::gateway::PiGatewayApiFamily; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i64)] +pub enum InputTokenSemantics { + /// OpenAI Responses/Completions and Google usage totals include cached + /// input. Fresh input is total minus the reported cache buckets. + TotalIncludesCacheBuckets = 1, + /// Anthropic reports fresh input separately from cache reads/creation. + FreshExcludesCache = 2, +} + +impl InputTokenSemantics { + pub const fn stored_value(self) -> i64 { + self as i64 + } + + pub const fn for_pi_family(family: PiGatewayApiFamily) -> Self { + match family { + PiGatewayApiFamily::AnthropicMessages => Self::FreshExcludesCache, + PiGatewayApiFamily::OpenAiCompletions + | PiGatewayApiFamily::OpenAiResponses + | PiGatewayApiFamily::GoogleGenerativeAi => Self::TotalIncludesCacheBuckets, + } + } +} diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 7d465810a..813630297 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -138,6 +138,10 @@ impl ConfigService { AppType::Hermes => { // Hermes uses additive mode, no live sync needed } + AppType::Pi => { + // Pi's shared models/settings documents are owned by the + // catalog coordinator, never by this legacy live-sync path. + } } Ok(()) diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index b57591b82..5160225cd 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -147,6 +147,13 @@ impl McpService { AppType::Hermes => { mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?; } + AppType::Pi => { + return Err(AppError::localized( + "mcp.pi.unsupported", + "固定版本的 Pi 核心没有原生 MCP 注册表", + "The pinned Pi core has no native MCP registry", + )); + } } Ok(()) } @@ -183,6 +190,13 @@ impl McpService { AppType::Hermes => { mcp::remove_server_from_hermes(id)?; } + AppType::Pi => { + return Err(AppError::localized( + "mcp.pi.unsupported", + "固定版本的 Pi 核心没有原生 MCP 注册表", + "The pinned Pi core has no native MCP registry", + )); + } } Ok(()) } @@ -227,7 +241,10 @@ impl McpService { servers: &IndexMap, app: &AppType, ) -> Result<(), AppError> { - if matches!(app, AppType::OpenClaw | AppType::ClaudeDesktop) { + if matches!( + app, + AppType::OpenClaw | AppType::ClaudeDesktop | AppType::Pi + ) { return Ok(()); } @@ -544,3 +561,32 @@ impl McpService { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use std::sync::Arc; + + #[test] + fn global_mcp_projection_treats_pi_as_explicitly_not_applicable() { + let state = AppState::new(Arc::new(Database::memory().expect("database"))); + let mut servers = IndexMap::new(); + servers.insert( + "example".to_string(), + McpServer { + id: "example".to_string(), + name: "Example".to_string(), + server: serde_json::json!({"command": "example"}), + apps: Default::default(), + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + + McpService::project_servers_to_app(&state, &servers, &AppType::Pi) + .expect("Pi is intentionally outside the pinned core MCP registry"); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 2fce3bec7..fb5a36946 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -8,6 +8,8 @@ pub mod mcp; pub mod model_fetch; pub mod model_pricing; pub mod omo; +pub(crate) mod pi_catalog; +pub mod pi_prompt_files; pub mod profile; pub mod prompt; pub mod provider; @@ -21,6 +23,7 @@ pub mod session_usage_gemini; pub mod session_usage_grokbuild; pub mod session_usage_opencode; pub mod skill; +pub(crate) mod skill_deployment; pub mod speedtest; pub mod sql_helpers; pub mod stream_check; diff --git a/src-tauri/src/services/pi_catalog.rs b/src-tauri/src/services/pi_catalog.rs new file mode 100644 index 000000000..a88eeb7c0 --- /dev/null +++ b/src-tauri/src/services/pi_catalog.rs @@ -0,0 +1,1171 @@ +//! Ordered mutations for Pi's managed provider catalog. +//! +//! SQLite is the managed aggregate authority, `pi_provider_projections` owns +//! exact keys in Pi's shared `models.json`, and Pi's `settings.json` owns the +//! native default. Every public mutation acquires the same Pi switch boundary; +//! callers must not compose the database and native-file primitives directly. + +use crate::app_config::AppType; +use crate::database::{NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate}; +use crate::error::AppError; +use crate::pi_config::document::{apply_pi_provider_patch, current_pi_provider_values}; +use crate::pi_config::model::{ + effective_pi_model, validate_pi_managed_provider, PiManagedProviderConfig, PiManagementStatus, +}; +use crate::pi_config::native::{ + get_pi_models_path, inspect_pi_native_entry, PiNativeInspectionService, +}; +use crate::pi_config::native_settings::{ + read_pi_native_defaults, replace_pi_native_defaults, set_pi_native_default, +}; +use crate::provider::{ProviderAggregate, ProviderMutationInput}; +use crate::settings; +use crate::store::AppState; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +const PI_APP: &str = "pi"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum PiCatalogAuthority { + Published, + PreviousRestored, + MutatedDatabaseAuthoritative, + ProjectionPending, +} + +impl PiCatalogAuthority { + fn as_str(self) -> &'static str { + match self { + Self::Published => "published", + Self::PreviousRestored => "previous_restored", + Self::MutatedDatabaseAuthoritative => "mutated_database_authoritative", + Self::ProjectionPending => "projection_pending", + } + } +} + +#[derive(Debug)] +pub(crate) enum PiCatalogMutation { + CreateProvider { + input: ProviderMutationInput, + provider_key: String, + activate_if_first: bool, + }, + UpdateProvider { + input: ProviderMutationInput, + }, + DeleteProvider { + provider_id: String, + }, + AddEndpoint { + provider_id: String, + url: String, + }, + RemoveEndpoint { + provider_id: String, + url: String, + }, + ImportNative { + provider_key: String, + expected_fingerprint: String, + }, + SetDefault { + provider_id: String, + model_id: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PiCatalogMutationResult { + pub authority: PiCatalogAuthority, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_id: Option, +} + +pub(crate) struct PiCatalogCoordinator; + +impl PiCatalogCoordinator { + pub(crate) fn apply( + state: &AppState, + mutation: PiCatalogMutation, + ) -> Result { + Self::run_with_runtime_reconcile(state, || match mutation { + PiCatalogMutation::CreateProvider { + input, + provider_key, + activate_if_first, + } => Self::create(state, input, provider_key, activate_if_first), + PiCatalogMutation::UpdateProvider { input } => Self::update(state, input), + PiCatalogMutation::DeleteProvider { provider_id } => Self::delete(state, &provider_id), + PiCatalogMutation::AddEndpoint { provider_id, url } => { + Self::add_endpoint(state, &provider_id, &url) + } + PiCatalogMutation::RemoveEndpoint { provider_id, url } => { + Self::remove_endpoint(state, &provider_id, &url) + } + PiCatalogMutation::ImportNative { + provider_key, + expected_fingerprint, + } => Self::import_native(state, &provider_key, &expected_fingerprint), + PiCatalogMutation::SetDefault { + provider_id, + model_id, + } => Self::set_default(state, &provider_id, &model_id), + }) + } + + /// Reconcile portable provider rows with this device's exact-key ledger. + /// + /// SQL/WebDAV/S3 exports intentionally omit device-local projection rows. + /// Imported Pi providers use their immutable provider id as their native + /// key. A missing native key can therefore be claimed and published + /// without guessing; an existing unclaimed key is never overwritten. + pub(crate) fn reconcile_portable_import(state: &AppState) -> Result<(), AppError> { + Self::run_with_runtime_reconcile(state, || { + let models_path = get_pi_models_path()?; + Self::reconcile_portable_catalog_at( + state, + &models_path, + |provider_id, provider_key, config| { + state + .proxy_service + .project_pi_provider_value(provider_id, provider_key, config) + }, + )?; + Ok(success(None)) + }) + .map(|_| ()) + } + + fn run_with_runtime_reconcile( + state: &AppState, + operation: impl FnOnce() -> Result, + ) -> Result { + let _switch_guard = futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ); + let catalog_epoch = + futures::executor::block_on(state.proxy_service.begin_pi_catalog_mutation()); + let result = operation(); + let reconcile = futures::executor::block_on( + state + .proxy_service + .reconcile_pi_runtime_at_epoch(catalog_epoch), + ); + match (result, reconcile) { + (Ok(result), Ok(_)) => Ok(result), + (Ok(_), Err(error)) => Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!("Pi catalog mutated but runtime publication failed: {error}"), + )), + (Err(error), Ok(_)) => Err(error), + (Err(error), Err(reconcile_error)) => Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!( + "{error}; additionally failed to reconcile Pi admission: {reconcile_error}" + ), + )), + } + } + + fn reconcile_portable_catalog_at( + state: &AppState, + models_path: &std::path::Path, + mut project: impl FnMut(&str, &str, &PiManagedProviderConfig) -> Result, + ) -> Result<(), AppError> { + let providers = state.db.get_all_providers(PI_APP)?; + let manifest = state.db.get_pi_projection_manifest()?; + let claimed_keys = manifest + .values() + .map(|projection| { + ( + projection.provider_key.clone(), + projection.provider_id.clone(), + ) + }) + .collect::>(); + + struct PlannedProjection { + provider_id: String, + provider_key: String, + config: PiManagedProviderConfig, + projected: Value, + needs_claim: bool, + } + + let mut plans = Vec::with_capacity(providers.len()); + for provider in providers.values() { + let config: PiManagedProviderConfig = + serde_json::from_value(provider.settings_config.clone()).map_err(|error| { + AppError::InvalidInput(format!( + "imported Pi provider '{}' cannot be decoded: {error}", + provider.id + )) + })?; + validate_pi_managed_provider(&config).map_err(|error| { + AppError::InvalidInput(format!( + "imported Pi provider '{}' is invalid: {error}", + provider.id + )) + })?; + let (provider_key, needs_claim) = match manifest.get(&provider.id) { + Some(projection) => (projection.provider_key.clone(), false), + None => (non_empty_native_key(&provider.id)?.to_string(), true), + }; + if let Some(owner) = claimed_keys.get(&provider_key) { + if owner != &provider.id { + return Err(AppError::Conflict(format!( + "cannot project imported Pi provider '{}': native key '{}' is owned by '{}'", + provider.id, provider_key, owner + ))); + } + } + let projected = project(&provider.id, &provider_key, &config)?; + plans.push(PlannedProjection { + provider_id: provider.id.clone(), + provider_key, + config, + projected, + needs_claim, + }); + } + + let before_file = current_pi_provider_values( + models_path, + plans.iter().map(|plan| plan.provider_key.clone()), + )?; + for plan in plans.iter().filter(|plan| plan.needs_claim) { + if before_file + .get(&plan.provider_key) + .and_then(Option::as_ref) + .is_some() + { + return Err(AppError::Conflict(format!( + "cannot project imported Pi provider '{}': unclaimed native key '{}' already exists", + plan.provider_id, plan.provider_key + ))); + } + } + // Native defaults are another authority input to the reconciliation + // plan. Read and validate them before changing either the exact-key + // ownership ledger or models.json, so an unreadable settings.json is a + // fail-before-write error rather than a partially compensated publish. + let previous_defaults = read_pi_native_defaults()?; + + // Portable restore replaces provider rows but deliberately preserves + // the device-local exact-key ledger. Claims whose providers disappeared + // must be released before publishing a runtime, otherwise the manifest + // and provider aggregate sets can never converge. Their native values + // remain untouched and become user-owned; without a retained provider + // aggregate there is no safe expected value with which to delete them. + let orphaned_claims = manifest + .values() + .filter(|projection| !providers.contains_key(&projection.provider_id)) + .map(|projection| { + ( + projection.provider_id.clone(), + projection.provider_key.clone(), + ) + }) + .collect::>(); + let mut released_claims = Vec::new(); + for (provider_id, provider_key) in &orphaned_claims { + match state.db.delete_pi_projection_key(provider_id, provider_key) { + Ok(true) => released_claims.push((provider_id.clone(), provider_key.clone())), + Ok(false) => { + return Err(compensate_portable_projection_ledger( + state, + &[], + &released_claims, + AppError::Conflict(format!( + "Pi projection claim '{provider_id}' disappeared during import reconciliation" + )), + )); + } + Err(error) => { + return Err(compensate_portable_projection_ledger( + state, + &[], + &released_claims, + error, + )); + } + } + } + + let mut newly_claimed = Vec::new(); + for plan in plans.iter().filter(|plan| plan.needs_claim) { + match state + .db + .claim_pi_projection_key(&plan.provider_id, &plan.provider_key) + { + Ok(_) => newly_claimed.push((plan.provider_id.clone(), plan.provider_key.clone())), + Err(error) => { + return Err(compensate_portable_projection_ledger( + state, + &newly_claimed, + &released_claims, + error, + )); + } + } + } + + let patch = plans + .iter() + .map(|plan| (plan.provider_key.clone(), Some(plan.projected.clone()))) + .collect::>(); + if !patch.is_empty() { + if let Err(error) = apply_pi_provider_patch(models_path, &patch) { + let file_restored = apply_pi_provider_patch(models_path, &before_file); + let claim_error = compensate_portable_projection_ledger( + state, + &newly_claimed, + &released_claims, + error, + ); + return Err(if file_restored.is_ok() { + claim_error + } else { + authority_error( + PiCatalogAuthority::ProjectionPending, + format!("{claim_error}; native catalog compensation failed"), + ) + }); + } + } + + let native_selection = match ( + previous_defaults.default_provider.as_deref(), + previous_defaults.default_model.as_deref(), + ) { + (Some(provider_key), Some(model_id)) => plans + .iter() + .find(|plan| { + plan.provider_key == provider_key + && effective_pi_model(&plan.config, model_id).is_ok() + }) + .map(|plan| (plan.provider_id.clone(), model_id.to_string())), + _ => None, + }; + // Only use a DB/local fallback when Pi has no native selection at all. + // A partial, invalid, or unowned native default is still real state and + // must be surfaced rather than silently overwritten during import. + let current_selection = if native_selection.is_some() { + native_selection + } else if previous_defaults.default_provider.is_none() + && previous_defaults.default_model.is_none() + { + crate::settings::get_effective_current_provider(&state.db, &AppType::Pi)? + .and_then(|provider_id| plans.iter().find(|plan| plan.provider_id == provider_id)) + .map(|plan| { + ( + plan.provider_id.clone(), + plan.config + .models + .first() + .expect("validated Pi provider has at least one model") + .id + .clone(), + ) + }) + } else { + None + }; + + if let Some((current_provider, model_id)) = current_selection { + let previous_local = settings::get_current_provider(&AppType::Pi); + let previous_db = state.db.get_current_provider(PI_APP)?; + if let Err(error) = Self::set_default(state, ¤t_provider, &model_id) { + let file_restored = apply_pi_provider_patch(models_path, &before_file); + let claims_restored = compensate_portable_projection_ledger( + state, + &newly_claimed, + &released_claims, + error, + ); + let defaults_restored = replace_pi_native_defaults(&previous_defaults); + let local_restored = + settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); + let db_restored = restore_db_current(state, previous_db.as_deref()); + if file_restored.is_err() + || defaults_restored.is_err() + || local_restored.is_err() + || db_restored.is_err() + { + return Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!( + "{claims_restored}; imported Pi default compensation was incomplete" + ), + )); + } + return Err(claims_restored); + } + } + Ok(()) + } + + pub(crate) fn inspect_native( + state: &AppState, + ) -> Result, AppError> { + PiNativeInspectionService::inspect_current(&managed_claims(state)?) + } + + /// Resolve the provider that Pi itself will start with. + /// + /// `settings.json` is the live authority. The device-local and SQLite + /// current markers are compensation/indexing aids and must never make the + /// UI claim that a different provider is active after an external Pi edit. + pub(crate) fn current_native_provider(state: &AppState) -> Result, AppError> { + let defaults = read_pi_native_defaults()?; + let Some(provider_key) = defaults.default_provider else { + return Ok(None); + }; + let Some(projection) = state.db.get_pi_projection_for_key(&provider_key)? else { + return Ok(None); + }; + Ok(state + .db + .get_provider_aggregate(PI_APP, &projection.provider_id)? + .is_some() + .then_some(projection.provider_id)) + } + + fn create( + state: &AppState, + input: ProviderMutationInput, + provider_key: String, + activate_if_first: bool, + ) -> Result { + let provider_key = non_empty_native_key(&provider_key)?; + let config = managed_config(&input)?; + let provider_id = input.id.clone(); + if state.db.get_pi_projection_for_key(provider_key)?.is_some() { + return Err(AppError::Conflict(format!( + "Pi native provider key '{provider_key}' is already managed" + ))); + } + + let models_path = get_pi_models_path()?; + let before_file = current_pi_provider_values(&models_path, [provider_key.to_string()])?; + if before_file + .get(provider_key) + .and_then(Option::as_ref) + .is_some() + { + return Err(AppError::Conflict(format!( + "Pi native provider key '{provider_key}' already exists; import it instead" + ))); + } + + let previous_defaults = read_pi_native_defaults()?; + let previous_local = settings::get_current_provider(&AppType::Pi); + let previous_db = state.db.get_current_provider(PI_APP)?; + let projected = + state + .proxy_service + .project_pi_provider_value(&provider_id, provider_key, &config)?; + state.db.create_pi_catalog_provider( + NewProviderAggregate::from_input(PI_APP, input)?, + provider_key, + )?; + + let projection = IndexMap::from([(provider_key.to_string(), Some(projected))]); + if let Err(projection_error) = apply_pi_provider_patch(&models_path, &projection) { + return Err(compensate_created_provider( + state, + &models_path, + &provider_id, + &before_file, + projection_error, + )); + } + + let no_selected_provider = previous_local.is_none() && previous_db.is_none(); + let native_defaults_empty = previous_defaults.default_provider.is_none() + && previous_defaults.default_model.is_none(); + if activate_if_first && no_selected_provider && native_defaults_empty { + let first_model = config + .models + .first() + .expect("validated Pi config has at least one model") + .id + .clone(); + if let Err(error) = Self::set_default(state, &provider_id, &first_model) { + let defaults_restore = replace_pi_native_defaults(&previous_defaults); + let local_restore = + settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); + let db_restore = restore_db_current(state, previous_db.as_deref()); + let catalog_restore = compensate_created_provider( + state, + &models_path, + &provider_id, + &before_file, + error, + ); + if defaults_restore.is_err() || local_restore.is_err() || db_restore.is_err() { + return Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!( + "Pi create activation failed and compensation was incomplete: {catalog_restore}" + ), + )); + } + return Err(catalog_restore); + } + } + + Ok(success(Some(provider_id))) + } + + fn update( + state: &AppState, + input: ProviderMutationInput, + ) -> Result { + let config = managed_config(&input)?; + let provider_id = input.id.clone(); + let projection = state.db.get_pi_projection(&provider_id)?.ok_or_else(|| { + AppError::Conflict(format!( + "Pi provider '{provider_id}' has no exact-key ownership claim" + )) + })?; + let previous = state + .db + .get_provider_aggregate(PI_APP, &provider_id)? + .ok_or_else(|| AppError::NotFound(format!("Pi provider '{provider_id}'")))?; + let previous_defaults = read_pi_native_defaults()?; + let previous_local = settings::get_current_provider(&AppType::Pi); + let previous_db = state.db.get_current_provider(PI_APP)?; + let was_current = previous_db.as_deref() == Some(&provider_id); + let models_path = get_pi_models_path()?; + let before_file = + current_pi_provider_values(&models_path, [projection.provider_key.clone()])?; + let projected = state.proxy_service.project_pi_provider_value( + &provider_id, + &projection.provider_key, + &config, + )?; + + let key = ProviderKey::new(PI_APP, provider_id.clone())?; + state + .db + .update_pi_catalog_provider(&key, &ProviderRowUpdate::from_input(&input)?)?; + let patch = IndexMap::from([(projection.provider_key.clone(), Some(projected))]); + if let Err(error) = apply_pi_provider_patch(&models_path, &patch) { + return Err(compensate_existing_provider( + state, + &models_path, + &previous, + was_current, + &projection, + &before_file, + error, + )); + } + if previous_defaults.default_provider.as_deref() == Some(&projection.provider_key) { + if let Some(default_model) = previous_defaults.default_model.as_deref() { + if effective_pi_model(&config, default_model).is_err() { + let replacement = config + .models + .first() + .expect("validated Pi config has at least one model") + .id + .clone(); + if let Err(error) = Self::set_default(state, &provider_id, &replacement) { + let defaults_restored = replace_pi_native_defaults(&previous_defaults); + let local_restored = + settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); + let db_restored = restore_db_current(state, previous_db.as_deref()); + let catalog_restored = compensate_existing_provider( + state, + &models_path, + &previous, + was_current, + &projection, + &before_file, + error, + ); + if defaults_restored.is_err() + || local_restored.is_err() + || db_restored.is_err() + { + return Err(authority_error( + PiCatalogAuthority::ProjectionPending, + format!( + "{catalog_restored}; Pi default-model compensation was incomplete" + ), + )); + } + return Err(catalog_restored); + } + } + } + } + Ok(success(Some(provider_id))) + } + + fn delete(state: &AppState, provider_id: &str) -> Result { + let projection = state + .db + .get_pi_projection(provider_id)? + .ok_or_else(|| AppError::NotFound(format!("Pi provider '{provider_id}'")))?; + let previous = state + .db + .get_provider_aggregate(PI_APP, provider_id)? + .ok_or_else(|| AppError::NotFound(format!("Pi provider '{provider_id}'")))?; + let local_current = settings::get_current_provider(&AppType::Pi); + let db_current = state.db.get_current_provider(PI_APP)?; + let native_defaults = read_pi_native_defaults()?; + if local_current.as_deref() == Some(provider_id) + || db_current.as_deref() == Some(provider_id) + || native_defaults.default_provider.as_deref() == Some(&projection.provider_key) + { + return Err(AppError::Conflict( + "the active Pi provider cannot be deleted".to_string(), + )); + } + + let models_path = get_pi_models_path()?; + let before_file = + current_pi_provider_values(&models_path, [projection.provider_key.clone()])?; + let was_current = db_current.as_deref() == Some(provider_id); + state.db.delete_pi_catalog_provider(provider_id)?; + let patch = IndexMap::from([(projection.provider_key.clone(), None)]); + if let Err(error) = apply_pi_provider_patch(&models_path, &patch) { + return Err(compensate_existing_provider( + state, + &models_path, + &previous, + was_current, + &projection, + &before_file, + error, + )); + } + Ok(success(Some(provider_id.to_string()))) + } + + fn add_endpoint( + state: &AppState, + provider_id: &str, + url: &str, + ) -> Result { + ensure_managed_provider(state, provider_id)?; + let normalized = normalize_endpoint(url)?; + state.db.add_provider_endpoint( + &ProviderKey::new(PI_APP, provider_id)?, + NewEndpoint::now(normalized)?, + )?; + Ok(success(Some(provider_id.to_string()))) + } + + fn remove_endpoint( + state: &AppState, + provider_id: &str, + url: &str, + ) -> Result { + ensure_managed_provider(state, provider_id)?; + let normalized = normalize_endpoint(url)?; + state + .db + .remove_provider_endpoint(&ProviderKey::new(PI_APP, provider_id)?, &normalized)?; + Ok(success(Some(provider_id.to_string()))) + } + + fn import_native( + state: &AppState, + provider_key: &str, + expected_fingerprint: &str, + ) -> Result { + let provider_key = non_empty_native_key(provider_key)?; + let claims = managed_claims(state)?; + let inspection = inspect_pi_native_entry(&get_pi_models_path()?, provider_key, &claims)? + .ok_or_else(|| AppError::NotFound(format!("Pi native provider '{provider_key}'")))?; + if inspection.diagnostic.fingerprint != expected_fingerprint { + return Err(AppError::Conflict(format!( + "Pi native provider '{provider_key}' changed since inspection" + ))); + } + if inspection.diagnostic.management_status != PiManagementStatus::Importable { + return Err(AppError::Conflict(format!( + "Pi native provider '{provider_key}' is not importable" + ))); + } + let config = inspection.managed_config.ok_or_else(|| { + AppError::Conflict(format!( + "Pi native provider '{provider_key}' is not representable by the managed catalog" + )) + })?; + validate_pi_managed_provider(&config) + .map_err(|error| AppError::InvalidInput(error.to_string()))?; + let display_name = config + .name + .clone() + .unwrap_or_else(|| provider_key.to_string()); + let input = ProviderMutationInput { + id: provider_key.to_string(), + name: display_name, + settings_config: serde_json::to_value(config).map_err(|source| { + AppError::Config(format!( + "failed to serialize imported Pi provider: {source}" + )) + })?, + website_url: None, + category: Some("imported".to_string()), + created_at: Some(chrono::Utc::now().timestamp_millis()), + sort_index: state + .db + .get_all_providers(PI_APP)? + .values() + .filter_map(|provider| provider.sort_index) + .max() + .map_or(Some(0), |index| index.checked_add(1)), + notes: None, + meta: None, + icon: Some("pi".to_string()), + icon_color: None, + in_failover_queue: false, + }; + state.db.create_pi_catalog_provider( + NewProviderAggregate::from_input(PI_APP, input)?, + provider_key, + )?; + Ok(success(Some(provider_key.to_string()))) + } + + fn set_default( + state: &AppState, + provider_id: &str, + model_id: &str, + ) -> Result { + let aggregate = ensure_managed_provider(state, provider_id)?; + let config: PiManagedProviderConfig = serde_json::from_value( + aggregate.provider.settings_config.clone(), + ) + .map_err(|error| { + AppError::Config(format!( + "managed Pi provider '{provider_id}' is invalid: {error}" + )) + })?; + effective_pi_model(&config, model_id) + .map_err(|error| AppError::InvalidInput(error.to_string()))?; + let projection = state.db.get_pi_projection(provider_id)?.ok_or_else(|| { + AppError::Conflict(format!( + "Pi provider '{provider_id}' has no exact-key ownership claim" + )) + })?; + + let previous_defaults = read_pi_native_defaults()?; + let previous_local = settings::get_current_provider(&AppType::Pi); + let previous_db = state.db.get_current_provider(PI_APP)?; + set_pi_native_default(&projection.provider_key, model_id)?; + if let Err(error) = settings::set_current_provider(&AppType::Pi, Some(provider_id)) { + let restored = replace_pi_native_defaults(&previous_defaults); + return Err(if restored.is_ok() { + authority_error(PiCatalogAuthority::PreviousRestored, error.to_string()) + } else { + authority_error(PiCatalogAuthority::ProjectionPending, error.to_string()) + }); + } + if let Err(error) = state.db.set_current_provider(PI_APP, provider_id) { + let native_restored = replace_pi_native_defaults(&previous_defaults); + let local_restored = + settings::set_current_provider(&AppType::Pi, previous_local.as_deref()); + let db_restored = restore_db_current(state, previous_db.as_deref()); + let authority = + if native_restored.is_ok() && local_restored.is_ok() && db_restored.is_ok() { + PiCatalogAuthority::PreviousRestored + } else { + PiCatalogAuthority::ProjectionPending + }; + return Err(authority_error(authority, error.to_string())); + } + Ok(success(Some(provider_id.to_string()))) + } +} + +fn managed_config(input: &ProviderMutationInput) -> Result { + let config: PiManagedProviderConfig = serde_json::from_value(input.settings_config.clone()) + .map_err(|error| { + AppError::InvalidInput(format!("invalid managed Pi provider config: {error}")) + })?; + validate_pi_managed_provider(&config) + .map_err(|error| AppError::InvalidInput(error.to_string()))?; + Ok(config) +} + +fn ensure_managed_provider( + state: &AppState, + provider_id: &str, +) -> Result { + if state.db.get_pi_projection(provider_id)?.is_none() { + return Err(AppError::Conflict(format!( + "Pi provider '{provider_id}' has no exact-key ownership claim" + ))); + } + state + .db + .get_provider_aggregate(PI_APP, provider_id)? + .ok_or_else(|| AppError::NotFound(format!("Pi provider '{provider_id}'"))) +} + +fn managed_claims(state: &AppState) -> Result, AppError> { + Ok(state + .db + .get_pi_projection_manifest()? + .into_values() + .map(|projection| (projection.provider_key, projection.provider_id)) + .collect()) +} + +fn non_empty_native_key(value: &str) -> Result<&str, AppError> { + let value = value.trim(); + if value.is_empty() { + Err(AppError::InvalidInput( + "Pi native provider key cannot be empty".to_string(), + )) + } else { + Ok(value) + } +} + +fn normalize_endpoint(value: &str) -> Result { + let normalized = value.trim().trim_end_matches('/').to_string(); + if normalized.is_empty() { + return Err(AppError::InvalidInput( + "Pi endpoint URL cannot be empty".to_string(), + )); + } + let parsed = url::Url::parse(&normalized) + .map_err(|error| AppError::InvalidInput(format!("invalid Pi endpoint URL: {error}")))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host().is_none() { + return Err(AppError::InvalidInput( + "Pi endpoint must be an absolute HTTP(S) URL".to_string(), + )); + } + Ok(normalized) +} + +fn compensate_created_provider( + state: &AppState, + models_path: &std::path::Path, + provider_id: &str, + before_file: &IndexMap>, + cause: AppError, +) -> AppError { + let db_restored = state.db.delete_pi_catalog_provider(provider_id); + let file_restored = apply_pi_provider_patch(models_path, before_file); + if db_restored.is_ok() && file_restored.is_ok() { + authority_error(PiCatalogAuthority::PreviousRestored, cause.to_string()) + } else { + authority_error( + if db_restored.is_err() { + PiCatalogAuthority::MutatedDatabaseAuthoritative + } else { + PiCatalogAuthority::ProjectionPending + }, + format!("{}; create compensation was incomplete", cause), + ) + } +} + +fn compensate_portable_projection_ledger( + state: &AppState, + new_claims: &[(String, String)], + released_claims: &[(String, String)], + cause: AppError, +) -> AppError { + let mut restored = true; + for (provider_id, provider_key) in new_claims.iter().rev() { + if state + .db + .delete_pi_projection_key(provider_id, provider_key) + .is_err() + { + // Continue compensating the remaining claims even after one row + // fails. A short-circuit here would strand every earlier claim. + restored = false; + } + } + for (provider_id, provider_key) in released_claims { + if state + .db + .claim_pi_projection_key(provider_id, provider_key) + .is_err() + { + restored = false; + } + } + if restored { + authority_error(PiCatalogAuthority::PreviousRestored, cause) + } else { + authority_error( + PiCatalogAuthority::ProjectionPending, + format!("{cause}; imported projection-ledger compensation was incomplete"), + ) + } +} + +#[allow(clippy::too_many_arguments)] +fn compensate_existing_provider( + state: &AppState, + models_path: &std::path::Path, + previous: &ProviderAggregate, + was_current: bool, + projection: &crate::database::PiProviderProjection, + before_file: &IndexMap>, + cause: AppError, +) -> AppError { + let db_restored = state + .db + .restore_pi_catalog_provider(previous, was_current, Some(projection)); + let file_restored = apply_pi_provider_patch(models_path, before_file); + if db_restored.is_ok() && file_restored.is_ok() { + authority_error(PiCatalogAuthority::PreviousRestored, cause.to_string()) + } else { + authority_error( + if db_restored.is_err() { + PiCatalogAuthority::MutatedDatabaseAuthoritative + } else { + PiCatalogAuthority::ProjectionPending + }, + format!("{}; catalog compensation was incomplete", cause), + ) + } +} + +fn restore_db_current(state: &AppState, previous: Option<&str>) -> Result<(), AppError> { + match previous { + Some(provider_id) => state.db.set_current_provider(PI_APP, provider_id), + None => state.db.clear_current_provider_for_app(PI_APP), + } +} + +fn success(provider_id: Option) -> PiCatalogMutationResult { + PiCatalogMutationResult { + authority: PiCatalogAuthority::Published, + provider_id, + } +} + +fn authority_error(authority: PiCatalogAuthority, message: impl std::fmt::Display) -> AppError { + AppError::Message(format!( + "{message} [authoritative_state={}]", + authority.as_str() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use serde_json::json; + use std::sync::Arc; + + fn insert_portable_pi_provider(db: &Database, id: &str) -> Result { + let config = json!({ + "name": "Portable Pi", + "api": "openai-responses", + "baseUrl": "https://portable.example/v1", + "apiKey": "literal-key", + "headers": {"x-capture": "present"}, + "models": [{ + "id": "portable-model", + "name": "Portable model", + "compat": {"supportsStore": true} + }] + }); + let conn = crate::database::lock_conn!(db.conn); + conn.execute( + "INSERT INTO providers + (id, app_type, name, settings_config, meta, is_current) + VALUES (?1, 'pi', 'Portable Pi', ?2, '{}', 0)", + rusqlite::params![id, config.to_string()], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(config) + } + + #[test] + fn portable_sql_import_claims_only_an_absent_identity_key_and_is_idempotent( + ) -> Result<(), AppError> { + let source = Database::memory()?; + let expected = insert_portable_pi_provider(&source, "portable-pi")?; + let exported = source.export_sql_string()?; + assert!(!exported.contains("INSERT INTO \"pi_provider_projections\"")); + + let target = Arc::new(Database::memory()?); + target.import_sql_string(&exported)?; + assert!(target.get_pi_projection("portable-pi")?.is_none()); + let state = AppState::new(target.clone()); + let temp = tempfile::tempdir().expect("tempdir"); + let models_path = temp.path().join("models.json"); + + let project_direct = + |_: &str, _: &str, config: &PiManagedProviderConfig| -> Result { + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source }) + }; + PiCatalogCoordinator::reconcile_portable_catalog_at(&state, &models_path, project_direct)?; + PiCatalogCoordinator::reconcile_portable_catalog_at(&state, &models_path, project_direct)?; + + let projection = target + .get_pi_projection("portable-pi")? + .expect("projection"); + assert_eq!(projection.provider_key, "portable-pi"); + let document: Value = + serde_json::from_slice(&std::fs::read(&models_path).expect("read models")) + .expect("parse models"); + assert_eq!( + document.pointer("/providers/portable-pi"), + Some(&expected), + "all portable provider fields must survive SQL import and native publication" + ); + Ok(()) + } + + #[test] + fn portable_import_never_claims_or_overwrites_an_unowned_native_key() -> Result<(), AppError> { + let db = Arc::new(Database::memory()?); + insert_portable_pi_provider(&db, "occupied")?; + let state = AppState::new(db.clone()); + let temp = tempfile::tempdir().expect("tempdir"); + let models_path = temp.path().join("models.json"); + let original = json!({ + "providers": { + "occupied": { + "api": "anthropic-messages", + "baseUrl": "https://native.example", + "apiKey": "native-secret", + "models": [{"id": "native-model"}] + } + } + }); + std::fs::write( + &models_path, + serde_json::to_vec_pretty(&original).expect("serialize"), + ) + .expect("write models"); + + let error = PiCatalogCoordinator::reconcile_portable_catalog_at( + &state, + &models_path, + |_, _, config| { + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source }) + }, + ) + .expect_err("unowned native value must block import publication"); + assert!(error.to_string().contains("unclaimed native key")); + assert!(db.get_pi_projection("occupied")?.is_none()); + let after: Value = + serde_json::from_slice(&std::fs::read(&models_path).expect("read models")) + .expect("parse models"); + assert_eq!(after, original); + Ok(()) + } + + #[test] + fn portable_import_releases_orphan_claim_but_preserves_its_native_value() -> Result<(), AppError> + { + let source = Database::memory()?; + let remote = insert_portable_pi_provider(&source, "remote")?; + let exported = source.export_sql_string()?; + + let target = Arc::new(Database::memory()?); + insert_portable_pi_provider(&target, "local")?; + target.claim_pi_projection_key("local", "local-native")?; + target.import_sql_string(&exported)?; + assert!( + target.get_provider_aggregate(PI_APP, "local")?.is_none(), + "portable provider rows must be replaced" + ); + assert!( + target.get_pi_projection("local")?.is_some(), + "the device-local claim is intentionally preserved by restore" + ); + + let state = AppState::new(target.clone()); + let temp = tempfile::tempdir().expect("tempdir"); + let models_path = temp.path().join("models.json"); + let local_native = json!({ + "api": "anthropic-messages", + "baseUrl": "https://local.example", + "apiKey": "local-secret", + "models": [{"id": "local-model"}] + }); + std::fs::write( + &models_path, + serde_json::to_vec_pretty(&json!({ + "providers": {"local-native": local_native.clone()} + })) + .expect("serialize"), + ) + .expect("write models"); + + PiCatalogCoordinator::reconcile_portable_catalog_at( + &state, + &models_path, + |_, _, config| { + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source }) + }, + )?; + + let manifest = target.get_pi_projection_manifest()?; + assert_eq!(manifest.len(), 1); + assert_eq!(manifest["remote"].provider_key, "remote"); + let document: Value = + serde_json::from_slice(&std::fs::read(&models_path).expect("read models")) + .expect("parse models"); + assert_eq!( + document.pointer("/providers/local-native"), + Some(&local_native), + "an orphaned exact-key value has no safe deletion proof and must become user-owned" + ); + assert_eq!(document.pointer("/providers/remote"), Some(&remote)); + Ok(()) + } + + #[test] + fn portable_import_of_empty_pi_catalog_releases_all_device_claims() -> Result<(), AppError> { + let empty_source = Database::memory()?; + let exported = empty_source.export_sql_string()?; + let target = Arc::new(Database::memory()?); + insert_portable_pi_provider(&target, "local")?; + target.claim_pi_projection_key("local", "local-native")?; + target.import_sql_string(&exported)?; + + let state = AppState::new(target.clone()); + let temp = tempfile::tempdir().expect("tempdir"); + let models_path = temp.path().join("models.json"); + let source = json!({"providers": {"local-native": {"models": [{"id": "m"}]}}}); + std::fs::write( + &models_path, + serde_json::to_vec_pretty(&source).expect("serialize"), + ) + .expect("models"); + + PiCatalogCoordinator::reconcile_portable_catalog_at( + &state, + &models_path, + |_, _, config| { + serde_json::to_value(config).map_err(|source| AppError::JsonSerialize { source }) + }, + )?; + + assert!(target.get_pi_projection_manifest()?.is_empty()); + assert_eq!( + serde_json::from_slice::(&std::fs::read(&models_path).expect("read models")) + .expect("parse"), + source, + "empty portable catalogs must not create, rewrite, or delete native values" + ); + Ok(()) + } +} diff --git a/src-tauri/src/services/pi_prompt_files.rs b/src-tauri/src/services/pi_prompt_files.rs new file mode 100644 index 000000000..50e65ca36 --- /dev/null +++ b/src-tauri/src/services/pi_prompt_files.rs @@ -0,0 +1,373 @@ +//! Pi native instruction files and prompt templates. +//! +//! AGENTS.md is also the Prompt-library projection. SYSTEM.md and +//! APPEND_SYSTEM.md are direct native resources: file presence is activation +//! and there is no shadow enabled flag. + +use crate::error::AppError; +use crate::pi_config::native::get_pi_agent_dir; +use crate::pi_config::shared_file::{delete_shared_file, read_shared_file, replace_shared_file}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex, MutexGuard}; + +const MAX_PROMPT_FILE_BYTES: u64 = 1024 * 1024; +const MAX_TEMPLATE_SLUG_BYTES: usize = 128; +static INSTRUCTION_FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +pub(crate) fn lock_instruction_files() -> Result, AppError> { + INSTRUCTION_FILE_LOCK + .lock() + .map_err(|error| AppError::Config(format!("Pi instruction-file lock is poisoned: {error}"))) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PiPromptFileKind { + GlobalContext, + SystemOverride, + SystemAppend, +} + +impl PiPromptFileKind { + fn filename(self) -> &'static str { + match self { + Self::GlobalContext => "AGENTS.md", + Self::SystemOverride => "SYSTEM.md", + Self::SystemAppend => "APPEND_SYSTEM.md", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiPromptFileSnapshot { + pub kind: PiPromptFileKind, + pub path: String, + pub exists: bool, + pub revision: String, + pub content: String, +} + +pub struct PiPromptFileService; + +impl PiPromptFileService { + pub fn read(kind: PiPromptFileKind) -> Result { + let guard = lock_instruction_files()?; + Self::read_under_guard(&guard, kind) + } + + pub fn replace( + kind: PiPromptFileKind, + expected_revision: &str, + content: &str, + ) -> Result { + if kind == PiPromptFileKind::GlobalContext { + return Err(AppError::InvalidInput( + "Pi AGENTS.md is managed through the Prompt library".to_string(), + )); + } + validate_direct_instruction_content(content)?; + let guard = lock_instruction_files()?; + Self::replace_under_guard(&guard, kind, expected_revision, content) + } + + pub fn delete(kind: PiPromptFileKind, expected_revision: &str) -> Result { + if kind == PiPromptFileKind::GlobalContext { + return Err(AppError::InvalidInput( + "Pi AGENTS.md is managed through the Prompt library".to_string(), + )); + } + let guard = lock_instruction_files()?; + Self::delete_under_guard(&guard, kind, expected_revision) + } + + pub(crate) fn read_under_guard( + _guard: &MutexGuard<'static, ()>, + kind: PiPromptFileKind, + ) -> Result { + Self::read_at(&get_pi_agent_dir()?, kind) + } + + pub(crate) fn replace_under_guard( + _guard: &MutexGuard<'static, ()>, + kind: PiPromptFileKind, + expected_revision: &str, + content: &str, + ) -> Result { + Self::replace_at(&get_pi_agent_dir()?, kind, expected_revision, content) + } + + pub(crate) fn delete_under_guard( + _guard: &MutexGuard<'static, ()>, + kind: PiPromptFileKind, + expected_revision: &str, + ) -> Result { + Self::delete_at(&get_pi_agent_dir()?, kind, expected_revision) + } + + fn read_at(root: &Path, kind: PiPromptFileKind) -> Result { + let path = root.join(kind.filename()); + let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt file")?; + let exists = snapshot.exists(); + let content = match snapshot.bytes { + Some(bytes) => String::from_utf8(bytes).map_err(|error| { + AppError::InvalidInput(format!( + "Pi prompt file must be UTF-8 ({}): {error}", + path.display() + )) + })?, + None => String::new(), + }; + Ok(PiPromptFileSnapshot { + kind, + path: path.to_string_lossy().into_owned(), + exists, + revision: snapshot.revision, + content, + }) + } + + fn replace_at( + root: &Path, + kind: PiPromptFileKind, + expected_revision: &str, + content: &str, + ) -> Result { + fs::create_dir_all(root).map_err(|error| AppError::io(root, error))?; + let path = root.join(kind.filename()); + replace_shared_file( + &path, + expected_revision, + content.as_bytes(), + MAX_PROMPT_FILE_BYTES, + Some(0o600), + "Pi prompt file", + )?; + Self::read_at(root, kind) + } + + fn delete_at( + root: &Path, + kind: PiPromptFileKind, + expected_revision: &str, + ) -> Result { + delete_shared_file( + &root.join(kind.filename()), + expected_revision, + MAX_PROMPT_FILE_BYTES, + "Pi prompt file", + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiPromptTemplate { + pub slug: String, + pub content: String, + pub revision: String, +} + +pub struct PiPromptTemplateService; + +impl PiPromptTemplateService { + pub fn list() -> Result, AppError> { + Self::list_at(&get_pi_agent_dir()?.join("prompts")) + } + + pub fn upsert( + slug: &str, + expected_revision: &str, + content: &str, + ) -> Result { + Self::upsert_at( + &get_pi_agent_dir()?.join("prompts"), + slug, + expected_revision, + content, + ) + } + + pub fn delete(slug: &str, expected_revision: &str) -> Result { + validate_template_slug(slug)?; + delete_shared_file( + &template_path(&get_pi_agent_dir()?.join("prompts"), slug), + expected_revision, + MAX_PROMPT_FILE_BYTES, + "Pi prompt template", + ) + } + + fn list_at(dir: &Path) -> Result, AppError> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(AppError::io(dir, error)), + }; + let mut templates = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| AppError::io(dir, error))?; + let path = entry.path(); + let Some(slug) = path.file_stem().and_then(|value| value.to_str()) else { + continue; + }; + if path.extension().and_then(|value| value.to_str()) != Some("md") + || validate_template_slug(slug).is_err() + { + continue; + } + let snapshot = read_shared_file(&path, MAX_PROMPT_FILE_BYTES, "Pi prompt template")?; + let Some(bytes) = snapshot.bytes else { + continue; + }; + let content = String::from_utf8(bytes).map_err(|error| { + AppError::InvalidInput(format!( + "Pi prompt template must be UTF-8 ({}): {error}", + path.display() + )) + })?; + templates.push(PiPromptTemplate { + slug: slug.to_string(), + content, + revision: snapshot.revision, + }); + } + templates.sort_by(|left, right| left.slug.cmp(&right.slug)); + Ok(templates) + } + + fn upsert_at( + dir: &Path, + slug: &str, + expected_revision: &str, + content: &str, + ) -> Result { + validate_template_slug(slug)?; + fs::create_dir_all(dir).map_err(|error| AppError::io(dir, error))?; + let snapshot = replace_shared_file( + &template_path(dir, slug), + expected_revision, + content.as_bytes(), + MAX_PROMPT_FILE_BYTES, + Some(0o600), + "Pi prompt template", + )?; + Ok(PiPromptTemplate { + slug: slug.to_string(), + content: content.to_string(), + revision: snapshot.revision, + }) + } +} + +fn template_path(dir: &Path, slug: &str) -> PathBuf { + dir.join(format!("{slug}.md")) +} + +fn validate_direct_instruction_content(content: &str) -> Result<(), AppError> { + if content.trim().is_empty() { + Err(AppError::InvalidInput( + "Pi SYSTEM.md and APPEND_SYSTEM.md content cannot be blank; delete the file to deactivate it" + .to_string(), + )) + } else { + Ok(()) + } +} + +fn validate_template_slug(slug: &str) -> Result<(), AppError> { + let valid = !slug.is_empty() + && slug.len() <= MAX_TEMPLATE_SLUG_BYTES + && slug != "." + && slug != ".." + && slug.trim() == slug + && !slug.starts_with('.') + && !slug.ends_with('.') + && !slug + .chars() + .any(|character| character.is_control() || matches!(character, '/' | '\\')); + if valid { + Ok(()) + } else { + Err(AppError::InvalidInput( + "Pi prompt-template slug must be one visible filename (1-128 UTF-8 bytes)".to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_instruction_files_use_presence_and_revision_as_native_state() { + let temp = tempfile::tempdir().expect("tempdir"); + for kind in [ + PiPromptFileKind::GlobalContext, + PiPromptFileKind::SystemOverride, + PiPromptFileKind::SystemAppend, + ] { + let missing = PiPromptFileService::read_at(temp.path(), kind).expect("missing"); + assert!(!missing.exists); + // scripts/pi-transport-capture.mjs executes pinned Pi's + // DefaultResourceLoader at + // ab366ebe94cacd419d986be454f12b1b9913aaca and records all three + // zero-byte files as present resources. + let empty = PiPromptFileService::replace_at(temp.path(), kind, "missing", "") + .expect("create empty instruction file"); + assert!(empty.exists); + assert_eq!(empty.content, ""); + let saved = + PiPromptFileService::replace_at(temp.path(), kind, &empty.revision, "content") + .expect("replace"); + assert!(saved.exists); + assert_eq!(saved.content, "content"); + assert!(PiPromptFileService::delete_at(temp.path(), kind, "missing").is_err()); + assert!( + PiPromptFileService::delete_at(temp.path(), kind, &saved.revision).expect("delete") + ); + } + } + + #[test] + fn direct_instruction_save_rejects_blank_content_without_redefining_native_presence() { + for content in ["", " \n\t"] { + assert!(validate_direct_instruction_content(content).is_err()); + } + assert!(validate_direct_instruction_content("# Explicit override").is_ok()); + } + + #[test] + fn templates_reject_ambiguous_or_traversing_slugs() { + for slug in [ + "", + ".", + "..", + ".hidden", + "trailing.", + " padded", + "a/b", + r"a\b", + ] { + assert!(validate_template_slug(slug).is_err(), "{slug:?}"); + } + for slug in ["review-pr", "release.v2", "评审", "SYSTEM"] { + assert!(validate_template_slug(slug).is_ok(), "{slug:?}"); + } + } + + #[test] + fn empty_template_is_present_and_round_trips_like_pinned_pi() { + // scripts/pi-transport-capture.mjs executes pinned Pi + // ab366ebe94cacd419d986be454f12b1b9913aaca and confirms that an empty + // prompts/empty.md is discovered as an active template. + let temp = tempfile::tempdir().expect("tempdir"); + let created = PiPromptTemplateService::upsert_at(temp.path(), "empty", "missing", "") + .expect("create empty template"); + assert_eq!(created.content, ""); + let listed = PiPromptTemplateService::list_at(temp.path()).expect("list templates"); + assert_eq!(listed, vec![created]); + } +} diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index d0dde040d..39683eb93 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -5,7 +5,11 @@ use crate::config::write_text_file; use crate::error::AppError; use crate::prompt::Prompt; use crate::prompt_files::prompt_file_path; +use crate::services::pi_prompt_files::{ + lock_instruction_files, PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, +}; use crate::store::AppState; +use sha2::{Digest, Sha256}; /// 安全地获取当前 Unix 时间戳 fn get_unix_timestamp() -> Result { @@ -31,6 +35,9 @@ impl PromptService { _id: &str, prompt: Prompt, ) -> Result<(), AppError> { + if matches!(app, AppType::Pi) { + return Self::upsert_pi_prompt(state, prompt); + } // 检查是否为已启用的提示词 let is_enabled = prompt.enabled; @@ -71,6 +78,9 @@ impl PromptService { } pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { + if matches!(app, AppType::Pi) { + return Self::enable_pi_prompt(state, id); + } // 回填当前 live 文件内容到已启用的提示词,或创建备份 let target_path = prompt_file_path(&app)?; if target_path.exists() { @@ -144,6 +154,15 @@ impl PromptService { } pub fn import_from_file(state: &AppState, app: AppType) -> Result { + if matches!(app, AppType::Pi) { + let guard = lock_instruction_files()?; + let snapshot = + PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; + if !snapshot.exists { + return Err(AppError::Message("Pi AGENTS.md does not exist".to_string())); + } + return Self::import_pi_snapshot(state, snapshot); + } let file_path = prompt_file_path(&app)?; if !file_path.exists() { @@ -173,6 +192,12 @@ impl PromptService { } pub fn get_current_file_content(app: AppType) -> Result, AppError> { + if matches!(app, AppType::Pi) { + let guard = lock_instruction_files()?; + let snapshot = + PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; + return Ok(snapshot.exists.then_some(snapshot.content)); + } let file_path = prompt_file_path(&app)?; if !file_path.exists() { return Ok(None); @@ -194,6 +219,32 @@ impl PromptService { return Ok(0); } + if matches!(app, AppType::Pi) { + let guard = lock_instruction_files()?; + 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}"), + name: format!( + "Auto-imported Prompt {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content: snapshot.content, + description: Some("Automatically imported on first launch".to_string()), + enabled: true, + created_at: Some(timestamp), + updated_at: Some(timestamp), + }, + )?; + return Ok(1); + } + let file_path = prompt_file_path(&app)?; // 检查文件是否存在 @@ -239,4 +290,401 @@ impl PromptService { log::info!("自动导入完成: {}", app.as_str()); Ok(1) } + + /// Reconcile portable Pi prompt rows to this device's native AGENTS.md. + /// + /// Prompt content is portable, but native instruction files are not. The + /// live file therefore decides which library row is active after an + /// import: exact content adopts an existing row, otherwise a local + /// 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> { + const MAX_EXTERNAL_RETRIES: usize = 3; + + for _ in 0..MAX_EXTERNAL_RETRIES { + let guard = lock_instruction_files()?; + let snapshot = + PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; + let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?; + for prompt in prompts.values_mut() { + prompt.enabled = false; + } + + if snapshot.exists { + let active_id = prompts + .iter() + .find_map(|(id, prompt)| { + (prompt.content == snapshot.content).then(|| id.clone()) + }) + .unwrap_or_else(|| { + let digest = format!("{:x}", Sha256::digest(snapshot.content.as_bytes())); + let base = format!("native-{digest}"); + let mut id = base.clone(); + let mut suffix = 1_u32; + while prompts.contains_key(&id) { + id = format!("{base}-{suffix}"); + suffix += 1; + } + let timestamp = chrono::Utc::now().timestamp(); + prompts.insert( + id.clone(), + Prompt { + id: id.clone(), + name: "Imported from Pi AGENTS.md".to_string(), + content: snapshot.content.clone(), + description: Some( + "Device-local native state preserved during portable import" + .to_string(), + ), + enabled: false, + created_at: Some(timestamp), + updated_at: Some(timestamp), + }, + ); + id + }); + prompts + .get_mut(&active_id) + .expect("selected Pi prompt is present") + .enabled = true; + } + + state + .db + .save_prompt_selection(AppType::Pi.as_str(), &prompts)?; + let verified = + PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; + if verified.revision == snapshot.revision { + return Ok(()); + } + } + + Err(AppError::Conflict( + "Pi AGENTS.md kept changing during portable prompt reconciliation".to_string(), + )) + } + + fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> { + let guard = lock_instruction_files()?; + let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?; + let previous = prompts.insert(prompt.id.clone(), prompt.clone()); + let snapshot = + PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; + let current_enabled = previous + .as_ref() + .filter(|candidate| candidate.enabled) + .or_else(|| { + prompts + .values() + .find(|candidate| candidate.id != prompt.id && candidate.enabled) + }); + + if prompt.enabled { + ensure_pi_library_projection_matches(&snapshot, current_enabled)?; + for candidate in prompts.values_mut() { + candidate.enabled = candidate.id == prompt.id; + } + let published = PiPromptFileService::replace_under_guard( + &guard, + PiPromptFileKind::GlobalContext, + &snapshot.revision, + &prompt.content, + )?; + if let Err(error) = state + .db + .save_prompt_selection(AppType::Pi.as_str(), &prompts) + { + restore_pi_prompt_file(&guard, &published, &snapshot)?; + return Err(error); + } + return Ok(()); + } + + if previous.as_ref().is_some_and(|value| value.enabled) { + ensure_pi_library_projection_matches(&snapshot, previous.as_ref())?; + let removed = PiPromptFileService::delete_under_guard( + &guard, + PiPromptFileKind::GlobalContext, + &snapshot.revision, + )?; + if let Err(error) = state + .db + .save_prompt_selection(AppType::Pi.as_str(), &prompts) + { + if removed { + let missing = PiPromptFileService::read_under_guard( + &guard, + PiPromptFileKind::GlobalContext, + )?; + PiPromptFileService::replace_under_guard( + &guard, + PiPromptFileKind::GlobalContext, + &missing.revision, + &snapshot.content, + )?; + } + return Err(error); + } + return Ok(()); + } + + state + .db + .save_prompt_selection(AppType::Pi.as_str(), &prompts) + } + + fn enable_pi_prompt(state: &AppState, id: &str) -> Result<(), AppError> { + let guard = lock_instruction_files()?; + let before = state.db.get_prompts(AppType::Pi.as_str())?; + let target = before + .get(id) + .cloned() + .ok_or_else(|| AppError::InvalidInput(format!("提示词 {id} 不存在")))?; + let snapshot = + PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?; + ensure_pi_library_projection_matches( + &snapshot, + before.values().find(|candidate| candidate.enabled), + )?; + let published = PiPromptFileService::replace_under_guard( + &guard, + PiPromptFileKind::GlobalContext, + &snapshot.revision, + &target.content, + )?; + + let mut after = before.clone(); + 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) { + restore_pi_prompt_file(&guard, &published, &snapshot)?; + return Err(error); + } + Ok(()) + } + + fn import_pi_snapshot( + state: &AppState, + snapshot: PiPromptFileSnapshot, + ) -> Result { + let timestamp = get_unix_timestamp()?; + let id = format!("imported-{timestamp}"); + let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?; + for prompt in prompts.values_mut() { + prompt.enabled = false; + } + prompts.insert( + id.clone(), + Prompt { + id: id.clone(), + name: format!( + "导入的提示词 {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content: snapshot.content, + description: Some("从 Pi AGENTS.md 导入".to_string()), + // Import is an explicit reconciliation action. The native file + // is already active by presence, so its exact DB counterpart + // must become the sole enabled library entry without + // rewriting the user-owned file. + enabled: true, + created_at: Some(timestamp), + updated_at: Some(timestamp), + }, + ); + state + .db + .save_prompt_selection(AppType::Pi.as_str(), &prompts)?; + Ok(id) + } +} + +fn ensure_pi_library_projection_matches( + snapshot: &PiPromptFileSnapshot, + current_enabled: Option<&Prompt>, +) -> Result<(), AppError> { + match current_enabled { + Some(prompt) if snapshot.exists && snapshot.content == prompt.content => Ok(()), + Some(_) => Err(AppError::Conflict( + "Pi AGENTS.md changed outside CC Switch; import or reconcile it before switching prompts" + .to_string(), + )), + None if !snapshot.exists => Ok(()), + None => Err(AppError::Conflict( + "Pi AGENTS.md is user-owned; import it before enabling a library prompt".to_string(), + )), + } +} + +fn restore_pi_prompt_file( + guard: &std::sync::MutexGuard<'static, ()>, + published: &PiPromptFileSnapshot, + previous: &PiPromptFileSnapshot, +) -> Result<(), AppError> { + if previous.exists { + PiPromptFileService::replace_under_guard( + guard, + PiPromptFileKind::GlobalContext, + &published.revision, + &previous.content, + )?; + } else { + PiPromptFileService::delete_under_guard( + guard, + PiPromptFileKind::GlobalContext, + &published.revision, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::Database; + use serial_test::serial; + use std::ffi::OsString; + use std::sync::Arc; + + struct EnvRestore { + key: &'static str, + previous: Option, + } + + impl EnvRestore { + fn set(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvRestore { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + #[test] + #[serial] + fn public_pi_import_reconciles_an_active_empty_agents_file() { + // Pinned-Pi provenance: scripts/pi-transport-capture.mjs executes + // DefaultResourceLoader at ab366ebe94cacd419d986be454f12b1b9913aaca + // and records an existing zero-byte AGENTS.md as an active resource. + let temp = tempfile::tempdir().expect("tempdir"); + let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path()); + std::fs::write(temp.path().join("AGENTS.md"), "").expect("seed AGENTS.md"); + let state = AppState::new(Arc::new(Database::memory().expect("database"))); + + let imported = + PromptService::import_from_file(&state, AppType::Pi).expect("import empty AGENTS.md"); + let prompts = PromptService::get_prompts(&state, AppType::Pi).expect("read prompts"); + let active = prompts.get(&imported).expect("imported prompt"); + assert!(active.enabled); + assert_eq!(active.content, ""); + assert_eq!( + prompts.values().filter(|prompt| prompt.enabled).count(), + 1, + "the native active file must have exactly one active DB owner" + ); + assert_eq!( + PromptService::get_current_file_content(AppType::Pi).expect("read live"), + Some(String::new()) + ); + } + + #[test] + fn unowned_empty_agents_file_is_not_treated_as_absent() { + let snapshot = PiPromptFileSnapshot { + kind: PiPromptFileKind::GlobalContext, + path: "AGENTS.md".to_string(), + exists: true, + revision: "present-empty".to_string(), + content: String::new(), + }; + assert!(matches!( + ensure_pi_library_projection_matches(&snapshot, None), + Err(AppError::Conflict(_)) + )); + } + + #[test] + #[serial] + fn portable_prompt_reconciliation_preserves_native_bytes_and_rebuilds_active_truth() { + let temp = tempfile::tempdir().expect("tempdir"); + let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path()); + let native_content = "device-local AGENTS"; + std::fs::write(temp.path().join("AGENTS.md"), native_content).expect("seed AGENTS.md"); + let state = AppState::new(Arc::new(Database::memory().expect("database"))); + state + .db + .save_prompt( + AppType::Pi.as_str(), + &Prompt { + id: "portable-active".to_string(), + name: "Portable active".to_string(), + content: "incoming portable content".to_string(), + description: None, + enabled: true, + created_at: Some(1), + updated_at: Some(1), + }, + ) + .expect("seed portable prompt"); + + PromptService::reconcile_pi_portable_import(&state).expect("reconcile"); + + assert_eq!( + std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("read native"), + native_content, + "portable import must not overwrite the device-local native file" + ); + let prompts = state + .db + .get_prompts(AppType::Pi.as_str()) + .expect("read prompts"); + let active = prompts + .values() + .filter(|prompt| prompt.enabled) + .collect::>(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].content, native_content); + assert!(!prompts["portable-active"].enabled); + } + + #[test] + #[serial] + fn portable_prompt_reconciliation_disables_shadow_state_when_agents_is_absent() { + let temp = tempfile::tempdir().expect("tempdir"); + let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path()); + let state = AppState::new(Arc::new(Database::memory().expect("database"))); + state + .db + .save_prompt( + AppType::Pi.as_str(), + &Prompt { + id: "portable-active".to_string(), + name: "Portable active".to_string(), + content: "incoming portable content".to_string(), + description: None, + enabled: true, + created_at: Some(1), + updated_at: Some(1), + }, + ) + .expect("seed portable prompt"); + + PromptService::reconcile_pi_portable_import(&state).expect("reconcile"); + + let prompts = state + .db + .get_prompts(AppType::Pi.as_str()) + .expect("read prompts"); + assert!(prompts.values().all(|prompt| !prompt.enabled)); + assert!(!temp.path().join("AGENTS.md").exists()); + } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 555d48a21..67dc866b5 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -530,6 +530,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet: | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes + | AppType::Pi | AppType::ClaudeDesktop => false, } } @@ -604,6 +605,7 @@ pub(crate) fn remove_common_config_from_settings( | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes + | AppType::Pi | AppType::ClaudeDesktop => Ok(settings.clone()), } } @@ -663,6 +665,7 @@ fn apply_common_config_to_settings( | AppType::OpenCode | AppType::OpenClaw | AppType::Hermes + | AppType::Pi | AppType::ClaudeDesktop => Ok(settings.clone()), } } @@ -1165,6 +1168,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone())?; log::debug!("Hermes provider '{}' written to live config", provider.id); } + AppType::Pi => { + return Err(AppError::localized( + "pi.live.requires_catalog_coordinator", + "Pi 的共享 models.json 必须通过 Pi 目录协调器写入", + "Pi's shared models.json must be written through the Pi catalog coordinator", + )); + } } Ok(()) } @@ -1281,6 +1291,10 @@ fn sync_current_provider_for_app_respecting_takeover( 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; + } 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 @@ -1426,6 +1440,11 @@ pub fn read_live_settings(app_type: AppType) -> Result { let config = crate::hermes_config::yaml_to_json(&yaml_config)?; Ok(config) } + AppType::Pi => Err(AppError::localized( + "pi.live.requires_catalog_inspection", + "Pi 的共享 models.json 必须通过 Pi 原生目录检查服务读取", + "Pi's shared models.json must be read through the Pi native catalog inspection service", + )), } } @@ -1534,6 +1553,13 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result { + return Err(AppError::localized( + "pi.import.requires_catalog_coordinator", + "Pi 原生供应商必须通过 Pi 目录导入流程导入", + "Native Pi providers must be imported through the Pi catalog import flow", + )); + } // OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => { unreachable!("additive mode apps are handled by early return") diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 5f2ba87e4..f1bd2c1bb 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -20,6 +20,7 @@ use crate::database::{ use crate::error::AppError; use crate::provider::{Provider, ProviderMutationInput, UsageResult}; use crate::services::mcp::McpService; +use crate::services::pi_catalog::{PiCatalogCoordinator, PiCatalogMutation}; use crate::settings::CustomEndpoint; use crate::store::AppState; @@ -478,6 +479,32 @@ mod tests { } } + fn pi_provider(id: &str) -> Provider { + Provider { + id: id.to_string(), + name: format!("Pi Provider {id}"), + settings_config: json!({ + "name": format!("Pi Provider {id}"), + "api": "openai-responses", + "baseUrl": "https://pi.example/v1", + "apiKey": "test-key", + "models": [ + {"id": "model-a", "name": "Model A"}, + {"id": "model-b", "name": "Model B"} + ] + }), + website_url: None, + category: Some("custom".to_string()), + created_at: Some(1), + sort_index: Some(0), + notes: None, + meta: None, + icon: Some("pi".to_string()), + icon_color: None, + in_failover_queue: false, + } + } + fn opencode_provider(id: &str) -> Provider { Provider { id: id.to_string(), @@ -632,6 +659,174 @@ mod tests { }); } + #[test] + #[serial] + fn pi_provider_service_create_hydrates_all_endpoints_and_publishes_native_default() { + with_test_home(|state, home| { + let original_settings = crate::settings::get_settings(); + let mut isolated_settings = original_settings.clone(); + isolated_settings.pi_config_dir = Some( + home.join(".pi") + .join("agent") + .to_string_lossy() + .into_owned(), + ); + isolated_settings.current_provider_pi = None; + crate::settings::update_settings(isolated_settings) + .expect("install isolated Pi settings"); + + let outcome = (|| -> Result<_, AppError> { + let expected_endpoints = HashMap::from([ + ( + "https://one.pi.example".to_string(), + endpoint("https://one.pi.example", None, Some(11)), + ), + ( + "https://two.pi.example".to_string(), + endpoint("https://two.pi.example", Some(20), None), + ), + ]); + let mut provider = pi_provider("managed-pi"); + provider.meta = Some(ProviderMeta { + custom_endpoints: expected_endpoints.clone(), + ..Default::default() + }); + ProviderService::add( + state, + AppType::Pi, + provider_to_mutation_input(provider), + false, + )?; + + let aggregate = state + .db + .get_provider_aggregate("pi", "managed-pi")? + .ok_or_else(|| AppError::NotFound("managed Pi aggregate".to_string()))?; + let hydrated = aggregate.endpoints.into_iter().collect::>(); + let models: Value = serde_json::from_slice( + &fs::read(home.join(".pi/agent/models.json")) + .map_err(|error| AppError::io(home, error))?, + ) + .map_err(|error| AppError::json(home, error))?; + let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?; + Ok((expected_endpoints, hydrated, models, defaults)) + })(); + + crate::settings::update_settings(original_settings).expect("restore process settings"); + let (expected, hydrated, models, defaults) = outcome.expect("Pi service create"); + assert_eq!(hydrated, expected); + assert_eq!( + models.pointer("/providers/managed-pi/models/0/id"), + Some(&json!("model-a")) + ); + assert_eq!(defaults.default_provider.as_deref(), Some("managed-pi")); + assert_eq!(defaults.default_model.as_deref(), Some("model-a")); + }); + } + + #[test] + #[serial] + fn pi_provider_service_update_rehomes_a_removed_active_model() { + with_test_home(|state, home| { + let original_settings = crate::settings::get_settings(); + let mut isolated_settings = original_settings.clone(); + isolated_settings.pi_config_dir = Some( + home.join(".pi") + .join("agent") + .to_string_lossy() + .into_owned(), + ); + isolated_settings.current_provider_pi = None; + crate::settings::update_settings(isolated_settings) + .expect("install isolated Pi settings"); + + let outcome = (|| -> Result<_, AppError> { + let provider = pi_provider("managed-pi-update"); + ProviderService::add( + state, + AppType::Pi, + provider_to_mutation_input(provider.clone()), + false, + )?; + + let mut updated = provider; + updated.settings_config["models"] = json!([{"id": "model-b", "name": "Model B"}]); + ProviderService::update( + state, + AppType::Pi, + Some("managed-pi-update"), + provider_to_mutation_input(updated), + )?; + + let defaults = crate::pi_config::native_settings::read_pi_native_defaults()?; + let models: Value = serde_json::from_slice( + &fs::read(home.join(".pi/agent/models.json")) + .map_err(|error| AppError::io(home, error))?, + ) + .map_err(|error| AppError::json(home, error))?; + Ok((defaults, models)) + })(); + + crate::settings::update_settings(original_settings).expect("restore process settings"); + let (defaults, models) = outcome.expect("Pi service update"); + assert_eq!( + defaults.default_provider.as_deref(), + Some("managed-pi-update") + ); + assert_eq!(defaults.default_model.as_deref(), Some("model-b")); + assert_eq!( + models.pointer("/providers/managed-pi-update/models"), + Some(&json!([{"id": "model-b", "name": "Model B"}])) + ); + }); + } + + #[test] + #[serial] + fn pi_provider_service_current_follows_the_native_default_after_external_edit() { + with_test_home(|state, home| { + let original_settings = crate::settings::get_settings(); + let mut isolated_settings = original_settings.clone(); + isolated_settings.pi_config_dir = Some( + home.join(".pi") + .join("agent") + .to_string_lossy() + .into_owned(), + ); + isolated_settings.current_provider_pi = None; + crate::settings::update_settings(isolated_settings) + .expect("install isolated Pi settings"); + + let outcome = (|| -> Result<_, AppError> { + for provider_id in ["native-first", "native-second"] { + ProviderService::add( + state, + AppType::Pi, + provider_to_mutation_input(pi_provider(provider_id)), + false, + )?; + } + assert_eq!( + state.db.get_current_provider("pi")?.as_deref(), + Some("native-first") + ); + crate::pi_config::native_settings::set_pi_native_default( + "native-second", + "model-b", + )?; + + ProviderService::current(state, AppType::Pi) + })(); + + crate::settings::update_settings(original_settings).expect("restore process settings"); + assert_eq!( + outcome.expect("resolve current Pi provider"), + "native-second", + "the UI current marker must follow Pi's live settings, not a stale DB marker" + ); + }); + } + #[test] #[serial] fn provider_service_create_canonicalizes_initial_endpoint_identity() { @@ -3404,6 +3599,10 @@ impl ProviderService { if app_type.is_additive_mode() { return Ok(String::new()); } + if matches!(app_type, AppType::Pi) { + return PiCatalogCoordinator::current_native_provider(state) + .map(|provider| provider.unwrap_or_default()); + } crate::settings::get_effective_current_provider(&state.db, &app_type) .map(|opt| opt.unwrap_or_default()) } @@ -3415,6 +3614,18 @@ impl ProviderService { input: ProviderMutationInput, add_to_live: bool, ) -> Result { + if matches!(app_type, AppType::Pi) { + let provider_key = input.id.clone(); + PiCatalogCoordinator::apply( + state, + PiCatalogMutation::CreateProvider { + input, + provider_key, + activate_if_first: true, + }, + )?; + return Ok(true); + } let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); let mut provider: Provider = input.into(); // Normalize Claude model keys @@ -3473,6 +3684,16 @@ impl ProviderService { // Reject endpoint-bearing edit payloads before any live or DB side // effect. Endpoints have their own typed mutation API. ProviderRowUpdate::from_input(&input)?; + if matches!(app_type, AppType::Pi) { + let original_id = original_id.unwrap_or(input.id.as_str()); + if original_id != input.id { + return Err(AppError::InvalidInput( + "Pi provider identity and native projection key cannot be renamed".to_string(), + )); + } + PiCatalogCoordinator::apply(state, PiCatalogMutation::UpdateProvider { input })?; + return Ok(true); + } let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); let mut provider: Provider = input.into(); let original_id = original_id.unwrap_or(provider.id.as_str()).to_string(); @@ -3713,6 +3934,15 @@ impl ProviderService { /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。 /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。 pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + PiCatalogCoordinator::apply( + state, + PiCatalogMutation::DeleteProvider { + provider_id: id.to_string(), + }, + )?; + return Ok(()); + } let _provider_mutation_guard = lock_additive_provider_mutation(state, &app_type); // Additive mode apps - no current provider concept if app_type.is_additive_mode() { @@ -3853,6 +4083,32 @@ impl ProviderService { /// d. Write target provider config to live files /// e. Sync MCP configuration pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result { + if matches!(app_type, AppType::Pi) { + let provider = state + .db + .get_provider_aggregate(AppType::Pi.as_str(), id)? + .ok_or_else(|| AppError::NotFound(format!("Pi provider '{id}'")))?; + let config: crate::pi_config::model::PiManagedProviderConfig = + serde_json::from_value(provider.provider.settings_config).map_err(|error| { + AppError::Config(format!("managed Pi provider '{id}' is invalid: {error}")) + })?; + let model_id = config + .models + .first() + .ok_or_else(|| { + AppError::InvalidInput(format!("Pi provider '{id}' has no selectable models")) + })? + .id + .clone(); + PiCatalogCoordinator::apply( + state, + PiCatalogMutation::SetDefault { + provider_id: id.to_string(), + model_id, + }, + )?; + return Ok(SwitchResult::default()); + } // The same per-app lock also guards additive provider key changes and // bulk live sync. Acquire it before observing the provider map so a // queued rename cannot leave this switch holding a stale source key. @@ -4366,6 +4622,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets + AppType::Pi => Ok(String::new()), // Pi owns a shared exact-key catalog, not snippets } } @@ -4383,6 +4640,7 @@ impl ProviderService { AppType::OpenCode => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets + AppType::Pi => Ok(String::new()), } } @@ -4952,6 +5210,16 @@ impl ProviderService { provider_id: &str, url: String, ) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + PiCatalogCoordinator::apply( + state, + PiCatalogMutation::AddEndpoint { + provider_id: provider_id.to_string(), + url, + }, + )?; + return Ok(()); + } endpoints::add_custom_endpoint(state, app_type, provider_id, url) } @@ -4962,6 +5230,16 @@ impl ProviderService { provider_id: &str, url: String, ) -> Result<(), AppError> { + if matches!(app_type, AppType::Pi) { + PiCatalogCoordinator::apply( + state, + PiCatalogMutation::RemoveEndpoint { + provider_id: provider_id.to_string(), + url, + }, + )?; + return Ok(()); + } endpoints::remove_custom_endpoint(state, app_type, provider_id, url) } @@ -4972,6 +5250,13 @@ impl ProviderService { provider_id: &str, url: String, ) -> Result<(), AppError> { + let _pi_switch_guard = matches!(app_type, AppType::Pi).then(|| { + futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ) + }); endpoints::update_endpoint_last_used(state, app_type, provider_id, url) } @@ -4981,11 +5266,34 @@ impl ProviderService { app_type: AppType, updates: Vec, ) -> Result { - for update in updates { - let key = ProviderKey::new(app_type.as_str(), update.id)?; - state - .db - .update_provider_sort_index(&key, update.sort_index)?; + // Validate the whole payload before opening Pi's odd catalog epoch. + // Returning early with an unclosed epoch would leave gateway admission + // fenced until the next successful catalog mutation. + let updates = updates + .into_iter() + .map(|update| { + ProviderKey::new(app_type.as_str(), update.id).map(|key| (key, update.sort_index)) + }) + .collect::, _>>()?; + let _pi_switch_guard = matches!(app_type, AppType::Pi).then(|| { + futures::executor::block_on( + state + .proxy_service + .lock_switch_for_app(AppType::Pi.as_str()), + ) + }); + let pi_epoch = matches!(app_type, AppType::Pi) + .then(|| futures::executor::block_on(state.proxy_service.begin_pi_catalog_mutation())); + if let Err(error) = state.db.update_provider_sort_index(&updates) { + if let Some(epoch) = pi_epoch { + let _ = futures::executor::block_on( + state.proxy_service.reconcile_pi_runtime_at_epoch(epoch), + ); + } + return Err(error); + } + if let Some(epoch) = pi_epoch { + futures::executor::block_on(state.proxy_service.reconcile_pi_runtime_at_epoch(epoch))?; } Ok(true) @@ -5149,6 +5457,25 @@ impl ProviderService { )); } } + AppType::Pi => { + let config: crate::pi_config::model::PiManagedProviderConfig = + serde_json::from_value(provider.settings_config.clone()).map_err(|error| { + AppError::localized( + "provider.pi.settings.invalid", + format!("Pi 配置无法解析: {error}"), + format!("Pi configuration cannot be decoded: {error}"), + ) + })?; + crate::pi_config::model::validate_pi_managed_provider(&config).map_err( + |error| { + AppError::localized( + "provider.pi.settings.invalid", + format!("Pi 配置无效: {error}"), + format!("Invalid Pi configuration: {error}"), + ) + }, + )?; + } } // Validate and clean UsageScript configuration (common for all app types) @@ -5377,6 +5704,18 @@ impl ProviderService { Ok((api_key, base_url)) } + AppType::Pi => { + let config: crate::pi_config::model::PiManagedProviderConfig = + serde_json::from_value(provider.settings_config.clone()).map_err(|error| { + AppError::Config(format!("invalid Pi provider configuration: {error}")) + })?; + let model = config.models.first().ok_or_else(|| { + AppError::Config("Pi provider has no configured model".to_string()) + })?; + let effective = crate::pi_config::model::effective_pi_model(&config, &model.id) + .map_err(|error| AppError::Config(format!("invalid Pi model: {error}")))?; + Ok((effective.api_key.unwrap_or_default(), effective.base_url)) + } } } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index a1c3e9ef5..90b7b69b2 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -5,7 +5,11 @@ use crate::app_config::AppType; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; use crate::database::Database; +use crate::error::AppError; use crate::provider::Provider; +use crate::proxy::pi_runtime::{ + build_pi_runtime, direct_pi_projection_patch, project_managed_pi_config, PiRuntimeStore, +}; use crate::proxy::server::ProxyServer; use crate::proxy::switch_lock::SwitchLockManager; use crate::proxy::types::*; @@ -16,7 +20,10 @@ use crate::services::provider::{ }; use serde_json::{json, Map, Value}; use std::str::FromStr; -use std::sync::Arc; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, RwLock as StdRwLock, +}; use tauri::Emitter; use tokio::sync::RwLock; @@ -63,6 +70,38 @@ pub struct ProxyService { /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新 app_handle: Arc>>, switch_locks: SwitchLockManager, + pi_runtime: Arc, + pi_server_sequence: Arc, + pi_listener: Arc>>, +} + +#[derive(Debug, Clone)] +struct PiListenerIdentity { + server_generation: u64, + gateway_origin: url::Url, +} + +fn pi_loopback_origin(address: &str, port: u16) -> Result { + let host = if address.eq_ignore_ascii_case("localhost") { + "127.0.0.1".to_string() + } else { + let ip = address.parse::().map_err(|_| { + AppError::Config(format!( + "Pi gateway requires an explicit loopback listener, got '{address}'" + )) + })?; + if !ip.is_loopback() { + return Err(AppError::Config(format!( + "Pi gateway refuses non-loopback listener '{address}'" + ))); + } + match ip { + std::net::IpAddr::V4(value) => value.to_string(), + std::net::IpAddr::V6(value) => format!("[{value}]"), + } + }; + url::Url::parse(&format!("http://{host}:{port}/")) + .map_err(|error| AppError::Config(format!("invalid Pi listener origin: {error}"))) } #[derive(Debug, Clone, Copy, Default)] @@ -77,6 +116,9 @@ impl ProxyService { server: Arc::new(RwLock::new(None)), app_handle: Arc::new(RwLock::new(None)), switch_locks: SwitchLockManager::new(), + pi_runtime: Arc::new(PiRuntimeStore::default()), + pi_server_sequence: Arc::new(AtomicU64::new(0)), + pi_listener: Arc::new(StdRwLock::new(None)), } } @@ -516,8 +558,246 @@ impl ProxyService { self.switch_locks.lock_for_app(app_type).await } + pub(crate) async fn begin_pi_catalog_mutation(&self) -> u64 { + self.pi_runtime.begin_mutation().await + } + + pub(crate) fn project_pi_provider_value( + &self, + provider_id: &str, + provider_key: &str, + config: &crate::pi_config::model::PiManagedProviderConfig, + ) -> Result { + if !crate::settings::pi_takeover_enabled() { + return serde_json::to_value(config) + .map_err(|source| AppError::JsonSerialize { source }); + } + let listener = self + .pi_listener + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .ok_or_else(|| { + AppError::Conflict( + "Pi gateway projection is desired but no loopback listener is active" + .to_string(), + ) + })?; + let token = crate::settings::get_or_create_pi_gateway_token()?; + project_managed_pi_config( + provider_id, + provider_key, + config, + &listener.gateway_origin, + &token, + ) + } + + pub(crate) async fn reconcile_pi_runtime_at_epoch( + &self, + catalog_epoch: u64, + ) -> Result, AppError> { + if !crate::settings::pi_takeover_enabled() { + self.pi_runtime.close(catalog_epoch).await?; + return Ok(Vec::new()); + } + let listener = self + .pi_listener + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .ok_or_else(|| { + AppError::Conflict( + "Pi takeover is desired but the loopback listener is unavailable".to_string(), + ) + })?; + let token = crate::settings::get_or_create_pi_gateway_token()?; + let app_config = crate::settings::get_pi_app_proxy_config(); + let build = build_pi_runtime( + self.db.as_ref(), + listener.server_generation, + catalog_epoch, + &listener.gateway_origin, + token, + app_config, + )?; + crate::pi_config::document::apply_pi_provider_patch( + &crate::pi_config::native::get_pi_models_path()?, + &build.projection_patch, + )?; + self.pi_runtime.publish(build.snapshot).await?; + Ok(build.direct_only_provider_ids) + } + + pub(crate) async fn reconcile_pi_runtime(&self) -> Result, AppError> { + let epoch = self.pi_runtime.begin_mutation().await; + self.reconcile_pi_runtime_at_epoch(epoch).await + } + + fn restore_pi_direct_projection_at( + &self, + models_path: &std::path::Path, + ) -> Result<(), AppError> { + let patch = direct_pi_projection_patch(self.db.as_ref())?; + crate::pi_config::document::apply_pi_provider_patch(models_path, &patch) + } + + fn restore_pi_direct_projection(&self) -> Result<(), AppError> { + let models_path = crate::pi_config::native::get_pi_models_path()?; + self.restore_pi_direct_projection_at(&models_path) + } + + /// Replace settings while preserving the ownership boundary when the Pi + /// native directory changes. The caller must hold Pi's switch lock from + /// the settings snapshot through this call. + pub(crate) async fn replace_settings_with_pi_directory_boundary_under_lock( + &self, + _guard: &tokio::sync::OwnedMutexGuard<()>, + existing: &crate::settings::AppSettings, + next: crate::settings::AppSettings, + ) -> Result<(), AppError> { + let old_models_path = crate::pi_config::native::get_pi_models_path_for_override( + existing.pi_config_dir.as_deref(), + )?; + let new_models_path = crate::pi_config::native::get_pi_models_path_for_override( + next.pi_config_dir.as_deref(), + )?; + + if old_models_path == new_models_path || !existing.pi_takeover_enabled { + return crate::settings::update_settings(next); + } + + // Admission closes before the old native file is restored. A Pi + // process that already loaded the old gateway projection therefore + // cannot enter a catalog whose directory ownership is in flight. + let epoch = self.pi_runtime.begin_mutation().await; + if let Err(error) = self.restore_pi_direct_projection_at(&old_models_path) { + let _ = self.pi_runtime.republish_current(epoch).await; + return Err(error); + } + + if let Err(error) = crate::settings::update_settings(next) { + let runtime_restored = self.reconcile_pi_runtime_at_epoch(epoch).await; + return Err(AppError::Config(if runtime_restored.is_ok() { + format!( + "failed to save the new Pi directory; the previous gateway projection was restored: {error}" + ) + } else { + format!( + "failed to save the new Pi directory and restore the previous gateway projection: {error}" + ) + })); + } + + if let Err(error) = self.reconcile_pi_runtime_at_epoch(epoch).await { + let new_direct_restored = self + .restore_pi_direct_projection_at(&new_models_path) + .is_ok(); + let settings_restored = crate::settings::update_settings(existing.clone()).is_ok(); + let old_gateway_restored = if settings_restored { + self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok() + } else { + self.pi_runtime.republish_current(epoch).await.is_ok() + }; + return Err(AppError::Config(format!( + "failed to publish Pi in the new native directory: {error}; rollback: new_direct={new_direct_restored}, settings={settings_restored}, old_gateway={old_gateway_restored}" + ))); + } + + Ok(()) + } + + async fn suspend_pi_takeover_projection(&self) -> Result<(), AppError> { + if !crate::settings::pi_takeover_enabled() { + return Ok(()); + } + let epoch = self.pi_runtime.begin_mutation().await; + if let Err(error) = self.restore_pi_direct_projection() { + let _ = self.pi_runtime.republish_current(epoch).await; + return Err(error); + } + self.pi_runtime.close(epoch).await + } + + /// Enter the portable-import boundary while the caller holds Pi's switch + /// lock. The old database still exists at this point, so this is the last + /// safe moment to restore every managed native key before SQL/binary + /// replacement can remove its direct configuration. + pub(crate) async fn prepare_pi_portable_import_under_lock( + &self, + _guard: &tokio::sync::OwnedMutexGuard<()>, + ) -> Result<(), AppError> { + self.suspend_pi_takeover_projection().await + } + + /// Re-publish the old catalog after an import operation aborts. Callers + /// retain Pi's switch lock from prepare through this compensation. + pub(crate) async fn recover_pi_after_aborted_portable_import_under_lock( + &self, + _guard: &tokio::sync::OwnedMutexGuard<()>, + ) -> Result<(), AppError> { + if crate::settings::pi_takeover_enabled() { + self.reconcile_pi_runtime().await.map(|_| ()) + } else { + Ok(()) + } + } + + async fn suspend_pi_takeover_for_process_exit(&self) -> Result<(), String> { + if !crate::settings::pi_takeover_enabled() { + return Ok(()); + } + let _guard = self.switch_locks.lock_for_app(AppType::Pi.as_str()).await; + self.suspend_pi_takeover_projection() + .await + .map_err(|error| { + format!( + "failed to restore Pi's direct native projection before process exit: {error}" + ) + }) + } + + pub(crate) async fn rotate_pi_gateway_token(&self) -> Result<(), AppError> { + let _guard = self.switch_locks.lock_for_app(AppType::Pi.as_str()).await; + let previous = crate::settings::get_or_create_pi_gateway_token()?; + crate::settings::reset_pi_gateway_token()?; + if !crate::settings::pi_takeover_enabled() { + return Ok(()); + } + let epoch = self.pi_runtime.begin_mutation().await; + if let Err(error) = self.reconcile_pi_runtime_at_epoch(epoch).await { + let token_restored = crate::settings::replace_pi_gateway_token(previous); + let runtime_restored = if token_restored.is_ok() { + let rollback_epoch = self.pi_runtime.begin_mutation().await; + self.reconcile_pi_runtime_at_epoch(rollback_epoch).await + } else { + Err(AppError::Config( + "failed to restore the previous Pi gateway credential".to_string(), + )) + }; + let rollback = if token_restored.is_ok() && runtime_restored.is_ok() { + "the previous credential and runtime were restored" + } else { + "credential rotation rollback was incomplete" + }; + return Err(AppError::Config(format!( + "Pi gateway credential rotation failed: {error}; {rollback}" + ))); + } + Ok(()) + } + /// 启动代理服务器 pub async fn start(&self) -> Result { + // Listener identity and the Pi runtime/native projection form one + // publication boundary. A direct start command must serialize with + // provider edits, takeover toggles, and listener reconfiguration just + // like every other operation that can replace that identity. + let _pi_guard = self.switch_locks.lock_for_app(AppType::Pi.as_str()).await; + self.start_with_pi_lock_held().await + } + + async fn start_with_pi_lock_held(&self) -> Result { // 1. 启动时自动设置 proxy_enabled = true let mut global_config = self .db @@ -553,7 +833,17 @@ impl ProxyService { // 4. 创建并启动服务器 let app_handle = self.app_handle.read().await.clone(); - let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle); + let pi_server_generation = self + .pi_server_sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1); + let server = ProxyServer::new( + config.clone(), + self.db.clone(), + app_handle, + self.pi_runtime.clone(), + pi_server_generation, + ); let info = server .start() .await @@ -567,8 +857,55 @@ impl ProxyService { } // 5. 保存服务器实例 + match pi_loopback_origin(&info.address, info.port) { + Ok(gateway_origin) => { + *self + .pi_listener + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(PiListenerIdentity { + server_generation: server.pi_server_generation(), + gateway_origin, + }); + } + Err(error) => { + *self + .pi_listener + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + log::warn!("Pi gateway admission unavailable: {error}"); + } + } *self.server.write().await = Some(server); + if crate::settings::pi_takeover_enabled() { + if let Err(error) = self.reconcile_pi_runtime().await { + let rollback = self.suspend_pi_takeover_projection().await; + if let Err(rollback_error) = rollback { + return Err(format!( + "Pi desired takeover could not be reconciled after listener start: {error}; direct-mode rollback failed, so the live listener was retained: {rollback_error}" + )); + } + let server = self.server.write().await.take(); + *self + .pi_listener + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let stopped = match server { + Some(server) => server.stop().await.map_err(|error| error.to_string()), + None => Ok(()), + }; + return Err(match stopped { + Ok(()) => format!( + "Pi desired takeover could not be reconciled after listener start; direct mode was restored: {error}" + ), + Err(stopped) => format!( + "Pi desired takeover could not be reconciled after listener start: {error}; direct mode was restored but listener shutdown failed: {stopped}" + ), + }); + } + } + log::info!("代理服务器已启动: {}:{}", info.address, info.port); Ok(info) } @@ -590,6 +927,57 @@ impl ProxyService { .map_err(|e| format!("保存动态代理端口失败: {e}")) } + async fn restore_proxy_config_snapshot(&self, config: &ProxyConfig) -> Result<(), String> { + self.db + .update_proxy_config(config.clone()) + .await + .map_err(|error| format!("恢复原代理配置失败: {error}")) + } + + fn replace_pi_listener_identity(&self, server: &ProxyServer, info: &ProxyServerInfo) { + *self + .pi_listener + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + pi_loopback_origin(&info.address, info.port) + .ok() + .map(|gateway_origin| PiListenerIdentity { + server_generation: server.pi_server_generation(), + gateway_origin, + }); + } + + async fn recover_previous_listener( + &self, + previous_server: Option, + previous_config: &ProxyConfig, + ) -> Result<(), String> { + let server = previous_server.ok_or_else(|| "原代理监听器实例不可用".to_string())?; + let info = server + .start() + .await + .map_err(|error| format!("恢复原代理监听器失败: {error}"))?; + + let mut recovered_config = previous_config.clone(); + recovered_config.listen_port = info.port; + if let Err(error) = self.restore_proxy_config_snapshot(&recovered_config).await { + let _ = server.stop().await; + return Err(error); + } + + self.replace_pi_listener_identity(&server, &info); + *self.server.write().await = Some(server); + if crate::settings::pi_takeover_enabled() { + if let Err(error) = self.reconcile_pi_runtime().await { + let direct_rollback = self.suspend_pi_takeover_projection().await; + return Err(format!( + "原监听器已恢复,但 Pi 网关重建失败: {error}; 直连回滚: {direct_rollback:?}" + )); + } + } + Ok(()) + } + async fn start_before_takeover_if_ephemeral_port(&self) -> Result { let config = self .db @@ -722,6 +1110,7 @@ impl ProxyService { grokbuild: grokbuild_enabled, opencode: opencode_enabled, openclaw: openclaw_enabled, + pi: crate::settings::pi_takeover_enabled(), }) } @@ -733,6 +1122,9 @@ impl ProxyService { let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?; let app_type_str = app.as_str(); let _guard = self.switch_locks.lock_for_app(app_type_str).await; + if app == AppType::Pi { + return self.set_pi_takeover_locked(enabled).await; + } if enabled { // 1) 代理服务未运行则自动启动 @@ -915,6 +1307,112 @@ impl ProxyService { Ok(()) } + async fn set_pi_takeover_locked(&self, enabled: bool) -> Result<(), String> { + if enabled { + let was_enabled = crate::settings::pi_takeover_enabled(); + if !self.is_running().await { + self.start_with_pi_lock_held().await?; + } + if self + .pi_listener + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + { + return Err( + "Pi gateway requires the proxy listener to bind an explicit loopback address" + .to_string(), + ); + } + // Persist the stable secret before changing desired state. A + // failure here leaves both native projection and admission intact. + crate::settings::get_or_create_pi_gateway_token().map_err(|error| error.to_string())?; + crate::settings::set_pi_takeover_enabled(true).map_err(|error| error.to_string())?; + + if let Err(error) = self.reconcile_pi_runtime().await { + if was_enabled { + let epoch = self.pi_runtime.begin_mutation().await; + let restored = self.pi_runtime.republish_current(epoch).await; + return Err(if restored.is_ok() { + format!( + "failed to refresh Pi gateway catalog; the previous runtime remains active: {error}" + ) + } else { + format!( + "failed to refresh Pi gateway catalog and restore admission: {error}" + ) + }); + } + + let direct_projection = self.restore_pi_direct_projection(); + match direct_projection { + Ok(()) => { + let desired_restored = crate::settings::set_pi_takeover_enabled(false); + let epoch = self.pi_runtime.begin_mutation().await; + let admission_closed = self.pi_runtime.close(epoch).await; + return Err(if desired_restored.is_ok() && admission_closed.is_ok() { + format!( + "failed to publish Pi gateway catalog; direct mode was restored: {error}" + ) + } else { + format!( + "failed to publish Pi gateway catalog and fully restore direct mode: {error}" + ) + }); + } + Err(restore_error) => { + // Keep the desired bit true when restoring the native + // direct projection itself failed. Startup + // reconciliation can retry, and the error remains + // explicit instead of claiming a successful rollback + // while models.json still points local. + let epoch = self.pi_runtime.begin_mutation().await; + let _ = self.pi_runtime.republish_current(epoch).await; + return Err(format!( + "failed to publish Pi gateway catalog: {error}; failed to restore the native direct projection: {restore_error}" + )); + } + } + } + self.refresh_active_target_from_current_provider(&AppType::Pi) + .await; + return Ok(()); + } + + if !crate::settings::pi_takeover_enabled() { + return Ok(()); + } + let epoch = self.pi_runtime.begin_mutation().await; + if let Err(error) = self.restore_pi_direct_projection() { + let _ = self.pi_runtime.republish_current(epoch).await; + return Err(format!( + "failed to restore Pi's direct native projection: {error}" + )); + } + if let Err(error) = crate::settings::set_pi_takeover_enabled(false) { + // Desired state did not change; rebuild the gateway projection and + // restore admission so the native file cannot be left lying. + let _ = self.reconcile_pi_runtime_at_epoch(epoch).await; + return Err(format!("failed to persist Pi takeover disable: {error}")); + } + + self.pi_runtime + .close(epoch) + .await + .map_err(|error| error.to_string())?; + + let status = self.get_takeover_status().await?; + if !status.claude + && !status.codex + && !status.gemini + && !status.grokbuild + && self.is_running().await + { + let _ = self.stop().await; + } + Ok(()) + } + /// 同步关闭指定应用的 Live 接管(恢复配置并清标志,不停止代理服务)。 /// /// 用于 `ProfileService::apply` 等 sync 路径:调用者所在线程可能没有 Tokio @@ -1283,7 +1781,36 @@ impl ProxyService { /// 停止代理服务器 pub async fn stop(&self) -> Result<(), String> { + // `stop()` has several internal callers (profile switching included) + // that do not pass through the public takeover command. Never leave + // models.json pointing at a listener that this call is about to stop. + // The desired bit remains true so a later start can republish a fresh + // listener identity. + let _pi_guard = if crate::settings::pi_takeover_enabled() { + Some(self.switch_locks.lock_for_app(AppType::Pi.as_str()).await) + } else { + None + }; + if crate::settings::pi_takeover_enabled() { + self.suspend_pi_takeover_projection() + .await + .map_err(|error| { + format!( + "refusing to stop the proxy while Pi's direct projection cannot be restored: {error}" + ) + })?; + } + self.stop_listener_only().await + } + + async fn stop_listener_only(&self) -> Result<(), String> { if let Some(server) = self.server.write().await.take() { + let terminal_epoch = self.pi_runtime.begin_mutation().await; + let _ = self.pi_runtime.close(terminal_epoch).await; + *self + .pi_listener + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; server .stop() .await @@ -1314,8 +1841,12 @@ impl ProxyService { /// /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。 pub async fn stop_with_restore(&self) -> Result<(), String> { + if crate::settings::pi_takeover_enabled() { + self.set_takeover_for_app(AppType::Pi.as_str(), false) + .await?; + } // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) - if let Err(e) = self.stop().await { + if let Err(e) = self.stop_listener_only().await { log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); } @@ -1361,8 +1892,14 @@ impl ProxyService { /// /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复 pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> { + // Pi's gateway projection lives in models.json rather than the legacy + // Live-backup table. Restore its direct exact-key projection while the + // listener is still alive, but retain the desired bit so startup can + // publish a fresh gateway projection. + self.suspend_pi_takeover_for_process_exit().await?; + // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑) - if let Err(e) = self.stop().await { + if let Err(e) = self.stop_listener_only().await { log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}"); } @@ -2196,7 +2733,7 @@ impl ProxyService { /// 检查是否处于 Live 接管模式 pub async fn is_takeover_active(&self) -> Result { let status = self.get_takeover_status().await?; - Ok(status.claude || status.codex || status.gemini || status.grokbuild) + Ok(status.claude || status.codex || status.gemini || status.grokbuild || status.pi) } /// 从异常退出中恢复(启动时调用) @@ -3089,6 +3626,11 @@ impl ProxyService { /// 更新代理配置 pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> { + // Listener identity is part of Pi's native projection contract. + // Serialize address changes with every Pi catalog/takeover mutation so + // models.json can never race from one listener generation to another. + let _pi_guard = self.switch_locks.lock_for_app(AppType::Pi.as_str()).await; + // 记录旧配置用于判定是否需要重启 let previous = self .db @@ -3116,28 +3658,84 @@ impl ProxyService { || new_config.listen_port != previous.listen_port; if require_restart { - if let Some(server) = server_guard.take() { - server - .stop() - .await - .map_err(|e| format!("重启前停止代理服务器失败: {e}"))?; + // A listener restart first returns Pi to its direct, pinned-native + // representation while the old listener is still reachable. The + // desired bit is retained and a fresh gateway projection is only + // published after the new listener identity is known. + if let Err(error) = self.suspend_pi_takeover_projection().await { + let config_rollback = self.restore_proxy_config_snapshot(&previous).await; + return Err(format!( + "重启代理前恢复 Pi 直连投影失败: {error}; 配置回滚: {config_rollback:?}" + )); } + let previous_server = server_guard.take(); + if let Some(server) = previous_server.as_ref() { + if let Err(error) = server.stop().await { + *self + .pi_listener + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + drop(server_guard); + let recovery = self + .recover_previous_listener(previous_server, &previous) + .await; + return Err(format!( + "重启前停止代理服务器失败: {error}; Pi 已恢复直连,原监听器恢复: {recovery:?}" + )); + } + } + *self + .pi_listener + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let app_handle = self.app_handle.read().await.clone(); - let new_server = ProxyServer::new(new_config.clone(), self.db.clone(), app_handle); - let info = new_server - .start() - .await - .map_err(|e| format!("重启代理服务器失败: {e}"))?; + let pi_server_generation = self + .pi_server_sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1); + let new_server = ProxyServer::new( + new_config.clone(), + self.db.clone(), + app_handle, + self.pi_runtime.clone(), + pi_server_generation, + ); + let info = match new_server.start().await { + Ok(info) => info, + Err(error) => { + drop(server_guard); + let recovery = self + .recover_previous_listener(previous_server, &previous) + .await; + return Err(format!( + "重启代理服务器失败: {error}; 原监听器恢复: {recovery:?}" + )); + } + }; if let Err(e) = self .persist_ephemeral_listen_port_if_needed(&new_config, info.port) .await { let _ = new_server.stop().await; - return Err(e); + drop(server_guard); + let recovery = self + .recover_previous_listener(previous_server, &previous) + .await; + return Err(format!("{e}; 原监听器恢复: {recovery:?}")); } + self.replace_pi_listener_identity(&new_server, &info); *server_guard = Some(new_server); + if crate::settings::pi_takeover_enabled() { + if let Err(error) = self.reconcile_pi_runtime().await { + let direct_rollback = self.suspend_pi_takeover_projection().await; + return Err(format!( + "重建 Pi 网关运行时失败: {error}; 新监听器保持运行,Pi 直连回滚: {direct_rollback:?}" + )); + } + } log::info!("代理配置已更新,服务器已自动重启应用最新配置"); // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口) @@ -3239,7 +3837,9 @@ impl ProxyService { #[cfg(test)] mod tests { use super::*; - use crate::provider::ProviderMeta; + use crate::database::NewProviderAggregate; + use crate::provider::{ProviderMeta, ProviderMutationInput}; + use crate::AppState; use serial_test::serial; use std::env; use tempfile::TempDir; @@ -3308,6 +3908,297 @@ mod tests { format!("http://127.0.0.1:{}/v1", status.port) } + #[test] + fn process_exit_projection_restores_claimed_pi_values_and_preserves_unowned_values() { + let db = Arc::new(Database::memory().expect("in-memory database")); + let config = serde_json::json!({ + "name": "Managed Pi", + "api": "openai-responses", + "baseUrl": "https://managed.example/v1", + "apiKey": "managed-key", + "models": [{"id": "model-a", "name": "Model A"}] + }); + let input = ProviderMutationInput { + id: "managed-pi".to_string(), + name: "Managed Pi".to_string(), + settings_config: config.clone(), + website_url: None, + category: None, + created_at: None, + sort_index: Some(0), + notes: None, + meta: None, + icon: Some("pi".to_string()), + icon_color: None, + in_failover_queue: false, + }; + db.create_pi_catalog_provider( + NewProviderAggregate::from_input("pi", input).expect("build aggregate"), + "managed-pi", + ) + .expect("seed managed Pi provider"); + let state = AppState::new(db); + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("models.json"); + let unowned = serde_json::json!({ + "api": "anthropic-messages", + "baseUrl": "https://native.example", + "apiKey": "native-key", + "models": [{"id": "native-model"}] + }); + std::fs::write( + &path, + serde_json::to_vec_pretty(&serde_json::json!({ + "providers": { + "managed-pi": { + "api": "openai-responses", + "baseUrl": "http://127.0.0.1:15721/pi/route", + "apiKey": "gateway-token", + "models": [{"id": "model-a"}] + }, + "native": unowned.clone() + } + })) + .expect("serialize gateway projection"), + ) + .expect("write gateway projection"); + + state + .proxy_service + .restore_pi_direct_projection_at(&path) + .expect("restore direct Pi projection"); + + let restored: Value = + serde_json::from_slice(&std::fs::read(&path).expect("read restored Pi catalog")) + .expect("parse restored Pi catalog"); + assert_eq!(restored.pointer("/providers/managed-pi"), Some(&config)); + assert_eq!(restored.pointer("/providers/native"), Some(&unowned)); + } + + #[tokio::test] + #[serial] + async fn changing_pi_directory_moves_gateway_ownership_and_restores_the_old_file() { + let home = TempHome::new(); + crate::settings::reload_settings().expect("reload isolated settings"); + let old_dir = home.dir.path().join("old-pi"); + let new_dir = home.dir.path().join("new-pi"); + std::fs::create_dir_all(&old_dir).expect("old Pi directory"); + std::fs::create_dir_all(&new_dir).expect("new Pi directory"); + + let direct = json!({ + "name": "Managed Pi", + "api": "openai-responses", + "baseUrl": "https://managed.example/v1", + "apiKey": "managed-key", + "models": [{"id": "model-a", "name": "Model A"}] + }); + for directory in [&old_dir, &new_dir] { + std::fs::write( + directory.join("models.json"), + serde_json::to_vec_pretty(&json!({ + "providers": {"managed-pi": direct.clone()} + })) + .expect("serialize Pi models"), + ) + .expect("seed Pi models"); + } + + let mut settings = crate::settings::get_settings(); + settings.pi_config_dir = Some(old_dir.to_string_lossy().into_owned()); + crate::settings::update_settings(settings).expect("set old Pi directory"); + + let db = Arc::new(Database::memory().expect("in-memory database")); + use_ephemeral_proxy_port(&db).await; + let input = ProviderMutationInput { + id: "managed-pi".to_string(), + name: "Managed Pi".to_string(), + settings_config: direct.clone(), + website_url: None, + category: None, + created_at: None, + sort_index: Some(0), + notes: None, + meta: None, + icon: Some("pi".to_string()), + icon_color: None, + in_failover_queue: false, + }; + db.create_pi_catalog_provider( + NewProviderAggregate::from_input("pi", input).expect("aggregate"), + "managed-pi", + ) + .expect("seed Pi catalog"); + + let service = ProxyService::new(db); + service + .set_takeover_for_app("pi", true) + .await + .expect("enable Pi takeover"); + + let guard = service.lock_switch_for_app("pi").await; + let existing = crate::settings::get_settings(); + let mut next = existing.clone(); + next.pi_config_dir = Some(new_dir.to_string_lossy().into_owned()); + service + .replace_settings_with_pi_directory_boundary_under_lock(&guard, &existing, next) + .await + .expect("move Pi directory ownership"); + drop(guard); + + let old_document: Value = serde_json::from_slice( + &std::fs::read(old_dir.join("models.json")).expect("read old Pi models"), + ) + .expect("parse old Pi models"); + assert_eq!( + old_document.pointer("/providers/managed-pi"), + Some(&direct), + "the old native directory must be restored to direct Pi semantics" + ); + + let new_document: Value = serde_json::from_slice( + &std::fs::read(new_dir.join("models.json")).expect("read new Pi models"), + ) + .expect("parse new Pi models"); + let new_base_url = new_document + .pointer("/providers/managed-pi/baseUrl") + .and_then(Value::as_str) + .expect("new gateway base URL"); + let new_base_url = url::Url::parse(new_base_url).expect("parse new gateway base URL"); + assert_eq!(new_base_url.host_str(), Some("127.0.0.1")); + assert!(new_base_url.path().starts_with("/pi/")); + assert_eq!( + crate::settings::get_settings().pi_config_dir.as_deref(), + Some(new_dir.to_string_lossy().as_ref()) + ); + + service + .set_takeover_for_app("pi", false) + .await + .expect("disable Pi takeover"); + } + + #[tokio::test] + #[serial] + async fn failed_listener_rebind_recovers_pi_on_the_previous_listener() { + let _home = TempHome::new(); + crate::settings::reload_settings().expect("reload isolated settings"); + let mut settings = crate::settings::get_settings(); + settings.pi_config_dir = Some( + crate::config::get_home_dir() + .join(".pi/agent") + .to_string_lossy() + .into_owned(), + ); + crate::settings::update_settings(settings).expect("set Pi directory"); + + let db = Arc::new(Database::memory().expect("in-memory database")); + use_ephemeral_proxy_port(&db).await; + let config = json!({ + "name": "Managed Pi", + "api": "openai-responses", + "baseUrl": "https://managed.example/v1", + "apiKey": "managed-key", + "models": [{"id": "model-a", "name": "Model A"}] + }); + let input = ProviderMutationInput { + id: "managed-pi".to_string(), + name: "Managed Pi".to_string(), + settings_config: config.clone(), + website_url: None, + category: None, + created_at: None, + sort_index: Some(0), + notes: None, + meta: None, + icon: Some("pi".to_string()), + icon_color: None, + in_failover_queue: false, + }; + db.create_pi_catalog_provider( + NewProviderAggregate::from_input("pi", input).expect("aggregate"), + "managed-pi", + ) + .expect("seed Pi catalog"); + let models_path = crate::pi_config::native::get_pi_models_path().expect("models path"); + std::fs::create_dir_all(models_path.parent().expect("Pi directory")).expect("Pi directory"); + std::fs::write( + &models_path, + serde_json::to_vec_pretty(&json!({"providers": {"managed-pi": config}})) + .expect("serialize models"), + ) + .expect("write models"); + + let service = ProxyService::new(db.clone()); + service + .set_takeover_for_app("pi", true) + .await + .expect("enable Pi takeover"); + let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("reserve conflicting port"); + let mut rejected = db.get_proxy_config().await.expect("proxy config"); + rejected.listen_port = occupied.local_addr().expect("reserved address").port(); + assert!(service.update_config(&rejected).await.is_err()); + + let status = service.get_status().await.expect("recovered status"); + assert!(status.running); + let stored = db.get_proxy_config().await.expect("recovered config"); + assert_eq!(stored.listen_port, status.port); + let projected: Value = + serde_json::from_slice(&std::fs::read(&models_path).expect("read models")) + .expect("parse models"); + let projected_base = projected + .pointer("/providers/managed-pi/baseUrl") + .and_then(Value::as_str) + .expect("gateway base url"); + assert!(projected_base.starts_with(&format!("http://127.0.0.1:{}/pi/", status.port))); + assert!(!projected_base.contains(&rejected.listen_port.to_string())); + + let import_guard = service.lock_switch_for_app(AppType::Pi.as_str()).await; + service + .prepare_pi_portable_import_under_lock(&import_guard) + .await + .expect("portable import boundary restores the old direct catalog"); + let import_safe: Value = + serde_json::from_slice(&std::fs::read(&models_path).expect("read import-safe models")) + .expect("parse import-safe models"); + assert_eq!(import_safe.pointer("/providers/managed-pi"), Some(&config)); + service + .recover_pi_after_aborted_portable_import_under_lock(&import_guard) + .await + .expect("aborted portable import restores the gateway"); + drop(import_guard); + let republished: Value = + serde_json::from_slice(&std::fs::read(&models_path).expect("read republished models")) + .expect("parse republished models"); + assert!( + republished + .pointer("/providers/managed-pi/baseUrl") + .and_then(Value::as_str) + .is_some_and( + |base| base.starts_with(&format!("http://127.0.0.1:{}/pi/", status.port)) + ) + ); + drop(occupied); + + service + .stop() + .await + .expect("a direct stop restores Pi before closing the listener"); + let direct: Value = + serde_json::from_slice(&std::fs::read(&models_path).expect("read direct models")) + .expect("parse direct models"); + assert_eq!(direct.pointer("/providers/managed-pi"), Some(&config)); + assert!( + crate::settings::pi_takeover_enabled(), + "safe listener stop preserves desired takeover for the next start" + ); + service + .set_takeover_for_app("pi", false) + .await + .expect("disable Pi takeover"); + } + fn seed_codex_model_template() { let codex_dir = crate::codex_config::get_codex_config_dir(); std::fs::create_dir_all(&codex_dir).expect("create codex dir"); diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 84f02e9eb..e8f9ddeb0 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -565,6 +565,9 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::Pi => { + return Ok(crate::pi_config::native::get_pi_agent_dir()?.join("skills")); + } } // 默认路径:回退到用户主目录下的标准位置。 @@ -581,6 +584,7 @@ impl SkillService { AppType::OpenCode => home.join(".config").join("opencode").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), + AppType::Pi => crate::pi_config::native::get_pi_agent_dir()?.join("skills"), }) } @@ -637,8 +641,19 @@ impl SkillService { // 同一仓库的同名 skill,返回现有记录(可能需要更新启用状态) let mut updated = existing.clone(); updated.apps.set_enabled_for(current_app, true); + if matches!(current_app, AppType::Pi) { + let guard = crate::services::skill_deployment::PiSkillDeploymentService::operation_guard(); + crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard( + &guard, + db, + &mut updated, + true, + ) + .map_err(|error| anyhow!(error.to_string()))?; + return Ok(updated); + } db.save_skill(&updated)?; - Self::sync_to_app_dir(&updated.directory, current_app)?; + Self::sync_installed_skill_to_app(db, &updated, current_app)?; log::info!( "Skill {} 已存在,更新 {:?} 启用状态", updated.name, @@ -671,6 +686,7 @@ impl SkillService { } let dest = ssot_dir.join(&install_name); + let destination_preexisted = dest.exists(); let mut repo_branch = skill.repo_branch.clone(); @@ -788,11 +804,39 @@ impl SkillService { updated_at: 0, }; - // 保存到数据库 - db.save_skill(&installed_skill)?; - - // 同步到当前应用目录 - Self::sync_to_app_dir(&install_name, current_app)?; + let installed_skill = if matches!(current_app, AppType::Pi) { + let guard = + crate::services::skill_deployment::PiSkillDeploymentService::operation_guard(); + let mut persisted = installed_skill.clone(); + // Let the deployment coordinator commit the desired bit and ledger + // evidence together. Until then the row is deliberately disabled. + persisted.apps.pi = false; + if let Err(error) = db.save_skill(&persisted) { + if !destination_preexisted { + let _ = fs::remove_dir_all(&dest); + } + return Err(error.into()); + } + if let Err(error) = + crate::services::skill_deployment::PiSkillDeploymentService::toggle_under_guard( + &guard, + db, + &mut persisted, + true, + ) + { + let _ = db.delete_skill(&persisted.id); + if !destination_preexisted { + let _ = fs::remove_dir_all(&dest); + } + return Err(anyhow!(error.to_string())); + } + persisted + } else { + db.save_skill(&installed_skill)?; + Self::sync_installed_skill_to_app(db, &installed_skill, current_app)?; + installed_skill + }; log::info!( "Skill {} 安装成功,已启用 {:?}", @@ -810,6 +854,8 @@ impl SkillService { /// 2. 从 SSOT 删除 /// 3. 从数据库删除 pub fn uninstall(db: &Arc, id: &str) -> Result { + let deployment_guard = + crate::services::skill_deployment::PiSkillDeploymentService::operation_guard(); // 获取 skill 信息 let skill = db .get_installed_skill(id)? @@ -828,8 +874,15 @@ impl SkillService { let backup_path = Self::create_uninstall_backup(&skill)? .map(|path| path.to_string_lossy().to_string()); + crate::services::skill_deployment::PiSkillDeploymentService::remove_before_uninstall_under_guard( + &deployment_guard, + db, + &skill, + ) + .map_err(|error| anyhow!(error.to_string()))?; + // 从所有应用目录删除 - for app in AppType::all() { + for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) { let _ = Self::remove_from_app(&directory, &app); } @@ -1113,15 +1166,40 @@ impl SkillService { )) })?; + // All Pi deployment mutations, SSOT replacement, and ledger + // reconciliation share one process boundary. Downloading remains + // outside the lock so a slow network cannot block toggles. + let deployment_guard = + crate::services::skill_deployment::PiSkillDeploymentService::operation_guard(); + // 备份旧文件 let _ = Self::create_uninstall_backup(&skill); - // 删除旧 SSOT 目录并复制新文件 + // Stage the exact old SSOT tree in the same directory. Reconstructing + // it from the remote source is not a rollback: local files may differ. let dest = ssot_dir.join(&skill.directory); - if dest.exists() { - fs::remove_dir_all(&dest)?; + let staged_previous = if fs::symlink_metadata(&dest).is_ok() { + let staged = ssot_dir.join(format!( + ".{}.cc-switch-update-{}", + skill.directory, + uuid::Uuid::new_v4().simple() + )); + fs::rename(&dest, &staged)?; + Some(staged) + } else { + None + }; + if let Err(error) = Self::copy_dir_recursive(&source, &dest) { + if let Some(staged) = staged_previous.as_deref() { + fs::rename(staged, &dest).with_context(|| { + format!( + "Skill update copy failed ({error}); restoring {} also failed", + dest.display() + ) + })?; + } + return Err(error); } - Self::copy_dir_recursive(&source, &dest)?; // 计算新哈希 + 解析新元数据 let new_hash = Self::compute_dir_hash(&dest).ok(); @@ -1151,14 +1229,71 @@ impl SkillService { updated_at: chrono::Utc::now().timestamp(), }; - db.save_skill(&updated_skill)?; + if let Err(error) = db.save_skill(&updated_skill) { + let _ = Self::remove_path(&dest); + if let Some(staged) = staged_previous.as_deref() { + fs::rename(staged, &dest).with_context(|| { + format!( + "Skill metadata update failed ({error}); restoring {} also failed", + dest.display() + ) + })?; + } + return Err(error.into()); + } - // 同步到所有已启用的应用目录 - for app in updated_skill.apps.enabled_apps() { - if let Err(e) = Self::sync_to_app_dir(&updated_skill.directory, &app) { + // Pi is the consistency-critical consumer: update its owned + // deployment before best-effort legacy app copies. + if updated_skill.apps.pi { + if let Err(error) = + crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill_under_guard( + &deployment_guard, + db, + &updated_skill, + ) + { + let db_rollback = db.save_skill(&skill); + let file_rollback = Self::remove_path(&dest).and_then(|_| { + if let Some(staged) = staged_previous.as_deref() { + fs::rename(staged, &dest).map_err(anyhow::Error::from) + } else { + Ok(()) + } + }); + return match (db_rollback, file_rollback) { + (Ok(()), Ok(())) => Err(anyhow!(error.to_string())), + (db_result, file_result) => Err(anyhow!( + "Pi Skill update failed ({error}); DB rollback: {}; file rollback: {}", + db_result + .err() + .map_or_else(|| "ok".to_string(), |value| value.to_string()), + file_result + .err() + .map_or_else(|| "ok".to_string(), |value| value.to_string()) + )), + }; + } + } + + // 同步到所有已启用的其他应用目录 + for app in updated_skill + .apps + .enabled_apps() + .into_iter() + .filter(|app| !matches!(app, AppType::Pi)) + { + if let Err(e) = Self::sync_installed_skill_to_app(db, &updated_skill, &app) { log::warn!("同步更新后的 skill 到 {:?} 失败: {e}", app); } } + if let Some(staged) = staged_previous { + if let Err(error) = Self::remove_path(&staged) { + log::warn!( + "Failed to remove committed Skill update rollback staging '{}': {error}", + staged.display() + ); + } + } log::info!("Skill {} 更新成功", updated_skill.name); Ok(updated_skill) @@ -1201,7 +1336,10 @@ impl SkillService { /// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件) /// - /// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。 + /// Safety strategy: copy first while the old SSOT remains live, switch the + /// setting, reconcile every app, then delete the old trees. Keeping both + /// roots during reconciliation lets the Pi ownership ledger verify its old + /// symlink/copy before atomically replacing it. pub fn migrate_storage( db: &Arc, target: SkillStorageLocation, @@ -1215,6 +1353,9 @@ impl SkillService { }); } + let deployment_guard = + crate::services::skill_deployment::PiSkillDeploymentService::operation_guard(); + // 1. 解析旧目录和新目录(不改设置) let old_dir = Self::get_ssot_dir()?; let new_dir = match target { @@ -1225,18 +1366,18 @@ impl SkillService { }; fs::create_dir_all(&new_dir)?; - // 2. 逐个移动 skill 目录 + // 2. Copy every valid tree. Do not rename/delete the old root before + // Pi has verified the ownership identity recorded in its ledger. let skills = db.get_all_installed_skills()?; let mut result = MigrationResult { migrated_count: 0, skipped_count: 0, errors: vec![], }; + let mut copied = Vec::<(PathBuf, PathBuf)>::new(); for skill in skills.values() { - // 下面是 rename 与 remove_dir_all,脏 directory 可把任意目录搬走或删掉。 - // 软失败:本函数已有 errors 收集通道,记一条继续处理其余 skill, - // 不要整体中断——用户只是在切换存储位置。 + // Invalid DB rows are reported but never joined to either root. let directory = match Self::require_valid_directory(&skill.directory) { Ok(directory) => directory, Err(err) => { @@ -1253,32 +1394,90 @@ impl SkillService { result.skipped_count += 1; continue; } - if dst.exists() { - result.skipped_count += 1; - continue; + if fs::symlink_metadata(&dst).is_ok() { + for (_, copied_destination) in copied.iter().rev() { + let _ = Self::remove_path(copied_destination); + } + return Err(anyhow!( + "Skill storage target already contains an unowned entry: {}", + dst.display() + )); } - - // 优先 rename(同文件系统原子操作),失败则 copy+delete - match fs::rename(&src, &dst) { - Ok(()) => result.migrated_count += 1, - Err(_) => match Self::copy_dir_recursive(&src, &dst) { - Ok(()) => { - let _ = fs::remove_dir_all(&src); - result.migrated_count += 1; - } - Err(e) => { - result.errors.push(format!("{}: {e}", skill.directory)); - } - }, + if let Err(error) = Self::copy_dir_recursive(&src, &dst) { + let _ = Self::remove_path(&dst); + for (_, copied_destination) in copied.iter().rev() { + let _ = Self::remove_path(copied_destination); + } + return Err(error); } + copied.push((src, dst)); + result.migrated_count += 1; } - // 3. 文件移动完成后才持久化设置 - crate::settings::set_skill_storage_location(target)?; + // 3. Switch authority only after every new tree is complete. + if let Err(error) = crate::settings::set_skill_storage_location(target) { + for (_, copied_destination) in copied.iter().rev() { + let _ = Self::remove_path(copied_destination); + } + return Err(error.into()); + } - // 4. 刷新所有应用目录的 symlink(指向新 SSOT) - for app in AppType::all() { - let _ = Self::sync_to_app(db, &app); + // 4. Reconcile Pi under the same mutex, then all legacy app views. + let reconcile_result = + crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard( + &deployment_guard, + db, + ) + .map_err(|error| anyhow!(error.to_string())) + .and_then(|()| { + for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) { + Self::sync_to_app(db, &app)?; + } + Ok(()) + }); + if let Err(error) = reconcile_result { + let mut rollback_errors = Vec::new(); + if let Err(rollback) = crate::settings::set_skill_storage_location(current) { + rollback_errors.push(format!("settings: {rollback}")); + } else { + if let Err(rollback) = + crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all_under_guard( + &deployment_guard, + db, + ) + { + rollback_errors.push(format!("Pi deployment: {rollback}")); + } + for app in AppType::all().filter(|app| !matches!(app, AppType::Pi)) { + if let Err(rollback) = Self::sync_to_app(db, &app) { + rollback_errors.push(format!("{app:?}: {rollback}")); + } + } + } + for (_, copied_destination) in copied.iter().rev() { + if let Err(rollback) = Self::remove_path(copied_destination) { + rollback_errors.push(format!("{}: {rollback}", copied_destination.display())); + } + } + return if rollback_errors.is_empty() { + Err(error) + } else { + Err(anyhow!( + "Skill storage migration failed ({error}); rollback failures: {}", + rollback_errors.join("; ") + )) + }; + } + + // 5. Only after every consumer points at the new root may the old + // sources be removed. Cleanup errors are visible but do not roll back + // an already-consistent authority switch. + for (old_source, _) in &copied { + if let Err(error) = Self::remove_path(old_source) { + result + .errors + .push(format!("{}: {error}", old_source.display())); + } } log::info!( @@ -1403,7 +1602,7 @@ impl SkillService { } if !restored_skill.apps.is_empty() { - if let Err(err) = Self::sync_to_app_dir(&restored_skill.directory, current_app) { + if let Err(err) = Self::sync_installed_skill_to_app(db, &restored_skill, current_app) { let _ = db.delete_skill(&restored_skill.id); let _ = fs::remove_dir_all(&restore_path); return Err(err); @@ -1429,6 +1628,14 @@ impl SkillService { .get_installed_skill(id)? .ok_or_else(|| anyhow!("Skill not found: {id}"))?; + if matches!(app, AppType::Pi) { + crate::services::skill_deployment::PiSkillDeploymentService::toggle( + db, &mut skill, enabled, + ) + .map_err(|error| anyhow!(error.to_string()))?; + return Ok(()); + } + // 更新状态 skill.apps.set_enabled_for(app, enabled); @@ -1517,6 +1724,11 @@ impl SkillService { db: &Arc, imports: Vec, ) -> Result> { + // Import can explicitly acquire or release Pi filesystem ownership. + // Serialize the source scan, SSOT establishment, ownership decision and + // desired-state transaction with every other Pi deployment operation. + let deployment_guard = + crate::services::skill_deployment::PiSkillDeploymentService::operation_guard(); let ssot_dir = Self::get_ssot_dir()?; let agents_lock = parse_agents_lock(); let mut imported = Vec::new(); @@ -1579,8 +1791,12 @@ impl SkillService { // 复制到 SSOT let dest = ssot_dir.join(&dir_name); - if !dest.exists() { - Self::copy_dir_recursive(&source, &dest)?; + let created_ssot = !dest.exists(); + if created_ssot { + if let Err(error) = Self::copy_dir_recursive(&source, &dest) { + let _ = Self::remove_path(&dest); + return Err(error); + } } // 解析元数据 @@ -1588,7 +1804,7 @@ impl SkillService { let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); // 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。 - let apps = selection.apps; + let requested_apps = selection.apps; // 从 lock 文件提取仓库信息 let (id, repo_owner, repo_name, repo_branch, readme_url) = @@ -1599,7 +1815,8 @@ impl SkillService { let content_hash = Self::compute_dir_hash(&ssot_skill_dir).ok(); // 创建记录 - let skill = InstalledSkill { + let previous = db.get_installed_skill(&id)?; + let mut skill = InstalledSkill { id, name, description, @@ -1608,14 +1825,54 @@ impl SkillService { repo_name, repo_branch, readme_url, - apps, + // save_skill intentionally preserves an existing Pi desired bit. + // For a new row keep it disabled until the deployment ledger and + // desired bit can commit in one transaction below. + apps: SkillApps { + pi: previous.as_ref().is_some_and(|installed| installed.apps.pi), + ..requested_apps.clone() + }, installed_at: chrono::Utc::now().timestamp(), content_hash, updated_at: 0, }; // 保存到数据库 - db.save_skill(&skill)?; + if let Err(error) = db.save_skill(&skill) { + if created_ssot { + let _ = Self::remove_path(&dest); + } + return Err(error.into()); + } + if let Err(error) = crate::services::skill_deployment::PiSkillDeploymentService::import_desired_state_under_guard( + &deployment_guard, + db, + &mut skill, + requested_apps.pi, + ) { + let db_rollback = if let Some(previous) = previous.as_ref() { + db.save_skill(previous).map(|_| ()) + } else { + db.delete_skill(&skill.id).map(|_| ()) + }; + let file_rollback = if created_ssot { + Self::remove_path(&dest) + } else { + Ok(()) + }; + return match (db_rollback, file_rollback) { + (Ok(()), Ok(())) => Err(anyhow!(error.to_string())), + (db_result, file_result) => Err(anyhow!( + "Pi Skill import failed ({error}); DB rollback: {}; SSOT rollback: {}", + db_result + .err() + .map_or_else(|| "ok".to_string(), |value| value.to_string()), + file_result + .err() + .map_or_else(|| "ok".to_string(), |value| value.to_string()) + )), + }; + } imported.push(skill); } @@ -1655,6 +1912,19 @@ impl SkillService { crate::settings::get_skill_sync_method() } + fn sync_installed_skill_to_app( + db: &Arc, + skill: &InstalledSkill, + app: &AppType, + ) -> Result<()> { + if matches!(app, AppType::Pi) { + crate::services::skill_deployment::PiSkillDeploymentService::reconcile_skill(db, skill) + .map_err(|error| anyhow!(error.to_string())) + } else { + Self::sync_to_app_dir(&skill.directory, app) + } + } + /// 同步 Skill 到应用目录(使用 symlink 或 copy) /// /// 根据配置和平台选择最佳同步方式: @@ -1665,6 +1935,11 @@ impl SkillService { if matches!(app, AppType::ClaudeDesktop) { return Ok(()); } + if matches!(app, AppType::Pi) { + return Err(anyhow!( + "Pi Skill deployment requires the ownership ledger; use the database-aware reconciler" + )); + } // directory 可能来自被污染的 DB 行(如同步导入的远端快照),join 前必须校验。 let directory = Self::require_valid_directory(directory)?; @@ -1861,6 +2136,10 @@ impl SkillService { if matches!(app, AppType::ClaudeDesktop) { return Ok(()); } + if matches!(app, AppType::Pi) { + return crate::services::skill_deployment::PiSkillDeploymentService::reconcile_all(db) + .map_err(|error| anyhow!(error.to_string())); + } let skills = db.get_all_installed_skills()?; let ssot_dir = Self::get_ssot_dir()?; @@ -2113,7 +2392,7 @@ impl SkillService { } /// 静态方法:解析技能元数据 - fn parse_skill_metadata_static(path: &Path) -> Result { + pub(crate) fn parse_skill_metadata_static(path: &Path) -> Result { let content = fs::read_to_string(path)?; let content = content.trim_start_matches('\u{feff}'); @@ -3101,7 +3380,7 @@ impl SkillService { db.save_skill(&skill)?; // 同步到当前应用目录 - Self::sync_to_app_dir(&install_name, current_app)?; + Self::sync_installed_skill_to_app(db, &skill, current_app)?; log::info!( "Skill {} installed from ZIP, enabled for {:?}", @@ -4142,6 +4421,89 @@ mod tests { } } + #[test] + #[serial_test::serial] + fn importing_a_native_pi_skill_adopts_exact_content_and_can_disable_it() { + struct PiDirGuard(Option); + impl Drop for PiDirGuard { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("PI_CODING_AGENT_DIR", value), + None => std::env::remove_var("PI_CODING_AGENT_DIR"), + } + } + } + struct StorageLocationGuard(SkillStorageLocation); + impl Drop for StorageLocationGuard { + fn drop(&mut self) { + let _ = crate::settings::set_skill_storage_location(self.0); + } + } + + let temp = tempdir().expect("tempdir"); + let _home_guard = TestHomeGuard::set(temp.path()); + let pi_agent_dir = temp.path().join("pi-agent"); + let _pi_dir_guard = PiDirGuard(std::env::var_os("PI_CODING_AGENT_DIR")); + std::env::set_var("PI_CODING_AGENT_DIR", &pi_agent_dir); + let _storage_guard = StorageLocationGuard(crate::settings::get_skill_storage_location()); + crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch) + .expect("select isolated SSOT"); + + let native = pi_agent_dir.join("skills").join("native-skill"); + write_skill(&native, "Native Skill"); + fs::write(native.join("details.txt"), "pinned native bytes").expect("native detail"); + let db = Arc::new(Database::memory().expect("memory db")); + + let imported = SkillService::import_from_apps( + &db, + vec![ImportSkillSelection { + directory: "native-skill".to_string(), + apps: SkillApps::only(&AppType::Pi), + }], + ) + .expect("explicit Pi import should adopt the exact native tree"); + assert_eq!(imported.len(), 1); + assert!(imported[0].apps.pi); + + let statuses = + crate::services::skill_deployment::PiSkillDeploymentService::inspect_all(&db) + .expect("inspect Pi deployment"); + let status = statuses + .get(&imported[0].id) + .expect("imported status must exist"); + assert_eq!( + status.ownership, + crate::services::skill_deployment::PiSkillOwnership::Owned + ); + assert_eq!( + status.discovery, + crate::services::skill_deployment::PiSkillDiscovery::Active + ); + assert!(status.effectively_discovered); + + SkillService::toggle_app(&db, &imported[0].id, &AppType::Pi, false) + .expect("owned imported Pi Skill can be disabled"); + assert!( + !native.exists(), + "disabling an explicitly adopted native tree removes that owned deployment" + ); + assert!( + SkillService::get_ssot_dir() + .expect("SSOT") + .join("native-skill") + .join("SKILL.md") + .is_file(), + "disabling Pi must preserve the managed SSOT" + ); + assert!( + !db.get_installed_skill(&imported[0].id) + .expect("read imported row") + .expect("row") + .apps + .pi + ); + } + fn poisoned_skill(id: &str, directory: &str) -> InstalledSkill { InstalledSkill { id: id.to_string(), diff --git a/src-tauri/src/services/skill_deployment.rs b/src-tauri/src/services/skill_deployment.rs new file mode 100644 index 000000000..c6b063fbf --- /dev/null +++ b/src-tauri/src/services/skill_deployment.rs @@ -0,0 +1,969 @@ +//! Ownership-safe Pi Skill deployment reconciliation. +//! +//! Desired state lives on the installed Skill row. Filesystem presence alone +//! is never ownership evidence; only the device-local deployment ledger may +//! authorize replacement or deletion. + +use crate::app_config::{AppType, InstalledSkill}; +use crate::database::{Database, SkillDeployment, SkillDeploymentMethod}; +use crate::error::AppError; +use crate::services::skill::{SkillService, SyncMethod}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +pub(crate) struct PiSkillDeploymentService; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PiSkillOwnership { + Absent, + Owned, + Foreign, + Stale, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PiSkillDiscovery { + Absent, + Active, + Shadowed, + Invalid, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillAppStatus { + pub desired_enabled: bool, + pub owned_deployment: bool, + pub effectively_discovered: bool, + pub ownership: PiSkillOwnership, + pub discovery: PiSkillDiscovery, + pub issue: Option, +} + +impl PiSkillDeploymentService { + pub(crate) fn reconcile_skill( + db: &Arc, + skill: &InstalledSkill, + ) -> Result<(), AppError> { + let guard = Self::operation_guard(); + Self::reconcile_skill_under_guard(&guard, db, skill) + } + + pub(crate) fn toggle( + db: &Arc, + skill: &mut InstalledSkill, + enabled: bool, + ) -> Result<(), AppError> { + let guard = Self::operation_guard(); + Self::toggle_under_guard(&guard, db, skill, enabled) + } + + pub(crate) fn toggle_under_guard( + _guard: &MutexGuard<'static, ()>, + db: &Arc, + skill: &mut InstalledSkill, + enabled: bool, + ) -> Result<(), AppError> { + let destination = skill_destination(skill)?; + let destination_key = destination_key(&destination); + let existing = db.get_pi_skill_deployment(&skill.id, &destination_key)?; + if enabled { + let source = skill_source(skill)?; + deploy( + db, + skill, + &source, + &destination, + &destination_key, + existing, + Some(true), + )?; + } else { + remove_owned( + db, + skill, + &destination, + &destination_key, + existing, + Some(false), + )?; + } + skill.apps.pi = enabled; + Ok(()) + } + + /// Apply the Pi desired state selected by the user while importing an + /// existing Skill from application directories. + /// + /// Import is the one operation where an unowned Pi destination may become + /// managed: the user explicitly selected that exact native tree. Adoption + /// is allowed only when every byte in the native destination matches the + /// newly established SSOT source. A mere directory/name match is never + /// ownership evidence. + pub(crate) fn import_desired_state_under_guard( + _guard: &MutexGuard<'static, ()>, + db: &Arc, + skill: &mut InstalledSkill, + enabled: bool, + ) -> Result<(), AppError> { + let destination = skill_destination(skill)?; + let destination_key = destination_key(&destination); + let existing = db.get_pi_skill_deployment(&skill.id, &destination_key)?; + if enabled { + let source = skill_source(skill)?; + if existing.is_none() && fs::symlink_metadata(&destination).is_ok() { + adopt_exact_import(db, skill, &source, &destination, &destination_key)?; + } else { + deploy( + db, + skill, + &source, + &destination, + &destination_key, + existing, + Some(true), + )?; + } + } else { + remove_owned( + db, + skill, + &destination, + &destination_key, + existing, + Some(false), + )?; + } + skill.apps.pi = enabled; + Ok(()) + } + + pub(crate) fn reconcile_all(db: &Arc) -> Result<(), AppError> { + let guard = Self::operation_guard(); + Self::reconcile_all_under_guard(&guard, db) + } + + pub(crate) fn operation_guard() -> MutexGuard<'static, ()> { + deployment_lock() + } + + pub(crate) fn reconcile_skill_under_guard( + _guard: &MutexGuard<'static, ()>, + db: &Arc, + skill: &InstalledSkill, + ) -> Result<(), AppError> { + reconcile_skill_unlocked(db, skill) + } + + pub(crate) fn reconcile_all_under_guard( + _guard: &MutexGuard<'static, ()>, + db: &Arc, + ) -> Result<(), AppError> { + 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 + // row hide every valid Pi Skill during startup/storage migration. + // Only this syntactic row corruption is skippable: source errors, + // foreign destinations and stale ownership still fail closed. + if let Err(error) = validate_directory_name(&skill.directory) { + log::warn!( + "skipping invalid Pi Skill row '{}' during reconciliation: {error}", + skill.id + ); + continue; + } + reconcile_skill_unlocked(db, skill)?; + } + Ok(()) + } + + pub(crate) fn remove_before_uninstall_under_guard( + _guard: &MutexGuard<'static, ()>, + db: &Arc, + skill: &InstalledSkill, + ) -> Result<(), AppError> { + let destination = skill_destination(skill)?; + let key = destination_key(&destination); + let existing = db.get_pi_skill_deployment(&skill.id, &key)?; + remove_owned(db, skill, &destination, &key, existing, None) + } + + pub(crate) fn inspect_all( + db: &Arc, + ) -> Result, AppError> { + let _guard = deployment_lock(); + let discovery = scan_pi_discovery()?; + db.get_all_installed_skills()? + .into_iter() + .map(|(id, skill)| { + inspect_skill_status(db, &skill, &discovery).map(|status| (id, status)) + }) + .collect() + } +} + +#[derive(Debug)] +struct PiDiscoveryScan { + by_manifest: HashMap)>, +} + +fn scan_pi_discovery() -> Result { + const MAX_SKILL_MANIFEST_BYTES: u64 = 1024 * 1024; + const MAX_SKILL_DIRECTORIES: usize = 10_000; + + let root = SkillService::get_app_skills_dir(&AppType::Pi) + .map_err(|error| AppError::Config(error.to_string()))?; + let mut entries = match fs::read_dir(&root) { + Ok(entries) => entries + .collect::, _>>() + .map_err(|error| AppError::io(&root, error))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(PiDiscoveryScan { + by_manifest: HashMap::new(), + }); + } + Err(error) => return Err(AppError::io(&root, error)), + }; + if entries.len() > MAX_SKILL_DIRECTORIES { + return Err(AppError::InvalidInput(format!( + "Pi Skill discovery exceeds {MAX_SKILL_DIRECTORIES} top-level entries" + ))); + } + entries.sort_by_key(std::fs::DirEntry::file_name); + + let mut winner_by_name = HashMap::::new(); + let mut by_manifest = HashMap::new(); + for entry in entries { + let directory = entry.path(); + let metadata = fs::metadata(&directory).map_err(|error| AppError::io(&directory, error))?; + if !metadata.is_dir() { + continue; + } + let manifest = directory.join("SKILL.md"); + let metadata = match fs::symlink_metadata(&manifest) { + Ok(metadata) if metadata.file_type().is_file() => metadata, + Ok(_) => { + by_manifest.insert( + manifest, + ( + PiSkillDiscovery::Invalid, + Some("SKILL.md is not a regular file".to_string()), + ), + ); + continue; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(AppError::io(&manifest, error)), + }; + if metadata.len() > MAX_SKILL_MANIFEST_BYTES { + by_manifest.insert( + manifest, + ( + PiSkillDiscovery::Invalid, + Some("SKILL.md exceeds the 1 MiB inspection limit".to_string()), + ), + ); + continue; + } + let parsed = SkillService::parse_skill_metadata_static(&manifest) + .map_err(|error| AppError::Config(error.to_string()))?; + let Some(name) = parsed.name.filter(|name| !name.trim().is_empty()) else { + by_manifest.insert( + manifest, + ( + PiSkillDiscovery::Invalid, + Some("SKILL.md has no non-empty frontmatter name".to_string()), + ), + ); + continue; + }; + if let Some(winner) = winner_by_name.get(&name) { + by_manifest.insert( + manifest, + ( + PiSkillDiscovery::Shadowed, + Some(format!( + "skill name '{name}' is shadowed by {}", + winner.display() + )), + ), + ); + } else { + winner_by_name.insert(name, manifest.clone()); + by_manifest.insert(manifest, (PiSkillDiscovery::Active, None)); + } + } + Ok(PiDiscoveryScan { by_manifest }) +} + +fn inspect_skill_status( + db: &Arc, + skill: &InstalledSkill, + discovery: &PiDiscoveryScan, +) -> Result { + let destination = skill_destination(skill)?; + let destination_key = destination_key(&destination); + let deployment = db.get_pi_skill_deployment(&skill.id, &destination_key)?; + let manifest = destination.join("SKILL.md"); + let discovered = discovery.by_manifest.get(&manifest); + let destination_exists = fs::symlink_metadata(&destination).is_ok(); + let owned_deployment = deployment + .as_ref() + .is_some_and(|deployment| verify_owned_destination(deployment, &destination).is_ok()); + let ownership = match (deployment.is_some(), destination_exists, owned_deployment) { + (_, _, true) => PiSkillOwnership::Owned, + (true, _, false) => PiSkillOwnership::Stale, + (false, true, false) => PiSkillOwnership::Foreign, + (false, false, false) => PiSkillOwnership::Absent, + }; + let (discovery_status, discovery_issue) = discovered.cloned().unwrap_or_else(|| { + ( + PiSkillDiscovery::Absent, + destination_exists.then(|| "Pi did not discover this destination".to_string()), + ) + }); + let effectively_discovered = discovery_status == PiSkillDiscovery::Active; + let issue = match ownership { + PiSkillOwnership::Stale => { + Some("recorded Pi deployment no longer matches the live filesystem".to_string()) + } + PiSkillOwnership::Foreign if skill.apps.pi => { + Some("desired Pi Skill collides with an unowned live destination".to_string()) + } + _ => discovery_issue, + }; + Ok(SkillAppStatus { + desired_enabled: skill.apps.pi, + owned_deployment, + effectively_discovered, + ownership, + discovery: discovery_status, + issue, + }) +} + +fn deployment_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn reconcile_skill_unlocked(db: &Arc, skill: &InstalledSkill) -> Result<(), AppError> { + let destination = skill_destination(skill)?; + let destination_key = destination_key(&destination); + let existing = db.get_pi_skill_deployment(&skill.id, &destination_key)?; + if skill.apps.pi { + let source = skill_source(skill)?; + deploy( + db, + skill, + &source, + &destination, + &destination_key, + existing, + None, + ) + } else { + remove_owned(db, skill, &destination, &destination_key, existing, None) + } +} + +fn skill_source(skill: &InstalledSkill) -> Result { + validate_directory_name(&skill.directory)?; + let source = SkillService::get_ssot_dir() + .map_err(|error| AppError::Config(error.to_string()))? + .join(&skill.directory); + validate_source_tree(&source)?; + Ok(source) +} + +fn skill_destination(skill: &InstalledSkill) -> Result { + validate_directory_name(&skill.directory)?; + Ok(SkillService::get_app_skills_dir(&AppType::Pi) + .map_err(|error| AppError::Config(error.to_string()))? + .join(&skill.directory)) +} + +fn validate_directory_name(value: &str) -> Result<(), AppError> { + let path = Path::new(value); + if value.is_empty() + || path.components().count() != 1 + || matches!( + path.components().next(), + Some(std::path::Component::CurDir | std::path::Component::ParentDir) + ) + || value.starts_with('.') + { + return Err(AppError::InvalidInput(format!( + "invalid Pi Skill directory '{value}'" + ))); + } + Ok(()) +} + +fn destination_key(destination: &Path) -> String { + #[cfg(windows)] + { + destination + .to_string_lossy() + .replace('\\', "/") + .to_lowercase() + } + #[cfg(not(windows))] + { + destination.to_string_lossy().into_owned() + } +} + +fn source_identity(source: &Path) -> Result<(String, String), AppError> { + let canonical = source + .canonicalize() + .map_err(|error| AppError::io(source, error))?; + let digest = tree_digest(source)?; + Ok(( + format!("path:{};digest:{digest}", canonical.display()), + digest, + )) +} + +fn adopt_exact_import( + db: &Arc, + skill: &InstalledSkill, + source: &Path, + destination: &Path, + destination_key: &str, +) -> Result<(), AppError> { + validate_source_tree(source)?; + validate_source_tree(destination)?; + let (source_identity, source_digest) = source_identity(source)?; + let destination_digest = tree_digest(destination)?; + if source_digest != destination_digest { + return Err(AppError::Conflict(format!( + "cannot adopt Pi Skill '{}': native destination differs from the imported SSOT", + skill.directory + ))); + } + + let previous_desired = db + .get_installed_skill(&skill.id)? + .ok_or_else(|| { + AppError::Conflict(format!( + "Pi Skill '{}' disappeared before import adoption", + skill.id + )) + })? + .apps + .pi; + let now = chrono::Utc::now().timestamp_millis(); + let deployment = SkillDeployment { + skill_id: skill.id.clone(), + destination: destination.to_string_lossy().into_owned(), + destination_key: destination_key.to_string(), + method: SkillDeploymentMethod::Copy, + source_identity, + deployed_digest: Some(destination_digest), + created_at: now, + updated_at: now, + }; + db.save_pi_skill_deployment_with_desired(&deployment, Some(true))?; + + let final_verification = verify_owned_destination(&deployment, destination).and_then(|_| { + let current_source_digest = tree_digest(source)?; + if current_source_digest == source_digest { + Ok(()) + } else { + Err(AppError::Conflict(format!( + "cannot adopt Pi Skill '{}': SSOT changed during import", + skill.directory + ))) + } + }); + if let Err(error) = final_verification { + let rollback = db.delete_pi_skill_deployment_with_desired( + &skill.id, + destination_key, + Some(previous_desired), + ); + return match rollback { + Ok(true) => Err(error), + Ok(false) => Err(AppError::Conflict(format!( + "Pi Skill import adoption failed ({error}); ownership rollback found no ledger row" + ))), + Err(rollback) => Err(AppError::Conflict(format!( + "Pi Skill import adoption failed ({error}); ownership rollback failed ({rollback})" + ))), + }; + } + Ok(()) +} + +fn deploy( + db: &Arc, + skill: &InstalledSkill, + source: &Path, + destination: &Path, + destination_key: &str, + existing: Option, + desired_enabled: Option, +) -> Result<(), AppError> { + let (source_identity, source_digest) = source_identity(source)?; + if let Some(existing) = existing.as_ref() { + verify_owned_destination(existing, destination)?; + } else if fs::symlink_metadata(destination).is_ok() { + return Err(AppError::Conflict(format!( + "Pi Skill destination already exists without ownership evidence: {}", + destination.display() + ))); + } + + let requested_method = choose_method(); + let previous = existing.clone(); + let staged_previous = if let Some(previous_deployment) = previous.as_ref() { + let staged = stage_destination(destination)?; + if let Err(error) = verify_deployment_identity(previous_deployment, &staged) { + restore_staged_destination(&staged, destination).map_err(|rollback| { + AppError::Conflict(format!( + "Pi Skill changed while it was staged ({error}); restoring it failed ({rollback})" + )) + })?; + return Err(error); + } + Some(staged) + } else { + None + }; + let method = match replace_destination(source, destination, requested_method) { + Ok(method) => method, + Err(error) => { + if let Some(staged) = staged_previous.as_deref() { + restore_staged_destination(staged, destination).map_err(|rollback| { + AppError::Conflict(format!( + "Pi Skill deployment failed ({error}); previous deployment rollback failed ({rollback})" + )) + })?; + } + return Err(error); + } + }; + let now = chrono::Utc::now().timestamp_millis(); + let deployment = SkillDeployment { + skill_id: skill.id.clone(), + destination: destination.to_string_lossy().into_owned(), + destination_key: destination_key.to_string(), + method, + source_identity, + deployed_digest: (method == SkillDeploymentMethod::Copy).then_some(source_digest), + created_at: previous.as_ref().map_or(now, |value| value.created_at), + updated_at: now, + }; + if let Err(error) = verify_owned_destination(&deployment, destination) { + rollback_verified_replacement(&deployment, destination, staged_previous.as_deref()) + .map_err(|rollback| { + AppError::Conflict(format!( + "Pi Skill deployment identity check failed ({error}); rollback failed ({rollback})" + )) + })?; + return Err(error); + } + if let Err(error) = db.save_pi_skill_deployment_with_desired(&deployment, desired_enabled) { + rollback_verified_replacement(&deployment, destination, staged_previous.as_deref()) + .map_err(|rollback| { + AppError::Conflict(format!( + "Pi Skill ledger write failed ({error}); deployment rollback failed ({rollback})" + )) + })?; + return Err(error); + } + if let Some(staged) = staged_previous { + let previous_deployment = previous.as_ref().ok_or_else(|| { + AppError::Config( + "Pi Skill replacement staging lost its previous ownership record".to_string(), + ) + })?; + verify_deployment_identity(previous_deployment, &staged)?; + if let Err(error) = remove_path(&staged) { + log::warn!( + "failed to remove committed Pi Skill rollback staging '{}': {error}", + staged.display() + ); + } + } + Ok(()) +} + +fn remove_owned( + db: &Arc, + skill: &InstalledSkill, + destination: &Path, + destination_key: &str, + existing: Option, + desired_enabled: Option, +) -> Result<(), AppError> { + let Some(existing) = existing else { + // Foreign/native discovered directories are preserved. + if let Some(desired_enabled) = desired_enabled { + db.delete_pi_skill_deployment_with_desired( + &skill.id, + destination_key, + Some(desired_enabled), + )?; + } + return Ok(()); + }; + verify_owned_destination(&existing, destination)?; + let staged = stage_destination(destination)?; + if let Err(error) = verify_deployment_identity(&existing, &staged) { + restore_staged_destination(&staged, destination).map_err(|rollback| { + AppError::Conflict(format!( + "Pi Skill changed while it was staged for removal ({error}); restoring it failed ({rollback})" + )) + })?; + return Err(error); + } + if let Err(error) = + db.delete_pi_skill_deployment_with_desired(&skill.id, destination_key, desired_enabled) + { + restore_staged_destination(&staged, destination).map_err(|rollback| { + AppError::Conflict(format!( + "Pi Skill ledger cleanup failed ({error}); file rollback failed ({rollback})" + )) + })?; + return Err(error); + } + verify_deployment_identity(&existing, &staged)?; + if let Err(error) = remove_path(&staged) { + log::warn!( + "failed to remove disabled Pi Skill rollback staging '{}': {error}", + staged.display() + ); + } + if fs::symlink_metadata(destination).is_ok() { + return Err(AppError::Conflict(format!( + "Pi Skill destination was recreated concurrently after ownership removal: {}", + destination.display() + ))); + } + Ok(()) +} + +fn choose_method() -> SkillDeploymentMethod { + match crate::settings::get_skill_sync_method() { + SyncMethod::Copy => SkillDeploymentMethod::Copy, + SyncMethod::Symlink | SyncMethod::Auto => SkillDeploymentMethod::Symlink, + } +} + +fn verify_owned_destination( + deployment: &SkillDeployment, + destination: &Path, +) -> Result<(), AppError> { + if Path::new(&deployment.destination) != destination { + return Err(AppError::Conflict( + "Pi Skill deployment destination changed since it was recorded".to_string(), + )); + } + verify_deployment_identity(deployment, destination) +} + +fn verify_deployment_identity( + deployment: &SkillDeployment, + destination: &Path, +) -> Result<(), AppError> { + match deployment.method { + SkillDeploymentMethod::Symlink => { + let metadata = fs::symlink_metadata(destination) + .map_err(|error| AppError::io(destination, error))?; + if !metadata.file_type().is_symlink() { + return Err(AppError::Conflict(format!( + "owned Pi Skill symlink was replaced externally: {}", + destination.display() + ))); + } + let target = + fs::read_link(destination).map_err(|error| AppError::io(destination, error))?; + let resolved = if target.is_absolute() { + target + } else { + destination + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(target) + }; + let canonical = resolved + .canonicalize() + .map_err(|error| AppError::io(&resolved, error))?; + if !deployment + .source_identity + .starts_with(&format!("path:{};", canonical.display())) + { + return Err(AppError::Conflict(format!( + "owned Pi Skill symlink target changed externally: {}", + destination.display() + ))); + } + } + SkillDeploymentMethod::Copy => { + let expected = deployment.deployed_digest.as_deref().ok_or_else(|| { + AppError::Conflict("copied Pi Skill lacks a recorded digest".to_string()) + })?; + if tree_digest(destination)? != expected { + return Err(AppError::Conflict(format!( + "owned Pi Skill copy was modified externally: {}", + destination.display() + ))); + } + } + } + Ok(()) +} + +fn replace_destination( + source: &Path, + destination: &Path, + method: SkillDeploymentMethod, +) -> Result { + if fs::symlink_metadata(destination).is_ok() { + return Err(AppError::Conflict(format!( + "Pi Skill replacement destination is not empty: {}", + destination.display() + ))); + } + let parent = destination + .parent() + .ok_or_else(|| AppError::InvalidInput("Pi Skill destination has no parent".to_string()))?; + fs::create_dir_all(parent).map_err(|error| AppError::io(parent, error))?; + match method { + SkillDeploymentMethod::Symlink => match create_directory_symlink(source, destination) { + Ok(()) => Ok(SkillDeploymentMethod::Symlink), + Err(_) if matches!(crate::settings::get_skill_sync_method(), SyncMethod::Auto) => { + copy_tree_atomic(source, destination)?; + Ok(SkillDeploymentMethod::Copy) + } + Err(error) => Err(error), + }, + SkillDeploymentMethod::Copy => { + copy_tree_atomic(source, destination)?; + Ok(SkillDeploymentMethod::Copy) + } + } +} + +fn stage_destination(destination: &Path) -> Result { + let parent = destination + .parent() + .ok_or_else(|| AppError::InvalidInput("Pi Skill destination has no parent".to_string()))?; + let file_name = destination + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + AppError::InvalidInput("Pi Skill destination name is invalid".to_string()) + })?; + let staged = parent.join(format!( + ".{file_name}.cc-switch-rollback-{}", + uuid::Uuid::new_v4().simple() + )); + fs::rename(destination, &staged).map_err(|error| AppError::io(destination, error))?; + Ok(staged) +} + +fn restore_staged_destination(staged: &Path, destination: &Path) -> Result<(), AppError> { + if fs::symlink_metadata(destination).is_ok() { + return Err(AppError::Conflict(format!( + "refusing to overwrite a concurrently created Pi Skill destination: {}", + destination.display() + ))); + } + fs::rename(staged, destination).map_err(|error| AppError::io(destination, error)) +} + +fn rollback_verified_replacement( + deployment: &SkillDeployment, + destination: &Path, + staged_previous: Option<&Path>, +) -> Result<(), AppError> { + let staged_replacement = stage_destination(destination)?; + if let Err(error) = verify_deployment_identity(deployment, &staged_replacement) { + restore_staged_destination(&staged_replacement, destination).map_err(|rollback| { + AppError::Conflict(format!( + "replacement ownership was lost before rollback ({error}); preserving it also failed ({rollback})" + )) + })?; + return Err(error); + } + remove_path(&staged_replacement)?; + if let Some(staged) = staged_previous { + restore_staged_destination(staged, destination)?; + } + Ok(()) +} + +#[cfg(unix)] +fn create_directory_symlink(source: &Path, destination: &Path) -> Result<(), AppError> { + std::os::unix::fs::symlink(source, destination) + .map_err(|error| AppError::io(destination, error)) +} + +#[cfg(windows)] +fn create_directory_symlink(source: &Path, destination: &Path) -> Result<(), AppError> { + std::os::windows::fs::symlink_dir(source, destination) + .map_err(|error| AppError::io(destination, error)) +} + +fn copy_tree_atomic(source: &Path, destination: &Path) -> Result<(), AppError> { + let parent = destination + .parent() + .ok_or_else(|| AppError::InvalidInput("Pi Skill destination has no parent".to_string()))?; + let temp = parent.join(format!(".pi-skill-{}.tmp", uuid::Uuid::new_v4().simple())); + let result = copy_tree(source, &temp).and_then(|_| { + fs::rename(&temp, destination).map_err(|error| AppError::io(destination, error)) + }); + if result.is_err() { + let _ = fs::remove_dir_all(&temp); + } + result +} + +fn copy_tree(source: &Path, destination: &Path) -> Result<(), AppError> { + validate_source_tree(source)?; + fs::create_dir(destination).map_err(|error| AppError::io(destination, error))?; + for entry in fs::read_dir(source).map_err(|error| AppError::io(source, error))? { + let entry = entry.map_err(|error| AppError::io(source, error))?; + let file_type = entry + .file_type() + .map_err(|error| AppError::io(entry.path(), error))?; + let target = destination.join(entry.file_name()); + if file_type.is_dir() { + copy_tree(&entry.path(), &target)?; + } else if file_type.is_file() { + fs::copy(entry.path(), &target).map_err(|error| AppError::io(&target, error))?; + } else { + return Err(AppError::InvalidInput(format!( + "Pi Skill source contains a non-regular entry: {}", + entry.path().display() + ))); + } + } + Ok(()) +} + +fn validate_source_tree(source: &Path) -> Result<(), AppError> { + let metadata = fs::symlink_metadata(source).map_err(|error| AppError::io(source, error))?; + if !metadata.file_type().is_dir() || !source.join("SKILL.md").is_file() { + return Err(AppError::InvalidInput(format!( + "Pi Skill source must be a directory containing SKILL.md: {}", + source.display() + ))); + } + Ok(()) +} + +fn tree_digest(root: &Path) -> Result { + let mut entries = Vec::new(); + collect_digest_entries(root, root, &mut entries)?; + entries.sort_by(|left, right| left.0.cmp(&right.0)); + let mut hasher = Sha256::new(); + for (relative, bytes) in entries { + hasher.update((relative.len() as u64).to_le_bytes()); + hasher.update(relative.as_bytes()); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +fn collect_digest_entries( + root: &Path, + current: &Path, + output: &mut Vec<(String, Vec)>, +) -> Result<(), AppError> { + for entry in fs::read_dir(current).map_err(|error| AppError::io(current, error))? { + let entry = entry.map_err(|error| AppError::io(current, error))?; + let path = entry.path(); + let kind = entry + .file_type() + .map_err(|error| AppError::io(&path, error))?; + if kind.is_dir() { + collect_digest_entries(root, &path, output)?; + } else if kind.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|_| AppError::Config("Pi Skill path escaped its root".to_string()))? + .to_string_lossy() + .replace('\\', "/"); + output.push(( + relative, + fs::read(&path).map_err(|error| AppError::io(&path, error))?, + )); + } else { + return Err(AppError::InvalidInput(format!( + "Pi Skill tree contains a symlink or special entry: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn remove_path(path: &Path) -> Result<(), AppError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(AppError::io(path, error)), + }; + if metadata.file_type().is_symlink() || metadata.file_type().is_file() { + fs::remove_file(path).map_err(|error| AppError::io(path, error)) + } else if metadata.file_type().is_dir() { + fs::remove_dir_all(path).map_err(|error| AppError::io(path, error)) + } else { + Err(AppError::InvalidInput(format!( + "refusing to remove special Pi Skill entry: {}", + path.display() + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_includes_hidden_files_and_rejects_symlinks() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write(temp.path().join("SKILL.md"), "skill").expect("manifest"); + fs::write(temp.path().join(".hidden"), "one").expect("hidden"); + let first = tree_digest(temp.path()).expect("digest"); + fs::write(temp.path().join(".hidden"), "two").expect("hidden update"); + assert_ne!(tree_digest(temp.path()).expect("digest"), first); + } + + #[test] + fn rollback_preserves_a_destination_without_matching_ownership_evidence() { + let temp = tempfile::tempdir().expect("tempdir"); + let destination = temp.path().join("skill"); + fs::create_dir(&destination).expect("foreign destination"); + fs::write(destination.join("SKILL.md"), "foreign").expect("foreign manifest"); + let deployment = SkillDeployment { + skill_id: "skill".to_string(), + destination: destination.to_string_lossy().into_owned(), + destination_key: destination_key(&destination), + method: SkillDeploymentMethod::Copy, + source_identity: "path:/managed;digest:sha256:managed".to_string(), + deployed_digest: Some("sha256:not-the-foreign-tree".to_string()), + created_at: 1, + updated_at: 1, + }; + + assert!(rollback_verified_replacement(&deployment, &destination, None).is_err()); + assert_eq!( + fs::read_to_string(destination.join("SKILL.md")).expect("foreign content survives"), + "foreign" + ); + } +} diff --git a/src-tauri/src/services/sql_helpers.rs b/src-tauri/src/services/sql_helpers.rs index 6a582f90f..e6de52935 100644 --- a/src-tauri/src/services/sql_helpers.rs +++ b/src-tauri/src/services/sql_helpers.rs @@ -54,8 +54,7 @@ pub fn fresh_input_sql(alias: &str) -> String { format!( "CASE \ WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_FRESH} THEN {prefix}input_tokens \ - WHEN {prefix}app_type IN ({app_type_list}) \ - AND {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \ + WHEN {prefix}input_token_semantics = {INPUT_TOKEN_SEMANTICS_TOTAL} \ AND {prefix}input_tokens >= ({prefix}cache_read_tokens + {prefix}cache_creation_tokens) \ THEN ({prefix}input_tokens - {prefix}cache_read_tokens - {prefix}cache_creation_tokens) \ WHEN {prefix}app_type IN ({app_type_list}) \ @@ -144,6 +143,40 @@ mod tests { assert_eq!(total, 400 + 500 + 450 + 200); } + #[test] + fn stored_wire_semantics_override_logical_pi_app_type() { + let conn = setup_conn(); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, app_type, input_tokens, cache_read_tokens, + cache_creation_tokens, input_token_semantics + ) VALUES + ('pi-openai', 'pi', 1000, 700, 100, 1), + ('pi-anthropic', 'pi', 1000, 700, 100, 2)", + [], + ) + .unwrap(); + + let sql = format!( + "SELECT request_id, {} FROM proxy_request_logs ORDER BY request_id", + fresh_input_sql("") + ); + let values: Vec<(String, i64)> = conn + .prepare(&sql) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!( + values, + vec![ + ("pi-anthropic".to_string(), 1000), + ("pi-openai".to_string(), 200), + ] + ); + } + #[test] fn fresh_input_handles_codex_with_cache_exceeding_input() { // Defensive: if a malformed Codex row somehow has cache > input, diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 5db28335b..b928efbd6 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -181,6 +181,7 @@ impl StreamCheckService { } AppType::OpenClaw => Self::extract_openclaw_base_url(provider), AppType::Hermes => Self::extract_hermes_base_url(provider), + AppType::Pi => Self::extract_pi_base_url(provider), AppType::ClaudeDesktop => ClaudeAdapter::new() .extract_base_url(provider) .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}"))), @@ -323,6 +324,31 @@ impl StreamCheckService { }) } + /// Pi endpoint inheritance is owned by the pinned composer. Reachability + /// checks deliberately consume its first effective model instead of + /// reimplementing provider/model fallback rules. + fn extract_pi_base_url(provider: &Provider) -> Result { + let config: crate::pi_config::model::PiManagedProviderConfig = + serde_json::from_value(provider.settings_config.clone()).map_err(|error| { + AppError::InvalidInput(format!( + "Pi provider '{}' is not a managed native configuration: {error}", + provider.id + )) + })?; + let composition = + crate::pi_config::native::compose_managed_pi_provider(&provider.id, &config)?; + composition + .models + .first() + .map(|model| model.base_url.clone()) + .ok_or_else(|| { + AppError::InvalidInput(format!( + "Pi provider '{}' has no effective models", + provider.id + )) + }) + } + /// OpenCode: `{ npm, options: { baseURL, apiKey }, ... }` /// /// 用户未显式填 `options.baseURL` 时,按 `npm`(AI SDK 包)回退到包自带默认端点。 @@ -501,6 +527,36 @@ mod tests { ); } + #[test] + fn pi_reachability_uses_composer_effective_model_endpoint() { + let provider = make_provider(serde_json::json!({ + "name": "Pi", + "api": "openai-responses", + "baseUrl": "https://provider.example/v1", + "apiKey": "literal", + "models": [{ + "id": "model", + "name": "Model", + "baseUrl": "https://model.example/custom", + "reasoning": false, + "input": ["text"], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 128000, + "maxTokens": 8192 + }] + })); + + assert_eq!( + StreamCheckService::resolve_base_url(&AppType::Pi, &provider).unwrap(), + "https://model.example/custom" + ); + } + #[test] fn test_resolve_base_url_uses_explicit_url_or_errors_when_missing() { // 有显式 base_url → 直接用 diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 326cd2837..420f69bf9 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -1897,19 +1897,18 @@ impl Database { // 1. 历史 cache-inclusive 行只包含 cache read;新 total 行还包含 cache write。 // 2. Claude/Anthropic 的 input_tokens 已经是 fresh input,不能再次扣减 // 3. 各项成本是基础成本(不含倍率),倍率只作用于最终总价 - let cache_inclusive_app = - crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()); - let billable_input_tokens = - if !cache_inclusive_app || log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH { - log.input_tokens as u64 - } else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL { - (log.input_tokens as u64) - .saturating_sub(log.cache_read_tokens as u64) - .saturating_sub(log.cache_creation_tokens as u64) - } else { - // v12 and earlier: input included cache reads but excluded cache writes. - (log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64) - }; + let billable_input_tokens = if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_FRESH { + log.input_tokens as u64 + } else if log.input_token_semantics == INPUT_TOKEN_SEMANTICS_TOTAL { + (log.input_tokens as u64) + .saturating_sub(log.cache_read_tokens as u64) + .saturating_sub(log.cache_creation_tokens as u64) + } else if crate::services::sql_helpers::is_cache_inclusive_app(log.app_type.as_str()) { + // v12 and earlier: input included cache reads but excluded cache writes. + (log.input_tokens as u64).saturating_sub(log.cache_read_tokens as u64) + } else { + log.input_tokens as u64 + }; let input_cost = rust_decimal::Decimal::from(billable_input_tokens) * pricing.input / million; let output_cost = diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index 839e94fb0..188c4b055 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -4,7 +4,7 @@ pub mod terminal; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode}; +use providers::{claude, codex, gemini, grokbuild, hermes, openclaw, opencode, pi}; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -56,7 +56,7 @@ pub struct DeleteSessionOutcome { } pub fn scan_sessions() -> Vec { - let (r1, r2, r3, r4, r5, r6, r7) = std::thread::scope(|s| { + let (r1, r2, r3, r4, r5, r6, r7, r8) = std::thread::scope(|s| { let h1 = s.spawn(codex::scan_sessions); let h2 = s.spawn(claude::scan_sessions); let h3 = s.spawn(opencode::scan_sessions); @@ -64,6 +64,7 @@ pub fn scan_sessions() -> Vec { let h5 = s.spawn(gemini::scan_sessions); let h6 = s.spawn(hermes::scan_sessions); let h7 = s.spawn(grokbuild::scan_sessions); + let h8 = s.spawn(pi::scan_sessions); ( h1.join().unwrap_or_default(), h2.join().unwrap_or_default(), @@ -72,6 +73,7 @@ pub fn scan_sessions() -> Vec { h5.join().unwrap_or_default(), h6.join().unwrap_or_default(), h7.join().unwrap_or_default(), + h8.join().unwrap_or_default(), ) }); @@ -83,6 +85,7 @@ pub fn scan_sessions() -> Vec { sessions.extend(r5); sessions.extend(r6); sessions.extend(r7); + sessions.extend(r8); sessions.sort_by(|a, b| { let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); @@ -111,6 +114,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result gemini::load_messages(path), "grokbuild" => grokbuild::load_messages(path), "hermes" => hermes::load_messages(path), + "pi" => pi::load_messages(path), _ => Err(format!("Unsupported provider: {provider_id}")), } } @@ -173,6 +177,7 @@ fn delete_session_with_roots( grokbuild::delete_session(&validated_root, &validated_source, session_id) } "hermes" => hermes::delete_session(&validated_root, &validated_source, session_id), + "pi" => pi::delete_session(&validated_root, &validated_source, session_id), _ => Err(format!("Unsupported provider: {provider_id}")), }; } @@ -203,6 +208,7 @@ fn provider_roots(provider_id: &str) -> Result, String> { "gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")], "grokbuild" => grokbuild::session_roots(), "hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")], + "pi" => pi::session_roots(), _ => return Err(format!("Unsupported provider: {provider_id}")), }; diff --git a/src-tauri/src/session_manager/providers/mod.rs b/src-tauri/src/session_manager/providers/mod.rs index 6a16fb5a8..1cd3fd1b5 100644 --- a/src-tauri/src/session_manager/providers/mod.rs +++ b/src-tauri/src/session_manager/providers/mod.rs @@ -5,4 +5,5 @@ pub mod grokbuild; pub mod hermes; pub mod openclaw; pub mod opencode; +pub mod pi; mod utils; diff --git a/src-tauri/src/session_manager/providers/pi.rs b/src-tauri/src/session_manager/providers/pi.rs new file mode 100644 index 000000000..5b19715c9 --- /dev/null +++ b/src-tauri/src/session_manager/providers/pi.rs @@ -0,0 +1,731 @@ +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::Value; + +use crate::session_manager::{SessionMessage, SessionMeta}; + +use super::utils::{ + extract_text, parse_timestamp_to_ms, path_basename, truncate_summary, TITLE_MAX_CHARS, +}; + +const PROVIDER_ID: &str = "pi"; +const MAX_TREE_ENTRIES: usize = 500_000; +const MAX_TREE_ID_BYTES: usize = 256; +const MAX_SCAN_DEPTH: usize = 8; +const MAX_SESSION_BYTES: u64 = 128 * 1024 * 1024; + +#[derive(Debug, PartialEq, Eq)] +enum SessionRootResolution { + Available { + root: PathBuf, + source: &'static str, + }, + RequiresProjectContext { + configured_path: String, + source: &'static str, + }, + Unavailable { + reason: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum PiSessionDiscovery { + Available { + root: String, + source: &'static str, + }, + RequiresProjectContext { + #[serde(rename = "configuredPath")] + configured_path: String, + source: &'static str, + }, + Unavailable { + reason: String, + }, +} + +#[derive(Debug)] +struct SessionHeader { + id: String, + cwd: String, + timestamp: Option, + version: u64, +} + +#[derive(Debug)] +struct SessionTree { + header: SessionHeader, + active_ids: HashSet, +} + +#[derive(Default)] +struct ActiveSessionData { + messages: Vec, + first_user_message: Option, + last_message: Option, + explicit_name: Option>, + last_active_at: Option, +} + +/// Pi keeps a relative `sessionDir` relative through SessionManager creation; +/// its file operations therefore depend on the launching process cwd. A global +/// session browser has no authoritative launch cwd, so relative values are +/// deliberately non-enumerable and never fall back to another root. +pub fn session_roots() -> Vec { + match resolve_session_root() { + SessionRootResolution::Available { root, .. } => vec![root], + SessionRootResolution::RequiresProjectContext { .. } + | SessionRootResolution::Unavailable { .. } => Vec::new(), + } +} + +pub fn session_discovery() -> PiSessionDiscovery { + match resolve_session_root() { + SessionRootResolution::Available { root, source } => PiSessionDiscovery::Available { + root: root.to_string_lossy().into_owned(), + source, + }, + SessionRootResolution::RequiresProjectContext { + configured_path, + source, + } => PiSessionDiscovery::RequiresProjectContext { + configured_path, + source, + }, + SessionRootResolution::Unavailable { reason } => PiSessionDiscovery::Unavailable { reason }, + } +} + +fn resolve_session_root() -> SessionRootResolution { + let home = crate::config::get_home_dir(); + if let Some(raw) = std::env::var_os("PI_CODING_AGENT_SESSION_DIR") { + if !raw.is_empty() { + return classify_configured_session_dir( + raw.to_string_lossy().as_ref(), + &home, + "environment", + ); + } + } + + match crate::pi_config::native_settings::read_pi_native_defaults() { + Ok(defaults) => { + if let Some(value) = defaults.session_dir.filter(|value| !value.is_empty()) { + return classify_configured_session_dir(&value, &home, "settings"); + } + } + Err(error) => { + return SessionRootResolution::Unavailable { + reason: error.to_string(), + }; + } + } + + match crate::pi_config::native::get_pi_agent_dir() { + Ok(agent_dir) => SessionRootResolution::Available { + root: agent_dir.join("sessions"), + source: "default", + }, + Err(error) => SessionRootResolution::Unavailable { + reason: error.to_string(), + }, + } +} + +fn classify_configured_session_dir( + value: &str, + home: &Path, + source: &'static str, +) -> SessionRootResolution { + match resolve_global_session_dir(value, home) { + Some(root) => SessionRootResolution::Available { root, source }, + None => SessionRootResolution::RequiresProjectContext { + configured_path: value.to_string(), + source, + }, + } +} + +fn resolve_global_session_dir(value: &str, home: &Path) -> Option { + let path = if value == "~" { + home.to_path_buf() + } else if let Some(suffix) = value + .strip_prefix("~/") + .or_else(|| value.strip_prefix("~\\")) + { + home.join(suffix) + } else { + PathBuf::from(value) + }; + path.is_absolute().then_some(path) +} + +pub fn scan_sessions() -> Vec { + let Some(root) = session_roots().into_iter().next() else { + match session_discovery() { + PiSessionDiscovery::RequiresProjectContext { + configured_path, .. + } => log::warn!( + "Pi sessionDir '{configured_path}' requires a project cwd and cannot be globally enumerated" + ), + PiSessionDiscovery::Unavailable { reason } => { + log::warn!("Pi session discovery unavailable: {reason}") + } + PiSessionDiscovery::Available { .. } => {} + } + return Vec::new(); + }; + scan_sessions_in_root(&root) +} + +fn scan_sessions_in_root(root: &Path) -> Vec { + let mut files = Vec::new(); + collect_jsonl_files(root, 0, &mut files); + files + .into_iter() + .filter_map(|path| match parse_session(&path) { + Ok(session) => Some(session), + Err(error) => { + log::debug!("Skipping invalid Pi session {}: {error}", path.display()); + None + } + }) + .collect() +} + +pub fn load_messages(path: &Path) -> Result, String> { + let root = session_roots() + .into_iter() + .next() + .ok_or_else(|| "Relative Pi sessionDir cannot be globally resolved".to_string())?; + load_messages_with_root(&root, path) +} + +fn load_messages_with_root(root: &Path, path: &Path) -> Result, String> { + let (_, source) = validate_source_under_root(root, path)?; + let tree = read_tree(&source)?; + Ok(read_active_data(&source, &tree)?.messages) +} + +pub fn delete_session(root: &Path, path: &Path, session_id: &str) -> Result { + if !is_valid_tree_id(session_id) { + return Err("Invalid Pi session ID".to_string()); + } + let (_, source) = validate_source_under_root(root, path)?; + let tree = read_tree(&source)?; + if tree.header.id != session_id { + return Err(format!( + "Pi session ID mismatch: expected {session_id}, found {}", + tree.header.id + )); + } + fs::remove_file(&source) + .map_err(|error| format!("Failed to delete Pi session {}: {error}", source.display()))?; + Ok(true) +} + +fn parse_session(path: &Path) -> Result { + let source = path + .canonicalize() + .map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?; + let source_path = source + .to_str() + .ok_or_else(|| "Pi session path is not valid UTF-8".to_string())? + .to_string(); + let tree = read_tree(&source)?; + let data = read_active_data(&source, &tree)?; + let title = data.explicit_name.flatten().or_else(|| { + data.first_user_message + .as_deref() + .map(|message| truncate_summary(message, TITLE_MAX_CHARS)) + .filter(|message| !message.is_empty()) + .or_else(|| path_basename(&tree.header.cwd)) + }); + let summary = data + .last_message + .as_deref() + .map(|message| truncate_summary(message, 160)) + .filter(|message| !message.is_empty()); + Ok(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: tree.header.id.clone(), + title, + summary, + project_dir: (!tree.header.cwd.trim().is_empty()).then(|| tree.header.cwd.clone()), + created_at: tree.header.timestamp, + last_active_at: data.last_active_at.or(tree.header.timestamp), + source_path: Some(source_path.clone()), + resume_command: Some(format!( + "pi --session {}", + crate::session_manager::terminal::shell_escape(&source_path) + )), + }) +} + +fn read_tree(path: &Path) -> Result { + validate_file_size(path)?; + let reader = BufReader::new( + File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?, + ); + let mut header = None; + let mut parents = HashMap::>::new(); + let mut latest_id = None; + let mut legacy_previous_id = None; + let mut entry_index = 0usize; + for line in reader.lines() { + let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if header.is_none() { + header = Some(parse_header(&value)?); + continue; + } + entry_index += 1; + if entry_index > MAX_TREE_ENTRIES { + return Err(format!( + "Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit" + )); + } + let version = header + .as_ref() + .map_or(1, |item: &SessionHeader| item.version); + let Some((id, parent_id)) = + entry_identity(&value, version, entry_index, legacy_previous_id.as_deref()) + else { + continue; + }; + if parents.insert(id.clone(), parent_id).is_some() { + return Err(format!("Pi session contains duplicate entry ID: {id}")); + } + latest_id = Some(id.clone()); + legacy_previous_id = Some(id); + } + let header = header.ok_or_else(|| "Pi session has no valid header".to_string())?; + let mut active_ids = HashSet::new(); + let mut current = latest_id; + while let Some(id) = current { + if !active_ids.insert(id.clone()) { + return Err(format!("Pi session tree contains a cycle at entry {id}")); + } + current = parents + .get(&id) + .ok_or_else(|| format!("Pi session entry references missing parent: {id}"))? + .clone(); + } + Ok(SessionTree { header, active_ids }) +} + +fn read_active_data(path: &Path, tree: &SessionTree) -> Result { + validate_file_size(path)?; + let reader = BufReader::new( + File::open(path).map_err(|error| format!("Failed to open Pi session: {error}"))?, + ); + let mut data = ActiveSessionData::default(); + let mut saw_header = false; + let mut entry_index = 0usize; + let mut legacy_previous_id = None; + for line in reader.lines() { + let line = line.map_err(|error| format!("Failed to read Pi session: {error}"))?; + if line.trim().is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if !saw_header { + if value.get("type").and_then(Value::as_str) == Some("session") { + saw_header = true; + } + continue; + } + entry_index += 1; + if entry_index > MAX_TREE_ENTRIES { + return Err(format!( + "Pi session exceeds the {MAX_TREE_ENTRIES}-entry safety limit" + )); + } + let Some((id, _)) = entry_identity( + &value, + tree.header.version, + entry_index, + legacy_previous_id.as_deref(), + ) else { + continue; + }; + legacy_previous_id = Some(id.clone()); + if value.get("type").and_then(Value::as_str) == Some("session_info") { + data.explicit_name = Some( + value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + ); + } + if !tree.active_ids.contains(&id) { + continue; + } + let entry_timestamp = value.get("timestamp").and_then(parse_timestamp_to_ms); + if let Some(timestamp) = entry_timestamp { + data.last_active_at = Some(timestamp); + } + match value.get("type").and_then(Value::as_str) { + Some("session_info") => {} + Some("message") => { + let Some((role, content)) = value.get("message").and_then(parse_message) else { + continue; + }; + let timestamp = value + .get("message") + .and_then(|message| message.get("timestamp")) + .and_then(parse_timestamp_to_ms) + .or(entry_timestamp); + if role == "user" && data.first_user_message.is_none() { + data.first_user_message = Some(content.clone()); + } + if matches!(role.as_str(), "user" | "assistant") { + data.last_message = Some(content.clone()); + } + data.messages.push(SessionMessage { + role, + content, + ts: timestamp, + }); + } + Some("compaction") | Some("branch_summary") => { + push_system( + &mut data.messages, + value + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default(), + entry_timestamp, + ); + } + Some("custom_message") + if value.get("display").and_then(Value::as_bool) != Some(false) => + { + push_system( + &mut data.messages, + &value.get("content").map(extract_text).unwrap_or_default(), + entry_timestamp, + ); + } + _ => {} + } + } + Ok(data) +} + +fn push_system(messages: &mut Vec, content: &str, ts: Option) { + if !content.trim().is_empty() { + messages.push(SessionMessage { + role: "system".to_string(), + content: content.to_string(), + ts, + }); + } +} + +fn parse_header(value: &Value) -> Result { + if value.get("type").and_then(Value::as_str) != Some("session") { + return Err("Pi session header must be the first valid JSON entry".to_string()); + } + let id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| is_valid_tree_id(id)) + .ok_or_else(|| "Pi session header has an invalid ID".to_string())? + .to_string(); + let version = value.get("version").and_then(Value::as_u64).unwrap_or(1); + if !(1..=3).contains(&version) { + return Err(format!("Unsupported Pi session version: {version}")); + } + Ok(SessionHeader { + id, + cwd: value + .get("cwd") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + timestamp: value.get("timestamp").and_then(parse_timestamp_to_ms), + version, + }) +} + +fn entry_identity( + value: &Value, + version: u64, + entry_index: usize, + legacy_previous_id: Option<&str>, +) -> Option<(String, Option)> { + if version < 2 { + return Some(( + format!("legacy-{entry_index}"), + legacy_previous_id.map(str::to_string), + )); + } + let id = value + .get("id") + .and_then(Value::as_str) + .filter(|id| is_valid_tree_id(id))? + .to_string(); + let parent_id = match value.get("parentId") { + None | Some(Value::Null) => None, + Some(Value::String(parent)) if is_valid_tree_id(parent) => Some(parent.clone()), + _ => return None, + }; + Some((id, parent_id)) +} + +fn parse_message(message: &Value) -> Option<(String, String)> { + let role = message.get("role").and_then(Value::as_str)?; + let (display_role, content) = match role { + "user" | "assistant" => ( + role.to_string(), + message.get("content").map(extract_text).unwrap_or_default(), + ), + "toolResult" => ( + "tool".to_string(), + message.get("content").map(extract_text).unwrap_or_default(), + ), + "bashExecution" => ( + "tool".to_string(), + format!( + "$ {}\n{}", + message + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(), + message + .get("output") + .and_then(Value::as_str) + .unwrap_or_default() + ), + ), + "branchSummary" | "compactionSummary" => ( + "system".to_string(), + message + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ), + _ => return None, + }; + (!content.trim().is_empty()).then_some((display_role, content)) +} + +fn validate_source_under_root(root: &Path, path: &Path) -> Result<(PathBuf, PathBuf), String> { + let root = root.canonicalize().map_err(|error| { + format!( + "Failed to resolve Pi session root {}: {error}", + root.display() + ) + })?; + let source = path + .canonicalize() + .map_err(|error| format!("Failed to resolve Pi session {}: {error}", path.display()))?; + if !source.starts_with(&root) { + return Err(format!( + "Pi session source is outside the session root: {}", + path.display() + )); + } + let metadata = fs::symlink_metadata(&source) + .map_err(|error| format!("Failed to inspect Pi session {}: {error}", source.display()))?; + if !metadata.file_type().is_file() + || source.extension().and_then(|value| value.to_str()) != Some("jsonl") + || metadata.len() > MAX_SESSION_BYTES + { + return Err(format!("Invalid Pi session file: {}", source.display())); + } + Ok((root, source)) +} + +fn validate_file_size(path: &Path) -> Result<(), String> { + let metadata = + fs::metadata(path).map_err(|error| format!("Failed to inspect Pi session: {error}"))?; + if metadata.len() > MAX_SESSION_BYTES { + Err(format!( + "Pi session exceeds the {MAX_SESSION_BYTES}-byte safety limit" + )) + } else { + Ok(()) + } +} + +fn is_valid_tree_id(id: &str) -> bool { + let bytes = id.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAX_TREE_ID_BYTES + && bytes.first().is_some_and(u8::is_ascii_alphanumeric) + && bytes.last().is_some_and(u8::is_ascii_alphanumeric) + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +fn collect_jsonl_files(root: &Path, depth: usize, output: &mut Vec) { + if depth > MAX_SCAN_DEPTH { + return; + } + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if file_type.is_dir() { + collect_jsonl_files(&path, depth + 1, output); + } else if file_type.is_file() + && path.extension().and_then(|value| value.to_str()) == Some("jsonl") + && entry + .metadata() + .is_ok_and(|metadata| metadata.len() <= MAX_SESSION_BYTES) + { + output.push(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latest_leaf_defines_the_active_branch() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("tree.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"question\"}}\n\ + {\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\ + {\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"assistant\",\"content\":\"active\"}}\n", + ) + .expect("session"); + let messages = load_messages_with_root(&root, &path).expect("messages"); + assert_eq!( + messages + .into_iter() + .map(|message| message.content) + .collect::>(), + vec!["question", "active"] + ); + } + + #[test] + fn capture_matches_global_name_and_malformed_line_semantics() { + // Executed by scripts/pi-transport-capture.mjs against pinned Pi: + // getSessionName() keeps the latest global session_info even when its + // branch is inactive, and SessionManager.open() skips a malformed line. + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("captured.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n\ + {\"type\":\"message\",\"id\":\"root\",\"parentId\":null,\"message\":{\"role\":\"user\",\"content\":\"root\"}}\n\ + {not valid json\n\ + {\"type\":\"session_info\",\"id\":\"dead-name\",\"parentId\":\"root\",\"name\":\"Abandoned branch name\"}\n\ + {\"type\":\"message\",\"id\":\"dead\",\"parentId\":\"dead-name\",\"message\":{\"role\":\"assistant\",\"content\":\"abandoned\"}}\n\ + {\"type\":\"message\",\"id\":\"live\",\"parentId\":\"root\",\"message\":{\"role\":\"user\",\"content\":\"active branch\"}}\n", + ) + .expect("captured session"); + + let session = parse_session(&path).expect("parse capture semantics"); + assert_eq!(session.title.as_deref(), Some("Abandoned branch name")); + assert_eq!(session.summary.as_deref(), Some("active branch")); + } + + #[test] + fn relative_root_is_explicitly_non_enumerable() { + assert_eq!( + resolve_global_session_dir(".pi/sessions", Path::new("/home/pi")), + None + ); + assert_eq!( + classify_configured_session_dir(".pi/sessions", Path::new("/home/pi"), "settings"), + SessionRootResolution::RequiresProjectContext { + configured_path: ".pi/sessions".to_string(), + source: "settings", + } + ); + } + + #[test] + fn capture_generated_v3_shape_round_trips_all_consumed_fields() { + // Generated by scripts/pi-transport-capture.mjs against pinned Pi + // ab366ebe94cacd419d986be454f12b1b9913aaca using SessionManager APIs. + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("captured.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"cc-switch-capture-session\",\"timestamp\":\"2023-11-14T22:13:20.000Z\",\"cwd\":\"/work/captured\",\"parentSession\":null}\n\ + {\"type\":\"session_info\",\"id\":\"00000000-0000-7000-8000-000000000001\",\"parentId\":null,\"timestamp\":\"2023-11-14T22:13:20.100Z\",\"name\":\"Captured session\"}\n\ + {\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000002\",\"parentId\":\"00000000-0000-7000-8000-000000000001\",\"timestamp\":\"2023-11-14T22:13:20.200Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"captured question\"}],\"timestamp\":1700000000000}}\n\ + {\"type\":\"message\",\"id\":\"00000000-0000-7000-8000-000000000003\",\"parentId\":\"00000000-0000-7000-8000-000000000002\",\"timestamp\":\"2023-11-14T22:13:21.200Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"captured answer\"}],\"api\":\"openai-responses\",\"provider\":\"capture\",\"model\":\"capture-model\",\"usage\":{\"input\":1,\"output\":1,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":2,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"stop\",\"timestamp\":1700000001000}}\n", + ) + .expect("captured session"); + + let session = parse_session(&path).expect("parse capture-generated session"); + assert_eq!(session.session_id, "cc-switch-capture-session"); + assert_eq!(session.title.as_deref(), Some("Captured session")); + assert_eq!(session.summary.as_deref(), Some("captured answer")); + assert_eq!(session.project_dir.as_deref(), Some("/work/captured")); + assert_eq!(session.created_at, Some(1_700_000_000_000)); + assert_eq!(session.last_active_at, Some(1_700_000_001_200)); + // scripts/pi-transport-capture.mjs executes pinned Pi's parseArgs with + // ["--session", ] and records that the + // exact path is returned in Args.session. + assert!(session + .resume_command + .as_deref() + .is_some_and(|command| command.starts_with("pi --session "))); + + let messages = load_messages_with_root(&root, &path).expect("load messages"); + assert_eq!( + messages + .iter() + .map(|message| (message.role.as_str(), message.content.as_str(), message.ts)) + .collect::>(), + vec![ + ("user", "captured question", Some(1_700_000_000_000)), + ("assistant", "captured answer", Some(1_700_000_001_000)), + ] + ); + } + + #[test] + fn deletion_requires_containment_and_matching_header_id() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("sessions"); + fs::create_dir_all(&root).expect("root"); + let path = root.join("session.jsonl"); + fs::write( + &path, + "{\"type\":\"session\",\"version\":3,\"id\":\"session-1\",\"cwd\":\"/work\"}\n", + ) + .expect("session"); + assert!(delete_session(&root, &path, "other").is_err()); + assert!(path.exists()); + assert!(delete_session(&root, &path, "session-1").expect("delete")); + } +} diff --git a/src-tauri/src/session_manager/terminal/mod.rs b/src-tauri/src/session_manager/terminal/mod.rs index 2124e6c17..aaacb1a48 100644 --- a/src-tauri/src/session_manager/terminal/mod.rs +++ b/src-tauri/src/session_manager/terminal/mod.rs @@ -333,7 +333,7 @@ fn build_shell_command(command: &str, cwd: Option<&str>) -> String { /// /// 单引号内不做任何展开,唯一的特例是 `'` 自身无法被表示:用「闭合-转义-重开」 /// 的 `'\''` 序列绕过。 -fn shell_escape(value: &str) -> String { +pub(crate) fn shell_escape(value: &str) -> String { format!("'{}'", value.replace('\'', r"'\''")) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 30010ff85..e55aac4af 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -17,10 +17,156 @@ pub struct CustomEndpoint { pub last_used: Option, } +/// Device-local Pi gateway behavior. The frozen `proxy_config` schema has a +/// closed four-app domain, so Pi must not manufacture an out-of-contract row. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PiProxySettings { + #[serde(default)] + pub auto_failover_enabled: bool, + #[serde(default = "default_pi_max_retries")] + pub max_retries: u32, + #[serde(default = "default_pi_first_byte_timeout")] + pub streaming_first_byte_timeout: u32, + #[serde(default = "default_pi_idle_timeout")] + pub streaming_idle_timeout: u32, + #[serde(default = "default_pi_request_timeout")] + pub non_streaming_timeout: u32, + #[serde(default = "default_pi_circuit_failure_threshold")] + pub circuit_failure_threshold: u32, + #[serde(default = "default_pi_circuit_success_threshold")] + pub circuit_success_threshold: u32, + #[serde(default = "default_pi_circuit_timeout")] + pub circuit_timeout_seconds: u32, + #[serde(default = "default_pi_circuit_error_rate")] + pub circuit_error_rate_threshold: f64, + #[serde(default = "default_pi_circuit_min_requests")] + pub circuit_min_requests: u32, +} + +const fn default_pi_max_retries() -> u32 { + 3 +} +const fn default_pi_first_byte_timeout() -> u32 { + 60 +} +const fn default_pi_idle_timeout() -> u32 { + 120 +} +const fn default_pi_request_timeout() -> u32 { + 600 +} +const fn default_pi_circuit_failure_threshold() -> u32 { + 4 +} +const fn default_pi_circuit_success_threshold() -> u32 { + 2 +} +const fn default_pi_circuit_timeout() -> u32 { + 60 +} +const fn default_pi_circuit_min_requests() -> u32 { + 10 +} +fn default_pi_circuit_error_rate() -> f64 { + 0.6 +} + +impl Default for PiProxySettings { + fn default() -> Self { + Self { + auto_failover_enabled: false, + max_retries: default_pi_max_retries(), + streaming_first_byte_timeout: default_pi_first_byte_timeout(), + streaming_idle_timeout: default_pi_idle_timeout(), + non_streaming_timeout: default_pi_request_timeout(), + circuit_failure_threshold: default_pi_circuit_failure_threshold(), + circuit_success_threshold: default_pi_circuit_success_threshold(), + circuit_timeout_seconds: default_pi_circuit_timeout(), + circuit_error_rate_threshold: default_pi_circuit_error_rate(), + circuit_min_requests: default_pi_circuit_min_requests(), + } + } +} + +impl PiProxySettings { + pub(crate) fn validate(&self) -> Result<(), AppError> { + if !self.circuit_error_rate_threshold.is_finite() + || !(0.0..=1.0).contains(&self.circuit_error_rate_threshold) + { + return Err(AppError::InvalidInput( + "Pi circuit error-rate threshold must be finite and within [0, 1]".to_string(), + )); + } + if self.max_retries > 32 { + return Err(AppError::InvalidInput( + "Pi max retries cannot exceed 32".to_string(), + )); + } + Ok(()) + } + + pub(crate) fn app_config(&self, enabled: bool) -> crate::proxy::types::AppProxyConfig { + crate::proxy::types::AppProxyConfig { + app_type: "pi".to_string(), + enabled, + auto_failover_enabled: self.auto_failover_enabled, + max_retries: self.max_retries, + streaming_first_byte_timeout: self.streaming_first_byte_timeout, + streaming_idle_timeout: self.streaming_idle_timeout, + non_streaming_timeout: self.non_streaming_timeout, + circuit_failure_threshold: self.circuit_failure_threshold, + circuit_success_threshold: self.circuit_success_threshold, + circuit_timeout_seconds: self.circuit_timeout_seconds, + circuit_error_rate_threshold: self.circuit_error_rate_threshold, + circuit_min_requests: self.circuit_min_requests, + } + } +} + fn default_true() -> bool { true } +/// Device-local bearer used only between Pi and cc-switch's loopback gateway. +/// +/// Deliberately redacts `Debug`; the value must never be returned by settings +/// IPC, copied into SQLite, or emitted to logs. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct GatewayToken(String); + +impl GatewayToken { + fn generate() -> Self { + Self(format!( + "ccs_pi_{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + )) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } + + pub(crate) fn constant_time_eq(&self, candidate: &str) -> bool { + let expected = self.0.as_bytes(); + let candidate = candidate.as_bytes(); + let mut difference = expected.len() ^ candidate.len(); + let shared = expected.len().min(candidate.len()); + for index in 0..shared { + difference |= usize::from(expected[index] ^ candidate[index]); + } + difference == 0 + } +} + +impl std::fmt::Debug for GatewayToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("GatewayToken()") + } +} + /// 主页面显示的应用配置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -46,6 +192,8 @@ pub struct VisibleApps { pub openclaw: bool, #[serde(default)] pub hermes: bool, + #[serde(default = "default_true")] + pub pi: bool, } impl Default for VisibleApps { @@ -59,6 +207,7 @@ impl Default for VisibleApps { opencode: true, openclaw: true, hermes: false, // 默认不显示,需用户手动启用 + pi: true, } } } @@ -75,6 +224,7 @@ impl VisibleApps { AppType::OpenCode => self.opencode, AppType::OpenClaw => self.openclaw, AppType::Hermes => self.hermes, + AppType::Pi => self.pi, } } } @@ -422,6 +572,8 @@ pub struct AppSettings { pub openclaw_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub hermes_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pi_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) @@ -448,6 +600,29 @@ pub struct AppSettings { /// 当前 Hermes 供应商 ID(本地存储,保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_hermes: Option, + /// 当前 Pi 供应商 ID(本地存储) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_pi: Option, + + /// Device-local desired state for Pi's native `models.json` gateway + /// projection. Unlike the shared proxy-config row, this survives database + /// replacement and is reconciled against the live listener on startup. + #[serde(default)] + pub pi_takeover_enabled: bool, + #[serde(default)] + pub pi_proxy: PiProxySettings, + + /// Stable device-installation secret for Pi's loopback gateway. + /// + /// This field is serialized only to the device settings file. The frontend + /// projection clears it and settings-save merge restores the existing + /// value, so ordinary IPC cannot read, replace, or clear it. + #[serde(default, skip_serializing_if = "Option::is_none")] + // Public so integration tests and downstream Rust callers can continue to + // use struct-update syntax with `AppSettings`. IPC still cannot observe or + // mutate the value: the settings command clears it on reads and restores + // the persisted value on writes. + pub pi_gateway_token: Option, // ===== Skill 同步设置 ===== /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy @@ -533,6 +708,7 @@ impl Default for AppSettings { opencode_config_dir: None, openclaw_config_dir: None, hermes_config_dir: None, + pi_config_dir: None, current_provider_claude: None, current_provider_claude_desktop: None, current_provider_codex: None, @@ -541,6 +717,10 @@ impl Default for AppSettings { current_provider_opencode: None, current_provider_openclaw: None, current_provider_hermes: None, + current_provider_pi: None, + pi_takeover_enabled: false, + pi_proxy: PiProxySettings::default(), + pi_gateway_token: None, skill_sync_method: SyncMethod::default(), skill_storage_location: SkillStorageLocation::default(), webdav_sync: None, @@ -614,6 +794,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.pi_config_dir = self + .pi_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.language = self .language .as_ref() @@ -672,31 +859,9 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - let json = serde_json::to_string_pretty(&normalized) + let json = serde_json::to_vec_pretty(&normalized) .map_err(|e| AppError::JsonSerialize { source: e })?; - #[cfg(unix)] - { - use std::fs::OpenOptions; - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o600) - .open(&path) - .map_err(|e| AppError::io(&path, e))?; - file.write_all(json.as_bytes()) - .map_err(|e| AppError::io(&path, e))?; - } - - #[cfg(not(unix))] - { - fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; - } - - Ok(()) + crate::config::atomic_write_durable(&path, &json, Some(0o600)) } static SETTINGS_STORE: OnceLock> = OnceLock::new(); @@ -705,7 +870,7 @@ fn settings_store() -> &'static RwLock { SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file())) } -fn resolve_override_path(raw: &str) -> PathBuf { +pub(crate) fn resolve_override_path(raw: &str) -> PathBuf { if raw == "~" { if let Some(home) = dirs::home_dir() { return home; @@ -742,17 +907,17 @@ pub fn get_settings_for_frontend() -> AppSettings { s3.secret_access_key.clear(); } settings.webdav_backup = None; + settings.pi_gateway_token = None; settings } pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { new_settings.normalize_paths(); - save_settings_file(&new_settings)?; - let mut guard = settings_store().write().unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }); + save_settings_file(&new_settings)?; *guard = new_settings; Ok(()) } @@ -933,6 +1098,56 @@ pub fn get_hermes_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_pi_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .pi_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + +pub(crate) fn get_or_create_pi_gateway_token() -> Result { + let mut token = None; + mutate_settings(|settings| { + let stored = settings + .pi_gateway_token + .get_or_insert_with(GatewayToken::generate); + token = Some(stored.clone()); + })?; + token.ok_or_else(|| AppError::Config("无法创建 Pi 网关凭据".to_string())) +} + +pub(crate) fn reset_pi_gateway_token() -> Result { + let generated = GatewayToken::generate(); + mutate_settings(|settings| settings.pi_gateway_token = Some(generated.clone()))?; + Ok(generated) +} + +pub(crate) fn replace_pi_gateway_token(token: GatewayToken) -> Result<(), AppError> { + mutate_settings(|settings| settings.pi_gateway_token = Some(token)) +} + +pub(crate) fn pi_takeover_enabled() -> bool { + get_settings().pi_takeover_enabled +} + +pub(crate) fn set_pi_takeover_enabled(enabled: bool) -> Result<(), AppError> { + mutate_settings(|settings| settings.pi_takeover_enabled = enabled) +} + +pub(crate) fn get_pi_proxy_settings() -> PiProxySettings { + get_settings().pi_proxy +} + +pub(crate) fn update_pi_proxy_settings(settings: PiProxySettings) -> Result<(), AppError> { + settings.validate()?; + mutate_settings(|current| current.pi_proxy = settings) +} + +pub(crate) fn get_pi_app_proxy_config() -> crate::proxy::types::AppProxyConfig { + get_pi_proxy_settings().app_config(pi_takeover_enabled()) +} + pub fn preserve_codex_official_auth_on_switch() -> bool { settings_store() .read() @@ -970,6 +1185,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::OpenCode => settings.current_provider_opencode.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), AppType::Hermes => settings.current_provider_hermes.clone(), + AppType::Pi => settings.current_provider_pi.clone(), } } @@ -988,6 +1204,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(), AppType::Hermes => settings.current_provider_hermes = id_owned.clone(), + AppType::Pi => settings.current_provider_pi = id_owned.clone(), }) } @@ -1161,6 +1378,10 @@ mod tests { .expect("visible apps"); assert!(visible.is_visible(&AppType::ClaudeDesktop)); + assert!( + visible.is_visible(&AppType::Pi), + "Pi is a first-class app and must be visible when older settings omit its field" + ); } #[test] diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index f34e1de9a..81b83b6b4 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -3,6 +3,7 @@ use crate::services::{ProxyService, UsageCache}; use std::sync::Arc; /// 全局应用状态 +#[derive(Clone)] pub struct AppState { pub db: Arc, pub proxy_service: ProxyService,