mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
feat(pi): add native catalog and gateway data plane
This commit is contained in:
+35
-30
@@ -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<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pi: Option<String>,
|
||||
}
|
||||
|
||||
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<Self, AppError> {
|
||||
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 {
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<bool,
|
||||
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())?,
|
||||
};
|
||||
|
||||
if !config_dir.exists() {
|
||||
|
||||
@@ -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<bool, String> {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -44,26 +44,56 @@ pub async fn import_config_from_file(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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<String, String> {
|
||||
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
|
||||
|
||||
@@ -111,8 +111,8 @@ pub struct ToolVersion {
|
||||
wsl_distro: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
"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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<Vec<PiNativeDiagnostic>, 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<String, String> {
|
||||
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<bool, String> {
|
||||
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<PiNativeDefaults, String> {
|
||||
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<bool, String> {
|
||||
state
|
||||
.proxy_service
|
||||
.rotate_pi_gateway_token()
|
||||
.await
|
||||
.map(|()| true)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -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<Option<Strin
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_pi_prompt_file(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, String> {
|
||||
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<PiPromptFileSnapshot, String> {
|
||||
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<bool, String> {
|
||||
PiPromptFileService::delete(kind, &expectedRevision).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_pi_prompt_templates() -> Result<Vec<PiPromptTemplate>, 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<PiPromptTemplate, String> {
|
||||
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<bool, String> {
|
||||
PiPromptTemplateService::delete(&slug, &expectedRevision).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
@@ -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<AppProxyConfig, String> {
|
||||
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())?;
|
||||
|
||||
@@ -107,18 +107,44 @@ pub async fn s3_sync_upload(state: State<'_, AppState>) -> Result<Value, String>
|
||||
#[tauri::command]
|
||||
pub async fn s3_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
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()),
|
||||
);
|
||||
|
||||
@@ -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<bool, String> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
|
||||
@@ -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<Vec<Instal
|
||||
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_pi_skill_statuses(
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<BTreeMap<String, SkillAppStatus>, String> {
|
||||
PiSkillDeploymentService::inspect_all(&app_state.db).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
|
||||
SkillService::list_backups().map_err(|e| e.to_string())
|
||||
|
||||
@@ -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<Database>) -> 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(())
|
||||
}
|
||||
|
||||
@@ -115,18 +115,44 @@ pub async fn webdav_sync_upload(state: State<'_, AppState>) -> Result<Value, Str
|
||||
#[tauri::command]
|
||||
pub async fn webdav_sync_download(state: State<'_, AppState>) -> Result<Value, String> {
|
||||
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()),
|
||||
);
|
||||
|
||||
+93
-35
@@ -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<u32>,
|
||||
) -> 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<u16> = temp_path.as_os_str().encode_wide().chain(Some(0)).collect();
|
||||
let destination: Vec<u16> = 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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PiProviderProjection, AppError> {
|
||||
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<Option<PiProviderProjection>, 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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
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;
|
||||
@@ -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<String, Prompt>,
|
||||
) -> 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);
|
||||
|
||||
@@ -37,14 +37,14 @@ impl ProviderKey {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowUpdate {
|
||||
name: String,
|
||||
settings_config: Value,
|
||||
website_url: Option<String>,
|
||||
category: Option<String>,
|
||||
notes: Option<String>,
|
||||
meta: ProviderMeta,
|
||||
icon: Option<String>,
|
||||
icon_color: Option<String>,
|
||||
pub(super) name: String,
|
||||
pub(super) settings_config: Value,
|
||||
pub(super) website_url: Option<String>,
|
||||
pub(super) category: Option<String>,
|
||||
pub(super) notes: Option<String>,
|
||||
pub(super) meta: ProviderMeta,
|
||||
pub(super) icon: Option<String>,
|
||||
pub(super) icon_color: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderRowUpdate {
|
||||
@@ -71,15 +71,15 @@ impl ProviderRowUpdate {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRowCreate {
|
||||
content: ProviderRowUpdate,
|
||||
created_at: Option<i64>,
|
||||
pub(super) content: ProviderRowUpdate,
|
||||
pub(super) created_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewEndpoint {
|
||||
url: String,
|
||||
added_at: Option<i64>,
|
||||
last_used: Option<i64>,
|
||||
pub(super) url: String,
|
||||
pub(super) added_at: Option<i64>,
|
||||
pub(super) last_used: Option<i64>,
|
||||
}
|
||||
|
||||
impl NewEndpoint {
|
||||
@@ -116,11 +116,11 @@ impl TryFrom<CustomEndpoint> for NewEndpoint {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProviderAggregate {
|
||||
key: ProviderKey,
|
||||
row: ProviderRowCreate,
|
||||
sort_index: Option<usize>,
|
||||
in_failover_queue: bool,
|
||||
initial_endpoints: Vec<NewEndpoint>,
|
||||
pub(super) key: ProviderKey,
|
||||
pub(super) row: ProviderRowCreate,
|
||||
pub(super) sort_index: Option<usize>,
|
||||
pub(super) in_failover_queue: bool,
|
||||
pub(super) initial_endpoints: Vec<NewEndpoint>,
|
||||
}
|
||||
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<bool>,
|
||||
) -> 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<bool, AppError> {
|
||||
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<bool>,
|
||||
) -> Result<bool, AppError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ pub struct DeepLinkImportRequest {
|
||||
/// Optional model name
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 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<String>,
|
||||
/// Optional notes/description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
|
||||
@@ -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::<bool>().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,
|
||||
|
||||
@@ -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<serde_json::Value, AppError> {
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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<bool>) -> 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,
|
||||
|
||||
+53
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#""(?:\\.|[^"\\])*"|//[^\n]*"#).expect("Pi JSON line-comment regex must compile")
|
||||
@@ -25,6 +31,23 @@ static PI_JSON_TRAILING_COMMAS: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r#""(?:\\.|[^"\\])*"|,(\s*[}\]])"#)
|
||||
.expect("Pi JSON trailing-comma regex must compile")
|
||||
});
|
||||
static PATH_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
fn path_lock(path: &Path) -> Result<Arc<Mutex<()>>, 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<MutexGuard<'_, ()>, 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<CstObject, AppE
|
||||
}
|
||||
}
|
||||
|
||||
fn cst_input(value: &Value) -> 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<String, Value>,
|
||||
after: &serde_json::Map<String, Value>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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<PiModelsDocument, AppError> {
|
||||
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<PiModelsDocument, A
|
||||
parse_models_source(path, source)
|
||||
}
|
||||
|
||||
fn fingerprint(bytes: Option<&[u8]>) -> 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<Vec<u8>, 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<String, Option<Value>>,
|
||||
) -> 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<Item = String>,
|
||||
) -> Result<IndexMap<String, Option<Value>>, 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(
|
||||
|
||||
@@ -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<PiGatewayReason>,
|
||||
pub plans: Vec<CandidateHeaderPlan>,
|
||||
@@ -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<String>;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ impl CandidateHeaderPlan {
|
||||
fn build(
|
||||
model: &PiComposedNativeModel,
|
||||
model_index: usize,
|
||||
allow_anthropic_oauth: bool,
|
||||
) -> Result<Self, Vec<PiGatewayReason>> {
|
||||
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<MaterializedCandidate, PiGatewayReason> {
|
||||
self.materialize_with_policy(resolver, false)
|
||||
}
|
||||
|
||||
pub(crate) fn materialize_for_runtime(
|
||||
&self,
|
||||
resolver: &impl DeferredValueResolver,
|
||||
) -> Result<MaterializedCandidate, PiGatewayReason> {
|
||||
self.materialize_with_policy(resolver, true)
|
||||
}
|
||||
|
||||
pub(crate) fn with_endpoint(&self, endpoint: &str) -> Result<Self, PiGatewayReason> {
|
||||
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<MaterializedCandidate, PiGatewayReason> {
|
||||
// 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<HeaderValue> {
|
||||
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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<PiNativeComposition, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
if value == "~" {
|
||||
return Ok(home.to_path_buf());
|
||||
@@ -180,7 +200,15 @@ fn normalize_pi_agent_dir(value: &str, home: &Path) -> Result<PathBuf, AppError>
|
||||
Ok(PathBuf::from(value))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_agent_dir() -> Result<PathBuf, AppError> {
|
||||
pub(crate) fn get_pi_agent_dir_for_override(
|
||||
override_dir: Option<&str>,
|
||||
) -> Result<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
normalize_pi_agent_dir(&raw.to_string_lossy(), &get_home_dir())
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_agent_dir() -> Result<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
Ok(get_pi_agent_dir_for_override(override_dir)?.join("models.json"))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_models_path() -> Result<PathBuf, AppError> {
|
||||
Ok(get_pi_agent_dir()?.join("models.json"))
|
||||
}
|
||||
|
||||
@@ -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<Mutex<()>> = 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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_dir: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_settings_path() -> Result<PathBuf, AppError> {
|
||||
Ok(super::native::get_pi_agent_dir()?.join("settings.json"))
|
||||
}
|
||||
|
||||
pub(crate) fn read_pi_native_defaults() -> Result<PiNativeDefaults, AppError> {
|
||||
read_pi_native_defaults_at(&get_pi_settings_path()?)
|
||||
}
|
||||
|
||||
pub(crate) fn read_pi_native_defaults_at(path: &Path) -> Result<PiNativeDefaults, AppError> {
|
||||
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<PiNativeDefaults, AppError> {
|
||||
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<PiNativeDefaults, AppError> {
|
||||
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<String, Value>, 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<String, Value>,
|
||||
key: &str,
|
||||
path: &Path,
|
||||
) -> Result<Option<String>, 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<String, Value>) -> 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<Value, AppError> {
|
||||
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<File> {
|
||||
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<File> {
|
||||
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<Vec<u8>, 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<Option<Vec<u8>>, 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SharedFileSnapshot {
|
||||
pub revision: String,
|
||||
pub bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
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<SharedFileSnapshot, AppError> {
|
||||
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<u32>,
|
||||
label: &str,
|
||||
) -> Result<SharedFileSnapshot, AppError> {
|
||||
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<bool, AppError> {
|
||||
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<Arc<Mutex<()>>, 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<File> {
|
||||
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<File> {
|
||||
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<Vec<u8>, 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<Option<Vec<u8>>, 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");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
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"),
|
||||
};
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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<StreamUsageEventFilter>,
|
||||
/// 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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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<u64>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<u8>,
|
||||
plan: CandidateHeaderPlan,
|
||||
endpoints: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PiRuntimeProvider {
|
||||
models: HashMap<String, PiRuntimeModel>,
|
||||
}
|
||||
|
||||
#[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<String, PiRuntimeProvider>,
|
||||
failover_ids: Vec<String>,
|
||||
routes: HashMap<String, PiRouteBinding>,
|
||||
}
|
||||
|
||||
#[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<PiRequestCandidate>,
|
||||
}
|
||||
|
||||
#[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<PiRuntimeSnapshot>,
|
||||
/// 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<String, Option<Value>>,
|
||||
pub(crate) direct_only_provider_ids: Vec<String>,
|
||||
}
|
||||
|
||||
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<PiRequestRoute, AppError> {
|
||||
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<Vec<PiRequestCandidate>, 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<PiMaterializedAttempt, AppError> {
|
||||
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<PiRuntimeBuild, AppError> {
|
||||
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::<Vec<_>>();
|
||||
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<IndexMap<String, Option<Value>>, 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<Value, AppError> {
|
||||
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<PiManagedProviderConfig, AppError> {
|
||||
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<String>,
|
||||
) -> Result<PiRuntimeModel, AppError> {
|
||||
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<Vec<u8>, 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::<BTreeMap<_, _>>();
|
||||
Value::Object(sorted.into_iter().collect())
|
||||
}
|
||||
scalar => scalar.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_config_for_gateway(
|
||||
config: &PiManagedProviderConfig,
|
||||
local_base: &Url,
|
||||
gateway_token: &GatewayToken,
|
||||
) -> Result<Value, AppError> {
|
||||
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<Option<Arc<PiRuntimeSnapshot>>>,
|
||||
catalog_epoch: AtomicU64,
|
||||
epoch_gate: Arc<AsyncRwLock<()>>,
|
||||
}
|
||||
|
||||
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<PiRuntimeSnapshot>) -> 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<bool, AppError> {
|
||||
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<Arc<PiRuntimeSnapshot>> {
|
||||
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<Self>,
|
||||
server_generation: u64,
|
||||
snapshot: &Arc<PiRuntimeSnapshot>,
|
||||
) -> Option<OwnedRwLockReadGuard<()>> {
|
||||
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<Self>,
|
||||
expected_epoch: u64,
|
||||
) -> Option<OwnedRwLockReadGuard<()>> {
|
||||
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<PiGatewayApiFamily> {
|
||||
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<Url, AppError> {
|
||||
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<String, String> {
|
||||
if let Some(command) = expression.strip_prefix('!') {
|
||||
return execute_config_command(command);
|
||||
}
|
||||
expand_environment(expression)
|
||||
}
|
||||
|
||||
fn expand_environment(input: &str) -> Result<String, String> {
|
||||
const ESCAPED_DOLLAR: char = '\u{e000}';
|
||||
const ESCAPED_BANG: char = '\u{e001}';
|
||||
let chars = input.chars().collect::<Vec<_>>();
|
||||
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::<String>(), 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::<String>(), 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<String, String> {
|
||||
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<Vec<u8>, 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,16 @@ impl ProviderRouter {
|
||||
}
|
||||
}
|
||||
|
||||
async fn app_proxy_config(
|
||||
&self,
|
||||
app_type: &str,
|
||||
) -> Result<crate::proxy::types::AppProxyConfig, AppError> {
|
||||
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<String>,
|
||||
) -> 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,
|
||||
|
||||
@@ -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<dyn ProviderAdapter> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u64>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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<tauri::AppHandle>,
|
||||
/// 故障转移切换管理器
|
||||
pub failover_manager: Arc<FailoverSwitchManager>,
|
||||
/// Immutable Pi catalog publication point shared with `ProxyService`.
|
||||
pub pi_runtime: Arc<PiRuntimeStore>,
|
||||
/// 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<RwLock<Option<oneshot::Sender<()>>>>,
|
||||
/// 服务器任务句柄,用于等待服务器实际关闭
|
||||
server_handle: Arc<RwLock<Option<JoinHandle<()>>>>,
|
||||
pi_server_generation: u64,
|
||||
}
|
||||
|
||||
impl ProxyServer {
|
||||
@@ -64,6 +71,8 @@ impl ProxyServer {
|
||||
config: ProxyConfig,
|
||||
db: Arc<Database>,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
pi_runtime: Arc<PiRuntimeStore>,
|
||||
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<ProxyServerInfo, ProxyError> {
|
||||
// 检查是否已在运行
|
||||
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())
|
||||
|
||||
@@ -116,6 +116,7 @@ pub struct ProxyTakeoverStatus {
|
||||
pub grokbuild: bool,
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
pub pi: bool,
|
||||
}
|
||||
|
||||
/// Provider健康状态
|
||||
|
||||
@@ -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<CostBreakdown> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CostBreakdown>,
|
||||
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<String>,
|
||||
provider_type: Option<String>,
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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<String, McpServer>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
pub(crate) fn lock_instruction_files() -> Result<MutexGuard<'static, ()>, 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<PiPromptFileSnapshot, AppError> {
|
||||
let guard = lock_instruction_files()?;
|
||||
Self::read_under_guard(&guard, kind)
|
||||
}
|
||||
|
||||
pub fn replace(
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
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<bool, AppError> {
|
||||
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<PiPromptFileSnapshot, AppError> {
|
||||
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<PiPromptFileSnapshot, AppError> {
|
||||
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<bool, AppError> {
|
||||
Self::delete_at(&get_pi_agent_dir()?, kind, expected_revision)
|
||||
}
|
||||
|
||||
fn read_at(root: &Path, kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
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<PiPromptFileSnapshot, AppError> {
|
||||
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<bool, AppError> {
|
||||
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<Vec<PiPromptTemplate>, AppError> {
|
||||
Self::list_at(&get_pi_agent_dir()?.join("prompts"))
|
||||
}
|
||||
|
||||
pub fn upsert(
|
||||
slug: &str,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
) -> Result<PiPromptTemplate, AppError> {
|
||||
Self::upsert_at(
|
||||
&get_pi_agent_dir()?.join("prompts"),
|
||||
slug,
|
||||
expected_revision,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete(slug: &str, expected_revision: &str) -> Result<bool, AppError> {
|
||||
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<Vec<PiPromptTemplate>, 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<PiPromptTemplate, AppError> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<i64, AppError> {
|
||||
@@ -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<String, AppError> {
|
||||
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<Option<String>, 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<String, AppError> {
|
||||
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<OsString>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value, AppError> {
|
||||
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<bool
|
||||
"config": config_obj
|
||||
})
|
||||
}
|
||||
AppType::Pi => {
|
||||
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")
|
||||
|
||||
@@ -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::<HashMap<_, _>>();
|
||||
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<bool, AppError> {
|
||||
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<SwitchResult, AppError> {
|
||||
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<ProviderSortUpdate>,
|
||||
) -> Result<bool, AppError> {
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+908
-17
File diff suppressed because it is too large
Load Diff
+412
-50
@@ -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<Database>, id: &str) -> Result<SkillUninstallResult> {
|
||||
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<Database>,
|
||||
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<Database>,
|
||||
imports: Vec<ImportSkillSelection>,
|
||||
) -> Result<Vec<InstalledSkill>> {
|
||||
// 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<Database>,
|
||||
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<SkillMetadata> {
|
||||
pub(crate) fn parse_skill_metadata_static(path: &Path) -> Result<SkillMetadata> {
|
||||
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<std::ffi::OsString>);
|
||||
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(),
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl PiSkillDeploymentService {
|
||||
pub(crate) fn reconcile_skill(
|
||||
db: &Arc<Database>,
|
||||
skill: &InstalledSkill,
|
||||
) -> Result<(), AppError> {
|
||||
let guard = Self::operation_guard();
|
||||
Self::reconcile_skill_under_guard(&guard, db, skill)
|
||||
}
|
||||
|
||||
pub(crate) fn toggle(
|
||||
db: &Arc<Database>,
|
||||
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<Database>,
|
||||
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<Database>,
|
||||
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<Database>) -> 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<Database>,
|
||||
skill: &InstalledSkill,
|
||||
) -> Result<(), AppError> {
|
||||
reconcile_skill_unlocked(db, skill)
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_all_under_guard(
|
||||
_guard: &MutexGuard<'static, ()>,
|
||||
db: &Arc<Database>,
|
||||
) -> 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<Database>,
|
||||
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<Database>,
|
||||
) -> Result<BTreeMap<String, SkillAppStatus>, 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<PathBuf, (PiSkillDiscovery, Option<String>)>,
|
||||
}
|
||||
|
||||
fn scan_pi_discovery() -> Result<PiDiscoveryScan, AppError> {
|
||||
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::<Result<Vec<_>, _>>()
|
||||
.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::<String, PathBuf>::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<Database>,
|
||||
skill: &InstalledSkill,
|
||||
discovery: &PiDiscoveryScan,
|
||||
) -> Result<SkillAppStatus, AppError> {
|
||||
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<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn reconcile_skill_unlocked(db: &Arc<Database>, 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<PathBuf, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
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<Database>,
|
||||
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<Database>,
|
||||
skill: &InstalledSkill,
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
destination_key: &str,
|
||||
existing: Option<SkillDeployment>,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> 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<Database>,
|
||||
skill: &InstalledSkill,
|
||||
destination: &Path,
|
||||
destination_key: &str,
|
||||
existing: Option<SkillDeployment>,
|
||||
desired_enabled: Option<bool>,
|
||||
) -> 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<SkillDeploymentMethod, AppError> {
|
||||
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<PathBuf, AppError> {
|
||||
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<String, AppError> {
|
||||
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<u8>)>,
|
||||
) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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::<Result<_, _>>()
|
||||
.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,
|
||||
|
||||
@@ -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<String, AppError> {
|
||||
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 → 直接用
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<SessionMeta> {
|
||||
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<SessionMeta> {
|
||||
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<SessionMeta> {
|
||||
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<SessionMeta> {
|
||||
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<Vec<Session
|
||||
"gemini" => 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<Vec<PathBuf>, 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}")),
|
||||
};
|
||||
|
||||
|
||||
@@ -5,4 +5,5 @@ pub mod grokbuild;
|
||||
pub mod hermes;
|
||||
pub mod openclaw;
|
||||
pub mod opencode;
|
||||
pub mod pi;
|
||||
mod utils;
|
||||
|
||||
@@ -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<i64>,
|
||||
version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionTree {
|
||||
header: SessionHeader,
|
||||
active_ids: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ActiveSessionData {
|
||||
messages: Vec<SessionMessage>,
|
||||
first_user_message: Option<String>,
|
||||
last_message: Option<String>,
|
||||
explicit_name: Option<Option<String>>,
|
||||
last_active_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<SessionMeta> {
|
||||
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<SessionMeta> {
|
||||
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<Vec<SessionMessage>, 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<Vec<SessionMessage>, 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<bool, String> {
|
||||
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<SessionMeta, String> {
|
||||
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<SessionTree, String> {
|
||||
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::<String, Option<String>>::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::<Value>(&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<ActiveSessionData, String> {
|
||||
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::<Value>(&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<SessionMessage>, content: &str, ts: Option<i64>) {
|
||||
if !content.trim().is_empty() {
|
||||
messages.push(SessionMessage {
|
||||
role: "system".to_string(),
|
||||
content: content.to_string(),
|
||||
ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header(value: &Value) -> Result<SessionHeader, String> {
|
||||
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<String>)> {
|
||||
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<PathBuf>) {
|
||||
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<_>>(),
|
||||
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", <SessionManager.getSessionFile()>] 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<_>>(),
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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"'\''"))
|
||||
}
|
||||
|
||||
|
||||
+248
-27
@@ -17,10 +17,156 @@ pub struct CustomEndpoint {
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
/// 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(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// 主页面显示的应用配置
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hermes_config_dir: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pi_config_dir: Option<String>,
|
||||
|
||||
// ===== 当前供应商 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<String>,
|
||||
/// 当前 Pi 供应商 ID(本地存储)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_provider_pi: Option<String>,
|
||||
|
||||
/// 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<GatewayToken>,
|
||||
|
||||
// ===== 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<RwLock<AppSettings>> = OnceLock::new();
|
||||
@@ -705,7 +870,7 @@ fn settings_store() -> &'static RwLock<AppSettings> {
|
||||
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<PathBuf> {
|
||||
.map(|p| resolve_override_path(p))
|
||||
}
|
||||
|
||||
pub fn get_pi_override_dir() -> Option<PathBuf> {
|
||||
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<GatewayToken, AppError> {
|
||||
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<GatewayToken, AppError> {
|
||||
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<String> {
|
||||
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]
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::services::{ProxyService, UsageCache};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 全局应用状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub proxy_service: ProxyService,
|
||||
|
||||
Reference in New Issue
Block a user