refactor: define module contract v2

This commit is contained in:
jxxghp
2026-08-21 21:25:35 +08:00
parent ff0ce8d1c1
commit b9ad5220ae
6 changed files with 511 additions and 25 deletions
+87 -23
View File
@@ -2,22 +2,54 @@
from __future__ import annotations from __future__ import annotations
import inspect
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from typing import Any, Protocol
class ModuleResultAggregation(StrEnum): class ModuleResultAggregation(StrEnum):
"""描述多模块结果沿调用链的兼容聚合方式。""" """描述多模块结果沿调用链的兼容聚合方式。"""
LEGACY = "legacy" LEGACY = "legacy"
FIRST_NON_EMPTY = "first_non_empty"
ORDERED_LIST_MERGE = "ordered_list_merge"
class ModuleExecutionMode(StrEnum):
"""描述 provider 可以采用的执行形态。"""
SYNC_OR_ASYNC = "sync_or_async"
class ModuleErrorPolicy(StrEnum):
"""描述单个 provider 失败后的兼容处理策略。"""
ISOLATE_PROVIDER = "isolate_provider"
class ModuleCapability(Protocol):
"""宿主和新插件可用于声明动态能力的最小 Protocol。"""
def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""执行模块能力并返回契约声明的结果。"""
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ModuleMethodContract: class ModuleMethodContract:
"""记录一个模块方法族的调用模式和结果规则""" """记录模块方法的输入、结果、执行与兼容错误协议"""
family: str family: str
aggregation: ModuleResultAggregation = ModuleResultAggregation.LEGACY aggregation: ModuleResultAggregation = ModuleResultAggregation.LEGACY
version: int = 1
input_contract: str = "legacy_args"
result_contract: str = "Any"
required_parameters: tuple[str, ...] = ()
execution: ModuleExecutionMode = ModuleExecutionMode.SYNC_OR_ASYNC
timeout_policy: str = "caller_budget"
error_policy: ModuleErrorPolicy = ModuleErrorPolicy.ISOLATE_PROVIDER
public_to_plugins: bool = True
supports_sync: bool = True supports_sync: bool = True
supports_async: bool = True supports_async: bool = True
plugin_short_circuit: bool = True plugin_short_circuit: bool = True
@@ -28,28 +60,34 @@ _DEFAULT_CONTRACT = ModuleMethodContract(family="legacy")
# 首批登记高频能力族。方法名仍保持开放字符串,以兼容第三方插件自定义模块能力; # 首批登记高频能力族。方法名仍保持开放字符串,以兼容第三方插件自定义模块能力;
# 未命中项继续使用冻结的 legacy 规则,并由架构快照记录新增调用位置。 # 未命中项继续使用冻结的 legacy 规则,并由架构快照记录新增调用位置。
_METHOD_CONTRACTS = { _METHOD_CONTRACTS = {
"recognize_media": ModuleMethodContract(family="media-recognition"), "recognize_media": ModuleMethodContract(
"search_medias": ModuleMethodContract(family="media-recognition"), family="media-recognition", input_contract="MediaRecognitionRequest",
"obtain_images": ModuleMethodContract(family="media-recognition"), result_contract="MediaInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY,
"media_category": ModuleMethodContract(family="media-recognition"), ),
"mediaserver_items": ModuleMethodContract(family="media-server"), "search_medias": ModuleMethodContract(
"mediaserver_iteminfo": ModuleMethodContract(family="media-server"), family="media-recognition", input_contract="MediaSearchRequest",
"mediaserver_play_url": ModuleMethodContract(family="media-server"), result_contract="list[MediaInfo]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE,
"mediaserver_tv_episodes": ModuleMethodContract(family="media-server"), ),
"download_file": ModuleMethodContract(family="storage"), "obtain_images": ModuleMethodContract(family="media-recognition", input_contract="MediaInfo", result_contract="MediaInfo | None"),
"upload_file": ModuleMethodContract(family="storage"), "media_category": ModuleMethodContract(family="media-recognition", input_contract="MediaCategoryRequest", result_contract="CategoryConfig | None"),
"list_files": ModuleMethodContract(family="storage"), "mediaserver_items": ModuleMethodContract(family="media-server", input_contract="MediaServerItemsRequest", result_contract="list[MediaServerItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
"get_file_item": ModuleMethodContract(family="storage"), "mediaserver_iteminfo": ModuleMethodContract(family="media-server", input_contract="MediaServerItemRequest", result_contract="MediaServerItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"get_folder": ModuleMethodContract(family="storage"), "mediaserver_play_url": ModuleMethodContract(family="media-server", input_contract="MediaServerPlayRequest", result_contract="str | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"get_parent_item": ModuleMethodContract(family="storage"), "mediaserver_tv_episodes": ModuleMethodContract(family="media-server", input_contract="MediaServerEpisodesRequest", result_contract="list[MediaServerPlayItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
"rename_file": ModuleMethodContract(family="storage"), "download_file": ModuleMethodContract(family="storage", input_contract="StorageDownloadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"storage_manage": ModuleMethodContract(family="storage"), "upload_file": ModuleMethodContract(family="storage", input_contract="StorageUploadRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"snapshot_storage": ModuleMethodContract(family="storage"), "list_files": ModuleMethodContract(family="storage", input_contract="StorageListRequest", result_contract="list[FileItem]", aggregation=ModuleResultAggregation.ORDERED_LIST_MERGE),
"send_message": ModuleMethodContract(family="messaging"), "get_file_item": ModuleMethodContract(family="storage", input_contract="StorageItemRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"finalize_message": ModuleMethodContract(family="messaging"), "get_folder": ModuleMethodContract(family="storage", input_contract="StorageFolderRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"register_commands": ModuleMethodContract(family="messaging"), "get_parent_item": ModuleMethodContract(family="storage", input_contract="StorageParentRequest", result_contract="FileItem | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"scheduler_job": ModuleMethodContract(family="scheduling"), "rename_file": ModuleMethodContract(family="storage", input_contract="StorageRenameRequest", result_contract="bool | FileItem", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"webhook_parser": ModuleMethodContract(family="integration"), "storage_manage": ModuleMethodContract(family="storage", input_contract="StorageManageRequest", result_contract="Any", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"snapshot_storage": ModuleMethodContract(family="storage", input_contract="StorageSnapshotRequest", result_contract="dict[str, dict] | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"send_message": ModuleMethodContract(family="messaging", input_contract="MessageSendRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"finalize_message": ModuleMethodContract(family="messaging", input_contract="MessageFinalizeRequest", result_contract="Message | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
"register_commands": ModuleMethodContract(family="messaging", input_contract="CommandRegistrationRequest", result_contract="None"),
"scheduler_job": ModuleMethodContract(family="scheduling", input_contract="SchedulerJobRequest", result_contract="None"),
"webhook_parser": ModuleMethodContract(family="integration", input_contract="WebhookRequest", result_contract="WebhookEventInfo | None", aggregation=ModuleResultAggregation.FIRST_NON_EMPTY),
} }
_PREFIX_CONTRACTS = ( _PREFIX_CONTRACTS = (
@@ -80,3 +118,29 @@ def get_module_method_contract(method: str) -> ModuleMethodContract:
def is_explicit_module_method(method: str) -> bool: def is_explicit_module_method(method: str) -> bool:
"""判断方法是否已进入首批显式能力族清单。""" """判断方法是否已进入首批显式能力族清单。"""
return get_module_method_contract(method) is not _DEFAULT_CONTRACT return get_module_method_contract(method) is not _DEFAULT_CONTRACT
def diagnose_module_callable(method: str, callback: Callable[..., Any]) -> tuple[str, ...]:
"""诊断显式能力的基础签名;兼容阶段只返回问题,不拒绝 provider。"""
contract = get_module_method_contract(method)
if contract is _DEFAULT_CONTRACT:
return ()
try:
parameters = inspect.signature(callback).parameters
except (TypeError, ValueError):
return ("signature-unavailable",)
missing = tuple(
name
for name in contract.required_parameters
if name not in parameters
and not any(
parameter.kind is inspect.Parameter.VAR_KEYWORD
for parameter in parameters.values()
)
)
return tuple(f"missing-parameter:{name}" for name in missing)
def list_explicit_module_contracts() -> dict[str, ModuleMethodContract]:
"""返回显式方法清单的副本,供架构基线和 SDK 文档使用。"""
return dict(_METHOD_CONTRACTS)
+24 -1
View File
@@ -9,7 +9,10 @@ from typing import Any, Protocol
from app.foundation.reflection import ObjectUtils from app.foundation.reflection import ObjectUtils
from app.runtime.execution import run_in_threadpool from app.runtime.execution import run_in_threadpool
from app.runtime.log import logger from app.runtime.log import logger
from app.runtime.extensions.module.contracts import get_module_method_contract from app.runtime.extensions.module.contracts import (
diagnose_module_callable,
get_module_method_contract,
)
from app.schemas.exception import RateLimitExceededException from app.schemas.exception import RateLimitExceededException
@@ -108,6 +111,7 @@ class ModuleInvocationDispatcher:
func = module_dict.get(method) func = module_dict.get(method)
if not func: if not func:
continue continue
self._diagnose_callable(method, func, f"插件 {plugin_id}")
logger.info("请求插件 %s 执行:%s ...", plugin_name, method) logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
if self.is_valid_empty(result): if self.is_valid_empty(result):
result = func(*args, **kwargs) result = func(*args, **kwargs)
@@ -154,6 +158,7 @@ class ModuleInvocationDispatcher:
func = module_dict.get(method) func = module_dict.get(method)
if not func: if not func:
continue continue
self._diagnose_callable(method, func, f"插件 {plugin_id}")
logger.info("请求插件 %s 执行:%s ...", plugin_name, method) logger.info("请求插件 %s 执行:%s ...", plugin_name, method)
if self.is_valid_empty(result): if self.is_valid_empty(result):
result = await self._async_call(func, *args, **kwargs) result = await self._async_call(func, *args, **kwargs)
@@ -199,6 +204,7 @@ class ModuleInvocationDispatcher:
module_name = self._module_name(module, module_id) module_name = self._module_name(module, module_id)
try: try:
func = getattr(module, method) func = getattr(module, method)
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
if self.is_valid_empty(result): if self.is_valid_empty(result):
result = func(*args, **kwargs) result = func(*args, **kwargs)
elif ObjectUtils.check_signature(func, result): elif ObjectUtils.check_signature(func, result):
@@ -245,6 +251,7 @@ class ModuleInvocationDispatcher:
module_name = self._module_name(module, module_id) module_name = self._module_name(module, module_id)
try: try:
func = getattr(module, method) func = getattr(module, method)
self._diagnose_callable(method, func, f"宿主模块 {module_id}")
if self.is_valid_empty(result): if self.is_valid_empty(result):
result = await self._async_call(func, *args, **kwargs) result = await self._async_call(func, *args, **kwargs)
elif ObjectUtils.check_signature(func, result): elif ObjectUtils.check_signature(func, result):
@@ -273,6 +280,22 @@ class ModuleInvocationDispatcher:
) )
return result return result
@staticmethod
def _diagnose_callable(
method: str,
callback: Callable[..., Any],
owner: str,
) -> None:
"""记录 Contract V2 签名偏差,兼容阶段不阻断旧插件执行。"""
problems = diagnose_module_callable(method, callback)
if problems:
logger.warning(
"%s 的模块方法 %s 与契约不一致:%s;当前仅诊断",
owner,
method,
", ".join(problems),
)
async def _async_call( async def _async_call(
self, self,
func: Callable[..., Any], func: Callable[..., Any],
@@ -553,6 +553,17 @@ ModuleMethodSpec(
**量化目标**:legacy 方法数从 96 开始只降不升;新增宿主调用必须先有显式 spec。 **量化目标**:legacy 方法数从 96 开始只降不升;新增宿主调用必须先有显式 spec。
**实施记录(2026-08-21**
- `ModuleMethodContract` 已升级为 V2,显式记录 version、input/result contract、aggregation、
execution、timeout、error、plugin visibility 与基础签名要求;首批 22 个识别、搜索、媒体服务器、
存储、消息和调度/集成能力完成登记。
- `run_module()` 与插件优先、短路、列表顺序合并、空值和异常隔离算法保持不变。Dispatcher 在真实
provider 调用边界执行基础签名诊断;旧插件不匹配只写可读 warning,不拒绝加载或执行,未知自定义
方法继续使用 legacy contract。
- runtime contract baseline 现包含稳定的 `module_method_specs`,后续字段或显式方法变化必须审查;
`ModuleCapability` Protocol 为宿主和新插件提供静态声明入口,但不替换字符串 dispatcher ABI。
#### ARCH-241Event Contract Registry #### ARCH-241Event Contract Registry
**目标**:为每个 EventType/ChainEventType 明确 payload、可见范围、投递和可靠性,不改变旧装饰器 API。 **目标**:为每个 EventType/ChainEventType 明确 payload、可见范围、投递和可靠性,不改变旧装饰器 API。
+17
View File
@@ -801,12 +801,29 @@ def collect_runtime_baseline() -> dict[str, Any]:
return { return {
"schema_version": 2, "schema_version": 2,
"run_module": collect_run_module_contracts(), "run_module": collect_run_module_contracts(),
"module_method_specs": collect_module_method_specs(),
"events": collect_event_contracts(), "events": collect_event_contracts(),
"sdk_exports": collect_sdk_exports(), "sdk_exports": collect_sdk_exports(),
"compat_manifest": collect_compat_manifest(), "compat_manifest": collect_compat_manifest(),
} }
def collect_module_method_specs() -> dict[str, Any]:
"""加载无运行资源副作用的 Module Contract V2 清单。"""
path = APP_ROOT / "runtime" / "extensions" / "module" / "contracts.py"
spec = importlib.util.spec_from_file_location("architecture_module_contracts", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载模块契约清单:{path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
contracts = module.list_explicit_module_contracts()
return json_compatible(contracts)
finally:
sys.modules.pop(spec.name, None)
def collect_runtime_diagnostics() -> dict[str, Any]: def collect_runtime_diagnostics() -> dict[str, Any]:
"""生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。""" """生成带当前源码行号的运行契约诊断视图,不写入语义 fixture。"""
return { return {
@@ -1766,6 +1766,338 @@
}, },
"producer_count": 66 "producer_count": 66
}, },
"module_method_specs": {
"download_file": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageDownloadRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "FileItem | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"finalize_message": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "messaging",
"input_contract": "MessageFinalizeRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "Message | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"get_file_item": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageItemRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "FileItem | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"get_folder": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageFolderRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "FileItem | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"get_parent_item": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageParentRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "FileItem | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"list_files": {
"aggregation": "ordered_list_merge",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageListRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "list[FileItem]",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"media_category": {
"aggregation": "legacy",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-recognition",
"input_contract": "MediaCategoryRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "CategoryConfig | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"mediaserver_iteminfo": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-server",
"input_contract": "MediaServerItemRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "MediaServerItem | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"mediaserver_items": {
"aggregation": "ordered_list_merge",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-server",
"input_contract": "MediaServerItemsRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "list[MediaServerItem]",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"mediaserver_play_url": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-server",
"input_contract": "MediaServerPlayRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "str | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"mediaserver_tv_episodes": {
"aggregation": "ordered_list_merge",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-server",
"input_contract": "MediaServerEpisodesRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "list[MediaServerPlayItem]",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"obtain_images": {
"aggregation": "legacy",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-recognition",
"input_contract": "MediaInfo",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "MediaInfo | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"recognize_media": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-recognition",
"input_contract": "MediaRecognitionRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "MediaInfo | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"register_commands": {
"aggregation": "legacy",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "messaging",
"input_contract": "CommandRegistrationRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"rename_file": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageRenameRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "bool | FileItem",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"scheduler_job": {
"aggregation": "legacy",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "scheduling",
"input_contract": "SchedulerJobRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"search_medias": {
"aggregation": "ordered_list_merge",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "media-recognition",
"input_contract": "MediaSearchRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "list[MediaInfo]",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"send_message": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "messaging",
"input_contract": "MessageSendRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "Message | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"snapshot_storage": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageSnapshotRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "dict[str, dict] | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"storage_manage": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageManageRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "Any",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"upload_file": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "storage",
"input_contract": "StorageUploadRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "FileItem | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"webhook_parser": {
"aggregation": "first_non_empty",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "integration",
"input_contract": "WebhookRequest",
"plugin_short_circuit": true,
"public_to_plugins": true,
"required_parameters": [],
"result_contract": "WebhookEventInfo | None",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
}
},
"run_module": { "run_module": {
"call_count": 260, "call_count": 260,
"dynamic_call_count": 0, "dynamic_call_count": 0,
+40 -1
View File
@@ -4,9 +4,13 @@ import json
from pathlib import Path from pathlib import Path
from app.runtime.extensions.module.contracts import ( from app.runtime.extensions.module.contracts import (
ModuleErrorPolicy,
ModuleExecutionMode,
ModuleResultAggregation, ModuleResultAggregation,
diagnose_module_callable,
get_module_method_contract, get_module_method_contract,
is_explicit_module_method, is_explicit_module_method,
list_explicit_module_contracts,
) )
@@ -23,7 +27,7 @@ def test_all_scanned_module_methods_resolve_a_contract() -> None:
assert methods assert methods
for method in methods: for method in methods:
contract = get_module_method_contract(method) contract = get_module_method_contract(method)
assert contract.aggregation is ModuleResultAggregation.LEGACY assert isinstance(contract.aggregation, ModuleResultAggregation)
assert contract.plugin_short_circuit is True assert contract.plugin_short_circuit is True
@@ -53,3 +57,38 @@ def test_unknown_plugin_method_keeps_legacy_compatibility() -> None:
assert contract.family == "legacy" assert contract.family == "legacy"
assert contract.supports_sync is True assert contract.supports_sync is True
assert contract.supports_async is True assert contract.supports_async is True
def test_contract_v2_freezes_at_least_twenty_high_value_methods() -> None:
"""首批能力必须具备可生成文档和诊断的完整 V2 字段。"""
contracts = list_explicit_module_contracts()
assert len(contracts) >= 20
for contract in contracts.values():
assert contract.version == 1
assert contract.input_contract != "legacy_args"
assert contract.result_contract
assert contract.execution is ModuleExecutionMode.SYNC_OR_ASYNC
assert contract.timeout_policy == "caller_budget"
assert contract.error_policy is ModuleErrorPolicy.ISOLATE_PROVIDER
assert contract.public_to_plugins is True
def test_signature_diagnostics_do_not_reject_legacy_callable() -> None:
"""无法检查的旧插件 callable 只产生诊断,仍由 dispatcher 决定是否执行。"""
class _OpaqueCallable:
"""模拟 inspect 无法解析签名的第三方 callable。"""
@property
def __signature__(self):
"""模拟扩展对象不提供 Python signature。"""
raise ValueError("opaque")
def __call__(self):
"""保留可调用行为。"""
return "ok"
assert diagnose_module_callable("recognize_media", _OpaqueCallable()) == (
"signature-unavailable",
)
assert _OpaqueCallable()() == "ok"