From 3a04e166c2a332bb7f06fe3c9577676059a966ff Mon Sep 17 00:00:00 2001 From: Zyy Date: Tue, 29 Sep 2026 20:56:10 +0800 Subject: [PATCH] refactor: clarify ownership and simplify plugin management Centralize audit classifications and retention policy, move cleanup orchestration to Host, and require explicit plugin lifecycle implementations. Remove unused interfaces and redundant state, type-check the standalone plugin bridge, and split management flows by responsibility. Update architecture boundaries and add focused regression coverage. Co-authored-by: Codex --- backend/apps/gateway/src/bootstrap.rs | 1 + backend/apps/gateway/tests/architecture.rs | 1 + .../crates/gateway-admin/src/backup/task.rs | 31 +- backend/crates/gateway-admin/src/lib.rs | 5 - .../crates/gateway-admin/src/model/audit.rs | 153 ++++++ .../gateway-admin/src/model/backup/mod.rs | 27 + backend/crates/gateway-admin/src/model/mod.rs | 2 + .../gateway-admin/src/model/retention.rs | 41 ++ .../crates/gateway-admin/src/ports/backup.rs | 10 +- backend/crates/gateway-admin/src/ports/mod.rs | 1 + .../crates/gateway-admin/src/ports/plugins.rs | 47 +- .../gateway-admin/src/ports/retention.rs | 23 + .../gateway-admin/src/use_case/settings.rs | 9 +- .../gateway-admin/tests/backup/support.rs | 19 +- .../crates/gateway-admin/tests/model/audit.rs | 69 +++ .../gateway-admin/tests/model/backup/mod.rs | 43 +- .../crates/gateway-admin/tests/model/mod.rs | 2 + .../gateway-admin/tests/model/retention.rs | 12 + .../tests/use_case/plugins/artifacts.rs | 38 ++ .../tests/use_case/plugins/instances.rs | 6 + .../tests/use_case/plugins/mod.rs | 38 ++ .../gateway-api/tests/admin/plugins/mod.rs | 24 + .../gateway-core/src/engine/coordinator.rs | 47 +- .../gateway-core/src/engine/execution.rs | 11 +- .../gateway-core/src/engine/middleware.rs | 2 +- backend/crates/gateway-core/src/engine/mod.rs | 37 +- .../gateway-core/src/engine/observation.rs | 107 ++-- .../gateway-core/src/engine/provider.rs | 6 - backend/crates/gateway-core/src/error.rs | 19 +- backend/crates/gateway-core/src/operation.rs | 22 +- .../gateway-core/tests/engine/coordinator.rs | 33 +- .../gateway-core/tests/engine/execution.rs | 7 +- .../gateway-core/tests/engine/middleware.rs | 4 - backend/crates/gateway-core/tests/error.rs | 24 +- backend/crates/gateway-host/src/lib.rs | 1 + backend/crates/gateway-host/src/retention.rs | 141 +++++ backend/crates/gateway-host/tests/main.rs | 1 + .../crates/gateway-host/tests/retention.rs | 146 ++++++ .../runtime/src/adapter/observer.rs | 4 +- .../runtime/src/adapter/scope.rs | 11 +- .../runtime/src/generation/prepare/mod.rs | 6 + .../runtime/tests/generation/observer.rs | 39 +- .../runtime/tests/generation/policy/mod.rs | 6 - .../runtime/tests/generation/prepare/mod.rs | 112 ++-- .../runtime/tests/generation/private_state.rs | 4 + .../crates/gateway-store/src/admin_adapter.rs | 31 +- backend/crates/gateway-store/src/bundle.rs | 8 +- backend/crates/gateway-store/src/lib.rs | 2 +- .../src/postgres/account_groups.rs | 19 +- .../src/postgres/admin_security_audit.rs | 12 + .../gateway-store/src/postgres/backup.rs | 17 +- .../src/postgres/client_budgets.rs | 4 +- .../gateway-store/src/postgres/client_keys.rs | 18 +- .../src/postgres/plugins/artifacts.rs | 10 +- .../src/postgres/plugins/credentials.rs | 10 +- .../src/postgres/plugins/instances.rs | 10 +- .../src/postgres/plugins/resources.rs | 45 +- .../src/postgres/plugins/sources.rs | 7 +- .../provider_accounts/admin_adapter.rs | 61 +-- .../provider_accounts/authorization.rs | 10 +- .../gateway-store/src/postgres/proxies.rs | 15 +- .../gateway-store/src/postgres/retention.rs | 252 ++------- backend/crates/gateway-store/src/value.rs | 45 +- backend/crates/gateway-store/src/workers.rs | 48 -- .../gateway-store/tests/postgres/backup.rs | 17 +- .../gateway-store/tests/postgres/retention.rs | 73 +-- .../gateway-store/tests/redis/worker_lease.rs | 6 +- .../providers/xai/tests/provider/contract.rs | 3 - docs/architecture.md | 15 +- frontend/build/plugin-bridge.ts | 45 ++ frontend/env.d.ts | 5 + frontend/src/api/modules/plugins.ts | 9 - .../plugins/composables/usePluginActions.ts | 36 ++ .../composables/usePluginArtifactActions.ts | 38 ++ .../composables/usePluginCredentials.ts | 52 ++ .../plugins/composables/usePluginInstances.ts | 199 +++++++ .../composables/usePluginManagement.ts | 489 ++---------------- .../plugins/composables/usePluginVersions.ts | 146 ++++++ .../plugins/management/bridge-contract.ts | 37 ++ .../plugins/management/bridge-runtime.ts | 475 +++++++++++++++++ .../src/views/plugins/management/frame.ts | 399 +------------- frontend/src/views/plugins/utils/model.ts | 46 -- frontend/src/views/proxies/utils/location.ts | 10 +- frontend/tsconfig.node.json | 2 +- frontend/vite.config.ts | 3 +- 85 files changed, 2392 insertions(+), 1729 deletions(-) create mode 100644 backend/crates/gateway-admin/src/model/audit.rs create mode 100644 backend/crates/gateway-admin/src/model/retention.rs create mode 100644 backend/crates/gateway-admin/src/ports/retention.rs create mode 100644 backend/crates/gateway-admin/tests/model/audit.rs create mode 100644 backend/crates/gateway-admin/tests/model/retention.rs create mode 100644 backend/crates/gateway-host/src/retention.rs create mode 100644 backend/crates/gateway-host/tests/retention.rs create mode 100644 frontend/build/plugin-bridge.ts create mode 100644 frontend/src/views/plugins/composables/usePluginActions.ts create mode 100644 frontend/src/views/plugins/composables/usePluginArtifactActions.ts create mode 100644 frontend/src/views/plugins/composables/usePluginCredentials.ts create mode 100644 frontend/src/views/plugins/composables/usePluginInstances.ts create mode 100644 frontend/src/views/plugins/composables/usePluginVersions.ts create mode 100644 frontend/src/views/plugins/management/bridge-contract.ts create mode 100644 frontend/src/views/plugins/management/bridge-runtime.ts diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index 30dc5cf7a..9f5a4dcd1 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -378,6 +378,7 @@ async fn launch( ); let mut plan = store.take_worker_contributions(); + plan.push(gateway_host::retention::worker(store.retention())?); plan.extend(core.take_worker_contributions()); plan.extend(openai.take_worker_contributions()); plan.extend(xai.take_worker_contributions()); diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index b2361a325..2bd3cf7d4 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -210,6 +210,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[ "plugin_distribution", "process", "proxy_probe", + "retention", "serve", "system_update", "workers", diff --git a/backend/crates/gateway-admin/src/backup/task.rs b/backend/crates/gateway-admin/src/backup/task.rs index 3c7a02989..10a4393bd 100644 --- a/backend/crates/gateway-admin/src/backup/task.rs +++ b/backend/crates/gateway-admin/src/backup/task.rs @@ -16,8 +16,8 @@ use tracing::{info, warn}; use crate::{ model::backup::{ - BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig, - BackupTriggerKind, build_backup_seed, code, + BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition, + BackupStorageConfig, BackupTriggerKind, build_backup_seed, code, }, ports::backup::{ BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest, @@ -224,8 +224,7 @@ impl BackupTask { .repository .transition_status( &record.id, - BackupStatus::Dumping, - BackupStatus::Uploading, + status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?, update, now, ) @@ -274,8 +273,7 @@ impl BackupTask { self.repository .transition_status( &record.id, - BackupStatus::Uploading, - BackupStatus::Completed, + status_transition(BackupStatus::Uploading, BackupStatus::Completed)?, StatusTransitionUpdate::default(), now, ) @@ -402,8 +400,7 @@ impl BackupTask { .repository .transition_status( &record.id, - BackupStatus::Dumping, - BackupStatus::Uploading, + status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?, update, now, ) @@ -471,8 +468,7 @@ impl BackupTask { .repository .transition_status( &record.id, - BackupStatus::Uploading, - BackupStatus::Completed, + status_transition(BackupStatus::Uploading, BackupStatus::Completed)?, StatusTransitionUpdate::default(), Utc::now(), ) @@ -555,7 +551,12 @@ impl BackupTask { }; let _ = self .repository - .transition_status(&record.id, record.status, BackupStatus::Failed, update, now) + .transition_status( + &record.id, + status_transition(record.status, BackupStatus::Failed)?, + update, + now, + ) .await .map_err(repo_error)?; let _ = self.dump.cleanup_staging(&record.id).await; @@ -639,6 +640,14 @@ fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) { } } +fn status_transition( + from: BackupStatus, + to: BackupStatus, +) -> Result { + BackupStatusTransition::try_new(from, to) + .ok_or_else(|| WorkerTaskError::safe("backup status transition is invalid")) +} + fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError { WorkerTaskError::safe(format!("backup store failed: {error}")) } diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index 9d48315aa..de83ac6f1 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -272,11 +272,6 @@ impl AdminServices { self.observability.as_ref() } - #[must_use] - pub fn settings_handle(&self) -> Arc { - self.settings.clone() - } - pub fn settings(&self) -> &dyn SettingsService { self.settings.as_ref() } diff --git a/backend/crates/gateway-admin/src/model/audit.rs b/backend/crates/gateway-admin/src/model/audit.rs new file mode 100644 index 000000000..3ed6f230b --- /dev/null +++ b/backend/crates/gateway-admin/src/model/audit.rs @@ -0,0 +1,153 @@ +//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。 + +use super::{ + MutationActor, MutationContext, + auth::{AdminAuditEvent, AuditActorKind, admin_session_actor_ref}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MutationAuditOperation { + ModelPricingSync, + ModelPricingUpdate, + RuntimeSettingsReplace, + AdminApiKeyChanged { exists: bool }, + AccountGroupCreate, + AccountGroupUpdate, + AccountGroupEnabled { enabled: bool }, + AccountGroupDelete, + ClientApiKeyResetBudget, + ProviderAccountUpdate, + BackupStorageUpdate, + BackupScheduleUpdate, + ProviderAccountAdaptConcurrency, + ProviderAccountRecover, + ProviderAccountBatchUpdate, + ProviderAccountDelete, + ProviderAccountExportCredentials, + PluginArtifactInstall, + PluginArtifactAccept, + PluginArtifactDelete, + PluginInstanceConfigure, + PluginInstanceDelete, + PluginSourceCredentialCreate, + PluginSourceCredentialDelete, + ClientApiKeyUpdateBudgetLimits, + ClientApiKeyCreate, + ClientApiKeyUpdate, + ClientApiKeyEnabled { enabled: bool }, + ClientApiKeyDelete, + PluginSourceChangeSource, + PluginSourceDelete, + OutboundProxyCreate, + OutboundProxyUpdate, + OutboundProxyDelete, + OutboundProxyTest, + AccountGroupPluginReconcile, + ClientApiKeyPluginReconcile, + ProviderAccountImportDocument, + ProviderAccountRotateCredential, + ProviderAccountRefreshCredential, + ProviderAccountAuthorize, + ProviderAccountReauthorize, +} + +impl MutationAuditOperation { + #[must_use] + pub const fn classification(self) -> (&'static str, &'static str) { + match self { + Self::ModelPricingSync => ("pricing.sync", "model_pricing"), + Self::ModelPricingUpdate => ("pricing.update", "model_pricing"), + Self::RuntimeSettingsReplace => ("settings.replace", "runtime_settings"), + Self::AdminApiKeyChanged { exists } => ( + if exists { + "admin_api_key.replace" + } else { + "admin_api_key.delete" + }, + "runtime_settings", + ), + Self::AccountGroupCreate => ("create", "account_group"), + Self::AccountGroupUpdate => ("update", "account_group"), + Self::AccountGroupEnabled { enabled } => { + (if enabled { "enable" } else { "disable" }, "account_group") + } + Self::AccountGroupDelete => ("delete", "account_group"), + Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"), + Self::ProviderAccountUpdate => ("update", "provider_account"), + Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"), + Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"), + Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"), + Self::ProviderAccountRecover => ("recover", "provider_account"), + Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"), + Self::ProviderAccountDelete => ("delete", "provider_account"), + Self::ProviderAccountExportCredentials => ("export_credentials", "provider_account"), + Self::PluginArtifactInstall => ("install", "plugin_artifact"), + Self::PluginArtifactAccept => ("accept", "plugin_artifact"), + Self::PluginArtifactDelete => ("delete", "plugin_artifact"), + Self::PluginInstanceConfigure => ("configure", "plugin_instance"), + Self::PluginInstanceDelete => ("delete", "plugin_instance"), + Self::PluginSourceCredentialCreate => ("create", "plugin_source_credential"), + Self::PluginSourceCredentialDelete => ("delete", "plugin_source_credential"), + Self::ClientApiKeyUpdateBudgetLimits => ("update_budget_limits", "client_api_key"), + Self::ClientApiKeyCreate => ("create", "client_api_key"), + Self::ClientApiKeyUpdate => ("update", "client_api_key"), + Self::ClientApiKeyEnabled { enabled } => { + (if enabled { "enable" } else { "disable" }, "client_api_key") + } + Self::ClientApiKeyDelete => ("delete", "client_api_key"), + Self::PluginSourceChangeSource => ("change_source", "plugin_source"), + Self::PluginSourceDelete => ("delete", "plugin_source"), + Self::OutboundProxyCreate => ("create", "outbound_proxy"), + Self::OutboundProxyUpdate => ("update", "outbound_proxy"), + Self::OutboundProxyDelete => ("delete", "outbound_proxy"), + Self::OutboundProxyTest => ("test", "outbound_proxy"), + Self::AccountGroupPluginReconcile => ("plugin_reconcile", "account_group"), + Self::ClientApiKeyPluginReconcile => ("plugin_reconcile", "client_api_key"), + Self::ProviderAccountImportDocument => ("import_document", "provider_account"), + Self::ProviderAccountRotateCredential => ("rotate_credential", "provider_account"), + Self::ProviderAccountRefreshCredential => ("refresh_credential", "provider_account"), + Self::ProviderAccountAuthorize => ("authorize", "provider_account"), + Self::ProviderAccountReauthorize => ("reauthorize", "provider_account"), + } + } +} + +/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。 +#[derive(Debug, Clone, Copy)] +pub struct MutationAuditIntent<'a> { + pub operation: MutationAuditOperation, + pub entity_ref: &'a str, +} + +impl MutationAuditIntent<'_> { + #[must_use] + pub fn event(self, context: &MutationContext, changed_fields: Vec) -> AdminAuditEvent { + let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor { + MutationActor::AdminSession { admin_user_id } => ( + AuditActorKind::AdminSession, + Some(admin_user_id.clone()), + admin_session_actor_ref(admin_user_id), + ), + MutationActor::AdminApiKey => ( + AuditActorKind::AdminApiKey, + None, + "admin_api_key".to_owned(), + ), + MutationActor::System => (AuditActorKind::System, None, "system".to_owned()), + }; + let (action, entity_kind) = self.operation.classification(); + AdminAuditEvent { + id: format!("audit_{}", uuid::Uuid::now_v7().simple()), + actor_kind, + actor_admin_user_id, + actor_ref, + request_id: Some(context.request_id.clone()), + action: action.to_owned(), + entity_kind: entity_kind.to_owned(), + entity_ref: self.entity_ref.to_owned(), + config_revision: None, + changed_fields, + occurred_at: chrono::Utc::now(), + } + } +} diff --git a/backend/crates/gateway-admin/src/model/backup/mod.rs b/backend/crates/gateway-admin/src/model/backup/mod.rs index ec5e185c1..ba9360c16 100644 --- a/backend/crates/gateway-admin/src/model/backup/mod.rs +++ b/backend/crates/gateway-admin/src/model/backup/mod.rs @@ -124,6 +124,33 @@ impl fmt::Display for BackupStatus { } } +/// 已通过领域状态机校验的显式迁移。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackupStatusTransition { + from: BackupStatus, + to: BackupStatus, +} + +impl BackupStatusTransition { + /// 校验并构造显式迁移;非法状态边返回 `None`。 + #[must_use] + pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option { + from.allows_transition_to(to).then_some(Self { from, to }) + } + + /// 迁移要求的当前状态。 + #[must_use] + pub const fn from(self) -> BackupStatus { + self.from + } + + /// 迁移完成后的目标状态。 + #[must_use] + pub const fn to(self) -> BackupStatus { + self.to + } +} + /// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。 #[derive(Clone)] pub struct UpdateBackupStorageCommand { diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index 3f2ad1f11..cf05e381c 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -4,6 +4,7 @@ use std::num::{NonZeroU16, NonZeroU64}; pub mod account_groups; pub mod accounts; +pub mod audit; pub mod auth; pub mod backup; pub mod client_distribution; @@ -19,6 +20,7 @@ pub mod provider_credentials; pub mod proxies; pub mod quota_forecast; pub mod quota_forecast_sampling; +pub mod retention; pub mod settings; pub mod system; diff --git a/backend/crates/gateway-admin/src/model/retention.rs b/backend/crates/gateway-admin/src/model/retention.rs new file mode 100644 index 000000000..60085feac --- /dev/null +++ b/backend/crates/gateway-admin/src/model/retention.rs @@ -0,0 +1,41 @@ +//! 历史数据保留规则;存储实现只接受已校验的窗口。 + +use super::AdminError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetentionTarget { + ModelRequests, + OpsEvents, + AdminAuditEvents, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RetentionPolicy { + usage_days: u32, + ops_days: u32, + audit_days: u32, +} + +impl RetentionPolicy { + pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result { + if usage_days < 31 || ops_days == 0 || audit_days == 0 { + return Err(AdminError::invalid( + "请求保留期至少 31 天,事件与审计保留期必须为正数", + )); + } + Ok(Self { + usage_days, + ops_days, + audit_days, + }) + } + + #[must_use] + pub const fn days(self, target: RetentionTarget) -> u32 { + match target { + RetentionTarget::ModelRequests => self.usage_days, + RetentionTarget::OpsEvents => self.ops_days, + RetentionTarget::AdminAuditEvents => self.audit_days, + } + } +} diff --git a/backend/crates/gateway-admin/src/ports/backup.rs b/backend/crates/gateway-admin/src/ports/backup.rs index c99abf044..8c524aa94 100644 --- a/backend/crates/gateway-admin/src/ports/backup.rs +++ b/backend/crates/gateway-admin/src/ports/backup.rs @@ -17,8 +17,8 @@ use crate::model::{ MutationContext, backup::{ BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage, - BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult, - UpdateBackupScheduleCommand, UpdateBackupStorageCommand, + BackupRecordSeed, BackupSettings, BackupStatusTransition, BackupStorageConfig, + ConnectionTestResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand, }, }; use crate::ports::store::AdminStoreResult; @@ -96,8 +96,7 @@ pub trait BackupRepository: Send + Sync { async fn transition_status( &self, id: &str, - from: BackupStatus, - to: BackupStatus, + transition: BackupStatusTransition, update: StatusTransitionUpdate, now: DateTime, ) -> AdminStoreResult>; @@ -333,8 +332,7 @@ impl BackupRepository for UnavailableBackupRepository { async fn transition_status( &self, _id: &str, - _from: BackupStatus, - _to: BackupStatus, + _transition: BackupStatusTransition, _update: StatusTransitionUpdate, _now: DateTime, ) -> AdminStoreResult> { diff --git a/backend/crates/gateway-admin/src/ports/mod.rs b/backend/crates/gateway-admin/src/ports/mod.rs index f085998c0..96b2f4448 100644 --- a/backend/crates/gateway-admin/src/ports/mod.rs +++ b/backend/crates/gateway-admin/src/ports/mod.rs @@ -11,5 +11,6 @@ pub mod plugins; pub mod pricing; pub mod provider; pub mod proxy; +pub mod retention; pub mod store; pub mod system; diff --git a/backend/crates/gateway-admin/src/ports/plugins.rs b/backend/crates/gateway-admin/src/ports/plugins.rs index f2c0c673c..2a7eb3991 100644 --- a/backend/crates/gateway-admin/src/ports/plugins.rs +++ b/backend/crates/gateway-admin/src/ports/plugins.rs @@ -28,7 +28,7 @@ use gateway_core::runtime::extensions::ExtensionSetReference; /// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。 #[async_trait] -pub trait PluginPreparation: Send + Sync { +pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle { /// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。 async fn configuration_ready( &self, @@ -44,36 +44,33 @@ pub trait PluginPreparation: Send + Sync { &self, snapshot: PluginInstanceSnapshot, ) -> Result; - /// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。 +} + +/// 运行诊断独立于发布准备;没有进程事实时显式返回 None。 +#[async_trait] +pub trait PluginRuntimeDiagnostics: Send + Sync { async fn runtime_diagnostics( &self, - _snapshot: &PluginInstanceSnapshot, - _published_revision: Option, - _published: Option<&ExtensionSetReference>, - ) -> Option> { - None - } + snapshot: &PluginInstanceSnapshot, + published_revision: Option, + published: Option<&ExtensionSetReference>, + ) -> Option>; +} + +/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期。 +#[async_trait] +pub trait PluginStateLifecycle: Send + Sync { async fn activate_state( &self, - _prepared: &ExtensionSetReference, - _instance: &PluginInstance, - ) -> Result<(), AdminError> { - Ok(()) - } - async fn quiesce_instance( - &self, - _instance_id: &str, - _artifact_sha256: &str, - _revision: Revision, - ) { - } + prepared: &ExtensionSetReference, + instance: &PluginInstance, + ) -> Result<(), AdminError>; + async fn quiesce_instance(&self, instance_id: &str, artifact_sha256: &str, revision: Revision); async fn migrate_state( &self, - _prepared: &ExtensionSetReference, - _transition: PluginStateTransition, - ) -> Result<(), AdminError> { - Err(AdminError::unavailable("插件状态迁移运行时不可用")) - } + prepared: &ExtensionSetReference, + transition: PluginStateTransition, + ) -> Result<(), AdminError>; } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/backend/crates/gateway-admin/src/ports/retention.rs b/backend/crates/gateway-admin/src/ports/retention.rs new file mode 100644 index 000000000..a4b111d17 --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/retention.rs @@ -0,0 +1,23 @@ +//! 有界保留期删除,不承载周期、暂停或重试策略。 + +use std::num::NonZeroU32; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; + +use super::store::AdminStoreResult; +use crate::model::retention::{RetentionPolicy, RetentionTarget}; + +#[async_trait] +pub trait RetentionStore: Send + Sync { + async fn load_policy(&self) -> AdminStoreResult; + + /// 每次最多删除 limit 行;请求关联事件随请求事务级联删除。 + async fn purge_batch( + &self, + target: RetentionTarget, + now: DateTime, + policy: RetentionPolicy, + limit: NonZeroU32, + ) -> AdminStoreResult; +} diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index 5a1a89470..08382ab5a 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -111,9 +111,12 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> && command.max_waiting_per_key <= 1_000 && command.max_waiting_per_account <= 1_000 && (1..=120).contains(&command.concurrency_wait_timeout_seconds) - && command.usage_retention_days >= 31 - && command.ops_event_retention_days > 0 - && command.audit_retention_days > 0 + && crate::model::retention::RetentionPolicy::try_new( + command.usage_retention_days, + command.ops_event_retention_days, + command.audit_retention_days, + ) + .is_ok() && valid_client_version(command.min_codex_desktop_version.as_deref()) && valid_client_version(command.min_codex_cli_version.as_deref()) && valid_probe_model(command.account_auto_freeze_probe_model.as_deref()) diff --git a/backend/crates/gateway-admin/tests/backup/support.rs b/backend/crates/gateway-admin/tests/backup/support.rs index 1a8d62ef5..99b658929 100644 --- a/backend/crates/gateway-admin/tests/backup/support.rs +++ b/backend/crates/gateway-admin/tests/backup/support.rs @@ -14,9 +14,9 @@ use gateway_admin::{ auth::AdminAuditEvent, backup::{ BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, - BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, - BackupTriggerKind, ConnectionTestResult, UpdateBackupScheduleCommand, - UpdateBackupStorageCommand, + BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, + BackupStatusTransition, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult, + UpdateBackupScheduleCommand, UpdateBackupStorageCommand, }, }, ports::{ @@ -330,19 +330,18 @@ impl BackupRepository for FakeBackupRepository { async fn transition_status( &self, id: &str, - from: BackupStatus, - to: BackupStatus, + transition: BackupStatusTransition, update: StatusTransitionUpdate, now: DateTime, ) -> AdminStoreResult> { let mut records = self.records.lock().expect("records"); let Some(record) = records .iter_mut() - .find(|record| record.id == id && record.status == from) + .find(|record| record.id == id && record.status == transition.from()) else { return Ok(None); }; - record.status = to; + record.status = transition.to(); if let Some(size) = update.size_bytes { record.size_bytes = Some(size); } @@ -356,7 +355,11 @@ impl BackupRepository for FakeBackupRepository { record.error_message = Some(message); } record.completed_at = update.completed_at.or_else(|| { - matches!(to, BackupStatus::Completed | BackupStatus::Failed).then_some(now) + matches!( + transition.to(), + BackupStatus::Completed | BackupStatus::Failed + ) + .then_some(now) }); record.updated_at = now; Ok(Some(record.clone())) diff --git a/backend/crates/gateway-admin/tests/model/audit.rs b/backend/crates/gateway-admin/tests/model/audit.rs new file mode 100644 index 000000000..45d086d76 --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/audit.rs @@ -0,0 +1,69 @@ +use gateway_admin::model::{ + MutationActor, MutationContext, + audit::{MutationAuditIntent, MutationAuditOperation}, + auth::AuditActorKind, +}; + +#[test] +fn mutation_intent_preserves_actor_request_and_committed_field_names() { + let intent = MutationAuditIntent { + operation: MutationAuditOperation::PluginInstanceConfigure, + entity_ref: "instance-1", + }; + for (actor, expected_kind, expected_user) in [ + ( + MutationActor::AdminSession { + admin_user_id: "admin-1".to_owned(), + }, + AuditActorKind::AdminSession, + Some("admin-1"), + ), + ( + MutationActor::AdminApiKey, + AuditActorKind::AdminApiKey, + None, + ), + (MutationActor::System, AuditActorKind::System, None), + ] { + let event = intent.event( + &MutationContext { + actor, + request_id: "request-1".to_owned(), + }, + vec!["configuration_json".to_owned()], + ); + assert_eq!(event.actor_kind, expected_kind); + assert_eq!(event.actor_admin_user_id.as_deref(), expected_user); + assert_eq!(event.request_id.as_deref(), Some("request-1")); + assert_eq!( + (event.action.as_str(), event.entity_kind.as_str()), + ("configure", "plugin_instance") + ); + assert_eq!(event.entity_ref, "instance-1"); + assert_eq!(event.changed_fields, ["configuration_json"]); + assert_eq!(event.config_revision, None); + } +} + +#[test] +fn resulting_state_selects_the_existing_audit_classification() { + for enabled in [true, false] { + let action = if enabled { "enable" } else { "disable" }; + assert_eq!( + MutationAuditOperation::AccountGroupEnabled { enabled }.classification(), + (action, "account_group"), + ); + assert_eq!( + MutationAuditOperation::ClientApiKeyEnabled { enabled }.classification(), + (action, "client_api_key"), + ); + } + assert_eq!( + MutationAuditOperation::AdminApiKeyChanged { exists: false }.classification(), + ("admin_api_key.delete", "runtime_settings"), + ); + assert_eq!( + MutationAuditOperation::AdminApiKeyChanged { exists: true }.classification(), + ("admin_api_key.replace", "runtime_settings"), + ); +} diff --git a/backend/crates/gateway-admin/tests/model/backup/mod.rs b/backend/crates/gateway-admin/tests/model/backup/mod.rs index c3fe2e772..5984a89ff 100644 --- a/backend/crates/gateway-admin/tests/model/backup/mod.rs +++ b/backend/crates/gateway-admin/tests/model/backup/mod.rs @@ -4,19 +4,42 @@ use chrono::{TimeZone as _, Utc}; use secrecy::SecretString; use gateway_admin::model::backup::{ - BackupSettings, BackupStatus, BackupStorageConfig, BackupTriggerKind, build_object_key, + BackupSettings, BackupStatus, BackupStatusTransition, BackupStorageConfig, BackupTriggerKind, + build_object_key, }; #[test] -fn status_machine_rejects_invalid_transitions() { - assert!(BackupStatus::Queued.allows_transition_to(BackupStatus::Dumping)); - assert!(BackupStatus::Dumping.allows_transition_to(BackupStatus::Uploading)); - assert!(BackupStatus::Uploading.allows_transition_to(BackupStatus::Completed)); - assert!(BackupStatus::Completed.allows_transition_to(BackupStatus::Deleting)); - assert!(!BackupStatus::Queued.allows_transition_to(BackupStatus::Completed)); - assert!(!BackupStatus::Completed.allows_transition_to(BackupStatus::Dumping)); - assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Failed)); - assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Completed)); +fn status_transition_accepts_every_legal_edge() { + let transitions = [ + (BackupStatus::Queued, BackupStatus::Dumping), + (BackupStatus::Queued, BackupStatus::Failed), + (BackupStatus::Dumping, BackupStatus::Uploading), + (BackupStatus::Dumping, BackupStatus::Failed), + (BackupStatus::Uploading, BackupStatus::Completed), + (BackupStatus::Uploading, BackupStatus::Failed), + (BackupStatus::Completed, BackupStatus::Deleting), + (BackupStatus::Failed, BackupStatus::Deleting), + ]; + assert!( + transitions + .into_iter() + .all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_some() }) + ); +} + +#[test] +fn status_transition_rejects_invalid_edges() { + let transitions = [ + (BackupStatus::Queued, BackupStatus::Completed), + (BackupStatus::Completed, BackupStatus::Dumping), + (BackupStatus::Deleting, BackupStatus::Failed), + (BackupStatus::Deleting, BackupStatus::Completed), + ]; + assert!( + transitions + .into_iter() + .all(|(from, to)| { BackupStatusTransition::try_new(from, to).is_none() }) + ); } #[test] diff --git a/backend/crates/gateway-admin/tests/model/mod.rs b/backend/crates/gateway-admin/tests/model/mod.rs index 5f7898549..369634178 100644 --- a/backend/crates/gateway-admin/tests/model/mod.rs +++ b/backend/crates/gateway-admin/tests/model/mod.rs @@ -1,7 +1,9 @@ //! 模型模块测试:镜像 src/model/ 中带逻辑的模块。 +mod audit; mod backup; mod plugins; mod provider_credentials; mod quota_forecast; mod quota_forecast_sampling; +mod retention; diff --git a/backend/crates/gateway-admin/tests/model/retention.rs b/backend/crates/gateway-admin/tests/model/retention.rs new file mode 100644 index 000000000..58d1ddbe5 --- /dev/null +++ b/backend/crates/gateway-admin/tests/model/retention.rs @@ -0,0 +1,12 @@ +use gateway_admin::model::retention::{RetentionPolicy, RetentionTarget}; + +#[test] +fn retention_policy_rejects_invalid_windows_and_preserves_independent_windows() { + for (usage, ops, audit) in [(30, 1, 1), (31, 0, 1), (31, 1, 0)] { + assert!(RetentionPolicy::try_new(usage, ops, audit).is_err()); + } + let policy = RetentionPolicy::try_new(31, 7, 90).unwrap(); + assert_eq!(policy.days(RetentionTarget::ModelRequests), 31); + assert_eq!(policy.days(RetentionTarget::OpsEvents), 7); + assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90); +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs index 21913a943..2af6e4e0b 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs @@ -95,6 +95,44 @@ impl ExtensionSetLease for Lease { } } +#[async_trait] +impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for Fixture { + async fn runtime_diagnostics( + &self, + _: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot, + _: Option, + _: Option<&gateway_core::runtime::extensions::ExtensionSetReference>, + ) -> Option< + std::collections::BTreeMap< + String, + gateway_admin::model::plugins::instances::PluginInstanceRuntime, + >, + > { + None + } +} + +#[async_trait] +impl gateway_admin::ports::plugins::PluginStateLifecycle for Fixture { + async fn activate_state( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::instances::PluginInstance, + ) -> Result<(), AdminError> { + Ok(()) + } + async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) { + panic!("unexpected instance drain") + } + async fn migrate_state( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: gateway_admin::model::plugins::state::PluginStateTransition, + ) -> Result<(), AdminError> { + panic!("unexpected state migration") + } +} + #[async_trait] impl PluginPreparation for Fixture { async fn configuration_ready( diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs index f52b5a773..f00bf6dd2 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs @@ -242,7 +242,10 @@ impl PluginPreparation for LifecycleFixture { Arc::new(Lease), )) } +} +#[async_trait] +impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for LifecycleFixture { async fn runtime_diagnostics( &self, _: &PluginInstanceSnapshot, @@ -251,7 +254,10 @@ impl PluginPreparation for LifecycleFixture { ) -> Option> { self.data.lock().unwrap().diagnostics.clone() } +} +#[async_trait] +impl gateway_admin::ports::plugins::PluginStateLifecycle for LifecycleFixture { async fn activate_state( &self, _: &ExtensionSetReference, diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs b/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs index b5b4210ec..6c511fd63 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs @@ -163,6 +163,44 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor } } +#[async_trait] +impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts { + async fn runtime_diagnostics( + &self, + _: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot, + _: Option, + _: Option<&gateway_core::runtime::extensions::ExtensionSetReference>, + ) -> Option< + std::collections::BTreeMap< + String, + gateway_admin::model::plugins::instances::PluginInstanceRuntime, + >, + > { + None + } +} + +#[async_trait] +impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts { + async fn activate_state( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::instances::PluginInstance, + ) -> Result<(), AdminError> { + panic!("unexpected state activation") + } + async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) { + panic!("unexpected instance drain") + } + async fn migrate_state( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: gateway_admin::model::plugins::state::PluginStateTransition, + ) -> Result<(), AdminError> { + panic!("unexpected state migration") + } +} + #[async_trait] impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts { async fn configuration_ready( diff --git a/backend/crates/gateway-api/tests/admin/plugins/mod.rs b/backend/crates/gateway-api/tests/admin/plugins/mod.rs index fbd4b1d17..62fe5e645 100644 --- a/backend/crates/gateway-api/tests/admin/plugins/mod.rs +++ b/backend/crates/gateway-api/tests/admin/plugins/mod.rs @@ -605,6 +605,27 @@ impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPor } } +#[async_trait] +impl gateway_admin::ports::plugins::PluginStateLifecycle for TestPluginPorts { + async fn activate_state( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::instances::PluginInstance, + ) -> Result<(), AdminError> { + Ok(()) + } + async fn quiesce_instance(&self, _: &str, _: &str, _: gateway_admin::model::Revision) { + panic!("unexpected instance drain") + } + async fn migrate_state( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: gateway_admin::model::plugins::state::PluginStateTransition, + ) -> Result<(), AdminError> { + panic!("unexpected state migration") + } +} + #[async_trait] impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts { async fn configuration_ready( @@ -627,7 +648,10 @@ impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts { ) -> Result { Err(AdminError::invalid("unused plugin fixture")) } +} +#[async_trait] +impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts { async fn runtime_diagnostics( &self, snapshot: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot, diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index 26ef5b65c..572bc4332 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -23,7 +23,7 @@ use crate::engine::{ CommitRequirement, ContinuationAttempt, CoordinatedEvent, EngineError, ExecutionOutcome, ExecutionStore, GatewayEngine, IntermediateFailure, ModelRequestFailureObservation, ModelRequestFinalization, ModelRequestId, NewModelRequest, ProviderAccountStateOwner, - ProviderAttemptOutcome, RequestAttemptContext, UpstreamSendState, + RequestAttemptContext, UpstreamSendState, }; use crate::error::{ ContinuationRecoveryDisposition, GatewayError, GatewayErrorKind, ProviderError, @@ -356,7 +356,7 @@ where image_generation_requested, last_retryable_failure: None, last_retryable_failure_events: Vec::new(), - provider_attempt_outcomes: Vec::new(), + last_observed_provider: None, pending_terminal_failure: None, }; @@ -485,7 +485,7 @@ pub struct ResponseExecutionSession { last_retryable_failure: Option, /// 与 `last_retryable_failure` 同属一个 attempt 的原始失败批次。 last_retryable_failure_events: Vec, - provider_attempt_outcomes: Vec, + last_observed_provider: Option, /// 原子失败批次已交给协议层、但尚待下游提交后收敛的原 Provider 错误。 pending_terminal_failure: Option, } @@ -719,14 +719,6 @@ where .unwrap_or(Decimal::ZERO) } - /// 返回截至当前已完成的实际上游调用结果。 - /// - /// 调用方可保存已消费下标;该切片在会话生命周期内只追加、不改写。 - #[must_use] - pub fn provider_attempt_outcomes(&self) -> &[ProviderAttemptOutcome] { - &self.provider_attempt_outcomes - } - /// 返回最终选中 attempt 已公开给协议层的安全响应头。 #[must_use] pub fn response_headers(&self) -> &[ProviderResponseHeader] { @@ -1176,7 +1168,7 @@ where | ProviderErrorKind::ConcurrencyQueueTimeout ) && error.send_state() == UpstreamSendState::NotSent) { - self.record_provider_failure(candidate.provider().clone(), error.kind()); + self.record_provider_failure(candidate.provider().clone()); } self.finish_provider_error(&error).await?; return Err(provider_engine_error(error)); @@ -1185,7 +1177,7 @@ where }; if !stream.metadata().confirms(&candidate) { drop(stream); - self.record_provider_failure(candidate.provider().clone(), ProviderErrorKind::Protocol); + self.record_provider_failure(candidate.provider().clone()); let error = GatewayError::new( GatewayErrorKind::Internal, "provider metadata did not match the frozen candidate", @@ -1435,7 +1427,7 @@ where self.observe_websocket_response(event, websocket_attempt.clone()); } } - self.record_provider_failure(current.metadata.provider().clone(), error.kind()); + self.record_provider_failure(current.metadata.provider().clone()); // attempt_send_state 是本 attempt 自身的发送事实;共享 effect 单独作为 // 一票否决的重试门。持久化与终态用请求级水位,不能把早先 Provider attempt // 的 sent 传染给当前 attempt,但任何已观测外部副作用都必须阻止重放。 @@ -1619,9 +1611,9 @@ where self.observation.observe_event(fact); } } - // 普通 clone 只保留稳定事实;原始 wire/HTTP response 由 request-local - // 所有权保留到下一次 attempt 成功,或最终空选路时返回客户端。 - let persistence_error = self.request_persisted.then(|| error.clone()); + // 原始 wire/HTTP response 由 request-local 所有权保留到下一次 attempt + // 成功,或最终空选路时返回客户端;持久化只取得稳定事实快照。 + let persistence_error = self.request_persisted.then(|| error.stable_snapshot()); if same_account_retry { let account = current.metadata.provider_account_id().clone(); self.credential_recovery_attempted_accounts @@ -2103,11 +2095,7 @@ where .current .as_ref() .map(|current| current.metadata.provider().clone()) - .or_else(|| { - self.provider_attempt_outcomes - .last() - .map(|outcome| outcome.provider_kind().clone()) - }); + .or_else(|| self.last_observed_provider.clone()); let observer = self.request_observation.clone(); let observation = observer.as_ref().map(|observer| { observer.finalization( @@ -2248,21 +2236,12 @@ where .as_ref() .map(|current| current.metadata.provider().clone()); if let Some(provider_kind) = provider_kind { - self.provider_attempt_outcomes - .push(ProviderAttemptOutcome::Succeeded { provider_kind }); + self.last_observed_provider = Some(provider_kind); } } - fn record_provider_failure( - &mut self, - provider_kind: crate::identity::ProviderKind, - error_kind: ProviderErrorKind, - ) { - self.provider_attempt_outcomes - .push(ProviderAttemptOutcome::Failed { - provider_kind, - error_kind, - }); + fn record_provider_failure(&mut self, provider_kind: crate::identity::ProviderKind) { + self.last_observed_provider = Some(provider_kind); } } diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index f0886de19..268061cb2 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -38,7 +38,8 @@ use crate::engine::nested::{ NestedModelExecutionRequest, }; use crate::engine::observation::{ - FrozenRequestObservationContext, RequestObservationDispatch, RequestObserverExtensionIndex, + FrozenRequestObservationContext, RequestObservationDispatch, RequestObservationScope, + RequestObserverExtensionIndex, }; use crate::engine::policy::{ ModelRouteDecision, RequestPolicyContext, RequestPolicyExtensionIndex, @@ -1100,8 +1101,10 @@ impl DefaultExecutionService { FrozenRequestObservationContext::new( request_id.clone(), request.client.snapshot.revision(), - request.client.policy.key_id().clone(), - account_group_ids.clone(), + RequestObservationScope::new( + request.client.policy.key_id().clone(), + account_group_ids.clone(), + ), request.operation.kind(), request.target.public_model().cloned(), authorization.extension_scope.clone(), @@ -2097,7 +2100,7 @@ impl DefaultExecutionService { provider_kind: observed.provider_kind.clone(), account_id: observed.account_id.clone(), upstream_model_id: observed.upstream_model.clone(), - error: provider_error.clone(), + error: provider_error.stable_snapshot(), latency, }) .await diff --git a/backend/crates/gateway-core/src/engine/middleware.rs b/backend/crates/gateway-core/src/engine/middleware.rs index 96ba5f7b3..7ba65cb30 100644 --- a/backend/crates/gateway-core/src/engine/middleware.rs +++ b/backend/crates/gateway-core/src/engine/middleware.rs @@ -216,7 +216,7 @@ impl MiddlewareRequest { effective = effective.require(*feature); } operation - .with_middleware_requirements(original, effective) + .with_inherited_capability_requirements(effective) .map_err(|_| MiddlewareError::InvalidState) } diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index 19668a942..7167019b4 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -34,9 +34,7 @@ use crate::account::{ ProviderAccountId, }; use crate::engine::continuation::{ContinuationBinding, NativeContinuationPin}; -use crate::error::{ - GatewayError, ProviderConnectionObservation, ProviderError, ProviderErrorKind, StoreError, -}; +use crate::error::{GatewayError, ProviderConnectionObservation, ProviderError, StoreError}; use crate::event::ProviderEvent; use crate::identity::ProviderKind; use crate::lifecycle::CancellationToken; @@ -123,39 +121,6 @@ impl AttemptTrigger { } } -/// 一次实际上游调用的诊断结果。 -/// -/// 该事实只描述调用结果,不参与跨请求的路由屏蔽。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ProviderAttemptOutcome { - /// 上游流自然完成且通过 canonical event 序列校验。 - Succeeded { provider_kind: ProviderKind }, - /// 上游打开或流式阶段返回了稳定 Provider 错误。 - Failed { - provider_kind: ProviderKind, - error_kind: ProviderErrorKind, - }, -} - -impl ProviderAttemptOutcome { - /// 返回本次调用实际归属的 Provider。 - #[must_use] - pub const fn provider_kind(&self) -> &ProviderKind { - match self { - Self::Succeeded { provider_kind } | Self::Failed { provider_kind, .. } => provider_kind, - } - } - - /// 成功返回 `None`,失败返回稳定 Provider 错误分类。 - #[must_use] - pub const fn error_kind(&self) -> Option { - match self { - Self::Succeeded { .. } => None, - Self::Failed { error_kind, .. } => Some(*error_kind), - } - } -} - /// 单次 attempt 的账号选择与账号绑定状态事实。 /// /// `required_account` 用于管理端 connection test,或请求局部的同账号恢复 attempt; diff --git a/backend/crates/gateway-core/src/engine/observation.rs b/backend/crates/gateway-core/src/engine/observation.rs index b572fe02e..ba9eac365 100644 --- a/backend/crates/gateway-core/src/engine/observation.rs +++ b/backend/crates/gateway-core/src/engine/observation.rs @@ -66,6 +66,35 @@ impl WebSocketResponseAttempt { } } +/// 请求观察使用的冻结 Client Key 与账号组范围。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestObservationScope { + client_key_id: ClientApiKeyId, + account_group_ids: Arc<[AccountGroupId]>, +} + +impl RequestObservationScope { + #[must_use] + pub fn new(client_key_id: ClientApiKeyId, mut account_group_ids: Vec) -> Self { + account_group_ids.sort(); + account_group_ids.dedup(); + Self { + client_key_id, + account_group_ids: account_group_ids.into(), + } + } + + #[must_use] + pub const fn client_key_id(&self) -> &ClientApiKeyId { + &self.client_key_id + } + + #[must_use] + pub fn account_group_ids(&self) -> &[AccountGroupId] { + &self.account_group_ids + } +} + /// 实际上游 WebSocket 响应事件的只读观察。 /// /// Key 与账号组只用于 Runtime 匹配冻结绑定,不得进入插件 wire;`wire` 保留策略加工前 @@ -75,8 +104,7 @@ pub struct WebSocketResponseObservation { event_id: String, request_id: ModelRequestId, config_revision: ConfigRevision, - client_key_id: Option, - account_group_ids: Arc<[AccountGroupId]>, + client_scope: RequestObservationScope, extension_scope: ExtensionCallScope, operation: OperationKind, requested_model: Option, @@ -86,11 +114,12 @@ pub struct WebSocketResponseObservation { } impl WebSocketResponseObservation { - /// 创建一个尚未附加冻结 Key/组/公开模型范围的实际上游事件。 + /// 创建一个已附加冻结 Key/组范围、尚未附加公开模型范围的实际上游事件。 #[must_use] pub fn new( request_id: ModelRequestId, config_revision: ConfigRevision, + client_scope: RequestObservationScope, operation: OperationKind, attempt: WebSocketResponseAttempt, sequence: u64, @@ -104,8 +133,7 @@ impl WebSocketResponseObservation { ), request_id, config_revision, - client_key_id: None, - account_group_ids: Arc::from([]), + client_scope, extension_scope: ExtensionCallScope::default(), operation, requested_model: None, @@ -115,19 +143,6 @@ impl WebSocketResponseObservation { } } - #[must_use] - pub fn with_client_scope( - mut self, - client_key_id: ClientApiKeyId, - mut account_group_ids: Vec, - ) -> Self { - account_group_ids.sort(); - account_group_ids.dedup(); - self.client_key_id = Some(client_key_id); - self.account_group_ids = account_group_ids.into(); - self - } - #[must_use] pub fn with_requested_model(mut self, model: PublicModelId) -> Self { self.requested_model = Some(model); @@ -166,13 +181,13 @@ impl WebSocketResponseObservation { } #[must_use] - pub const fn client_key_id(&self) -> Option<&ClientApiKeyId> { - self.client_key_id.as_ref() + pub const fn client_key_id(&self) -> &ClientApiKeyId { + self.client_scope.client_key_id() } #[must_use] pub fn account_group_ids(&self) -> &[AccountGroupId] { - &self.account_group_ids + self.client_scope.account_group_ids() } #[must_use] @@ -227,8 +242,7 @@ pub struct RequestObservation { event_id: String, request_id: ModelRequestId, config_revision: ConfigRevision, - client_key_id: Option, - account_group_ids: Arc<[AccountGroupId]>, + client_scope: RequestObservationScope, extension_scope: ExtensionCallScope, operation: OperationKind, requested_model: Option, @@ -251,11 +265,12 @@ pub struct RequestObservation { } impl RequestObservation { - /// 创建一个不带 Provider、模型、用量或错误详情的最终事实。 + /// 创建一个带冻结 Key/组范围、不带 Provider、模型、用量或错误详情的最终事实。 #[must_use] pub fn new( request_id: ModelRequestId, config_revision: ConfigRevision, + client_scope: RequestObservationScope, operation: OperationKind, outcome: RequestObservationOutcome, send_state: UpstreamSendState, @@ -265,8 +280,7 @@ impl RequestObservation { event_id: format!("{}:terminal", request_id.as_str()), request_id, config_revision, - client_key_id: None, - account_group_ids: Arc::from([]), + client_scope, extension_scope: ExtensionCallScope::default(), operation, requested_model: None, @@ -289,19 +303,6 @@ impl RequestObservation { } } - #[must_use] - pub fn with_client_scope( - mut self, - client_key_id: ClientApiKeyId, - mut account_group_ids: Vec, - ) -> Self { - account_group_ids.sort(); - account_group_ids.dedup(); - self.client_key_id = Some(client_key_id); - self.account_group_ids = account_group_ids.into(); - self - } - #[must_use] pub fn with_requested_model(mut self, model: PublicModelId) -> Self { self.requested_model = Some(model); @@ -414,13 +415,13 @@ impl RequestObservation { } #[must_use] - pub const fn client_key_id(&self) -> Option<&ClientApiKeyId> { - self.client_key_id.as_ref() + pub const fn client_key_id(&self) -> &ClientApiKeyId { + self.client_scope.client_key_id() } #[must_use] pub fn account_group_ids(&self) -> &[AccountGroupId] { - &self.account_group_ids + self.client_scope.account_group_ids() } #[must_use] @@ -568,8 +569,7 @@ struct RequestObservationDispatchState { pub(super) struct FrozenRequestObservationContext { request_id: ModelRequestId, config_revision: ConfigRevision, - client_key_id: ClientApiKeyId, - account_group_ids: Arc<[AccountGroupId]>, + client_scope: RequestObservationScope, operation: OperationKind, requested_model: Option, extension_scope: ExtensionCallScope, @@ -580,19 +580,15 @@ impl FrozenRequestObservationContext { pub(super) fn new( request_id: ModelRequestId, config_revision: ConfigRevision, - client_key_id: ClientApiKeyId, - mut account_group_ids: Vec, + client_scope: RequestObservationScope, operation: OperationKind, requested_model: Option, extension_scope: ExtensionCallScope, ) -> Self { - account_group_ids.sort(); - account_group_ids.dedup(); Self { request_id, config_revision, - client_key_id, - account_group_ids: account_group_ids.into(), + client_scope, operation, requested_model, extension_scope, @@ -622,8 +618,7 @@ impl RequestObservationDispatch { event_id: self.event_id(), request_id: self.state.context.request_id.clone(), config_revision: self.state.context.config_revision, - client_key_id: Some(self.state.context.client_key_id.clone()), - account_group_ids: Arc::clone(&self.state.context.account_group_ids), + client_scope: self.state.context.client_scope.clone(), extension_scope: self.state.context.extension_scope.clone(), operation: self.state.context.operation, requested_model: self.state.context.requested_model.clone(), @@ -655,15 +650,12 @@ impl RequestObservationDispatch { let mut observation = WebSocketResponseObservation::new( self.state.context.request_id.clone(), self.state.context.config_revision, + self.state.context.client_scope.clone(), self.state.context.operation, attempt, sequence, wire, ) - .with_client_scope( - self.state.context.client_key_id.clone(), - self.state.context.account_group_ids.to_vec(), - ) .with_extension_scope(self.state.context.extension_scope.clone()); if let Some(model) = self.state.context.requested_model.clone() { observation = observation.with_requested_model(model); @@ -700,8 +692,7 @@ impl RequestObservationDispatch { event_id: self.event_id(), request_id: self.state.context.request_id.clone(), config_revision: self.state.context.config_revision, - client_key_id: Some(self.state.context.client_key_id.clone()), - account_group_ids: Arc::clone(&self.state.context.account_group_ids), + client_scope: self.state.context.client_scope.clone(), extension_scope: self.state.context.extension_scope.clone(), operation: self.state.context.operation, requested_model: self.state.context.requested_model.clone(), diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index 66f120db1..01bef97ec 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -359,12 +359,6 @@ impl ProviderStream { self } - /// 返回本次 stream 是否需要原生响应转换。 - #[must_use] - pub const fn has_native_response_translator(&self) -> bool { - self.native_response_translator.is_some() - } - /// 在 Core 已记录原始事实且完成 `BeforeTranslation` 后执行原生转换。 /// /// # Errors diff --git a/backend/crates/gateway-core/src/error.rs b/backend/crates/gateway-core/src/error.rs index 3487dafd0..56bb201cd 100644 --- a/backend/crates/gateway-core/src/error.rs +++ b/backend/crates/gateway-core/src/error.rs @@ -356,7 +356,7 @@ impl ProviderConnectionObservation { /// 投影不能覆盖 [`ProviderError`] 中用于诊断和记账的真实上游事实。 /// /// 该值不属于稳定诊断事实,不能进入日志或持久化。它刻意不实现 [`Clone`]; -/// [`ProviderError`] 的普通 clone 会丢弃它,只有最终失败的原对象才能把响应交给 +/// [`ProviderError::stable_snapshot`] 会丢弃它,只有最终失败的原对象才能把响应交给 /// 原客户端协议,或由认证管理端的账号连接测试显式复制到请求局部结果。 #[derive(PartialEq, Eq)] pub struct ClientVisibleUpstreamResponse { @@ -577,11 +577,6 @@ impl ProviderError { self } - /// 原地标记 Provider 已证明本次失败可从已交付的状态检查点安全恢复。 - pub const fn set_replay_safe(&mut self) { - self.replay_safe = true; - } - /// 允许 Core 仅在客户端尚未收到任何事件时执行一次受预算约束的换号恢复。 /// /// 该标记不证明上游未执行,也不等价于 [`Self::with_replay_safe`];它只表达 @@ -850,8 +845,8 @@ impl ProviderError { /// 取走只供本次重试/提交决策使用的原子客户端事件。 /// - /// Core 必须在克隆错误、记录中间失败或构造终态前调用本方法,避免原始 wire - /// 进入诊断或持久化对象。 + /// Core 必须在创建稳定快照、记录中间失败或构造终态前调用本方法,避免原始 + /// wire 进入诊断或持久化对象。 #[must_use] pub fn take_atomic_client_events(&mut self) -> Vec { self.atomic_client_events @@ -864,10 +859,12 @@ impl ProviderError { pub fn has_atomic_client_events(&self) -> bool { self.atomic_client_events.is_some() } -} -impl Clone for ProviderError { - fn clone(&self) -> Self { + /// 复制可安全进入诊断和持久化边界的稳定错误事实。 + /// + /// 请求局部的原始上游响应与尚未提交的客户端事件不会进入快照。 + #[must_use] + pub fn stable_snapshot(&self) -> Self { Self { kind: self.kind, send_state: self.send_state, diff --git a/backend/crates/gateway-core/src/operation.rs b/backend/crates/gateway-core/src/operation.rs index 97986853b..b1387d8ac 100644 --- a/backend/crates/gateway-core/src/operation.rs +++ b/backend/crates/gateway-core/src/operation.rs @@ -403,7 +403,7 @@ struct GeneratePayload { protocol_payload: ProtocolPayload, provider_session_state: Option, source_requirements: Option, - middleware_requirements: Option<(CapabilityRequirements, CapabilityRequirements)>, + inherited_capability_requirements: Option, } impl GenerateRequest { @@ -418,7 +418,7 @@ impl GenerateRequest { protocol_payload, provider_session_state: None, source_requirements: None, - middleware_requirements: None, + inherited_capability_requirements: None, }), } } @@ -518,7 +518,7 @@ impl GenerateRequest { } let mut requirements = CapabilityRequirements::new(OperationKind::Generate) .with_requested_output_tokens(self.max_output_tokens()); - if let Some((_, inherited)) = &self.payload.middleware_requirements { + if let Some(inherited) = &self.payload.inherited_capability_requirements { for feature in inherited.features() { requirements = requirements.require(*feature); } @@ -873,9 +873,8 @@ pub enum Operation { } impl Operation { - pub(crate) fn with_middleware_requirements( + pub(crate) fn with_inherited_capability_requirements( self, - original: CapabilityRequirements, inherited: CapabilityRequirements, ) -> Result { let Self::Generate(mut request) = self else { @@ -883,21 +882,10 @@ impl Operation { field: "middleware capabilities", }); }; - Arc::make_mut(&mut request.payload).middleware_requirements = Some((original, inherited)); + Arc::make_mut(&mut request.payload).inherited_capability_requirements = Some(inherited); Ok(Self::Generate(request)) } - /// 请求转换前的需求仅供诊断,选路始终使用有效上游需求。 - #[must_use] - pub fn original_capability_requirements(&self) -> CapabilityRequirements { - if let Self::Generate(request) = self - && let Some((original, _)) = &request.payload.middleware_requirements - { - return original.clone(); - } - self.capability_requirements() - } - /// 把协议正文编码为中间件可见的原始字节;不包含非 wire context 或会话状态。 /// /// # Errors diff --git a/backend/crates/gateway-core/tests/engine/coordinator.rs b/backend/crates/gateway-core/tests/engine/coordinator.rs index f3fc8ca53..9814f1d8d 100644 --- a/backend/crates/gateway-core/tests/engine/coordinator.rs +++ b/backend/crates/gateway-core/tests/engine/coordinator.rs @@ -25,8 +25,7 @@ use gateway_core::engine::provider::{ use gateway_core::engine::{ AttemptContext, AttemptCoordinator, AttemptRecord, AttemptTransport, CommitRequirement, ContinuationAttempt, EngineError, ExecutionOutcome, ExecutionStore, GatewayEngine, - IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest, - ProviderAttemptOutcome, RecoveryReport, + IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest, RecoveryReport, }; use gateway_core::error::{ ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, ContinuationFailure, @@ -838,12 +837,6 @@ fn success_updates_one_model_request_and_persists_usage() { block_on(session.commit_downstream(Some(200))).expect("commit response"); assert!(session.is_finalized()); - assert_eq!( - session.provider_attempt_outcomes(), - &[ProviderAttemptOutcome::Succeeded { - provider_kind: ProviderKind::new("openai").expect("provider"), - }] - ); let state = store.state.lock().expect("store lock"); assert_eq!(state.created, 1); assert_eq!(state.attempts.len(), 1); @@ -2150,13 +2143,6 @@ fn required_account_disables_account_retry_after_stream_creation() { error, gateway_core::engine::EngineError::Provider(_) )); - assert_eq!( - session.provider_attempt_outcomes(), - &[ProviderAttemptOutcome::Failed { - provider_kind: ProviderKind::new("openai").expect("provider"), - error_kind: ProviderErrorKind::Unavailable, - }] - ); assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1); assert_eq!(provider.scripts.lock().expect("scripts lock").len(), 1); let state = store.state.lock().expect("store lock"); @@ -3911,13 +3897,6 @@ fn structural_event_before_replay_safe_failure_should_switch_account_before_comm assert_eq!(first.into_provider_events().len(), 1); block_on(session.commit_downstream(Some(200))).expect("commit first event"); block_on(session.next_event()).expect_err("committed stream failure must not be replayed"); - assert_eq!( - session.provider_attempt_outcomes(), - &[ProviderAttemptOutcome::Failed { - provider_kind: ProviderKind::new("openai").expect("provider"), - error_kind: ProviderErrorKind::Transport, - }] - ); assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1); let state = store.state.lock().expect("store lock"); assert_eq!(state.commits, 1); @@ -4000,7 +3979,6 @@ fn no_eligible_account_before_stream_records_failure_without_fabricating_an_atte gateway_core::engine::EngineError::Provider(ref error) if error.kind() == kind )); - assert!(session.provider_attempt_outcomes().is_empty()); assert!(session.is_finalized()); block_on(session.record_client_status(status)).expect("HTTP error status"); block_on(session.cancel_and_finalize()).expect("repeated finalization"); @@ -4265,7 +4243,6 @@ fn deadline_during_preparation_records_failure_without_an_upstream_attempt() { block_on(session.next_event()), Err(EngineError::Deadline) )); - assert!(session.provider_attempt_outcomes().is_empty()); assert!(session.is_finalized()); assert_eq!(provider.contexts.lock().unwrap().len(), 1); let state = store.state.lock().unwrap(); @@ -4534,7 +4511,7 @@ fn charged_terminal_write_resumes_without_replacing_success_failure_or_send_fact } #[test] -fn deadline_after_commit_is_incomplete_without_provider_circuit_failure() { +fn deadline_after_commit_is_persisted_as_incomplete() { let operation = generate_operation(); let route_plan = plan(&operation); let (coordinator, store, _) = coordinator(vec![Script::HangingStream { @@ -4565,16 +4542,13 @@ fn deadline_after_commit_is_incomplete_without_provider_circuit_failure() { let error = block_on(session.next_event()).expect_err("deadline elapses mid-stream"); assert!(matches!(error, EngineError::Deadline)); - // 网关自身请求预算到期不是上游超时;已在交付中的长流集中到期 - // 不得作为 provider Timeout 计入熔断。 - assert!(session.provider_attempt_outcomes().is_empty()); let state = store.state.lock().expect("store lock"); assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete); assert!(state.finalizations[0].committed); } #[test] -fn deadline_before_first_event_records_no_provider_circuit_failure() { +fn deadline_before_first_event_is_persisted_as_failed_attempt() { let operation = generate_operation(); let route_plan = plan(&operation); let (coordinator, store, _) = coordinator(vec![Script::HangingStream { @@ -4594,7 +4568,6 @@ fn deadline_before_first_event_records_no_provider_circuit_failure() { let error = block_on(session.next_event()).expect_err("deadline elapses before first event"); assert!(matches!(error, EngineError::Deadline)); - assert!(session.provider_attempt_outcomes().is_empty()); let state = store.state.lock().expect("store lock"); assert_eq!(state.attempts.len(), 1); assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed); diff --git a/backend/crates/gateway-core/tests/engine/execution.rs b/backend/crates/gateway-core/tests/engine/execution.rs index 81dcbb701..f3162d2b5 100644 --- a/backend/crates/gateway-core/tests/engine/execution.rs +++ b/backend/crates/gateway-core/tests/engine/execution.rs @@ -791,7 +791,7 @@ impl Provider for RetryTestProvider { ); Ok(ProviderStream::new( metadata, - futures::stream::iter([Err(self.error.clone())]), + futures::stream::iter([Err(self.error.stable_snapshot())]), (), )) } @@ -2457,10 +2457,7 @@ fn final_observation_is_emitted_once_for_success_rejection_and_detached_cancella } fn assert_observation_scope(observation: &RequestObservation) { - assert_eq!( - observation.client_key_id().map(ClientApiKeyId::as_str), - Some("key_start_test") - ); + assert_eq!(observation.client_key_id().as_str(), "key_start_test"); assert_eq!( observation .account_group_ids() diff --git a/backend/crates/gateway-core/tests/engine/middleware.rs b/backend/crates/gateway-core/tests/engine/middleware.rs index fcc4f0ecb..96ef05f7a 100644 --- a/backend/crates/gateway-core/tests/engine/middleware.rs +++ b/backend/crates/gateway-core/tests/engine/middleware.rs @@ -202,10 +202,6 @@ fn capability_declaration_preserves_original_semantics_and_recomputes_upstream_n let operation = request .apply_capabilities(body_operation(&converted)) .unwrap(); - assert_eq!( - operation.original_capability_requirements(), - body_operation(&original).capability_requirements() - ); assert_eq!( operation.capability_requirements().features(), &[Feature::Tools, Feature::Reasoning].into() diff --git a/backend/crates/gateway-core/tests/error.rs b/backend/crates/gateway-core/tests/error.rs index 928fc1875..34e8720a1 100644 --- a/backend/crates/gateway-core/tests/error.rs +++ b/backend/crates/gateway-core/tests/error.rs @@ -27,15 +27,15 @@ fn provider_error_debug_should_not_print_classified_upstream_values() { } #[test] -fn classified_provider_diagnostic_survives_clone_without_entering_debug() { +fn classified_provider_diagnostic_survives_stable_snapshot_without_entering_debug() { let message = "OpenAI WebSocket closed before terminal response (close code 1000)"; let error = ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous) .with_diagnostic( ProviderDiagnostic::new(message) .with_classification("receive", "closed_before_terminal"), ); - let cloned = error.clone(); - let gateway = GatewayError::from_provider(&cloned); + let snapshot = error.stable_snapshot(); + let gateway = GatewayError::from_provider(&snapshot); assert_eq!( gateway.diagnostic().map(ProviderDiagnostic::as_str), @@ -54,14 +54,14 @@ fn classified_provider_diagnostic_survives_clone_without_entering_debug() { } #[test] -fn raw_upstream_error_survives_clone_without_entering_debug() { +fn raw_upstream_error_survives_stable_snapshot_without_entering_debug() { let raw = r#"{"error":{"message":"verbatim upstream marker"}}"#; let error = ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent) .with_raw_upstream_error(RawUpstreamError::new(raw)); - let cloned = error.clone(); + let snapshot = error.stable_snapshot(); assert_eq!( - cloned.raw_upstream_error().map(RawUpstreamError::as_str), + snapshot.raw_upstream_error().map(RawUpstreamError::as_str), Some(raw) ); assert!(!format!("{error:?}").contains("verbatim upstream marker")); @@ -112,7 +112,7 @@ fn provider_error_atomic_client_events_should_be_take_only_and_debug_redacted() } #[test] -fn provider_error_clone_should_drop_request_local_upstream_response() { +fn provider_error_stable_snapshot_should_drop_request_local_upstream_response() { let marker = Bytes::from_static(b"raw-client-response-only"); let error = ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent) .with_client_visible_upstream_response( @@ -127,7 +127,7 @@ fn provider_error_clone_should_drop_request_local_upstream_response() { )]), ); - let cloned = error.clone(); + let snapshot = error.stable_snapshot(); assert_eq!( error @@ -135,13 +135,13 @@ fn provider_error_clone_should_drop_request_local_upstream_response() { .map(ClientVisibleUpstreamResponse::body), Some(&marker) ); - assert!(cloned.client_visible_upstream_response().is_none()); + assert!(snapshot.client_visible_upstream_response().is_none()); assert!(!format!("{error:?}").contains("raw-client-response-only")); assert!(!format!("{error:?}").contains("opaque-header-value")); } #[test] -fn provider_error_clone_should_drop_atomic_client_events() { +fn provider_error_stable_snapshot_should_drop_atomic_client_events() { let event = ProviderEvent::wire( ProtocolWireEvent::json( "openai", @@ -152,10 +152,10 @@ fn provider_error_clone_should_drop_atomic_client_events() { ); let error = ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent) .with_atomic_client_events(vec![event]); - let cloned = error.clone(); + let snapshot = error.stable_snapshot(); assert!(error.has_atomic_client_events()); - assert!(!cloned.has_atomic_client_events()); + assert!(!snapshot.has_atomic_client_events()); } #[test] diff --git a/backend/crates/gateway-host/src/lib.rs b/backend/crates/gateway-host/src/lib.rs index 28a2afb57..fb5f51d1f 100644 --- a/backend/crates/gateway-host/src/lib.rs +++ b/backend/crates/gateway-host/src/lib.rs @@ -10,6 +10,7 @@ pub mod plugin_distribution; pub mod pricing; pub mod process; pub mod proxy_probe; +pub mod retention; pub mod serve; pub mod system_update; pub mod workers; diff --git a/backend/crates/gateway-host/src/retention.rs b/backend/crates/gateway-host/src/retention.rs new file mode 100644 index 000000000..93d9651d2 --- /dev/null +++ b/backend/crates/gateway-host/src/retention.rs @@ -0,0 +1,141 @@ +//! 保留期任务的调度、单轮预算、取消与日志;删除事务由 Store 端口负责。 + +use std::{num::NonZeroU32, sync::Arc, time::Duration}; + +use chrono::Utc; +use gateway_admin::{model::retention::RetentionTarget, ports::retention::RetentionStore}; +use gateway_core::task::{ + ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDefinitionError, WorkerId, + WorkerKind, WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, + WorkerTaskError, +}; + +#[derive(Debug, Clone, Copy)] +pub struct RetentionCycleBudget { + pub batch_rows: NonZeroU32, + pub max_batches: NonZeroU32, + pub max_duration: Duration, + pub batch_pause: Duration, +} + +impl Default for RetentionCycleBudget { + fn default() -> Self { + Self { + batch_rows: NonZeroU32::new(5_000).unwrap_or(NonZeroU32::MIN), + max_batches: NonZeroU32::new(12).unwrap_or(NonZeroU32::MIN), + max_duration: Duration::from_secs(30), + batch_pause: Duration::from_millis(50), + } + } +} + +pub fn worker(store: Arc) -> Result { + // 保持稳定的租约身份,滚动升级期间仍与已有进程互斥。 + let id = WorkerId::try_new(WorkerKind::Retention, "postgres")?; + let schedule = WorkerSchedule::try_new( + Duration::from_secs(60 * 60), + Duration::from_secs(1), + Duration::from_secs(60), + Duration::from_secs(15 * 60), + Duration::from_secs(5 * 60), + )?; + let lease = WorkerLeaseRequest::try_new(id.clone(), schedule.leader_lease_ttl())?; + Ok(WorkerContribution::Registration( + WorkerRegistration::try_new( + id, + WorkerRunnable::Scheduled { + schedule, + lease: Some(lease), + task: Box::new(RetentionTask::new(store, RetentionCycleBudget::default())), + }, + )?, + )) +} + +pub struct RetentionTask { + store: Arc, + budget: RetentionCycleBudget, +} + +impl RetentionTask { + #[must_use] + pub fn new(store: Arc, budget: RetentionCycleBudget) -> Self { + Self { store, budget } + } + + async fn cleanup(&self, context: &WorkerCycleContext) -> Result<(), WorkerTaskError> { + let policy = self + .store + .load_policy() + .await + .map_err(|_| WorkerTaskError::safe("retention settings read failed"))?; + let now = Utc::now(); + let started_at = tokio::time::Instant::now(); + let mut targets = [ + (RetentionTarget::ModelRequests, 0_u64, false), + (RetentionTarget::OpsEvents, 0, false), + (RetentionTarget::AdminAuditEvents, 0, false), + ]; + let mut batches = 0; + 'cycles: loop { + for (target, deleted, complete) in &mut targets { + if *complete { + continue; + } + if batches >= self.budget.max_batches.get() + || started_at.elapsed() >= self.budget.max_duration + || context.cancellation().is_cancelled() + { + break 'cycles; + } + let count = self + .store + .purge_batch(*target, now, policy, self.budget.batch_rows) + .await + .map_err(|_| WorkerTaskError::safe("retention cleanup failed"))?; + batches += 1; + *deleted = deleted.saturating_add(count); + *complete = count < u64::from(self.budget.batch_rows.get()); + if !*complete + && batches < self.budget.max_batches.get() + && !self.budget.batch_pause.is_zero() + { + let remaining = self + .budget + .max_duration + .saturating_sub(started_at.elapsed()); + tokio::time::sleep(self.budget.batch_pause.min(remaining)).await; + } + } + if targets.iter().all(|(_, _, complete)| *complete) { + break; + } + } + tracing::info!( + model_requests = targets[0].1, + ops_events = targets[1].1, + admin_audit_events = targets[2].1, + batches, + budget_exhausted = targets.iter().any(|(_, _, complete)| !complete), + elapsed_milliseconds = + u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + "retention cycle completed" + ); + Ok(()) + } +} + +impl ScheduledTask for RetentionTask { + fn run_cycle( + &self, + context: WorkerCycleContext, + ) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> { + Box::pin(async move { + tokio::select! { + biased; + () = context.cancellation().cancelled() => Ok(()), + result = self.cleanup(&context) => result, + } + }) + } +} diff --git a/backend/crates/gateway-host/tests/main.rs b/backend/crates/gateway-host/tests/main.rs index ffff276ae..ead209dc7 100644 --- a/backend/crates/gateway-host/tests/main.rs +++ b/backend/crates/gateway-host/tests/main.rs @@ -7,6 +7,7 @@ mod plugin_distribution; mod pricing; mod process; mod proxy_probe; +mod retention; mod serve; mod support; mod system_update; diff --git a/backend/crates/gateway-host/tests/retention.rs b/backend/crates/gateway-host/tests/retention.rs new file mode 100644 index 000000000..4333d61df --- /dev/null +++ b/backend/crates/gateway-host/tests/retention.rs @@ -0,0 +1,146 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use gateway_admin::{ + model::retention::{RetentionPolicy, RetentionTarget}, + ports::{ + retention::RetentionStore, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use gateway_core::{ + lifecycle::CancellationToken, + task::{ScheduledTask, WorkerCycleContext, WorkerId, WorkerKind}, +}; +use gateway_host::retention::{RetentionCycleBudget, RetentionTask}; +use std::{ + num::NonZeroU32, + sync::{Arc, Mutex}, + time::Duration, +}; + +#[derive(Default)] +struct Store { + calls: Mutex>, + fail: bool, + full: bool, + block_load: bool, +} + +#[async_trait] +impl RetentionStore for Store { + async fn load_policy(&self) -> AdminStoreResult { + if self.block_load { + std::future::pending::<()>().await; + } + Ok(RetentionPolicy::try_new(31, 7, 90).unwrap()) + } + async fn purge_batch( + &self, + target: RetentionTarget, + _: DateTime, + _: RetentionPolicy, + limit: NonZeroU32, + ) -> AdminStoreResult { + self.calls.lock().unwrap().push(target); + if self.fail { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "retention", + "fixture", + )); + } + Ok(if self.full { u64::from(limit.get()) } else { 0 }) + } +} + +fn context(cancellation: CancellationToken) -> WorkerCycleContext { + WorkerCycleContext::new( + WorkerId::try_new(WorkerKind::Retention, "postgres").unwrap(), + None, + cancellation, + ) +} + +fn budget() -> RetentionCycleBudget { + RetentionCycleBudget { + batch_rows: NonZeroU32::new(2).unwrap(), + max_batches: NonZeroU32::new(4).unwrap(), + max_duration: Duration::from_secs(1), + batch_pause: Duration::ZERO, + } +} + +#[tokio::test] +async fn full_tables_rotate_until_batch_budget_is_exhausted() { + let store = Arc::new(Store { + full: true, + ..Store::default() + }); + RetentionTask::new(store.clone(), budget()) + .run_cycle(context(CancellationToken::new())) + .await + .unwrap(); + assert_eq!( + *store.calls.lock().unwrap(), + [ + RetentionTarget::ModelRequests, + RetentionTarget::OpsEvents, + RetentionTarget::AdminAuditEvents, + RetentionTarget::ModelRequests + ] + ); +} + +#[tokio::test] +async fn empty_tables_are_not_revisited_and_errors_stop_the_cycle() { + let store = Arc::new(Store::default()); + RetentionTask::new(store.clone(), budget()) + .run_cycle(context(CancellationToken::new())) + .await + .unwrap(); + assert_eq!(store.calls.lock().unwrap().len(), 3); + let store = Arc::new(Store { + fail: true, + ..Store::default() + }); + assert!( + RetentionTask::new(store.clone(), budget()) + .run_cycle(context(CancellationToken::new())) + .await + .is_err() + ); + assert_eq!(store.calls.lock().unwrap().len(), 1); +} + +#[tokio::test(start_paused = true)] +async fn duration_budget_stops_cleanup_during_batch_pause() { + let store = Arc::new(Store { + full: true, + ..Store::default() + }); + let mut budget = budget(); + budget.batch_pause = Duration::from_secs(2); + RetentionTask::new(store.clone(), budget) + .run_cycle(context(CancellationToken::new())) + .await + .unwrap(); + assert_eq!(store.calls.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn cancellation_interrupts_settings_read_before_any_delete() { + let store = Arc::new(Store { + block_load: true, + ..Store::default() + }); + let token = CancellationToken::new(); + let task = RetentionTask::new(store.clone(), budget()); + let run = task.run_cycle(context(token.clone())); + tokio::pin!(run); + tokio::select! { + result = &mut run => panic!("unexpected completion: {result:?}"), + () = tokio::task::yield_now() => token.cancel(), + } + run.await.unwrap(); + assert!(store.calls.lock().unwrap().is_empty()); +} diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs b/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs index c00050cf9..43ec30b13 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs @@ -456,9 +456,7 @@ fn wire_observation(observation: &RequestObservation) -> RequestCompleted { request_id: observation.request_id().as_str().to_owned(), config_revision: observation.config_revision().get(), operation: observation.operation().as_str().to_owned(), - client_key_id: observation - .client_key_id() - .map(|key| key.as_str().to_owned()), + client_key_id: Some(observation.client_key_id().as_str().to_owned()), account_id: observation .account_id() .map(|account| account.as_str().to_owned()), diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs b/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs index a4c807af0..944b8c107 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/scope.rs @@ -102,12 +102,12 @@ impl BindingScope { pub(crate) fn matches_route(&self, input: &ModelRouteInput) -> bool { // Router 执行时 Provider 尚未产生;带 Provider 条件的绑定在编译时拒绝。 !self.has_provider_condition() - && self.matches_identity(Some(input.client_key_id()), input.account_group_ids()) + && self.matches_identity(input.client_key_id(), input.account_group_ids()) && self.matches_model(Some(input.requested_model().as_str())) } pub(crate) fn matches_schedule(&self, input: &AccountScheduleInput) -> bool { - self.matches_identity(Some(input.client_key_id()), input.account_group_ids()) + self.matches_identity(input.client_key_id(), input.account_group_ids()) && self.matches_provider(Some(input.provider())) && self.matches_model(input.model()) } @@ -119,7 +119,7 @@ impl BindingScope { provider: Option<&ProviderKind>, model: Option<&str>, ) -> bool { - self.matches_identity(Some(client_key), account_groups) + self.matches_identity(client_key, account_groups) && self.matches_provider(provider) && self.matches_model(model) } @@ -133,11 +133,10 @@ impl BindingScope { fn matches_identity( &self, - client_key: Option<&ClientApiKeyId>, + client_key: &ClientApiKeyId, account_groups: &[AccountGroupId], ) -> bool { - (self.client_keys.is_empty() - || client_key.is_some_and(|key| self.client_keys.contains(key))) + (self.client_keys.is_empty() || self.client_keys.contains(client_key)) && (self.account_groups.is_empty() || account_groups .iter() diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs index d6bb378ef..d1c5f8d6a 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/mod.rs @@ -904,7 +904,10 @@ impl PluginPreparation for PluginRuntime { self.prepare_snapshot(snapshot, Some(required_revision)) .await } +} +#[async_trait] +impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for PluginRuntime { async fn runtime_diagnostics( &self, snapshot: &PluginInstanceSnapshot, @@ -954,7 +957,10 @@ impl PluginPreparation for PluginRuntime { .collect(), ) } +} +#[async_trait] +impl gateway_admin::ports::plugins::PluginStateLifecycle for PluginRuntime { async fn activate_state( &self, prepared: &ExtensionSetReference, diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs index 3ca0342c2..39c5d36ce 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs @@ -14,8 +14,8 @@ use gateway_core::{ engine::{ ModelRequestId, ModelRequestTimings, observation::{ - RequestObservation, RequestObservationOutcome, WebSocketResponseAttempt, - WebSocketResponseObservation, + RequestObservation, RequestObservationOutcome, RequestObservationScope, + WebSocketResponseAttempt, WebSocketResponseObservation, }, }, error::GatewayErrorKind, @@ -142,6 +142,20 @@ async fn setup_instances( } fn observation(id: &str, outcome: RequestObservationOutcome) -> RequestObservation { + observation_with_scope( + id, + outcome, + "client-key-observed", + vec![AccountGroupId::new("grp_11111111111111111111111111111111").unwrap()], + ) +} + +fn observation_with_scope( + id: &str, + outcome: RequestObservationOutcome, + client_key_id: &str, + account_group_ids: Vec, +) -> RequestObservation { let send_state = if outcome == RequestObservationOutcome::Rejected { UpstreamSendState::NotSent } else { @@ -150,6 +164,10 @@ fn observation(id: &str, outcome: RequestObservationOutcome) -> RequestObservati RequestObservation::new( ModelRequestId::new(id).unwrap(), ConfigRevision::new(1).unwrap(), + RequestObservationScope::new( + ClientApiKeyId::new(client_key_id).unwrap(), + account_group_ids, + ), OperationKind::Generate, outcome, send_state, @@ -172,6 +190,10 @@ fn websocket_observation( WebSocketResponseObservation::new( ModelRequestId::new(id).unwrap(), ConfigRevision::new(1).unwrap(), + RequestObservationScope::new( + ClientApiKeyId::new("client-key-observed").unwrap(), + vec![AccountGroupId::new("grp_11111111111111111111111111111111").unwrap()], + ), OperationKind::Generate, WebSocketResponseAttempt::new( ProviderKind::new("openai").unwrap(), @@ -181,10 +203,6 @@ fn websocket_observation( sequence, ProtocolWireEvent::json("openai", Some("response.output_text.delta".into()), body).unwrap(), ) - .with_client_scope( - ClientApiKeyId::new("client-key-observed").unwrap(), - vec![AccountGroupId::new("grp_11111111111111111111111111111111").unwrap()], - ) .with_requested_model(PublicModelId::new("gpt-observed").unwrap()) } @@ -640,8 +658,10 @@ async fn client_scope_filters_observations_and_exposes_only_the_key_identifier() plan.dispatch( generation.clone(), - observation("req_scope_match", RequestObservationOutcome::Rejected).with_client_scope( - ClientApiKeyId::new("client-key-observed").unwrap(), + observation_with_scope( + "req_scope_match", + RequestObservationOutcome::Rejected, + "client-key-observed", vec![AccountGroupId::new(matching_group).unwrap()], ), ); @@ -682,8 +702,7 @@ async fn client_scope_filters_observations_and_exposes_only_the_key_identifier() ] { plan.dispatch( generation.clone(), - observation(request_id, RequestObservationOutcome::Rejected) - .with_client_scope(ClientApiKeyId::new(key).unwrap(), groups), + observation_with_scope(request_id, RequestObservationOutcome::Rejected, key, groups), ); } tokio::time::sleep(Duration::from_millis(100)).await; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs index 835345dbc..30fbb3a95 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs @@ -1466,12 +1466,6 @@ impl CapabilitiesNext { ProtocolPayload::json_object("openai", body).unwrap(), )); let operation = request.apply_capabilities(operation)?; - assert!( - operation - .original_capability_requirements() - .features() - .contains(&gateway_core::operation::Feature::JsonSchema) - ); assert!( !operation .capability_requirements() diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs index 294c5c7a1..3021ceffa 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs @@ -268,7 +268,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat .await .unwrap(); - let running = PluginPreparation::runtime_diagnostics( + let running = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( &runtime, &snapshot, Some(snapshot.config_revision.get()), @@ -294,7 +294,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat .await .is_err() ); - let diagnostics = PluginPreparation::runtime_diagnostics( + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( &runtime, &failed, Some(snapshot.config_revision.get()), @@ -314,7 +314,7 @@ async fn diagnostics_distinguish_running_failed_preparation_and_draining_generat disabled.config_revision = Revision::new(3).unwrap(); disabled.instances[0].revision = Revision::new(3).unwrap(); disabled.instances[0].enabled = false; - let diagnostics = PluginPreparation::runtime_diagnostics( + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( &runtime, &disabled, Some(snapshot.config_revision.get()), @@ -344,7 +344,12 @@ async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() { let diagnostics = tokio::time::timeout( std::time::Duration::from_millis(100), - PluginPreparation::runtime_diagnostics(&runtime, &diagnostic_snapshot, None, None), + gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, + &diagnostic_snapshot, + None, + None, + ), ) .await .expect("diagnostics must not wait for plugin startup") @@ -375,7 +380,7 @@ async fn diagnostics_report_a_published_process_fault_without_plugin_details() { .await .expect("worker exit observed"); - let diagnostics = PluginPreparation::runtime_diagnostics( + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( &runtime, &snapshot, Some(snapshot.config_revision.get()), @@ -436,7 +441,7 @@ async fn restart_circuit_caps_short_lived_crashes_and_a_new_revision_recovers() ); assert_eq!(startup_count(&marker), 3); - let diagnostics = PluginPreparation::runtime_diagnostics( + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( &runtime, &snapshot, Some(snapshot.config_revision.get()), @@ -545,7 +550,7 @@ async fn restart_circuit_ignores_planned_generation_shutdown() { .await .expect("first generation"); let instance = &snapshot.instances[0]; - PluginPreparation::quiesce_instance( + gateway_admin::ports::plugins::PluginStateLifecycle::quiesce_instance( &runtime, &instance.id, &instance.artifact_sha256, @@ -598,9 +603,11 @@ async fn restart_circuit_counts_exit_before_the_candidate_is_indexed() { "open circuit must gate process spawn" ); - let diagnostics = PluginPreparation::runtime_diagnostics(&runtime, &snapshot, None, None) - .await - .expect("runtime diagnostics"); + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, &snapshot, None, None, + ) + .await + .expect("runtime diagnostics"); assert_eq!( diagnostics["instance-one"] .failure @@ -694,9 +701,11 @@ async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures() snapshot.config_revision = Revision::new(5).unwrap(); snapshot.instances[1].revision = Revision::new(5).unwrap(); snapshot.instances[1].configuration = serde_json::json!({"revision":5}); - let circuit = PluginPreparation::runtime_diagnostics(&runtime, &snapshot, None, None) - .await - .unwrap(); + let circuit = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, &snapshot, None, None, + ) + .await + .unwrap(); assert_eq!( circuit["instance-one"] .failure @@ -707,10 +716,14 @@ async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures() let recovered = PluginPreparation::prepare(&runtime, snapshot.clone()) .await .expect("an older failed instance is isolated during unrelated recovery"); - let diagnostics = - PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(5), Some(&recovered)) - .await - .unwrap(); + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, + &snapshot, + Some(5), + Some(&recovered), + ) + .await + .unwrap(); assert_eq!( diagnostics["instance-one"] .failure @@ -764,14 +777,15 @@ async fn restart_circuit_restores_an_older_failure_after_candidate_shutdown() { tokio::time::timeout(Duration::from_secs(2), async { loop { - let diagnostics = PluginPreparation::runtime_diagnostics( - &runtime, - &snapshot, - Some(1), - Some(&published), - ) - .await - .expect("runtime diagnostics"); + let diagnostics = + gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, + &snapshot, + Some(1), + Some(&published), + ) + .await + .expect("runtime diagnostics"); if diagnostics["instance-one"] .failure .as_ref() @@ -791,10 +805,14 @@ async fn restart_circuit_restores_an_older_failure_after_candidate_shutdown() { let recovered = PluginPreparation::prepare(&runtime, snapshot.clone()) .await .expect("an older failed instance is isolated during unrelated recovery"); - let diagnostics = - PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(3), Some(&recovered)) - .await - .unwrap(); + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, + &snapshot, + Some(3), + Some(&recovered), + ) + .await + .unwrap(); assert_eq!( diagnostics["instance-one"] .failure @@ -876,10 +894,14 @@ async fn restoring_a_failed_plugin_keeps_other_instances_ready_and_reports_only_ .await .expect("one failed plugin must not stop restoration"); assert!(generation.can_serve()); - let diagnostics = - PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&generation)) - .await - .unwrap(); + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, + &snapshot, + Some(1), + Some(&generation), + ) + .await + .unwrap(); assert_eq!( diagnostics["failed-plugin"].status, PluginInstanceRuntimeStatus::PreparationFailed @@ -919,10 +941,14 @@ async fn a_published_process_crash_preserves_the_serving_snapshot_and_other_plug wait_until_unready(&generation).await; assert!(generation.can_serve()); assert!(!generation.is_ready()); - let diagnostics = - PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&generation)) - .await - .unwrap(); + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, + &snapshot, + Some(1), + Some(&generation), + ) + .await + .unwrap(); assert_eq!( diagnostics["instance-one"].status, PluginInstanceRuntimeStatus::Running @@ -957,10 +983,14 @@ async fn an_incompatible_host_quarantines_the_plugin_with_a_specific_version_err .unwrap(); assert!(restored.can_serve()); let snapshot = store.snapshot.lock().unwrap().clone(); - let diagnostics = - PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&restored)) - .await - .unwrap(); + let diagnostics = gateway_admin::ports::plugins::PluginRuntimeDiagnostics::runtime_diagnostics( + &runtime, + &snapshot, + Some(1), + Some(&restored), + ) + .await + .unwrap(); let failure = diagnostics["instance-one"].failure.as_ref().unwrap(); assert!( failure diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs b/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs index b86596dd4..4fd3ec13c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs @@ -315,6 +315,10 @@ async fn observation_callbacks_use_typed_state_crud_and_reject_undeclared_namesp RequestObservation::new( ModelRequestId::new("req_state").unwrap(), ConfigRevision::new(1).unwrap(), + gateway_core::engine::observation::RequestObservationScope::new( + gateway_core::policy::ClientApiKeyId::new("client-key-state").unwrap(), + vec![], + ), OperationKind::Generate, RequestObservationOutcome::Succeeded, UpstreamSendState::Sent, diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index 82f328884..595c23509 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -1,6 +1,7 @@ //! Admin 认证与设置 adapter。 use super::*; +use gateway_admin::model::audit::MutationAuditOperation; pub(crate) struct AuthStoreAdapter { pub(crate) security: postgres::PgAdminSecurityAuditRepository, @@ -29,8 +30,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { ) -> AdminStoreResult { let audit = mutation_audit( context, - "pricing.sync", - "model_pricing", + MutationAuditOperation::ModelPricingSync, "models.dev", vec!["synced".to_owned()], ); @@ -49,8 +49,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { ) -> AdminStoreResult { let audit = mutation_audit( context, - "pricing.update", - "model_pricing", + MutationAuditOperation::ModelPricingUpdate, &command.provider, command.models.clone(), ); @@ -118,8 +117,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { }, audit: mutation_audit( context, - "settings.replace", - "runtime_settings", + MutationAuditOperation::RuntimeSettingsReplace, "1", vec![ "provider_request_profiles_json".to_owned(), @@ -181,12 +179,7 @@ impl AdminSettingsStoreAdapter { admin_api_key, mutation_audit( context, - if exists { - "admin_api_key.replace" - } else { - "admin_api_key.delete" - }, - "runtime_settings", + MutationAuditOperation::AdminApiKeyChanged { exists }, "1", vec!["admin_api_key".to_owned()], ), @@ -411,21 +404,9 @@ fn auth_audit_record(event: AdminAuditModel) -> AdminStoreResult { - postgres::AdminAuditActorKind::AdminSession - } - gateway_admin::model::auth::AuditActorKind::AdminApiKey => { - postgres::AdminAuditActorKind::AdminApiKey - } - gateway_admin::model::auth::AuditActorKind::System => postgres::AdminAuditActorKind::System, - gateway_admin::model::auth::AuditActorKind::Anonymous => { - postgres::AdminAuditActorKind::Anonymous - } - }; Ok(postgres::AdminAuditEvent { id: event.id, - actor_kind, + actor_kind: event.actor_kind.into(), actor_admin_user_id: event.actor_admin_user_id, actor_ref: event.actor_ref, admin_request_id: event.request_id, diff --git a/backend/crates/gateway-store/src/bundle.rs b/backend/crates/gateway-store/src/bundle.rs index d5915837e..9e9a84ced 100644 --- a/backend/crates/gateway-store/src/bundle.rs +++ b/backend/crates/gateway-store/src/bundle.rs @@ -10,6 +10,7 @@ pub struct StoreBundle { admin_ports: AdminStorePorts, core_ports: CoreStorePorts, provider_ports: ProviderStorePorts, + retention: Arc, worker_leader_lease: Arc, health_probes: Vec>, worker_contributions: Vec, @@ -33,6 +34,11 @@ impl StoreBundle { self.provider_ports.clone() } + #[must_use] + pub fn retention(&self) -> Arc { + Arc::clone(&self.retention) + } + #[must_use] pub fn worker_leader_lease(&self) -> Arc { Arc::clone(&self.worker_leader_lease) @@ -256,7 +262,6 @@ async fn connect( execution_writer, client_key_usage_writer, admission_release_writer, - retention, )?, None, ), @@ -273,6 +278,7 @@ async fn connect( admin_ports, core_ports, provider_ports, + retention, worker_leader_lease, health_probes, worker_contributions, diff --git a/backend/crates/gateway-store/src/lib.rs b/backend/crates/gateway-store/src/lib.rs index 935bc2416..b8a8c5bea 100644 --- a/backend/crates/gateway-store/src/lib.rs +++ b/backend/crates/gateway-store/src/lib.rs @@ -12,7 +12,7 @@ use gateway_admin::model::settings::{ AdminApiKey, AdminApiKeyMutation, ModelMappings, ReplaceRuntimeSettings, RotationStrategy as AdminRotationStrategy, RuntimeSettings as AdminRuntimeSettings, }; -use gateway_admin::model::{MutationActor, MutationContext, Revision as AdminRevision}; +use gateway_admin::model::{MutationContext, Revision as AdminRevision}; use gateway_admin::ports::backup::BackupStorePorts; use gateway_admin::ports::store::{ AdminAccountStorePorts, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 659c67b6a..237ef879e 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -1,5 +1,6 @@ //! PostgreSQL owner for provider-neutral account groups and memberships. +use gateway_admin::model::audit::MutationAuditOperation; use std::{collections::BTreeMap, str::FromStr as _}; use async_trait::async_trait; @@ -231,8 +232,7 @@ impl AccountGroupStore for PgAccountGroupRepository { let id = command.id.clone(); let audit = mutation_audit( context, - "create", - "account_group", + MutationAuditOperation::AccountGroupCreate, id.as_str(), vec![ "name".to_owned(), @@ -264,8 +264,7 @@ impl AccountGroupStore for PgAccountGroupRepository { let id = command.id.clone(); let audit = mutation_audit( context, - "update", - "account_group", + MutationAuditOperation::AccountGroupUpdate, id.as_str(), vec![ "name".to_owned(), @@ -308,8 +307,9 @@ impl AccountGroupStore for PgAccountGroupRepository { let id = command.id.clone(); let audit = mutation_audit( context, - if command.enabled { "enable" } else { "disable" }, - "account_group", + MutationAuditOperation::AccountGroupEnabled { + enabled: command.enabled, + }, id.as_str(), vec!["enabled".to_owned()], ); @@ -341,7 +341,12 @@ impl AccountGroupStore for PgAccountGroupRepository { context: &MutationContext, ) -> AdminStoreResult { let id = command.id.clone(); - let audit = mutation_audit(context, "delete", "account_group", id.as_str(), Vec::new()); + let audit = mutation_audit( + context, + MutationAuditOperation::AccountGroupDelete, + id.as_str(), + Vec::new(), + ); let revision = self .mutate(audit, |transaction| { Box::pin(async move { diff --git a/backend/crates/gateway-store/src/postgres/admin_security_audit.rs b/backend/crates/gateway-store/src/postgres/admin_security_audit.rs index 77094d40c..a403ce901 100644 --- a/backend/crates/gateway-store/src/postgres/admin_security_audit.rs +++ b/backend/crates/gateway-store/src/postgres/admin_security_audit.rs @@ -28,6 +28,18 @@ impl AdminAuditActorKind { } } +impl From for AdminAuditActorKind { + fn from(value: gateway_admin::model::auth::AuditActorKind) -> Self { + use gateway_admin::model::auth::AuditActorKind; + match value { + AuditActorKind::AdminSession => Self::AdminSession, + AuditActorKind::AdminApiKey => Self::AdminApiKey, + AuditActorKind::System => Self::System, + AuditActorKind::Anonymous => Self::Anonymous, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdminAuditEvent { pub id: String, diff --git a/backend/crates/gateway-store/src/postgres/backup.rs b/backend/crates/gateway-store/src/postgres/backup.rs index 1682afa4c..dd7b9e766 100644 --- a/backend/crates/gateway-store/src/postgres/backup.rs +++ b/backend/crates/gateway-store/src/postgres/backup.rs @@ -5,12 +5,14 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use gateway_admin::model::audit::MutationAuditOperation; use secrecy::{ExposeSecret as _, SecretString}; use sqlx::{PgPool, Postgres, Row as _, Transaction}; use gateway_admin::model::backup::{ BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupRecordSeed, BackupSettings, - BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand, UpdateBackupStorageCommand, + BackupStatus, BackupStatusTransition, BackupTriggerKind, UpdateBackupScheduleCommand, + UpdateBackupStorageCommand, }; use gateway_admin::model::{MutationContext, Revision as AdminRevision}; use gateway_admin::ports::backup::{BackupRepository, StatusTransitionUpdate}; @@ -118,8 +120,7 @@ impl BackupRepository for PgBackupRepository { } let audit = crate::mutation_audit( context, - "backup.s3_config_updated", - "backup_settings", + MutationAuditOperation::BackupStorageUpdate, "1", changed, ); @@ -184,8 +185,7 @@ impl BackupRepository for PgBackupRepository { let audit = crate::mutation_audit( context, - "backup.schedule_updated", - "backup_settings", + MutationAuditOperation::BackupScheduleUpdate, "1", vec![ "schedule_enabled".to_owned(), @@ -460,8 +460,7 @@ impl BackupRepository for PgBackupRepository { async fn transition_status( &self, id: &str, - from: BackupStatus, - to: BackupStatus, + transition: BackupStatusTransition, update: StatusTransitionUpdate, now: DateTime, ) -> AdminStoreResult> { @@ -487,7 +486,7 @@ impl BackupRepository for PgBackupRepository { attempt_count, error_code, error_message, started_at, completed_at, expires_at, created_at, updated_at", ) - .bind(to.as_str()) + .bind(transition.to().as_str()) .bind(size_bytes) .bind(update.sha256.as_deref()) .bind(update.error_code.as_deref()) @@ -495,7 +494,7 @@ impl BackupRepository for PgBackupRepository { .bind(update.completed_at) .bind(now) .bind(id) - .bind(from.as_str()) + .bind(transition.from().as_str()) .fetch_optional(&self.pool) .await .map_err(|_| store_unavailable("transition backup task"))? diff --git a/backend/crates/gateway-store/src/postgres/client_budgets.rs b/backend/crates/gateway-store/src/postgres/client_budgets.rs index 045f8cdb9..f2bd70b69 100644 --- a/backend/crates/gateway-store/src/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/src/postgres/client_budgets.rs @@ -1,5 +1,6 @@ //! 按 Key 串行检查限额,并幂等累计已取得的 USD 费用。 +use gateway_admin::model::audit::MutationAuditOperation; use std::{collections::BTreeMap, sync::Mutex, time::Duration}; use chrono::{DateTime, Utc}; @@ -104,8 +105,7 @@ async fn reset_client_key_budget_in_transaction( tx, mutation_audit( context, - "reset_budget", - "client_api_key", + MutationAuditOperation::ClientApiKeyResetBudget, command.id.as_str(), fields, ), diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index 8c5f7dae6..1fb37bf11 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -1,5 +1,6 @@ //! 明文 `client_api_keys` 的 PostgreSQL owner。 +use gateway_admin::model::audit::MutationAuditOperation; use std::{ collections::{BTreeMap, BTreeSet}, fmt, @@ -783,8 +784,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { &mut tx, mutation_audit( context, - "update_budget_limits", - "client_api_key", + MutationAuditOperation::ClientApiKeyUpdateBudgetLimits, command.id.as_str(), fields, ), @@ -884,8 +884,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { }, mutation_audit( context, - "create", - "client_api_key", + MutationAuditOperation::ClientApiKeyCreate, id.as_str(), [ "name", @@ -935,8 +934,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { }, mutation_audit( context, - "update", - "client_api_key", + MutationAuditOperation::ClientApiKeyUpdate, id.as_str(), [ "name", @@ -971,8 +969,9 @@ impl ClientKeyStore for PgAdminClientKeyStore { command.enabled, mutation_audit( context, - if command.enabled { "enable" } else { "disable" }, - "client_api_key", + MutationAuditOperation::ClientApiKeyEnabled { + enabled: command.enabled, + }, id.as_str(), vec!["enabled".to_owned()], ), @@ -992,8 +991,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { command.id.as_str(), mutation_audit( context, - "delete", - "client_api_key", + MutationAuditOperation::ClientApiKeyDelete, command.id.as_str(), Vec::new(), ), diff --git a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs index e69694b30..09d6705e9 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use gateway_admin::model::audit::MutationAuditOperation; use gateway_admin::model::plugins::distribution::{SourceCredential, SourceCredentialInfo}; use gateway_admin::{ model::{ @@ -268,8 +269,7 @@ impl PluginStore for PgPluginStore { &mut transaction, mutation_audit( context, - "install", - "plugin_artifact", + MutationAuditOperation::PluginArtifactInstall, &metadata.plugin_id, vec!["artifact".into(), "source".into()], ), @@ -315,8 +315,7 @@ impl PluginStore for PgPluginStore { &mut transaction, mutation_audit( context, - "accept", - "plugin_artifact", + MutationAuditOperation::PluginArtifactAccept, digest, vec!["acceptedAt".into()], ), @@ -373,8 +372,7 @@ impl PluginStore for PgPluginStore { &mut transaction, mutation_audit( context, - "delete", - "plugin_artifact", + MutationAuditOperation::PluginArtifactDelete, &id, vec!["artifact".into()], ), diff --git a/backend/crates/gateway-store/src/postgres/plugins/credentials.rs b/backend/crates/gateway-store/src/postgres/plugins/credentials.rs index 11cec8626..9c81e85e7 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/credentials.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/credentials.rs @@ -1,3 +1,4 @@ +use gateway_admin::model::audit::MutationAuditOperation; use gateway_admin::{ model::{ MutationContext, Revision, @@ -78,8 +79,7 @@ pub(super) async fn save( &mut tx, mutation_audit( context, - "create", - "plugin_source_credential", + MutationAuditOperation::PluginSourceCredentialCreate, &credential.info.id, vec!["credential".into()], ), @@ -112,8 +112,7 @@ pub(super) async fn delete_unused( tx, mutation_audit( context, - "delete", - "plugin_source_credential", + MutationAuditOperation::PluginSourceCredentialDelete, &id.to_string(), vec!["credential".into()], ), @@ -155,8 +154,7 @@ pub(super) async fn delete( &mut tx, mutation_audit( context, - "delete", - "plugin_source_credential", + MutationAuditOperation::PluginSourceCredentialDelete, id, vec!["credential".into()], ), diff --git a/backend/crates/gateway-store/src/postgres/plugins/instances.rs b/backend/crates/gateway-store/src/postgres/plugins/instances.rs index 56100ffbf..b17dabbe4 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/instances.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/instances.rs @@ -1,3 +1,4 @@ +use gateway_admin::model::audit::MutationAuditOperation; use std::collections::{BTreeMap, BTreeSet}; use gateway_admin::{ @@ -308,8 +309,7 @@ async fn save_inner( &mut tx, mutation_audit( context, - "configure", - "plugin_instance", + MutationAuditOperation::PluginInstanceConfigure, &replacement.id, vec!["enabled".into()], ), @@ -333,8 +333,7 @@ async fn save_inner( &mut tx, mutation_audit( context, - "configure", - "plugin_instance", + MutationAuditOperation::PluginInstanceConfigure, &instance.id, vec![ "artifact".into(), @@ -385,8 +384,7 @@ pub(super) async fn delete( &mut tx, mutation_audit( context, - "delete", - "plugin_instance", + MutationAuditOperation::PluginInstanceDelete, id, vec!["instance".into()], ), diff --git a/backend/crates/gateway-store/src/postgres/plugins/resources.rs b/backend/crates/gateway-store/src/postgres/plugins/resources.rs index f409c1747..c6996ed23 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/resources.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/resources.rs @@ -8,6 +8,7 @@ use super::super::{ use super::PgPluginStore; use crate::{admin_revision, admin_store_error, mutation_audit}; use async_trait::async_trait; +use gateway_admin::model::audit::MutationAuditOperation; use gateway_admin::{ model::{ MutationContext, @@ -65,7 +66,7 @@ fn validate_resource_key(key: &str) -> AdminStoreResult<()> { async fn finish( mut tx: Transaction<'_, Postgres>, context: &MutationContext, - kind: &str, + kind: MutationAuditOperation, id: &str, changed: bool, value: T, @@ -74,13 +75,7 @@ async fn finish( let revision = bump_config_revision_in_transaction(&mut tx) .await .map_err(|e| admin_store_error("plugin resource", e))?; - let audit = mutation_audit( - context, - "plugin_reconcile", - kind, - id, - vec!["plugin_owned_resource".to_owned()], - ); + let audit = mutation_audit(context, kind, id, vec!["plugin_owned_resource".to_owned()]); append_admin_audit_event_in_transaction(&mut tx, audit, revision) .await .map_err(|e| admin_store_error("plugin resource", e))?; @@ -123,7 +118,15 @@ impl PluginResourceStore for PgPluginStore { .await .map_err(|_| unavailable())? { - return finish(tx, context, "account_group", "", false, resource(&row)?).await; + return finish( + tx, + context, + MutationAuditOperation::AccountGroupPluginReconcile, + "", + false, + resource(&row)?, + ) + .await; } insert_account_group_in_transaction(&mut tx, &command) .await @@ -142,7 +145,7 @@ impl PluginResourceStore for PgPluginStore { finish( tx, context, - "account_group", + MutationAuditOperation::AccountGroupPluginReconcile, command.id.as_str(), true, value, @@ -181,7 +184,15 @@ impl PluginResourceStore for PgPluginStore { .await .map_err(|_| unavailable())? { - return finish(tx, context, "client_api_key", "", false, resource(&row)?).await; + return finish( + tx, + context, + MutationAuditOperation::ClientApiKeyPluginReconcile, + "", + false, + resource(&row)?, + ) + .await; } let group_ids: Vec = sqlx::query_scalar("select group_id from plugin_group_resources where instance_id=$1 and resource_key=any($2::text[])") .bind(instance) @@ -219,7 +230,15 @@ impl PluginResourceStore for PgPluginStore { name: key.name.trim().to_owned(), enabled: true, }; - finish(tx, context, "client_api_key", &key.id, true, value).await + finish( + tx, + context, + MutationAuditOperation::ClientApiKeyPluginReconcile, + &key.id, + true, + value, + ) + .await } async fn change_members( @@ -274,7 +293,7 @@ impl PluginResourceStore for PgPluginStore { finish( tx, context, - "account_group", + MutationAuditOperation::AccountGroupPluginReconcile, &group, added + removed > 0, GroupMembersChanged { added, removed }, diff --git a/backend/crates/gateway-store/src/postgres/plugins/sources.rs b/backend/crates/gateway-store/src/postgres/plugins/sources.rs index 62e9eb803..3072b88c7 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/sources.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/sources.rs @@ -1,3 +1,4 @@ +use gateway_admin::model::audit::MutationAuditOperation; use gateway_admin::{ model::{ MutationContext, Revision, @@ -69,8 +70,7 @@ pub(super) async fn change( &mut tx, mutation_audit( context, - "change_source", - "plugin_source", + MutationAuditOperation::PluginSourceChangeSource, &binding.plugin_id, vec!["source".into(), "policy".into(), "outbound_proxy".into()], ), @@ -128,8 +128,7 @@ pub(super) async fn delete_if_unused( tx, mutation_audit( context, - "delete", - "plugin_source", + MutationAuditOperation::PluginSourceDelete, plugin_id, vec!["source".into(), "policy".into(), "outbound_proxy".into()], ), diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index e0fa013f1..9ffa11dca 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -1,5 +1,6 @@ //! `gateway-admin` 账号端口的 PostgreSQL adapter。 +use gateway_admin::model::audit::MutationAuditOperation; use std::collections::BTreeMap; use std::sync::Arc; @@ -229,7 +230,7 @@ impl PgAdminAccountStore { prepared: PreparedCredentialImport, settings: Option, context: &MutationContext, - action: &str, + action: MutationAuditOperation, outbound_proxy: Option, ) -> AdminStoreResult { let command = prepare_import(prepared, settings, context, action, outbound_proxy)?; @@ -246,7 +247,7 @@ impl PgAdminAccountStore { prepared: PreparedCredentialRotationFacts, settings: Option, context: &MutationContext, - action: &str, + action: MutationAuditOperation, ) -> AdminStoreResult { let account_id = prepared.account_id.clone(); let command = prepare_rotation(prepared, settings, context, action)?; @@ -588,7 +589,7 @@ impl AccountStore for PgAdminAccountStore { command.prepared, command.settings, context, - "import_document", + MutationAuditOperation::ProviderAccountImportDocument, command.outbound_proxy, ) .await?; @@ -620,7 +621,7 @@ impl AccountStore for PgAdminAccountStore { command.prepared, command.settings, context, - "rotate_credential", + MutationAuditOperation::ProviderAccountRotateCredential, ) .await } @@ -637,8 +638,13 @@ impl AccountStore for PgAdminAccountStore { "credential refresh cannot change account settings", )); } - self.commit_prepared_rotation(command.prepared, None, context, "refresh_credential") - .await + self.commit_prepared_rotation( + command.prepared, + None, + context, + MutationAuditOperation::ProviderAccountRefreshCredential, + ) + .await } async fn update_account( @@ -681,8 +687,7 @@ impl AccountStore for PgAdminAccountStore { outbound_proxy: command.outbound_proxy, audit: mutation_audit( context, - "update", - "provider_account", + MutationAuditOperation::ProviderAccountUpdate, &command.account_id, changed_fields, ), @@ -730,13 +735,7 @@ impl AccountStore for PgAdminAccountStore { let revision = bump_config_revision_in_transaction(&mut transaction).await?; append_admin_audit_event_in_transaction( &mut transaction, - mutation_audit( - context, - "adapt_concurrency", - "provider_account", - account_id.as_str(), - vec!["concurrency_limit".to_owned()], - ), + mutation_audit(context, MutationAuditOperation::ProviderAccountAdaptConcurrency, account_id.as_str(), vec!["concurrency_limit".to_owned()]), revision, ) .await?; @@ -780,8 +779,7 @@ impl AccountStore for PgAdminAccountStore { account_id: account_id.as_str().to_owned(), audit: mutation_audit( context, - "recover", - "provider_account", + MutationAuditOperation::ProviderAccountRecover, account_id.as_str(), vec!["status".to_owned(), "quota".to_owned()], ), @@ -848,8 +846,7 @@ impl AccountStore for PgAdminAccountStore { outbound_proxy: command.outbound_proxy, audit: mutation_audit( context, - "batch_update", - "provider_account", + MutationAuditOperation::ProviderAccountBatchUpdate, &audit_target, changed_fields, ), @@ -887,8 +884,7 @@ impl AccountStore for PgAdminAccountStore { account_ids: command.account_ids, audit: mutation_audit( context, - "delete", - "provider_account", + MutationAuditOperation::ProviderAccountDelete, &audit_target, Vec::new(), ), @@ -941,8 +937,7 @@ impl AccountStore for PgAdminAccountStore { &mut transaction, mutation_audit( context, - "export_credentials", - "provider_account", + MutationAuditOperation::ProviderAccountExportCredentials, account_id, Vec::new(), ), @@ -963,7 +958,7 @@ pub(super) fn prepare_import( prepared: PreparedCredentialImport, settings: Option, context: &MutationContext, - action: &str, + action: MutationAuditOperation, outbound_proxy: Option, ) -> AdminStoreResult { let provider_kind = prepared.provider_kind.as_str().to_owned(); @@ -997,13 +992,7 @@ pub(super) fn prepare_import( provider_kind: provider_kind.clone(), }, accounts, - audit: mutation_audit( - context, - action, - "provider_account", - &provider_kind, - changed_fields, - ), + audit: mutation_audit(context, action, &provider_kind, changed_fields), }) } @@ -1062,7 +1051,7 @@ pub(super) fn prepare_rotation( prepared: PreparedCredentialRotationFacts, settings: Option, context: &MutationContext, - action: &str, + action: MutationAuditOperation, ) -> AdminStoreResult { let account_id = prepared.account_id.clone(); let scope = ProviderAccountAdminScope { @@ -1103,13 +1092,7 @@ pub(super) fn prepare_rotation( preserve_profile: prepared.preserve_profile, preserve_credential_state: prepared.preserve_credential_state, }, - audit: mutation_audit( - context, - action, - "provider_account", - account_id.as_str(), - changed_fields, - ), + audit: mutation_audit(context, action, account_id.as_str(), changed_fields), }) } diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/authorization.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/authorization.rs index 5adb88bd2..8c5a7631b 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/authorization.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/authorization.rs @@ -93,7 +93,7 @@ async fn commit_authorization_in_transaction( }, command.settings, context, - "authorize", + gateway_admin::model::audit::MutationAuditOperation::ProviderAccountAuthorize, command .pending .outbound_proxy_id() @@ -120,8 +120,12 @@ async fn commit_authorization_in_transaction( { return Err(invalid_receipt()); } - let prepared = - admin_adapter::prepare_rotation(*prepared, None, context, "reauthorize")?; + let prepared = admin_adapter::prepare_rotation( + *prepared, + None, + context, + gateway_admin::model::audit::MutationAuditOperation::ProviderAccountReauthorize, + )?; let result = repository::rotate_provider_account_admin_in_transaction(transaction, prepared) .await diff --git a/backend/crates/gateway-store/src/postgres/proxies.rs b/backend/crates/gateway-store/src/postgres/proxies.rs index dd61ebb81..b520cc026 100644 --- a/backend/crates/gateway-store/src/postgres/proxies.rs +++ b/backend/crates/gateway-store/src/postgres/proxies.rs @@ -1,5 +1,6 @@ //! 命名代理持有认证信息,账号保留解析后的 URL 供 Provider 传输使用。 +use gateway_admin::model::audit::MutationAuditOperation; use std::{collections::BTreeMap, sync::Arc}; use async_trait::async_trait; @@ -323,7 +324,7 @@ pub(crate) async fn resolve_proxy_selection( async fn audit( transaction: &mut Transaction<'_, Postgres>, context: &MutationContext, - action: &str, + action: MutationAuditOperation, id: &str, fields: &[&str], revision: Revision, @@ -333,7 +334,6 @@ async fn audit( mutation_audit( context, action, - "outbound_proxy", id, fields.iter().map(|value| (*value).to_owned()).collect(), ), @@ -377,8 +377,7 @@ impl ProxyStore for PgProxyRepository { &mut transaction, mutation_audit( context, - "update", - "provider_account", + MutationAuditOperation::ProviderAccountUpdate, account_id.as_str(), vec!["outbound_proxy".to_owned()], ), @@ -634,7 +633,7 @@ impl ProxyStore for PgProxyRepository { audit( &mut transaction, context, - "create", + MutationAuditOperation::OutboundProxyCreate, &id, &["name", "proxy_url", "location", "auto_location"], revision, @@ -720,7 +719,7 @@ impl ProxyStore for PgProxyRepository { audit( &mut transaction, context, - "update", + MutationAuditOperation::OutboundProxyUpdate, &command.id, &["name", "proxy_url", "location", "auto_location"], revision, @@ -775,7 +774,7 @@ impl ProxyStore for PgProxyRepository { audit( &mut transaction, context, - "delete", + MutationAuditOperation::OutboundProxyDelete, id, &[], config_revision, @@ -843,7 +842,7 @@ impl ProxyStore for PgProxyRepository { audit( &mut transaction, context, - "test", + MutationAuditOperation::OutboundProxyTest, id, &["last_test", "detected_location"], revision, diff --git a/backend/crates/gateway-store/src/postgres/retention.rs b/backend/crates/gateway-store/src/postgres/retention.rs index eeaf4864a..4fb8d528d 100644 --- a/backend/crates/gateway-store/src/postgres/retention.rs +++ b/backend/crates/gateway-store/src/postgres/retention.rs @@ -1,250 +1,106 @@ -//! 终态请求、后台事件和审计事件的保留期清理。 +//! PostgreSQL 保留期投影与单批删除适配器。 +use crate::{admin_store_error, postgres_unavailable}; use async_trait::async_trait; use chrono::{DateTime, Utc}; +use gateway_admin::{ + model::retention::{RetentionPolicy, RetentionTarget}, + ports::{ + retention::RetentionStore, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; use sqlx::PgPool; -use std::time::{Duration, Instant}; - -use crate::{StoreError, StoreResult, postgres_unavailable}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RuntimeRetentionSettings { - pub usage_retention_days: u32, - pub ops_event_retention_days: u32, - pub audit_retention_days: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct RetentionReport { - pub model_requests: u64, - pub ops_events: u64, - pub admin_audit_events: u64, - pub batches: u32, - pub budget_exhausted: bool, -} - -/// 一次 retention cycle 可占用的删除预算;不从部署配置暴露低价值开关。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RetentionCycleBudget { - batch_rows: u32, - max_batches: u32, - max_duration: Duration, - batch_pause: Duration, -} - -impl RetentionCycleBudget { - pub fn try_new( - batch_rows: u32, - max_batches: u32, - max_duration: Duration, - batch_pause: Duration, - ) -> StoreResult { - if batch_rows == 0 || max_batches == 0 || max_duration.is_zero() { - return Err(StoreError::InvalidData { - entity: "retention cycle budget", - message: "row, batch, and duration budgets must be positive".to_owned(), - }); - } - Ok(Self { - batch_rows, - max_batches, - max_duration, - batch_pause, - }) - } - - const fn production() -> Self { - Self { - batch_rows: 5_000, - max_batches: 12, - max_duration: Duration::from_secs(30), - batch_pause: Duration::from_millis(50), - } - } -} - -#[async_trait] -pub trait RetentionRepository: Send + Sync { - async fn load_retention_settings(&self) -> StoreResult; - async fn apply_retention( - &self, - now: DateTime, - settings: RuntimeRetentionSettings, - ) -> StoreResult; -} +use std::num::NonZeroU32; #[derive(Clone)] pub struct PgRetentionRepository { pool: PgPool, - cycle_budget: RetentionCycleBudget, } impl PgRetentionRepository { #[must_use] pub const fn new(pool: PgPool) -> Self { - Self { - pool, - cycle_budget: RetentionCycleBudget::production(), - } - } - - #[must_use] - pub const fn with_cycle_budget(pool: PgPool, cycle_budget: RetentionCycleBudget) -> Self { - Self { pool, cycle_budget } + Self { pool } } } #[async_trait] -impl RetentionRepository for PgRetentionRepository { - async fn load_retention_settings(&self) -> StoreResult { +impl RetentionStore for PgRetentionRepository { + async fn load_policy(&self) -> AdminStoreResult { let row = sqlx::query_as::<_, (i64, i64, i64)>( "select usage_retention_days, ops_event_retention_days, audit_retention_days from runtime_settings where id = 1", ) .fetch_optional(&self.pool) .await - .map_err(|_| postgres_unavailable("load retention settings"))? - .ok_or_else(|| StoreError::NotFound { - entity: "runtime settings", - id: "1".to_owned(), + .map_err(|_| unavailable("load retention settings"))? + .ok_or_else(|| { + AdminStoreError::new( + AdminStoreErrorKind::NotFound, + "retention settings", + "runtime settings not found", + ) })?; - Ok(RuntimeRetentionSettings { - usage_retention_days: to_u32(row.0)?, - ops_event_retention_days: to_u32(row.1)?, - audit_retention_days: to_u32(row.2)?, - }) + let to_days = |value| u32::try_from(value).map_err(|_| invalid_policy()); + RetentionPolicy::try_new(to_days(row.0)?, to_days(row.1)?, to_days(row.2)?) + .map_err(|_| invalid_policy()) } - async fn apply_retention( + async fn purge_batch( &self, + target: RetentionTarget, now: DateTime, - settings: RuntimeRetentionSettings, - ) -> StoreResult { - if settings.usage_retention_days < 31 - || settings.ops_event_retention_days == 0 - || settings.audit_retention_days == 0 - { - return Err(StoreError::InvalidData { - entity: "retention settings", - message: "retention values violate the frozen constraints".to_owned(), - }); - } - - // 各表轮转执行单批独立事务;行数、批数和 wall-clock 同时有界。 - let mut targets = [ - RetentionTarget::new( + policy: RetentionPolicy, + limit: NonZeroU32, + ) -> AdminStoreResult { + let sql = match target { + RetentionTarget::ModelRequests => { "delete from model_requests where ctid in ( select ctid from model_requests where outcome <> 'running' and completed_at < $1 - ($2 * interval '1 day') limit $3 - )", - settings.usage_retention_days, - "delete expired model requests", - ), - RetentionTarget::new( + )" + } + RetentionTarget::OpsEvents => { "delete from ops_events where ctid in ( select ctid from ops_events where model_request_id is null and created_at < $1 - ($2 * interval '1 day') limit $3 - )", - settings.ops_event_retention_days, - "delete expired ops events", - ), - RetentionTarget::new( + )" + } + RetentionTarget::AdminAuditEvents => { "delete from admin_audit_events where ctid in ( select ctid from admin_audit_events where created_at < $1 - ($2 * interval '1 day') limit $3 - )", - settings.audit_retention_days, - "delete expired admin audit events", - ), - ]; - let started_at = Instant::now(); - let mut batches = 0_u32; - 'cycles: loop { - let mut attempted = false; - for target in &mut targets { - if target.complete { - continue; - } - if batches >= self.cycle_budget.max_batches - || started_at.elapsed() >= self.cycle_budget.max_duration - { - break 'cycles; - } - attempted = true; - let deleted = - purge_batch(&self.pool, target, now, self.cycle_budget.batch_rows).await?; - batches = batches.saturating_add(1); - target.deleted = target.deleted.saturating_add(deleted); - target.complete = deleted < u64::from(self.cycle_budget.batch_rows); - if !target.complete - && batches < self.cycle_budget.max_batches - && started_at.elapsed() < self.cycle_budget.max_duration - && !self.cycle_budget.batch_pause.is_zero() - { - tokio::time::sleep(self.cycle_budget.batch_pause).await; - } + )" } - if !attempted || targets.iter().all(|target| target.complete) { - break; - } - } - Ok(RetentionReport { - model_requests: targets[0].deleted, - ops_events: targets[1].deleted, - admin_audit_events: targets[2].deleted, - batches, - budget_exhausted: targets.iter().any(|target| !target.complete), - }) + }; + sqlx::query(sql) + .bind(now) + .bind(i64::from(policy.days(target))) + .bind(i64::from(limit.get())) + .execute(&self.pool) + .await + .map(|result| result.rows_affected()) + .map_err(|_| unavailable("delete expired records")) } } -struct RetentionTarget { - delete_sql: &'static str, - retention_days: u32, - label: &'static str, - deleted: u64, - complete: bool, +fn unavailable(operation: &'static str) -> AdminStoreError { + admin_store_error("retention", postgres_unavailable(operation)) } -impl RetentionTarget { - const fn new(delete_sql: &'static str, retention_days: u32, label: &'static str) -> Self { - Self { - delete_sql, - retention_days, - label, - deleted: 0, - complete: false, - } - } -} - -async fn purge_batch( - pool: &PgPool, - target: &RetentionTarget, - now: DateTime, - batch_rows: u32, -) -> StoreResult { - sqlx::query(target.delete_sql) - .bind(now) - .bind(i64::from(target.retention_days)) - .bind(i64::from(batch_rows)) - .execute(pool) - .await - .map_err(|_| postgres_unavailable(target.label)) - .map(|result| result.rows_affected()) -} - -fn to_u32(value: i64) -> StoreResult { - u32::try_from(value).map_err(|_| StoreError::InvalidData { - entity: "retention settings", - message: "retention days are outside the supported range".to_owned(), - }) +fn invalid_policy() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Invalid, + "retention settings", + "invalid retention policy", + ) } diff --git a/backend/crates/gateway-store/src/value.rs b/backend/crates/gateway-store/src/value.rs index c8acf2815..d934a562c 100644 --- a/backend/crates/gateway-store/src/value.rs +++ b/backend/crates/gateway-store/src/value.rs @@ -62,40 +62,27 @@ pub(crate) fn admin_revision(revision: Revision) -> AdminStoreResult, ) -> postgres::AdminAuditEvent { - let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor { - MutationActor::AdminSession { admin_user_id } => ( - postgres::AdminAuditActorKind::AdminSession, - Some(admin_user_id.clone()), - gateway_admin::model::auth::admin_session_actor_ref(admin_user_id), - ), - MutationActor::AdminApiKey => ( - postgres::AdminAuditActorKind::AdminApiKey, - None, - "admin_api_key".to_owned(), - ), - MutationActor::System => ( - postgres::AdminAuditActorKind::System, - None, - "system".to_owned(), - ), - }; + let event = gateway_admin::model::audit::MutationAuditIntent { + operation, + entity_ref, + } + .event(context, changed_fields); postgres::AdminAuditEvent { - id: format!("audit_{}", uuid::Uuid::now_v7().simple()), - actor_kind, - actor_admin_user_id, - actor_ref, - admin_request_id: Some(context.request_id.clone()), - action: action.to_owned(), - entity_kind: entity_kind.to_owned(), - entity_ref: entity_ref.to_owned(), + id: event.id, + actor_kind: event.actor_kind.into(), + actor_admin_user_id: event.actor_admin_user_id, + actor_ref: event.actor_ref, + admin_request_id: event.request_id, + action: event.action, + entity_kind: event.entity_kind, + entity_ref: event.entity_ref, config_revision: None, - changed_fields, - created_at: chrono::Utc::now(), + changed_fields: event.changed_fields, + created_at: event.occurred_at, } } diff --git a/backend/crates/gateway-store/src/workers.rs b/backend/crates/gateway-store/src/workers.rs index 399450e2c..12af9a7a6 100644 --- a/backend/crates/gateway-store/src/workers.rs +++ b/backend/crates/gateway-store/src/workers.rs @@ -99,12 +99,9 @@ pub(crate) fn store_worker_contributions( execution_writer: postgres::ExecutionObservationWriter, client_key_usage_writer: postgres::PgClientApiKeyUsageWriter, admission_release_writer: redis::ClientAdmissionReleaseWriter, - retention: Arc, ) -> StoreResult> { let stale_id = WorkerId::try_new(WorkerKind::StaleModelRequestRecovery, "postgres") .map_err(worker_definition_error)?; - let retention_id = - WorkerId::try_new(WorkerKind::Retention, "postgres").map_err(worker_definition_error)?; let ops_flush_id = WorkerId::try_new(WorkerKind::OpsFlush, "postgres").map_err(worker_definition_error)?; let client_key_usage_flush_id = @@ -121,11 +118,6 @@ pub(crate) fn store_worker_contributions( Duration::from_secs(30), Box::new(StaleModelRequestRecoveryTask { execution }), )?), - WorkerContribution::Registration(scheduled_worker( - retention_id, - Duration::from_secs(60 * 60), - Box::new(RetentionTask { retention }), - )?), WorkerContribution::Registration( WorkerRegistration::try_new( ops_flush_id, @@ -215,46 +207,6 @@ impl ScheduledTask for StaleModelRequestRecoveryTask { } } -pub(crate) struct RetentionTask { - retention: Arc, -} - -impl ScheduledTask for RetentionTask { - fn run_cycle( - &self, - context: WorkerCycleContext, - ) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> { - Box::pin(async move { - let settings = - postgres::RetentionRepository::load_retention_settings(self.retention.as_ref()) - .await - .map_err(|_| WorkerTaskError::safe("retention settings read failed"))?; - let started_at = std::time::Instant::now(); - let cleanup = postgres::RetentionRepository::apply_retention( - self.retention.as_ref(), - chrono::Utc::now(), - settings, - ); - let report = tokio::select! { - () = context.cancellation().cancelled() => return Ok(()), - result = cleanup => result - .map_err(|_| WorkerTaskError::safe("retention cleanup failed"))?, - }; - tracing::info!( - model_requests = report.model_requests, - ops_events = report.ops_events, - admin_audit_events = report.admin_audit_events, - batches = report.batches, - budget_exhausted = report.budget_exhausted, - elapsed_milliseconds = - u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), - "PostgreSQL retention cycle completed" - ); - Ok(()) - }) - } -} - pub struct PostgresHealthProbe { pool: sqlx::PgPool, max_connections: u32, diff --git a/backend/crates/gateway-store/tests/postgres/backup.rs b/backend/crates/gateway-store/tests/postgres/backup.rs index 6e10164c4..ca1c1a595 100644 --- a/backend/crates/gateway-store/tests/postgres/backup.rs +++ b/backend/crates/gateway-store/tests/postgres/backup.rs @@ -4,8 +4,8 @@ use chrono::Utc; use secrecy::SecretString; use gateway_admin::model::backup::{ - BackupRecordSeed, BackupSettings, BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand, - UpdateBackupStorageCommand, + BackupRecordSeed, BackupSettings, BackupStatus, BackupStatusTransition, BackupTriggerKind, + UpdateBackupScheduleCommand, UpdateBackupStorageCommand, }; use gateway_admin::model::{MutationActor, MutationContext}; use gateway_admin::ports::backup::{BackupRepository, StatusTransitionUpdate}; @@ -36,6 +36,10 @@ fn context() -> MutationContext { } } +fn transition(from: BackupStatus, to: BackupStatus) -> BackupStatusTransition { + BackupStatusTransition::try_new(from, to).expect("legal backup status transition") +} + fn storage_command(endpoint: &str) -> UpdateBackupStorageCommand { UpdateBackupStorageCommand { endpoint: endpoint.to_owned(), @@ -187,8 +191,7 @@ async fn backup_lifecycle_transitions_through_deleting_and_hard_delete() { let uploading = repository .transition_status( &record.id, - BackupStatus::Dumping, - BackupStatus::Uploading, + transition(BackupStatus::Dumping, BackupStatus::Uploading), update, Utc::now(), ) @@ -201,8 +204,7 @@ async fn backup_lifecycle_transitions_through_deleting_and_hard_delete() { let completed = repository .transition_status( &record.id, - BackupStatus::Uploading, - BackupStatus::Completed, + transition(BackupStatus::Uploading, BackupStatus::Completed), StatusTransitionUpdate::default(), Utc::now(), ) @@ -260,8 +262,7 @@ async fn active_task_unique_index_blocks_second_queued() { repository .transition_status( &claimed.id, - BackupStatus::Dumping, - BackupStatus::Failed, + transition(BackupStatus::Dumping, BackupStatus::Failed), StatusTransitionUpdate { error_code: Some("backup.pg_dump_failed".to_owned()), error_message: Some("boom".to_owned()), diff --git a/backend/crates/gateway-store/tests/postgres/retention.rs b/backend/crates/gateway-store/tests/postgres/retention.rs index 4e7e2e169..4a872ac1f 100644 --- a/backend/crates/gateway-store/tests/postgres/retention.rs +++ b/backend/crates/gateway-store/tests/postgres/retention.rs @@ -1,24 +1,16 @@ -use std::time::Duration; +use std::num::NonZeroU32; use chrono::{Duration as ChronoDuration, Utc}; -use gateway_store::postgres::{ - PgRetentionRepository, RetentionCycleBudget, RetentionRepository as _, RuntimeRetentionSettings, +use gateway_admin::{ + model::retention::{RetentionPolicy, RetentionTarget}, + ports::retention::RetentionStore as _, }; +use gateway_store::postgres::PgRetentionRepository; use super::TestDatabase; -#[test] -fn retention_settings_preserve_independent_windows() { - let settings = RuntimeRetentionSettings { - usage_retention_days: 31, - ops_event_retention_days: 30, - audit_retention_days: 90, - }; - assert_eq!(settings.audit_retention_days, 90); -} - #[tokio::test] -async fn retention_cycle_should_stop_at_the_batch_budget() { +async fn retention_batch_deletes_only_expired_rows_up_to_limit() { let Some(database) = TestDatabase::create("retention_cycle_budget").await else { return; }; @@ -29,39 +21,50 @@ async fn retention_cycle_should_stop_at_the_batch_budget() { changed_fields, created_at ) select 'retention-' || value::text, 'system', 'retention', 'cleanup', - 'fixture', 'fixture-' || value::text, array[]::text[], $1 - from generate_series(1, 5) as value", + 'fixture', 'fixture-' || value::text, array[]::text[], + case when value = 6 then now() else $1 end + from generate_series(1, 6) as value", ) .bind(expired_at) .execute(&database.pool) .await - .expect("seed expired audit events"); + .expect("seed expired and retained audit events"); - let budget = RetentionCycleBudget::try_new(2, 3, Duration::from_secs(1), Duration::ZERO) - .expect("retention cycle budget"); - let repository = PgRetentionRepository::with_cycle_budget(database.pool.clone(), budget); - let report = repository - .apply_retention( + let repository = PgRetentionRepository::new(database.pool.clone()); + let deleted = repository + .purge_batch( + RetentionTarget::AdminAuditEvents, Utc::now(), - RuntimeRetentionSettings { - usage_retention_days: 31, - ops_event_retention_days: 30, - audit_retention_days: 90, - }, + RetentionPolicy::try_new(31, 30, 90).unwrap(), + NonZeroU32::new(2).unwrap(), ) .await - .expect("bounded retention cycle"); - - assert_eq!(report.model_requests, 0); - assert_eq!(report.ops_events, 0); - assert_eq!(report.admin_audit_events, 2); - assert_eq!(report.batches, 3); - assert!(report.budget_exhausted); + .expect("bounded retention batch"); + assert_eq!(deleted, 2); let remaining: i64 = sqlx::query_scalar("select count(*) from admin_audit_events where id like 'retention-%'") .fetch_one(&database.pool) .await .expect("count remaining audit events"); - assert_eq!(remaining, 3); + assert_eq!(remaining, 4); + let policy = repository.load_policy().await.expect("load valid policy"); + assert_eq!( + repository + .purge_batch( + RetentionTarget::AdminAuditEvents, + Utc::now(), + policy, + NonZeroU32::new(10).unwrap(), + ) + .await + .expect("finish expired audit cleanup"), + 3, + ); + let retained: Vec = + sqlx::query_scalar("select id from admin_audit_events where id like 'retention-%'") + .fetch_all(&database.pool) + .await + .expect("read retained audit events"); + assert_eq!(retained, ["retention-6"]); database.close().await; } diff --git a/backend/crates/gateway-store/tests/redis/worker_lease.rs b/backend/crates/gateway-store/tests/redis/worker_lease.rs index 001687148..affbc240f 100644 --- a/backend/crates/gateway-store/tests/redis/worker_lease.rs +++ b/backend/crates/gateway-store/tests/redis/worker_lease.rs @@ -40,11 +40,7 @@ async fn store_bundle_worker_plan_and_leader_lease_are_single_use_and_fenced() { .collect::>(); assert_eq!( kinds, - BTreeSet::from([ - WorkerKind::StaleModelRequestRecovery, - WorkerKind::Retention, - WorkerKind::OpsFlush, - ]) + BTreeSet::from([WorkerKind::StaleModelRequestRecovery, WorkerKind::OpsFlush,]) ); assert!(first.take_worker_contributions().is_empty()); diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index 81d0b8557..05e1b6147 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -257,7 +257,6 @@ async fn native_response_translation_keeps_raw_xai_and_delivery_state_independen ) .await .expect("native response stream"); - assert!(stream.has_native_response_translator()); let mut added_was_deferred = false; let mut done_expanded = false; @@ -332,7 +331,6 @@ async fn pass_through_middleware_projects_native_response_once() { ) .await .expect("native response stream"); - assert!(!stream.has_native_response_translator()); let mut client_events = 0; while let Some(event) = stream.next().await { @@ -3915,7 +3913,6 @@ async fn cancellation_before_poll_never_calls_transport() { ) .await .expect("prepared stream"); - assert!(!stream.has_native_response_translator()); cancellation.cancel(); let error = stream .next() diff --git a/docs/architecture.md b/docs/architecture.md index 639618e77..719d03c29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,10 +59,10 @@ flowchart TB | `backend/apps/plugin-cli`(包名 `codex-proxy-plugin-cli`) | `cpr-plugin package` 校验作者清单并生成平台元数据、资源摘要与归档;内部仅依赖 SDK,不参与网关运行时加载 | | `gateway-protocol` | 跨层共享的 OpenAI wire contract、SSE 编解码与无业务 owner 的解析事实,不依赖其他 workspace crate | | `gateway-core` | operation、canonical event、请求快照、路由、admission、attempt 协调、交付边界和计量 | -| `gateway-admin` | 管理领域、Key 用量查询、Provider/Store 端口、审计语义和备份策略 | +| `gateway-admin` | 管理领域、Key 用量查询、Provider/Store 端口、审计语义、备份与历史保留策略 | | `gateway-api` | HTTP/WS/SSE 解码与交付、Admin 与 Key 用量 wire、静态 Web UI;不直接访问 Store 或具体 Provider | | `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 | -| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督、系统更新及外部价格源适配 | +| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督与历史保留任务、系统更新及外部价格源适配 | | `gateway-plugin/sdk`(包名 `gateway-plugin-sdk`) | 公开插件清单与线协议,独立于网关领域;异步收发通过可选 `io` feature 提供 | | `gateway-plugin/runtime`(包名 `gateway-plugin-runtime`) | 插件包校验、能力适配、双向 RPC 与发布集合;通过 Host 管理子进程和受管 HTTP | | `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images/Search transport | @@ -78,6 +78,10 @@ flowchart TB 3. API 只调用 Core/Admin 抽象;Store 只实现端口 4. 具体实现只在组合根相遇 +管理变更的审计动作、实体分类与 actor 投影由 Admin 的类型化操作和审计意图定义。 +Store 按端口命令与实际提交结果选择操作、补齐稳定实体标识和物理差异,在同一事务写入配置 revision 与审计记录; +适配器不另定义动作和实体分类字符串,也不为生成审计而在事务外重复读取旧值 + `frontend/` 是独立的 Node 项目,自行管理依赖、pnpm 配置、锁文件和 ESLint;仓库根目录不建立前端 workspace。 管理端与独立示例仓库分别依赖 `@codex-proxy/ui` 的固定 GitHub 标签或提交,通过锁文件固定实际提交并校验源码归档完整性,不要求同级源码目录。安装与构建许可见 [开发环境](development.md#环境与依赖)。 `modules/ui` 与 `modules/plugins` 是可选的 Git 子模块,分别指向两个独立仓库;不加入宿主的 Cargo 或 pnpm workspace,不参与宿主发行构建。源码联调和版本指针维护见 [开发指南](development.md)。 @@ -122,7 +126,7 @@ flowchart LR 公开列表、详情、原生目录和受管模型查询复用相同别名与账号访问范围,不另维护插件专用目录 - **重试由 Core 裁决**:`retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。 发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核 -- **转换声明不替代正文事实**:middleware v3 在 request 阶段声明具体承担的功能和额外上游需求,原始需求保留用于诊断,实际正文与未承担的原始功能仍参与选路。 +- **转换声明不替代正文事实**:middleware v3 在 request 阶段声明具体承担的功能和额外上游需求,逐层继承尚未承担的需求,实际正文与有效需求共同参与选路。 attempt 阶段拒绝需求声明。没有改写的正文、header 和响应帧在相同网关边界保留原字节与顺序 - **安装意味着完整信任**:只读校验不持久化;上传、URL 和 GitHub 的确认安装记录对精确摘要的信任, @@ -132,6 +136,8 @@ flowchart LR 读取和重复安装不重写已有 JSON,废弃字段通过迁移清理;新增必填字段或改变语义时须提供迁移。包清单、RPC 与能力合同仍按 SDK 校验,运行兼容性由宿主版本范围及清单、协议、能力版本决定 - **一份发布快照**:数据面和管理页面使用同一不可变扩展集合;CLI 复用准备与调用合同,按次读取持久化配置。 新配置准备成功后才提交发布,在途请求持有旧集合直到结束;管理目标和回调另按当前身份与版本复验 +- **发布能力显式实现**:Admin 分别定义准备、运行诊断和状态生命周期端口,完整发布实现必须满足全部合同。 + 诊断可以明确返回不可用;状态激活、实例排空与迁移必须由实现显式处理,不以缺省空操作或静默成功代替 - **插件故障按实例隔离**:恢复时将启动失败的实例及其错误保留在发布集合中,正常实例和原生能力继续发布; 用户正在修改的实例仍须准备成功才能提交。集合区分进程就绪、可继续服务与需要后台重建,单个进程退出不撤销全部请求快照。 故障绑定保留原有作用范围和拒绝/委托策略,入口认证故障不降级为其他认证方式;宿主配置存储与 revision 无法确认时仍停止新请求 @@ -733,7 +739,8 @@ Provider metadata 的 `upstreamServiceTier`,不改写客户端收到的响应 Worker 由各 Bundle 贡献、由 Host 统一监督: -- Store:过期请求恢复、历史保留和 PostgreSQL/Redis 观测队列 +- Store:过期请求恢复和 PostgreSQL/Redis 观测队列 +- Host:历史保留任务,持有每小时调度、单轮行数/批数/时长预算、取消和日志;Admin 校验保留窗口,Store 执行有界批量删除 - Core:`runtime` owner 的 RuntimeSnapshot 周期对账和 Redis change 订阅 - Admin:S3/R2 备份 daemon,负责调度、执行、删除收敛与保留清理; 以及账号冻结恢复 worker(容量熔断的自适应并发下调与到期探测解冻) diff --git a/frontend/build/plugin-bridge.ts b/frontend/build/plugin-bridge.ts new file mode 100644 index 000000000..5cd632739 --- /dev/null +++ b/frontend/build/plugin-bridge.ts @@ -0,0 +1,45 @@ +import type { Plugin } from 'vite' +import { fileURLToPath } from 'node:url' +import { build } from 'vite' + +// 桥运行在隔离 iframe 中,独立编译以保证注入源码不依赖宿主闭包或分块。 +export function pluginBridge(): Plugin { + const id = 'virtual:plugin-bridge-source' + const resolvedId = `\0${id}` + const entry = fileURLToPath(new URL('../src/views/plugins/management/bridge-runtime.ts', import.meta.url)) + const dependencies = new Set() + return { + name: 'plugin-management-bridge', + resolveId: source => source === id ? resolvedId : undefined, + async load(source) { + if (source !== resolvedId) + return + const result = await build({ + configFile: false, + logLevel: 'silent', + build: { + write: false, + minify: true, + lib: { entry, name: 'CodexProxyPluginBridge', formats: ['iife'] }, + }, + }) + const outputs = (Array.isArray(result) ? result : [result]) + .flatMap(output => 'output' in output ? output.output : []) + const [chunk] = outputs + if (outputs.length !== 1 || chunk?.type !== 'chunk' || chunk.imports.length || chunk.dynamicImports.length) + throw new Error('插件管理桥必须构建为单个独立脚本') + dependencies.clear() + for (const dependency of Object.keys(chunk.modules)) { + dependencies.add(dependency) + this.addWatchFile(dependency) + } + return `export default ${JSON.stringify(chunk.code)}` + }, + handleHotUpdate({ file, server }) { + if (dependencies.has(file)) { + const module = server.moduleGraph.getModuleById(resolvedId) + return module ? [module] : [] + } + }, + } +} diff --git a/frontend/env.d.ts b/frontend/env.d.ts index d7b433c2d..eec606cdc 100644 --- a/frontend/env.d.ts +++ b/frontend/env.d.ts @@ -2,3 +2,8 @@ declare module '@fontsource-variable/inter' declare module '@fontsource-variable/jetbrains-mono' + +declare module 'virtual:plugin-bridge-source' { + const source: string + export default source +} diff --git a/frontend/src/api/modules/plugins.ts b/frontend/src/api/modules/plugins.ts index fe4637112..231be7ff2 100644 --- a/frontend/src/api/modules/plugins.ts +++ b/frontend/src/api/modules/plugins.ts @@ -404,15 +404,6 @@ export function getPluginInstances(options: RequestOptions = {}) { }) } -export function createPluginInstance(data: ConfigurePluginInstanceRequest, options: RequestOptions = {}) { - return request({ - url: '/api/admin/plugins/instances', - method: 'POST', - data, - ...options, - }) -} - export function updatePluginInstance(data: { id: string, instance: ConfigurePluginInstanceRequest }, options: RequestOptions = {}) { return request({ url: '/api/admin/plugins/instances/update', diff --git a/frontend/src/views/plugins/composables/usePluginActions.ts b/frontend/src/views/plugins/composables/usePluginActions.ts new file mode 100644 index 000000000..be91b5053 --- /dev/null +++ b/frontend/src/views/plugins/composables/usePluginActions.ts @@ -0,0 +1,36 @@ +import type { Ref } from 'vue' +import { toast } from '@codex-proxy/ui' +import { ApiError } from '@/api/request' +import { errorMessage } from '@/utils/operation' + +export type PluginActionContext = ReturnType + +export function usePluginActions(refresh: (silent?: boolean, suppressErrors?: boolean) => Promise) { + function notifyError(title: string, error: unknown) { + if (error instanceof ApiError && error.kind === 'cancelled') + return + toast.error(errorMessage(error, title)) + } + + async function runAction( + flag: Ref, + title: string, + task: () => Promise, + ): Promise { + if (flag.value) + return undefined + flag.value = true + try { + return await task() + } + catch (error) { + notifyError(title, error) + return undefined + } + finally { + flag.value = false + } + } + + return { refresh, notifyError, runAction } +} diff --git a/frontend/src/views/plugins/composables/usePluginArtifactActions.ts b/frontend/src/views/plugins/composables/usePluginArtifactActions.ts new file mode 100644 index 000000000..1b3761590 --- /dev/null +++ b/frontend/src/views/plugins/composables/usePluginArtifactActions.ts @@ -0,0 +1,38 @@ +import type { PluginActionContext } from './usePluginActions' +import type { PluginArtifact } from '@/api' +import { toast } from '@codex-proxy/ui' +import { shallowRef } from 'vue' +import { deletePluginArtifact } from '@/api' + +export function usePluginArtifactActions({ refresh, notifyError }: Pick) { + const showArtifactDelete = shallowRef(false) + const pendingArtifact = shallowRef(null) + const busyDigest = shallowRef('') + + function requestArtifactDelete(artifact: PluginArtifact) { + pendingArtifact.value = artifact + showArtifactDelete.value = true + } + + async function confirmArtifactDelete() { + const artifact = pendingArtifact.value + if (!artifact || busyDigest.value) + return + busyDigest.value = artifact.metadata.sha256 + try { + await deletePluginArtifact({ sha256: artifact.metadata.sha256 }, { silent: true }) + showArtifactDelete.value = false + pendingArtifact.value = null + toast.success('插件制品已删除') + await refresh(true) + } + catch (error) { + notifyError('插件制品删除失败', error) + } + finally { + busyDigest.value = '' + } + } + + return { showArtifactDelete, pendingArtifact, busyDigest, requestArtifactDelete, confirmArtifactDelete } +} diff --git a/frontend/src/views/plugins/composables/usePluginCredentials.ts b/frontend/src/views/plugins/composables/usePluginCredentials.ts new file mode 100644 index 000000000..302954bdf --- /dev/null +++ b/frontend/src/views/plugins/composables/usePluginCredentials.ts @@ -0,0 +1,52 @@ +import type { Ref } from 'vue' +import type { PluginActionContext } from './usePluginActions' +import type { CreatePluginSourceCredentialRequest, PluginSourceCredential } from '@/api' +import { toast } from '@codex-proxy/ui' +import { shallowRef } from 'vue' +import { createPluginSourceCredential, deletePluginSourceCredential } from '@/api' + +interface CredentialContext extends PluginActionContext { + credentials: Ref +} + +export function usePluginCredentials({ credentials, refresh, notifyError, runAction }: CredentialContext) { + const savingCredential = shallowRef(false) + const showCredentialDelete = shallowRef(false) + const pendingCredential = shallowRef(null) + const busyDistributionId = shallowRef('') + + async function saveCredential(request: CreatePluginSourceCredentialRequest) { + const result = await runAction(savingCredential, '下载认证保存失败', () => createPluginSourceCredential(request, { silent: true })) + if (!result) + return null + credentials.value = [...credentials.value, result] + return result + } + + function requestCredentialDelete(credential: PluginSourceCredential) { + pendingCredential.value = credential + showCredentialDelete.value = true + } + + async function confirmCredentialDelete() { + const credential = pendingCredential.value + if (!credential || busyDistributionId.value) + return + busyDistributionId.value = credential.id + try { + await deletePluginSourceCredential({ id: credential.id }, { silent: true }) + showCredentialDelete.value = false + pendingCredential.value = null + toast.success('来源凭据已删除') + await refresh(true) + } + catch (error) { + notifyError('来源凭据删除失败', error) + } + finally { + busyDistributionId.value = '' + } + } + + return { savingCredential, showCredentialDelete, pendingCredential, busyDistributionId, saveCredential, requestCredentialDelete, confirmCredentialDelete } +} diff --git a/frontend/src/views/plugins/composables/usePluginInstances.ts b/frontend/src/views/plugins/composables/usePluginInstances.ts new file mode 100644 index 000000000..dc575bbdf --- /dev/null +++ b/frontend/src/views/plugins/composables/usePluginInstances.ts @@ -0,0 +1,199 @@ +import type { Ref } from 'vue' +import type { PluginActionContext } from './usePluginActions' +import type { ConfigurePluginInstanceRequest, PluginArtifact, PluginInstance, PluginVersionPlan } from '@/api' +import { toast } from '@codex-proxy/ui' +import { computed, shallowRef, watch } from 'vue' +import { deletePluginInstance, disablePluginInstance, getPluginArtifacts, getPluginInstances, updatePluginInstance } from '@/api' +import { configurationStatus } from '../utils/catalog' + +interface InstanceContext extends PluginActionContext { + artifacts: Ref + onEdit: (pluginId: string) => void + onSaved: () => void +} + +export function usePluginInstances({ artifacts, refresh, notifyError, runAction, onEdit, onSaved }: InstanceContext) { + const configurationArtifact = shallowRef(null) + const showInstance = shallowRef(false) + const configurationDraft = shallowRef(null) + const configurationError = shallowRef('') + const editingInstance = shallowRef(null) + const savingInstance = shallowRef(false) + const pendingEnable = shallowRef<{ + request: ConfigurePluginInstanceRequest + instanceId?: string + artifact: PluginArtifact + replacements: (Pick & { version?: string })[] + } | null>(null) + const showEnableConfirmation = computed({ + get: () => pendingEnable.value !== null, + set: (open: boolean) => { + if (!open && !savingInstance.value) + pendingEnable.value = null + }, + }) + const showInstanceDelete = shallowRef(false) + const pendingDeleteInstance = shallowRef(null) + const busyInstanceId = shallowRef('') + + function openEditInstance(instance: PluginInstance) { + configurationDraft.value = null + configurationError.value = '' + configurationArtifact.value = artifacts.value.find(value => value.metadata.sha256 === instance.artifactSha256) ?? null + onEdit(configurationArtifact.value?.metadata.pluginId ?? '') + editingInstance.value = instance + showInstance.value = true + } + + async function saveInstance(request: ConfigurePluginInstanceRequest) { + if (savingInstance.value || pendingEnable.value) + return + const instance = editingInstance.value + if (!instance) + return + const input = { ...request, expectedRevision: configurationDraft.value?.instanceRevision ?? instance.revision } + const result = await runAction(savingInstance, '插件配置保存失败', async () => { + if (request.enabled) { + const pending = await prepareInstanceEnable(input, instance?.id) + if (pending.replacements.length) { + pendingEnable.value = pending + return true + } + } + await persistInstance(input, instance?.id) + return true + }) + if (!result) + await refresh(true, true) + } + + async function prepareInstanceEnable(request: ConfigurePluginInstanceRequest, instanceId?: string) { + // 启用入口共用确认快照,不自动停用确认后出现或修改的配置。 + const [currentArtifacts, currentInstances] = await Promise.all([ + getPluginArtifacts({ silent: true }), + getPluginInstances({ silent: true }), + ]) + const artifact = currentArtifacts.find(artifact => artifact.metadata.sha256 === request.artifactSha256) + if (!artifact) + throw new Error('所选版本已不存在,请重新选择') + const digests = new Set(currentArtifacts.filter(value => value.metadata.pluginId === artifact.metadata.pluginId).map(value => value.metadata.sha256)) + const replacements = currentInstances + .filter(current => current.enabled && current.id !== (instanceId ?? request.creationId) && digests.has(current.artifactSha256)) + .map(({ id, name, revision, artifactSha256 }) => ({ id, name, revision, version: currentArtifacts.find(value => value.metadata.sha256 === artifactSha256)?.metadata.version })) + return { request, instanceId, artifact, replacements } + } + + async function requestInstanceEnable(instance: PluginInstance) { + if ((instance.enabled && configurationStatus(instance) !== 'failed') || savingInstance.value || pendingEnable.value) + return + if (instance.configurationRequired) { + openEditInstance(instance) + return + } + const result = await runAction(savingInstance, '启用配置加载失败', async () => { + // 不传 secrets,沿用已保存密钥,不读取或回填明文。 + const pending = await prepareInstanceEnable({ + expectedRevision: instance.revision, + name: instance.name, + artifactSha256: instance.artifactSha256, + enabled: true, + configuration: instance.configuration, + bindings: instance.bindings, + }, instance.id) + if (pending.replacements.length) + pendingEnable.value = pending + else await persistInstance(pending.request, instance.id) + return true + }) + if (!result) + await refresh(true, true) + } + + async function confirmInstanceEnable() { + const pending = pendingEnable.value + if (!pending || savingInstance.value) + return + const result = await runAction(savingInstance, '配置启用失败', async () => { + await persistInstance({ + ...pending.request, + replaceInstances: pending.replacements.map(instance => ({ id: instance.id, expectedRevision: instance.revision })), + }, pending.instanceId) + return true + }) + // 失败后保留编辑草稿或已保存配置,再次提交需重新读取并确认停用范围。 + pendingEnable.value = null + if (!result) + await refresh(true, true) + } + + async function persistInstance(request: ConfigurePluginInstanceRequest, instanceId?: string) { + if (!instanceId) + return + await updatePluginInstance({ id: instanceId, instance: request }, { silent: true }) + pendingEnable.value = null + showInstance.value = false + editingInstance.value = null + toast.success(request.replaceInstances?.length ? '已切换当前配置' : request.enabled ? '插件设置已应用' : '设置已保存,插件保持停用') + await refresh(true) + onSaved() + } + + async function requestInstanceDisable(instance: PluginInstance) { + if (busyInstanceId.value) + return + busyInstanceId.value = instance.id + try { + await disablePluginInstance({ id: instance.id }, { silent: true }) + toast.success('插件已停用') + await refresh(true) + } + catch (error) { + notifyError('插件停用失败', error) + } + finally { + busyInstanceId.value = '' + } + } + + function requestInstanceDelete(instance: PluginInstance) { + pendingDeleteInstance.value = instance + showInstanceDelete.value = true + } + + async function confirmInstanceDelete() { + const instance = pendingDeleteInstance.value + if (!instance || busyInstanceId.value) + return + busyInstanceId.value = instance.id + try { + await deletePluginInstance({ id: instance.id }, { silent: true }) + showInstanceDelete.value = false + pendingDeleteInstance.value = null + toast.success('配置、密钥与私有数据已删除,无法恢复') + await refresh(true) + } + catch (error) { + notifyError('插件实例删除失败', error) + } + finally { + busyInstanceId.value = '' + } + } + + watch(showInstance, (open) => { + if (!open && !savingInstance.value) { + editingInstance.value = null + pendingEnable.value = null + } + }) + + function editVersionPlan(instance: PluginInstance, artifact: PluginArtifact, plan: PluginVersionPlan, error: string) { + configurationArtifact.value = artifact + configurationDraft.value = plan + configurationError.value = error + editingInstance.value = instance + showInstance.value = true + } + + return { configurationArtifact, configurationDraft, configurationError, showInstance, editingInstance, savingInstance, pendingEnable, showEnableConfirmation, busyInstanceId, showInstanceDelete, pendingDeleteInstance, openEditInstance, saveInstance, requestInstanceEnable, confirmInstanceEnable, requestInstanceDisable, requestInstanceDelete, confirmInstanceDelete, editVersionPlan } +} diff --git a/frontend/src/views/plugins/composables/usePluginManagement.ts b/frontend/src/views/plugins/composables/usePluginManagement.ts index ce6055bff..7425ecd17 100644 --- a/frontend/src/views/plugins/composables/usePluginManagement.ts +++ b/frontend/src/views/plugins/composables/usePluginManagement.ts @@ -1,43 +1,30 @@ -import type { Ref } from 'vue' import type { PluginUpdateSelection } from './usePluginUpdateCheck' import type { - ConfigurePluginInstanceRequest, - CreatePluginSourceCredentialRequest, PluginArtifact, PluginArtifactMutationResponse, PluginInstance, - PluginRollbackPlan, PluginSourceCredential, PluginUpdateSourceBinding, - PluginVersionPlan, } from '@/api' -import { toast } from '@codex-proxy/ui' import { storeToRefs } from 'pinia' import { computed, onMounted, onScopeDispose, shallowRef, watch } from 'vue' import { - createPluginSourceCredential, - deletePluginArtifact, - deletePluginInstance, - deletePluginSourceCredential, - disablePluginInstance, getPluginArtifacts, getPluginInstances, - getPluginRollbackPlan, getPluginSourceCredentials, getPluginUpdateSources, - getPluginVersionPlan, - rollbackPluginInstance, - switchPluginVersion, - updatePluginInstance, } from '@/api' -import { ApiError } from '@/api/request' import { usePluginViewsStore } from '@/stores/modules/plugin-views' -import { errorMessage } from '@/utils/operation' -import { configurationStatus, currentPluginInstance, groupInstalledPlugins } from '../utils/catalog' +import { currentPluginInstance, groupInstalledPlugins } from '../utils/catalog' +import { usePluginActions } from './usePluginActions' +import { usePluginArtifactActions } from './usePluginArtifactActions' +import { usePluginCredentials } from './usePluginCredentials' import { usePluginInstallation } from './usePluginInstallation' +import { usePluginInstances } from './usePluginInstances' import { usePluginUninstall } from './usePluginUninstall' import { usePluginUpdateCheck } from './usePluginUpdateCheck' +import { usePluginVersions } from './usePluginVersions' export function usePluginManagement() { const artifacts = shallowRef([]) @@ -52,58 +39,11 @@ export function usePluginManagement() { const selectedPlugin = computed(() => catalog.value.find(plugin => plugin.id === selectedPluginId.value) ?? null) const showDetail = shallowRef(false) const detailSection = shallowRef<'configurations' | 'versions'>('configurations') - const configurationArtifact = shallowRef(null) - const showInstance = shallowRef(false) - const configurationDraft = shallowRef(null) - const configurationError = shallowRef('') - const editingInstance = shallowRef(null) - const savingInstance = shallowRef(false) - const pendingEnable = shallowRef<{ - request: ConfigurePluginInstanceRequest - instanceId?: string - artifact: PluginArtifact - replacements: (Pick & { version?: string })[] - } | null>(null) - const showEnableConfirmation = computed({ - get: () => pendingEnable.value !== null, - set: (open: boolean) => { - if (!open && !savingInstance.value) - pendingEnable.value = null - }, - }) - const showRollback = shallowRef(false) - const rollbackInstance = shallowRef(null) - const rollbackPlan = shallowRef(null) - const loadingRollback = shallowRef(false) - const savingCredential = shallowRef(false) - - const showArtifactDelete = shallowRef(false) - const pendingArtifact = shallowRef(null) - const showInstanceDelete = shallowRef(false) - const pendingDeleteInstance = shallowRef(null) - const showCredentialDelete = shallowRef(false) - const pendingCredential = shallowRef(null) - - const busyDigest = shallowRef('') - const busyInstanceId = shallowRef('') - const busyDistributionId = shallowRef('') - const pendingVersionSwitch = shallowRef<{ instance: PluginInstance, artifact: PluginArtifact, currentVersion: string } | null>(null) - const showVersionSwitch = computed({ - get: () => pendingVersionSwitch.value !== null, - set: (open: boolean) => { - if (!open && !busyInstanceId.value) - pendingVersionSwitch.value = null - }, - }) let refreshController: AbortController | undefined - let rollbackController: AbortController | undefined - function notifyError(title: string, error: unknown) { - if (error instanceof ApiError && error.kind === 'cancelled') - return - toast.error(errorMessage(error, title)) - } + const actions = usePluginActions(refresh) + const { notifyError, runAction } = actions async function refresh(silent = false, suppressErrors = false) { refreshController?.abort() @@ -144,25 +84,29 @@ export function usePluginManagement() { } } - async function runAction( - flag: Ref, - title: string, - task: () => Promise, - ): Promise { - if (flag.value) - return undefined - flag.value = true - try { - return await task() - } - catch (error) { - notifyError(title, error) - return undefined - } - finally { - flag.value = false - } + const artifactActions = usePluginArtifactActions(actions) + const credentialActions = usePluginCredentials({ ...actions, credentials }) + function showConfigurations() { + detailSection.value = 'configurations' + showDetail.value = true } + const instanceActions = usePluginInstances({ + ...actions, + artifacts, + onEdit: pluginId => selectedPluginId.value = pluginId, + onSaved: showConfigurations, + }) + const versionActions = usePluginVersions({ + ...actions, + catalog, + instances, + busyInstanceId: instanceActions.busyInstanceId, + onApplied: showConfigurations, + onConfigurationRequired: (...args) => { + instanceActions.editVersionPlan(...args) + showDetail.value = false + }, + }) const installation = usePluginInstallation({ runAction, @@ -187,51 +131,26 @@ export function usePluginManagement() { selectedPluginId.value = artifact.metadata.pluginId await refresh(true) if (switchTarget) { - await applyVersionSwitch(switchTarget, artifact) + await versionActions.applyVersionSwitch(switchTarget, artifact) return } if (configurationRequired && defaultInstanceId) { const instance = instances.value.find(instance => instance.id === defaultInstanceId) if (instance) { - openEditInstance(instance) + instanceActions.openEditInstance(instance) return } } const plugin = catalog.value.find(plugin => plugin.id === artifact.metadata.pluginId) const current = plugin && currentPluginInstance(plugin) if (current && current.artifactSha256 !== artifact.metadata.sha256) { - requestVersionSwitch(artifact) + versionActions.requestVersionSwitch(artifact) return } detailSection.value = 'configurations' showDetail.value = true } - function requestArtifactDelete(artifact: PluginArtifact) { - pendingArtifact.value = artifact - showArtifactDelete.value = true - } - - async function confirmArtifactDelete() { - const artifact = pendingArtifact.value - if (!artifact || busyDigest.value) - return - busyDigest.value = artifact.metadata.sha256 - try { - await deletePluginArtifact({ sha256: artifact.metadata.sha256 }, { silent: true }) - showArtifactDelete.value = false - pendingArtifact.value = null - toast.success('插件制品已删除') - await refresh(true) - } - catch (error) { - notifyError('插件制品删除失败', error) - } - finally { - busyDigest.value = '' - } - } - function openDetail(pluginId: string) { selectedPluginId.value = pluginId const plugin = catalog.value.find(value => value.id === pluginId) @@ -239,303 +158,6 @@ export function usePluginManagement() { showDetail.value = true } - function openEditInstance(instance: PluginInstance) { - configurationDraft.value = null - configurationError.value = '' - configurationArtifact.value = artifacts.value.find(value => value.metadata.sha256 === instance.artifactSha256) ?? null - selectedPluginId.value = configurationArtifact.value?.metadata.pluginId ?? '' - editingInstance.value = instance - showInstance.value = true - } - - async function saveInstance(request: ConfigurePluginInstanceRequest) { - if (savingInstance.value || pendingEnable.value) - return - const instance = editingInstance.value - if (!instance) - return - const input = { ...request, expectedRevision: configurationDraft.value?.instanceRevision ?? instance.revision } - const result = await runAction(savingInstance, '插件配置保存失败', async () => { - if (request.enabled) { - const pending = await prepareInstanceEnable(input, instance?.id) - if (pending.replacements.length) { - pendingEnable.value = pending - return true - } - } - await persistInstance(input, instance?.id) - return true - }) - if (!result) - await refresh(true, true) - } - - async function prepareInstanceEnable(request: ConfigurePluginInstanceRequest, instanceId?: string) { - // 启用入口共用确认快照,不自动停用确认后出现或修改的配置。 - const [currentArtifacts, currentInstances] = await Promise.all([ - getPluginArtifacts({ silent: true }), - getPluginInstances({ silent: true }), - ]) - const artifact = currentArtifacts.find(artifact => artifact.metadata.sha256 === request.artifactSha256) - if (!artifact) - throw new Error('所选版本已不存在,请重新选择') - const digests = new Set(currentArtifacts.filter(value => value.metadata.pluginId === artifact.metadata.pluginId).map(value => value.metadata.sha256)) - const replacements = currentInstances - .filter(current => current.enabled && current.id !== (instanceId ?? request.creationId) && digests.has(current.artifactSha256)) - .map(({ id, name, revision, artifactSha256 }) => ({ id, name, revision, version: currentArtifacts.find(value => value.metadata.sha256 === artifactSha256)?.metadata.version })) - return { request, instanceId, artifact, replacements } - } - - async function requestInstanceEnable(instance: PluginInstance) { - if ((instance.enabled && configurationStatus(instance) !== 'failed') || savingInstance.value || pendingEnable.value) - return - if (instance.configurationRequired) { - openEditInstance(instance) - return - } - const result = await runAction(savingInstance, '启用配置加载失败', async () => { - // 不传 secrets,沿用已保存密钥,不读取或回填明文。 - const pending = await prepareInstanceEnable({ - expectedRevision: instance.revision, - name: instance.name, - artifactSha256: instance.artifactSha256, - enabled: true, - configuration: instance.configuration, - bindings: instance.bindings, - }, instance.id) - if (pending.replacements.length) - pendingEnable.value = pending - else await persistInstance(pending.request, instance.id) - return true - }) - if (!result) - await refresh(true, true) - } - - async function confirmInstanceEnable() { - const pending = pendingEnable.value - if (!pending || savingInstance.value) - return - const result = await runAction(savingInstance, '配置启用失败', async () => { - await persistInstance({ - ...pending.request, - replaceInstances: pending.replacements.map(instance => ({ id: instance.id, expectedRevision: instance.revision })), - }, pending.instanceId) - return true - }) - // 失败后保留编辑草稿或已保存配置,再次提交需重新读取并确认停用范围。 - pendingEnable.value = null - if (!result) - await refresh(true, true) - } - - async function persistInstance(request: ConfigurePluginInstanceRequest, instanceId?: string) { - if (!instanceId) - return - await updatePluginInstance({ id: instanceId, instance: request }, { silent: true }) - pendingEnable.value = null - showInstance.value = false - editingInstance.value = null - toast.success(request.replaceInstances?.length ? '已切换当前配置' : request.enabled ? '插件设置已应用' : '设置已保存,插件保持停用') - await refresh(true) - detailSection.value = 'configurations' - showDetail.value = true - } - - async function requestInstanceDisable(instance: PluginInstance) { - if (busyInstanceId.value) - return - busyInstanceId.value = instance.id - try { - await disablePluginInstance({ id: instance.id }, { silent: true }) - toast.success('插件已停用') - await refresh(true) - } - catch (error) { - notifyError('插件停用失败', error) - } - finally { - busyInstanceId.value = '' - } - } - - function requestVersionSwitch(artifact: PluginArtifact) { - const plugin = catalog.value.find(plugin => plugin.id === artifact.metadata.pluginId) - const instance = plugin && currentPluginInstance(plugin) - if (!plugin || !instance || !artifact.acceptedAt || busyInstanceId.value || instance.artifactSha256 === artifact.metadata.sha256) - return - pendingVersionSwitch.value = { instance, artifact, currentVersion: plugin.artifact.metadata.version } - } - - async function confirmVersionSwitch() { - if (!pendingVersionSwitch.value || busyInstanceId.value) - return - const { instance, artifact } = pendingVersionSwitch.value - await applyVersionSwitch(instance, artifact) - } - - async function applyVersionSwitch(instance: PluginInstance, artifact: PluginArtifact) { - busyInstanceId.value = instance.id - try { - await switchPluginVersion({ - id: instance.id, - target: { artifactSha256: artifact.metadata.sha256, expectedRevision: instance.revision }, - }, { silent: true }) - pendingVersionSwitch.value = null - toast.success(`已切换至 ${artifact.metadata.version}`) - await refresh(true) - detailSection.value = 'configurations' - showDetail.value = true - } - catch (error) { - await refresh(true, true) - const current = instances.value.find(value => value.id === instance.id) - if (error instanceof ApiError && error.status === 400 && current?.revision === instance.revision) { - try { - const plan = await getPluginVersionPlan(instance.id, artifact.metadata.sha256, { silent: true }) - if (plan.instanceRevision !== instance.revision) - throw new Error('插件设置已变更,请刷新后重试') - configurationArtifact.value = artifact - pendingVersionSwitch.value = null - configurationDraft.value = plan - configurationError.value = errorMessage(error, '请调整不兼容的设置后重试') - editingInstance.value = current - showDetail.value = false - showInstance.value = true - } - catch (planError) { - notifyError('版本设置加载失败', planError) - } - } - else { - notifyError('版本切换失败', error) - } - } - finally { - busyInstanceId.value = '' - } - } - - async function openRollback(instance: PluginInstance) { - rollbackController?.abort() - const controller = new AbortController() - rollbackController = controller - rollbackInstance.value = instance - rollbackPlan.value = null - showRollback.value = true - loadingRollback.value = true - try { - const plan = await getPluginRollbackPlan(instance.id, { signal: controller.signal, silent: true }) - if (rollbackController === controller) - rollbackPlan.value = plan - } - catch (error) { - if (rollbackController === controller) - notifyError('回退版本加载失败', error) - } - finally { - if (rollbackController === controller) { - rollbackController = undefined - loadingRollback.value = false - } - } - } - - async function confirmRollback(artifactSha256: string) { - const instance = rollbackInstance.value - const plan = rollbackPlan.value - const target = plan?.targets.find(target => target.artifactSha256 === artifactSha256) - if (!instance || !plan || !target || busyInstanceId.value) - return - busyInstanceId.value = instance.id - try { - await rollbackPluginInstance({ id: instance.id, target: { artifactSha256, expectedRevision: plan.instanceRevision } }, { silent: true }) - showRollback.value = false - rollbackInstance.value = null - rollbackPlan.value = null - toast.success(`已回退至 ${target.version},并恢复对应设置`) - await refresh(true) - } - catch (error) { - notifyError('插件版本回退失败', error) - } - finally { - busyInstanceId.value = '' - } - } - - function requestInstanceDelete(instance: PluginInstance) { - pendingDeleteInstance.value = instance - showInstanceDelete.value = true - } - - async function confirmInstanceDelete() { - const instance = pendingDeleteInstance.value - if (!instance || busyInstanceId.value) - return - busyInstanceId.value = instance.id - try { - await deletePluginInstance({ id: instance.id }, { silent: true }) - showInstanceDelete.value = false - pendingDeleteInstance.value = null - toast.success('配置、密钥与私有数据已删除,无法恢复') - await refresh(true) - } - catch (error) { - notifyError('插件实例删除失败', error) - } - finally { - busyInstanceId.value = '' - } - } - - async function saveCredential(request: CreatePluginSourceCredentialRequest) { - const result = await runAction(savingCredential, '下载认证保存失败', () => createPluginSourceCredential(request, { silent: true })) - if (!result) - return null - credentials.value = [...credentials.value, result] - return result - } - - function requestCredentialDelete(credential: PluginSourceCredential) { - pendingCredential.value = credential - showCredentialDelete.value = true - } - - async function confirmCredentialDelete() { - const credential = pendingCredential.value - if (!credential || busyDistributionId.value) - return - busyDistributionId.value = credential.id - try { - await deletePluginSourceCredential({ id: credential.id }, { silent: true }) - showCredentialDelete.value = false - pendingCredential.value = null - toast.success('来源凭据已删除') - await refresh(true) - } - catch (error) { - notifyError('来源凭据删除失败', error) - } - finally { - busyDistributionId.value = '' - } - } - - watch(showInstance, (open) => { - if (!open && !savingInstance.value) { - editingInstance.value = null - pendingEnable.value = null - } - }) - watch(showRollback, (open) => { - if (!open) { - rollbackController?.abort() - rollbackController = undefined - loadingRollback.value = false - } - }) - onMounted(() => void refresh()) const uninstall = usePluginUninstall(() => refresh(true), notifyError) let pollTimer: ReturnType | undefined @@ -560,11 +182,14 @@ export function usePluginManagement() { disposed = true clearTimeout(pollTimer) refreshController?.abort() - rollbackController?.abort() }) return { ...installation, + ...artifactActions, + ...credentialActions, + ...instanceActions, + ...versionActions, updateCheck, upgradeCheckedPlugin, catalog, @@ -572,9 +197,6 @@ export function usePluginManagement() { showDetail, detailSection, openDetail, - configurationArtifact, - configurationDraft, - configurationError, uninstall, artifacts, instances, @@ -582,43 +204,6 @@ export function usePluginManagement() { credentials, extensions, loading, - showInstance, - editingInstance, - savingInstance, - pendingEnable, - showEnableConfirmation, - confirmInstanceEnable, - showRollback, - rollbackInstance, - rollbackPlan, - loadingRollback, - openRollback, - confirmRollback, - savingCredential, - requestVersionSwitch, - confirmVersionSwitch, - pendingVersionSwitch, - showVersionSwitch, - showArtifactDelete, - pendingArtifact, - showInstanceDelete, - pendingDeleteInstance, - showCredentialDelete, - pendingCredential, - busyDigest, - busyInstanceId, - busyDistributionId, refresh, - requestArtifactDelete, - confirmArtifactDelete, - openEditInstance, - saveInstance, - requestInstanceEnable, - requestInstanceDisable, - requestInstanceDelete, - confirmInstanceDelete, - saveCredential, - requestCredentialDelete, - confirmCredentialDelete, } } diff --git a/frontend/src/views/plugins/composables/usePluginVersions.ts b/frontend/src/views/plugins/composables/usePluginVersions.ts new file mode 100644 index 000000000..1c2ccc6e1 --- /dev/null +++ b/frontend/src/views/plugins/composables/usePluginVersions.ts @@ -0,0 +1,146 @@ +import type { Ref } from 'vue' +import type { InstalledPlugin } from '../utils/catalog' +import type { PluginActionContext } from './usePluginActions' +import type { PluginArtifact, PluginInstance, PluginRollbackPlan, PluginVersionPlan } from '@/api' +import { toast } from '@codex-proxy/ui' +import { computed, onScopeDispose, shallowRef, watch } from 'vue' +import { getPluginRollbackPlan, getPluginVersionPlan, rollbackPluginInstance, switchPluginVersion } from '@/api' +import { ApiError } from '@/api/request' +import { errorMessage } from '@/utils/operation' +import { currentPluginInstance } from '../utils/catalog' + +interface VersionContext extends Pick { + catalog: Ref + instances: Ref + busyInstanceId: Ref + onApplied: () => void + onConfigurationRequired: (instance: PluginInstance, artifact: PluginArtifact, plan: PluginVersionPlan, error: string) => void +} + +export function usePluginVersions({ catalog, instances, busyInstanceId, refresh, notifyError, onApplied, onConfigurationRequired }: VersionContext) { + const showRollback = shallowRef(false) + const rollbackInstance = shallowRef(null) + const rollbackPlan = shallowRef(null) + const loadingRollback = shallowRef(false) + + const pendingVersionSwitch = shallowRef<{ instance: PluginInstance, artifact: PluginArtifact, currentVersion: string } | null>(null) + const showVersionSwitch = computed({ + get: () => pendingVersionSwitch.value !== null, + set: (open: boolean) => { + if (!open && !busyInstanceId.value) + pendingVersionSwitch.value = null + }, + }) + let rollbackController: AbortController | undefined + + function requestVersionSwitch(artifact: PluginArtifact) { + const plugin = catalog.value.find(plugin => plugin.id === artifact.metadata.pluginId) + const instance = plugin && currentPluginInstance(plugin) + if (!plugin || !instance || !artifact.acceptedAt || busyInstanceId.value || instance.artifactSha256 === artifact.metadata.sha256) + return + pendingVersionSwitch.value = { instance, artifact, currentVersion: plugin.artifact.metadata.version } + } + + async function confirmVersionSwitch() { + if (!pendingVersionSwitch.value || busyInstanceId.value) + return + const { instance, artifact } = pendingVersionSwitch.value + await applyVersionSwitch(instance, artifact) + } + + async function applyVersionSwitch(instance: PluginInstance, artifact: PluginArtifact) { + busyInstanceId.value = instance.id + try { + await switchPluginVersion({ + id: instance.id, + target: { artifactSha256: artifact.metadata.sha256, expectedRevision: instance.revision }, + }, { silent: true }) + pendingVersionSwitch.value = null + toast.success(`已切换至 ${artifact.metadata.version}`) + await refresh(true) + onApplied() + } + catch (error) { + await refresh(true, true) + const current = instances.value.find(value => value.id === instance.id) + if (error instanceof ApiError && error.status === 400 && current?.revision === instance.revision) { + try { + const plan = await getPluginVersionPlan(instance.id, artifact.metadata.sha256, { silent: true }) + if (plan.instanceRevision !== instance.revision) + throw new Error('插件设置已变更,请刷新后重试') + pendingVersionSwitch.value = null + onConfigurationRequired(current, artifact, plan, errorMessage(error, '请调整不兼容的设置后重试')) + } + catch (planError) { + notifyError('版本设置加载失败', planError) + } + } + else { + notifyError('版本切换失败', error) + } + } + finally { + busyInstanceId.value = '' + } + } + + async function openRollback(instance: PluginInstance) { + rollbackController?.abort() + const controller = new AbortController() + rollbackController = controller + rollbackInstance.value = instance + rollbackPlan.value = null + showRollback.value = true + loadingRollback.value = true + try { + const plan = await getPluginRollbackPlan(instance.id, { signal: controller.signal, silent: true }) + if (rollbackController === controller) + rollbackPlan.value = plan + } + catch (error) { + if (rollbackController === controller) + notifyError('回退版本加载失败', error) + } + finally { + if (rollbackController === controller) { + rollbackController = undefined + loadingRollback.value = false + } + } + } + + async function confirmRollback(artifactSha256: string) { + const instance = rollbackInstance.value + const plan = rollbackPlan.value + const target = plan?.targets.find(target => target.artifactSha256 === artifactSha256) + if (!instance || !plan || !target || busyInstanceId.value) + return + busyInstanceId.value = instance.id + try { + await rollbackPluginInstance({ id: instance.id, target: { artifactSha256, expectedRevision: plan.instanceRevision } }, { silent: true }) + showRollback.value = false + rollbackInstance.value = null + rollbackPlan.value = null + toast.success(`已回退至 ${target.version},并恢复对应设置`) + await refresh(true) + } + catch (error) { + notifyError('插件版本回退失败', error) + } + finally { + busyInstanceId.value = '' + } + } + + watch(showRollback, (open) => { + if (!open) { + rollbackController?.abort() + rollbackController = undefined + loadingRollback.value = false + } + }) + + onScopeDispose(() => rollbackController?.abort()) + + return { showRollback, rollbackInstance, rollbackPlan, loadingRollback, pendingVersionSwitch, showVersionSwitch, requestVersionSwitch, confirmVersionSwitch, applyVersionSwitch, openRollback, confirmRollback } +} diff --git a/frontend/src/views/plugins/management/bridge-contract.ts b/frontend/src/views/plugins/management/bridge-contract.ts new file mode 100644 index 000000000..2dac5814c --- /dev/null +++ b/frontend/src/views/plugins/management/bridge-contract.ts @@ -0,0 +1,37 @@ +export const PLUGIN_MANAGEMENT_BRIDGE = 'codex-proxy-plugin-management' +export const PLUGIN_MANAGEMENT_BRIDGE_VERSION = 2 +export const MAXIMUM_MANAGEMENT_BODY_BYTES = 1024 * 1024 +export const MAXIMUM_MANAGEMENT_QUERY_BYTES = 8192 +export const MAXIMUM_MANAGEMENT_IN_FLIGHT = 8 +export const MAXIMUM_MODEL_BODY_BYTES = 8 * 1024 * 1024 +export const MAXIMUM_MODEL_CHUNK_BYTES = 64 * 1024 +export const MAXIMUM_MODEL_IN_FLIGHT = 4 +export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000 +export const MODEL_RESPONSE_IDLE_MS = 30 * 1000 + +export interface PluginFrameTheme { + name: 'light' | 'dark' + tokens: Record +} + +export interface PluginBridgeInitial { + target: string + version: number + channel: string + session: string + parentOrigin: string + plugin: { name: string } + page: { id: string, title: string } + theme: PluginFrameTheme + viewportHeight: number + resourceUrls: Record + managementTimeoutMs: number + apiTimeoutMs: number +} + +export function hasControlCharacter(value: string) { + return [...value].some((character) => { + const code = character.codePointAt(0) ?? 0 + return code < 0x20 || code === 0x7F + }) +} diff --git a/frontend/src/views/plugins/management/bridge-runtime.ts b/frontend/src/views/plugins/management/bridge-runtime.ts new file mode 100644 index 000000000..10e6a3d80 --- /dev/null +++ b/frontend/src/views/plugins/management/bridge-runtime.ts @@ -0,0 +1,475 @@ +import type { PluginBridgeInitial } from './bridge-contract' +import { hasControlCharacter, MAXIMUM_MANAGEMENT_BODY_BYTES, MAXIMUM_MANAGEMENT_IN_FLIGHT, MAXIMUM_MANAGEMENT_QUERY_BYTES, MAXIMUM_MODEL_BODY_BYTES, MAXIMUM_MODEL_CHUNK_BYTES, MAXIMUM_MODEL_IN_FLIGHT, MODEL_REQUEST_DEADLINE_MS } from './bridge-contract' + +interface PendingOperation { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: number +} + +interface ModelOperation { + id: number + resolve: (value: Response) => void + reject: (error: Error) => void + signal?: AbortSignal + abort?: () => void + timer?: number + controller?: ReadableStreamDefaultController> + responseStarted: boolean + pullPromise?: Promise + pullResolve?: () => void + pullReject?: (error: Error) => void + pullSequence: number + pullId?: number +} + +interface ModelMessage { + id: number + type: string + payload?: Record + error?: unknown +} + +interface ManagementRequest { + method: string + path: string + query?: string + contentType?: string + body?: string | ArrayBuffer | ArrayBufferView +} + +interface ModelRequest { + clientKeyId: string + body: Record + signal?: AbortSignal +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +export default function installPluginBridge(initial: PluginBridgeInitial) { + const pending = new Map() + const modelOperations = new Map() + let sequence = 0 + let resizeObserver: ResizeObserver | undefined + let resizeFrame = 0 + let previousHeight = 0 + let disposed = false + let currentTheme = initial.theme.name + const maximumBodyBytes = MAXIMUM_MANAGEMENT_BODY_BYTES + const maximumQueryBytes = MAXIMUM_MANAGEMENT_QUERY_BYTES + const maximumInFlight = MAXIMUM_MANAGEMENT_IN_FLIGHT + const maximumModelBodyBytes = MAXIMUM_MODEL_BODY_BYTES + const maximumModelChunkBytes = MAXIMUM_MODEL_CHUNK_BYTES + const maximumModelInFlight = MAXIMUM_MODEL_IN_FLIGHT + const modelRequestDeadlineMs = MODEL_REQUEST_DEADLINE_MS + + function applyLayout(height: unknown) { + if (typeof height === 'number' && Number.isSafeInteger(height) && height >= 0) { + document.documentElement.style.setProperty('--cp-plugin-viewport-height', `${height}px`) + } + } + + function applyTheme(theme: unknown) { + if (!isRecord(theme) || (theme.name !== 'light' && theme.name !== 'dark') || !theme.tokens || typeof theme.tokens !== 'object') + return + const root = document.documentElement + root.dataset.theme = theme.name + root.style.colorScheme = theme.name + for (const [name, value] of Object.entries(theme.tokens)) { + if (typeof name === 'string' && name.startsWith('--cp-') && typeof value === 'string') + root.style.setProperty(name, value) + } + currentTheme = theme.name + window.dispatchEvent(new CustomEvent('codex-proxy-themechange', { detail: { theme: currentTheme } })) + } + + async function bodyBytes(body: unknown): Promise { + if (body == null) + return new ArrayBuffer(0) + if (typeof body === 'string') + return new TextEncoder().encode(body).buffer + if (body instanceof ArrayBuffer) + return body.slice(0) + if (ArrayBuffer.isView(body)) + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength).slice().buffer + throw new TypeError('请求正文只支持 string、ArrayBuffer 或 TypedArray') + } + + function nextRequestId() { + sequence += 1 + if (!Number.isSafeInteger(sequence)) + throw new Error('插件页面请求序号已耗尽') + return sequence + } + + function post(type: string, id: number, payload?: unknown, transfer: Transferable[] = []) { + parent.postMessage({ + target: initial.target, + version: initial.version, + channel: initial.channel, + session: initial.session, + type, + id, + payload, + }, initial.parentOrigin, transfer) + } + + function invoke(kind: string, payload: unknown, transfer: Transferable[] = []): Promise { + if (disposed) + return Promise.reject(new Error('插件页面已关闭')) + if (pending.size >= maximumInFlight) + return Promise.reject(new Error('插件管理请求已达并发上限')) + const id = nextRequestId() + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + pending.delete(id) + reject(new Error('插件管理请求超时')) + }, kind === 'request' ? initial.managementTimeoutMs : initial.apiTimeoutMs) + pending.set(id, { resolve, reject, timer }) + post(kind, id, payload, transfer) + }) + } + + function reportContentHeight() { + // 不读 documentElement.scrollHeight,避免当前 iframe 高度阻止内容收缩。 + const height = Math.max(1, Math.ceil(document.body.getBoundingClientRect().height)) + if (height === previousHeight) + return + previousHeight = height + post('resize', nextRequestId(), { height }) + } + + async function observeContentHeight() { + // load 后先触发布局,让实际使用的字体进入加载集合,再等待字体与布局就绪。 + document.body.getBoundingClientRect() + await document.fonts.ready + if (disposed) + return + // 首次上报不依赖渲染回调,后台或视口外的 iframe 也能完成初始化。 + reportContentHeight() + resizeObserver = new ResizeObserver(() => { + if (resizeFrame) + return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = 0 + reportContentHeight() + }) + }) + resizeObserver.observe(document.body, { box: 'border-box' }) + } + + window.addEventListener('load', observeContentHeight, { once: true }) + + async function request(input: ManagementRequest) { + if (!input || typeof input !== 'object') + throw new TypeError('插件管理请求无效') + const method = String(input.method || '').toUpperCase() + const path = String(input.path || '') + const query = input.query == null ? '' : String(input.query) + const contentType = input.contentType == null ? undefined : String(input.contentType) + if (new TextEncoder().encode(query).byteLength > maximumQueryBytes || query.includes('#') || query.startsWith('?')) + throw new TypeError('插件管理查询参数无效') + const body = await bodyBytes(input.body) + if (body.byteLength > maximumBodyBytes) + throw new TypeError('插件管理请求正文超过 1 MiB') + return invoke('request', { method, path, query, contentType, body }, [body]) + } + + function callbackTicket(input: { path: string, ttlSeconds: number }) { + if (!input || typeof input !== 'object') + return Promise.reject(new TypeError('插件回调票据请求无效')) + return invoke('callback-ticket', { path: String(input.path || ''), ttlSeconds: Number(input.ttlSeconds) }) + } + + function abortError(reason: unknown) { + return reason instanceof Error ? reason : new DOMException('模型请求已取消', 'AbortError') + } + + function clearModelOperation(operation: ModelOperation) { + if (modelOperations.get(operation.id) !== operation) + return + modelOperations.delete(operation.id) + clearTimeout(operation.timer) + if (operation.signal && operation.abort) + operation.signal.removeEventListener('abort', operation.abort) + } + + function settleModelPull(operation: ModelOperation, error?: Error) { + const resolve = operation.pullResolve + const reject = operation.pullReject + operation.pullResolve = undefined + operation.pullReject = undefined + operation.pullPromise = undefined + operation.pullId = undefined + if (error) + reject?.(error) + else resolve?.() + } + + function failModelOperation(operation: ModelOperation, error: Error, notifyParent: boolean) { + if (modelOperations.get(operation.id) !== operation) + return + clearModelOperation(operation) + settleModelPull(operation, error) + if (operation.controller) { + try { + operation.controller.error(error) + } + catch {} + } + else { + operation.reject(error) + } + if (notifyParent) + post('models-cancel', operation.id) + } + + function finishModelOperation(operation: ModelOperation) { + if (modelOperations.get(operation.id) !== operation) + return + clearModelOperation(operation) + settleModelPull(operation) + } + + function validateResponseHeaders(value: unknown) { + if (!Array.isArray(value)) + throw new Error('模型响应头无效') + for (const entry of value) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') + throw new Error('模型响应头无效') + } + // 与宿主共享浏览器的 Headers 校验,不再按插件身份过滤字段。 + return new Headers(value as [string, string][]) + } + + function handleModelMessage(message: ModelMessage) { + const operation = modelOperations.get(message.id) + if (!operation) + return + if (message.type === 'models-error') { + const text = typeof message.error === 'string' && message.error ? message.error : '模型请求失败' + failModelOperation(operation, new Error(text), false) + return + } + if (message.type === 'models-response') { + if (operation.controller || operation.responseStarted || !message.payload || typeof message.payload !== 'object') { + failModelOperation(operation, new Error('模型响应协议无效'), true) + return + } + try { + const status = message.payload.status + const statusText = message.payload.statusText ?? '' + const hasBody = message.payload.hasBody + if (typeof status !== 'number' || !Number.isSafeInteger(status) || status < 200 || status > 599 || typeof statusText !== 'string' || statusText.length > 256 || hasControlCharacter(statusText) || typeof hasBody !== 'boolean') + throw new Error('模型响应状态无效') + const headers = validateResponseHeaders(message.payload.headers) + operation.responseStarted = true + if (!hasBody) { + finishModelOperation(operation) + operation.resolve(new Response(null, { status, statusText, headers })) + return + } + const stream = new ReadableStream({ + start(controller) { + operation.controller = controller + }, + pull() { + if (modelOperations.get(operation.id) !== operation) + return undefined + if (operation.pullPromise) + return operation.pullPromise + operation.pullSequence += 1 + operation.pullId = operation.pullSequence + operation.pullPromise = new Promise((resolve, reject) => { + operation.pullResolve = resolve + operation.pullReject = reject + post('models-pull', operation.id, { pullId: operation.pullId }) + }) + return operation.pullPromise + }, + cancel() { + if (modelOperations.get(operation.id) === operation) { + clearModelOperation(operation) + settleModelPull(operation) + post('models-cancel', operation.id) + } + }, + }, { highWaterMark: 0 }) + operation.resolve(new Response(stream, { status, statusText, headers })) + } + catch (error) { + failModelOperation(operation, error instanceof Error ? error : new Error('模型响应协议无效'), true) + } + return + } + if (!operation.controller || !operation.responseStarted) { + failModelOperation(operation, new Error('模型响应协议无效'), true) + return + } + if (message.type === 'models-chunk') { + const pullId = message.payload?.pullId + const body = message.payload?.body + if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId || !(body instanceof ArrayBuffer) || body.byteLength === 0 || body.byteLength > maximumModelChunkBytes) { + failModelOperation(operation, new Error('模型响应分块无效'), true) + return + } + try { + operation.controller.enqueue(new Uint8Array(body)) + settleModelPull(operation) + } + catch (error) { + failModelOperation(operation, error instanceof Error ? error : new Error('模型响应读取失败'), true) + } + return + } + if (message.type === 'models-complete') { + const pullId = message.payload?.pullId + if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId) { + failModelOperation(operation, new Error('模型响应结束标记无效'), true) + return + } + try { + operation.controller.close() + } + catch {} + finishModelOperation(operation) + } + } + + function modelResponses(input: ModelRequest): Promise { + if (disposed) + return Promise.reject(new Error('插件页面已关闭')) + if (!input || typeof input !== 'object' || !input.body || typeof input.body !== 'object' || Array.isArray(input.body)) + return Promise.reject(new TypeError('Responses 请求正文必须是 JSON 对象')) + if (typeof input.clientKeyId !== 'string' || !input.clientKeyId || input.clientKeyId !== input.clientKeyId.trim() || input.clientKeyId.length > 128 || hasControlCharacter(input.clientKeyId)) + return Promise.reject(new TypeError('clientKeyId 无效')) + if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) + return Promise.reject(new TypeError('signal 必须是 AbortSignal')) + if (input.signal?.aborted) + return Promise.reject(abortError(input.signal.reason)) + if (modelOperations.size >= maximumModelInFlight) + return Promise.reject(new Error('模型请求已达并发上限')) + let body + try { + body = JSON.stringify(input.body) + } + catch { + return Promise.reject(new TypeError('Responses 请求正文必须可序列化为 JSON')) + } + if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > maximumModelBodyBytes) + return Promise.reject(new TypeError('Responses 请求正文超过 8 MiB')) + const id = nextRequestId() + return new Promise((resolve, reject) => { + const operation: ModelOperation = { + id, + resolve, + reject, + signal: input.signal, + abort: undefined, + timer: undefined, + controller: undefined, + responseStarted: false, + pullPromise: undefined, + pullResolve: undefined, + pullReject: undefined, + pullSequence: 0, + pullId: undefined, + } + operation.abort = () => failModelOperation(operation, abortError(input.signal?.reason), true) + operation.timer = window.setTimeout(() => failModelOperation(operation, new Error('模型请求超过 10 分钟'), true), modelRequestDeadlineMs) + modelOperations.set(id, operation) + input.signal?.addEventListener('abort', operation.abort, { once: true }) + post('models-request', id, { clientKeyId: input.clientKeyId, body }) + }) + } + + document.addEventListener('click', (event) => { + if (event.defaultPrevented || !(event.target instanceof Element)) + return + const submitter = event.target.closest('button, input') + if (!(submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement)) + return + const type = submitter.type.toLowerCase() + if (type !== 'submit' && type !== 'image') + return + const form = submitter.form + if (!form) + return + event.preventDefault() + queueMicrotask(() => { + if (!form.isConnected || !submitter.isConnected) + return + if (!submitter.formNoValidate && !form.reportValidity()) + return + form.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter })) + }) + }) + + window.addEventListener('message', (event) => { + if (event.source !== parent || event.origin !== initial.parentOrigin) + return + const message: unknown = event.data + if (!isRecord(message) || message.target !== initial.target || message.version !== initial.version || message.channel !== initial.channel || message.session !== initial.session) + return + if (message.type === 'theme') { + applyTheme(message.payload) + return + } + if (message.type === 'layout') { + applyLayout(isRecord(message.payload) ? message.payload.viewportHeight : undefined) + return + } + if (typeof message.id !== 'number' || !Number.isSafeInteger(message.id) || message.id < 1) + return + if (message.type === 'models-response' || message.type === 'models-chunk' || message.type === 'models-complete' || message.type === 'models-error') { + handleModelMessage({ id: message.id, type: message.type, error: message.error, payload: isRecord(message.payload) ? message.payload : undefined }) + return + } + if (message.type !== 'result') + return + const operation = pending.get(message.id) + if (!operation) + return + pending.delete(message.id) + clearTimeout(operation.timer) + if (message.ok) + operation.resolve(message.payload) + else operation.reject(new Error(typeof message.error === 'string' ? message.error : '插件管理请求失败')) + }) + + window.addEventListener('pagehide', () => { + disposed = true + resizeObserver?.disconnect() + cancelAnimationFrame(resizeFrame) + const error = new Error('插件页面已关闭') + for (const operation of pending.values()) { + clearTimeout(operation.timer) + operation.reject(error) + } + pending.clear() + for (const operation of [...modelOperations.values()]) + failModelOperation(operation, error, true) + }, { once: true }) + + const api = { + version: initial.version, + plugin: Object.freeze(initial.plugin), + page: Object.freeze(initial.page), + get theme() { return currentTheme }, + request, + callbackTicket, + models: Object.freeze({ responses: modelResponses }), + resourceUrl(path: string) { + const key = String(path) + if (!Object.hasOwn(initial.resourceUrls, key)) + throw new Error('资源未注册或不可作为页面子资源') + const value = initial.resourceUrls[key] + return value + }, + } + Object.defineProperty(window, 'codexProxyPlugin', { value: Object.freeze(api), configurable: false, writable: false }) + applyTheme(initial.theme) + applyLayout(initial.viewportHeight) +} diff --git a/frontend/src/views/plugins/management/frame.ts b/frontend/src/views/plugins/management/frame.ts index 8b2665630..cf8734db3 100644 --- a/frontend/src/views/plugins/management/frame.ts +++ b/frontend/src/views/plugins/management/frame.ts @@ -1,3 +1,4 @@ +import type { PluginBridgeInitial, PluginFrameTheme } from './bridge-contract' import type { PluginManagementPage, PluginManagementResource, @@ -5,20 +6,13 @@ import type { } from '@/api' import { THEME_TOKEN_NAMES } from '@codex-proxy/ui/theme' import { generate, parse, walk } from 'css-tree' - +import bridgeSource from 'virtual:plugin-bridge-source' import { getPluginManagementResource, PLUGIN_MANAGEMENT_TIMEOUT_MS } from '@/api' -import { API_TIMEOUT_MS } from '@/api/constants' -export const PLUGIN_MANAGEMENT_BRIDGE = 'codex-proxy-plugin-management' -export const PLUGIN_MANAGEMENT_BRIDGE_VERSION = 2 -export const MAXIMUM_MANAGEMENT_BODY_BYTES = 1024 * 1024 -export const MAXIMUM_MANAGEMENT_QUERY_BYTES = 8192 -export const MAXIMUM_MANAGEMENT_IN_FLIGHT = 8 -export const MAXIMUM_MODEL_BODY_BYTES = 8 * 1024 * 1024 -export const MAXIMUM_MODEL_CHUNK_BYTES = 64 * 1024 -export const MAXIMUM_MODEL_IN_FLIGHT = 4 -export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000 -export const MODEL_RESPONSE_IDLE_MS = 30 * 1000 +import { API_TIMEOUT_MS } from '@/api/constants' +import { hasControlCharacter, PLUGIN_MANAGEMENT_BRIDGE, PLUGIN_MANAGEMENT_BRIDGE_VERSION } from './bridge-contract' + +export * from './bridge-contract' const MAXIMUM_MANAGEMENT_RESOURCES = 128 const MAXIMUM_MANAGEMENT_RESOURCE_BYTES = 1024 * 1024 @@ -50,11 +44,6 @@ const JAVASCRIPT_CONTENT_TYPES = new Set([ 'text/javascript', ]) -export interface PluginFrameTheme { - name: 'light' | 'dark' - tokens: Record -} - export interface AssembledPluginPage { srcdoc: string revoke: () => void @@ -439,13 +428,6 @@ function safeThemeValue(value: string) { return normalized } -function hasControlCharacter(value: string) { - return [...value].some((character) => { - const code = character.codePointAt(0) ?? 0 - return code < 0x20 || code === 0x7F - }) -} - function createBridgeScript(input: { view: PluginManagementView page: PluginManagementPage @@ -467,369 +449,8 @@ function createBridgeScript(input: { theme: input.theme, viewportHeight: input.viewportHeight, resourceUrls: input.resourceUrls, - }).replace(/ { - 'use strict'; - const initial = ${initial}; - const pending = new Map(); - const modelOperations = new Map(); - let sequence = 0; - let resizeObserver; - let resizeFrame = 0; - let previousHeight = 0; - let disposed = false; - let currentTheme = initial.theme.name; - const maximumBodyBytes = ${MAXIMUM_MANAGEMENT_BODY_BYTES}; - const maximumQueryBytes = ${MAXIMUM_MANAGEMENT_QUERY_BYTES}; - const maximumInFlight = ${MAXIMUM_MANAGEMENT_IN_FLIGHT}; - const maximumModelBodyBytes = ${MAXIMUM_MODEL_BODY_BYTES}; - const maximumModelChunkBytes = ${MAXIMUM_MODEL_CHUNK_BYTES}; - const maximumModelInFlight = ${MAXIMUM_MODEL_IN_FLIGHT}; - const modelRequestDeadlineMs = ${MODEL_REQUEST_DEADLINE_MS}; - - function applyLayout(height) { - if (Number.isSafeInteger(height) && height >= 0) { - document.documentElement.style.setProperty('--cp-plugin-viewport-height', height + 'px'); - } - } - - function applyTheme(theme) { - if (!theme || (theme.name !== 'light' && theme.name !== 'dark') || !theme.tokens || typeof theme.tokens !== 'object') return; - const root = document.documentElement; - root.dataset.theme = theme.name; - root.style.colorScheme = theme.name; - for (const [name, value] of Object.entries(theme.tokens)) { - if (typeof name === 'string' && name.startsWith('--cp-') && typeof value === 'string') root.style.setProperty(name, value); - } - currentTheme = theme.name; - window.dispatchEvent(new CustomEvent('codex-proxy-themechange', { detail: { theme: currentTheme } })); - } - - async function bodyBytes(body) { - if (body == null) return new ArrayBuffer(0); - if (typeof body === 'string') return new TextEncoder().encode(body).buffer; - if (body instanceof ArrayBuffer) return body.slice(0); - if (ArrayBuffer.isView(body)) return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength); - throw new TypeError('请求正文只支持 string、ArrayBuffer 或 TypedArray'); - } - - function nextRequestId() { - sequence += 1; - if (!Number.isSafeInteger(sequence)) throw new Error('插件页面请求序号已耗尽'); - return sequence; - } - - function post(type, id, payload, transfer = []) { - parent.postMessage({ - target: initial.target, - version: initial.version, - channel: initial.channel, - session: initial.session, - type, - id, - payload, - }, initial.parentOrigin, transfer); - } - - function invoke(kind, payload, transfer = []) { - if (pending.size >= maximumInFlight) return Promise.reject(new Error('插件管理请求已达并发上限')); - const id = nextRequestId(); - return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => { - pending.delete(id); - reject(new Error('插件管理请求超时')); - }, kind === 'request' ? ${PLUGIN_MANAGEMENT_TIMEOUT_MS} : ${API_TIMEOUT_MS}); - pending.set(id, { resolve, reject, timer }); - post(kind, id, payload, transfer); - }); - } - - function reportContentHeight() { - // 不读 documentElement.scrollHeight,避免当前 iframe 高度阻止内容收缩。 - const height = Math.max(1, Math.ceil(document.body.getBoundingClientRect().height)); - if (height === previousHeight) return; - previousHeight = height; - post('resize', nextRequestId(), { height }); - } - - async function observeContentHeight() { - // load 后先触发布局,让实际使用的字体进入加载集合,再等待字体与布局就绪。 - document.body.getBoundingClientRect(); - await document.fonts.ready; - if (disposed) return; - // 首次上报不依赖渲染回调,后台或视口外的 iframe 也能完成初始化。 - reportContentHeight(); - resizeObserver = new ResizeObserver(() => { - if (resizeFrame) return; - resizeFrame = requestAnimationFrame(() => { - resizeFrame = 0; - reportContentHeight(); - }); - }); - resizeObserver.observe(document.body, { box: 'border-box' }); - } - - window.addEventListener('load', observeContentHeight, { once: true }); - - async function request(input) { - if (!input || typeof input !== 'object') throw new TypeError('插件管理请求无效'); - const method = String(input.method || '').toUpperCase(); - const path = String(input.path || ''); - const query = input.query == null ? '' : String(input.query); - const contentType = input.contentType == null ? undefined : String(input.contentType); - if (new TextEncoder().encode(query).byteLength > maximumQueryBytes || query.includes('#') || query.startsWith('?')) throw new TypeError('插件管理查询参数无效'); - const body = await bodyBytes(input.body); - if (body.byteLength > maximumBodyBytes) throw new TypeError('插件管理请求正文超过 1 MiB'); - return invoke('request', { method, path, query, contentType, body }, [body]); - } - - function callbackTicket(input) { - if (!input || typeof input !== 'object') return Promise.reject(new TypeError('插件回调票据请求无效')); - return invoke('callback-ticket', { path: String(input.path || ''), ttlSeconds: Number(input.ttlSeconds) }); - } - - function abortError(reason) { - return reason instanceof Error ? reason : new DOMException('模型请求已取消', 'AbortError'); - } - - function clearModelOperation(operation) { - if (modelOperations.get(operation.id) !== operation) return; - modelOperations.delete(operation.id); - clearTimeout(operation.timer); - if (operation.signal) operation.signal.removeEventListener('abort', operation.abort); - } - - function settleModelPull(operation, error) { - const resolve = operation.pullResolve; - const reject = operation.pullReject; - operation.pullResolve = undefined; - operation.pullReject = undefined; - operation.pullPromise = undefined; - operation.pullId = undefined; - if (error) reject?.(error); - else resolve?.(); - } - - function failModelOperation(operation, error, notifyParent) { - if (modelOperations.get(operation.id) !== operation) return; - clearModelOperation(operation); - settleModelPull(operation, error); - if (operation.controller) { - try { operation.controller.error(error); } catch {} - } else { - operation.reject(error); - } - if (notifyParent) post('models-cancel', operation.id); - } - - function finishModelOperation(operation) { - if (modelOperations.get(operation.id) !== operation) return; - clearModelOperation(operation); - settleModelPull(operation); - } - - function validateResponseHeaders(value) { - if (!Array.isArray(value)) throw new Error('模型响应头无效'); - for (const entry of value) { - if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') throw new Error('模型响应头无效'); - } - // 与宿主共享浏览器的 Headers 校验,不再按插件身份过滤字段。 - return new Headers(value); - } - - function handleModelMessage(message) { - const operation = modelOperations.get(message.id); - if (!operation) return; - if (message.type === 'models-error') { - const text = typeof message.error === 'string' && message.error ? message.error : '模型请求失败'; - failModelOperation(operation, new Error(text), false); - return; - } - if (message.type === 'models-response') { - if (operation.controller || operation.responseStarted || !message.payload || typeof message.payload !== 'object') { - failModelOperation(operation, new Error('模型响应协议无效'), true); - return; - } - try { - const status = message.payload.status; - const statusText = message.payload.statusText == null ? '' : message.payload.statusText; - const hasBody = message.payload.hasBody; - if (!Number.isSafeInteger(status) || status < 200 || status > 599 || typeof statusText !== 'string' || statusText.length > 256 || /[\\u0000-\\u001F\\u007F]/u.test(statusText) || typeof hasBody !== 'boolean') throw new Error('模型响应状态无效'); - const headers = validateResponseHeaders(message.payload.headers); - operation.responseStarted = true; - if (!hasBody) { - finishModelOperation(operation); - operation.resolve(new Response(null, { status, statusText, headers })); - return; - } - const stream = new ReadableStream({ - start(controller) { - operation.controller = controller; - }, - pull() { - if (modelOperations.get(operation.id) !== operation) return undefined; - if (operation.pullPromise) return operation.pullPromise; - operation.pullSequence += 1; - operation.pullId = operation.pullSequence; - operation.pullPromise = new Promise((resolve, reject) => { - operation.pullResolve = resolve; - operation.pullReject = reject; - post('models-pull', operation.id, { pullId: operation.pullId }); - }); - return operation.pullPromise; - }, - cancel() { - if (modelOperations.get(operation.id) === operation) { - clearModelOperation(operation); - settleModelPull(operation); - post('models-cancel', operation.id); - } - }, - }, { highWaterMark: 0 }); - operation.resolve(new Response(stream, { status, statusText, headers })); - } catch (error) { - failModelOperation(operation, error instanceof Error ? error : new Error('模型响应协议无效'), true); - } - return; - } - if (!operation.controller || !operation.responseStarted) { - failModelOperation(operation, new Error('模型响应协议无效'), true); - return; - } - if (message.type === 'models-chunk') { - const pullId = message.payload?.pullId; - const body = message.payload?.body; - if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId || !(body instanceof ArrayBuffer) || body.byteLength === 0 || body.byteLength > maximumModelChunkBytes) { - failModelOperation(operation, new Error('模型响应分块无效'), true); - return; - } - try { - operation.controller.enqueue(new Uint8Array(body)); - settleModelPull(operation); - } catch (error) { - failModelOperation(operation, error instanceof Error ? error : new Error('模型响应读取失败'), true); - } - return; - } - if (message.type === 'models-complete') { - const pullId = message.payload?.pullId; - if (!operation.pullPromise || !Number.isSafeInteger(pullId) || pullId !== operation.pullId) { - failModelOperation(operation, new Error('模型响应结束标记无效'), true); - return; - } - try { operation.controller.close(); } catch {} - finishModelOperation(operation); - } - } - - function modelResponses(input) { - if (!input || typeof input !== 'object' || !input.body || typeof input.body !== 'object' || Array.isArray(input.body)) return Promise.reject(new TypeError('Responses 请求正文必须是 JSON 对象')); - if (typeof input.clientKeyId !== 'string' || !input.clientKeyId || input.clientKeyId !== input.clientKeyId.trim() || input.clientKeyId.length > 128 || /[\\u0000-\\u001F\\u007F]/u.test(input.clientKeyId)) return Promise.reject(new TypeError('clientKeyId 无效')); - if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) return Promise.reject(new TypeError('signal 必须是 AbortSignal')); - if (input.signal?.aborted) return Promise.reject(abortError(input.signal.reason)); - if (modelOperations.size >= maximumModelInFlight) return Promise.reject(new Error('模型请求已达并发上限')); - let body; - try { - body = JSON.stringify(input.body); - } catch { - return Promise.reject(new TypeError('Responses 请求正文必须可序列化为 JSON')); - } - if (typeof body !== 'string' || new TextEncoder().encode(body).byteLength > maximumModelBodyBytes) return Promise.reject(new TypeError('Responses 请求正文超过 8 MiB')); - const id = nextRequestId(); - return new Promise((resolve, reject) => { - const operation = { - id, - resolve, - reject, - signal: input.signal, - abort: undefined, - timer: undefined, - controller: undefined, - responseStarted: false, - pullPromise: undefined, - pullResolve: undefined, - pullReject: undefined, - pullSequence: 0, - pullId: undefined, - }; - operation.abort = () => failModelOperation(operation, abortError(input.signal?.reason), true); - operation.timer = window.setTimeout(() => failModelOperation(operation, new Error('模型请求超过 10 分钟'), true), modelRequestDeadlineMs); - modelOperations.set(id, operation); - input.signal?.addEventListener('abort', operation.abort, { once: true }); - post('models-request', id, { clientKeyId: input.clientKeyId, body }); - }); - } - - document.addEventListener('click', (event) => { - if (event.defaultPrevented || !(event.target instanceof Element)) return; - const submitter = event.target.closest('button, input'); - if (!(submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement)) return; - const type = submitter.type.toLowerCase(); - if (type !== 'submit' && type !== 'image') return; - const form = submitter.form; - if (!form) return; - event.preventDefault(); - queueMicrotask(() => { - if (!form.isConnected || !submitter.isConnected) return; - if (!submitter.formNoValidate && !form.reportValidity()) return; - form.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter })); - }); - }); - - window.addEventListener('message', (event) => { - if (event.source !== parent || event.origin !== initial.parentOrigin) return; - const message = event.data; - if (!message || message.target !== initial.target || message.version !== initial.version || message.channel !== initial.channel || message.session !== initial.session) return; - if (message.type === 'theme') { - applyTheme(message.payload); - return; - } - if (message.type === 'layout') { - applyLayout(message.payload?.viewportHeight); - return; - } - if (!Number.isSafeInteger(message.id) || message.id < 1) return; - if (message.type === 'models-response' || message.type === 'models-chunk' || message.type === 'models-complete' || message.type === 'models-error') { - handleModelMessage(message); - return; - } - if (message.type !== 'result') return; - const operation = pending.get(message.id); - if (!operation) return; - pending.delete(message.id); - clearTimeout(operation.timer); - if (message.ok) operation.resolve(message.payload); - else operation.reject(new Error(typeof message.error === 'string' ? message.error : '插件管理请求失败')); - }); - - window.addEventListener('pagehide', () => { - disposed = true; - resizeObserver?.disconnect(); - cancelAnimationFrame(resizeFrame); - for (const operation of [...modelOperations.values()]) { - clearModelOperation(operation); - settleModelPull(operation); - post('models-cancel', operation.id); - } - }, { once: true }); - - const api = { - version: initial.version, - plugin: Object.freeze(initial.plugin), - page: Object.freeze(initial.page), - get theme() { return currentTheme; }, - request, - callbackTicket, - models: Object.freeze({ responses: modelResponses }), - resourceUrl(path) { - const key = String(path); - if (!Object.prototype.hasOwnProperty.call(initial.resourceUrls, key)) throw new Error('资源未注册或不可作为页面子资源'); - const value = initial.resourceUrls[key]; - return value; - }, - }; - Object.defineProperty(window, 'codexProxyPlugin', { value: Object.freeze(api), configurable: false, writable: false }); - applyTheme(initial.theme); - applyLayout(initial.viewportHeight); - })();` + managementTimeoutMs: PLUGIN_MANAGEMENT_TIMEOUT_MS, + apiTimeoutMs: API_TIMEOUT_MS, + } satisfies PluginBridgeInitial).replace(/ = { - disabled: '已停用', - awaiting_publication: '等待发布', - preparing: '准备中', - running: '已启用', - blocked: '发布阻塞', - preparation_failed: '准备失败', - faulted: '运行故障', - draining: '排空中', -} - -export function pluginRuntimeStatusLabel(status: PluginInstanceRuntimeStatus) { - return PLUGIN_RUNTIME_STATUS_LABELS[status] -} - export function sourceLabel(source: PluginSource | PluginUpdateSource) { switch (source.kind) { case 'builtin': @@ -172,33 +156,3 @@ export function artifactForInstance(instance: PluginInstance, artifacts: PluginA export function cloneJsonValue(value: T): T { return JSON.parse(JSON.stringify(value)) as T } - -export function configurationForSchema( - schemaValue: Record, - current: Record = {}, - excludedFields: string[] = [], -) { - const schema = schemaValue as JsonSchema - let result: Record = cloneJsonValue(current) - for (const [name, property] of Object.entries(schema.properties ?? {})) { - if (!excludedFields.includes(name) && !Object.hasOwn(result, name) && property.default !== undefined) { - // 计算属性保留 __proto__ 等合法 JSON 字段,不触发普通对象的原型 setter - result = { ...result, [name]: cloneJsonValue(property.default) } - } - } - return result -} - -export function pluginIdOptions(artifacts: PluginArtifact[]) { - const names = new Map() - for (const artifact of artifacts) - names.set(artifact.metadata.pluginId, artifact.metadata.displayName) - return [...names].map(([value, name]) => ({ value, label: `${name} · ${value}` })) -} - -export function uniquePluginArtifacts(artifacts: PluginArtifact[]) { - return [...artifacts].sort((left, right) => { - const byId = left.metadata.pluginId.localeCompare(right.metadata.pluginId) - return byId || right.metadata.version.localeCompare(left.metadata.version, undefined, { numeric: true }) - }) -} diff --git a/frontend/src/views/proxies/utils/location.ts b/frontend/src/views/proxies/utils/location.ts index a11a968b4..2d3f470e5 100644 --- a/frontend/src/views/proxies/utils/location.ts +++ b/frontend/src/views/proxies/utils/location.ts @@ -1,13 +1,5 @@ -import type { OutboundProxyRecord, OutboundProxyTest } from '@/api' +import type { OutboundProxyRecord } from '@/api' export function effectiveProxyLocation(proxy: OutboundProxyRecord) { return proxy.autoLocation ? proxy.detectedLocation?.location : proxy.location } - -export function locationDetectionWarning(test: OutboundProxyTest | null | undefined) { - if (test?.location.status === 'conflict') - return 'IPv4 与 IPv6 的出口时区不一致,自动位置未生效' - if (test?.location.status === 'failed') - return test.location.message - return '' -} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json index a82fc6fc2..c09e23773 100644 --- a/frontend/tsconfig.node.json +++ b/frontend/tsconfig.node.json @@ -13,5 +13,5 @@ "noEmit": true, "verbatimModuleSyntax": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "build/**/*.ts"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4a28b7bec..f19765e18 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,6 +3,7 @@ import CodexProxyUI from '@codex-proxy/ui/vite' import tailwindcss from '@tailwindcss/vite' import vue from '@vitejs/plugin-vue' import { defineConfig } from 'vite' +import { pluginBridge } from './build/plugin-bridge.ts' export default defineConfig(({ mode }) => { const sourceUi = mode === 'source' @@ -10,7 +11,7 @@ export default defineConfig(({ mode }) => { return { base: '/', - plugins: [vue(), tailwindcss(), CodexProxyUI({ source: sourceUi ? uiRoot : undefined })], + plugins: [vue(), tailwindcss(), CodexProxyUI({ source: sourceUi ? uiRoot : undefined }), pluginBridge()], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, },