feat(plugin): 分身后缀可留空自动分配,并支持按后缀恢复已停用的分身 (#6673)

This commit is contained in:
Aqr-K
2026-09-14 07:00:31 +08:00
committed by GitHub
parent a51acb05e5
commit 57fb96fc3c
27 changed files with 1304 additions and 117 deletions

View File

@@ -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"),

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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="查询插件运行能力",

View File

@@ -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)}")

View File

@@ -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",)),

View File

@@ -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)

View File

@@ -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]]:
"""
查询插件全部实例(含本体)当前的日志等级设置

View File

@@ -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,

View File

@@ -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):

View File

@@ -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'),

View File

@@ -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]

View File

@@ -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 |

View File

@@ -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 工具与完整参数合同

View File

@@ -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",

View File

@@ -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 | 获取插件仪表板配置 |

View File

@@ -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 未进入包级覆盖率门禁 |

View File

@@ -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

View File

@@ -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`.

View File

@@ -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",

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 任务关闭后不得继续占用循环或提交晚到的快照。"""

View File

@@ -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"]

View File

@@ -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

View File

@@ -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,

View File

@@ -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,