diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index 33c0c5675..90799d7c2 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -567,6 +567,9 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( _write("plugin.folder.plugins.update", recovery=RecoveryMode.TRANSACTION), _write("plugin.folder.plugin.assign", recovery=RecoveryMode.TRANSACTION), _write("plugin.folder.plugin.remove", recovery=RecoveryMode.TRANSACTION), + _admin_read("plugin.loglevel.get", sensitivity=ResultSensitivity.PRIVATE), + _write("plugin.loglevel.set"), + _write("plugin.loglevel.clear"), ) @@ -815,6 +818,13 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "DELETE", "/api/v1/plugin/folders/{folder_name}/plugins/{plugin_id}", ), + "plugin.loglevel.get": ApiOperationRoute("GET", "/api/v1/plugin/loglevel/{plugin_id}"), + "plugin.loglevel.set": ApiOperationRoute( + "PUT", "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}" + ), + "plugin.loglevel.clear": ApiOperationRoute( + "DELETE", "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}" + ), } diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index b5417095f..868b4a849 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -228,6 +228,9 @@ OPERATION_DESCRIPTIONS = { "plugin.folder.plugins.update": "Replace the ordered plugin IDs assigned to one named plugin folder.", "plugin.folder.plugin.assign": "Move one installed plugin into one named folder and remove its other folder assignments.", "plugin.folder.plugin.remove": "Remove one installed plugin from one named folder without uninstalling it.", + "plugin.loglevel.get": "List one plugin's instances, including the plugin itself, with each instance's configured and effective log level.", + "plugin.loglevel.set": "Set one plugin instance's log-level override, taking effect immediately without following the global log level.", + "plugin.loglevel.clear": "Clear one plugin instance's log-level override so it immediately follows the global log level again.", } @@ -265,6 +268,7 @@ FIELD_DESCRIPTIONS = { "completed_episode": "Highest episode number already completed for the subscription.", "current_site_id": "Configured site ID currently handling the subscription execution.", "cookie": "Site authentication cookie. Treat this value as a secret.", + "configured_level": "Plugin instance's own configured log-level override, null when unset or expired.", "count": "Maximum number of records to return on the requested page.", "current_audio_format": "Audio format of the best version currently held.", "current_bit_depth": "Bit depth of the best version currently held.", @@ -292,6 +296,7 @@ FIELD_DESCRIPTIONS = { "douban_sort": "Douban Music category order: U for comprehensive, S for rating, R for newest, or O for hottest.", "drive_id": "Provider-native storage drive identifier.", "effect": "Video or release-effect filter expression used by the subscription.", + "effective_level": "Plugin instance's actual log level after resolving its override and expiry against the global log level.", "error": "Human-readable workflow, provider, or execution error message.", "entity": "Music exploration entity: recording for tracks or album for release groups.", "enclosure": "Torrent download URL or enclosure supplied by the indexer result.", @@ -304,6 +309,7 @@ FIELD_DESCRIPTIONS = { "episodes": "Episode-number expression recorded in history, such as E01-E03.", "errmsg": "Error message recorded for a failed transfer.", "expected_revision": "Current revision from a preceding read, used to reject concurrent state changes.", + "expires_at": "Expiry timestamp for a plugin instance's log-level override; null means it never expires. A timestamp without a timezone is read as UTC.", "exclude": "Regular expression or filter expression that rejects matching releases.", "extension": "Filename extension, including or excluding the leading dot as returned by storage.", "fileid": "Provider-native storage item identifier.", @@ -330,10 +336,12 @@ FIELD_DESCRIPTIONS = { "include_group_refs": "Include custom rules referenced only through rule groups.", "include_usage": "Include the subscriptions or defaults that reference each rule group.", "include_values": "Return complete setting values instead of discovery summaries.", + "instance_id": "Exact plugin instance ID returned by plugin.loglevel.get.", "is_active": "Whether the configured site is enabled.", "jobid": "Exact scheduler job ID returned by scheduler.list.", "key": "Optional exact plugin data key used to narrow the returned preview.", "keyword": "Case-insensitive substring used to discover settings or filter storage entries.", + "level": "Target log level, one of DEBUG, INFO, WARNING, ERROR, or CRITICAL.", "labels": "Labels attached to the media, classification result, or torrent result.", "lack_episode": "Number of episodes still missing from the subscription.", "last_update": "Timestamp of the subscription's most recent update.", @@ -452,6 +460,7 @@ FIELD_DESCRIPTIONS = { "target_path": "Destination path used by manual transfer.", "target_storage": "Configured storage name receiving the manual transfer.", "tags": "Comma-separated Douban Music category tags; use only with a Douban Music exploration source.", + "template": "Pass default to read the built-in media-classification policy template; omit it to read the active policy.", "thumbnail": "Thumbnail URL returned by the storage provider.", "timeout": "Per-request site timeout in seconds.", "title": "Media, torrent, subscription, or history title used by the operation.", @@ -651,6 +660,7 @@ MODEL_DESCRIPTIONS = { "MediaSource": "Canonical metadata source identifier paired with a source-native media ID.", "MediaType": "MoviePilot media type.", "MusicRecognizeRequest": "Exact source-native recording or album identity to resolve into canonical music metadata.", + "PluginInstanceLogLevelUpdateRequest": "One plugin instance's log-level override update request.", "PluginSourceChangeRequest": "Explicit online-source change request guarded by the current identity revision.", "PluginSourceInstallRequest": "Explicit online-source installation request for an unbound plugin.", "Site-Input": "Complete site configuration and runtime state.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 8c1ffe926..d11c470c3 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -3146,6 +3146,34 @@ "title": "PluginFoldersData", "type": "object" }, + "PluginInstanceLogLevelUpdateRequest": { + "description": "One plugin instance's log-level override update request.", + "properties": { + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Expiry timestamp for a plugin instance's log-level override; null means it never expires. A timestamp without a timezone is read as UTC.", + "title": "Expires At" + }, + "level": { + "description": "Target log level, one of DEBUG, INFO, WARNING, ERROR, or CRITICAL.", + "title": "Level", + "type": "string" + } + }, + "required": [ + "level" + ], + "title": "PluginInstanceLogLevelUpdateRequest", + "type": "object" + }, "PluginMarketSyncRequest": { "description": "Approved Wiki source request for plugin-market synchronization.", "properties": { @@ -7971,6 +7999,25 @@ "const": "media.classification.policy.get", "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", "type": "string" + }, + "query": { + "additionalProperties": false, + "description": "Filters and control values for media.classification.policy.get. Read the active automatic media-classification policy. Use only the named fields below.", + "properties": { + "template": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Pass default to read the built-in media-classification policy template; omit it to read the active policy.", + "title": "Template" + } + }, + "type": "object" } }, "required": [ @@ -10190,6 +10237,119 @@ "total_count_field": "collection.total_count" } }, + { + "additionalProperties": false, + "description": "Clear one plugin instance's log-level override so it immediately follows the global log level again. Method: DELETE. Path: /api/v1/plugin/loglevel/{plugin_id}/{instance_id}. Effect: reversible_write.", + "properties": { + "operation_id": { + "const": "plugin.loglevel.clear", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.loglevel.clear. Clear one plugin instance's log-level override so it immediately follows the global log level again. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.loglevel.get.", + "title": "Instance Id", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id", + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.loglevel.clear", + "type": "object" + }, + { + "additionalProperties": false, + "description": "List one plugin's instances, including the plugin itself, with each instance's configured and effective log level. Method: GET. Path: /api/v1/plugin/loglevel/{plugin_id}. Effect: safe_read.", + "properties": { + "operation_id": { + "const": "plugin.loglevel.get", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.loglevel.get. List one plugin's instances, including the plugin itself, with each instance's configured and effective log level. Use only the named fields below.", + "properties": { + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.loglevel.get", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Set one plugin instance's log-level override, taking effect immediately without following the global log level. Method: PUT. Path: /api/v1/plugin/loglevel/{plugin_id}/{instance_id}. Effect: reversible_write.", + "properties": { + "body": { + "$ref": "#/$defs/PluginInstanceLogLevelUpdateRequest", + "description": "Request value for plugin.loglevel.set. Set one plugin instance's log-level override, taking effect immediately without following the global log level. Use the exact type and fields below." + }, + "operation_id": { + "const": "plugin.loglevel.set", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.loglevel.set. Set one plugin instance's log-level override, taking effect immediately without following the global log level. Use only the named fields below.", + "properties": { + "instance_id": { + "description": "Exact plugin instance ID returned by plugin.loglevel.get.", + "title": "Instance Id", + "type": "string" + }, + "plugin_id": { + "description": "Exact installed or marketplace plugin ID.", + "title": "Plugin Id", + "type": "string" + } + }, + "required": [ + "plugin_id", + "instance_id" + ], + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params", + "body" + ], + "title": "plugin.loglevel.set", + "type": "object" + }, { "additionalProperties": false, "description": "List plugins available from configured marketplaces. Method: GET. Path: /api/v1/plugin/. Effect: safe_read. Collection response: data remains a list; omit both page and count to preserve the legacy complete result. Successful gateway output adds collection.result_count and the exact collection.total_count. For a count or summary, send page=1 and count=1, then read collection.total_count; do not query the database merely because item data is truncated.", @@ -13194,6 +13354,25 @@ "const": "subscription.metadata.refresh", "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", "type": "string" + }, + "query": { + "additionalProperties": false, + "description": "Filters and control values for subscription.metadata.refresh. Start a system-wide refresh of subscription TMDB metadata. Use only the named fields below.", + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/$defs/MediaType" + }, + { + "type": "null" + } + ], + "description": "MoviePilot media or storage item type required by the selected operation.", + "title": "Type" + } + }, + "type": "object" } }, "required": [ @@ -13334,6 +13513,25 @@ "const": "subscription.refresh", "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", "type": "string" + }, + "query": { + "additionalProperties": false, + "description": "Filters and control values for subscription.refresh. Start the configured system-wide subscription refresh job. Use only the named fields below.", + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/$defs/MediaType" + }, + { + "type": "null" + } + ], + "description": "MoviePilot media or storage item type required by the selected operation.", + "title": "Type" + } + }, + "type": "object" } }, "required": [ @@ -13397,6 +13595,25 @@ "subscribe_id" ], "type": "object" + }, + "query": { + "additionalProperties": false, + "description": "Filters and control values for subscription.search. Run an immediate search for one existing subscription. Use only the named fields below.", + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/$defs/MediaType" + }, + { + "type": "null" + } + ], + "description": "MoviePilot media or storage item type required by the selected operation.", + "title": "Type" + } + }, + "type": "object" } }, "required": [ @@ -13414,6 +13631,25 @@ "const": "subscription.search_all", "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", "type": "string" + }, + "query": { + "additionalProperties": false, + "description": "Filters and control values for subscription.search_all. Start immediate searches for all subscriptions accessible to the current user. Use only the named fields below.", + "properties": { + "type": { + "anyOf": [ + { + "$ref": "#/$defs/MediaType" + }, + { + "type": "null" + } + ], + "description": "MoviePilot media or storage item type required by the selected operation.", + "title": "Type" + } + }, + "type": "object" } }, "required": [ @@ -15862,6 +16098,9 @@ "plugin.history", "plugin.install", "plugin.installed", + "plugin.loglevel.clear", + "plugin.loglevel.get", + "plugin.loglevel.set", "plugin.market", "plugin.market.sync_wiki", "plugin.rating", diff --git a/app/api/endpoints/pluginloglevel.py b/app/api/endpoints/pluginloglevel.py new file mode 100644 index 000000000..0bdc15983 --- /dev/null +++ b/app/api/endpoints/pluginloglevel.py @@ -0,0 +1,82 @@ +"""插件实例日志等级查询与设置接口。""" + +from typing import Any + +from fastapi import Depends, HTTPException + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.principal import ApiPrincipal +from app.api.response import ResponseAPIRouter +from app.application.plugin.runtime import get_plugin_manager +from app.schemas.plugin import PluginInstanceLogLevelOverview as _SchemaPluginInstanceLogLevelOverview +from app.schemas.plugin import PluginInstanceLogLevelUpdateRequest as _SchemaPluginInstanceLogLevelUpdateRequest +from app.schemas.response import Response as _SchemaResponse + +router = ResponseAPIRouter() + + +@router.get( # type: ignore[misc] + "/loglevel/{plugin_id}", + summary="查询插件全部实例的日志等级设置", + response_model=_SchemaResponse[_SchemaPluginInstanceLogLevelOverview], +) +def plugin_instance_log_levels( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 查询插件全部实例(含本体)当前的日志等级设置 + """ + try: + levels = get_plugin_manager().get_plugin_instance_log_levels(plugin_id) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + return _SchemaResponse( + success=True, + data={"plugin_id": plugin_id, "instances": levels}, + ) + + +@router.put( # type: ignore[misc] + "/loglevel/{plugin_id}/{instance_id}", + summary="设置插件实例的日志等级覆盖", + response_model=_SchemaResponse[None], +) +def set_plugin_instance_log_level( + plugin_id: str, + instance_id: str, + update: _SchemaPluginInstanceLogLevelUpdateRequest, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 设置指定插件实例的日志等级覆盖,运行期立即生效,不随全局日志等级变更 + """ + try: + get_plugin_manager().set_plugin_instance_log_level( + plugin_id, instance_id, update.level, update.expires_at + ) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return _SchemaResponse(success=True) + + +@router.delete( # type: ignore[misc] + "/loglevel/{plugin_id}/{instance_id}", + summary="清除插件实例的日志等级覆盖", + response_model=_SchemaResponse[None], +) +def clear_plugin_instance_log_level( + plugin_id: str, + instance_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 清除指定插件实例的日志等级覆盖,立即回落全局等级,重复调用保持幂等 + """ + try: + get_plugin_manager().clear_plugin_instance_log_level(plugin_id, instance_id) + except LookupError as error: + raise HTTPException(status_code=404, detail=str(error)) from error + return _SchemaResponse(success=True) diff --git a/app/api/routers.py b/app/api/routers.py index ced8c46e5..13ab4e1b4 100644 --- a/app/api/routers.py +++ b/app/api/routers.py @@ -25,6 +25,7 @@ from app.api.endpoints import ( notification, openai, plugin, + pluginloglevel, recommend, rule, search, @@ -73,6 +74,7 @@ API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = ( RouterSpec(notification.router, "/notification", ("notification",)), RouterSpec(llm.router, "/llm", ("llm",)), RouterSpec(plugin.router, "/plugin", ("plugin",)), + RouterSpec(pluginloglevel.router, "/plugin", ("plugin",)), RouterSpec(download.router, "/download", ("download",)), RouterSpec(dashboard.router, "/dashboard", ("dashboard",)), RouterSpec(storage.router, "/storage", ("storage",)), diff --git a/app/db/models/plugininstance.py b/app/db/models/plugininstance.py index c4732b8b3..232ef5b84 100644 --- a/app/db/models/plugininstance.py +++ b/app/db/models/plugininstance.py @@ -26,6 +26,10 @@ class PluginInstance(Base): 淹掉;而且它存的其实是实例 ID,表里没有任何一列说得出它属于哪个插件,想列出 某个插件的全部实例配置就只能靠字符串前缀去猜。 + ``log_level`` 为空表示该实例跟随全局日志等级;非空且未过期时覆盖全局等级, + ``log_expires_at`` 为空表示覆盖不过期。过期判定在读取时惰性执行,实现见 + ``app.runtime.log``,库里只存原样设置,不存已折算的结果。 + 表名由 ``Base`` 按类名自动派生为小写 ``plugininstance``。 """ @@ -35,6 +39,8 @@ class PluginInstance(Base): plugin_name: Mapped[Optional[str]] = mapped_column(String(255)) plugin_desc: Mapped[Optional[str]] = mapped_column(String(255)) plugin_icon: Mapped[Optional[str]] = mapped_column(String(255)) + log_level: Mapped[Optional[str]] = mapped_column(String(16)) + log_expires_at: Mapped[Optional[str]] = mapped_column(String(40)) config_data: Mapped[Optional[Any]] = mapped_column(JSON) created_at: Mapped[str] = mapped_column(String(40), nullable=False) updated_at: Mapped[str] = mapped_column(String(40), nullable=False) @@ -60,6 +66,8 @@ class PluginInstance(Base): return not any( ( self.config_data is not None, + self.log_level, + self.log_expires_at, self.plugin_name, self.plugin_desc, self.plugin_icon, diff --git a/app/db/oper/plugininstance.py b/app/db/oper/plugininstance.py index f0cce8269..707c2b05a 100644 --- a/app/db/oper/plugininstance.py +++ b/app/db/oper/plugininstance.py @@ -142,6 +142,52 @@ class PluginInstanceOper(DbOper): return self._execute_sync_write(stage) + def set_log_level( + self, + *, + instance_id: str, + log_level: Optional[str], + log_expires_at: Optional[str], + ) -> bool: + """写入或清除该实例的日志等级覆盖,没有实例行时按本体建出。 + + 只设过等级、从没存过参数的插件同样要有一行,否则覆盖无处落盘;反过来, + 清除覆盖后若该行是本体且各列皆空,整行一并回收,与清空业务参数同一规则, + 不让只剩一对身份列的空行堆积。 + + :param instance_id: 实例 ID + :param log_level: 目标等级名,None 表示清除覆盖 + :param log_expires_at: 覆盖失效时间的 ISO-8601 文本,None 表示不过期 + :return: 是否发生了写入;清除一个本来就没有覆盖的实例返回 False + """ + + def stage(session: Session) -> bool: + """在同一事务内写入等级覆盖,并回收清空后只剩身份的本体行。""" + record = PluginInstance.get_by_instance_id(session, instance_id) + if record is None: + if log_level is None and log_expires_at is None: + return False + now = _now() + session.add( + PluginInstance( + instance_id=instance_id, + source_plugin_id=instance_id, + log_level=log_level, + log_expires_at=log_expires_at, + created_at=now, + updated_at=now, + ) + ) + return True + record.log_level = log_level + record.log_expires_at = log_expires_at + record.updated_at = _now() + if record.is_host and record.carries_only_identity: + session.delete(record) + return True + + return bool(self._execute_sync_write(stage)) + def clear_config_data(self, instance_id: str) -> bool: """清空某实例的业务参数,保留其身份与展示信息。 diff --git a/app/runtime/event/dispatch.py b/app/runtime/event/dispatch.py index ea714b78a..0fb0f00f4 100644 --- a/app/runtime/event/dispatch.py +++ b/app/runtime/event/dispatch.py @@ -5,17 +5,27 @@ from __future__ import annotations import inspect import time from collections.abc import Callable +from contextlib import AbstractContextManager, nullcontext from typing import Any from app.runtime.correlation import correlation_scope from app.runtime.event.binding import EventBindingResolver from app.runtime.event.registry import EventRegistry from app.runtime.execution import run_in_threadpool -from app.runtime.log import logger +from app.runtime.log import bind_plugin_instance, logger from app.runtime.observability import observe_duration from app.schemas.types import EventType +def _instance_binding_scope(class_name: str) -> AbstractContextManager[None]: + """处理器归属某个类时绑定其插件实例日志上下文,自由函数处理器不绑定。 + + ``class_name`` 就是声明该处理器的类的 ``__name__``;宿主类处理器同样会 + 命中这里,但缓存里没有它们的等级覆盖记录,过滤时自然回落全局等级。 + """ + return bind_plugin_instance(class_name) if class_name else nullcontext() + + class EventDispatcher: """基于订阅快照执行链式或广播事件,不拥有注册和生命周期状态。""" @@ -247,7 +257,7 @@ class EventDispatcher: "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): method(event) except Exception as err: self._error_handler( @@ -278,7 +288,7 @@ class EventDispatcher: "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): method(event) except Exception as err: self._error_handler( @@ -302,7 +312,7 @@ class EventDispatcher: "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): if inspect.iscoroutinefunction(method): await method(event) elif binding.run_sync_in_threadpool or not class_name: @@ -339,7 +349,7 @@ class EventDispatcher: "event.handler.duration", event_type=event.event_type.value, handler_type="bound" if class_name else "function", - ): + ), _instance_binding_scope(class_name): if inspect.iscoroutinefunction(method): await method(event) elif run_sync_in_threadpool or binding.run_sync_in_threadpool or not class_name: diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py index 992f02cf0..e23286b9a 100644 --- a/app/runtime/extensions/plugin/catalog.py +++ b/app/runtime/extensions/plugin/catalog.py @@ -11,6 +11,7 @@ from app.foundation.version import compare_version from app.runtime.extensions.plugin.contracts import supports_plugin_hook from app.runtime.extensions.plugin.storage import PluginStorage from app.runtime.extensions.plugin.system import PluginSystemServices +from app.runtime.log import get_plugin_instance_log_level_override from app.runtime.settings import get_runtime_setting from app.schemas.plugin import Plugin, PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey @@ -96,6 +97,7 @@ class PluginCatalogFacade: source_plugin_id=getattr(plugin_class, "plugin_source_id", None), is_instance=instance is not None, instance_mode=instance.mode if instance else None, + **self._instance_overlay(plugin_id), ) if not self._auth_checker(plugin=plugin, source=plugin_class): continue @@ -130,11 +132,26 @@ class PluginCatalogFacade: ), is_instance=instance is not None, instance_mode=instance.mode if instance else None, + **self._instance_overlay(plugin_id), )) # 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。 # 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。 return result + @staticmethod + def _instance_overlay(instance_id: str) -> dict[str, Any]: + """把该实例当前的日志等级覆盖投影为卡片列表的只读叠加字段。 + + 直接按实例 ID 查进程内覆盖缓存,不回表:本体与分身在覆盖表里共用同一个 + 命名空间,而卡片的 ``plugin_id`` 本身就是运行实例的 ID,本体等于插件 ID、 + 分身等于分身实例 ID,再去查一次实例行只会为同一个键多走一趟数据库。 + + :param instance_id: 运行实例 ID + :return: 可直接展开进 ``Plugin(...)`` 构造参数的字段字典 + """ + override = get_plugin_instance_log_level_override(instance_id) + return {"log_level_effective": override[0] if override is not None else None} + def local_version(self, plugin_id: str) -> Optional[str]: """读取指定已安装插件版本,不触发全量目录投影。""" installed = self._installed_ids() diff --git a/app/runtime/extensions/plugin/lifecycle.py b/app/runtime/extensions/plugin/lifecycle.py index 58b4f2087..c751bdb0b 100644 --- a/app/runtime/extensions/plugin/lifecycle.py +++ b/app/runtime/extensions/plugin/lifecycle.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, Optional, ParamSpec, TypeVar, cast from app.runtime.extensions.plugin.database import PluginDatabase +from app.runtime.log import bind_plugin_instance from app.runtime.observability import record_metric from app.schemas.plugin import PluginRuntimeStatus @@ -132,8 +133,9 @@ class PluginLifecycle: continue self._remove_classification(current_id) self._classes[current_id] = plugin - instance = plugin() - instance.init_plugin(self._plugin_config(current_id)) + with bind_plugin_instance(current_id): + instance = plugin() + instance.init_plugin(self._plugin_config(current_id)) self._ensure_database(current_id, instance) enabled = bool(instance.get_state()) if enabled: @@ -208,7 +210,8 @@ class PluginLifecycle: return self._remove_classification(plugin_id) try: - plugin.init_plugin(config) + with bind_plugin_instance(plugin_id): + plugin.init_plugin(config) enabled = bool(plugin.get_state()) if enabled: self._refresh_classification_safely(plugin_id, plugin) diff --git a/app/runtime/extensions/plugin/loglevel.py b/app/runtime/extensions/plugin/loglevel.py new file mode 100644 index 000000000..e98a06fb4 --- /dev/null +++ b/app/runtime/extensions/plugin/loglevel.py @@ -0,0 +1,137 @@ +"""插件实例日志等级查询与设置。""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Any, Optional + +from app.runtime.log import ( + clear_plugin_instance_log_level, + get_effective_plugin_instance_log_level, + get_plugin_instance_log_level_override, + normalize_log_expiry, + set_plugin_instance_log_level, +) +from app.schemas.plugin import PluginInstance + + +class PluginLogLevelControl: + """组装插件全部实例(含本体)的日志等级设置,并同步落盘与进程内缓存。""" + + def __init__( + self, + *, + plugin_exists: Callable[[str], bool], + get_instance: Callable[[str], Optional[PluginInstance]], + instances_for_source: Callable[[str], list[PluginInstance]], + read_log_level: Callable[[str], tuple[Optional[str], Optional[datetime]]], + write_log_level: Callable[[str, Optional[str], Optional[datetime]], None], + ) -> None: + """保存插件存在性判定、实例枚举端口与日志等级的配置侧读写端口。 + + 实例存储只回答「有哪些分身」;日志等级是用户按实例设置、带失效时间的配置, + 与业务参数同一生命周期,因此落在插件实例配置表而不是实例描述符上。 + """ + self._plugin_exists = plugin_exists + self._get_instance = get_instance + self._instances_for_source = instances_for_source + self._read_log_level = read_log_level + self._write_log_level = write_log_level + + @staticmethod + def _host_instance(plugin_id: str) -> PluginInstance: + """构造源插件本体自身的实例视图。 + + 本体不需要从实例表里读:这里只用到它的实例 ID,而本体的实例 ID 恒等于 + 插件 ID。去查表反而会把「本体从未登记过任何设置」这件事变成查不到、 + 进而列不出本体一项。 + """ + return PluginInstance( + instance_id=plugin_id, + source_plugin_id=plugin_id, + ) + + def _resolve(self, plugin_id: str, instance_id: str) -> PluginInstance: + """按插件 ID 和实例 ID 定位实例描述,实例须真实归属该插件。 + + :raise LookupError: 插件不存在,``plugin_id`` 实为某个分身自身的实例 ID, + 或实例不存在/不归属该插件 + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + if self._get_instance(plugin_id) is not None: + raise LookupError(f"{plugin_id} 是分身实例,请使用源插件 ID 管理日志等级") + if instance_id == plugin_id: + return self._host_instance(plugin_id) + instance = self._get_instance(instance_id) + if instance is None or instance.source_plugin_id != plugin_id: + raise LookupError(f"插件实例 {instance_id} 不存在") + return instance + + @staticmethod + def _describe(instance: PluginInstance) -> dict[str, Any]: + """把一个实例的日志等级覆盖投影为查询响应条目。""" + override = get_plugin_instance_log_level_override(instance.instance_id) + configured_level, expires_at = override if override is not None else (None, None) + return { + "instance_id": instance.instance_id, + "configured_level": configured_level, + "expires_at": expires_at, + "effective_level": get_effective_plugin_instance_log_level(instance.instance_id), + } + + def list_levels(self, plugin_id: str) -> list[dict[str, Any]]: + """列出插件全部实例(含本体)当前的日志等级设置。 + + :param plugin_id: 插件 ID + :return: 每个实例的等级设置条目列表,首项固定是本体自身 + :raise LookupError: 插件不存在,或 ``plugin_id`` 实为某个分身自身的实例 ID + """ + if not self._plugin_exists(plugin_id): + raise LookupError(f"插件 {plugin_id} 不存在") + if self._get_instance(plugin_id) is not None: + raise LookupError(f"{plugin_id} 是分身实例,请使用源插件 ID 查询日志等级") + instances = [ + self._host_instance(plugin_id), + *self._instances_for_source(plugin_id), + ] + return [self._describe(instance) for instance in instances] + + def set_level( + self, + plugin_id: str, + instance_id: str, + level: str, + expires_at: Optional[datetime] = None, + ) -> None: + """设置指定实例的日志等级覆盖,写入进程内缓存后立即落盘。 + + 失效时间先归一到 UTC 再分发给缓存与落盘两处:客户端提交的时间可能不带 + 时区,两处各自按本地时区折算一次就会和读取口返回的 UTC 值对不上。 + + :param plugin_id: 插件 ID + :param instance_id: 实例 ID + :param level: 目标日志等级 + :param expires_at: 覆盖失效时间,None 表示不过期 + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + :raise ValueError: level 不是受支持的等级名 + """ + self._resolve(plugin_id, instance_id) + expiry = normalize_log_expiry(expires_at) + set_plugin_instance_log_level(instance_id, level, expiry) + self._write_log_level(instance_id, level.strip().upper(), expiry) + + def clear_level(self, plugin_id: str, instance_id: str) -> None: + """清除指定实例的日志等级覆盖,运行期立即回落全局等级;重复清除保持幂等。 + + :param plugin_id: 插件 ID + :param instance_id: 实例 ID + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + """ + self._resolve(plugin_id, instance_id) + clear_plugin_instance_log_level(instance_id) + level, expires_at = self._read_log_level(instance_id) + if level is None and expires_at is None: + return + self._write_log_level(instance_id, None, None) diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index 10be37caa..9011721f6 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -5,6 +5,7 @@ import posixpath import threading import time from contextlib import contextmanager +from datetime import datetime from pathlib import Path from typing import ( Any, @@ -35,6 +36,7 @@ from app.runtime.extensions.plugin.monitor import PluginChangeMonitor from app.runtime.extensions.plugin.projection import PluginProjection from app.runtime.extensions.plugin.runtime import PluginRuntime from app.runtime.extensions.plugin.tools import PluginToolCatalog +from app.runtime.log import clear_plugin_instance_log_level as clear_instance_log_level_override from app.runtime.log import logger from app.runtime.observability import observe_compat_facade from app.runtime.reload import ConfigReloadMixin @@ -218,6 +220,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): self._plugin_metadata = self._plugin_runtime.metadata self._plugin_sync = self._plugin_runtime.sync self._plugin_clone = self._plugin_runtime.clone + self._plugin_log_level = self._plugin_runtime.log_level self._plugin_classification = self._plugin_runtime.classification # 事件总线只通过通用解析器访问运行中的插件实例。 eventmanager.register_handler_instance_resolver( @@ -770,13 +773,20 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): return self._plugin_instance_store.for_source(plugin_id) def delete_plugin_instance(self, plugin_id: str) -> bool: - """删除虚拟实例描述;调用方仍负责停止实例和清理业务数据。""" + """删除虚拟实例描述;调用方仍负责停止实例和清理业务数据。 + + 同时清掉该实例的进程内日志等级覆盖:覆盖表按实例 ID 常驻进程,只删库里那 + 一行的话,同一进程内用相同后缀重建的分身会继承上一个分身的等级——界面显示 + 「跟随全局」,实际仍按旧等级输出,直到重启才恢复。 + """ try: with self.mutation("删除插件实例描述"): - return self._plugin_instance_store.delete(plugin_id) + deleted = self._plugin_instance_store.delete(plugin_id) except PluginMutationRejectedError as error: logger.warning(str(error)) return False + clear_instance_log_level_override(plugin_id) + return deleted def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool: """ @@ -1251,6 +1261,42 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): logger.warning(str(error)) return False, str(error) + def get_plugin_instance_log_levels(self, plugin_id: str) -> List[Dict[str, Any]]: + """ + 查询插件全部实例(含本体)当前的日志等级设置 + :param plugin_id: 插件ID + :return: 每个实例的等级设置条目列表,首项固定是本体自身 + :raise LookupError: 插件不存在,或 plugin_id 实为某个分身自身的实例ID + """ + return self._plugin_log_level.list_levels(plugin_id) + + def set_plugin_instance_log_level( + self, + plugin_id: str, + instance_id: str, + level: str, + expires_at: Optional[datetime] = None, + ) -> None: + """ + 设置指定实例的日志等级覆盖,运行期立即生效并落盘 + :param plugin_id: 插件ID + :param instance_id: 实例ID + :param level: 目标日志等级 + :param expires_at: 覆盖失效时间,为空表示不过期 + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + :raise ValueError: level 不是受支持的等级名 + """ + self._plugin_log_level.set_level(plugin_id, instance_id, level, expires_at) + + def clear_plugin_instance_log_level(self, plugin_id: str, instance_id: str) -> None: + """ + 清除指定实例的日志等级覆盖,立即回落全局等级,重复清除保持幂等 + :param plugin_id: 插件ID + :param instance_id: 实例ID + :raise LookupError: 插件不存在,或实例不存在/不归属该插件 + """ + self._plugin_log_level.clear_level(plugin_id, instance_id) + def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str, name: str, description: str, version: str = None, icon: str = None) -> Tuple[bool, str]: diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 8b3c3405b..005114165 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -9,6 +9,7 @@ from app.runtime.extensions.plugin.contracts import ( supports_plugin_hook, ) from app.runtime.log import logger as default_logger +from app.runtime.log import wrap_for_plugin_instance from app.schemas.plugin import PluginDashboard @@ -51,7 +52,7 @@ class PluginProjection: return commands def apis(self, pid: Optional[str] = None) -> List[Dict[str, Any]]: - """聚合插件 API 并补充宿主路径和默认认证方式。""" + """聚合插件 API 并补充宿主路径和默认认证方式,端点绑定发起实例的日志上下文。""" apis: list[dict] = [] for plugin_id, plugin in self._items(pid): if not supports_plugin_hook(plugin, "get_api"): @@ -62,6 +63,9 @@ class PluginProjection: api["path"] = f"/{plugin_id}{api['path']}" if not api.get("auth"): api["auth"] = "apikey" + endpoint = api.get("endpoint") + if callable(endpoint): + api["endpoint"] = wrap_for_plugin_instance(endpoint, plugin_id) apis.append(api) except Exception as error: self._logger.error(f"获取插件 {plugin_id} API出错:{str(error)}") diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py index dcdced008..a4fd82516 100644 --- a/app/runtime/extensions/plugin/runtime.py +++ b/app/runtime/extensions/plugin/runtime.py @@ -20,6 +20,7 @@ from app.runtime.extensions.plugin.database import PluginDatabase from app.runtime.extensions.plugin.dependency import PluginDependencyService from app.runtime.extensions.plugin.lifecycle import PluginLifecycle from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.loglevel import PluginLogLevelControl from app.runtime.extensions.plugin.metadata import PluginMetadataMapper from app.runtime.extensions.plugin.monitor import PluginMonitorController from app.runtime.extensions.plugin.paths import PluginPathResolver @@ -122,6 +123,7 @@ class PluginRuntime: metadata: PluginMetadataMapper sync: PluginSyncService clone: PluginCloneService + log_level: PluginLogLevelControl projection: PluginProjection classification: PluginClassificationRegistry recent_local_sync: dict[str, float] @@ -321,6 +323,13 @@ def build_plugin_runtime( remove_plugin=host.remove_plugin, log=environment.logger, ) + log_level = PluginLogLevelControl( + plugin_exists=lambda plugin_id: registry.plugin_class(plugin_id) is not None, + get_instance=instances.get, + instances_for_source=instances.for_source, + read_log_level=configs.read_log_level, + write_log_level=configs.write_log_level, + ) projection = PluginProjection( registry.running, environment.logger, @@ -346,6 +355,7 @@ def build_plugin_runtime( metadata=metadata, sync=sync, clone=clone, + log_level=log_level, projection=projection, classification=classification, recent_local_sync=recent_local_sync, diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index 9a5250e4d..ba3b82f83 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -3,7 +3,8 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any +from datetime import datetime +from typing import Any, Optional from pydantic import ValidationError @@ -17,6 +18,10 @@ ConfigWriter = Callable[[Any, Any], Any] AsyncConfigWriter = Callable[[Any, Any], Awaitable[Any]] ConfigDeleter = Callable[[Any], bool] PluginDataDeleter = Callable[[str], Any] +# 日志等级覆盖端口的载荷:`(等级名, 失效时间)`,两者皆为 None 即未设置覆盖 +LogLevelOverride = tuple[Optional[str], Optional[datetime]] +LogLevelReader = Callable[[str], LogLevelOverride] +LogLevelWriter = Callable[[str, Optional[str], Optional[datetime]], None] PluginExists = Callable[[str], bool] @@ -42,6 +47,19 @@ def _ignore_plugin_data_delete(_plugin_id: str) -> None: """组合根尚未装配时忽略插件数据删除。""" +def _no_log_level(_instance_id: str) -> LogLevelOverride: + """组合根尚未装配时报告实例没有日志等级覆盖。""" + return (None, None) + + +def _ignore_log_level_write( + _instance_id: str, + _level: Optional[str], + _expires_at: Optional[datetime], +) -> None: + """组合根尚未装配时忽略日志等级落盘。""" + + class PluginStorage: """封装插件运行时所需的最小持久化能力。""" @@ -53,6 +71,8 @@ class PluginStorage: async_write: AsyncConfigWriter = _ignore_async_write, delete: ConfigDeleter = _ignore_delete, delete_data: PluginDataDeleter = _ignore_plugin_data_delete, + read_log_level: LogLevelReader = _no_log_level, + write_log_level: LogLevelWriter = _ignore_log_level_write, ) -> None: """保存由启动组合根提供的读写函数。""" self._read = read @@ -60,6 +80,8 @@ class PluginStorage: self._async_write = async_write self._delete = delete self._delete_data = delete_data + self._read_log_level = read_log_level + self._write_log_level = write_log_level def read(self, key: Any) -> Any: """读取插件运行时配置。""" @@ -81,6 +103,19 @@ class PluginStorage: """删除指定插件的业务数据。""" return self._delete_data(plugin_id) + def read_log_level(self, instance_id: str) -> LogLevelOverride: + """读取实例配置里登记的日志等级覆盖与失效时间。""" + return self._read_log_level(instance_id) + + def write_log_level( + self, + instance_id: str, + level: Optional[str], + expires_at: Optional[datetime], + ) -> None: + """把日志等级覆盖写进该实例的配置行。""" + self._write_log_level(instance_id, level, expires_at) + class PluginConfigStore: """封装插件配置键、存在性和强制删除规则。""" @@ -139,6 +174,19 @@ class PluginConfigStore: return False return self._storage().delete(self._key(plugin_id)) + def read_log_level(self, instance_id: str) -> LogLevelOverride: + """读取实例的日志等级覆盖;它与业务参数同属该实例的配置。""" + return self._storage().read_log_level(instance_id) + + def write_log_level( + self, + instance_id: str, + level: Optional[str], + expires_at: Optional[datetime], + ) -> None: + """写入实例的日志等级覆盖。""" + self._storage().write_log_level(instance_id, level, expires_at) + def delete_data(self, plugin_id: str, force: bool = False) -> bool: """删除插件业务数据与自有数据库,并保持旧的布尔结果合同。""" if not force and not self._plugin_exists(plugin_id): diff --git a/app/runtime/log.py b/app/runtime/log.py index 35515b33f..21c86bd17 100644 --- a/app/runtime/log.py +++ b/app/runtime/log.py @@ -1,6 +1,10 @@ +"""日志基础设施:等级过滤、控制台与文件路由、插件实例日志等级覆盖。""" + from __future__ import annotations import asyncio +import functools +import inspect import logging import os import queue @@ -8,11 +12,14 @@ import sys import threading import time from collections import deque -from datetime import datetime +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from datetime import datetime, timezone from logging.handlers import RotatingFileHandler from pathlib import Path from types import FrameType -from typing import Any, Callable, Dict, Optional, Protocol, Self +from typing import Any, Callable, Dict, Iterator, Optional, Protocol, Self, Tuple import click from pydantic import BaseModel, ConfigDict @@ -103,6 +110,214 @@ def _get_log_correlation_id() -> str: return _correlation_id_provider() or "-" +# 插件实例日志等级允许的取值,与标准库 logging 的等级名保持一致。 +LOG_LEVELS: Tuple[str, ...] = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + + +def _current_global_log_level() -> int: + """返回当前全局日志策略对应的标准库日志级别。""" + if log_settings.DEBUG: + return logging.DEBUG + return getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO) + + +def normalize_log_expiry(expires_at: Optional[datetime]) -> Optional[datetime]: + """把覆盖失效时间归一为带 UTC 时区的时刻。 + + 不带时区的时间没有确定的时刻,落到 ``timestamp()`` 会按进程所在时区解释,而 + 读取口一律返回 UTC-aware 值;两边不对称的结果是客户端提交什么时刻、读回来就 + 偏移几个时区。这里在写入侧统一按 UTC 解读裸时间,读写从此描述同一个时刻。 + + :param expires_at: 失效时间,可能不带时区 + :return: 带 UTC 时区的失效时间;传入 None 时为 None + """ + if expires_at is None: + return None + if expires_at.tzinfo is None: + return expires_at.replace(tzinfo=timezone.utc) + return expires_at.astimezone(timezone.utc) + + +@dataclass(frozen=True) +class _PluginLevelOverride: + """一个插件实例的日志等级覆盖。""" + + level: int + level_name: str + expires_at: Optional[float] + + +# 宿主在构造、init_plugin、事件分发、定时任务、API 端点等自己控制的调用点用它绑定 +# 当前插件实例;插件自建的原生线程不会继承这里绑定的取值(contextvars 只在同一 +# 协程/任务链内传播),这类线程内的日志按全局等级过滤,不属于本机制的覆盖范围。 +_current_plugin_instance: "ContextVar[Optional[str]]" = ContextVar( + "current_plugin_instance", default=None +) + +# 插件实例日志等级覆盖缓存:等级来自数据库,避免每条日志都查库;写入源是日志控制 +# API(配置变更时直接调用 set/clear,立即生效)和启动组合根(进程重启后从数据库 +# 预热);覆盖的过期回落在读取时惰性判定并清理,不额外起后台线程扫描。 +# +# `LoggerManager.logger` 只在 `current_plugin_instance_id()` 命中时才查这份缓存 +# (未绑定实例的日志直接按全局等级过滤),因此不需要额外维护一个全局快速闸—— +# ContextVar 读取本身已经足够便宜,也避免了「某个实例的覆盖不小心放宽了所有未绑定 +# 日志的过滤阈值」这类跨实例串扰。 +_plugin_level_overrides: Dict[str, _PluginLevelOverride] = {} +_plugin_level_lock = threading.RLock() + + +def set_plugin_instance_log_level( + instance_id: str, + level: str, + expires_at: Optional[datetime] = None, +) -> None: + """ + 设置插件实例的日志等级覆盖,写入进程内缓存并立即生效。 + + 只维护运行期生效状态;把覆盖持久化到数据库是调用方(日志控制 API、启动组合根 + 的缓存预热)的职责,本函数不做任何数据库读写。 + :param instance_id: 实例 ID,源插件本体与其各个分身共用同一命名空间 + :param level: 目标等级,取值须在 LOG_LEVELS 内 + :param expires_at: 覆盖失效时间,None 表示不过期;不带时区时按 UTC 解读 + :raises ValueError: level 不是受支持的等级名 + """ + normalized = (level or "").strip().upper() + if normalized not in LOG_LEVELS: + raise ValueError(f"不支持的日志等级:{level}") + expiry = normalize_log_expiry(expires_at) + entry = _PluginLevelOverride( + level=getattr(logging, normalized), + level_name=normalized, + expires_at=expiry.timestamp() if expiry else None, + ) + with _plugin_level_lock: + _plugin_level_overrides[instance_id] = entry + + +def clear_plugin_instance_log_level(instance_id: str) -> None: + """ + 清除插件实例的日志等级覆盖,运行期立即回落全局等级。 + :param instance_id: 实例 ID + """ + with _plugin_level_lock: + _plugin_level_overrides.pop(instance_id, None) + + +def get_plugin_instance_log_level_override( + instance_id: str, +) -> Optional[Tuple[str, Optional[datetime]]]: + """ + 返回插件实例当前缓存的原始等级覆盖设置,未设置或已过期时为 None。 + :param instance_id: 实例 ID + :return: `(等级名, 失效时间)`;失效时间为 None 表示不过期 + """ + with _plugin_level_lock: + entry = _plugin_level_overrides.get(instance_id) + if entry is None: + return None + if entry.expires_at is not None and entry.expires_at <= time.time(): + del _plugin_level_overrides[instance_id] + return None + # 带上时区:不传 tz 得到的是无时区的本地时间,调用方按 UTC 解读就会整体 + # 偏移,表现为「提交的失效时刻与读回来的对不上」。 + expires_dt = ( + datetime.fromtimestamp(entry.expires_at, tz=timezone.utc) + if entry.expires_at + else None + ) + return entry.level_name, expires_dt + + +def get_effective_plugin_instance_log_level(instance_id: str) -> str: + """ + 返回插件实例当前生效的日志等级名,覆盖过期时回落全局等级。 + :param instance_id: 实例 ID + :return: 等级名,如 "DEBUG" + """ + override = get_plugin_instance_log_level_override(instance_id) + if override is not None: + return override[0] + return "DEBUG" if log_settings.DEBUG else log_settings.LOG_LEVEL.upper() + + +def _effective_instance_level_int(instance_id: str) -> int: + """返回插件实例过滤日志时实际使用的等级整数,供 `LoggerManager.logger` 精确过滤。""" + with _plugin_level_lock: + entry = _plugin_level_overrides.get(instance_id) + if entry is None: + return _current_global_log_level() + if entry.expires_at is not None and entry.expires_at <= time.time(): + del _plugin_level_overrides[instance_id] + return _current_global_log_level() + return entry.level + + +def current_plugin_instance_id() -> Optional[str]: + """返回当前受控调用点绑定的插件实例 ID,未绑定时为 None。""" + return _current_plugin_instance.get() + + +@contextmanager +def bind_plugin_instance(instance_id: str) -> Iterator[None]: + """ + 在宿主自己控制的调用点(构造、init_plugin、事件分发、定时任务、API 端点……)内 + 绑定当前插件实例,供日志等级过滤使用。 + + 绑定只在当前协程/任务链内生效;插件自建的原生线程不继承这个绑定。 + :param instance_id: 实例 ID + """ + token = _current_plugin_instance.set(instance_id) + try: + yield + finally: + _current_plugin_instance.reset(token) + + +# 标记包装函数已绑定的实例,用于判定是否已经包过,避免重复包装叠成长链 +_PLUGIN_BINDING_MARK = "_plugin_instance_binding" + + +def wrap_for_plugin_instance( + func: Callable[..., Any], instance_id: str +) -> Callable[..., Any]: + """ + 包装一个插件回调,使其执行期间的日志按指定实例过滤等级。 + + 用于回调在注册时被捕获、稍后才由宿主(如调度器、HTTP 路由)调用的场景; + 绑定发生在包装函数自身调用内部,因此不依赖调用方所在协程/线程如何传播 + 上下文。同步/异步函数各自返回同型包装,`inspect.iscoroutinefunction` + 等自省结果不变。 + + 对同一实例重复包装直接返回原对象:插件的 `get_api`/`get_service` 可能 + 每次都返回同一份缓存好的声明,每次刷新注册再包一层会让包装链随重载次数 + 无限增长。 + :param func: 插件提供的原始回调,通常是插件实例的绑定方法 + :param instance_id: 实例 ID + :return: 包装后的可调用对象 + """ + if getattr(func, _PLUGIN_BINDING_MARK, None) == instance_id: + return func + + if inspect.iscoroutinefunction(func): + @functools.wraps(func) + async def _async_wrapped(*args: Any, **kwargs: Any) -> Any: + """在绑定实例上下文内等待原始协程回调。""" + with bind_plugin_instance(instance_id): + return await func(*args, **kwargs) + + setattr(_async_wrapped, _PLUGIN_BINDING_MARK, instance_id) + return _async_wrapped + + @functools.wraps(func) + def _sync_wrapped(*args: Any, **kwargs: Any) -> Any: + """在绑定实例上下文内调用原始同步回调。""" + with bind_plugin_instance(instance_id): + return func(*args, **kwargs) + + setattr(_sync_wrapped, _PLUGIN_BINDING_MARK, instance_id) + return _sync_wrapped + + class NonBlockingFileHandler: """使用后台队列和滚动文件处理器写入业务日志。""" @@ -512,9 +727,7 @@ class LoggerManager: @staticmethod def _get_log_level() -> int: """返回当前日志策略对应的标准库日志级别。""" - if log_settings.DEBUG: - return logging.DEBUG - return getattr(logging, log_settings.LOG_LEVEL.upper(), logging.INFO) + return _current_global_log_level() @classmethod def _write_file_log(cls, level: str, message: str, logfile: Path) -> None: @@ -528,9 +741,24 @@ class LoggerManager: writer.write_log(level, message, log_path / logfile) def logger(self, method: str, msg: str, *args: Any, **kwargs: Any) -> None: - """按调用来源路由并输出一条日志。""" + """按调用来源路由并输出一条日志。 + + 等级过滤只看 `current_plugin_instance_id()`:命中受控调用点绑定的插件 + 实例时按该实例的覆盖等级过滤(未设置覆盖时等同全局等级),未绑定任何 + 实例时直接按全局等级过滤。这一步必须排在全局等级判定之前——先按全局 + 等级短路就会把实例调低的等级挡在门外,再往下的栈帧内省也就永远看不到 + 这条日志。栈回溯识别出的调用来源只用于文件路由,不参与等级判定:插件 + 经宿主公共方法转发调用时,栈顶是宿主而不是发起调用的插件,按它判等级 + 会认错来源。 + """ method_level = getattr(logging, method.upper(), logging.INFO) - if method_level < self._get_log_level(): + instance_id = current_plugin_instance_id() + effective_level = ( + _effective_instance_level_int(instance_id) + if instance_id + else _current_global_log_level() + ) + if method_level < effective_level: return caller_name, plugin_name = self._get_caller() diff --git a/app/scheduler/reconcile.py b/app/scheduler/reconcile.py index 5676ff2e5..6fd4a1da5 100644 --- a/app/scheduler/reconcile.py +++ b/app/scheduler/reconcile.py @@ -21,7 +21,7 @@ from app.application.scheduling import ( # noqa: E402 ) from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module from app.application.workflow import WorkflowSnapshot -from app.runtime.log import logger +from app.runtime.log import logger, wrap_for_plugin_instance from app.runtime.scheduling import TimerUtils from app.scheduler.contract import _SchedulerOwnerBase from app.schemas.message import Message @@ -427,7 +427,7 @@ class SchedulerReconcileOwner(_SchedulerOwnerBase): job = JobSpec( job_id, service["name"], - service["func"], + wrap_for_plugin_instance(service["func"], pid), f"plugin:{pid}", kwargs=service.get("func_kwargs") or {}, ).to_runtime_state() diff --git a/app/schemas/exports.py b/app/schemas/exports.py index ec7cda840..05f0ab361 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -353,6 +353,9 @@ SCHEMA_EXPORTS = { 'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'), 'PluginInstallOutcome': ('app.schemas.plugin', 'PluginInstallOutcome'), 'PluginInstance': ('app.schemas.plugin', 'PluginInstance'), + 'PluginInstanceLogLevel': ('app.schemas.plugin', 'PluginInstanceLogLevel'), + 'PluginInstanceLogLevelOverview': ('app.schemas.plugin', 'PluginInstanceLogLevelOverview'), + 'PluginInstanceLogLevelUpdateRequest': ('app.schemas.plugin', 'PluginInstanceLogLevelUpdateRequest'), 'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'), 'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'), 'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index cbc27338a..62873c99f 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -1,3 +1,4 @@ +from datetime import datetime as _datetime from enum import Enum as _Enum from typing import Annotated as _Annotated from typing import Dict, List, Literal, Optional, Union @@ -75,6 +76,34 @@ class PluginInstance(BaseModel): return "host" if self.is_host else "virtual" +class PluginInstanceLogLevel(BaseModel): # type: ignore[misc] + """单个实例的日志等级设置与生效结果。""" + + instance_id: str = Field(description="实例 ID") + configured_level: Optional[str] = Field(default=None, description="该实例设置的日志等级覆盖,None 表示未设置或已过期") + expires_at: Optional[_datetime] = Field(default=None, description="日志等级覆盖的失效时间,None 表示不过期") + effective_level: str = Field(description="按过期回落判定后实际生效的日志等级") + + +class PluginInstanceLogLevelOverview(BaseModel): # type: ignore[misc] + """插件全部实例(含本体)的日志等级设置总览。""" + + plugin_id: str = Field(description="插件 ID") + instances: List[PluginInstanceLogLevel] = Field( + default_factory=list, description="该插件全部实例的日志等级设置,首项固定是本体自身" + ) + + +class PluginInstanceLogLevelUpdateRequest(BaseModel): # type: ignore[misc] + """设置实例日志等级覆盖的请求参数。""" + + level: str = Field(description="目标日志等级,如 DEBUG、INFO、WARNING、ERROR、CRITICAL") + expires_at: Optional[_datetime] = Field( + default=None, + description="覆盖失效时间,None 表示不过期;不带时区时按 UTC 解读", + ) + + class Plugin(BaseModel): """ 插件信息 @@ -143,6 +172,8 @@ class Plugin(BaseModel): is_instance: Optional[bool] = False # 实例实现模式;存量物理分身为空 instance_mode: Optional[str] = None + # 该实例当前生效的日志等级覆盖;未设置覆盖或覆盖已过期回落全局等级时为空 + log_level_effective: Optional[str] = None @property def package_version(self) -> Optional[str]: diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index b8e28f77d..a2d12e4ea 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -5,6 +5,8 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, cast +import sqlalchemy as sa + from app.adapters.external.market import ( LOCAL_REPO_PREFIX, configure_installed_plugins_provider, @@ -117,6 +119,7 @@ from app.runtime.extensions.plugin.runtime import ( build_plugin_runtime, ) from app.runtime.extensions.plugin.storage import ( + LogLevelOverride, PluginInstanceDirectory, PluginStorage, configure_plugin_instance_directory, @@ -129,7 +132,7 @@ from app.runtime.extensions.plugin.system import ( configure_plugin_system, get_plugin_system, ) -from app.runtime.log import logger +from app.runtime.log import logger, normalize_log_expiry, set_plugin_instance_log_level from app.runtime.loop import main_loop_registry from app.runtime.resources import acquire_managed_resource from app.runtime.settings import get_runtime_setting @@ -160,6 +163,68 @@ def _delete_plugin_data(plugin_id: str) -> None: session.close() +def _read_plugin_log_level(instance_id: str) -> LogLevelOverride: + """从实例行读取日志等级覆盖与失效时间。""" + session = SessionFactory() + try: + record = PluginInstanceRecord.get_by_instance_id(session, instance_id) + if record is None or not record.log_level: + return (None, None) + expires_at = ( + datetime.fromisoformat(record.log_expires_at) if record.log_expires_at else None + ) + return (record.log_level, expires_at) + finally: + session.close() + + +def _write_plugin_log_level( + instance_id: str, + level: str | None, + expires_at: datetime | None, +) -> None: + """把日志等级覆盖写进该实例行;没有实例行时按本体建出。 + + 失效时间按 UTC 归一后再转字符串:库里存的是裸 ISO 文本,不带时区的写入会在 + 读回时被当成本地时间,同一个覆盖在不同进程时区下表示不同时刻。 + """ + normalized = normalize_log_expiry(expires_at) + PluginInstanceOper().set_log_level( + instance_id=instance_id, + log_level=level, + log_expires_at=normalized.isoformat() if normalized else None, + ) + + +def _prime_plugin_instance_log_levels() -> None: + """进程启动时把数据库中已设置的实例日志等级覆盖预热进运行期缓存。 + + 过期覆盖也照常预热:过期判定统一在读取时惰性执行(见 `app.runtime.log`), + 这里不重复实现一份过期过滤逻辑。单条记录预热失败不得阻断其余记录。 + """ + session = SessionFactory() + try: + records = session.execute( + sa.select(PluginInstanceRecord).where( + PluginInstanceRecord.log_level.is_not(None) + ) + ).scalars().all() + for record in records: + try: + expires_at = ( + datetime.fromisoformat(record.log_expires_at) + if record.log_expires_at + else None + ) + set_plugin_instance_log_level(record.instance_id, record.log_level, expires_at) + except ValueError as error: + logger.warning( + f"预热插件实例 {record.instance_id} 的日志等级覆盖失败:{error}" + ) + finally: + session.close() + + def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance: """把插件实例表的 ORM 行投影为运行时端口使用的 Pydantic 描述。 @@ -481,9 +546,12 @@ def configure_plugin_services() -> None: async_write=_async_write_plugin_config, delete=lambda key: get_configured_system_config().delete(key), delete_data=_delete_plugin_data, + read_log_level=_read_plugin_log_level, + write_log_level=_write_plugin_log_level, )) configure_plugin_database(_build_plugin_database()) configure_plugin_instance_directory(_build_plugin_instance_directory()) + _prime_plugin_instance_log_levels() def _register_plugin_runtime(plugin_id: str) -> None: diff --git a/database/versions/487f7e681955_3_0_36.py b/database/versions/487f7e681955_3_0_36.py new file mode 100644 index 000000000..4aff91836 --- /dev/null +++ b/database/versions/487f7e681955_3_0_36.py @@ -0,0 +1,51 @@ +"""3.0.36 插件实例增加日志等级覆盖。 + +Revision ID: 487f7e681955 +Revises: c4e1a7b9d2f6 +Create Date: 2026-09-12 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "487f7e681955" +down_revision = "c4e1a7b9d2f6" +branch_labels = None +depends_on = None + +_TABLE = "plugininstance" + + +def _column_names() -> set: + """读取实例表已有列名;表不存在时为空集。 + + 兼容重复升级,以及表已由 create_all 按当前模型建出、两列本就存在的场景。 + """ + inspector = sa.inspect(op.get_bind()) + if _TABLE not in set(inspector.get_table_names()): + return set() + return {column["name"] for column in inspector.get_columns(_TABLE)} + + +def upgrade() -> None: + """给实例行加上日志等级覆盖与失效时间,缺省为空即跟随全局等级。 + + 等级落在实例自己那一行,而不是另起一张按实例 ID 索引的表:它和业务参数一样是 + 用户按实例设置的东西,同一个生命周期,实例被删时应当随行一起消失。 + """ + columns = _column_names() + if not columns: + return + if "log_level" not in columns: + op.add_column(_TABLE, sa.Column("log_level", sa.String(length=16), nullable=True)) + if "log_expires_at" not in columns: + op.add_column(_TABLE, sa.Column("log_expires_at", sa.String(length=40), nullable=True)) + + +def downgrade() -> None: + """删除日志等级覆盖列;覆盖本就带失效时间,回退丢掉它不影响插件功能。""" + columns = _column_names() + if "log_expires_at" in columns: + op.drop_column(_TABLE, "log_expires_at") + if "log_level" in columns: + op.drop_column(_TABLE, "log_level") diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 5e331b506..438eed3e3 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -756,8 +756,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1012 | -| 内部导入边 | 8,598 | +| Python 模块 | 1014 | +| 内部导入边 | 8,617 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 29ac41db7..4a8f3b398 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -41,6 +41,22 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。如果客户端缓存了工具列表,插件状态变化后需要让客户端重新请求 `tools/list`;无法手动刷新的客户端应重新连接 MCP 服务或新建会话。 +### 插件实例日志等级 + +`plugin.loglevel.get` 使用源插件 ID 查询,返回该插件全部实例(首项固定是本体自身,其后 +是各个分身)的 `configured_level`、`expires_at` 与 `effective_level`。用某个分身自身的 +实例 ID 当作 `plugin_id` 会被拒绝——分身的日志等级归它的源插件管。`configured_level` +为 `null` 表示没有覆盖或覆盖已过期;`effective_level` 才是此刻真正用于过滤的等级。 + +`plugin.loglevel.set` 与 `plugin.loglevel.clear` 使用该总览返回的精确 `instance_id`, +运行期立即生效,不必重载插件,也不改变全局日志等级。`expires_at` 为空表示覆盖不过期; +提交不带时区的时间会按 UTC 解读,与查询接口返回的 UTC 时刻保持一致。覆盖过期后自动回落 +全局等级,不需要再调一次 `clear`;重复 `clear` 保持幂等。 + +覆盖只对宿主受控调用点内产生的日志生效——插件的构造与 `init_plugin`、事件处理器、定时 +服务回调、插件自己声明的 HTTP 端点。插件自建的原生线程不继承这个绑定,其中的日志仍按 +全局等级过滤,不要据此断定覆盖没有写进去。 + ## 3.1 结构化 Agent 工具与完整参数合同 `tools/list` 会为以下四个正式入口返回可直接校验的 JSON Schema。每个入口都按 operation/action 生成 `oneOf` 分支,分支中包含必填字段、类型、默认值、枚举、嵌套对象和互斥/至少一项等跨字段规则;外部 MCP 客户端可以在一次 `tools/call` 中完成参数构造,不需要猜测 URL、HTTP 方法或第三方 SDK 参数。 diff --git a/docs/refactor/agent-api-surface-audit.json b/docs/refactor/agent-api-surface-audit.json index 8d4d6b913..701a4cef5 100644 --- a/docs/refactor/agent-api-surface-audit.json +++ b/docs/refactor/agent-api-surface-audit.json @@ -2,7 +2,7 @@ "disposition_counts": { "alternate-auth-duplicate": 11, "consolidated": 71, - "gateway": 218, + "gateway": 221, "provider-skill": 12, "stream_or_binary": 10, "transport_or_identity": 66, @@ -18,10 +18,10 @@ "reason": "The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route." } ], - "gateway_http_route_count": 219, - "gateway_operation_count": 221, - "matched_gateway_http_route_count": 218, - "openapi_operation_count": 401, + "gateway_http_route_count": 222, + "gateway_operation_count": 224, + "matched_gateway_http_route_count": 221, + "openapi_operation_count": 404, "operations": [ { "disposition": "consolidated", @@ -2255,6 +2255,48 @@ "plugin" ] }, + { + "disposition": "gateway", + "method": "GET", + "operation_ids": [ + "plugin.loglevel.get" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/loglevel/{plugin_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "查询插件全部实例的日志等级设置", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "DELETE", + "operation_ids": [ + "plugin.loglevel.clear" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "清除插件实例的日志等级覆盖", + "tags": [ + "plugin" + ] + }, + { + "disposition": "gateway", + "method": "PUT", + "operation_ids": [ + "plugin.loglevel.set" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}", + "reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.", + "summary": "设置插件实例的日志等级覆盖", + "tags": [ + "plugin" + ] + }, { "disposition": "ui_presentation", "method": "GET", diff --git a/docs/refactor/agent-api-surface-audit.md b/docs/refactor/agent-api-surface-audit.md index 439e40545..2c72df585 100644 --- a/docs/refactor/agent-api-surface-audit.md +++ b/docs/refactor/agent-api-surface-audit.md @@ -5,10 +5,10 @@ ## Result -- OpenAPI HTTP operations: **401** -- Stable `moviepilot_api` operations: **221** -- Exact HTTP routes used by the gateway: **219** -- OpenAPI routes matched directly by the gateway: **218** +- OpenAPI HTTP operations: **404** +- Stable `moviepilot_api` operations: **224** +- Exact HTTP routes used by the gateway: **222** +- OpenAPI routes matched directly by the gateway: **221** - Bounded dynamic gateway routes: **1** - Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`. - Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input. @@ -19,7 +19,7 @@ | :--- | ---: | :--- | | `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. | | `consolidated` | 71 | Source/UI route represented by a stable aggregate Agent operation. | -| `gateway` | 218 | Approved structured MoviePilot Agent operation. | +| `gateway` | 221 | Approved structured MoviePilot Agent operation. | | `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. | | `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. | | `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. | @@ -209,6 +209,9 @@ | `GET` | `/api/v1/plugin/history/{plugin_id}` | plugin | `gateway` | plugin.history | 获取插件更新说明 | | `GET` | `/api/v1/plugin/install/{plugin_id}` | plugin | `gateway` | plugin.install | 安装插件 | | `GET` | `/api/v1/plugin/installed` | plugin | `consolidated` | plugin.installed | 已安装插件 | +| `GET` | `/api/v1/plugin/loglevel/{plugin_id}` | plugin | `gateway` | plugin.loglevel.get | 查询插件全部实例的日志等级设置 | +| `DELETE` | `/api/v1/plugin/loglevel/{plugin_id}/{instance_id}` | plugin | `gateway` | plugin.loglevel.clear | 清除插件实例的日志等级覆盖 | +| `PUT` | `/api/v1/plugin/loglevel/{plugin_id}/{instance_id}` | plugin | `gateway` | plugin.loglevel.set | 设置插件实例的日志等级覆盖 | | `GET` | `/api/v1/plugin/page/{plugin_id}` | plugin | `ui_presentation` | host-ui | 获取插件数据页面 | | `GET` | `/api/v1/plugin/rating` | plugin | `gateway` | plugin.ratings | 批量查询插件评分 | | `GET` | `/api/v1/plugin/rating/{plugin_id}` | plugin | `gateway` | plugin.rating | 查询插件评分 | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index c4b4c5961..f3034408d 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1012 / 8,598 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1014 / 8,617 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index 21067d590..187fe4d68 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -1,6 +1,6 @@ --- name: database-operation -version: 8 +version: 9 description: >- Use this skill when you need to inspect, query, maintain, or carefully modify the MoviePilot database. This skill uses the bundled scripts/mp-db.py helper, @@ -207,10 +207,10 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Columns: `id`, `transaction_id`, `plugin_id`, `phase`, `membership_before`, `membership_target`, `identity_before_revision`, `identity_target_revision`, `package_existed`, `persistent_backup_existed`, `created_at`, `updated_at`, `schema_version` ### `plugininstance` -- Purpose: Stores one row per shared-source plugin runtime instance, covering both clones and the host plugin itself (instance_id equals source_plugin_id), together with that instance's display overrides and its own configuration payload. A clone exists exactly while its row exists, so deleting the row uninstalls the clone and discards its configuration. +- Purpose: Stores one row per shared-source plugin runtime instance, covering both clones and the host plugin itself (instance_id equals source_plugin_id), together with that instance's display overrides, its own log-level override, and its own configuration payload. A clone exists exactly while its row exists, so deleting the row uninstalls the clone and discards its configuration. - Useful queries: Diagnosing clone naming and ownership, or inspecting what a plugin or one of its clones is configured with. - Write boundary: Owned by the plugin instance and plugin configuration APIs; never edit rows directly. -- Columns: `id`, `instance_id`, `source_plugin_id`, `plugin_name`, `plugin_desc`, `plugin_icon`, `config_data`, `created_at`, `updated_at` +- Columns: `id`, `instance_id`, `source_plugin_id`, `plugin_name`, `plugin_desc`, `plugin_icon`, `log_level`, `log_expires_at`, `config_data`, `created_at`, `updated_at` ### `site` - Purpose: Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding. diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 340625741..f286dddad 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1,6 +1,6 @@ --- name: moviepilot-api -version: 31 +version: 32 description: >- Use this skill for MoviePilot product operations such as media search, torrent search, downloads, subscriptions, library checks, sites, storage, workflows, @@ -60,7 +60,8 @@ allowed-api-operations: >- plugin.statistics plugin.reset plugin.clone config.user.get config.public.get system.usage.statistics plugin.folders.get plugin.folders.update plugin.folder.create plugin.folder.update plugin.folder.delete plugin.folder.plugins.update - plugin.folder.plugin.assign plugin.folder.plugin.remove + plugin.folder.plugin.assign plugin.folder.plugin.remove plugin.loglevel.get plugin.loglevel.set + plugin.loglevel.clear --- # MoviePilot API diff --git a/skills/moviepilot-api/api/plugin.md b/skills/moviepilot-api/api/plugin.md index 77a688c82..f11ec5dc6 100644 --- a/skills/moviepilot-api/api/plugin.md +++ b/skills/moviepilot-api/api/plugin.md @@ -117,6 +117,27 @@ Purpose: List installed plugins and their runtime status. - `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries. - `body`: none +### `plugin.loglevel.clear` +`DELETE /api/v1/plugin/loglevel/{plugin_id}/{instance_id}`; policy effect: `reversible_write`. +Purpose: Clear one plugin instance's log-level override so it immediately follows the global log level again. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.loglevel.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.loglevel.get` +`GET /api/v1/plugin/loglevel/{plugin_id}`; policy effect: `safe_read`. +Purpose: List one plugin's instances, including the plugin itself, with each instance's configured and effective log level. +- `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: none + +### `plugin.loglevel.set` +`PUT /api/v1/plugin/loglevel/{plugin_id}/{instance_id}`; policy effect: `reversible_write`. +Purpose: Set one plugin instance's log-level override, taking effect immediately without following the global log level. +- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.loglevel.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `query`: none +- `body`: `expires_at` (string|null): Expiry timestamp for a plugin instance's log-level override; null means it never expires. A timestamp without a timezone is read as UTC.; `level*` (string): Target log level, one of DEBUG, INFO, WARNING, ERROR, or CRITICAL. + ### `plugin.market` `GET /api/v1/plugin/`; policy effect: `safe_read`. Purpose: List plugins available from configured marketplaces. diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index e0578e0fe..04cc6a48b 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1077,8 +1077,8 @@ "runtime_only": true } }, - "edge_count": 8598, - "edge_sha256": "980206679f2d82d37521d9e8798188d27b123ec1b085f621a9b281023b8cd0df", + "edge_count": 8617, + "edge_sha256": "ec4a6fc00aec0b61b100a5065dacd0f13d3dbff602bff74c40ff8e85fc8b8c28", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2707,6 +2707,17 @@ "app.api.endpoints.pluginfolder -> app.schemas", "app.api.endpoints.pluginfolder -> app.schemas.plugin", "app.api.endpoints.pluginfolder -> app.schemas.response", + "app.api.endpoints.pluginloglevel -> app.api", + "app.api.endpoints.pluginloglevel -> app.api.dependencies", + "app.api.endpoints.pluginloglevel -> app.api.dependencies.auth", + "app.api.endpoints.pluginloglevel -> app.api.principal", + "app.api.endpoints.pluginloglevel -> app.api.response", + "app.api.endpoints.pluginloglevel -> app.application", + "app.api.endpoints.pluginloglevel -> app.application.plugin", + "app.api.endpoints.pluginloglevel -> app.application.plugin.runtime", + "app.api.endpoints.pluginloglevel -> app.schemas", + "app.api.endpoints.pluginloglevel -> app.schemas.plugin", + "app.api.endpoints.pluginloglevel -> app.schemas.response", "app.api.endpoints.recommend -> app.adapters", "app.api.endpoints.recommend -> app.adapters.web", "app.api.endpoints.recommend -> app.adapters.web.security", @@ -3112,6 +3123,7 @@ "app.api.routers -> app.api.endpoints.notification", "app.api.routers -> app.api.endpoints.openai", "app.api.routers -> app.api.endpoints.plugin", + "app.api.routers -> app.api.endpoints.pluginloglevel", "app.api.routers -> app.api.endpoints.recommend", "app.api.routers -> app.api.endpoints.rule", "app.api.routers -> app.api.endpoints.search", @@ -8215,6 +8227,7 @@ "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.contracts", "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.storage", "app.runtime.extensions.plugin.catalog -> app.runtime.extensions.plugin.system", + "app.runtime.extensions.plugin.catalog -> app.runtime.log", "app.runtime.extensions.plugin.catalog -> app.runtime.settings", "app.runtime.extensions.plugin.catalog -> app.schemas", "app.runtime.extensions.plugin.catalog -> app.schemas.plugin", @@ -8242,6 +8255,7 @@ "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions", "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions.plugin", "app.runtime.extensions.plugin.lifecycle -> app.runtime.extensions.plugin.database", + "app.runtime.extensions.plugin.lifecycle -> app.runtime.log", "app.runtime.extensions.plugin.lifecycle -> app.runtime.observability", "app.runtime.extensions.plugin.lifecycle -> app.schemas", "app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin", @@ -8251,6 +8265,10 @@ "app.runtime.extensions.plugin.loader -> app.runtime.settings", "app.runtime.extensions.plugin.loader -> app.schemas", "app.runtime.extensions.plugin.loader -> app.schemas.plugin", + "app.runtime.extensions.plugin.loglevel -> app.runtime", + "app.runtime.extensions.plugin.loglevel -> app.runtime.log", + "app.runtime.extensions.plugin.loglevel -> app.schemas", + "app.runtime.extensions.plugin.loglevel -> app.schemas.plugin", "app.runtime.extensions.plugin.manager -> app.foundation", "app.runtime.extensions.plugin.manager -> app.foundation.environment", "app.runtime.extensions.plugin.manager -> app.foundation.singleton", @@ -8312,6 +8330,7 @@ "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.dependency", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.lifecycle", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.loader", + "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.loglevel", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.metadata", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.monitor", "app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.paths", @@ -9679,7 +9698,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1012, + "module_count": 1014, "modules": [ "app", "app.adapters", @@ -9867,6 +9886,7 @@ "app.api.endpoints.openai", "app.api.endpoints.plugin", "app.api.endpoints.pluginfolder", + "app.api.endpoints.pluginloglevel", "app.api.endpoints.recommend", "app.api.endpoints.rule", "app.api.endpoints.search", @@ -10519,6 +10539,7 @@ "app.runtime.extensions.plugin.dependency", "app.runtime.extensions.plugin.lifecycle", "app.runtime.extensions.plugin.loader", + "app.runtime.extensions.plugin.loglevel", "app.runtime.extensions.plugin.manager", "app.runtime.extensions.plugin.metadata", "app.runtime.extensions.plugin.monitor", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index ec50b0170..2ce4b39ea 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 549, + "loaded_app_module_count": 550, "max_ms": 1293.338, "median_ms": 1156.239, "min_ms": 1102.806, @@ -17,7 +17,7 @@ ] }, "app.factory": { - "loaded_app_module_count": 561, + "loaded_app_module_count": 562, "max_ms": 1127.911, "median_ms": 1122.382, "min_ms": 1119.221, @@ -28,7 +28,7 @@ ] }, "app.main": { - "loaded_app_module_count": 563, + "loaded_app_module_count": 564, "max_ms": 1188.652, "median_ms": 1183.509, "min_ms": 1174.522, diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index ec5e8b76e..0bef554b9 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -34,8 +34,8 @@ def test_api_operation_registry_matches_migration_batches() -> None: assert len(API_PARITY_OPERATION_SPECS) == 15 assert len(API_MUSIC_OPERATION_SPECS) == 10 assert len(API_SYSTEM_OPERATION_SPECS) == 7 - assert len(API_EXTENDED_OPERATION_SPECS) == 136 - assert len(API_OPERATION_SPECS) == 221 + assert len(API_EXTENDED_OPERATION_SPECS) == 139 + assert len(API_OPERATION_SPECS) == 224 assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES) assert { "download.list", diff --git a/tests/test_agent_skills_middleware.py b/tests/test_agent_skills_middleware.py index 8d02b5103..87baaed9f 100644 --- a/tests/test_agent_skills_middleware.py +++ b/tests/test_agent_skills_middleware.py @@ -155,7 +155,7 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None: assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES assert payload["truncated"] is False assert payload["truncation_message"] is None - assert len(payload["skill"]["allowed_api_operations"]) == 221 + assert len(payload["skill"]["allowed_api_operations"]) == 224 assert "## API Category Index" in payload["content"] assert "### `workflow.update`" not in payload["content"] assert "api/workflow.md" in payload["supporting_files"] diff --git a/tests/test_builtin_skill_boundaries.py b/tests/test_builtin_skill_boundaries.py index 42c1d8068..9dd0e8219 100644 --- a/tests/test_builtin_skill_boundaries.py +++ b/tests/test_builtin_skill_boundaries.py @@ -115,9 +115,9 @@ def test_modified_builtin_skills_have_incremented_versions() -> None: expected_versions = { "browser-use": "3", "command-dispatch": "2", - "database-operation": "8", + "database-operation": "9", "feedback-issue": "9", - "moviepilot-api": "31", + "moviepilot-api": "32", "moviepilot-update": "5", "organize-files": "5", "transfer-failed-retry": "5", diff --git a/tests/test_plugin_catalog_runtime.py b/tests/test_plugin_catalog_runtime.py index b9d853bdc..78052dcf6 100644 --- a/tests/test_plugin_catalog_runtime.py +++ b/tests/test_plugin_catalog_runtime.py @@ -4,11 +4,44 @@ from types import SimpleNamespace import pytest +from app.runtime import log as log_module from app.runtime.extensions.plugin.catalog import PluginCatalogFacade -from app.schemas.plugin import PluginRuntimeStatus +from app.runtime.log import set_plugin_instance_log_level +from app.schemas.plugin import PluginInstance, PluginRuntimeStatus from app.schemas.types import SystemConfigKey +@pytest.fixture(name="_isolated_log_overrides") +def fixture_isolated_log_overrides(monkeypatch): + """隔离进程内日志等级覆盖缓存,避免与其他用例的实例 ID 相互污染。""" + monkeypatch.setattr(log_module, "_plugin_level_overrides", {}) + + +def _facade(**overrides): + """按测试所需覆盖最小可用回调集合,构造一个 PluginCatalogFacade。""" + defaults = dict( + classes=lambda: {}, + running=lambda: {}, + storage=lambda: SimpleNamespace( + read=lambda _key: None, + write=lambda _key, _value: None, + ), + system=lambda: SimpleNamespace(), + market_catalog=lambda: None, + market_loader=lambda *_args, **_kwargs: [], + async_market_loader=lambda *_args, **_kwargs: [], + map_plugin=lambda **_kwargs: None, + auth_checker=lambda **_kwargs: True, + plugin_attr=lambda _plugin_id, _attr: None, + plugin_instance=lambda _plugin_id: None, + plugin_instances=lambda: {}, + runtime_status=lambda _plugin_id: None, + log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None), + ) + defaults.update(overrides) + return PluginCatalogFacade(**defaults) + + @pytest.mark.asyncio async def test_async_online_normalizes_market_configuration(monkeypatch) -> None: """目录入口与候选库存使用相同的规范化市场列表。""" @@ -143,3 +176,99 @@ def test_local_repository_failure_does_not_break_catalog_projection(): ] with pytest.raises(RuntimeError, match="invalid local package"): facade.local_repository(raise_errors=True) + + +def test_local_projects_effective_log_level_for_virtual_instance( + _isolated_log_overrides, +): + """分身设了日志等级覆盖时,生效等级要如实投影到它的卡片上。""" + set_plugin_instance_log_level("CatalogOverlayVirtual", "DEBUG") + + class DemoPlugin: + plugin_name = "Demo" + plugin_order = 0 + + instance = PluginInstance( + instance_id="CatalogOverlayVirtual", + source_plugin_id="DemoPlugin", + ) + facade = _facade( + classes=lambda: {"CatalogOverlayVirtual": DemoPlugin}, + plugin_instance=lambda plugin_id: ( + instance if plugin_id == "CatalogOverlayVirtual" else None + ), + plugin_instances=lambda: {"CatalogOverlayVirtual": instance}, + ) + + plugin = facade.local()[0] + + assert plugin.log_level_effective == "DEBUG" + + +def test_local_reports_no_log_override_when_instance_follows_global_level( + _isolated_log_overrides, +): + """没有覆盖的分身实例不应带任何日志等级徽标信息。""" + class DemoPlugin: + plugin_name = "Demo" + plugin_order = 0 + + instance = PluginInstance( + instance_id="CatalogOverlayDefaultVirtual", + source_plugin_id="DemoPlugin", + ) + facade = _facade( + classes=lambda: {"CatalogOverlayDefaultVirtual": DemoPlugin}, + plugin_instance=lambda plugin_id: ( + instance if plugin_id == "CatalogOverlayDefaultVirtual" else None + ), + plugin_instances=lambda: {"CatalogOverlayDefaultVirtual": instance}, + ) + + plugin = facade.local()[0] + + assert plugin.log_level_effective is None + + +def test_local_projects_effective_log_level_for_physical_plugin( + _isolated_log_overrides, +): + """物理插件本体的覆盖同样要投影出来,实例 ID 就是插件 ID。 + + ``is_instance``、``instance_mode`` 只看分身记录,不受日志等级覆盖影响, + 现有语义保持不变。 + """ + set_plugin_instance_log_level("CatalogOverlayHost", "WARNING") + + class DemoPlugin: + plugin_name = "Demo" + plugin_order = 0 + + facade = _facade(classes=lambda: {"CatalogOverlayHost": DemoPlugin}) + + plugin = facade.local()[0] + + assert plugin.is_instance is False + assert plugin.instance_mode is None + assert plugin.log_level_effective == "WARNING" + + +def test_installed_placeholder_projects_effective_log_level( + _isolated_log_overrides, +): + """未加载插件的占位卡片同样要投影生效日志等级。""" + set_plugin_instance_log_level("CatalogOverlayPlaceholder", "ERROR") + + facade = _facade( + storage=lambda: SimpleNamespace( + read=lambda key: ( + ["CatalogOverlayPlaceholder"] + if key is SystemConfigKey.UserInstalledPlugins + else None + ) + ), + ) + + plugin = facade.installed()[0] + + assert plugin.log_level_effective == "ERROR" diff --git a/tests/test_plugin_instance_log_context_entrypoints.py b/tests/test_plugin_instance_log_context_entrypoints.py new file mode 100644 index 000000000..f18d9ee1d --- /dev/null +++ b/tests/test_plugin_instance_log_context_entrypoints.py @@ -0,0 +1,334 @@ +"""插件实例日志上下文接入点契约测试。 + +覆盖四类宿主受控调用点:插件实例的构造与 `init_plugin` +(`PluginLifecycle.start`/`initialize`)、事件处理器回调(`EventDispatcher` +的四个 invoke 方法)、定时服务回调(`SchedulerReconcileOwner.update_plugin_job`)、 +HTTP API 端点回调(`PluginProjection.apis`)。每个用例只断言绑定期间 +`current_plugin_instance_id()` 能读到发起调用的实例 ID,且调用结束后上下文 +必须恢复为未绑定,不污染后续用例。 +""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +import app.scheduler.reconcile as reconcile_module +from app.runtime.event.binding import EventBindingResolver, EventHandlerBinding +from app.runtime.event.dispatch import EventDispatcher +from app.runtime.extensions.plugin.database import PluginDatabase +from app.runtime.extensions.plugin.lifecycle import PluginLifecycle +from app.runtime.extensions.plugin.projection import PluginProjection +from app.runtime.log import current_plugin_instance_id +from app.scheduler.reconcile import SchedulerReconcileOwner + +# --------------------------------------------------------------------------- +# 1. 插件实例构造与 init_plugin +# --------------------------------------------------------------------------- + + +def _lifecycle(*, plugins, running=None): + """构造隔离外部事件和模块清理的生命周期实例。""" + classes: dict = {} + running = running if running is not None else {} + lifecycle = PluginLifecycle( + classes=classes, + running=running, + load_plugins=lambda _pid, _installed, _check: list(plugins), + installed_plugins=lambda: ["DemoPluginWork"], + plugin_config=lambda _pid: {}, + auth_checker=lambda _plugin: True, + clear_modules=MagicMock(), + clear_tools=MagicMock(), + enable_events=MagicMock(), + disable_events=MagicMock(), + runtime_status_writer=MagicMock(), + database=lambda: PluginDatabase(), + log=MagicMock(), + event_sender=MagicMock(), + ) + return lifecycle, classes, running + + +def test_lifecycle_start_binds_instance_during_construct_and_init_plugin(): + """构造与 init_plugin 期间应能读到发起它的实例 ID,start 返回后上下文必须清空。""" + seen: list[tuple[str, str | None]] = [] + + class _Plugin: + plugin_name = "演示插件" + plugin_version = "1.0.0" + + def __init__(self) -> None: + seen.append(("construct", current_plugin_instance_id())) + + def init_plugin(self, _config: dict) -> None: + seen.append(("init", current_plugin_instance_id())) + + @staticmethod + def get_state() -> bool: + return True + + _Plugin.__name__ = "DemoPluginWork" + lifecycle, _classes, _running = _lifecycle(plugins=[_Plugin]) + + lifecycle.start("DemoPluginWork") + + assert seen == [ + ("construct", "DemoPluginWork"), + ("init", "DemoPluginWork"), + ] + assert current_plugin_instance_id() is None + + +def test_lifecycle_initialize_binds_instance_during_reinit(): + """公开 init_plugin(配置页重新生效)同样要绑定发起它的实例。""" + seen: list[str | None] = [] + + class _Plugin: + def init_plugin(self, _config: dict) -> None: + seen.append(current_plugin_instance_id()) + + @staticmethod + def get_state() -> bool: + return True + + lifecycle, _classes, _running = _lifecycle( + plugins=[], + running={"DemoPluginWork": _Plugin()}, + ) + + lifecycle.initialize("DemoPluginWork", {"enable": True}) + + assert seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +# --------------------------------------------------------------------------- +# 2. 事件处理器回调 +# --------------------------------------------------------------------------- + + +class _FakeEventType: + """提供 dispatch 内部读取的 `.value` 属性。""" + + value = "test.event" + + +class _FakeEvent: + """携带 dispatch 内部读取的最小事件属性集。""" + + correlation_id = None + event_type = _FakeEventType() + + +def _dispatcher(resolvers: dict) -> EventDispatcher: + """构造只依赖真实 EventBindingResolver 的最小事件调度器。""" + binding_resolver = EventBindingResolver(lock=threading.Lock(), resolvers=lambda: resolvers) + return EventDispatcher( + registry=MagicMock(), + binding_resolver=binding_resolver, + event_factory=MagicMock(), + error_handler=MagicMock(side_effect=AssertionError("handler must not error")), + async_handle_sink=MagicMock(), + sync_handle_sink=MagicMock(), + ) + + +class _PluginEventHandler: + """模拟虚拟实例克隆类:`__name__` 与运行实例 ID 相同。""" + + def __init__(self) -> None: + """记录事件处理期间观察到的实例上下文。""" + self.seen: list[str | None] = [] + + def on_event(self, _event: object) -> None: + """记录当前绑定的实例 ID。""" + self.seen.append(current_plugin_instance_id()) + + async def on_event_async(self, _event: object) -> None: + """异步处理器同样记录当前绑定的实例 ID。""" + self.seen.append(current_plugin_instance_id()) + + +def test_invoke_sync_binds_owning_instance(): + """同步事件处理器执行期间应绑定声明它的插件实例。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True) + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + dispatcher.invoke_sync(_PluginEventHandler.on_event, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +def test_invoke_sync_strict_binds_owning_instance(): + """strict 变体同样要绑定实例,且失败时仍需正确复位上下文。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo") + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + dispatcher.invoke_sync_strict(_PluginEventHandler.on_event, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +@pytest.mark.asyncio +async def test_invoke_async_binds_owning_instance_for_coroutine_handler(): + """异步事件处理器(直接 await)执行期间应绑定声明它的插件实例。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo") + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + await dispatcher.invoke_async(_PluginEventHandler.on_event_async, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +@pytest.mark.asyncio +async def test_invoke_async_binds_owning_instance_across_threadpool_hop(): + """同步处理器经线程池执行时,绑定必须跨越 `run_in_threadpool` 的执行上下文切换。""" + instance = _PluginEventHandler() + _PluginEventHandler.__name__ = "DemoPluginWork" + resolvers = { + "plugins": lambda owner_class: ( + EventHandlerBinding(instance=instance, owner_name="Demo", run_sync_in_threadpool=True) + if owner_class is _PluginEventHandler + else None + ) + } + dispatcher = _dispatcher(resolvers) + + await dispatcher.invoke_async(_PluginEventHandler.on_event, _FakeEvent()) + + assert instance.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +def test_invoke_sync_does_not_bind_free_function_handler(): + """自由函数处理器不属于任何插件实例,不应绑定任何上下文。""" + seen: list[str | None] = [] + + def _free_handler(_event: object) -> None: + seen.append(current_plugin_instance_id()) + + dispatcher = _dispatcher({}) + + dispatcher.invoke_sync(_free_handler, _FakeEvent()) + + assert seen == [None] + + +# --------------------------------------------------------------------------- +# 3. 定时服务回调 +# --------------------------------------------------------------------------- + + +def test_update_plugin_job_binds_instance_around_service_callback(monkeypatch): + """插件定时服务被调度器实际调用时应绑定注册它的插件实例。""" + seen: list[str | None] = [] + + def _service_callback() -> None: + seen.append(current_plugin_instance_id()) + + fake_manager = SimpleNamespace( + get_plugin_services=lambda pid: [ + { + "id": "job1", + "name": "演示任务", + "func": _service_callback, + "trigger": "interval", + "kwargs": {"seconds": 60}, + } + ], + get_plugin_attr=lambda _pid, _attr: "演示插件", + ) + monkeypatch.setattr(reconcile_module, "get_plugin_manager", lambda: fake_manager) + + owner = SchedulerReconcileOwner.__new__(SchedulerReconcileOwner) + owner._scheduler = MagicMock() + owner._lock = threading.RLock() + owner._jobs = {} + owner.start = MagicMock() + owner.remove_plugin_job = lambda _pid, job_id=None: None + owner._assign_job_generation = lambda _job_id, _job: None + + owner.update_plugin_job("DemoPluginWork") + + registered_job = owner._jobs["DemoPluginWork_job1"] + registered_job["func"]() + + assert seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +# --------------------------------------------------------------------------- +# 4. HTTP API 端点回调 +# --------------------------------------------------------------------------- + + +class _ApiEndpointPlugin: + """声明一条会读取实例上下文的 HTTP API 路由的最小插件桩。""" + + plugin_name = "接口插件" + + def __init__(self) -> None: + """初始化调用记录。""" + self.seen: list[str | None] = [] + + def get_state(self) -> bool: + """插件始终启用。""" + return True + + def get_name(self) -> str: + """返回插件展示名称。""" + return self.plugin_name + + def get_api(self) -> list[dict]: + """声明一条状态查询路由,endpoint 绑定到本实例的方法。""" + return [{"path": "/status", "endpoint": self.status, "methods": ["GET"]}] + + def status(self) -> dict: + """处理状态查询请求期间记录当前绑定的实例 ID。""" + self.seen.append(current_plugin_instance_id()) + return {"ok": True} + + +def test_projection_api_endpoint_binds_owning_instance_without_any_caller_context(): + """路由被 FastAPI 直接调用(不经过任何宿主受控调用点)时仍应绑定实例。""" + plugin = _ApiEndpointPlugin() + projection = PluginProjection({"DemoPluginWork": plugin}) + + apis = projection.apis() + + assert current_plugin_instance_id() is None + apis[0]["endpoint"]() + + assert plugin.seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None diff --git a/tests/test_plugin_instance_log_level_migration.py b/tests/test_plugin_instance_log_level_migration.py new file mode 100644 index 000000000..32ac756fd --- /dev/null +++ b/tests/test_plugin_instance_log_level_migration.py @@ -0,0 +1,132 @@ +"""插件实例日志等级覆盖列 Alembic 迁移测试。""" + +from __future__ import annotations + +import importlib +from datetime import datetime, timezone + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from app.db.models.plugininstance import PluginInstance + +MIGRATION_MODULE = "database.versions.487f7e681955_3_0_36" + + +def _bind_migration(monkeypatch, connection): + """把迁移绑定到隔离数据库连接。""" + migration = importlib.import_module(MIGRATION_MODULE) + context = MigrationContext.configure(connection) + monkeypatch.setattr(migration, "op", Operations(context)) + return migration + + +def _create_legacy_table(connection: sa.engine.Connection) -> None: + """建出加列前的表结构(3.0.35 的落点),模拟迁移前的存量数据库。""" + now = datetime.now(timezone.utc).isoformat() + table = sa.Table( + "plugininstance", + sa.MetaData(), + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("instance_id", sa.String(length=128), nullable=False), + sa.Column("source_plugin_id", sa.String(length=128), nullable=False), + sa.Column("plugin_name", sa.String(length=255)), + sa.Column("plugin_desc", sa.String(length=255)), + sa.Column("plugin_icon", sa.String(length=255)), + sa.Column("config_data", sa.JSON()), + sa.Column("created_at", sa.String(length=40), nullable=False), + sa.Column("updated_at", sa.String(length=40), nullable=False), + ) + table.create(connection) + connection.execute( + table.insert().values( + instance_id="DemoPluginWork", + source_plugin_id="DemoPlugin", + created_at=now, + updated_at=now, + ) + ) + + +def test_plugin_instance_log_level_migration_adds_columns_and_keeps_existing_rows( + monkeypatch, +) -> None: + """新增列必须可空,且不得影响已有行;重复升级与完整回滚都要幂等。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + _create_legacy_table(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + columns = { + column["name"]: column + for column in sa.inspect(connection).get_columns("plugininstance") + } + # 与该迁移自身的落点比对,而不是与持续演进的当前模型比对:后续迁移 + # 还会继续给该表加列,本断言不应该随之跟着变红。 + assert columns.keys() == { + "id", + "instance_id", + "source_plugin_id", + "plugin_name", + "plugin_desc", + "plugin_icon", + "config_data", + "log_level", + "log_expires_at", + "created_at", + "updated_at", + } + assert columns["log_level"]["nullable"] is True + assert columns["log_expires_at"]["nullable"] is True + + table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection) + row = connection.execute(sa.select(table)).mappings().one() + assert row["instance_id"] == "DemoPluginWork" + assert row["log_level"] is None + assert row["log_expires_at"] is None + + migration.downgrade() + remaining = { + column["name"] for column in sa.inspect(connection).get_columns("plugininstance") + } + assert "log_level" not in remaining + assert "log_expires_at" not in remaining + + migration.upgrade() + restored = { + column["name"] for column in sa.inspect(connection).get_columns("plugininstance") + } + assert {"log_level", "log_expires_at"}.issubset(restored) + + +def test_plugin_instance_log_level_migration_accepts_fresh_current_schema( + monkeypatch, +) -> None: + """create_all 已建当前表时重复升级不得因列已存在而报错。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + PluginInstance.__table__.create(connection) + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + migration.upgrade() + + assert { + column["name"] + for column in sa.inspect(connection).get_columns("plugininstance") + } == {column.name for column in PluginInstance.__table__.columns} + + +def test_plugin_instance_log_level_migration_skips_when_table_absent(monkeypatch) -> None: + """表尚未建出时升级要安静跳过,不得因反射不到表而炸掉整条迁移链。""" + engine = sa.create_engine("sqlite://") + with engine.begin() as connection: + migration = _bind_migration(monkeypatch, connection) + + migration.upgrade() + + assert "plugininstance" not in set(sa.inspect(connection).get_table_names()) diff --git a/tests/test_plugin_instance_log_level_oper.py b/tests/test_plugin_instance_log_level_oper.py new file mode 100644 index 000000000..a94223526 --- /dev/null +++ b/tests/test_plugin_instance_log_level_oper.py @@ -0,0 +1,133 @@ +"""插件实例日志等级覆盖列的持久化读写测试。""" + +from __future__ import annotations + +import pytest +from sqlalchemy import delete +from sqlalchemy.orm import Session + +from app.db.models.plugininstance import PluginInstance +from app.db.oper.plugininstance import PluginInstanceOper +from app.db.uow import run_sync_transaction + +_INSTANCE_IDS = ("LogLevelHost", "LogLevelHost@clone1", "LogLevelNeverSet") + + +@pytest.fixture(autouse=True) +def purge_written_rows(): + """删除用例写入的行,测试库在整个会话内共享,残留会干扰后续用例。""" + yield + + def purge(session: Session) -> None: + """按本文件使用的固定标识精确删除,不触碰其他用例的数据。""" + session.execute( + delete(PluginInstance).where( + PluginInstance.instance_id.in_(_INSTANCE_IDS) + ) + ) + + run_sync_transaction(purge) + + +def test_set_log_level_creates_host_row_when_instance_has_no_row_yet(): + """只设过等级、从没存过参数的插件也要有一行,否则覆盖无处落盘。""" + oper = PluginInstanceOper() + + assert oper.set_log_level( + instance_id="LogLevelHost", + log_level="DEBUG", + log_expires_at="2026-01-01T00:00:00+00:00", + ) is True + + record = oper.get("LogLevelHost") + assert record is not None + assert record.is_host is True + assert record.log_level == "DEBUG" + assert record.log_expires_at == "2026-01-01T00:00:00+00:00" + + +def test_set_log_level_keeps_existing_config_and_identity_on_a_clone_row(): + """在分身行上写等级只动两列,业务参数与归属原样保留。""" + oper = PluginInstanceOper() + oper.save(instance_id="LogLevelHost@clone1", source_plugin_id="LogLevelHost") + oper.save_config_data( + instance_id="LogLevelHost@clone1", + source_plugin_id="LogLevelHost", + config_data={"enable": True}, + ) + + assert oper.set_log_level( + instance_id="LogLevelHost@clone1", + log_level="ERROR", + log_expires_at=None, + ) is True + + record = oper.get("LogLevelHost@clone1") + assert record is not None + assert record.log_level == "ERROR" + assert record.log_expires_at is None + assert record.config_data == {"enable": True} + assert record.source_plugin_id == "LogLevelHost" + + +def test_clearing_log_level_recycles_a_host_row_that_carries_nothing_else(): + """清除覆盖后只剩一对身份列的本体行要回收,不留空行堆积。""" + oper = PluginInstanceOper() + oper.set_log_level( + instance_id="LogLevelHost", + log_level="WARNING", + log_expires_at=None, + ) + + assert oper.set_log_level( + instance_id="LogLevelHost", + log_level=None, + log_expires_at=None, + ) is True + + assert oper.get("LogLevelHost") is None + + +def test_clearing_log_level_keeps_a_clone_row_and_a_host_row_with_config(): + """分身行与仍承载配置的本体行不因清除等级而消失。""" + oper = PluginInstanceOper() + oper.save(instance_id="LogLevelHost@clone1", source_plugin_id="LogLevelHost") + oper.set_log_level( + instance_id="LogLevelHost@clone1", + log_level="DEBUG", + log_expires_at=None, + ) + oper.save_config_data( + instance_id="LogLevelHost", + source_plugin_id="LogLevelHost", + config_data={"enable": False}, + ) + oper.set_log_level( + instance_id="LogLevelHost", + log_level="DEBUG", + log_expires_at=None, + ) + + oper.set_log_level( + instance_id="LogLevelHost@clone1", log_level=None, log_expires_at=None + ) + oper.set_log_level(instance_id="LogLevelHost", log_level=None, log_expires_at=None) + + clone = oper.get("LogLevelHost@clone1") + assert clone is not None + assert clone.log_level is None + host = oper.get("LogLevelHost") + assert host is not None + assert host.config_data == {"enable": False} + + +def test_clearing_log_level_for_an_unknown_instance_creates_nothing(): + """清除一个从未落盘过的实例不得凭空建出一行。""" + oper = PluginInstanceOper() + + assert oper.set_log_level( + instance_id="LogLevelNeverSet", + log_level=None, + log_expires_at=None, + ) is False + assert oper.get("LogLevelNeverSet") is None diff --git a/tests/test_plugin_instance_logging.py b/tests/test_plugin_instance_logging.py new file mode 100644 index 000000000..af84616e9 --- /dev/null +++ b/tests/test_plugin_instance_logging.py @@ -0,0 +1,271 @@ +"""插件实例日志等级覆盖缓存与上下文绑定测试。""" + +from __future__ import annotations + +import asyncio +import inspect +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from app.runtime import log as log_module +from app.runtime.log import ( + LoggerManager, + bind_plugin_instance, + clear_plugin_instance_log_level, + current_plugin_instance_id, + get_effective_plugin_instance_log_level, + get_plugin_instance_log_level_override, + logger, + normalize_log_expiry, + set_plugin_instance_log_level, + wrap_for_plugin_instance, +) + + +class _CapturingLogWriter: + """记录日志写入目标,避免测试访问真实文件系统。""" + + def __init__(self) -> None: + """初始化空的调用记录列表。""" + self.entries: list[tuple[str, str, Path]] = [] + + def write_log(self, level: str, message: str, file_path: Path) -> None: + """保存单条日志的级别、内容和目标路径。""" + self.entries.append((level, message, file_path)) + + @staticmethod + def shutdown() -> bool: + """测试写入器没有待释放资源。""" + return True + + +@pytest.fixture(autouse=True) +def _isolate_plugin_log_state(monkeypatch): + """快照并还原插件实例日志等级涉及的全部进程内全局状态,避免用例间互相污染。""" + monkeypatch.setattr(log_module, "_plugin_level_overrides", {}) + monkeypatch.setattr(log_module.log_settings, "DEBUG", False) + monkeypatch.setattr(log_module.log_settings, "LOG_LEVEL", "INFO") + yield + + +@pytest.fixture(name="fake_writer") +def fixture_fake_writer(monkeypatch, tmp_path): + """装配内存写入器,跳过真实文件 I/O。""" + writer = _CapturingLogWriter() + monkeypatch.setattr(LoggerManager, "_writer", writer) + monkeypatch.setattr(LoggerManager, "_log_path", tmp_path) + monkeypatch.setattr( + LoggerManager, + "_get_console_logger", + classmethod( + lambda _cls, _logfile: SimpleNamespace( + info=lambda *_a, **_kw: None, + debug=lambda *_a, **_kw: None, + warning=lambda *_a, **_kw: None, + error=lambda *_a, **_kw: None, + critical=lambda *_a, **_kw: None, + ) + ), + ) + return writer + + +def test_set_and_get_effective_level_returns_override(): + """设置覆盖后按实例查询生效等级应返回覆盖值而非全局等级。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + assert get_effective_plugin_instance_log_level("DemoPluginWork") == "DEBUG" + assert get_effective_plugin_instance_log_level("OtherInstance") == "INFO" + + +def test_set_invalid_level_raises_value_error(): + """非受支持的等级名必须拒绝写入缓存。""" + with pytest.raises(ValueError): + set_plugin_instance_log_level("DemoPluginWork", "LOUD") + + assert get_plugin_instance_log_level_override("DemoPluginWork") is None + + +def test_clear_resets_to_global_level(): + """清除覆盖后立即回落全局等级。""" + set_plugin_instance_log_level("DemoPluginWork", "ERROR") + + clear_plugin_instance_log_level("DemoPluginWork") + + assert get_plugin_instance_log_level_override("DemoPluginWork") is None + assert get_effective_plugin_instance_log_level("DemoPluginWork") == "INFO" + + +def test_clear_is_idempotent_for_unset_instance(): + """清除一个从未设置过覆盖的实例不应报错。""" + clear_plugin_instance_log_level("NeverConfigured") + clear_plugin_instance_log_level("NeverConfigured") + + +def test_expired_override_evicts_on_read(): + """过期覆盖必须在读取时惰性判定并清理,而不是继续生效。""" + set_plugin_instance_log_level( + "DemoPluginWork", + "DEBUG", + expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + + assert get_plugin_instance_log_level_override("DemoPluginWork") is None + assert get_effective_plugin_instance_log_level("DemoPluginWork") == "INFO" + + +def test_unexpired_override_survives_read(): + """未过期覆盖读取后仍然生效,失效时间按同一时刻带时区返回。 + + 读回的是带时区的时间:不带时区会让调用方按 UTC 解读时整体偏移,表现为提交的 + 失效时刻与读回来的对不上。因此按时刻比较,而不是直接相减两种不同的时间。 + """ + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + set_plugin_instance_log_level("DemoPluginWork", "WARNING", expires_at=expires_at) + + override = get_plugin_instance_log_level_override("DemoPluginWork") + + assert override is not None + level_name, returned_expiry = override + assert level_name == "WARNING" + assert returned_expiry is not None + assert returned_expiry.tzinfo is not None + assert abs(returned_expiry.timestamp() - expires_at.timestamp()) < 1 + + +def test_normalize_log_expiry_reads_naive_input_as_utc(): + """不带时区的失效时间按 UTC 解读,而不是按进程所在时区。""" + naive = datetime(2026, 1, 1, 12, 0, 0) + + normalized = normalize_log_expiry(naive) + + assert normalized == datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + assert normalize_log_expiry(None) is None + + +def test_normalize_log_expiry_converts_aware_input_to_utc(): + """带时区的失效时间原样换算到 UTC,描述的仍是同一个时刻。""" + aware = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone(timedelta(hours=8))) + + normalized = normalize_log_expiry(aware) + + assert normalized == datetime(2026, 1, 1, 4, 0, 0, tzinfo=timezone.utc) + + +def test_naive_expiry_round_trips_without_timezone_shift(): + """提交裸时间再读回来必须是同一时刻,不因进程时区整体偏移。""" + naive = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=2) + set_plugin_instance_log_level("DemoPluginWork", "DEBUG", expires_at=naive) + + override = get_plugin_instance_log_level_override("DemoPluginWork") + + assert override is not None + _level_name, returned_expiry = override + assert returned_expiry == naive.replace(tzinfo=timezone.utc) + + +def test_override_does_not_leak_into_unrelated_instance_effective_level(): + """一个实例的覆盖不得影响另一个未设置覆盖实例的生效等级。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + assert get_effective_plugin_instance_log_level("SiblingInstance") == "INFO" + + +def test_current_plugin_instance_id_defaults_to_none(): + """未绑定时读取当前实例上下文应为 None。""" + assert current_plugin_instance_id() is None + + +def test_bind_plugin_instance_sets_and_resets_context(): + """绑定上下文管理器退出后必须恢复为未绑定状态,支持嵌套。""" + assert current_plugin_instance_id() is None + with bind_plugin_instance("Outer"): + assert current_plugin_instance_id() == "Outer" + with bind_plugin_instance("Inner"): + assert current_plugin_instance_id() == "Inner" + assert current_plugin_instance_id() == "Outer" + assert current_plugin_instance_id() is None + + +def test_wrap_for_plugin_instance_binds_sync_callable(): + """同步回调包装后执行期间应能读到绑定的实例 ID。""" + seen: list[str | None] = [] + + def _callback() -> None: + seen.append(current_plugin_instance_id()) + + wrapped = wrap_for_plugin_instance(_callback, "DemoPluginWork") + wrapped() + + assert seen == ["DemoPluginWork"] + assert current_plugin_instance_id() is None + + +def test_wrap_for_plugin_instance_binds_async_callable(): + """异步回调包装后仍保持协程函数身份,且执行期间能读到绑定的实例 ID。""" + seen: list[str | None] = [] + + async def _callback() -> None: + seen.append(current_plugin_instance_id()) + + wrapped = wrap_for_plugin_instance(_callback, "DemoPluginWork") + assert inspect.iscoroutinefunction(wrapped) + asyncio.run(wrapped()) + + assert seen == ["DemoPluginWork"] + + +def test_wrap_for_plugin_instance_is_idempotent_for_the_same_instance(): + """同一实例重复包装返回原对象,避免每次刷新注册都叠一层包装。""" + + def _callback() -> None: + """空回调,只用于观察包装结果。""" + + wrapped = wrap_for_plugin_instance(_callback, "DemoPluginWork") + + assert wrap_for_plugin_instance(wrapped, "DemoPluginWork") is wrapped + assert wrap_for_plugin_instance(wrapped, "OtherInstance") is not wrapped + + +def test_bound_instance_with_lower_override_emits_debug_log(fake_writer): + """绑定实例设了更宽松的覆盖时,全局等级挡不住的 DEBUG 日志应放行。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + with bind_plugin_instance("DemoPluginWork"): + logger.debug("verbose diagnostic") + + assert any("verbose diagnostic" in message for _level, message, _path in fake_writer.entries) + + +def test_unbound_debug_log_is_dropped_by_global_level(fake_writer): + """未绑定任何实例时,DEBUG 日志仍按全局 INFO 等级过滤丢弃。""" + set_plugin_instance_log_level("DemoPluginWork", "DEBUG") + + logger.debug("verbose diagnostic without binding") + + assert fake_writer.entries == [] + + +def test_bound_instance_with_stricter_override_drops_info_log(fake_writer): + """绑定实例设了更严格的覆盖时,全局等级本会放行的 INFO 日志应被丢弃。""" + set_plugin_instance_log_level("DemoPluginWork", "ERROR") + + with bind_plugin_instance("DemoPluginWork"): + logger.info("routine progress") + + assert fake_writer.entries == [] + + +def test_other_bound_instance_is_unaffected_by_sibling_override(fake_writer): + """一个实例的覆盖不得影响另一个未设置覆盖实例的过滤结果。""" + set_plugin_instance_log_level("DemoPluginWork", "ERROR") + + with bind_plugin_instance("SiblingInstance"): + logger.info("sibling routine progress") + + assert any( + "sibling routine progress" in message for _level, message, _path in fake_writer.entries + ) diff --git a/tests/test_plugin_local_sync.py b/tests/test_plugin_local_sync.py index 134d11634..5637309b4 100644 --- a/tests/test_plugin_local_sync.py +++ b/tests/test_plugin_local_sync.py @@ -868,7 +868,12 @@ def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch): assert set(scheduler._jobs) == {"DemoPlugin_new"} service = scheduler._jobs["DemoPlugin_new"] - assert service["func"] is current_func + # 定时服务回调经 wrap_for_plugin_instance 包了一层日志实例上下文绑定, + # 不再是原始 callable 本身;用 __wrapped__ 核对包装前后是同一个函数, + # 并实测调用确实透传到原始 Mock。 + assert service["func"].__wrapped__ is current_func + service["func"](marker="check") + current_func.assert_called_once_with(marker="check") assert service["kwargs"] == {"marker": "new"} assert set(backend.jobs) == {"DemoPlugin_new"} registered_job = backend.jobs["DemoPlugin_new"] diff --git a/tests/test_plugin_log_control_endpoints.py b/tests/test_plugin_log_control_endpoints.py new file mode 100644 index 000000000..31a3197f4 --- /dev/null +++ b/tests/test_plugin_log_control_endpoints.py @@ -0,0 +1,176 @@ +"""插件实例日志等级查询与设置接口测试。""" + +from __future__ import annotations + +import inspect +from datetime import datetime, timezone + +import pytest +from fastapi import HTTPException + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.endpoints import pluginloglevel as pluginloglevel_endpoint +from app.api.endpoints.pluginloglevel import ( + clear_plugin_instance_log_level, + plugin_instance_log_levels, + set_plugin_instance_log_level, +) +from app.schemas.plugin import PluginInstanceLogLevelUpdateRequest + + +def _depends_default(func, parameter_name: str): + """取出端点函数指定参数的 FastAPI Depends 默认值。""" + return inspect.signature(func).parameters[parameter_name].default + + +def _manager(**methods): + """按方法名快速拼装一个鸭子类型的 Manager 替身。""" + return type("Manager", (), methods)() + + +def test_all_endpoints_require_superuser_dependency(): + """三个日志等级端点都要求超级管理员,不能被低权限用户直接调用。""" + for func in ( + plugin_instance_log_levels, + set_plugin_instance_log_level, + clear_plugin_instance_log_level, + ): + depends = _depends_default(func, "_") + assert depends.dependency is get_current_active_superuser + + +def test_get_returns_manager_levels_wrapped_in_plugin_overview(monkeypatch): + """查询接口把 Manager 组装好的等级列表包进插件级总览对象,而不是裸列表。 + + 响应模型直接裸露列表会被宿主分页契约门禁判定为集合接口、要求声明分页参数; + 插件实例数量始终很小,因而包一层 plugin_id + instances。 + """ + levels = [ + { + "instance_id": "DemoPlugin", + "configured_level": None, + "expires_at": None, + "effective_level": "INFO", + }, + { + "instance_id": "DemoPluginWork", + "configured_level": "DEBUG", + "expires_at": None, + "effective_level": "DEBUG", + }, + ] + manager = _manager(get_plugin_instance_log_levels=lambda self, _plugin_id: levels) + monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager) + + result = plugin_instance_log_levels("DemoPlugin", None) + + assert result.success is True + assert result.data == {"plugin_id": "DemoPlugin", "instances": levels} + + +def test_get_reports_missing_plugin_as_404(monkeypatch): + """插件不存在时查询接口返回 404,而不是让异常穿透或吞掉。""" + + def _raise(_plugin_id): + raise LookupError("插件 Missing 不存在") + + manager = _manager( + get_plugin_instance_log_levels=lambda self, plugin_id: _raise(plugin_id) + ) + monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + plugin_instance_log_levels("Missing", None) + + assert excinfo.value.status_code == 404 + + +def test_put_delegates_to_manager_and_reports_success(monkeypatch): + """设置请求原样转交给 Manager,携带等级与失效时间。""" + calls: list = [] + manager = _manager( + set_plugin_instance_log_level=lambda self, *a, **kw: calls.append((a, kw)) + ) + monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager) + expires_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + + result = set_plugin_instance_log_level( + "DemoPlugin", + "DemoPluginWork", + PluginInstanceLogLevelUpdateRequest(level="DEBUG", expires_at=expires_at), + None, + ) + + assert result.success is True + assert calls == [(("DemoPlugin", "DemoPluginWork", "DEBUG", expires_at), {})] + + +def test_put_reports_missing_plugin_or_instance_as_404(monkeypatch): + """未知插件或实例时设置接口返回 404。""" + + def _raise(*_a, **_kw): + raise LookupError("插件实例 Missing 不存在") + + manager = _manager(set_plugin_instance_log_level=_raise) + monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + set_plugin_instance_log_level( + "DemoPlugin", + "Missing", + PluginInstanceLogLevelUpdateRequest(level="DEBUG"), + None, + ) + + assert excinfo.value.status_code == 404 + + +def test_put_reports_invalid_level_as_400(monkeypatch): + """非法等级时设置接口返回 400。""" + + def _raise(*_a, **_kw): + raise ValueError("不支持的日志等级:LOUD") + + manager = _manager(set_plugin_instance_log_level=_raise) + monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + set_plugin_instance_log_level( + "DemoPlugin", + "DemoPlugin", + PluginInstanceLogLevelUpdateRequest(level="LOUD"), + None, + ) + + assert excinfo.value.status_code == 400 + + +def test_delete_delegates_to_manager_and_is_idempotent(monkeypatch): + """清除请求原样转交给 Manager,重复调用同样返回成功。""" + calls: list = [] + manager = _manager( + clear_plugin_instance_log_level=lambda self, *a: calls.append(a) + ) + monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager) + + first = clear_plugin_instance_log_level("DemoPlugin", "DemoPluginWork", None) + second = clear_plugin_instance_log_level("DemoPlugin", "DemoPluginWork", None) + + assert first.success is True + assert second.success is True + assert calls == [("DemoPlugin", "DemoPluginWork"), ("DemoPlugin", "DemoPluginWork")] + + +def test_delete_reports_missing_plugin_or_instance_as_404(monkeypatch): + """未知插件或实例时清除接口返回 404。""" + + def _raise(*_a): + raise LookupError("插件实例 Missing 不存在") + + manager = _manager(clear_plugin_instance_log_level=_raise) + monkeypatch.setattr(pluginloglevel_endpoint, "get_plugin_manager", lambda: manager) + + with pytest.raises(HTTPException) as excinfo: + clear_plugin_instance_log_level("DemoPlugin", "Missing", None) + + assert excinfo.value.status_code == 404 diff --git a/tests/test_plugin_log_level_control.py b/tests/test_plugin_log_level_control.py new file mode 100644 index 000000000..8161300c4 --- /dev/null +++ b/tests/test_plugin_log_level_control.py @@ -0,0 +1,160 @@ +"""插件实例日志等级控制面的身份校验与读写分发测试。""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.runtime import log as log_module +from app.runtime.extensions.plugin.loglevel import PluginLogLevelControl +from app.runtime.log import get_plugin_instance_log_level_override +from app.schemas.plugin import PluginInstance + + +@pytest.fixture(autouse=True) +def _isolate_plugin_log_state(monkeypatch): + """隔离进程内覆盖缓存与全局等级,避免与其他用例的实例 ID 相互污染。""" + monkeypatch.setattr(log_module, "_plugin_level_overrides", {}) + monkeypatch.setattr(log_module.log_settings, "DEBUG", False) + monkeypatch.setattr(log_module.log_settings, "LOG_LEVEL", "INFO") + yield + + +def _control( + *, + instances: dict[str, PluginInstance], + stored: dict | None = None, + writes: list | None = None, +) -> PluginLogLevelControl: + """按测试所需装配最小依赖,配置侧读写落在内存字典上。""" + stored = stored if stored is not None else {} + writes = writes if writes is not None else [] + + def _write(instance_id: str, level, expires_at) -> None: + """记录一次落盘写入并同步内存中的已存值。""" + writes.append((instance_id, level, expires_at)) + if level is None and expires_at is None: + stored.pop(instance_id, None) + else: + stored[instance_id] = (level, expires_at) + + return PluginLogLevelControl( + plugin_exists=lambda plugin_id: True, + get_instance=instances.get, + instances_for_source=lambda source_plugin_id: [ + instance + for instance in instances.values() + if instance.source_plugin_id == source_plugin_id + ], + read_log_level=lambda instance_id: stored.get(instance_id, (None, None)), + write_log_level=_write, + ) + + +def test_list_levels_rejects_clone_own_id_as_plugin_id(): + """用分身自身的实例 ID 查询日志等级总览必须拒绝,不能把分身伪装成本体。""" + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + control = _control(instances={"DemoPluginWork": clone}) + + with pytest.raises(LookupError): + control.list_levels("DemoPluginWork") + + +def test_set_level_rejects_clone_own_id_as_plugin_id(): + """写路径同样要拒绝分身自身实例 ID,否则会把该行改写成本体记录。""" + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + control = _control(instances={"DemoPluginWork": clone}) + + with pytest.raises(LookupError): + control.set_level("DemoPluginWork", "DemoPluginWork", "DEBUG") + + +def test_clear_level_rejects_clone_own_id_as_plugin_id(): + """清除路径同样拒绝分身自身实例 ID。""" + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + control = _control(instances={"DemoPluginWork": clone}) + + with pytest.raises(LookupError): + control.clear_level("DemoPluginWork", "DemoPluginWork") + + +def test_set_level_rejects_instance_of_another_plugin(): + """实例必须真实归属被点名的源插件,跨插件写入要拒绝。""" + foreign = PluginInstance(instance_id="OtherWork", source_plugin_id="OtherPlugin") + control = _control(instances={"OtherWork": foreign}) + + with pytest.raises(LookupError): + control.set_level("DemoPlugin", "OtherWork", "DEBUG") + + +def test_list_levels_lists_host_and_clones_for_a_real_source_plugin_id(): + """用真正的源插件 ID 查询时正常返回本体与全部分身。""" + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + control = _control(instances={"DemoPluginWork": clone}) + + levels = control.list_levels("DemoPlugin") + + assert [item["instance_id"] for item in levels] == [ + "DemoPlugin", + "DemoPluginWork", + ] + assert all(item["effective_level"] == "INFO" for item in levels) + assert all(item["configured_level"] is None for item in levels) + + +def test_set_level_writes_cache_and_storage_then_clear_reverts_both(): + """设置要同时落进程内缓存与配置行,清除后两处都回到未设置。""" + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + stored: dict = {} + writes: list = [] + control = _control(instances={"DemoPluginWork": clone}, stored=stored, writes=writes) + + control.set_level("DemoPlugin", "DemoPluginWork", "debug") + + assert get_plugin_instance_log_level_override("DemoPluginWork") == ("DEBUG", None) + assert writes == [("DemoPluginWork", "DEBUG", None)] + + control.clear_level("DemoPlugin", "DemoPluginWork") + + assert get_plugin_instance_log_level_override("DemoPluginWork") is None + assert writes[-1] == ("DemoPluginWork", None, None) + assert stored == {} + + +def test_clear_level_skips_storage_write_when_nothing_was_configured(): + """从未设置过覆盖时清除不该产生一次多余的落盘写入。""" + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + writes: list = [] + control = _control(instances={"DemoPluginWork": clone}, writes=writes) + + control.clear_level("DemoPlugin", "DemoPluginWork") + + assert writes == [] + + +def test_set_level_normalizes_naive_expiry_to_utc_before_persisting(): + """裸时间在写入侧就归一到 UTC,落盘值与缓存读回值描述同一个时刻。""" + clone = PluginInstance(instance_id="DemoPluginWork", source_plugin_id="DemoPlugin") + writes: list = [] + control = _control(instances={"DemoPluginWork": clone}, writes=writes) + naive = (datetime.now(timezone.utc) + timedelta(hours=3)).replace(tzinfo=None) + + control.set_level("DemoPlugin", "DemoPluginWork", "WARNING", naive) + + _instance_id, _level, persisted_expiry = writes[0] + assert persisted_expiry == naive.replace(tzinfo=timezone.utc) + override = get_plugin_instance_log_level_override("DemoPluginWork") + assert override is not None + assert override[1] == persisted_expiry + + +def test_set_level_on_host_uses_plugin_id_as_instance_id(): + """本体自身不需要实例行也能设置覆盖,实例 ID 就是插件 ID。""" + writes: list = [] + control = _control(instances={}, writes=writes) + + control.set_level("DemoPlugin", "DemoPlugin", "ERROR") + + assert writes == [("DemoPlugin", "ERROR", None)] + assert get_plugin_instance_log_level_override("DemoPlugin") == ("ERROR", None)