fix(pi): restore catalog and takeover authority boundaries

This commit is contained in:
SaladDay
2026-08-03 00:38:29 +00:00
parent 26447effa2
commit 9d0e75d56a
10 changed files with 925 additions and 96 deletions
+76
View File
@@ -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,
+3
View File
@@ -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(
+9 -1
View File
@@ -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)
+10
View File
@@ -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健康状态
+492 -25
View File
@@ -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");
}
}
+16 -9
View File
@@ -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> {
+19 -9
View File
@@ -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> {
+5 -22
View File
@@ -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
View File
@@ -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");
+8
View File
@@ -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()))?;