From c0b31bddb6138e6a7690e958125a4d8d195af9e6 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 21 Aug 2026 21:29:52 +0800 Subject: [PATCH] refactor: register event delivery contracts --- app/runtime/event/contracts.py | 152 +++++ app/runtime/events.py | 8 + .../backend-architecture-next-stage.md | 11 + scripts/architecture/baseline.py | 28 + .../architecture/dependency-baseline.json | 11 +- .../runtime-contract-baseline.json | 540 ++++++++++++++++++ tests/test_event_contracts.py | 69 +++ 7 files changed, 816 insertions(+), 3 deletions(-) create mode 100644 app/runtime/event/contracts.py create mode 100644 tests/test_event_contracts.py diff --git a/app/runtime/event/contracts.py b/app/runtime/event/contracts.py new file mode 100644 index 000000000..418fd5243 --- /dev/null +++ b/app/runtime/event/contracts.py @@ -0,0 +1,152 @@ +"""事件 payload、可见性、投递与可靠性契约清单。""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, ValidationError + +import app.schemas.event as event_schemas +from app.schemas.types import ChainEventType, EventType + + +class EventDelivery(StrEnum): + """描述事件当前的交付保证。""" + + EPHEMERAL = "ephemeral" + DURABLE_REQUIRED = "durable_required" + + +class EventVisibility(StrEnum): + """描述事件是否属于插件公开扩展面。""" + + HOST_ONLY = "host_only" + PLUGIN_PUBLIC = "plugin_public" + TARGET_PLUGIN = "target_plugin" + + +class EventErrorBehavior(StrEnum): + """描述 handler 异常后的传播规则。""" + + ISOLATE = "isolate" + STOP_CHAIN = "stop_chain" + NOTIFY = "notify" + + +@dataclass(frozen=True, slots=True) +class EventContract: + """冻结一个事件的 payload 与运行语义。""" + + event_name: str + payload_model: type[BaseModel] | None + payload_contract: str + mode: str + visibility: EventVisibility + delivery: EventDelivery + error_behavior: EventErrorBehavior + ordering: str + sensitive_fields: tuple[str, ...] = () + legacy_reason: str | None = None + + +_PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = { + EventType.ConfigChanged: event_schemas.ConfigChangeEventData, + EventType.AgentTokensUsage: event_schemas.AgentTokensUsageEventData, + EventType.SubscribeModified: event_schemas.SubscribeModifiedEventData, + ChainEventType.PluginDataReset: event_schemas.PluginDataResetEventData, + ChainEventType.AuthVerification: event_schemas.AuthCredentials, + ChainEventType.AuthIntercept: event_schemas.AuthInterceptCredentials, + ChainEventType.CommandRegister: event_schemas.CommandRegisterEventData, + ChainEventType.TransferRename: event_schemas.TransferRenameEventData, + ChainEventType.TransferRenameBuild: event_schemas.TransferRenameBuildEventData, + ChainEventType.TransferIntercept: event_schemas.TransferInterceptEventData, + ChainEventType.TransferOverwriteCheck: event_schemas.TransferOverwriteCheckEventData, + ChainEventType.ResourceSelection: event_schemas.ResourceSelectionEventData, + ChainEventType.ResourceDownload: event_schemas.ResourceDownloadEventData, + ChainEventType.DiscoverSource: event_schemas.DiscoverSourceEventData, + ChainEventType.MediaRecognizeConvert: event_schemas.MediaRecognizeConvertEventData, + ChainEventType.RecommendSource: event_schemas.RecommendSourceEventData, + ChainEventType.StorageOperSelection: event_schemas.StorageOperSelectionEventData, + ChainEventType.AgentLLMProvider: event_schemas.AgentLLMProviderEventData, + ChainEventType.SubscribeEpisodesRefresh: event_schemas.SubscribeEpisodesRefreshEventData, + ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckEventData, +} + +_DURABLE_REQUIRED = { + EventType.SubscribeAdded, + EventType.SubscribeModified, + EventType.SubscribeDeleted, + EventType.DownloadAdded, + EventType.TransferComplete, + EventType.TransferFailed, +} +_TARGET_PLUGIN = {EventType.PluginAction, EventType.PluginTriggered} +_HOST_ONLY = {EventType.SystemError, EventType.ConfigChanged, EventType.ModuleReload} +_SENSITIVE_FIELDS = { + ChainEventType.AuthVerification: ("password", "token", "mfa_code"), + ChainEventType.AuthIntercept: ("token",), + ChainEventType.AgentLLMProvider: ("api_key",), +} + + +def _build_contract(event_type: EventType | ChainEventType) -> EventContract: + """按 enum 类别和首批 model 映射构造完整契约。""" + payload_model = _PAYLOAD_MODELS.get(event_type) + is_chain = isinstance(event_type, ChainEventType) + visibility = EventVisibility.PLUGIN_PUBLIC + if event_type in _TARGET_PLUGIN: + visibility = EventVisibility.TARGET_PLUGIN + elif event_type in _HOST_ONLY: + visibility = EventVisibility.HOST_ONLY + return EventContract( + event_name=f"{event_type.__class__.__name__}.{event_type.name}", + payload_model=payload_model, + payload_contract=payload_model.__name__ if payload_model else "legacy_dict", + mode="chain" if is_chain else "broadcast", + visibility=visibility, + delivery=( + EventDelivery.DURABLE_REQUIRED + if event_type in _DURABLE_REQUIRED + else EventDelivery.EPHEMERAL + ), + error_behavior=( + EventErrorBehavior.STOP_CHAIN if is_chain else EventErrorBehavior.NOTIFY + ), + ordering="priority_serial" if is_chain else "priority_queue", + sensitive_fields=_SENSITIVE_FIELDS.get(event_type, ()), + legacy_reason=( + None + if payload_model + else "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI" + ), + ) + + +EVENT_CONTRACTS = { + event_type: _build_contract(event_type) + for event_type in (*tuple(EventType), *tuple(ChainEventType)) +} + + +def get_event_contract(event_type: EventType | ChainEventType) -> EventContract: + """返回 enum 事件的完整登记契约。""" + return EVENT_CONTRACTS[event_type] + + +def validate_event_payload( + event_type: EventType | ChainEventType, + payload: Any, +) -> tuple[str, ...]: + """在发送边界诊断首批 typed payload,保持原对象和插件 dict 形状不变。""" + model = get_event_contract(event_type).payload_model + if model is None or payload is None: + return () + if isinstance(payload, model): + return () + try: + model.model_validate(payload) + except (ValidationError, TypeError, ValueError) as error: + return (str(error),) + return () diff --git a/app/runtime/events.py b/app/runtime/events.py index d8e5620ec..880871386 100644 --- a/app/runtime/events.py +++ b/app/runtime/events.py @@ -20,6 +20,7 @@ from app.runtime.event.binding import ( from app.runtime.event.dispatch import EventDispatcher from app.runtime.event.errors import EventErrorNotifier, EventErrorPolicy from app.runtime.event.registry import EventRegistry +from app.runtime.event.contracts import validate_event_payload DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级 MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数 @@ -40,6 +41,13 @@ class Event: :param event_data: 可选,事件携带的数据,默认为空字典 :param priority: 可选,事件的优先级,默认为 10 """ + payload_problems = validate_event_payload(event_type, event_data) + if payload_problems: + logger.warning( + "事件 %s payload 与登记契约不一致:%s;当前保留旧 payload 继续投递", + event_type.value, + "; ".join(payload_problems), + ) self.event_id = str(uuid.uuid4()) # 事件ID self.event_type = event_type # 事件类型 self.event_data = event_data or {} # 事件数据 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index 087b14c83..c371dc583 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -590,6 +590,17 @@ ModuleMethodSpec( 5. 报告“宿主无 consumer”时区分插件公开事件、预留事件和真正死事件。 6. `SystemError` 递归保护继续保留并补 contract;错误通知不能再次构造无限错误链。 +**实施记录(2026-08-21)**: + +- 新增 `app/runtime/event/contracts.py`,53 个 `EventType` / `ChainEventType` 全量登记 payload、 + broadcast/chain、可见性、顺序、错误策略、敏感字段和 ephemeral/durable-required 语义;尚未模型化的 + 事件显式记录 legacy dict 原因,不把“未登记”当成兼容策略。 +- 首批 20 个已有 Pydantic payload 的配置、订阅、整理、资源、认证、插件和 Agent 事件绑定具体 model。 + `Event` 创建边界对 dict/model 做诊断校验,但继续投递原对象,因此插件 dict 形状和链式原地修改语义不变。 +- 订阅变更、下载添加、整理成功/失败等用户副作用标记为 `durable_required`,只表达完成语义要求; + 在 ARCH-251 pilot 完成前不虚构当前已具备持久投递。SystemError 仍沿用既有递归保护和异常通知路径。 +- runtime contract baseline 新增稳定 `event_specs`,后续 enum 新增必须同步登记,且不比较源码行号。 + #### ARCH-242:Module/Integration 质量清单 **目标**:借鉴 Home Assistant Integration Quality Scale,为 `app/modules` 建立可检查但渐进的质量视图。 diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index 93e38786e..fa2442f8c 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -803,6 +803,7 @@ def collect_runtime_baseline() -> dict[str, Any]: "run_module": collect_run_module_contracts(), "module_method_specs": collect_module_method_specs(), "events": collect_event_contracts(), + "event_specs": collect_event_specs(), "sdk_exports": collect_sdk_exports(), "compat_manifest": collect_compat_manifest(), } @@ -824,6 +825,33 @@ def collect_module_method_specs() -> dict[str, Any]: sys.modules.pop(spec.name, None) +def collect_event_specs() -> dict[str, Any]: + """收集全部 enum 事件的稳定 payload、可见性与可靠性登记。""" + project_root = str(PROJECT_ROOT) + inserted = project_root not in sys.path + if inserted: + sys.path.insert(0, project_root) + try: + from app.runtime.event.contracts import EVENT_CONTRACTS + finally: + if inserted: + sys.path.remove(project_root) + + return { + contract.event_name: { + "payload_contract": contract.payload_contract, + "mode": contract.mode, + "visibility": contract.visibility.value, + "delivery": contract.delivery.value, + "error_behavior": contract.error_behavior.value, + "ordering": contract.ordering, + "sensitive_fields": list(contract.sensitive_fields), + "legacy_reason": contract.legacy_reason, + } + for contract in EVENT_CONTRACTS.values() + } + + def collect_runtime_diagnostics() -> dict[str, Any]: """生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。""" return { diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 3ee2c6a6a..fc312268e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6204, - "edge_sha256": "9a988284502bb26fae5266321765fbf0e10b9fe608fd448c6868e67a824ea862", + "edge_count": 6208, + "edge_sha256": "a91776a358fc4820d1d6f6dc300b3432491dc6b87a0423a99acd0e6c1366526b", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -5345,6 +5345,9 @@ "app.runtime.event.binding -> app.runtime.event", "app.runtime.event.binding -> app.runtime.event.registry", "app.runtime.event.binding -> app.runtime.log", + "app.runtime.event.contracts -> app.schemas", + "app.runtime.event.contracts -> app.schemas.event", + "app.runtime.event.contracts -> app.schemas.types", "app.runtime.event.dispatch -> app.runtime", "app.runtime.event.dispatch -> app.runtime.event", "app.runtime.event.dispatch -> app.runtime.event.binding", @@ -5367,6 +5370,7 @@ "app.runtime.events -> app.runtime.config", "app.runtime.events -> app.runtime.event", "app.runtime.events -> app.runtime.event.binding", + "app.runtime.events -> app.runtime.event.contracts", "app.runtime.events -> app.runtime.event.dispatch", "app.runtime.events -> app.runtime.event.errors", "app.runtime.events -> app.runtime.event.registry", @@ -6221,7 +6225,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 771, + "module_count": 772, "modules": [ "app", "app.adapters", @@ -6852,6 +6856,7 @@ "app.runtime.debounce", "app.runtime.event", "app.runtime.event.binding", + "app.runtime.event.contracts", "app.runtime.event.dispatch", "app.runtime.event.errors", "app.runtime.event.registry", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index df4b8cb63..139d9053f 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -1159,6 +1159,546 @@ "app.utils" ] }, + "event_specs": { + "ChainEventType.AgentLLMProvider": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "AgentLLMProviderEventData", + "sensitive_fields": [ + "api_key" + ], + "visibility": "plugin_public" + }, + "ChainEventType.AuthIntercept": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "AuthInterceptCredentials", + "sensitive_fields": [ + "token" + ], + "visibility": "plugin_public" + }, + "ChainEventType.AuthVerification": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "AuthCredentials", + "sensitive_fields": [ + "password", + "token", + "mfa_code" + ], + "visibility": "plugin_public" + }, + "ChainEventType.CommandRegister": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "CommandRegisterEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.DiscoverSource": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "DiscoverSourceEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.MediaRecognize": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.MediaRecognizeConvert": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "MediaRecognizeConvertEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.MusicMediaRecognize": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.MusicNameRecognize": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.NameRecognize": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.PluginDataReset": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "PluginDataResetEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.RecommendSource": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "RecommendSourceEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.ResourceDownload": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "ResourceDownloadEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.ResourceSelection": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "ResourceSelectionEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.StorageOperSelection": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "StorageOperSelectionEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.SubscribeCompletionCheck": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "SubscribeCompletionCheckEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.SubscribeEpisodesRefresh": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "SubscribeEpisodesRefreshEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.TransferIntercept": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "TransferInterceptEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.TransferOverwriteCheck": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "TransferOverwriteCheckEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.TransferRename": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "TransferRenameEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.TransferRenameBuild": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": null, + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "TransferRenameBuildEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "ChainEventType.WorkflowExecution": { + "delivery": "ephemeral", + "error_behavior": "stop_chain", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "chain", + "ordering": "priority_serial", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.AgentTokensUsage": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": null, + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "AgentTokensUsageEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.AudioTransferComplete": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.AudioTransferFailed": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.CommandExcute": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.ConfigChanged": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": null, + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "ConfigChangeEventData", + "sensitive_fields": [], + "visibility": "host_only" + }, + "EventType.DownloadAdded": { + "delivery": "durable_required", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.DownloadDeleted": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.DownloadFileDeleted": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.HistoryDeleted": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.MessageAction": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.MetadataScrape": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.ModuleReload": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "host_only" + }, + "EventType.NoticeMessage": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.PluginAction": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "target_plugin" + }, + "EventType.PluginReload": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.PluginTriggered": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "target_plugin" + }, + "EventType.SiteDeleted": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SiteRefreshed": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SiteUpdated": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SubscribeAdded": { + "delivery": "durable_required", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SubscribeComplete": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SubscribeDeleted": { + "delivery": "durable_required", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SubscribeModified": { + "delivery": "durable_required", + "error_behavior": "notify", + "legacy_reason": null, + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "SubscribeModifiedEventData", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SubtitleTransferComplete": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SubtitleTransferFailed": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.SystemError": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "host_only" + }, + "EventType.TransferComplete": { + "delivery": "durable_required", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.TransferFailed": { + "delivery": "durable_required", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.UserMessage": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.WebhookMessage": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + }, + "EventType.WorkflowExecute": { + "delivery": "ephemeral", + "error_behavior": "notify", + "legacy_reason": "现有插件 payload 尚未收敛为稳定 model,保留原始 dict ABI", + "mode": "broadcast", + "ordering": "priority_queue", + "payload_contract": "legacy_dict", + "sensitive_fields": [], + "visibility": "plugin_public" + } + }, "events": { "consumer_count": 15, "dynamic_consumers": [ diff --git a/tests/test_event_contracts.py b/tests/test_event_contracts.py new file mode 100644 index 000000000..cb466dde3 --- /dev/null +++ b/tests/test_event_contracts.py @@ -0,0 +1,69 @@ +"""Event Contract Registry 完整性和兼容校验测试。""" + +from unittest.mock import patch + +from app.runtime.event.contracts import ( + EVENT_CONTRACTS, + EventDelivery, + get_event_contract, + validate_event_payload, +) +from app.runtime.events import Event +from app.schemas.event import ConfigChangeEventData +from app.schemas.types import ChainEventType, EventType + + +def test_every_event_enum_has_complete_contract() -> None: + """53 个广播/链式事件必须全部登记且 legacy 项必须解释原因。""" + expected = {*EventType, *ChainEventType} + + assert set(EVENT_CONTRACTS) == expected + assert len(EVENT_CONTRACTS) == 53 + for contract in EVENT_CONTRACTS.values(): + assert contract.mode in {"broadcast", "chain"} + assert contract.payload_contract + if contract.payload_contract == "legacy_dict": + assert contract.legacy_reason + + +def test_typed_payload_is_validated_without_changing_public_shape() -> None: + """旧 dict 通过 model 校验后仍以同一个 dict 对象投递给插件。""" + payload = {"key": "PROXY_HOST", "value": "http://proxy"} + + assert validate_event_payload(EventType.ConfigChanged, payload) == () + event = Event(EventType.ConfigChanged, payload) + + assert event.event_data is payload + assert isinstance(event.event_data, dict) + + +def test_invalid_typed_payload_is_diagnostic_only() -> None: + """兼容阶段坏 payload 产生诊断但不阻断既有投递。""" + payload = {"value": "missing-key"} + + with patch("app.runtime.events.logger.warning") as warning: + event = Event(EventType.ConfigChanged, payload) + + assert event.event_data is payload + warning.assert_called_once() + + +def test_selected_user_side_effects_are_marked_durable_required() -> None: + """订阅、下载和整理完成事件必须明确暴露后续 durable pilot 要求。""" + for event_type in ( + EventType.SubscribeAdded, + EventType.SubscribeModified, + EventType.SubscribeDeleted, + EventType.DownloadAdded, + EventType.TransferComplete, + EventType.TransferFailed, + ): + assert get_event_contract(event_type).delivery is EventDelivery.DURABLE_REQUIRED + + +def test_model_instance_remains_mutable_chain_payload() -> None: + """链式处理器继续接收原 model 实例,确保输出字段可原地接力。""" + payload = ConfigChangeEventData(key={"PROXY_HOST"}) + event = Event(EventType.ConfigChanged, payload) + + assert event.event_data is payload