From 3ba4027d846faaaa01e0c677ec0cebbf9c309735 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 25 Aug 2026 22:32:27 +0800 Subject: [PATCH] feat(events): expose typed payload snapshots --- app/runtime/event/contracts.py | 101 ++++- app/runtime/event/snapshot.py | 116 ++++++ app/runtime/events.py | 27 +- app/schemas/event.py | 208 +++++++++- app/schemas/exports.py | 18 + app/sdk/events.py | 44 +- docs/architecture-overview.md | 13 + docs/rules/05-architecture.md | 1 + scripts/architecture/baseline.py | 10 +- .../architecture/dependency-baseline.json | 20 +- .../runtime-contract-baseline.json | 377 +++++++++++++++++- tests/test_event_contracts.py | 43 +- tests/test_event_sdk_snapshot.py | 99 +++++ 13 files changed, 1023 insertions(+), 54 deletions(-) create mode 100644 app/runtime/event/snapshot.py create mode 100644 tests/test_event_sdk_snapshot.py diff --git a/app/runtime/event/contracts.py b/app/runtime/event/contracts.py index 709f293cc..71f6eeda0 100644 --- a/app/runtime/event/contracts.py +++ b/app/runtime/event/contracts.py @@ -37,6 +37,21 @@ class EventErrorBehavior(StrEnum): NOTIFY = "notify" +class EventPayloadMode(StrEnum): + """描述事件 payload 是运行时对象还是稳定快照。""" + + RUNTIME = "runtime" + SNAPSHOT = "snapshot" + EXTENSION = "extension" + + +class EventValidationMode(StrEnum): + """描述契约校验失败后的兼容策略。""" + + DIAGNOSTIC = "diagnostic" + STRICT = "strict" + + @dataclass(frozen=True, slots=True) class EventContract: """冻结一个事件的 payload 与运行语义。""" @@ -49,6 +64,11 @@ class EventContract: delivery: EventDelivery error_behavior: EventErrorBehavior ordering: str + input_model: type[BaseModel] | None = None + output_model: type[BaseModel] | None = None + schema_version: int = 1 + payload_mode: EventPayloadMode = EventPayloadMode.RUNTIME + validation_mode: EventValidationMode = EventValidationMode.DIAGNOSTIC sensitive_fields: tuple[str, ...] = () legacy_reason: str | None = None @@ -66,13 +86,13 @@ _PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = { EventType.SubscribeAdded: event_schemas.SubscribeAddedEventData, EventType.SubscribeDeleted: event_schemas.SubscribeDeletedEventData, EventType.SubscribeModified: event_schemas.SubscribeModifiedEventData, - EventType.DownloadAdded: event_schemas.DownloadAddedEventData, - EventType.TransferComplete: event_schemas.TransferResultEventData, - EventType.TransferFailed: event_schemas.TransferResultEventData, - EventType.SubtitleTransferComplete: event_schemas.TransferResultEventData, - EventType.SubtitleTransferFailed: event_schemas.TransferResultEventData, - EventType.AudioTransferComplete: event_schemas.TransferResultEventData, - EventType.AudioTransferFailed: event_schemas.TransferResultEventData, + EventType.DownloadAdded: event_schemas.DownloadAddedContractData, + EventType.TransferComplete: event_schemas.TransferResultContractData, + EventType.TransferFailed: event_schemas.TransferResultContractData, + EventType.SubtitleTransferComplete: event_schemas.TransferResultContractData, + EventType.SubtitleTransferFailed: event_schemas.TransferResultContractData, + EventType.AudioTransferComplete: event_schemas.TransferResultContractData, + EventType.AudioTransferFailed: event_schemas.TransferResultContractData, EventType.HistoryDeleted: event_schemas.HistoryDeletedEventData, EventType.DownloadFileDeleted: event_schemas.DownloadFileDeletedEventData, EventType.DownloadDeleted: event_schemas.DownloadDeletedEventData, @@ -81,7 +101,7 @@ _PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = { EventType.NoticeMessage: event_schemas.NoticeMessageEventData, EventType.SubscribeComplete: event_schemas.SubscribeCompleteEventData, EventType.SystemError: event_schemas.SystemErrorEventData, - EventType.MetadataScrape: event_schemas.MetadataScrapeEventData, + EventType.MetadataScrape: event_schemas.MetadataScrapeContractData, EventType.ModuleReload: event_schemas.EmptyEventData, EventType.MessageAction: event_schemas.MessageActionEventData, EventType.WorkflowExecute: event_schemas.WorkflowExecuteEventData, @@ -93,15 +113,15 @@ _PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = { 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.ResourceSelection: event_schemas.ResourceSelectionContractData, + ChainEventType.ResourceDownload: event_schemas.ResourceDownloadContractData, 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, + ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckContractData, ChainEventType.NameRecognize: event_schemas.NameRecognizeEventData, ChainEventType.MusicNameRecognize: event_schemas.MusicNameRecognizeEventData, ChainEventType.MediaRecognize: event_schemas.MediaRecognizeEventData, @@ -109,6 +129,32 @@ _PAYLOAD_MODELS: dict[EventType | ChainEventType, type[BaseModel]] = { ChainEventType.WorkflowExecution: ActionContext, } +_SNAPSHOT_EVENTS = { + EventType.DownloadAdded, + EventType.TransferComplete, + EventType.TransferFailed, + EventType.SubtitleTransferComplete, + EventType.SubtitleTransferFailed, + EventType.AudioTransferComplete, + EventType.AudioTransferFailed, + EventType.MetadataScrape, + ChainEventType.ResourceSelection, + ChainEventType.ResourceDownload, + ChainEventType.SubscribeCompletionCheck, +} + +_INPUT_MODELS = { + ChainEventType.ResourceSelection: event_schemas.ResourceSelectionInputContractData, + ChainEventType.ResourceDownload: event_schemas.ResourceDownloadInputContractData, + ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckInputContractData, +} + +_OUTPUT_MODELS = { + ChainEventType.ResourceSelection: event_schemas.ResourceSelectionOutputContractData, + ChainEventType.ResourceDownload: event_schemas.ResourceDownloadOutputContractData, + ChainEventType.SubscribeCompletionCheck: event_schemas.SubscribeCompletionCheckOutputContractData, +} + _DURABLE_REQUIRED = { EventType.SubscribeAdded, EventType.SubscribeModified, @@ -150,6 +196,14 @@ def _build_contract(event_type: EventType | ChainEventType) -> EventContract: EventErrorBehavior.STOP_CHAIN if is_chain else EventErrorBehavior.NOTIFY ), ordering="priority_serial" if is_chain else "priority_queue", + input_model=_INPUT_MODELS.get(event_type, payload_model), + output_model=_OUTPUT_MODELS.get(event_type), + payload_mode=( + EventPayloadMode.SNAPSHOT + if event_type in _SNAPSHOT_EVENTS + else EventPayloadMode.RUNTIME + ), + validation_mode=EventValidationMode.DIAGNOSTIC, sensitive_fields=_SENSITIVE_FIELDS.get(event_type, ()), legacy_reason=( None @@ -195,16 +249,23 @@ def validate_event_payload( ) -> tuple[str, ...]: """在发送边界诊断首批 typed payload,保持原对象和插件 dict 形状不变。""" try: - model = get_event_contract(event_type).payload_model + contract = get_event_contract(event_type) + models = tuple( + model + for model in (contract.input_model, contract.output_model) + if model is not None + ) except KeyError: # 动态插件在旧 ABI 下可能使用宿主枚举之外的字符串事件。 return () - if model is None or payload is None: + if not models 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 () + problems: list[str] = [] + for model in models: + if isinstance(payload, model): + continue + try: + model.model_validate(payload) + except (ValidationError, TypeError, ValueError) as error: + problems.append(f"{model.__name__}: {error}") + return tuple(problems) diff --git a/app/runtime/event/snapshot.py b/app/runtime/event/snapshot.py new file mode 100644 index 000000000..a570f5ea1 --- /dev/null +++ b/app/runtime/event/snapshot.py @@ -0,0 +1,116 @@ +"""事件 payload 的只读契约快照访问。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel, ValidationError + +from app.runtime.event.contracts import get_event_contract, normalize_event_type +from app.schemas.types import ChainEventType, EventType + + +@dataclass(frozen=True, slots=True) +class EventPayloadSnapshot: + """保存原始 payload 及按登记契约解析出的独立快照。""" + + event_type: EventType | ChainEventType | str + raw: Any + payload: BaseModel | None + input: BaseModel | None + output: BaseModel | None + known: bool + schema_version: int | None + payload_mode: str | None + validation_mode: str | None + errors: tuple[str, ...] = () + + @property + def valid(self) -> bool: + """返回已知事件的全部登记模型是否都通过校验。""" + return self.known and not self.errors + + +def _model_snapshot( + model: type[BaseModel] | None, + payload: Any, + label: str, + cache: dict[type[BaseModel], BaseModel | None], + errors: list[str], +) -> BaseModel | None: + """按单个模型构造快照,同一模型在一次访问中只解析一次。""" + if model is None: + return None + if model in cache: + return cache[model] + if isinstance(payload, model): + cache[model] = payload.model_copy(deep=True) + return cache[model] + try: + cache[model] = model.model_validate(payload) + except (ValidationError, TypeError, ValueError) as error: + cache[model] = None + errors.append(f"{label}:{model.__name__}: {error}") + return cache[model] + + +def snapshot_event_data( + event_type: EventType | ChainEventType | str, + event_data: Any, +) -> EventPayloadSnapshot: + """按事件注册契约解析 payload,未知自定义事件保持原始数据并返回未登记状态。""" + normalized = normalize_event_type(event_type) + try: + contract = get_event_contract(normalized) + except KeyError: + return EventPayloadSnapshot( + event_type=normalized, + raw=event_data, + payload=None, + input=None, + output=None, + known=False, + schema_version=None, + payload_mode=None, + validation_mode=None, + ) + + errors: list[str] = [] + cache: dict[type[BaseModel], BaseModel | None] = {} + payload_snapshot = _model_snapshot( + contract.payload_model, + event_data, + "payload", + cache, + errors, + ) + input_snapshot = _model_snapshot( + contract.input_model, + event_data, + "input", + cache, + errors, + ) + output_snapshot = _model_snapshot( + contract.output_model, + event_data, + "output", + cache, + errors, + ) + return EventPayloadSnapshot( + event_type=normalized, + raw=event_data, + payload=payload_snapshot, + input=input_snapshot, + output=output_snapshot, + known=True, + schema_version=contract.schema_version, + payload_mode=contract.payload_mode.value, + validation_mode=contract.validation_mode.value, + errors=tuple(errors), + ) + + +__all__ = ["EventPayloadSnapshot", "snapshot_event_data"] diff --git a/app/runtime/events.py b/app/runtime/events.py index fef91b56f..f7ff05645 100644 --- a/app/runtime/events.py +++ b/app/runtime/events.py @@ -7,26 +7,29 @@ import uuid from contextvars import ContextVar from dataclasses import dataclass from queue import Empty, PriorityQueue -from typing import Callable, Dict, List, Optional, Tuple, Union, Any, Type +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Type, Union -from app.runtime.config import global_vars -from app.runtime.thread import ThreadHelper -from app.runtime.log import logger -from app.schemas.event import ChainEventData -from app.schemas.types import ChainEventType, EventType -from app.runtime.rate import ExponentialBackoffRateLimiter from app.foundation.singleton import Singleton +from app.runtime.config import global_vars +from app.runtime.correlation import get_correlation_id from app.runtime.event.binding import ( EventBindingResolver, EventHandlerBinding, HandlerInstanceResolver, ) +from app.runtime.event.contracts import normalize_event_type, validate_event_payload 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 normalize_event_type, validate_event_payload -from app.runtime.correlation import get_correlation_id +from app.runtime.log import logger from app.runtime.observability import record_metric +from app.runtime.rate import ExponentialBackoffRateLimiter +from app.runtime.thread import ThreadHelper +from app.schemas.event import ChainEventData +from app.schemas.types import ChainEventType, EventType + +if TYPE_CHECKING: + from app.runtime.event.snapshot import EventPayloadSnapshot DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级 MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数 @@ -84,6 +87,12 @@ class Event: event_name = getattr(self.event_type, "value", self.event_type) return f"<{event_kind}: {event_name}, ID: {self.event_id}, Priority: {self.priority}>" + def snapshot(self) -> "EventPayloadSnapshot": + """返回插件可安全读取的类型化 payload 快照,原始 event_data 保持不变。""" + from app.runtime.event.snapshot import snapshot_event_data + + return snapshot_event_data(self.event_type, self.event_data) + def __lt__(self, other): """ 定义事件对象的比较规则,基于优先级比较 diff --git a/app/schemas/event.py b/app/schemas/event.py index 0c16cec31..ae7a2b2c2 100644 --- a/app/schemas/event.py +++ b/app/schemas/event.py @@ -1,14 +1,89 @@ +from collections.abc import Mapping as _Mapping +from enum import Enum as _Enum from pathlib import Path -from typing import Iterable, Optional, Dict, Any, List, Set, Callable +from typing import Annotated as _Annotated +from typing import Any, Callable, Dict, Iterable, List, Optional, Set +from typing import Union as _Union from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BeforeValidator as _BeforeValidator from app.schemas.common import JsonData -from app.schemas.types import MediaType, NotificationChannel +from app.schemas.context import Context as _ContextSnapshotBase +from app.schemas.context import MediaInfo as _MediaInfoSnapshot +from app.schemas.context import MetaInfo as _MetaInfoSnapshot from app.schemas.file import FileItem from app.schemas.media import OptionalMediaIdentityMixin, RequiredMediaIdentityMixin +from app.schemas.music import MusicInfo as _MusicInfoSnapshot +from app.schemas.music import MusicMeta as _MusicMetaSnapshot +from app.schemas.subscribe import Subscribe as _SubscribeSnapshot from app.schemas.transfer import TransferInfo -from app.schemas.types import MediaSource +from app.schemas.types import MediaSource, MediaType, NotificationChannel + +JsonValue = JsonData +"""事件扩展字段允许的 JSON 值类型。""" + + +def _coerce_event_snapshot(value: Any) -> Any: + """把旧运行时对象转换为只用于契约校验的可读取快照。""" + if isinstance(value, BaseModel): + value = value.model_dump(mode="python") + elif hasattr(value, "to_dict") and callable(value.to_dict): + value = value.to_dict() + elif isinstance(value, _Enum): + return value.value + elif isinstance(value, Path): + return value.as_posix() + if isinstance(value, _Mapping): + return {str(key): _coerce_event_snapshot(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set, frozenset)): + return [_coerce_event_snapshot(item) for item in value] + return value + + +EventJsonValue = _Annotated[JsonValue, _BeforeValidator(_coerce_event_snapshot)] +MediaSnapshot = _Annotated[ + _Union[_MusicInfoSnapshot, _MediaInfoSnapshot], + _BeforeValidator(_coerce_event_snapshot), +] +MetaSnapshot = _Annotated[ + _Union[_MusicMetaSnapshot, _MetaInfoSnapshot], + _BeforeValidator(_coerce_event_snapshot), +] + + +class ContextSnapshot(_ContextSnapshotBase): + """资源上下文的稳定事件快照,不替换链路中的运行时 Context。""" + + allowed_episodes: Optional[Set[int]] = None + + model_config = ConfigDict( + extra="allow", + from_attributes=True, + arbitrary_types_allowed=True, + ) + + @model_validator(mode="before") + @classmethod + def coerce_runtime_context(cls, value: Any) -> Any: + """允许旧 Context/dataclass 进入校验,同时保持原对象继续投递。""" + return _coerce_event_snapshot(value) + + +class FileContextSnapshot(BaseModel): + """音乐批次中单个文件的元数据上下文快照。""" + + path: str + meta: Optional[MetaSnapshot] = None + mediainfo: Optional[MediaSnapshot] = None + + model_config = ConfigDict(extra="allow", from_attributes=True) + + @model_validator(mode="before") + @classmethod + def coerce_runtime_context(cls, value: Any) -> Any: + """把旧文件上下文对象转换成可验证的字典。""" + return _coerce_event_snapshot(value) class Event(BaseModel): @@ -26,7 +101,7 @@ class BaseEventData(BaseModel): 事件数据的基类,所有具体事件数据类应继承自此类 """ - pass + model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) class ExtensibleEventData(BaseEventData): @@ -249,7 +324,130 @@ class ChainEventData(BaseEventData): 链式事件数据的基类,所有具体事件数据类应继承自此类 """ - pass + model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True) + + +class ResourceSelectionContractData(ChainEventData): + """ResourceSelection 的稳定输入/输出契约。""" + + contexts: List[ContextSnapshot] = Field(default_factory=list) + downloader: Optional[str] = None + origin: Optional[str] = None + updated: bool = False + updated_contexts: Optional[List[ContextSnapshot]] = None + source: Optional[str] = "未知拦截源" + + +class ResourceSelectionInputContractData(ChainEventData): + """ResourceSelection 事件输入字段。""" + + contexts: List[ContextSnapshot] = Field(default_factory=list) + downloader: Optional[str] = None + origin: Optional[str] = None + + +class ResourceSelectionOutputContractData(ChainEventData): + """ResourceSelection 事件插件回写字段。""" + + updated: bool = False + updated_contexts: Optional[List[ContextSnapshot]] = None + source: Optional[str] = "未知拦截源" + + +class ResourceDownloadContractData(ChainEventData): + """ResourceDownload 的稳定输入/输出契约。""" + + context: Optional[ContextSnapshot] = None + episodes: Optional[Set[int]] = None + channel: Optional[NotificationChannel] = None + origin: Optional[str] = None + downloader: Optional[str] = None + options: Optional[Dict[str, EventJsonValue]] = None + cancel: bool = False + source: str = "未知拦截源" + reason: str = "" + + +class ResourceDownloadInputContractData(ChainEventData): + """ResourceDownload 事件输入字段。""" + + context: Optional[ContextSnapshot] = None + episodes: Optional[Set[int]] = None + channel: Optional[NotificationChannel] = None + origin: Optional[str] = None + downloader: Optional[str] = None + options: Optional[Dict[str, EventJsonValue]] = None + + +class ResourceDownloadOutputContractData(ChainEventData): + """ResourceDownload 事件插件回写字段。""" + + cancel: bool = False + source: str = "未知拦截源" + reason: str = "" + + +class DownloadAddedContractData(BaseEventData): + """DownloadAdded 的稳定下载上下文契约。""" + + hash: str + context: ContextSnapshot + username: Optional[str] = None + downloader: Optional[str] = None + episodes: List[int] = Field(default_factory=list) + source: Optional[str] = None + idempotency_key: Optional[str] = None + + +class TransferResultContractData(BaseEventData): + """整理结果事件的稳定媒体与元数据契约。""" + + fileitem: Optional[FileItem] = None + meta: Optional[MetaSnapshot] = None + mediainfo: Optional[MediaSnapshot] = None + transferinfo: Optional[TransferInfo] = None + downloader: Optional[str] = None + download_hash: Optional[str] = None + transfer_history_id: Optional[int] = None + idempotency_key: Optional[str] = None + + +class MetadataScrapeContractData(ExtensibleEventData): + """MetadataScrape 的稳定媒体刮削契约。""" + + fileitem: FileItem + file_list: List[str] = Field(default_factory=list) + meta: Optional[MetaSnapshot] = None + mediainfo: Optional[MediaSnapshot] = None + overwrite: bool = False + file_contexts: List[FileContextSnapshot] = Field(default_factory=list) + + +class SubscribeCompletionCheckContractData(ChainEventData): + """订阅完成判定的稳定订阅、媒体和元数据契约。""" + + subscribe: Optional[_SubscribeSnapshot] = None + mediainfo: Optional[MediaSnapshot] = None + meta: Optional[MetaSnapshot] = None + cancel: bool = False + source: str = "未知来源" + reason: str = "" + + +class SubscribeCompletionCheckInputContractData(ChainEventData): + """SubscribeCompletionCheck 事件输入字段。""" + + subscribe: Optional[_SubscribeSnapshot] = None + mediainfo: Optional[MediaSnapshot] = None + meta: Optional[MetaSnapshot] = None + + +class SubscribeCompletionCheckOutputContractData(ChainEventData): + """SubscribeCompletionCheck 事件插件回写字段。""" + + cancel: bool = False + source: str = "未知来源" + reason: str = "" class PluginDataResetEventData(ChainEventData): diff --git a/app/schemas/exports.py b/app/schemas/exports.py index dd88f9b39..dda210a94 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -71,6 +71,7 @@ SCHEMA_EXPORTS = { 'ConfigDict': ('app.schemas.workflow', 'ConfigDict'), 'ContentType': ('app.schemas.message', 'ContentType'), 'Context': ('app.schemas.workflow', 'Context'), + 'ContextSnapshot': ('app.schemas.event', 'ContextSnapshot'), 'ConversationMemory': ('app.schemas.agent', 'ConversationMemory'), 'CookieActionResponse': ('app.schemas.servcookie', 'CookieActionResponse'), 'CookieData': ('app.schemas.servcookie', 'CookieData'), @@ -87,6 +88,7 @@ SCHEMA_EXPORTS = { 'DiscoverMediaSource': ('app.schemas.event', 'DiscoverMediaSource'), 'DiscoverSourceEventData': ('app.schemas.event', 'DiscoverSourceEventData'), 'Discriminator': ('app.schemas.context', 'Discriminator'), + 'DownloadAddedContractData': ('app.schemas.event', 'DownloadAddedContractData'), 'DownloadAddedData': ('app.schemas.download', 'DownloadAddedData'), 'DownloadAddedEventData': ('app.schemas.event', 'DownloadAddedEventData'), 'DownloadDeletedEventData': ('app.schemas.event', 'DownloadDeletedEventData'), @@ -109,9 +111,11 @@ SCHEMA_EXPORTS = { 'EpisodeFormatRule': ('app.schemas.transfer', 'EpisodeFormatRule'), 'ErrorRequest': ('app.schemas.monitoring', 'ErrorRequest'), 'Event': ('app.schemas.event', 'Event'), + 'EventJsonValue': ('app.schemas.event', 'EventJsonValue'), 'ExistMediaInfo': ('app.schemas.mediaserver', 'ExistMediaInfo'), 'ExtensibleEventData': ('app.schemas.event', 'ExtensibleEventData'), 'Field': ('app.schemas.mcp', 'Field'), + 'FileContextSnapshot': ('app.schemas.event', 'FileContextSnapshot'), 'FileItem': ('app.schemas.workflow', 'FileItem'), 'FileNameData': ('app.schemas.common', 'FileNameData'), 'FileURI': ('app.schemas.file', 'FileURI'), @@ -125,6 +129,7 @@ SCHEMA_EXPORTS = { 'JsonData': ('app.schemas.mcp', 'JsonData'), 'JsonObject': ('app.schemas.common', 'JsonObject'), 'JsonObjectList': ('app.schemas.common', 'JsonObjectList'), + 'JsonValue': ('app.schemas.event', 'JsonValue'), 'LLMAuthStatus': ('app.schemas.llm', 'LLMAuthStatus'), 'LLMModelCatalogData': ('app.schemas.llm', 'LLMModelCatalogData'), 'LLMModelInfo': ('app.schemas.llm', 'LLMModelInfo'), @@ -196,6 +201,7 @@ SCHEMA_EXPORTS = { 'MediaServerPlayData': ('app.schemas.mediaserver', 'MediaServerPlayData'), 'MediaServerPlayItem': ('app.schemas.mediaserver', 'MediaServerPlayItem'), 'MediaServerSeasonInfo': ('app.schemas.mediaserver', 'MediaServerSeasonInfo'), + 'MediaSnapshot': ('app.schemas.event', 'MediaSnapshot'), 'MediaSource': ('app.schemas.transfer', 'MediaSource'), 'MediaSourceInfo': ('app.schemas.event', 'MediaSourceInfo'), 'MediaType': ('app.schemas.subscribe', 'MediaType'), @@ -208,6 +214,8 @@ SCHEMA_EXPORTS = { 'MessageResponse': ('app.schemas.message', 'MessageResponse'), 'MessageType': ('app.schemas.message', 'MessageType'), 'MetaInfo': ('app.schemas.transfer', 'MetaInfo'), + 'MetaSnapshot': ('app.schemas.event', 'MetaSnapshot'), + 'MetadataScrapeContractData': ('app.schemas.event', 'MetadataScrapeContractData'), 'MetadataScrapeEventData': ('app.schemas.event', 'MetadataScrapeEventData'), 'MfaChallenge': ('app.schemas.token', 'MfaChallenge'), 'MfaStatusData': ('app.schemas.mfa', 'MfaStatusData'), @@ -295,8 +303,14 @@ SCHEMA_EXPORTS = { 'RefreshMediaItem': ('app.schemas.mediaserver', 'RefreshMediaItem'), 'RequestMetrics': ('app.schemas.monitoring', 'RequestMetrics'), 'RequiredMediaIdentityMixin': ('app.schemas.music', 'RequiredMediaIdentityMixin'), + 'ResourceDownloadContractData': ('app.schemas.event', 'ResourceDownloadContractData'), 'ResourceDownloadEventData': ('app.schemas.event', 'ResourceDownloadEventData'), + 'ResourceDownloadInputContractData': ('app.schemas.event', 'ResourceDownloadInputContractData'), + 'ResourceDownloadOutputContractData': ('app.schemas.event', 'ResourceDownloadOutputContractData'), + 'ResourceSelectionContractData': ('app.schemas.event', 'ResourceSelectionContractData'), 'ResourceSelectionEventData': ('app.schemas.event', 'ResourceSelectionEventData'), + 'ResourceSelectionInputContractData': ('app.schemas.event', 'ResourceSelectionInputContractData'), + 'ResourceSelectionOutputContractData': ('app.schemas.event', 'ResourceSelectionOutputContractData'), 'Response': ('app.schemas.response', 'Response'), 'RootModel': ('app.schemas.mcp', 'RootModel'), 'RuleTestData': ('app.schemas.system', 'RuleTestData'), @@ -349,7 +363,10 @@ SCHEMA_EXPORTS = { 'Subscribe': ('app.schemas.workflow', 'Subscribe'), 'SubscribeAddedEventData': ('app.schemas.event', 'SubscribeAddedEventData'), 'SubscribeCompleteEventData': ('app.schemas.event', 'SubscribeCompleteEventData'), + 'SubscribeCompletionCheckContractData': ('app.schemas.event', 'SubscribeCompletionCheckContractData'), 'SubscribeCompletionCheckEventData': ('app.schemas.event', 'SubscribeCompletionCheckEventData'), + 'SubscribeCompletionCheckInputContractData': ('app.schemas.event', 'SubscribeCompletionCheckInputContractData'), + 'SubscribeCompletionCheckOutputContractData': ('app.schemas.event', 'SubscribeCompletionCheckOutputContractData'), 'SubscribeDeletedEventData': ('app.schemas.event', 'SubscribeDeletedEventData'), 'SubscribeDownloadFileInfo': ('app.schemas.subscribe', 'SubscribeDownloadFileInfo'), 'SubscribeEpisodeInfo': ('app.schemas.subscribe', 'SubscribeEpisodeInfo'), @@ -396,6 +413,7 @@ SCHEMA_EXPORTS = { 'TransferOverwriteCheckEventData': ('app.schemas.event', 'TransferOverwriteCheckEventData'), 'TransferRenameBuildEventData': ('app.schemas.event', 'TransferRenameBuildEventData'), 'TransferRenameEventData': ('app.schemas.event', 'TransferRenameEventData'), + 'TransferResultContractData': ('app.schemas.event', 'TransferResultContractData'), 'TransferResultEventData': ('app.schemas.event', 'TransferResultEventData'), 'TransferTorrent': ('app.schemas.transfer', 'TransferTorrent'), 'TypeAdapter': ('app.schemas.mcp', 'TypeAdapter'), diff --git a/app/sdk/events.py b/app/sdk/events.py index 00fae72b7..18cf964ab 100644 --- a/app/sdk/events.py +++ b/app/sdk/events.py @@ -1,6 +1,46 @@ """插件事件订阅与发布接口。""" +from app.runtime.event.snapshot import EventPayloadSnapshot, snapshot_event_data from app.runtime.events import Event, EventManager, eventmanager +from app.schemas.event import ( + ContextSnapshot, + DownloadAddedContractData, + FileContextSnapshot, + MediaSnapshot, + MetadataScrapeContractData, + MetaSnapshot, + ResourceDownloadContractData, + ResourceDownloadInputContractData, + ResourceDownloadOutputContractData, + ResourceSelectionContractData, + ResourceSelectionInputContractData, + ResourceSelectionOutputContractData, + SubscribeCompletionCheckContractData, + SubscribeCompletionCheckInputContractData, + SubscribeCompletionCheckOutputContractData, + TransferResultContractData, +) - -__all__ = ["Event", "EventManager", "eventmanager"] +__all__ = [ + "ContextSnapshot", + "DownloadAddedContractData", + "Event", + "EventManager", + "EventPayloadSnapshot", + "FileContextSnapshot", + "MediaSnapshot", + "MetadataScrapeContractData", + "MetaSnapshot", + "ResourceDownloadContractData", + "ResourceDownloadInputContractData", + "ResourceDownloadOutputContractData", + "ResourceSelectionContractData", + "ResourceSelectionInputContractData", + "ResourceSelectionOutputContractData", + "SubscribeCompletionCheckContractData", + "SubscribeCompletionCheckInputContractData", + "SubscribeCompletionCheckOutputContractData", + "TransferResultContractData", + "eventmanager", + "snapshot_event_data", +] diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index db50921c5..90eaff6ce 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -351,6 +351,19 @@ flowchart TB 宿主内建 `EventType` / `ChainEventType` 均在事件注册表中绑定 typed payload。开放插件事件允许 额外字段,校验只生成诊断;分发给既有插件的仍是原始 dict/model 对象,不改变事件 ABI。 +插件可通过 `app.sdk.events` 调用 `event.snapshot()` 或 +`snapshot_event_data(event_type, event_data)` 获取独立的类型化快照。返回结果中的 `raw` 保留 +原始对象,`payload` 是组合契约,链式事件还提供 `input` / `output` 快照;`known`、`valid` 和 +`errors` 分别用于处理未知自定义事件和兼容期校验失败。快照修改不会回写原事件,如需拦截、取消 +或替换链式结果,插件仍应修改原始 `event.event_data`。 + +```python +from app.sdk.events import snapshot_event_data + +snapshot = snapshot_event_data(event.event_type, event.event_data) +if snapshot.valid and snapshot.payload.context.media_info.type == "音乐": + music_type = snapshot.payload.context.media_info.music_type +``` ```mermaid sequenceDiagram diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 07bc117f9..23bdaa3e5 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -536,6 +536,7 @@ Durable post-commit side effects have a separate boundary: | `app/runtime/event/binding.py` | Explicit module/plugin/host handler resolvers; unresolved classes are diagnosed and skipped, never implicitly constructed by the bus | | `app/runtime/event/dispatch.py` | Chain/broadcast ordering, concurrency, target-plugin filtering and isolated delivery | | `app/runtime/event/errors.py` | Handler failure notification and non-recursive `SystemError` downgrade policy | +| `app/runtime/event/snapshot.py` | Read-only typed payload snapshots for the plugin SDK; never mutates or replaces the event ABI | | `app/runtime/extensions/module/dispatcher.py` | Plugin-first invocation, short-circuit, list merge, signature relay and sync/async execution | | `app/runtime/extensions/module/contracts.py` | High-frequency method families and frozen legacy fallback contract | | `app/application/chain/context.py` | Injectable Chain dependencies and no-argument compatibility provider | diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index f7ae97a03..b84eac232 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -13,7 +13,6 @@ from collections import defaultdict from pathlib import Path from typing import Any, Optional - PROJECT_ROOT = Path(__file__).resolve().parents[2] APP_ROOT = PROJECT_ROOT / "app" BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture" @@ -988,6 +987,15 @@ def collect_event_specs() -> dict[str, Any]: return { contract.event_name: { "payload_contract": contract.payload_contract, + "input_contract": ( + contract.input_model.__name__ if contract.input_model else None + ), + "output_contract": ( + contract.output_model.__name__ if contract.output_model else None + ), + "schema_version": contract.schema_version, + "payload_mode": contract.payload_mode.value, + "validation_mode": contract.validation_mode.value, "mode": contract.mode, "visibility": contract.visibility.value, "delivery": contract.delivery.value, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 78dcb7b57..755f8d506 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": 6689, - "edge_sha256": "764be9020b4657c344453dc801ba270c925a2b26c94c0fb43c5a9f89953a4e07", + "edge_count": 6702, + "edge_sha256": "a6d5e5b0ccca6f84fe5d8aa5acb0402e7253958913da8d9624a23bd80ceab748", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -5734,6 +5734,11 @@ "app.runtime.event.registry -> app.runtime.log", "app.runtime.event.registry -> app.schemas", "app.runtime.event.registry -> app.schemas.types", + "app.runtime.event.snapshot -> app.runtime", + "app.runtime.event.snapshot -> app.runtime.event", + "app.runtime.event.snapshot -> app.runtime.event.contracts", + "app.runtime.event.snapshot -> app.schemas", + "app.runtime.event.snapshot -> app.schemas.types", "app.runtime.events -> app.foundation", "app.runtime.events -> app.foundation.singleton", "app.runtime.events -> app.runtime", @@ -5745,6 +5750,7 @@ "app.runtime.events -> app.runtime.event.dispatch", "app.runtime.events -> app.runtime.event.errors", "app.runtime.events -> app.runtime.event.registry", + "app.runtime.events -> app.runtime.event.snapshot", "app.runtime.events -> app.runtime.log", "app.runtime.events -> app.runtime.observability", "app.runtime.events -> app.runtime.rate", @@ -5987,8 +5993,11 @@ "app.schemas.dashboard -> app.schemas.common", "app.schemas.event -> app.schemas", "app.schemas.event -> app.schemas.common", + "app.schemas.event -> app.schemas.context", "app.schemas.event -> app.schemas.file", "app.schemas.event -> app.schemas.media", + "app.schemas.event -> app.schemas.music", + "app.schemas.event -> app.schemas.subscribe", "app.schemas.event -> app.schemas.transfer", "app.schemas.event -> app.schemas.types", "app.schemas.file -> app.schemas", @@ -6093,7 +6102,11 @@ "app.sdk.database -> app.application.backup", "app.sdk.database -> app.application.database", "app.sdk.events -> app.runtime", + "app.sdk.events -> app.runtime.event", + "app.sdk.events -> app.runtime.event.snapshot", "app.sdk.events -> app.runtime.events", + "app.sdk.events -> app.schemas", + "app.sdk.events -> app.schemas.event", "app.sdk.logging -> app.runtime", "app.sdk.logging -> app.runtime.log", "app.sdk.media -> app.domain", @@ -6706,7 +6719,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 823, + "module_count": 824, "modules": [ "app", "app.adapters", @@ -7382,6 +7395,7 @@ "app.runtime.event.dispatch", "app.runtime.event.errors", "app.runtime.event.registry", + "app.runtime.event.snapshot", "app.runtime.events", "app.runtime.execution", "app.runtime.extensions", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index dce3bf943..4726b03a7 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -1163,539 +1163,804 @@ "ChainEventType.AgentLLMProvider": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "AgentLLMProviderEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "AgentLLMProviderEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [ "api_key" ], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.AuthIntercept": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "AuthInterceptCredentials", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "AuthInterceptCredentials", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [ "token" ], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.AuthVerification": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "AuthCredentials", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "AuthCredentials", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [ "password", "token", "mfa_code" ], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.CommandRegister": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "CommandRegisterEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "CommandRegisterEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.DiscoverSource": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "DiscoverSourceEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "DiscoverSourceEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.MediaRecognize": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "MediaRecognizeEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "MediaRecognizeEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.MediaRecognizeConvert": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "MediaRecognizeConvertEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "MediaRecognizeConvertEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.MusicMediaRecognize": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "MusicMediaRecognizeEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "MusicMediaRecognizeEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.MusicNameRecognize": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "MusicNameRecognizeEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "MusicNameRecognizeEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.NameRecognize": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "NameRecognizeEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "NameRecognizeEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.PluginDataReset": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "PluginDataResetEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "PluginDataResetEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.RecommendSource": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "RecommendSourceEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "RecommendSourceEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.ResourceDownload": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "ResourceDownloadInputContractData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", - "payload_contract": "ResourceDownloadEventData", + "output_contract": "ResourceDownloadOutputContractData", + "payload_contract": "ResourceDownloadContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.ResourceSelection": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "ResourceSelectionInputContractData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", - "payload_contract": "ResourceSelectionEventData", + "output_contract": "ResourceSelectionOutputContractData", + "payload_contract": "ResourceSelectionContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.StorageOperSelection": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "StorageOperSelectionEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "StorageOperSelectionEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.SubscribeCompletionCheck": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "SubscribeCompletionCheckInputContractData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", - "payload_contract": "SubscribeCompletionCheckEventData", + "output_contract": "SubscribeCompletionCheckOutputContractData", + "payload_contract": "SubscribeCompletionCheckContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.SubscribeEpisodesRefresh": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "SubscribeEpisodesRefreshEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "SubscribeEpisodesRefreshEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.TransferIntercept": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "TransferInterceptEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "TransferInterceptEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.TransferOverwriteCheck": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "TransferOverwriteCheckEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "TransferOverwriteCheckEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.TransferRename": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "TransferRenameEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "TransferRenameEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.TransferRenameBuild": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "TransferRenameBuildEventData", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "TransferRenameBuildEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "ChainEventType.WorkflowExecution": { "delivery": "ephemeral", "error_behavior": "stop_chain", + "input_contract": "ActionContext", "legacy_reason": null, "mode": "chain", "ordering": "priority_serial", + "output_contract": null, "payload_contract": "ActionContext", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.AgentTokensUsage": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "AgentTokensUsageEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "AgentTokensUsageEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.AudioTransferComplete": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "TransferResultContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "TransferResultEventData", + "output_contract": null, + "payload_contract": "TransferResultContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.AudioTransferFailed": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "TransferResultContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "TransferResultEventData", + "output_contract": null, + "payload_contract": "TransferResultContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.CommandExcute": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "CommandExecuteEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "CommandExecuteEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.ConfigChanged": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "ConfigChangeEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "ConfigChangeEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "host_only" }, "EventType.DownloadAdded": { "delivery": "durable_required", "error_behavior": "notify", + "input_contract": "DownloadAddedContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "DownloadAddedEventData", + "output_contract": null, + "payload_contract": "DownloadAddedContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.DownloadDeleted": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "DownloadDeletedEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "DownloadDeletedEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.DownloadFileDeleted": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "DownloadFileDeletedEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "DownloadFileDeletedEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.HistoryDeleted": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "HistoryDeletedEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "HistoryDeletedEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.MessageAction": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "MessageActionEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "MessageActionEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.MetadataScrape": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "MetadataScrapeContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "MetadataScrapeEventData", + "output_contract": null, + "payload_contract": "MetadataScrapeContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.ModuleReload": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "EmptyEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "EmptyEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "host_only" }, "EventType.NoticeMessage": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "NoticeMessageEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "NoticeMessageEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.PluginAction": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "PluginActionEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "PluginActionEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "target_plugin" }, "EventType.PluginReload": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "PluginReloadEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "PluginReloadEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.PluginTriggered": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "PluginTriggeredEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "PluginTriggeredEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "target_plugin" }, "EventType.SiteDeleted": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "SiteEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SiteEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SiteRefreshed": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "SiteEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SiteEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SiteUpdated": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "SiteEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SiteEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SubscribeAdded": { "delivery": "durable_required", "error_behavior": "notify", + "input_contract": "SubscribeAddedEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SubscribeAddedEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SubscribeComplete": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "SubscribeCompleteEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SubscribeCompleteEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SubscribeDeleted": { "delivery": "durable_required", "error_behavior": "notify", + "input_contract": "SubscribeDeletedEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SubscribeDeletedEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SubscribeModified": { "delivery": "durable_required", "error_behavior": "notify", + "input_contract": "SubscribeModifiedEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SubscribeModifiedEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SubtitleTransferComplete": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "TransferResultContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "TransferResultEventData", + "output_contract": null, + "payload_contract": "TransferResultContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SubtitleTransferFailed": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "TransferResultContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "TransferResultEventData", + "output_contract": null, + "payload_contract": "TransferResultContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.SystemError": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "SystemErrorEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "SystemErrorEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "host_only" }, "EventType.TransferComplete": { "delivery": "durable_required", "error_behavior": "notify", + "input_contract": "TransferResultContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "TransferResultEventData", + "output_contract": null, + "payload_contract": "TransferResultContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.TransferFailed": { "delivery": "durable_required", "error_behavior": "notify", + "input_contract": "TransferResultContractData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", - "payload_contract": "TransferResultEventData", + "output_contract": null, + "payload_contract": "TransferResultContractData", + "payload_mode": "snapshot", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.UserMessage": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "UserMessageEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "UserMessageEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.WebhookMessage": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "WebhookEventInfo", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "WebhookEventInfo", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" }, "EventType.WorkflowExecute": { "delivery": "ephemeral", "error_behavior": "notify", + "input_contract": "WorkflowExecuteEventData", "legacy_reason": null, "mode": "broadcast", "ordering": "priority_queue", + "output_contract": null, "payload_contract": "WorkflowExecuteEventData", + "payload_mode": "runtime", + "schema_version": 1, "sensitive_fields": [], + "validation_mode": "diagnostic", "visibility": "plugin_public" } }, @@ -8239,6 +8504,16 @@ } ], "app.sdk.events": [ + { + "kind": "import", + "name": "ContextSnapshot", + "target": "app.schemas.event.ContextSnapshot" + }, + { + "kind": "import", + "name": "DownloadAddedContractData", + "target": "app.schemas.event.DownloadAddedContractData" + }, { "kind": "import", "name": "Event", @@ -8249,10 +8524,90 @@ "name": "EventManager", "target": "app.runtime.events.EventManager" }, + { + "kind": "import", + "name": "EventPayloadSnapshot", + "target": "app.runtime.event.snapshot.EventPayloadSnapshot" + }, + { + "kind": "import", + "name": "FileContextSnapshot", + "target": "app.schemas.event.FileContextSnapshot" + }, + { + "kind": "import", + "name": "MediaSnapshot", + "target": "app.schemas.event.MediaSnapshot" + }, + { + "kind": "import", + "name": "MetaSnapshot", + "target": "app.schemas.event.MetaSnapshot" + }, + { + "kind": "import", + "name": "MetadataScrapeContractData", + "target": "app.schemas.event.MetadataScrapeContractData" + }, + { + "kind": "import", + "name": "ResourceDownloadContractData", + "target": "app.schemas.event.ResourceDownloadContractData" + }, + { + "kind": "import", + "name": "ResourceDownloadInputContractData", + "target": "app.schemas.event.ResourceDownloadInputContractData" + }, + { + "kind": "import", + "name": "ResourceDownloadOutputContractData", + "target": "app.schemas.event.ResourceDownloadOutputContractData" + }, + { + "kind": "import", + "name": "ResourceSelectionContractData", + "target": "app.schemas.event.ResourceSelectionContractData" + }, + { + "kind": "import", + "name": "ResourceSelectionInputContractData", + "target": "app.schemas.event.ResourceSelectionInputContractData" + }, + { + "kind": "import", + "name": "ResourceSelectionOutputContractData", + "target": "app.schemas.event.ResourceSelectionOutputContractData" + }, + { + "kind": "import", + "name": "SubscribeCompletionCheckContractData", + "target": "app.schemas.event.SubscribeCompletionCheckContractData" + }, + { + "kind": "import", + "name": "SubscribeCompletionCheckInputContractData", + "target": "app.schemas.event.SubscribeCompletionCheckInputContractData" + }, + { + "kind": "import", + "name": "SubscribeCompletionCheckOutputContractData", + "target": "app.schemas.event.SubscribeCompletionCheckOutputContractData" + }, + { + "kind": "import", + "name": "TransferResultContractData", + "target": "app.schemas.event.TransferResultContractData" + }, { "kind": "import", "name": "eventmanager", "target": "app.runtime.events.eventmanager" + }, + { + "kind": "import", + "name": "snapshot_event_data", + "target": "app.runtime.event.snapshot.snapshot_event_data" } ], "app.sdk.logging": [ diff --git a/tests/test_event_contracts.py b/tests/test_event_contracts.py index 2271033f3..34ea1c659 100644 --- a/tests/test_event_contracts.py +++ b/tests/test_event_contracts.py @@ -8,6 +8,8 @@ from app.application.outbox import ( DURABLE_EVENT_TOPICS, validate_durable_event_handlers, ) +from app.domain.context import Context, MediaInfo +from app.domain.metainfo import MetaInfo from app.runtime.event.contracts import ( EVENT_CONTRACTS, EventDelivery, @@ -15,9 +17,13 @@ from app.runtime.event.contracts import ( validate_event_payload, ) from app.runtime.events import Event -from app.domain.context import Context, MediaInfo -from app.domain.metainfo import MetaInfo -from app.schemas.event import ConfigChangeEventData +from app.schemas.event import ( + ConfigChangeEventData, + ResourceDownloadInputContractData, + ResourceDownloadOutputContractData, + ResourceSelectionInputContractData, + ResourceSelectionOutputContractData, +) from app.schemas.file import FileItem from app.schemas.transfer import TransferInfo from app.schemas.types import ChainEventType, EventType, MediaType @@ -129,6 +135,37 @@ def test_download_and_transfer_typed_contracts_accept_legacy_runtime_objects() - ) == () +def test_music_runtime_objects_are_checked_as_typed_snapshots() -> None: + """音乐运行时对象保留给插件,同时通过快照契约明确 type/music_type。""" + from app.domain.context import MusicInfo + + media = MusicInfo(title="Song", artists=["Artist"], album="Album") + context = Context(media_info=media) + + assert validate_event_payload( + EventType.DownloadAdded, + {"hash": "music-hash", "context": context}, + ) == () + assert validate_event_payload( + EventType.TransferComplete, + {"mediainfo": media}, + ) == () + + +def test_snapshot_contract_metadata_declares_mutable_chain_boundaries() -> None: + """核心链式事件公开快照输入输出模型,但仍处于兼容诊断模式。""" + selection = get_event_contract(ChainEventType.ResourceSelection) + download = get_event_contract(ChainEventType.ResourceDownload) + + assert selection.schema_version == 1 + assert selection.payload_mode.value == "snapshot" + assert selection.input_model is ResourceSelectionInputContractData + assert selection.output_model is ResourceSelectionOutputContractData + assert download.input_model is ResourceDownloadInputContractData + assert download.output_model is ResourceDownloadOutputContractData + assert selection.validation_mode.value == "diagnostic" + + def test_model_instance_remains_mutable_chain_payload() -> None: """链式处理器继续接收原 model 实例,确保输出字段可原地接力。""" payload = ConfigChangeEventData(key={"PROXY_HOST"}) diff --git a/tests/test_event_sdk_snapshot.py b/tests/test_event_sdk_snapshot.py new file mode 100644 index 000000000..97de38b09 --- /dev/null +++ b/tests/test_event_sdk_snapshot.py @@ -0,0 +1,99 @@ +"""插件 SDK 事件快照访问测试。""" + +from app.domain.context import Context, MediaInfo, MusicInfo +from app.runtime.event.snapshot import ( + EventPayloadSnapshot as RuntimeEventPayloadSnapshot, +) +from app.runtime.event.snapshot import snapshot_event_data as runtime_snapshot_event_data +from app.schemas.event import ResourceSelectionEventData +from app.schemas.types import ChainEventType, EventType, MediaType +from app.sdk.events import ( + DownloadAddedContractData, + Event, + EventPayloadSnapshot, + ResourceSelectionInputContractData, + ResourceSelectionOutputContractData, + snapshot_event_data, +) + + +def test_sdk_exports_canonical_snapshot_interfaces() -> None: + """SDK 只公开 canonical 快照实现,不复制事件解析逻辑。""" + assert EventPayloadSnapshot is RuntimeEventPayloadSnapshot + assert snapshot_event_data is runtime_snapshot_event_data + + +def test_event_snapshot_exposes_music_type_without_changing_raw_payload() -> None: + """插件可从快照稳定识别音乐,原 Context 和 MusicInfo 继续按旧 ABI 投递。""" + media = MusicInfo(title="Song", artists=["Artist"], album="Album") + context = Context(media_info=media) + raw = {"hash": "music-hash", "context": context} + event = Event(EventType.DownloadAdded, raw) + + snapshot = event.snapshot() + + assert snapshot.known is True + assert snapshot.valid is True + assert snapshot.raw is raw + assert isinstance(snapshot.payload, DownloadAddedContractData) + assert snapshot.input is snapshot.payload + assert snapshot.output is None + assert snapshot.payload.context.media_info.type == MediaType.MUSIC.value + assert snapshot.payload.context.media_info.music_type == "recording" + assert event.event_data["context"] is context + + snapshot.payload.context.media_info.title = "Snapshot Song" + assert media.title == "Song" + + +def test_chain_snapshot_separates_plugin_input_and_output_models() -> None: + """链式事件同时提供输入和回写快照,且不替换可变运行时事件模型。""" + context = Context( + media_info=MediaInfo(type=MediaType.MOVIE, title="Movie", year="2026") + ) + raw = ResourceSelectionEventData( + contexts=[context], + updated=True, + updated_contexts=[context], + source="plugin", + ) + event = Event(ChainEventType.ResourceSelection, raw) + + snapshot = event.snapshot() + + assert snapshot.raw is raw + assert isinstance(snapshot.input, ResourceSelectionInputContractData) + assert isinstance(snapshot.output, ResourceSelectionOutputContractData) + assert snapshot.input.contexts[0].media_info.type == MediaType.MOVIE.value + assert snapshot.output.updated is True + assert snapshot.output.updated_contexts[0].media_info.title == "Movie" + assert event.event_data is raw + + +def test_unknown_plugin_event_returns_unregistered_snapshot_without_error() -> None: + """插件自定义字符串事件没有宿主契约时保留原 payload,不制造假模型。""" + raw = {"plugin_owned": {"value": 1}} + + snapshot = snapshot_event_data("plugin.custom.event", raw) + + assert snapshot.known is False + assert snapshot.valid is False + assert snapshot.raw is raw + assert snapshot.payload is None + assert snapshot.input is None + assert snapshot.output is None + assert snapshot.errors == () + + +def test_invalid_known_event_returns_validation_errors_instead_of_raising() -> None: + """坏 payload 由 SDK 结果携带错误,插件可自行决定降级策略。""" + raw = {"context": {"media_info": {"type": "音乐"}}} + + snapshot = snapshot_event_data(EventType.DownloadAdded, raw) + + assert snapshot.known is True + assert snapshot.valid is False + assert snapshot.raw is raw + assert snapshot.payload is None + assert snapshot.errors + assert "DownloadAddedContractData" in snapshot.errors[0]