mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-03 19:12:04 +08:00
fix(pi): restore catalog and takeover authority boundaries
This commit is contained in:
@@ -13,9 +13,85 @@ use super::providers::delete_provider_on_tx;
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
use crate::provider::{ProviderAggregate, ProviderMutationInput};
|
||||
use indexmap::IndexMap;
|
||||
use rusqlite::params;
|
||||
|
||||
impl Database {
|
||||
pub(crate) fn restore_pi_catalog_snapshot(
|
||||
&self,
|
||||
aggregates: &IndexMap<String, ProviderAggregate>,
|
||||
projections: &[PiProviderProjection],
|
||||
current_provider: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let mut conn = lock_conn!(self.conn);
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
tx.execute("DELETE FROM pi_provider_projections", [])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let current_ids = {
|
||||
let mut statement = tx
|
||||
.prepare("SELECT id FROM providers WHERE app_type = 'pi'")
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let ids = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
ids
|
||||
};
|
||||
for provider_id in current_ids
|
||||
.iter()
|
||||
.filter(|provider_id| !aggregates.contains_key(provider_id.as_str()))
|
||||
{
|
||||
// Only rows created after the snapshot are removed. Updating
|
||||
// providers which existed in the snapshot preserves dependent
|
||||
// provider_health history instead of triggering ON DELETE CASCADE.
|
||||
delete_provider_on_tx(&tx, "pi", provider_id)?;
|
||||
}
|
||||
|
||||
for aggregate in aggregates.values() {
|
||||
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<_>, _>>()?;
|
||||
restore_provider_aggregate_on_tx(
|
||||
&tx,
|
||||
&key,
|
||||
&row,
|
||||
aggregate.provider.created_at,
|
||||
aggregate.provider.sort_index,
|
||||
current_provider == Some(key.id()),
|
||||
aggregate.provider.in_failover_queue,
|
||||
&endpoints,
|
||||
)?;
|
||||
}
|
||||
for projection in projections {
|
||||
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()))
|
||||
}
|
||||
|
||||
pub(crate) fn create_pi_catalog_provider(
|
||||
&self,
|
||||
input: NewProviderAggregate,
|
||||
|
||||
@@ -94,6 +94,9 @@ impl Database {
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
transaction
|
||||
.execute("DELETE FROM prompts WHERE app_type = ?1", [app_type])
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
{
|
||||
let mut statement = transaction
|
||||
.prepare(
|
||||
|
||||
@@ -1956,7 +1956,15 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
|
||||
// 失败时清除该应用的状态,避免下次启动再次尝试
|
||||
// Pi desired state is device-local user intent. Keep it
|
||||
// pending/degraded so a transient bind or projection failure
|
||||
// is retried on the next startup.
|
||||
if app_type == "pi" {
|
||||
continue;
|
||||
}
|
||||
// Legacy live-config apps retain their historical cleanup
|
||||
// behavior because their enabled bit also describes a live
|
||||
// file takeover, not an independent desired/operational pair.
|
||||
if let Err(clear_err) = state
|
||||
.proxy_service
|
||||
.set_takeover_for_app(app_type, false)
|
||||
|
||||
@@ -117,6 +117,16 @@ pub struct ProxyTakeoverStatus {
|
||||
pub opencode: bool,
|
||||
pub openclaw: bool,
|
||||
pub pi: bool,
|
||||
pub pi_operational_state: PiTakeoverOperationalState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PiTakeoverOperationalState {
|
||||
#[default]
|
||||
Disabled,
|
||||
Active,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
/// Provider健康状态
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! callers must not compose the database and native-file primitives directly.
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::database::{NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate};
|
||||
use crate::database::{
|
||||
NewEndpoint, NewProviderAggregate, PiProviderProjection, ProviderKey, ProviderRowUpdate,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::pi_config::document::{apply_pi_provider_patch, current_pi_provider_values};
|
||||
use crate::pi_config::model::{
|
||||
@@ -16,7 +18,7 @@ use crate::pi_config::native::{
|
||||
get_pi_models_path, inspect_pi_native_entry, PiNativeInspectionService,
|
||||
};
|
||||
use crate::pi_config::native_settings::{
|
||||
read_pi_native_defaults, replace_pi_native_defaults, set_pi_native_default,
|
||||
read_pi_native_defaults, replace_pi_native_defaults, set_pi_native_default, PiNativeDefaults,
|
||||
};
|
||||
use crate::provider::{ProviderAggregate, ProviderMutationInput};
|
||||
use crate::settings;
|
||||
@@ -25,6 +27,7 @@ use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const PI_APP: &str = "pi";
|
||||
|
||||
@@ -89,12 +92,72 @@ pub(crate) struct PiCatalogMutationResult {
|
||||
|
||||
pub(crate) struct PiCatalogCoordinator;
|
||||
|
||||
struct PiCatalogSnapshot {
|
||||
aggregates: IndexMap<String, ProviderAggregate>,
|
||||
projections: Vec<PiProviderProjection>,
|
||||
defaults: PiNativeDefaults,
|
||||
local_current: Option<String>,
|
||||
db_current: Option<String>,
|
||||
models_path: PathBuf,
|
||||
native_values: IndexMap<String, Option<Value>>,
|
||||
}
|
||||
|
||||
impl PiCatalogCoordinator {
|
||||
pub(crate) fn update_route_order(
|
||||
state: &AppState,
|
||||
updates: Vec<(ProviderKey, usize)>,
|
||||
) -> Result<bool, AppError> {
|
||||
let _switch_guard = futures::executor::block_on(
|
||||
state
|
||||
.proxy_service
|
||||
.lock_switch_for_app(AppType::Pi.as_str()),
|
||||
);
|
||||
let aggregates = state.db.get_all_provider_aggregates(PI_APP)?;
|
||||
for (key, _) in &updates {
|
||||
if !aggregates.contains_key(key.id()) {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"Pi provider '{}' cannot be sorted because it does not exist",
|
||||
key.id()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let projections = state
|
||||
.db
|
||||
.get_pi_projection_manifest()?
|
||||
.into_values()
|
||||
.collect::<Vec<_>>();
|
||||
let db_current = state.db.get_current_provider(PI_APP)?;
|
||||
|
||||
state.db.update_provider_sort_index(&updates)?;
|
||||
if let Err(error) =
|
||||
futures::executor::block_on(state.proxy_service.publish_pi_runtime_order())
|
||||
{
|
||||
let rollback = state.db.restore_pi_catalog_snapshot(
|
||||
&aggregates,
|
||||
&projections,
|
||||
db_current.as_deref(),
|
||||
);
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to publish sorted Pi runtime: {error}; DB rollback={}",
|
||||
rollback
|
||||
.err()
|
||||
.map_or_else(|| "ok".to_string(), |value| value.to_string())
|
||||
)));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn apply(
|
||||
state: &AppState,
|
||||
mutation: PiCatalogMutation,
|
||||
) -> Result<PiCatalogMutationResult, AppError> {
|
||||
Self::run_with_runtime_reconcile(state, || match mutation {
|
||||
let additional_native_key = match &mutation {
|
||||
PiCatalogMutation::CreateProvider { provider_key, .. }
|
||||
| PiCatalogMutation::ImportNative { provider_key, .. } => Some(provider_key.clone()),
|
||||
_ => None,
|
||||
};
|
||||
Self::run_with_runtime_reconcile(state, additional_native_key.as_deref(), || match mutation
|
||||
{
|
||||
PiCatalogMutation::CreateProvider {
|
||||
input,
|
||||
provider_key,
|
||||
@@ -126,7 +189,7 @@ impl PiCatalogCoordinator {
|
||||
/// key. A missing native key can therefore be claimed and published
|
||||
/// without guessing; an existing unclaimed key is never overwritten.
|
||||
pub(crate) fn reconcile_portable_import(state: &AppState) -> Result<(), AppError> {
|
||||
Self::run_with_runtime_reconcile(state, || {
|
||||
Self::run_with_runtime_reconcile(state, None, || {
|
||||
let models_path = get_pi_models_path()?;
|
||||
Self::reconcile_portable_catalog_at(
|
||||
state,
|
||||
@@ -144,6 +207,7 @@ impl PiCatalogCoordinator {
|
||||
|
||||
fn run_with_runtime_reconcile(
|
||||
state: &AppState,
|
||||
additional_native_key: Option<&str>,
|
||||
operation: impl FnOnce() -> Result<PiCatalogMutationResult, AppError>,
|
||||
) -> Result<PiCatalogMutationResult, AppError> {
|
||||
let _switch_guard = futures::executor::block_on(
|
||||
@@ -151,6 +215,8 @@ impl PiCatalogCoordinator {
|
||||
.proxy_service
|
||||
.lock_switch_for_app(AppType::Pi.as_str()),
|
||||
);
|
||||
Self::reconcile_current_indexes_from_native(state)?;
|
||||
let snapshot = PiCatalogSnapshot::capture(state, additional_native_key)?;
|
||||
let catalog_epoch =
|
||||
futures::executor::block_on(state.proxy_service.begin_pi_catalog_mutation());
|
||||
let result = operation();
|
||||
@@ -161,10 +227,42 @@ impl PiCatalogCoordinator {
|
||||
);
|
||||
match (result, reconcile) {
|
||||
(Ok(result), Ok(_)) => Ok(result),
|
||||
(Ok(_), Err(error)) => Err(authority_error(
|
||||
PiCatalogAuthority::ProjectionPending,
|
||||
format!("Pi catalog mutated but runtime publication failed: {error}"),
|
||||
)),
|
||||
(Ok(_), Err(error)) => {
|
||||
if let Err(rollback_error) = snapshot.restore(state) {
|
||||
let _ = futures::executor::block_on(
|
||||
state.proxy_service.close_pi_runtime_at_epoch(catalog_epoch),
|
||||
);
|
||||
return Err(authority_error(
|
||||
PiCatalogAuthority::ProjectionPending,
|
||||
format!(
|
||||
"Pi catalog runtime publication failed ({error}); snapshot rollback failed ({rollback_error})"
|
||||
),
|
||||
));
|
||||
}
|
||||
match futures::executor::block_on(
|
||||
state
|
||||
.proxy_service
|
||||
.reconcile_pi_runtime_at_epoch(catalog_epoch),
|
||||
) {
|
||||
Ok(_) => Err(authority_error(
|
||||
PiCatalogAuthority::PreviousRestored,
|
||||
format!(
|
||||
"Pi catalog runtime publication failed and the previous catalog was restored: {error}"
|
||||
),
|
||||
)),
|
||||
Err(rollback_error) => {
|
||||
let _ = futures::executor::block_on(
|
||||
state.proxy_service.close_pi_runtime_at_epoch(catalog_epoch),
|
||||
);
|
||||
Err(authority_error(
|
||||
PiCatalogAuthority::ProjectionPending,
|
||||
format!(
|
||||
"Pi catalog runtime publication failed ({error}); the database snapshot was restored but runtime recovery failed ({rollback_error})"
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
(Err(error), Ok(_)) => Err(error),
|
||||
(Err(error), Err(reconcile_error)) => Err(authority_error(
|
||||
PiCatalogAuthority::ProjectionPending,
|
||||
@@ -426,17 +524,31 @@ impl PiCatalogCoordinator {
|
||||
/// UI claim that a different provider is active after an external Pi edit.
|
||||
pub(crate) fn current_native_provider(state: &AppState) -> Result<Option<String>, AppError> {
|
||||
let defaults = read_pi_native_defaults()?;
|
||||
let Some(provider_key) = defaults.default_provider else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(projection) = state.db.get_pi_projection_for_key(&provider_key)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(state
|
||||
.db
|
||||
.get_provider_aggregate(PI_APP, &projection.provider_id)?
|
||||
.is_some()
|
||||
.then_some(projection.provider_id))
|
||||
resolve_native_current_provider(state, &defaults)
|
||||
}
|
||||
|
||||
fn reconcile_current_indexes_from_native(state: &AppState) -> Result<(), AppError> {
|
||||
let defaults = read_pi_native_defaults()?;
|
||||
let native_current = resolve_native_current_provider(state, &defaults)?;
|
||||
let previous_local = settings::get_current_provider(&AppType::Pi);
|
||||
let previous_db = state.db.get_current_provider(PI_APP)?;
|
||||
if previous_local == native_current && previous_db == native_current {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
settings::set_current_provider(&AppType::Pi, native_current.as_deref())?;
|
||||
if let Err(error) = restore_db_current(state, native_current.as_deref()) {
|
||||
let local_restored =
|
||||
settings::set_current_provider(&AppType::Pi, previous_local.as_deref()).is_ok();
|
||||
let db_restored = restore_db_current(state, previous_db.as_deref()).is_ok();
|
||||
return Err(authority_error(
|
||||
PiCatalogAuthority::ProjectionPending,
|
||||
format!(
|
||||
"failed to align Pi current indexes with native settings: {error}; rollback: local={local_restored}, db={db_restored}"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create(
|
||||
@@ -445,6 +557,7 @@ impl PiCatalogCoordinator {
|
||||
provider_key: String,
|
||||
activate_if_first: bool,
|
||||
) -> Result<PiCatalogMutationResult, AppError> {
|
||||
let catalog_was_empty = state.db.get_all_providers(PI_APP)?.is_empty();
|
||||
let provider_key = non_empty_native_key(&provider_key)?;
|
||||
let config = managed_config(&input)?;
|
||||
let provider_id = input.id.clone();
|
||||
@@ -492,7 +605,7 @@ impl PiCatalogCoordinator {
|
||||
let no_selected_provider = previous_local.is_none() && previous_db.is_none();
|
||||
let native_defaults_empty = previous_defaults.default_provider.is_none()
|
||||
&& previous_defaults.default_model.is_none();
|
||||
if activate_if_first && no_selected_provider && native_defaults_empty {
|
||||
if activate_if_first && catalog_was_empty && no_selected_provider && native_defaults_empty {
|
||||
let first_model = config
|
||||
.models
|
||||
.first()
|
||||
@@ -621,13 +734,9 @@ impl PiCatalogCoordinator {
|
||||
.db
|
||||
.get_provider_aggregate(PI_APP, provider_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Pi provider '{provider_id}'")))?;
|
||||
let local_current = settings::get_current_provider(&AppType::Pi);
|
||||
let db_current = state.db.get_current_provider(PI_APP)?;
|
||||
let native_defaults = read_pi_native_defaults()?;
|
||||
if local_current.as_deref() == Some(provider_id)
|
||||
|| db_current.as_deref() == Some(provider_id)
|
||||
|| native_defaults.default_provider.as_deref() == Some(&projection.provider_key)
|
||||
{
|
||||
if native_defaults.default_provider.as_deref() == Some(&projection.provider_key) {
|
||||
return Err(AppError::Conflict(
|
||||
"the active Pi provider cannot be deleted".to_string(),
|
||||
));
|
||||
@@ -949,6 +1058,78 @@ fn restore_db_current(state: &AppState, previous: Option<&str>) -> Result<(), Ap
|
||||
}
|
||||
}
|
||||
|
||||
impl PiCatalogSnapshot {
|
||||
fn capture(state: &AppState, additional_native_key: Option<&str>) -> Result<Self, AppError> {
|
||||
let aggregates = state.db.get_all_provider_aggregates(PI_APP)?;
|
||||
let manifest = state.db.get_pi_projection_manifest()?;
|
||||
let projections = manifest.values().cloned().collect::<Vec<_>>();
|
||||
let mut native_keys = manifest
|
||||
.values()
|
||||
.map(|projection| projection.provider_key.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(key) = additional_native_key {
|
||||
if !native_keys.iter().any(|candidate| candidate == key) {
|
||||
native_keys.push(key.to_string());
|
||||
}
|
||||
}
|
||||
let models_path = get_pi_models_path()?;
|
||||
let native_values = current_pi_provider_values(&models_path, native_keys)?;
|
||||
Ok(Self {
|
||||
aggregates,
|
||||
projections,
|
||||
defaults: read_pi_native_defaults()?,
|
||||
local_current: settings::get_current_provider(&AppType::Pi),
|
||||
db_current: state.db.get_current_provider(PI_APP)?,
|
||||
models_path,
|
||||
native_values,
|
||||
})
|
||||
}
|
||||
|
||||
fn restore(&self, state: &AppState) -> Result<(), AppError> {
|
||||
let mut failures = Vec::new();
|
||||
if let Err(error) = state.db.restore_pi_catalog_snapshot(
|
||||
&self.aggregates,
|
||||
&self.projections,
|
||||
self.db_current.as_deref(),
|
||||
) {
|
||||
failures.push(format!("database={error}"));
|
||||
}
|
||||
if let Err(error) = replace_pi_native_defaults(&self.defaults) {
|
||||
failures.push(format!("native_defaults={error}"));
|
||||
}
|
||||
if let Err(error) =
|
||||
settings::set_current_provider(&AppType::Pi, self.local_current.as_deref())
|
||||
{
|
||||
failures.push(format!("local_current={error}"));
|
||||
}
|
||||
if let Err(error) = apply_pi_provider_patch(&self.models_path, &self.native_values) {
|
||||
failures.push(format!("models={error}"));
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Config(failures.join(", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_native_current_provider(
|
||||
state: &AppState,
|
||||
defaults: &PiNativeDefaults,
|
||||
) -> Result<Option<String>, AppError> {
|
||||
let Some(provider_key) = defaults.default_provider.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(projection) = state.db.get_pi_projection_for_key(provider_key)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(state
|
||||
.db
|
||||
.get_provider_aggregate(PI_APP, &projection.provider_id)?
|
||||
.is_some()
|
||||
.then_some(projection.provider_id))
|
||||
}
|
||||
|
||||
fn success(provider_id: Option<String>) -> PiCatalogMutationResult {
|
||||
PiCatalogMutationResult {
|
||||
authority: PiCatalogAuthority::Published,
|
||||
@@ -970,6 +1151,29 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn managed_input(id: &str, base_url: &str) -> ProviderMutationInput {
|
||||
ProviderMutationInput {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
settings_config: json!({
|
||||
"name": id,
|
||||
"api": "openai-responses",
|
||||
"baseUrl": base_url,
|
||||
"apiKey": "literal-key",
|
||||
"models": [{"id": "model-a", "name": "Model A"}]
|
||||
}),
|
||||
website_url: None,
|
||||
category: None,
|
||||
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 insert_portable_pi_provider(db: &Database, id: &str) -> Result<Value, AppError> {
|
||||
let config = json!({
|
||||
"name": "Portable Pi",
|
||||
@@ -1168,4 +1372,267 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn external_native_switch_repairs_indexes_before_deleting_the_inactive_provider(
|
||||
) -> Result<(), AppError> {
|
||||
struct HomeGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for HomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
let _ = crate::settings::reload_settings();
|
||||
}
|
||||
}
|
||||
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = HomeGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
crate::settings::reload_settings()?;
|
||||
let pi_dir = temp.path().join("pi-agent");
|
||||
let mut app_settings = crate::settings::get_settings();
|
||||
app_settings.pi_config_dir = Some(pi_dir.to_string_lossy().into_owned());
|
||||
app_settings.pi_takeover_enabled = false;
|
||||
crate::settings::update_settings(app_settings)?;
|
||||
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let state = AppState::new(db.clone());
|
||||
PiCatalogCoordinator::apply(
|
||||
&state,
|
||||
PiCatalogMutation::CreateProvider {
|
||||
input: managed_input("provider-a", "https://a.example/v1"),
|
||||
provider_key: "native-a".to_string(),
|
||||
activate_if_first: true,
|
||||
},
|
||||
)?;
|
||||
PiCatalogCoordinator::apply(
|
||||
&state,
|
||||
PiCatalogMutation::CreateProvider {
|
||||
input: managed_input("provider-b", "https://b.example/v1"),
|
||||
provider_key: "native-b".to_string(),
|
||||
activate_if_first: true,
|
||||
},
|
||||
)?;
|
||||
assert_eq!(
|
||||
settings::get_current_provider(&AppType::Pi).as_deref(),
|
||||
Some("provider-a")
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_current_provider(PI_APP)?.as_deref(),
|
||||
Some("provider-a")
|
||||
);
|
||||
|
||||
set_pi_native_default("native-b", "model-a")?;
|
||||
assert_eq!(
|
||||
PiCatalogCoordinator::current_native_provider(&state)?.as_deref(),
|
||||
Some("provider-b"),
|
||||
"native settings must immediately drive displayed current state"
|
||||
);
|
||||
|
||||
PiCatalogCoordinator::apply(
|
||||
&state,
|
||||
PiCatalogMutation::DeleteProvider {
|
||||
provider_id: "provider-a".to_string(),
|
||||
},
|
||||
)?;
|
||||
assert!(db.get_provider_aggregate(PI_APP, "provider-a")?.is_none());
|
||||
assert!(db.get_provider_aggregate(PI_APP, "provider-b")?.is_some());
|
||||
assert_eq!(
|
||||
settings::get_current_provider(&AppType::Pi).as_deref(),
|
||||
Some("provider-b")
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_current_provider(PI_APP)?.as_deref(),
|
||||
Some("provider-b")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn runtime_publication_failure_restores_endpoint_database_and_native_snapshot(
|
||||
) -> Result<(), AppError> {
|
||||
struct HomeGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for HomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
let _ = crate::settings::reload_settings();
|
||||
}
|
||||
}
|
||||
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = HomeGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
crate::settings::reload_settings()?;
|
||||
let pi_dir = temp.path().join("pi-agent");
|
||||
let mut app_settings = crate::settings::get_settings();
|
||||
app_settings.pi_config_dir = Some(pi_dir.to_string_lossy().into_owned());
|
||||
app_settings.pi_takeover_enabled = false;
|
||||
crate::settings::update_settings(app_settings)?;
|
||||
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let state = AppState::new(db.clone());
|
||||
PiCatalogCoordinator::apply(
|
||||
&state,
|
||||
PiCatalogMutation::CreateProvider {
|
||||
input: managed_input("provider-a", "https://a.example/v1"),
|
||||
provider_key: "native-a".to_string(),
|
||||
activate_if_first: true,
|
||||
},
|
||||
)?;
|
||||
let before_aggregate = db
|
||||
.get_provider_aggregate(PI_APP, "provider-a")?
|
||||
.expect("provider");
|
||||
futures::executor::block_on(db.update_provider_health(
|
||||
"provider-a",
|
||||
PI_APP,
|
||||
false,
|
||||
Some("captured health".to_string()),
|
||||
))?;
|
||||
let before_health =
|
||||
futures::executor::block_on(db.get_provider_health("provider-a", PI_APP))?;
|
||||
let models_path = get_pi_models_path()?;
|
||||
let before_models = std::fs::read(&models_path).expect("models");
|
||||
|
||||
state.proxy_service.fail_next_pi_reconcile_for_test();
|
||||
let error = PiCatalogCoordinator::apply(
|
||||
&state,
|
||||
PiCatalogMutation::AddEndpoint {
|
||||
provider_id: "provider-a".to_string(),
|
||||
url: "https://endpoint.example/v1".to_string(),
|
||||
},
|
||||
)
|
||||
.expect_err("injected publication failure");
|
||||
assert!(error.to_string().contains("previous catalog was restored"));
|
||||
|
||||
let after_aggregate = db
|
||||
.get_provider_aggregate(PI_APP, "provider-a")?
|
||||
.expect("provider remains");
|
||||
assert_eq!(
|
||||
serde_json::to_value(after_aggregate).expect("after aggregate"),
|
||||
serde_json::to_value(before_aggregate).expect("before aggregate")
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(models_path).expect("models after"),
|
||||
before_models
|
||||
);
|
||||
let after_health =
|
||||
futures::executor::block_on(db.get_provider_health("provider-a", PI_APP))?;
|
||||
assert_eq!(
|
||||
serde_json::to_value(after_health).expect("after health"),
|
||||
serde_json::to_value(before_health).expect("before health"),
|
||||
"catalog compensation must not cascade-delete provider health history"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn route_sort_publishes_an_even_runtime_without_rewriting_native_models() {
|
||||
let result: Result<(), AppError> = async {
|
||||
struct HomeGuard(Option<std::ffi::OsString>);
|
||||
impl Drop for HomeGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.0.take() {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
let _ = crate::settings::reload_settings();
|
||||
}
|
||||
}
|
||||
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let _home = HomeGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||||
crate::settings::reload_settings()?;
|
||||
let mut app_settings = crate::settings::get_settings();
|
||||
app_settings.pi_config_dir =
|
||||
Some(temp.path().join("pi-agent").to_string_lossy().into_owned());
|
||||
app_settings.pi_takeover_enabled = false;
|
||||
crate::settings::update_settings(app_settings)?;
|
||||
|
||||
let db = Arc::new(Database::memory()?);
|
||||
let state = AppState::new(db.clone());
|
||||
for (id, key) in [("provider-a", "native-a"), ("provider-b", "native-b")] {
|
||||
PiCatalogCoordinator::apply(
|
||||
&state,
|
||||
PiCatalogMutation::CreateProvider {
|
||||
input: managed_input(id, &format!("https://{id}.example/v1")),
|
||||
provider_key: key.to_string(),
|
||||
activate_if_first: true,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
state
|
||||
.proxy_service
|
||||
.set_takeover_for_app(PI_APP, true)
|
||||
.await
|
||||
.map_err(AppError::Message)?;
|
||||
let models_path = get_pi_models_path()?;
|
||||
let before_models = std::fs::read(&models_path).expect("models");
|
||||
|
||||
PiCatalogCoordinator::update_route_order(
|
||||
&state,
|
||||
vec![
|
||||
(ProviderKey::new(PI_APP, "provider-b")?, 0),
|
||||
(ProviderKey::new(PI_APP, "provider-a")?, 1),
|
||||
],
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(&models_path).expect("models after sort"),
|
||||
before_models,
|
||||
"sorting is DB/runtime-only and must not rewrite Pi's shared file"
|
||||
);
|
||||
let aggregates = db.get_all_provider_aggregates(PI_APP)?;
|
||||
assert_eq!(aggregates["provider-b"].provider.sort_index, Some(0));
|
||||
assert_eq!(aggregates["provider-a"].provider.sort_index, Some(1));
|
||||
assert_eq!(
|
||||
state
|
||||
.proxy_service
|
||||
.get_takeover_status()
|
||||
.await
|
||||
.map_err(AppError::Message)?
|
||||
.pi_operational_state,
|
||||
crate::proxy::types::PiTakeoverOperationalState::Active,
|
||||
"sort publication must never leave the runtime at an odd admission epoch"
|
||||
);
|
||||
|
||||
let mut corrupted_settings = crate::settings::get_settings();
|
||||
corrupted_settings.pi_gateway_token = None;
|
||||
crate::settings::update_settings(corrupted_settings)?;
|
||||
let missing_token = PiCatalogCoordinator::update_route_order(
|
||||
&state,
|
||||
vec![
|
||||
(ProviderKey::new(PI_APP, "provider-a")?, 0),
|
||||
(ProviderKey::new(PI_APP, "provider-b")?, 1),
|
||||
],
|
||||
)
|
||||
.expect_err("pure sorting must not silently rotate the gateway credential");
|
||||
assert!(missing_token
|
||||
.to_string()
|
||||
.contains("credential is unavailable"));
|
||||
let aggregates = db.get_all_provider_aggregates(PI_APP)?;
|
||||
assert_eq!(aggregates["provider-b"].provider.sort_index, Some(0));
|
||||
assert_eq!(aggregates["provider-a"].provider.sort_index, Some(1));
|
||||
assert_eq!(
|
||||
std::fs::read(&models_path).expect("models after rejected sort"),
|
||||
before_models
|
||||
);
|
||||
state
|
||||
.proxy_service
|
||||
.set_takeover_for_app(PI_APP, false)
|
||||
.await
|
||||
.map_err(AppError::Message)?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
result.expect("route sort");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,23 @@ use crate::pi_config::shared_file::{delete_shared_file, read_shared_file, replac
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, Mutex, MutexGuard};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
|
||||
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(()));
|
||||
static INSTRUCTION_FILE_LOCK: LazyLock<Arc<Mutex<()>>> = LazyLock::new(|| Arc::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}")))
|
||||
pub(crate) type PiInstructionFileGuard = OwnedMutexGuard<()>;
|
||||
|
||||
pub(crate) fn lock_instruction_files() -> Result<PiInstructionFileGuard, AppError> {
|
||||
Ok(futures::executor::block_on(
|
||||
INSTRUCTION_FILE_LOCK.clone().lock_owned(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn lock_instruction_files_async() -> PiInstructionFileGuard {
|
||||
INSTRUCTION_FILE_LOCK.clone().lock_owned().await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -84,14 +91,14 @@ impl PiPromptFileService {
|
||||
}
|
||||
|
||||
pub(crate) fn read_under_guard(
|
||||
_guard: &MutexGuard<'static, ()>,
|
||||
_guard: &PiInstructionFileGuard,
|
||||
kind: PiPromptFileKind,
|
||||
) -> Result<PiPromptFileSnapshot, AppError> {
|
||||
Self::read_at(&get_pi_agent_dir()?, kind)
|
||||
}
|
||||
|
||||
pub(crate) fn replace_under_guard(
|
||||
_guard: &MutexGuard<'static, ()>,
|
||||
_guard: &PiInstructionFileGuard,
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
content: &str,
|
||||
@@ -100,7 +107,7 @@ impl PiPromptFileService {
|
||||
}
|
||||
|
||||
pub(crate) fn delete_under_guard(
|
||||
_guard: &MutexGuard<'static, ()>,
|
||||
_guard: &PiInstructionFileGuard,
|
||||
kind: PiPromptFileKind,
|
||||
expected_revision: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
|
||||
@@ -2,11 +2,13 @@ use indexmap::IndexMap;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::config::write_text_file;
|
||||
use crate::database::Database;
|
||||
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,
|
||||
lock_instruction_files, PiInstructionFileGuard, PiPromptFileKind, PiPromptFileService,
|
||||
PiPromptFileSnapshot,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -26,6 +28,9 @@ impl PromptService {
|
||||
state: &AppState,
|
||||
app: AppType,
|
||||
) -> Result<IndexMap<String, Prompt>, AppError> {
|
||||
if matches!(app, AppType::Pi) {
|
||||
Self::reconcile_pi_portable_import(state)?;
|
||||
}
|
||||
state.db.get_prompts(app.as_str())
|
||||
}
|
||||
|
||||
@@ -299,13 +304,20 @@ impl PromptService {
|
||||
/// 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> {
|
||||
let guard = lock_instruction_files()?;
|
||||
Self::reconcile_pi_native_under_guard(state.db.as_ref(), &guard)
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_pi_native_under_guard(
|
||||
db: &Database,
|
||||
guard: &PiInstructionFileGuard,
|
||||
) -> 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())?;
|
||||
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
||||
let mut prompts = db.get_prompts(AppType::Pi.as_str())?;
|
||||
for prompt in prompts.values_mut() {
|
||||
prompt.enabled = false;
|
||||
}
|
||||
@@ -349,11 +361,9 @@ impl PromptService {
|
||||
.enabled = true;
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &prompts)?;
|
||||
db.save_prompt_selection(AppType::Pi.as_str(), &prompts)?;
|
||||
let verified =
|
||||
PiPromptFileService::read_under_guard(&guard, PiPromptFileKind::GlobalContext)?;
|
||||
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
|
||||
if verified.revision == snapshot.revision {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -518,7 +528,7 @@ fn ensure_pi_library_projection_matches(
|
||||
}
|
||||
|
||||
fn restore_pi_prompt_file(
|
||||
guard: &std::sync::MutexGuard<'static, ()>,
|
||||
guard: &PiInstructionFileGuard,
|
||||
published: &PiPromptFileSnapshot,
|
||||
previous: &PiPromptFileSnapshot,
|
||||
) -> Result<(), AppError> {
|
||||
|
||||
@@ -5266,35 +5266,18 @@ impl ProviderService {
|
||||
app_type: AppType,
|
||||
updates: Vec<ProviderSortUpdate>,
|
||||
) -> Result<bool, AppError> {
|
||||
// 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.
|
||||
// Validate the whole payload before entering the app-specific
|
||||
// ordering boundary.
|
||||
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))?;
|
||||
if matches!(app_type, AppType::Pi) {
|
||||
return PiCatalogCoordinator::update_route_order(state, updates);
|
||||
}
|
||||
state.db.update_provider_sort_index(&updates)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
+287
-30
@@ -18,6 +18,7 @@ use crate::services::provider::{
|
||||
provider_to_mutation_input, reconcile_provider_record_with_precondition,
|
||||
write_live_with_common_config, ReconcilePrecondition,
|
||||
};
|
||||
use crate::services::{pi_prompt_files::lock_instruction_files_async, prompt::PromptService};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::{
|
||||
@@ -27,6 +28,9 @@ use std::sync::{
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||||
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
|
||||
|
||||
@@ -73,6 +77,8 @@ pub struct ProxyService {
|
||||
pi_runtime: Arc<PiRuntimeStore>,
|
||||
pi_server_sequence: Arc<AtomicU64>,
|
||||
pi_listener: Arc<StdRwLock<Option<PiListenerIdentity>>>,
|
||||
#[cfg(test)]
|
||||
fail_next_pi_reconcile: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -119,6 +125,8 @@ impl ProxyService {
|
||||
pi_runtime: Arc::new(PiRuntimeStore::default()),
|
||||
pi_server_sequence: Arc::new(AtomicU64::new(0)),
|
||||
pi_listener: Arc::new(StdRwLock::new(None)),
|
||||
#[cfg(test)]
|
||||
fail_next_pi_reconcile: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +570,13 @@ impl ProxyService {
|
||||
self.pi_runtime.begin_mutation().await
|
||||
}
|
||||
|
||||
pub(crate) async fn close_pi_runtime_at_epoch(
|
||||
&self,
|
||||
catalog_epoch: u64,
|
||||
) -> Result<(), AppError> {
|
||||
self.pi_runtime.close(catalog_epoch).await
|
||||
}
|
||||
|
||||
pub(crate) fn project_pi_provider_value(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
@@ -597,6 +612,12 @@ impl ProxyService {
|
||||
&self,
|
||||
catalog_epoch: u64,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
#[cfg(test)]
|
||||
if self.fail_next_pi_reconcile.swap(false, Ordering::AcqRel) {
|
||||
return Err(AppError::Config(
|
||||
"injected Pi runtime reconciliation failure".to_string(),
|
||||
));
|
||||
}
|
||||
if !crate::settings::pi_takeover_enabled() {
|
||||
self.pi_runtime.close(catalog_epoch).await?;
|
||||
return Ok(Vec::new());
|
||||
@@ -629,11 +650,45 @@ impl ProxyService {
|
||||
Ok(build.direct_only_provider_ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fail_next_pi_reconcile_for_test(&self) {
|
||||
self.fail_next_pi_reconcile.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(crate) async fn reconcile_pi_runtime(&self) -> Result<Vec<String>, AppError> {
|
||||
let epoch = self.pi_runtime.begin_mutation().await;
|
||||
self.reconcile_pi_runtime_at_epoch(epoch).await
|
||||
}
|
||||
|
||||
/// Publish a DB-only catalog ordering without closing admission or
|
||||
/// touching the native projection. The caller holds Pi's switch lock and
|
||||
/// must restore the DB order if this preparation fails.
|
||||
pub(crate) async fn publish_pi_runtime_order(&self) -> Result<(), AppError> {
|
||||
if !crate::settings::pi_takeover_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let listener = self
|
||||
.pi_listener
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
.ok_or_else(|| {
|
||||
AppError::Conflict(
|
||||
"Pi takeover is desired but the loopback listener is unavailable".to_string(),
|
||||
)
|
||||
})?;
|
||||
let epoch = self.pi_runtime.next_even_epoch()?;
|
||||
let build = build_pi_runtime(
|
||||
self.db.as_ref(),
|
||||
listener.server_generation,
|
||||
epoch,
|
||||
&listener.gateway_origin,
|
||||
crate::settings::get_pi_gateway_token()?,
|
||||
crate::settings::get_pi_app_proxy_config(),
|
||||
)?;
|
||||
self.pi_runtime.publish(build.snapshot).await
|
||||
}
|
||||
|
||||
fn restore_pi_direct_projection_at(
|
||||
&self,
|
||||
models_path: &std::path::Path,
|
||||
@@ -663,10 +718,34 @@ impl ProxyService {
|
||||
next.pi_config_dir.as_deref(),
|
||||
)?;
|
||||
|
||||
if old_models_path == new_models_path || !existing.pi_takeover_enabled {
|
||||
if old_models_path == new_models_path {
|
||||
return crate::settings::update_settings(next);
|
||||
}
|
||||
|
||||
// Prompt operations and directory ownership share one sendable mutex.
|
||||
// Holding it across runtime publication prevents a prompt write from
|
||||
// committing against the new root while a failed directory move is
|
||||
// rolling settings and the DB selection back to the old root.
|
||||
let prompt_guard = lock_instruction_files_async().await;
|
||||
let previous_prompts = self.db.get_prompts(AppType::Pi.as_str())?;
|
||||
|
||||
if !existing.pi_takeover_enabled {
|
||||
crate::settings::update_settings(next)?;
|
||||
if let Err(error) =
|
||||
PromptService::reconcile_pi_native_under_guard(self.db.as_ref(), &prompt_guard)
|
||||
{
|
||||
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
|
||||
let prompts_restored = self
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}"
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Admission closes before the old native file is restored. A Pi
|
||||
// process that already loaded the old gateway projection therefore
|
||||
// cannot enter a catalog whose directory ownership is in flight.
|
||||
@@ -689,18 +768,36 @@ impl ProxyService {
|
||||
}));
|
||||
}
|
||||
|
||||
if let Err(error) =
|
||||
PromptService::reconcile_pi_native_under_guard(self.db.as_ref(), &prompt_guard)
|
||||
{
|
||||
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
|
||||
let prompts_restored = self
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
let runtime_restored = self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok();
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to reconcile Pi prompts in the new directory: {error}; rollback: settings={settings_restored}, prompts={prompts_restored}, gateway={runtime_restored}"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Err(error) = self.reconcile_pi_runtime_at_epoch(epoch).await {
|
||||
let new_direct_restored = self
|
||||
.restore_pi_direct_projection_at(&new_models_path)
|
||||
.is_ok();
|
||||
let settings_restored = crate::settings::update_settings(existing.clone()).is_ok();
|
||||
let prompts_restored = self
|
||||
.db
|
||||
.save_prompt_selection(AppType::Pi.as_str(), &previous_prompts)
|
||||
.is_ok();
|
||||
let old_gateway_restored = if settings_restored {
|
||||
self.reconcile_pi_runtime_at_epoch(epoch).await.is_ok()
|
||||
} else {
|
||||
self.pi_runtime.republish_current(epoch).await.is_ok()
|
||||
};
|
||||
return Err(AppError::Config(format!(
|
||||
"failed to publish Pi in the new native directory: {error}; rollback: new_direct={new_direct_restored}, settings={settings_restored}, old_gateway={old_gateway_restored}"
|
||||
"failed to publish Pi in the new native directory: {error}; rollback: new_direct={new_direct_restored}, settings={settings_restored}, prompts={prompts_restored}, old_gateway={old_gateway_restored}"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -886,23 +983,9 @@ impl ProxyService {
|
||||
"Pi desired takeover could not be reconciled after listener start: {error}; direct-mode rollback failed, so the live listener was retained: {rollback_error}"
|
||||
));
|
||||
}
|
||||
let server = self.server.write().await.take();
|
||||
*self
|
||||
.pi_listener
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
let stopped = match server {
|
||||
Some(server) => server.stop().await.map_err(|error| error.to_string()),
|
||||
None => Ok(()),
|
||||
};
|
||||
return Err(match stopped {
|
||||
Ok(()) => format!(
|
||||
"Pi desired takeover could not be reconciled after listener start; direct mode was restored: {error}"
|
||||
),
|
||||
Err(stopped) => format!(
|
||||
"Pi desired takeover could not be reconciled after listener start: {error}; direct mode was restored but listener shutdown failed: {stopped}"
|
||||
),
|
||||
});
|
||||
return Err(format!(
|
||||
"Pi desired takeover could not be reconciled after listener start; direct mode was restored and the shared listener was retained: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,6 +1185,24 @@ impl ProxyService {
|
||||
// OpenCode and OpenClaw don't support proxy features, always return false
|
||||
let opencode_enabled = false;
|
||||
let openclaw_enabled = false;
|
||||
let pi_enabled = crate::settings::pi_takeover_enabled();
|
||||
let pi_operational_state = if !pi_enabled {
|
||||
PiTakeoverOperationalState::Disabled
|
||||
} else {
|
||||
let listener = self
|
||||
.pi_listener
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
if listener
|
||||
.as_ref()
|
||||
.is_some_and(|listener| self.pi_runtime.is_admitting(listener.server_generation))
|
||||
{
|
||||
PiTakeoverOperationalState::Active
|
||||
} else {
|
||||
PiTakeoverOperationalState::Degraded
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ProxyTakeoverStatus {
|
||||
claude: claude_enabled,
|
||||
@@ -1110,7 +1211,8 @@ impl ProxyService {
|
||||
grokbuild: grokbuild_enabled,
|
||||
opencode: opencode_enabled,
|
||||
openclaw: openclaw_enabled,
|
||||
pi: crate::settings::pi_takeover_enabled(),
|
||||
pi: pi_enabled,
|
||||
pi_operational_state,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1310,6 +1412,12 @@ impl ProxyService {
|
||||
async fn set_pi_takeover_locked(&self, enabled: bool) -> Result<(), String> {
|
||||
if enabled {
|
||||
let was_enabled = crate::settings::pi_takeover_enabled();
|
||||
if !was_enabled {
|
||||
// Desired intent is durable before bind/token/projection work.
|
||||
// Operational failures remain retryable on the next startup.
|
||||
crate::settings::set_pi_takeover_enabled(true)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
if !self.is_running().await {
|
||||
self.start_with_pi_lock_held().await?;
|
||||
}
|
||||
@@ -1319,15 +1427,21 @@ impl ProxyService {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.is_none()
|
||||
{
|
||||
let epoch = self.pi_runtime.begin_mutation().await;
|
||||
let _ = self.pi_runtime.close(epoch).await;
|
||||
return Err(
|
||||
"Pi gateway requires the proxy listener to bind an explicit loopback address"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// Persist the stable secret before changing desired state. A
|
||||
// failure here leaves both native projection and admission intact.
|
||||
crate::settings::get_or_create_pi_gateway_token().map_err(|error| error.to_string())?;
|
||||
crate::settings::set_pi_takeover_enabled(true).map_err(|error| error.to_string())?;
|
||||
// Token creation is stable and occurs only after a listener exists.
|
||||
if let Err(error) = crate::settings::get_or_create_pi_gateway_token() {
|
||||
if !was_enabled {
|
||||
let epoch = self.pi_runtime.begin_mutation().await;
|
||||
let _ = self.pi_runtime.close(epoch).await;
|
||||
}
|
||||
return Err(error.to_string());
|
||||
}
|
||||
|
||||
if let Err(error) = self.reconcile_pi_runtime().await {
|
||||
if was_enabled {
|
||||
@@ -1347,16 +1461,15 @@ impl ProxyService {
|
||||
let direct_projection = self.restore_pi_direct_projection();
|
||||
match direct_projection {
|
||||
Ok(()) => {
|
||||
let desired_restored = crate::settings::set_pi_takeover_enabled(false);
|
||||
let epoch = self.pi_runtime.begin_mutation().await;
|
||||
let admission_closed = self.pi_runtime.close(epoch).await;
|
||||
return Err(if desired_restored.is_ok() && admission_closed.is_ok() {
|
||||
return Err(if admission_closed.is_ok() {
|
||||
format!(
|
||||
"failed to publish Pi gateway catalog; direct mode was restored: {error}"
|
||||
"failed to publish Pi gateway catalog; direct mode was restored and desired takeover remains pending: {error}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"failed to publish Pi gateway catalog and fully restore direct mode: {error}"
|
||||
"failed to publish Pi gateway catalog and close Pi admission: {error}"
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -1367,7 +1480,7 @@ impl ProxyService {
|
||||
// explicit instead of claiming a successful rollback
|
||||
// while models.json still points local.
|
||||
let epoch = self.pi_runtime.begin_mutation().await;
|
||||
let _ = self.pi_runtime.republish_current(epoch).await;
|
||||
let _ = self.pi_runtime.close(epoch).await;
|
||||
return Err(format!(
|
||||
"failed to publish Pi gateway catalog: {error}; failed to restore the native direct projection: {restore_error}"
|
||||
));
|
||||
@@ -3984,6 +4097,8 @@ mod tests {
|
||||
let new_dir = home.dir.path().join("new-pi");
|
||||
std::fs::create_dir_all(&old_dir).expect("old Pi directory");
|
||||
std::fs::create_dir_all(&new_dir).expect("new Pi directory");
|
||||
std::fs::write(old_dir.join("AGENTS.md"), "old-root-agents").expect("old Pi AGENTS.md");
|
||||
std::fs::write(new_dir.join("AGENTS.md"), "new-root-agents").expect("new Pi AGENTS.md");
|
||||
|
||||
let direct = json!({
|
||||
"name": "Managed Pi",
|
||||
@@ -4008,6 +4123,32 @@ mod tests {
|
||||
crate::settings::update_settings(settings).expect("set old Pi directory");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("in-memory database"));
|
||||
db.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&crate::prompt::Prompt {
|
||||
id: "old-root".to_string(),
|
||||
name: "Old root".to_string(),
|
||||
content: "old-root-agents".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
created_at: Some(1),
|
||||
updated_at: Some(1),
|
||||
},
|
||||
)
|
||||
.expect("old prompt");
|
||||
db.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&crate::prompt::Prompt {
|
||||
id: "new-root".to_string(),
|
||||
name: "New root".to_string(),
|
||||
content: "new-root-agents".to_string(),
|
||||
description: None,
|
||||
enabled: false,
|
||||
created_at: Some(2),
|
||||
updated_at: Some(2),
|
||||
},
|
||||
)
|
||||
.expect("new prompt");
|
||||
use_ephemeral_proxy_port(&db).await;
|
||||
let input = ProviderMutationInput {
|
||||
id: "managed-pi".to_string(),
|
||||
@@ -4029,7 +4170,7 @@ mod tests {
|
||||
)
|
||||
.expect("seed Pi catalog");
|
||||
|
||||
let service = ProxyService::new(db);
|
||||
let service = ProxyService::new(db.clone());
|
||||
service
|
||||
.set_takeover_for_app("pi", true)
|
||||
.await
|
||||
@@ -4070,6 +4211,18 @@ mod tests {
|
||||
crate::settings::get_settings().pi_config_dir.as_deref(),
|
||||
Some(new_dir.to_string_lossy().as_ref())
|
||||
);
|
||||
let prompts = db.get_prompts(AppType::Pi.as_str()).expect("Pi prompts");
|
||||
assert!(!prompts["old-root"].enabled);
|
||||
assert!(prompts["new-root"].enabled);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(old_dir.join("AGENTS.md")).expect("old AGENTS"),
|
||||
"old-root-agents",
|
||||
"directory changes must not migrate or clean the old native file"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(new_dir.join("AGENTS.md")).expect("new AGENTS"),
|
||||
"new-root-agents"
|
||||
);
|
||||
|
||||
service
|
||||
.set_takeover_for_app("pi", false)
|
||||
@@ -4077,6 +4230,60 @@ mod tests {
|
||||
.expect("disable Pi takeover");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn changing_pi_directory_without_takeover_reconciles_missing_agents_truth() {
|
||||
let home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload isolated settings");
|
||||
let old_dir = home.dir.path().join("old-direct-pi");
|
||||
let new_dir = home.dir.path().join("new-direct-pi");
|
||||
std::fs::create_dir_all(&old_dir).expect("old Pi directory");
|
||||
std::fs::create_dir_all(&new_dir).expect("new Pi directory");
|
||||
std::fs::write(old_dir.join("AGENTS.md"), "old-only").expect("old AGENTS");
|
||||
|
||||
let mut settings = crate::settings::get_settings();
|
||||
settings.pi_config_dir = Some(old_dir.to_string_lossy().into_owned());
|
||||
settings.pi_takeover_enabled = false;
|
||||
crate::settings::update_settings(settings).expect("old directory settings");
|
||||
|
||||
let db = Arc::new(Database::memory().expect("database"));
|
||||
db.save_prompt(
|
||||
AppType::Pi.as_str(),
|
||||
&crate::prompt::Prompt {
|
||||
id: "old-only".to_string(),
|
||||
name: "Old only".to_string(),
|
||||
content: "old-only".to_string(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
created_at: Some(1),
|
||||
updated_at: Some(1),
|
||||
},
|
||||
)
|
||||
.expect("prompt");
|
||||
let service = ProxyService::new(db.clone());
|
||||
let switch_guard = service.lock_switch_for_app(AppType::Pi.as_str()).await;
|
||||
let existing = crate::settings::get_settings();
|
||||
let mut next = existing.clone();
|
||||
next.pi_config_dir = Some(new_dir.to_string_lossy().into_owned());
|
||||
service
|
||||
.replace_settings_with_pi_directory_boundary_under_lock(&switch_guard, &existing, next)
|
||||
.await
|
||||
.expect("change direct Pi directory");
|
||||
|
||||
assert!(
|
||||
db.get_prompts(AppType::Pi.as_str())
|
||||
.expect("prompts")
|
||||
.values()
|
||||
.all(|prompt| !prompt.enabled),
|
||||
"a missing AGENTS.md in the new root is the inactive authority"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(old_dir.join("AGENTS.md")).expect("old AGENTS survives"),
|
||||
"old-only"
|
||||
);
|
||||
assert!(!new_dir.join("AGENTS.md").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn failed_listener_rebind_recovers_pi_on_the_previous_listener() {
|
||||
@@ -4199,6 +4406,56 @@ mod tests {
|
||||
.expect("disable Pi takeover");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn initial_pi_bind_failure_keeps_desired_state_and_reports_degraded() {
|
||||
let _home = TempHome::new();
|
||||
crate::settings::reload_settings().expect("reload isolated settings");
|
||||
let mut settings = crate::settings::get_settings();
|
||||
settings.pi_config_dir = Some(
|
||||
crate::config::get_home_dir()
|
||||
.join(".pi/agent")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
settings.pi_takeover_enabled = false;
|
||||
crate::settings::update_settings(settings).expect("Pi settings");
|
||||
|
||||
let occupied = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.expect("reserve port");
|
||||
let db = Arc::new(Database::memory().expect("database"));
|
||||
let mut proxy_config = db.get_proxy_config().await.expect("proxy config");
|
||||
proxy_config.listen_address = "127.0.0.1".to_string();
|
||||
proxy_config.listen_port = occupied.local_addr().expect("address").port();
|
||||
db.update_proxy_config(proxy_config)
|
||||
.await
|
||||
.expect("fixed occupied port");
|
||||
let service = ProxyService::new(db);
|
||||
|
||||
service
|
||||
.set_takeover_for_app("pi", true)
|
||||
.await
|
||||
.expect_err("occupied port must fail");
|
||||
assert!(
|
||||
crate::settings::pi_takeover_enabled(),
|
||||
"bind failure must not erase user intent"
|
||||
);
|
||||
let status = service.get_takeover_status().await.expect("status");
|
||||
assert!(status.pi);
|
||||
assert_eq!(
|
||||
status.pi_operational_state,
|
||||
PiTakeoverOperationalState::Degraded
|
||||
);
|
||||
assert!(!service.is_running().await);
|
||||
|
||||
drop(occupied);
|
||||
service
|
||||
.set_takeover_for_app("pi", false)
|
||||
.await
|
||||
.expect("explicit disable clears desired state");
|
||||
}
|
||||
|
||||
fn seed_codex_model_template() {
|
||||
let codex_dir = crate::codex_config::get_codex_config_dir();
|
||||
std::fs::create_dir_all(&codex_dir).expect("create codex dir");
|
||||
|
||||
@@ -1117,6 +1117,14 @@ pub(crate) fn get_or_create_pi_gateway_token() -> Result<GatewayToken, AppError>
|
||||
token.ok_or_else(|| AppError::Config("无法创建 Pi 网关凭据".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_pi_gateway_token() -> Result<GatewayToken, AppError> {
|
||||
get_settings().pi_gateway_token.ok_or_else(|| {
|
||||
AppError::Conflict(
|
||||
"Pi takeover is active but its gateway credential is unavailable".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()))?;
|
||||
|
||||
Reference in New Issue
Block a user