feat(pi): add native catalog and gateway data plane

This commit is contained in:
SaladDay
2026-08-02 22:52:26 +00:00
parent 26a95aeb05
commit 7609ff799e
70 changed files with 10738 additions and 367 deletions
+14
View File
@@ -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() {
+173
View File
@@ -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", &current)
.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(())
}
+87 -17
View File
@@ -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
+39 -2
View 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]
+2
View File
@@ -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::*;
+76
View File
@@ -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())
}
+50
View File
@@ -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())
}
+58
View File
@@ -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())?;
+31 -5
View File
@@ -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()),
);
+59 -2
View File
@@ -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, &current, 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);
}
}
/// 获取开机自启状态
+9
View File
@@ -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())
+5 -7
View File
@@ -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(())
}
+31 -5
View File
@@ -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()),
);