From 57fb96fc3cd3e55dfb0a79bc7e7a87d7f07d0ce4 Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:00:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=88=86=E8=BA=AB=E5=90=8E?= =?UTF-8?q?=E7=BC=80=E5=8F=AF=E7=95=99=E7=A9=BA=E8=87=AA=E5=8A=A8=E5=88=86?= =?UTF-8?q?=E9=85=8D=EF=BC=8C=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89=E5=90=8E?= =?UTF-8?q?=E7=BC=80=E6=81=A2=E5=A4=8D=E5=B7=B2=E5=81=9C=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E5=88=86=E8=BA=AB=20(#6673)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/policy/api.py | 4 + app/agent/policy/mcp.py | 4 +- .../policy/resources/api_mcp_schema.json | 104 ++- app/api/endpoints/plugin.py | 36 +- app/api/endpoints/pluginclone.py | 119 ++++ app/api/routers.py | 2 + app/runtime/extensions/plugin/clone.py | 240 ++++++- app/runtime/extensions/plugin/manager.py | 43 +- app/runtime/extensions/plugin/runtime.py | 38 +- app/runtime/extensions/plugin/storage.py | 11 + app/schemas/exports.py | 2 + app/schemas/plugin.py | 53 +- docs/architecture-overview.md | 4 +- docs/mcp-api.md | 24 + docs/refactor/agent-api-surface-audit.json | 24 +- docs/refactor/agent-api-surface-audit.md | 11 +- docs/refactor/optimization-checklist.md | 4 +- skills/moviepilot-api/SKILL.md | 6 +- skills/moviepilot-api/api/plugin.md | 12 +- .../architecture/dependency-baseline.json | 24 +- .../fixtures/architecture/mypy-baseline.json | 2 +- tests/test_agent_api_gateway.py | 4 +- tests/test_agent_lifecycle.py | 19 +- tests/test_agent_skills_middleware.py | 2 +- tests/test_plugin_clone_experience.py | 612 ++++++++++++++++++ tests/test_plugin_endpoint.py | 9 +- tests/test_plugin_virtual_instances.py | 8 +- 27 files changed, 1304 insertions(+), 117 deletions(-) create mode 100644 app/api/endpoints/pluginclone.py create mode 100644 tests/test_plugin_clone_experience.py diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index bd6423902..58ad78a32 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -555,6 +555,7 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = ( effect=ActionEffect.DESTRUCTIVE_WRITE, recovery=RecoveryMode.MANUAL_ONLY, ), + _admin_read("plugin.clone.restorable", sensitivity=ResultSensitivity.PRIVATE), _write("plugin.clone", effect=ActionEffect.EXTERNAL_SIDE_EFFECT, recovery=RecoveryMode.RECONCILE), _spec("config.user.get", result_sensitivity=ResultSensitivity.PRIVATE), _spec("config.public.get"), @@ -810,6 +811,9 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = { "plugin.statistics": ApiOperationRoute("GET", "/api/v1/plugin/statistic"), "plugin.reset": ApiOperationRoute("GET", "/api/v1/plugin/reset/{plugin_id}"), "plugin.clone": ApiOperationRoute("POST", "/api/v1/plugin/clone/{plugin_id}"), + "plugin.clone.restorable": ApiOperationRoute( + "GET", "/api/v1/plugin/clone/{plugin_id}/restorable" + ), "config.user.get": ApiOperationRoute("GET", "/api/v1/system/global/user"), "config.public.get": ApiOperationRoute("GET", "/api/v1/system/setting/public/{key}"), "system.usage.statistics": ApiOperationRoute("GET", "/api/v1/system/usage/statistic"), diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 818a9e6b2..c00814309 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -123,7 +123,8 @@ OPERATION_DESCRIPTIONS = { "media.recognize_file": "Recognize canonical media identity from one exact filename and optional path context.", "media.seasons": "List seasons for one exact media identity or a title-and-year fallback.", "media.sources": "List metadata sources currently registered for MoviePilot media operations.", - "plugin.clone": "Create a configurable clone of one installed plugin.", + "plugin.clone": "Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration.", + "plugin.clone.restorable": "List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix.", "plugin.history": "Read marketplace update notes and history for one plugin.", "plugin.market.sync_wiki": "Refresh the configured plugin marketplace repositories from the MoviePilot Wiki.", "plugin.rating": "Read the current aggregate rating for one plugin.", @@ -614,6 +615,7 @@ FIELD_DESCRIPTIONS.update( "result_payload": "Structured external-operation result recorded with manual review.", "release_status": "Music release status used by classification rules.", "rules": "Ordered classification rules evaluated from highest priority to lowest.", + "restore_previous": "When creating a plugin clone, reuse the stored configuration of a disabled clone that already holds the same suffix; false rebuilds that clone's configuration from the source plugin template.", "retry": "Workflow retry-policy definition for this action.", "retry_count": "Number of retry attempts already used by the operation.", "retry_exhausted": "Whether the operation has used all configured retry attempts.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index 83564d956..6d581d8cd 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -2899,13 +2899,25 @@ "title": "Name", "type": "string" }, + "restore_previous": { + "default": true, + "description": "When creating a plugin clone, reuse the stored configuration of a disabled clone that already holds the same suffix; false rebuilds that clone's configuration from the source plugin template.", + "title": "Restore Previous", + "type": "boolean" + }, "suffix": { + "anyOf": [ + { + "maxLength": 20, + "pattern": "^[A-Za-z0-9]+$", + "type": "string" + }, + { + "type": "null" + } + ], "description": "File suffix or extension matched by an automatic category rule.", - "maxLength": 20, - "minLength": 1, - "pattern": "^[A-Za-z0-9]+$", - "title": "Suffix", - "type": "string" + "title": "Suffix" }, "version": { "anyOf": [ @@ -2920,9 +2932,6 @@ "title": "Version" } }, - "required": [ - "suffix" - ], "title": "PluginCloneRequest", "type": "object" }, @@ -9632,11 +9641,11 @@ }, { "additionalProperties": false, - "description": "Create a configurable clone of one installed plugin. Method: POST. Path: /api/v1/plugin/clone/{plugin_id}. Effect: external_side_effect.", + "description": "Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration. Method: POST. Path: /api/v1/plugin/clone/{plugin_id}. Effect: external_side_effect.", "properties": { "body": { "$ref": "#/$defs/PluginCloneRequest", - "description": "Request value for plugin.clone. Create a configurable clone of one installed plugin. Use the exact type and fields below." + "description": "Request value for plugin.clone. Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration. Use the exact type and fields below." }, "operation_id": { "const": "plugin.clone", @@ -9645,7 +9654,7 @@ }, "path_params": { "additionalProperties": false, - "description": "Resource identity placeholders for plugin.clone. Create a configurable clone of one installed plugin. Use only the named fields below.", + "description": "Resource identity placeholders for plugin.clone. Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration. Use only the named fields below.", "properties": { "plugin_id": { "description": "Exact installed or marketplace plugin ID.", @@ -9667,6 +9676,78 @@ "title": "plugin.clone", "type": "object" }, + { + "additionalProperties": false, + "description": "List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix. Method: GET. Path: /api/v1/plugin/clone/{plugin_id}/restorable. 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.", + "properties": { + "operation_id": { + "const": "plugin.clone.restorable", + "description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.", + "type": "string" + }, + "path_params": { + "additionalProperties": false, + "description": "Resource identity placeholders for plugin.clone.restorable. List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix. 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" + }, + "query": { + "additionalProperties": false, + "description": "Filters and control values for plugin.clone.restorable. List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix. Use only the named fields below.", + "properties": { + "count": { + "anyOf": [ + { + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.", + "title": "Count" + }, + "page": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.", + "title": "Page" + } + }, + "type": "object" + } + }, + "required": [ + "operation_id", + "path_params" + ], + "title": "plugin.clone.restorable", + "type": "object", + "x-moviepilot-collection": { + "body_shape": "list", + "default_pagination": "unpaginated", + "result_count_field": "collection.result_count", + "total_count_field": "collection.total_count" + } + }, { "additionalProperties": false, "description": "Read one loaded plugin's configuration form and its defaults merged with saved values. Method: GET. Path: /api/v1/plugin/form/{plugin_id}. Effect: safe_read.", @@ -16280,6 +16361,7 @@ "music.recognize", "plugin.capabilities", "plugin.clone", + "plugin.clone.restorable", "plugin.config.get", "plugin.config.update", "plugin.data", diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index e66e9df0f..814213cc8 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -38,7 +38,7 @@ from app.application.configuration import get_api_runtime_config_snapshot, get_c from app.application.plugin.catalog import get_plugin_catalog_query from app.application.plugin.config import PluginConfigCommand from app.application.plugin.data import PluginDataQueryService, PluginDataSummaryService -from app.application.plugin.folders import add_clone_to_plugin_folder, remove_plugin_from_folders +from app.application.plugin.folders import remove_plugin_from_folders from app.application.plugin.gateway import get_plugin_install_service from app.application.plugin.management import ( get_plugin_snapshot, @@ -57,7 +57,6 @@ from app.runtime.tasks import TaskRegistry from app.schemas.common import JsonObject as _SchemaJsonObject from app.schemas.exception import PluginMutationRejectedError from app.schemas.plugin import Plugin as _SchemaPlugin -from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard from app.schemas.plugin import PluginDashboardMetaItem as _SchemaPluginDashboardMetaItem from app.schemas.plugin import PluginDataSummary as _SchemaPluginDataSummary @@ -767,39 +766,6 @@ async def plugin_static_file( raise HTTPException(status_code=500, detail="Internal Server Error") -@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=_SchemaResponse[None]) -def clone_plugin( - plugin_id: str, - clone_data: _SchemaPluginCloneRequest, - _: ApiPrincipal = Depends(get_current_active_superuser), -) -> Any: - """ - 创建插件分身 - """ - plugin_manager = get_plugin_manager() - try: - with plugin_manager.mutation(f"创建插件 {plugin_id} 分身"): - success, message = plugin_manager.clone_plugin( - plugin_id=plugin_id, - suffix=clone_data.suffix, - name=clone_data.name, - description=clone_data.description, - version=clone_data.version, - icon=clone_data.icon, - ) - - if success: - # 分身服务已完成运行态加载,此处只补齐宿主注册。 - register_plugin(message) - # 将分身插件添加到原插件所在的文件夹中 - add_clone_to_plugin_folder(plugin_id, message) - return _SchemaResponse(success=True, message="插件分身创建成功") - return _SchemaResponse(success=False, message=message) - except Exception as e: - logger.error(f"创建插件分身失败:{str(e)}") - return _SchemaResponse(success=False, message=f"创建插件分身失败:{str(e)}") - - @router.get( # type: ignore[misc] "/runtime/capabilities", summary="查询插件运行能力", diff --git a/app/api/endpoints/pluginclone.py b/app/api/endpoints/pluginclone.py new file mode 100644 index 000000000..8019c5a8a --- /dev/null +++ b/app/api/endpoints/pluginclone.py @@ -0,0 +1,119 @@ +"""插件分身的创建、恢复与可恢复清单接口。 + +这些路由并入 ``plugin`` 路由器,路径与单文件时期完全一致;单独成篇只是让分身创建与 +插件目录、市场、静态资源等关注点各自分开,与 ``pluginfolder``/``pluginloglevel``/ +``plugintarget`` 的切分方式一致。 +""" + +from typing import Any, List + +from fastapi import Depends, Response + +from app.api.dependencies.auth import get_current_active_superuser +from app.api.endpoints.plugin import register_plugin +from app.api.principal import ApiPrincipal +from app.api.response import ( + COLLECTION_TOTAL_HEADER, + COLLECTION_TOTAL_OPENAPI_KEY, + CompatibleCountParam, + CompatiblePageParam, + ResponseAPIRouter, + resolve_compatible_pagination, +) +from app.application.plugin.folders import add_clone_to_plugin_folder +from app.application.plugin.runtime import get_plugin_manager +from app.runtime.log import logger +from app.schemas.plugin import PluginCloneOutcome as _SchemaPluginCloneOutcome +from app.schemas.plugin import PluginCloneRequest as _SchemaPluginCloneRequest +from app.schemas.plugin import PluginRestorableInstance as _SchemaPluginRestorableInstance +from app.schemas.response import Response as _SchemaResponse + +router = ResponseAPIRouter() + + +@router.get( # type: ignore[misc] + "/clone/{plugin_id}/restorable", + summary="列出可恢复的已停用分身", + response_model=List[_SchemaPluginRestorableInstance], + openapi_extra={COLLECTION_TOTAL_OPENAPI_KEY: True}, +) +def plugin_restorable_instances( + plugin_id: str, + _: ApiPrincipal = Depends(get_current_active_superuser), + page: CompatiblePageParam = None, + count: CompatibleCountParam = None, + response: Response = None, +) -> Any: + """ + 列出该插件名下已停用、设置仍留存可被恢复的分身 + + 停用只把启用位置假,业务参数与展示信息都还留在那一行上。启用中的分身不在此列 + ——它们的配置正被使用,摆进恢复选择器只会让人误以为能把一个活着的实例再建一遍。 + + 未指定分页时返回完整清单:一个插件的历史分身数量有限,恢复选择器要一次看全。 + """ + instances = [ + _SchemaPluginRestorableInstance(**item) + for item in get_plugin_manager().get_restorable_plugin_instances(plugin_id) + ] + if response is not None: + response.headers[COLLECTION_TOTAL_HEADER] = str(len(instances)) + if page is not None or count is not None: + page, count = resolve_compatible_pagination(page, count) + assert page is not None and count is not None + return instances[(page - 1) * count: page * count] + return instances + + +@router.post( # type: ignore[misc] + "/clone/{plugin_id}", + summary="创建插件分身", + response_model=_SchemaResponse[_SchemaPluginCloneOutcome], +) +def clone_plugin( + plugin_id: str, + clone_data: _SchemaPluginCloneRequest, + _: ApiPrincipal = Depends(get_current_active_superuser), +) -> Any: + """ + 创建插件分身 + + 不填后缀时由服务端分配一个最小可用序号,因而实例 ID 只能由回执给出;该后缀名下 + 留有一个已停用的分身时,本次创建就是把那一行连同它的业务参数重新启用。 + """ + plugin_manager = get_plugin_manager() + try: + with plugin_manager.mutation(f"创建插件 {plugin_id} 分身"): + success, message = plugin_manager.clone_plugin( + plugin_id=plugin_id, + suffix=clone_data.suffix, + name=clone_data.name, + description=clone_data.description, + version=clone_data.version, + icon=clone_data.icon, + restore_previous=clone_data.restore_previous, + ) + if not success: + return _SchemaResponse(success=False, message=message) + # 分身此时已经创建并加载完成,后面只是补齐宿主注册。这一步失败不能报成 + # 「创建失败」:分身确实已经存在,用户照提示重试只会撞上「分身已存在」, + # 真正的原因反而被那句话盖掉。 + outcome = _SchemaPluginCloneOutcome(instance_id=message) + try: + register_plugin(message) + # 将分身插件添加到原插件所在的文件夹中 + add_clone_to_plugin_folder(plugin_id, message) + except Exception as register_error: # noqa: BLE001 + logger.error(f"插件分身 {message} 已创建,注册宿主能力失败:{register_error}") + return _SchemaResponse( + success=False, + message=( + f"插件分身 {message} 已创建,但注册定时任务或路由失败:" + f"{register_error};请检查该分身配置后重载插件" + ), + data=outcome, + ) + return _SchemaResponse(success=True, message="插件分身创建成功", data=outcome) + except Exception as e: + logger.error(f"创建插件分身失败:{str(e)}") + return _SchemaResponse(success=False, message=f"创建插件分身失败:{str(e)}") diff --git a/app/api/routers.py b/app/api/routers.py index f43299b3b..0acfc2520 100644 --- a/app/api/routers.py +++ b/app/api/routers.py @@ -25,6 +25,7 @@ from app.api.endpoints import ( notification, openai, plugin, + pluginclone, plugininstance, pluginloglevel, plugintarget, @@ -76,6 +77,7 @@ API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = ( RouterSpec(notification.router, "/notification", ("notification",)), RouterSpec(llm.router, "/llm", ("llm",)), RouterSpec(plugin.router, "/plugin", ("plugin",)), + RouterSpec(pluginclone.router, "/plugin", ("plugin",)), RouterSpec(plugininstance.router, "/plugin", ("plugin",)), RouterSpec(pluginloglevel.router, "/plugin", ("plugin",)), RouterSpec(plugintarget.router, "/plugin", ("plugin",)), diff --git a/app/runtime/extensions/plugin/clone.py b/app/runtime/extensions/plugin/clone.py index 4ae0f8040..012362d05 100644 --- a/app/runtime/extensions/plugin/clone.py +++ b/app/runtime/extensions/plugin/clone.py @@ -2,11 +2,32 @@ from __future__ import annotations +import threading from collections.abc import Callable -from typing import Any, Optional +from typing import Any, NamedTuple, Optional + +from pydantic import ValidationError from app.schemas.plugin import PluginInstance, PluginRuntimeStatus +# 自动分配的后缀从 2 起算:源插件本体在用户眼里就是第 1 个实例,分身接着往下排 +_FIRST_AUTO_SUFFIX = 2 +# 探测次数上限:占用判据若因端口故障恒为真,没有上限会让请求线程原地打转并一直扣着占位锁 +_MAX_AUTO_SUFFIX_PROBES = 1000 + + +def _first_validation_message(error: ValidationError) -> str: + """取校验错误里的首条说明,供回给用户的单行原因使用。""" + details = error.errors() + return str(details[0].get("msg")) if details else str(error) + + +class _Reservation(NamedTuple): + """占位阶段已经定下来的事实,供后续加载与回滚判断来历。""" + + clone_id: str + restoring: bool + class PluginCloneService: """创建共享源码的虚拟插件实例并协调失败回滚。""" @@ -15,10 +36,12 @@ class PluginCloneService: self, *, plugin_class: Callable[[str], Optional[Any]], - plugin_exists: Callable[[str], bool], + instance_id_taken: Callable[[str], bool], + get_instance: Callable[[str], Optional[PluginInstance]], source_plugin_id: Callable[[str], str], save_instance: Callable[[PluginInstance], Any], delete_instance: Callable[[str], bool], + disable_instance: Callable[[str], bool], read_config: Callable[[str], dict], save_config: Callable[[str, dict], bool], delete_config: Callable[[str], bool], @@ -28,74 +51,235 @@ class PluginCloneService: ) -> None: """保存实例描述、持久化和运行态端口。""" self._plugin_class = plugin_class - self._plugin_exists = plugin_exists + self._instance_id_taken = instance_id_taken + self._get_instance = get_instance self._source_plugin_id = source_plugin_id self._save_instance = save_instance self._delete_instance = delete_instance + self._disable_instance = disable_instance self._read_config = read_config self._save_config = save_config self._delete_config = delete_config self._reload_plugin = reload_plugin self._remove_plugin = remove_plugin self._logger = log + # 「挑一个没被占用的 ID」与「把这个 ID 占下来」之间存在窗口,两个并发的创建 + # 请求会在窗口里选出同一个 ID,随后一个覆盖另一个的实例行。创建是低频的管理 + # 动作,用一把进程内互斥把占位串行化即可,代价远小于让两个分身共用一行 + self._reservation_lock = threading.Lock() def clone( self, *, plugin_id: str, - suffix: str, + suffix: Optional[str] = None, name: str, description: str, version: Optional[str] = None, icon: Optional[str] = None, + restore_previous: bool = True, ) -> tuple[bool, str]: - """创建虚拟分身,复制隔离配置并保持默认禁用语义。""" - if not plugin_id or not suffix: - return False, "插件ID和分身后缀不能为空" + """创建虚拟分身,复制隔离配置并保持默认禁用语义。 + + :param plugin_id: 源插件ID + :param suffix: 追加到源插件ID后的分身后缀;留空时自动分配最小可用序号 + :param name: 分身展示名称,恢复时留空表示沿用停用前登记的那一份 + :param description: 分身展示描述,留空时的处理同 ``name`` + :param version: 旧客户端仍会带上;分身始终跟随源插件版本,此处不消费 + :param icon: 分身展示图标,留空时的处理同 ``name`` + :param restore_previous: 该后缀名下留有一个已停用的分身时是否沿用它的业务 + 参数;为假时改按源插件模板重建一份配置 + :return: 是否成功与分身ID或失败原因 + """ + del version + rejection = self._reject_invalid_source(plugin_id) + if rejection: + return False, rejection + + with self._reservation_lock: + reservation, message = self._reserve( + plugin_id=plugin_id, + suffix=suffix, + name=name, + description=description, + icon=icon, + restore_previous=restore_previous, + ) + if reservation is None: + return False, message + return self._activate( + reservation, + plugin_id=plugin_id, + restore_previous=restore_previous, + ) + + def _reject_invalid_source(self, plugin_id: str) -> str: + """判断给定插件能否作为分身来源,可以时返回空串。 + + 分身不能再生分身:分身共享源插件的源码,它自己并不是一份源码。放行的话新实例 + 会挂到源插件名下,后缀却是按分身 ID 算的,自动分配因而会在一个与占用判据对不上 + 的号段里挑号。 + """ + if not plugin_id: + return "插件ID不能为空" if self._plugin_class(plugin_id) is None: - return False, f"原插件 {plugin_id} 不存在" + return f"原插件 {plugin_id} 不存在" + source_id = self._source_plugin_id(plugin_id) + if source_id != plugin_id: + return ( + f"{plugin_id} 是分身实例,不能作为分身来源," + f"请对源插件 {source_id} 创建分身" + ) + return "" - clone_id = f"{plugin_id}{suffix.lower()}" - if self._plugin_exists(clone_id): - return False, f"分身插件 {clone_id} 已存在" + def _reserve( + self, + *, + plugin_id: str, + suffix: Optional[str], + name: str, + description: str, + icon: Optional[str], + restore_previous: bool, + ) -> tuple[Optional[_Reservation], str]: + """定下分身 ID 并把实例行落库,返回占位结果或拒绝原因。 + 调用方须持有占位锁:本方法内部先读后写,两个并发创建各自读到「未占用」就会 + 落到同一行上。 + """ + resolved_suffix = (suffix or "").strip().lower() or self._allocate_suffix(plugin_id) + if not resolved_suffix: + return None, f"插件 {plugin_id} 的可用分身后缀已耗尽,请手动指定一个" + clone_id = f"{plugin_id}{resolved_suffix}" + # 已停用的分身仍留着自己那一行,业务参数原样挂在上面:同后缀重建就是把它连同 + # 配置一起拿回来,而不是撞车。归属对不上的行不算可恢复——它是别的源插件的分身 + previous = self._get_instance(clone_id) + restoring = ( + previous is not None + and not previous.is_enabled + and previous.source_plugin_id == plugin_id + ) + if not restoring and self._instance_id_taken(clone_id): + return None, f"分身插件 {clone_id} 已存在" + + instance, message = self._build_instance( + clone_id=clone_id, + source_plugin_id=plugin_id, + name=name, + description=description, + icon=icon, + previous=previous if restoring else None, + inherit_display=restore_previous, + ) + if instance is None: + return None, message + self._save_instance(instance) + return _Reservation(clone_id=clone_id, restoring=restoring), "" + + def _allocate_suffix(self, plugin_id: str) -> str: + """为新分身分配一个最小可用的数字后缀,无号可用时返回空串。 + + 占用判据与显式指定后缀走的是同一个 ``instance_id_taken``,自动分配因此不可能 + 挑中一个手填时会被判成「已存在」的 ID。已停用的分身同样占位:重用它的 ID 是 + 「恢复」而不是新建,用户没点恢复就不该凭空拿到上一个分身留下的配置。 + """ + for index in range( + _FIRST_AUTO_SUFFIX, + _FIRST_AUTO_SUFFIX + _MAX_AUTO_SUFFIX_PROBES, + ): + if not self._instance_id_taken(f"{plugin_id}{index}"): + return str(index) + return "" + + def _build_instance( + self, + *, + clone_id: str, + source_plugin_id: str, + name: str, + description: str, + icon: Optional[str], + previous: Optional[PluginInstance], + inherit_display: bool, + ) -> tuple[Optional[PluginInstance], str]: + """构造实例描述,并在任何写入之前拦下不合法的实例 ID。 + + 合法性由 ``PluginInstance`` 自己判定,这里不另抄一份规则;构造排在落库之前, + 非法 ID 因而不会先写出一行再靠回滚擦掉。 + + :param previous: 正在被恢复的那一行,非恢复时为 None + :param inherit_display: 恢复时展示信息是否沿用停用前登记的那一份 + """ + if previous is not None and inherit_display: + name = name or (previous.plugin_name or "") + description = description or (previous.plugin_desc or "") + icon = icon or (previous.plugin_icon or "") try: instance = PluginInstance( instance_id=clone_id, - source_plugin_id=self._source_plugin_id(plugin_id), + source_plugin_id=source_plugin_id, plugin_name=name or None, plugin_desc=description or None, plugin_icon=icon or None, + # 恢复时保留该行原有的默认目标置位;新建的分身不该凭空成为默认目标 + is_default_target=( + previous.is_default_target if previous is not None else False + ), # 新建的分身就是要拿去跑的;启用位是装载判据,留空会让它建出来却不加载 is_enabled=True, ) - self._save_instance(instance) + except ValidationError as error: + return None, f"分身实例 ID {clone_id} 不合法:{_first_validation_message(error)}" + return instance, "" - original_config = self._read_config(plugin_id) - if original_config: - clone_config = dict(original_config) - clone_config["enable"] = False - clone_config["enabled"] = False - if not self._save_config(clone_id, clone_config): - raise RuntimeError("虚拟实例配置保存失败") + def _activate( + self, + reservation: _Reservation, + *, + plugin_id: str, + restore_previous: bool, + ) -> tuple[bool, str]: + """准备配置并完成首次加载,失败时按来历回滚。""" + clone_id = reservation.clone_id + # 恢复留存配置时不得用源插件模板盖掉它,那正是用户要拿回来的东西 + keep_previous_config = reservation.restoring and restore_previous + try: + if not keep_previous_config: + original_config = self._read_config(plugin_id) + if original_config: + clone_config = dict(original_config) + clone_config["enable"] = False + clone_config["enabled"] = False + if not self._save_config(clone_id, clone_config): + raise RuntimeError("虚拟实例配置保存失败") status = self._reload_plugin(clone_id) if status is PluginRuntimeStatus.LOAD_FAILED: raise RuntimeError("虚拟实例加载失败") - self._logger.info(f"插件分身 {clone_id} 创建成功") + action = "恢复" if reservation.restoring else "创建" + self._logger.info(f"插件分身 {clone_id} {action}成功") return True, clone_id except Exception as error: # noqa: BLE001 - self._rollback(clone_id) + self._rollback(clone_id, purge_instance=not reservation.restoring) self._logger.error(f"创建插件分身失败:{error}") return False, f"创建插件分身失败:{error}" - def _rollback(self, clone_id: str) -> None: - """逐项清理失败实例,单个清理错误不得阻断其余回滚。""" - rollback_steps = ( + def _rollback(self, clone_id: str, *, purge_instance: bool) -> None: + """逐项清理失败实例,单个清理错误不得阻断其余回滚。 + + :param clone_id: 本次创建的分身ID + :param purge_instance: 实例行与配置是否由本次创建产生、可随之抹掉;恢复一个 + 已停用的分身时为假——那一行连同用户留在上面的业务参数是特意保留的,一次 + 加载失败不该把它毁掉,只需把启用位退回停用,下次仍可再试一遍恢复 + """ + rollback_steps: list[tuple[str, Callable[[str], Any]]] = [ ("运行态", self._remove_plugin), - ("实例描述", self._delete_instance), - ("配置", self._delete_config), - ) + ] + if purge_instance: + rollback_steps.append(("实例描述", self._delete_instance)) + rollback_steps.append(("配置", self._delete_config)) + else: + rollback_steps.append(("启用位", self._disable_instance)) for label, rollback in rollback_steps: try: rollback(clone_id) diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index c3470c6f0..13ef138cd 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -1282,17 +1282,19 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): """ return PluginAccessPolicy.private_key(plugin_id) - def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str, - version: str = None, icon: str = None) -> Tuple[bool, str]: + def clone_plugin(self, plugin_id: str, suffix: Optional[str], name: str, description: str, + version: str = None, icon: str = None, + restore_previous: bool = True) -> Tuple[bool, str]: """ 创建插件分身 :param plugin_id: 原插件ID - :param suffix: 分身后缀 + :param suffix: 分身后缀;留空时由运行时自动分配最小可用序号 :param name: 分身名称 :param description: 分身描述 - :param version: 自定义版本号 + :param version: 自定义版本号,分身始终跟随源插件版本,仅为旧客户端保留 :param icon: 自定义图标URL - :return: (是否成功, 错误信息) + :param restore_previous: 同后缀名下留有已停用的分身时是否沿用它的业务参数 + :return: (是否成功, 成功时为分身实例ID/失败时为可读原因) """ try: with self.mutation("创建插件分身"): @@ -1303,11 +1305,42 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton): description=description, version=version, icon=icon, + restore_previous=restore_previous, ) except PluginMutationRejectedError as error: logger.warning(str(error)) return False, str(error) + def get_restorable_plugin_instances(self, plugin_id: str) -> List[Dict[str, Any]]: + """列出该插件名下已停用、设置仍留存可恢复的分身。 + + 停用只把启用位置假,业务参数与展示信息都还在那一行上;用户在创建分身时挑一个 + 拿回来,比按后缀去猜哪个留有残留可靠得多。启用中的分身不在此列——它们的配置 + 正被使用,摆进恢复选择器只会让人误以为能把一个活着的实例再建一遍。 + + :param plugin_id: 源插件ID + :return: 每个可恢复分身的实例ID、后缀、展示信息与是否留有业务参数 + """ + prefix_length = len(plugin_id) + results: List[Dict[str, Any]] = [] + for instance in self._plugin_instance_store.for_source(plugin_id): + if instance.is_enabled: + continue + instance_id = instance.instance_id + results.append({ + "instance_id": instance_id, + # 分身ID由源插件ID直接拼后缀而成,去掉前缀即还原用户当初填的后缀 + "suffix": ( + instance_id[prefix_length:] + if instance_id.startswith(plugin_id) + else instance_id + ), + "plugin_name": instance.plugin_name, + "plugin_desc": instance.plugin_desc, + "has_config": self._plugin_config_store.has_config(instance_id), + }) + return results + def get_plugin_instance_log_levels(self, plugin_id: str) -> List[Dict[str, Any]]: """ 查询插件全部实例(含本体)当前的日志等级设置 diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py index fd939b174..86d7e5d94 100644 --- a/app/runtime/extensions/plugin/runtime.py +++ b/app/runtime/extensions/plugin/runtime.py @@ -355,12 +355,48 @@ def build_plugin_runtime( ) or [] return plugin_id in installed + def instance_id_taken(instance_id: str) -> bool: + """判断一个候选实例 ID 是否已被占用。 + + 创建分身的判存与自动分配后缀共用这一个判据,自动分配因此不可能挑中一个手填 + 时会被拒绝的 ID。四条依据各自覆盖一类占用者,缺一条就会让新分身顶掉一个真实 + 存在的插件身份: + + * 类注册表——当前已装载的本体与分身; + * 实例行——含已停用的分身与本体,它们的配置还留在行上,不是空位; + * 安装清单——装过但此刻未装载的物理插件; + * 插件包目录——卸载不删源码,磁盘上因此会留下不在前三者里的插件包,占了它的 + 号会让那个插件以后再也装不回来(实例行的归属列对不上,写入直接被拒)。 + + 判存不能只看运行态:源插件本次加载失败时,已有的同名分身会被判成「不存在」 + 而放行,随后它的描述符被覆盖,再在回滚里连同配置一起删掉。``catalog.exists`` + 不足以充当磁盘判据——它要从**运行中**的实例上取版本号,未装载的插件包一律 + 报告不存在,因而这里直接看包目录。 + + :param instance_id: 候选实例 ID + :return: 该 ID 是否已被占用 + """ + if registry.plugin_class(instance_id) is not None: + return True + if instances.get(instance_id) is not None: + return True + if instances.get_host(instance_id) is not None: + return True + installed = environment.storage().read( + SystemConfigKey.UserInstalledPlugins + ) or [] + if instance_id in installed: + return True + return (environment.plugins_root / instance_id.lower()).is_dir() + clone = PluginCloneService( plugin_class=registry.plugin_class, - plugin_exists=catalog.exists, + instance_id_taken=instance_id_taken, + get_instance=instances.get, source_plugin_id=source_plugin_id, save_instance=instances.save, delete_instance=instances.delete, + disable_instance=instances.disable, read_config=configs.read, save_config=lambda plugin_id, config: configs.write( plugin_id, diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index 3b8d43532..2910a3378 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -198,6 +198,17 @@ class PluginConfigStore: if key } + def has_config(self, instance_id: str) -> bool: + """判断该实例行上是否留有业务参数。 + + 不复用 :meth:`read`:那里要求插件类当前已登记,而这里问的恰恰是已停用、因而 + 不在类注册表里的实例——走 :meth:`read` 会让每一个可恢复分身都报告「没有配置」。 + + :param instance_id: 实例 ID + :return: 该实例是否留有业务参数 + """ + return bool(self._storage().read_config(instance_id)) + def write(self, plugin_id: str, config: dict, force: bool = False) -> bool: """保存配置,默认拒绝不存在插件的配置写入。""" if not force and not self._plugin_exists(plugin_id): diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 2e3318cc1..db66889d8 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -341,6 +341,7 @@ SCHEMA_EXPORTS = { 'PerformanceSnapshot': ('app.schemas.monitoring', 'PerformanceSnapshot'), 'Plugin': ('app.schemas.plugin', 'Plugin'), 'PluginActionEventData': ('app.schemas.event', 'PluginActionEventData'), + 'PluginCloneOutcome': ('app.schemas.plugin', 'PluginCloneOutcome'), 'PluginCloneRequest': ('app.schemas.plugin', 'PluginCloneRequest'), 'PluginDashboard': ('app.schemas.plugin', 'PluginDashboard'), 'PluginDashboardMetaItem': ('app.schemas.plugin', 'PluginDashboardMetaItem'), @@ -370,6 +371,7 @@ SCHEMA_EXPORTS = { 'PluginReleaseItem': ('app.schemas.plugin', 'PluginReleaseItem'), 'PluginReloadEventData': ('app.schemas.event', 'PluginReloadEventData'), 'PluginRemoteInfo': ('app.schemas.plugin', 'PluginRemoteInfo'), + 'PluginRestorableInstance': ('app.schemas.plugin', 'PluginRestorableInstance'), 'PluginRuntimeActionCapability': ('app.schemas.plugin', 'PluginRuntimeActionCapability'), 'PluginRuntimeActionGroup': ('app.schemas.plugin', 'PluginRuntimeActionGroup'), 'PluginRuntimeCapabilities': ('app.schemas.plugin', 'PluginRuntimeCapabilities'), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 6e5ca4316..5b875dfbb 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -5,6 +5,7 @@ from typing import Dict, List, Literal, Optional, Union from pydantic import AfterValidator as _AfterValidator from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator +from pydantic import BeforeValidator as _BeforeValidator from pydantic import PrivateAttr as _PrivateAttr from pydantic import computed_field as _computed_field @@ -327,14 +328,32 @@ class PluginInstallOutcome(BaseModel): restart_required: bool = Field(description="本次依赖更新是否需要重启 MoviePilot 才能完成") +def _blank_clone_suffix_to_none(value: object) -> object: + """把「没填后缀」的三种写法归一成同一个值。 + + 前端的后缀输入框留空时可能整个字段不带、带 null,也可能带一个空串或只有空白; + 三者表达的都是「由服务端挑一个」。不归一的话空串会撞上格式校验,用户看到的是 + 一条与他的操作对不上的格式错误,而不是自动分配。 + """ + if isinstance(value, str): + stripped = value.strip() + return stripped or None + return value + + +_CloneSuffix = _Annotated[Optional[str], _BeforeValidator(_blank_clone_suffix_to_none)] + + class PluginCloneRequest(BaseModel): """创建虚拟插件分身的请求参数。""" - suffix: str = Field( - min_length=1, + # 格式与长度在这一层判定:非法后缀拼出的实例 ID 用不了 Python 类名与路由段, + # 等落库之后再报错意味着已经写进一行、还要靠回滚把它擦掉 + suffix: _CloneSuffix = Field( + default=None, max_length=20, pattern=r"^[A-Za-z0-9]+$", - description="追加到当前插件 ID 后的 ASCII 字母或数字后缀", + description="追加到当前插件 ID 后的 ASCII 字母或数字后缀;留空时由服务端自动分配", ) name: str = Field(default="", description="分身展示名称") description: str = Field(default="", description="分身展示描述") @@ -343,6 +362,34 @@ class PluginCloneRequest(BaseModel): default=None, description="兼容旧客户端保留,虚拟分身始终跟随源插件版本", ) + restore_previous: bool = Field( + default=True, + description="该后缀名下留有一个已停用的分身时是否沿用它的业务参数,为假时按源插件模板重建", + ) + + +class PluginCloneOutcome(BaseModel): # type: ignore[misc] + """一次分身创建或恢复的结果。 + + 实例 ID 必须回传:后缀可以由服务端自动分配,调用方因此再也算不出它,而后续要 + 拿它去打开配置、刷新列表或跳转。 + """ + + instance_id: str = Field(description="新建或恢复出来的分身实例 ID") + + +class PluginRestorableInstance(BaseModel): # type: ignore[misc] + """一个已停用、其设置仍留存可被恢复的分身实例。 + + 在册启用的分身不在此列:它们的配置正在被使用,拿来「恢复」没有意义,摆进选择器 + 只会让用户误以为能把一个活着的实例再创建一遍。 + """ + + instance_id: str = Field(description="分身实例 ID") + suffix: str = Field(description="该实例相对源插件 ID 的后缀") + plugin_name: Optional[str] = Field(default=None, description="停用前登记的展示名称") + plugin_desc: Optional[str] = Field(default=None, description="停用前登记的展示描述") + has_config: bool = Field(default=False, description="是否留有业务参数") class PluginSourceIdentity(BaseModel): # type: ignore[misc] diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 9f34950e2..bfe2f3fa1 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -756,8 +756,8 @@ flowchart LR | 指标 | 当前值 | |---|---:| -| Python 模块 | 1018 | -| 内部导入边 | 8,650 | +| Python 模块 | 1019 | +| 内部导入边 | 8,667 | | 非平凡 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 126f1c6c1..08c2815cf 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -97,6 +97,30 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。 时不会把它计入 `purged`。实例 ID 必须是合法的插件实例 ID(字母开头、仅字母与数字): 它会被拼进插件数据目录路径,带路径分隔符或上跳的标识一律在动任何存储之前被拒绝。 +### 插件分身的创建与恢复 + +`plugin.clone` 用源插件 ID 创建一个共享它源码的分身。`suffix` 可以不填:留空时由服务端 +挑一个最小可用序号(本体算第 1 个实例,分身从 2 起排),因而调用方**算不出**分身 ID, +必须从回执的 `data.instance_id` 取。填了后缀则只接受 ASCII 字母与数字,且在请求解析阶段 +就会被拦下——非法后缀拼出的实例 ID 用不了 Python 类名与路由段,等落库再报错意味着已经 +写出一行、还要靠回滚擦掉。 + +占号判据不止看「此刻在不在跑」:当前已装载的类、实例表里的每一行(含已停用的分身与本体)、 +安装清单,以及磁盘上留存的插件包目录,任意一项命中都算已占用。自动分配与手填后缀共用这 +同一个判据,自动分配因此不会挑中一个手填时会被拒绝的 ID。 + +同一个后缀名下留有一个**已停用**的分身时,这次创建就是把那一行重新置为启用,而不是新建: +停用从不删行,业务参数一直挂在上面,恢复因而拿回的正是用户当初留下的那份配置。展示名、 +描述与图标留空则沿用停用前登记的那一份,填了就按填的改。要改为「丢掉旧配置、按源插件模板 +重建」,把 `restore_previous` 置为 false——静默丢弃用户数据不能是默认行为。恢复失败时只 +把启用位退回停用,不会连同那一行与它的配置一起毁掉。 + +`plugin.clone.restorable` 用源插件 ID 列出该插件名下所有已停用、配置仍留存的分身,给出 +实例 ID、相对源插件 ID 的后缀、停用前登记的展示信息,以及是否留有业务参数。启用中的分身 +不在此列:它们的配置正被使用,拿来「恢复」没有意义。 + +被 `plugin.instance.purge` 彻底清理掉的分身也不在此列,而且不可恢复:清理删的是实例行 +本身,恢复所依赖的那份留存配置随之消失,同后缀再建只会得到一个全新的空白分身。 ## 3.1 结构化 Agent 工具与完整参数合同 diff --git a/docs/refactor/agent-api-surface-audit.json b/docs/refactor/agent-api-surface-audit.json index ec63ca728..89b7538eb 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": 225, + "gateway": 226, "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": 226, - "gateway_operation_count": 228, - "matched_gateway_http_route_count": 225, - "openapi_operation_count": 408, + "gateway_http_route_count": 227, + "gateway_operation_count": 229, + "matched_gateway_http_route_count": 226, + "openapi_operation_count": 409, "operations": [ { "disposition": "consolidated", @@ -2039,6 +2039,20 @@ "plugin" ] }, + { + "disposition": "gateway", + "method": "GET", + "operation_ids": [ + "plugin.clone.restorable" + ], + "owner": "moviepilot-api", + "path": "/api/v1/plugin/clone/{plugin_id}/restorable", + "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 de015483f..9746a763a 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: **408** -- Stable `moviepilot_api` operations: **228** -- Exact HTTP routes used by the gateway: **226** -- OpenAPI routes matched directly by the gateway: **225** +- OpenAPI HTTP operations: **409** +- Stable `moviepilot_api` operations: **229** +- Exact HTTP routes used by the gateway: **227** +- OpenAPI routes matched directly by the gateway: **226** - 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` | 225 | Approved structured MoviePilot Agent operation. | +| `gateway` | 226 | 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. | @@ -193,6 +193,7 @@ | `POST` | `/api/v1/openai/v1/responses` | openai | `transport_or_identity` | host-runtime | OpenAI compatible responses | | `GET` | `/api/v1/plugin/` | plugin | `gateway` | plugin.installed, plugin.market | 所有插件 | | `POST` | `/api/v1/plugin/clone/{plugin_id}` | plugin | `gateway` | plugin.clone | 创建插件分身 | +| `GET` | `/api/v1/plugin/clone/{plugin_id}/restorable` | plugin | `gateway` | plugin.clone.restorable | 列出可恢复的已停用分身 | | `GET` | `/api/v1/plugin/dashboard/meta` | plugin | `ui_presentation` | host-ui | 获取所有插件仪表板元信息 | | `GET` | `/api/v1/plugin/dashboard/{plugin_id}` | plugin | `ui_presentation` | host-ui | 获取插件仪表板配置 | | `GET` | `/api/v1/plugin/dashboard/{plugin_id}/{key}` | plugin | `ui_presentation` | host-ui | 获取插件仪表板配置 | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 2e0dacf96..a064619b3 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1018 / 8,650 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1019 / 8,667 | `dependency-baseline.json` 当前快照;分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | @@ -102,7 +102,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement | | Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 | | 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 | -| 全量 mypy 历史债务 | 9,371 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | +| 全量 mypy 历史债务 | 9,370 / 509 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 | | Ruff 历史诊断 | 519 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` | | 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index ca844ce45..a9dfeb11d 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -57,9 +57,9 @@ allowed-api-operations: >- system.network.targets system.network.test system.module.list system.module.catalog system.module.settings system.module.test plugin.market.sync_wiki plugin.runtime.status plugin.history plugin.releases plugin.ratings plugin.rating plugin.rating.submit - 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.statistics plugin.reset plugin.clone.restorable 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.loglevel.get plugin.loglevel.set plugin.loglevel.clear plugin.default_target.set plugin.default_target.clear plugin.instance.set_enabled plugin.instance.purge diff --git a/skills/moviepilot-api/api/plugin.md b/skills/moviepilot-api/api/plugin.md index a04d40776..6fb3ef817 100644 --- a/skills/moviepilot-api/api/plugin.md +++ b/skills/moviepilot-api/api/plugin.md @@ -13,10 +13,18 @@ Purpose: Inspect the runtime capabilities exposed by installed plugins. ### `plugin.clone` `POST /api/v1/plugin/clone/{plugin_id}`; policy effect: `external_side_effect`. -Purpose: Create a configurable clone of one installed plugin. +Purpose: Create a configurable clone of one installed plugin; leave suffix empty to let the server allocate the next free instance ID, and reusing the suffix of a disabled clone re-enables that clone together with its stored configuration. - `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID. - `query`: none -- `body`: `description` (string; default ``): Human-readable media, torrent, or subscription description.; `icon` (string|null): Icon name or URL used by a workflow, network target, plugin, or category.; `name` (string; default ``): Human-readable name of the site, storage item, subscription, or rule group.; `suffix*` (string; minimum length `1`): File suffix or extension matched by an automatic category rule.; `version` (string|null): Plugin release or schema version selected by the operation. +- `body`: `description` (string; default ``): Human-readable media, torrent, or subscription description.; `icon` (string|null): Icon name or URL used by a workflow, network target, plugin, or category.; `name` (string; default ``): Human-readable name of the site, storage item, subscription, or rule group.; `restore_previous` (boolean; default `True`): When creating a plugin clone, reuse the stored configuration of a disabled clone that already holds the same suffix; false rebuilds that clone's configuration from the source plugin template.; `suffix` (string|null): File suffix or extension matched by an automatic category rule.; `version` (string|null): Plugin release or schema version selected by the operation. + +### `plugin.clone.restorable` +`GET /api/v1/plugin/clone/{plugin_id}/restorable`; policy effect: `safe_read`. +Purpose: List one plugin's disabled clones whose configuration is still stored and can be brought back by creating a clone with the same suffix. +- `response`: `data` remains a list; omitting both `page` and `count` keeps the complete legacy result. `collection.result_count` reports the returned items and `collection.total_count` reports the exact pre-pagination total. For counts or summaries, send `page=1,count=1`, read `collection.total_count`, and do not fall back to a database query because the item preview was truncated. +- `path_params`: `plugin_id*` (string): Exact installed or marketplace plugin ID. +- `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.; `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. +- `body`: none ### `plugin.config.get` `GET /api/v1/plugin/form/{plugin_id}`; policy effect: `safe_read`. diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index b71f01a14..6ede5373a 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1077,8 +1077,8 @@ "runtime_only": true } }, - "edge_count": 8650, - "edge_sha256": "b127ad724b438d3a940f056676b637592f97903a38457b1c99842b56ea1deb0f", + "edge_count": 8667, + "edge_sha256": "0c4c36ff964813d64a77cf77fc905770298c4e00a48e0ffbc2f017a4d1ccce1f", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -2696,6 +2696,22 @@ "app.api.endpoints.plugin -> app.startup", "app.api.endpoints.plugin -> app.startup.composition", "app.api.endpoints.plugin -> app.startup.composition.context", + "app.api.endpoints.pluginclone -> app.api", + "app.api.endpoints.pluginclone -> app.api.dependencies", + "app.api.endpoints.pluginclone -> app.api.dependencies.auth", + "app.api.endpoints.pluginclone -> app.api.endpoints", + "app.api.endpoints.pluginclone -> app.api.endpoints.plugin", + "app.api.endpoints.pluginclone -> app.api.principal", + "app.api.endpoints.pluginclone -> app.api.response", + "app.api.endpoints.pluginclone -> app.application", + "app.api.endpoints.pluginclone -> app.application.plugin", + "app.api.endpoints.pluginclone -> app.application.plugin.folders", + "app.api.endpoints.pluginclone -> app.application.plugin.runtime", + "app.api.endpoints.pluginclone -> app.runtime", + "app.api.endpoints.pluginclone -> app.runtime.log", + "app.api.endpoints.pluginclone -> app.schemas", + "app.api.endpoints.pluginclone -> app.schemas.plugin", + "app.api.endpoints.pluginclone -> app.schemas.response", "app.api.endpoints.pluginfolder -> app.api", "app.api.endpoints.pluginfolder -> app.api.dependencies", "app.api.endpoints.pluginfolder -> app.api.dependencies.auth", @@ -3147,6 +3163,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.pluginclone", "app.api.routers -> app.api.endpoints.plugininstance", "app.api.routers -> app.api.endpoints.pluginloglevel", "app.api.routers -> app.api.endpoints.plugintarget", @@ -9731,7 +9748,7 @@ "app.workflow.actions.transfer_file -> app.workflow", "app.workflow.actions.transfer_file -> app.workflow.actions" ], - "module_count": 1018, + "module_count": 1019, "modules": [ "app", "app.adapters", @@ -9918,6 +9935,7 @@ "app.api.endpoints.notification", "app.api.endpoints.openai", "app.api.endpoints.plugin", + "app.api.endpoints.pluginclone", "app.api.endpoints.pluginfolder", "app.api.endpoints.plugininstance", "app.api.endpoints.pluginloglevel", diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index c45ea592b..51a021000 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -495,7 +495,7 @@ "app/api/endpoints/plugin.py": { "assignment": 1, "import-untyped": 1, - "misc": 29, + "misc": 28, "no-any-return": 5, "no-untyped-call": 1, "no-untyped-def": 2, diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 5bec6c69b..e7e798ba7 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) == 143 - assert len(API_OPERATION_SPECS) == 228 + assert len(API_EXTENDED_OPERATION_SPECS) == 144 + assert len(API_OPERATION_SPECS) == 229 assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES) assert { "download.list", diff --git a/tests/test_agent_lifecycle.py b/tests/test_agent_lifecycle.py index da76e79ae..b365559c0 100644 --- a/tests/test_agent_lifecycle.py +++ b/tests/test_agent_lifecycle.py @@ -4,8 +4,10 @@ from unittest.mock import AsyncMock, MagicMock import pytest import app.agent.orchestrator as agent_module +from app.adapters.system import resource as resource_module +from app.adapters.system.resource import configure_resource_version_provider from app.agent.memory import MemoryManager -from app.agent.orchestrator import ( +from app.agent.orchestrator import ( # pylint: disable=no-name-in-module AGENT_SESSION_QUEUE_MAX_SIZE, AgentManager, AgentManagerQueueFullError, @@ -22,6 +24,21 @@ from app.startup.initializers import agent as agent_initializer from app.startup.initializers import modules as modules_initializer +@pytest.fixture(autouse=True) +def restore_resource_version_provider(): + """还原进程级的站点资源版本读取器。 + + 本文件有用例把 ``SitesHelper`` 桩成 MagicMock 后去跑真实的 ``init_modules``, + 而组合根会把那个桩对象捕获进 ``_resource_version_provider`` 这个进程级闭包里。 + ``monkeypatch`` 只还原它自己改过的 ``modules_initializer.SitesHelper`` 属性, + 还不掉已经被闭包捕获的那份引用;不显式撤销,同一进程内后续任何读取站点资源 + 版本的用例都会拿到 MagicMock,而它既不是字符串也无法参与版本比较。 + """ + previous = resource_module._resource_version_provider + yield + configure_resource_version_provider(previous) + + @pytest.mark.anyio async def test_web_agent_background_tasks_are_cancelled_and_drained() -> None: """Web Agent 任务关闭后不得继续占用循环或提交晚到的快照。""" diff --git a/tests/test_agent_skills_middleware.py b/tests/test_agent_skills_middleware.py index 1a9ca5b6c..51e72e985 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"]) == 228 + assert len(payload["skill"]["allowed_api_operations"]) == 229 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_plugin_clone_experience.py b/tests/test_plugin_clone_experience.py new file mode 100644 index 000000000..468ca8b9c --- /dev/null +++ b/tests/test_plugin_clone_experience.py @@ -0,0 +1,612 @@ +"""分身创建体验测试:自动分配后缀、按 ID 恢复已停用的分身与提交前的后缀校验。 + +这些行为大多只在 ``build_plugin_runtime`` 装配出来的整体上成立:分身服务自己只认注入 +进来的端口,「哪些 ID 算被占用」是组合根的决定,因而占用判据必须在这一层验证,而不是 +在用例里另接一套判据自证。 +""" + +from __future__ import annotations + +import threading +import time +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Optional + +import pytest +from pydantic import ValidationError + +from app.runtime.extensions.plugin.manager import PluginManager +from app.runtime.extensions.plugin.runtime import ( + PluginRuntime, + PluginRuntimeEnvironment, + build_plugin_runtime, +) +from app.runtime.extensions.plugin.storage import ( + PluginInstanceDirectory, + PluginStorage, +) +from app.schemas.plugin import PluginCloneRequest, PluginInstance, PluginRuntimeStatus +from app.schemas.types import SystemConfigKey + + +class DemoPlugin: + """充当源插件本体的最小插件类,只需要能被注册表登记。""" + + +def _logger() -> SimpleNamespace: + """提供运行时构造所需的最小日志对象。""" + return SimpleNamespace( + debug=lambda *_args, **_kwargs: None, + info=lambda *_args, **_kwargs: None, + warning=lambda *_args, **_kwargs: None, + error=lambda *_args, **_kwargs: None, + ) + + +class _World: + """一套全内存的插件世界,暴露用例要断言的每一份真实状态。""" + + def __init__(self) -> None: + """建立空的实例表、配置表与运行态记录。""" + self.rows: dict[str, PluginInstance] = {} + self.configs: dict[str, dict] = {} + self.installed: list[str] = [] + self.plugins_root = Path("/nonexistent-plugins-root") + self.reloaded: list[str] = [] + self.removed: list[str] = [] + self.status = PluginRuntimeStatus.ACTIVE + self.probe_delay = 0.0 + self.runtime: Optional[PluginRuntime] = None + + def clone(self, **kwargs: Any) -> tuple[bool, str]: + """按默认展示信息发起一次分身创建。""" + assert self.runtime is not None + params: dict[str, Any] = {"name": "", "description": ""} + params.update(kwargs) + return self.runtime.clone.clone(**params) + + def restorable(self) -> list[dict[str, Any]]: + """调用管理器上的可恢复清单投影。 + + ``get_restorable_plugin_instances`` 是一段纯投影,只用到实例表与配置表两个 + 属性;用一个替身 self 调用它,既验证真实投影逻辑,又不必把 PluginManager + 这个单例连同它的文件监控与线程池一起启动起来。 + """ + assert self.runtime is not None + stand_in = SimpleNamespace( + _plugin_instance_store=self.runtime.instances, + _plugin_config_store=self.runtime.configs, + ) + return PluginManager.get_restorable_plugin_instances(stand_in, "DemoPlugin") + + +def _build_world( + *, + rows: Optional[dict[str, PluginInstance]] = None, + configs: Optional[dict[str, dict]] = None, + installed: Optional[list[str]] = None, + plugins_root: Optional[Path] = None, +) -> _World: + """按给定实例行、配置、安装清单与插件包目录装配一个全内存运行时。""" + world = _World() + world.rows.update(rows or {}) + world.configs.update(configs or {}) + world.installed.extend(installed or []) + if plugins_root is not None: + world.plugins_root = plugins_root + + values: dict = {SystemConfigKey.UserInstalledPlugins: world.installed} + + def _read_row(instance_id: str) -> Optional[PluginInstance]: + """读取实例行,可按需放慢以拉开并发创建的竞争窗口。""" + if world.probe_delay: + time.sleep(world.probe_delay) + return world.rows.get(instance_id) + + def _set_enabled(instance_id: str, is_enabled: bool) -> bool: + """就地翻转启用位,行不存在时报告未写入。""" + record = world.rows.get(instance_id) + if record is None: + return False + world.rows[instance_id] = record.model_copy(update={"is_enabled": is_enabled}) + return True + + storage = PluginStorage( + read=values.get, + write=values.__setitem__, + read_config=world.configs.get, + write_config=lambda instance_id, config: world.configs.__setitem__( + instance_id, + config, + ), + delete_config=lambda instance_id: world.configs.pop(instance_id, None) is not None, + ) + directory = PluginInstanceDirectory( + get=_read_row, + list_all=lambda: list(world.rows.values()), + list_by_source=lambda source_plugin_id: [ + record + for record in world.rows.values() + if record.source_plugin_id == source_plugin_id + ], + save=lambda instance: world.rows.__setitem__(instance.instance_id, instance), + delete=lambda instance_id: world.rows.pop(instance_id, None) is not None, + list_enabled=lambda: [ + record for record in world.rows.values() if record.is_enabled + ], + set_enabled=_set_enabled, + ) + + def _reload(plugin_id: str) -> PluginRuntimeStatus: + """记录一次定向重载并回报当前世界设定的结果。""" + world.reloaded.append(plugin_id) + return world.status + + environment = PluginRuntimeEnvironment( + plugins_root=world.plugins_root, + storage=lambda: storage, + instance_directory=lambda: directory, + system=lambda: SimpleNamespace(), + database=lambda: SimpleNamespace(), + catalog_factory=lambda _mapper: SimpleNamespace(), + import_preparer=lambda **_kwargs: None, + import_scanner=lambda **_kwargs: None, + auth_level=lambda: 0, + remote_entry=lambda _plugin_id, _page: "", + development=lambda: False, + logger=_logger(), + set_default_target=lambda _plugin_id, _instance_id: True, + clear_default_target=lambda _plugin_id: None, + ) + runtime = build_plugin_runtime( + SimpleNamespace( + reload_plugin=_reload, + remove_plugin=world.removed.append, + get_plugin_remote_entry=lambda _plugin_id, _page: "", + _run_file_watcher=lambda: None, + get_plugins_from_market=lambda *_args, **_kwargs: None, + async_get_plugins_from_market=lambda *_args, **_kwargs: None, + ), + environment, + tool_build_max_attempts=1, + ) + # 源插件本体必须在类注册表里:分身共享它的源码,它缺席就谈不上给它建分身 + runtime.registry.classes["DemoPlugin"] = DemoPlugin + world.runtime = runtime + return world + + +def _disabled_clone(instance_id: str, **fields: Any) -> PluginInstance: + """构造一行已停用的分身,模拟「停用后设置仍留存」的状态。""" + payload: dict[str, Any] = { + "instance_id": instance_id, + "source_plugin_id": "DemoPlugin", + "is_enabled": False, + } + payload.update(fields) + return PluginInstance(**payload) + + +# --------------------------------------------------------------------------- # +# 自动分配后缀 +# --------------------------------------------------------------------------- # + + +def test_auto_allocated_suffix_starts_right_after_the_host_instance(): + """不填后缀时从 2 起算:本体在用户眼里就是第 1 个实例,分身接着往下排。""" + world = _build_world() + + success, instance_id = world.clone(plugin_id="DemoPlugin") + + assert success is True + assert instance_id == "DemoPlugin2" + assert world.rows["DemoPlugin2"].is_enabled is True + + +def test_auto_allocated_suffix_skips_a_disabled_clone_row(): + """已停用的分身同样占号,自动分配不得挑中它。 + + 重用它的 ID 是「恢复」而不是新建:用户点的是「新建一个」,却拿到上一个分身留下 + 的业务参数,等于凭空继承了一份他没打算要的配置。 + """ + world = _build_world( + rows={"DemoPlugin2": _disabled_clone("DemoPlugin2")}, + configs={"DemoPlugin2": {"token": "旧的"}}, + ) + + success, instance_id = world.clone(plugin_id="DemoPlugin") + + assert success is True + assert instance_id == "DemoPlugin3" + # 旧行原样留着,没有被这次新建顶掉 + assert world.rows["DemoPlugin2"].is_enabled is False + assert world.configs["DemoPlugin2"] == {"token": "旧的"} + + +def test_auto_allocated_suffix_skips_an_enabled_clone_row(): + """在册的分身占号,自动分配跳过它继续往下找。""" + world = _build_world( + rows={ + "DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True), + "DemoPlugin3": _disabled_clone("DemoPlugin3", is_enabled=True), + }, + ) + + success, instance_id = world.clone(plugin_id="DemoPlugin") + + assert success is True + assert instance_id == "DemoPlugin4" + + +def test_auto_allocated_suffix_skips_an_installed_physical_plugin(): + """装过但此刻未装载的物理插件同样占号。 + + 它不在类注册表里,只看运行态会把 ``DemoPlugin2`` 判成空位,新分身于是顶着一个 + 真实插件的身份建出来,那个插件下次装载时两者撞在同一个 ID 上。 + """ + world = _build_world(installed=["DemoPlugin", "DemoPlugin2"]) + + success, instance_id = world.clone(plugin_id="DemoPlugin") + + assert success is True + assert instance_id == "DemoPlugin3" + + +def test_auto_allocated_suffix_skips_a_plugin_package_present_on_disk(tmp_path): + """磁盘上留着同名插件包时不得占用该号,否则那个插件以后再也装不回来。 + + 卸载不删源码,包目录会一直留着;它既不在类注册表里,也不在安装清单里,只看这两 + 处会把它判成空位。分身占了这个号之后,那个插件重装时实例行的归属列对不上,写入 + 会被持久化层直接拒绝。 + """ + (tmp_path / "demoplugin2").mkdir() + world = _build_world(plugins_root=tmp_path) + + success, instance_id = world.clone(plugin_id="DemoPlugin") + + assert success is True + assert instance_id == "DemoPlugin3" + + +def test_auto_allocated_suffix_skips_a_currently_loaded_plugin_class(): + """运行期已登记同名类时不得占用该号。""" + world = _build_world() + world.runtime.registry.classes["DemoPlugin2"] = DemoPlugin + + success, instance_id = world.clone(plugin_id="DemoPlugin") + + assert success is True + assert instance_id == "DemoPlugin3" + + +@pytest.mark.parametrize("blank", ["", " ", None]) +def test_blank_suffix_falls_back_to_automatic_allocation(blank): + """后缀留空的几种写法都走自动分配,而不是拼出一个等于源插件 ID 的实例。""" + world = _build_world() + + success, instance_id = world.clone(plugin_id="DemoPlugin", suffix=blank) + + assert success is True + assert instance_id == "DemoPlugin2" + + +# --------------------------------------------------------------------------- # +# 判存:同一个占用判据挡住显式后缀 +# --------------------------------------------------------------------------- # + + +def test_explicit_suffix_colliding_with_an_enabled_clone_is_rejected(): + """显式后缀撞上在册分身时拒绝,且不得覆盖它的描述符。""" + world = _build_world( + rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True, plugin_name="在跑的")}, + ) + + success, message = world.clone(plugin_id="DemoPlugin", suffix="2", name="新的") + + assert success is False + assert "已存在" in message + assert world.rows["DemoPlugin2"].plugin_name == "在跑的" + + +def test_explicit_suffix_colliding_with_another_sources_clone_is_rejected(): + """归属对不上的停用行不算可恢复:它是别的源插件的分身,不能被这里改嫁。""" + world = _build_world( + rows={ + "DemoPlugin2": PluginInstance( + instance_id="DemoPlugin2", + source_plugin_id="OtherPlugin", + is_enabled=False, + ), + }, + ) + + success, message = world.clone(plugin_id="DemoPlugin", suffix="2") + + assert success is False + assert "已存在" in message + assert world.rows["DemoPlugin2"].source_plugin_id == "OtherPlugin" + + +def test_a_clone_cannot_be_used_as_a_clone_source(): + """分身不能再生分身:它共享源插件的源码,自己并不是一份源码。""" + world = _build_world(rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True)}) + world.runtime.registry.classes["DemoPlugin2"] = DemoPlugin + + success, message = world.clone(plugin_id="DemoPlugin2", suffix="3") + + assert success is False + assert "不能作为分身来源" in message + assert "DemoPlugin" in message + + +def test_repeating_the_same_create_is_rejected_rather_than_overwriting(): + """同一个后缀重复提交两次,第二次确定地被判成已存在。""" + world = _build_world() + + first = world.clone(plugin_id="DemoPlugin", suffix="Work", name="第一次") + second = world.clone(plugin_id="DemoPlugin", suffix="Work", name="第二次") + + assert first == (True, "DemoPluginwork") + assert second[0] is False + assert "已存在" in second[1] + assert world.rows["DemoPluginwork"].plugin_name == "第一次" + + +# --------------------------------------------------------------------------- # +# 按 ID 恢复已停用的分身 +# --------------------------------------------------------------------------- # + + +def test_restoring_a_disabled_clone_brings_back_its_config_and_display_info(): + """按 ID 恢复就是把那一行重新置为启用,配置与展示信息原样回来。 + + 停用从不删行,业务参数一直挂在上面;恢复因而不能是「新建一行再按源插件模板铺一份 + 配置」,那会把用户特意留着的东西盖掉。 + """ + world = _build_world( + rows={ + "DemoPlugin2": _disabled_clone( + "DemoPlugin2", + plugin_name="夜间任务", + plugin_desc="只在夜里跑", + plugin_icon="night.png", + ), + }, + configs={ + "DemoPlugin": {"token": "源插件的"}, + "DemoPlugin2": {"token": "分身自己的", "cron": "0 3 * * *"}, + }, + ) + + success, instance_id = world.clone(plugin_id="DemoPlugin", suffix="2") + + assert (success, instance_id) == (True, "DemoPlugin2") + restored = world.rows["DemoPlugin2"] + assert restored.is_enabled is True + assert restored.plugin_name == "夜间任务" + assert restored.plugin_desc == "只在夜里跑" + assert restored.plugin_icon == "night.png" + # 配置没有被源插件模板顶掉,这正是用户要拿回来的东西 + assert world.configs["DemoPlugin2"] == {"token": "分身自己的", "cron": "0 3 * * *"} + assert world.reloaded == ["DemoPlugin2"] + + +def test_restoring_accepts_a_new_display_name_while_keeping_the_config(): + """恢复时可以顺手改名,改的只是展示信息,业务参数仍然沿用。""" + world = _build_world( + rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="旧名字")}, + configs={"DemoPlugin2": {"token": "留着"}}, + ) + + success, _instance_id = world.clone( + plugin_id="DemoPlugin", + suffix="2", + name="新名字", + ) + + assert success is True + assert world.rows["DemoPlugin2"].plugin_name == "新名字" + assert world.configs["DemoPlugin2"] == {"token": "留着"} + + +def test_restore_previous_false_rebuilds_the_config_from_the_source_template(): + """显式要求全新时按源插件模板重建配置,并保持业务开关关闭待用户配置。""" + world = _build_world( + rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="旧名字")}, + configs={ + "DemoPlugin": {"enable": True, "token": "源插件的"}, + "DemoPlugin2": {"token": "该被丢弃"}, + }, + ) + + success, _instance_id = world.clone( + plugin_id="DemoPlugin", + suffix="2", + name="全新", + restore_previous=False, + ) + + assert success is True + assert world.configs["DemoPlugin2"] == { + "enable": False, + "enabled": False, + "token": "源插件的", + } + assert world.rows["DemoPlugin2"].plugin_name == "全新" + + +def test_failed_restore_only_puts_the_row_back_to_disabled(): + """恢复失败只把启用位退回停用,不得连同用户留存的配置一起毁掉。 + + 那一行是用户特意留着的,不是本次创建的产物;一次加载失败就删掉它,等于让用户为 + 一个可重试的故障付出丢数据的代价。 + """ + world = _build_world( + rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="夜间任务")}, + configs={"DemoPlugin2": {"token": "必须留着"}}, + ) + world.status = PluginRuntimeStatus.LOAD_FAILED + + success, message = world.clone(plugin_id="DemoPlugin", suffix="2") + + assert success is False + assert "加载失败" in message + assert world.rows["DemoPlugin2"].is_enabled is False + assert world.rows["DemoPlugin2"].plugin_name == "夜间任务" + assert world.configs["DemoPlugin2"] == {"token": "必须留着"} + assert world.removed == ["DemoPlugin2"] + + +def test_failed_fresh_creation_still_removes_the_row_it_created(): + """新建失败仍按老规矩连行带配置抹掉:它整个是本次的产物,留着只是垃圾。""" + world = _build_world(configs={"DemoPlugin": {"token": "源插件的"}}) + world.status = PluginRuntimeStatus.LOAD_FAILED + + success, message = world.clone(plugin_id="DemoPlugin", suffix="Work") + + assert success is False + assert "加载失败" in message + assert "DemoPluginwork" not in world.rows + assert "DemoPluginwork" not in world.configs + + +# --------------------------------------------------------------------------- # +# 校验在提交前拦截 +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "suffix", + ["a-b", "带中文", "work!", "with space", "x" * 21], +) +def test_illegal_suffix_is_rejected_by_the_request_schema(suffix): + """非法后缀在请求解析阶段就被拦下,根本到不了会写库的那一层。""" + with pytest.raises(ValidationError): + PluginCloneRequest(suffix=suffix) + + +@pytest.mark.parametrize("blank", ["", " ", None]) +def test_blank_suffix_is_normalized_to_automatic_allocation_by_the_schema(blank): + """留空的三种写法在请求层归一为 None,不会撞上格式校验。""" + assert PluginCloneRequest(suffix=blank).suffix is None + + +def test_request_schema_trims_and_keeps_a_legal_suffix(): + """合法后缀去掉首尾空白后原样保留,大小写留给运行时归一。""" + assert PluginCloneRequest(suffix=" Work ").suffix == "Work" + + +def test_restore_previous_defaults_to_reusing_the_stored_settings(): + """默认沿用留存设置:静默丢弃用户数据不能是默认行为。""" + assert PluginCloneRequest().restore_previous is True + + +def test_an_overlong_composed_instance_id_is_rejected_before_any_write(): + """拼出来的实例 ID 超长时在落库之前就被拒绝,不留下半个实例。 + + 非法 ID 若等到持久化层才报错,意味着已经写出一行、还要靠回滚去擦掉它;把判定 + 提到构造阶段,失败路径上根本没有东西需要清理。 + """ + long_id = "D" + "e" * 119 + world = _build_world() + world.runtime.registry.classes[long_id] = DemoPlugin + + success, message = world.clone(plugin_id=long_id, suffix="x" * 20) + + assert success is False + assert "不合法" in message + assert world.rows == {} + assert world.configs == {} + assert world.removed == [] + + +# --------------------------------------------------------------------------- # +# 并发创建 +# --------------------------------------------------------------------------- # + + +def test_concurrent_auto_allocation_never_hands_out_the_same_instance_id(): + """两个并发的自动分配各自拿到一个号,不会落到同一行上。 + + 「挑一个没被占用的 ID」与「把它占下来」之间存在窗口;用例把占用探测放慢来把窗口 + 拉开,没有占位互斥时两个线程会选出同一个 ID,后写的顶掉先写的。 + """ + world = _build_world() + world.probe_delay = 0.005 + barrier = threading.Barrier(2) + results: list[tuple[bool, str]] = [] + lock = threading.Lock() + + def _create() -> None: + """两个线程在同一时刻发起创建。""" + barrier.wait(timeout=5) + outcome = world.clone(plugin_id="DemoPlugin") + with lock: + results.append(outcome) + + threads = [threading.Thread(target=_create) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + assert all(success for success, _ in results) + assert sorted(instance_id for _, instance_id in results) == [ + "DemoPlugin2", + "DemoPlugin3", + ] + assert sorted(world.rows) == ["DemoPlugin2", "DemoPlugin3"] + + +# --------------------------------------------------------------------------- # +# 可恢复清单 +# --------------------------------------------------------------------------- # + + +def test_restorable_listing_only_reports_disabled_clones(): + """在册的分身与本体那一行都不在恢复清单里。 + + 活着的分身配置正被使用,摆进恢复选择器只会让人误以为能把它再创建一遍;本体不是 + 分身,也没有「恢复成分身」这回事。 + """ + world = _build_world( + rows={ + "DemoPlugin": PluginInstance( + instance_id="DemoPlugin", + source_plugin_id="DemoPlugin", + is_enabled=False, + ), + "DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True), + "DemoPlugin3": _disabled_clone("DemoPlugin3"), + }, + ) + + assert [item["instance_id"] for item in world.restorable()] == ["DemoPlugin3"] + + +def test_restorable_listing_reports_suffix_display_info_and_stored_config(): + """清单给出后缀、展示信息与是否留有业务参数,供恢复选择器直接渲染。""" + world = _build_world( + rows={ + "DemoPluginwork": _disabled_clone( + "DemoPluginwork", + plugin_name="工作实例", + plugin_desc="独立配置", + ), + "DemoPlugin9": _disabled_clone("DemoPlugin9"), + }, + configs={"DemoPluginwork": {"token": "留着"}}, + ) + + listing = {item["instance_id"]: item for item in world.restorable()} + + assert listing["DemoPluginwork"] == { + "instance_id": "DemoPluginwork", + "suffix": "work", + "plugin_name": "工作实例", + "plugin_desc": "独立配置", + "has_config": True, + } + # 停用的实例不在类注册表里,「有没有配置」不能走要求插件在册的读取口去问 + assert listing["DemoPlugin9"]["has_config"] is False diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index ffe2e6b33..df02978aa 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -10,6 +10,7 @@ from starlette.responses import Response from app import schemas from app.api.endpoints import plugin as plugin_endpoint +from app.api.endpoints import pluginclone as plugin_clone_endpoint from app.api.endpoints import pluginfolder as plugin_folders_endpoint from app.api.endpoints.plugin import ( plugin_capabilities, @@ -1317,11 +1318,11 @@ def test_sealed_http_clone_rejects_before_runtime_and_registration(monkeypatch): plugin_manager.mutation.side_effect = admission.hold register = MagicMock() add_to_folder = MagicMock() - monkeypatch.setattr(plugin_endpoint, "get_plugin_manager", lambda: plugin_manager) - monkeypatch.setattr(plugin_endpoint, "register_plugin", register) - monkeypatch.setattr(plugin_endpoint, "add_clone_to_plugin_folder", add_to_folder) + monkeypatch.setattr(plugin_clone_endpoint, "get_plugin_manager", lambda: plugin_manager) + monkeypatch.setattr(plugin_clone_endpoint, "register_plugin", register) + monkeypatch.setattr(plugin_clone_endpoint, "add_clone_to_plugin_folder", add_to_folder) - result = plugin_endpoint.clone_plugin( + result = plugin_clone_endpoint.clone_plugin( "DemoPlugin", schemas.PluginCloneRequest(suffix="Work"), None, diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py index 480df3d74..ee9674133 100644 --- a/tests/test_plugin_virtual_instances.py +++ b/tests/test_plugin_virtual_instances.py @@ -656,13 +656,15 @@ def test_clone_service_persists_descriptor_without_copying_source_package(): service = PluginCloneService( plugin_class=lambda plugin_id: DemoPlugin if plugin_id == "DemoPlugin" else None, - plugin_exists=lambda plugin_id: plugin_id in instances, + instance_id_taken=lambda plugin_id: plugin_id in instances, + get_instance=instances.get, source_plugin_id=lambda plugin_id: plugin_id, save_instance=lambda instance: instances.__setitem__( instance.instance_id, instance, ), delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None, + disable_instance=lambda plugin_id: plugin_id in instances, read_config=lambda plugin_id: configs.get(plugin_id, {}), save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config), delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None, @@ -704,13 +706,15 @@ def test_clone_service_rolls_back_descriptor_and_config_after_load_failure(): service = PluginCloneService( plugin_class=lambda _plugin_id: DemoPlugin, - plugin_exists=lambda plugin_id: plugin_id in instances, + instance_id_taken=lambda plugin_id: plugin_id in instances, + get_instance=instances.get, source_plugin_id=lambda plugin_id: plugin_id, save_instance=lambda instance: instances.__setitem__( instance.instance_id, instance, ), delete_instance=lambda plugin_id: instances.pop(plugin_id, None) is not None, + disable_instance=lambda plugin_id: plugin_id in instances, read_config=lambda plugin_id: configs.get(plugin_id, {}), save_config=lambda plugin_id, config: not configs.__setitem__(plugin_id, config), delete_config=lambda plugin_id: configs.pop(plugin_id, None) is not None,