Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement

feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个
This commit is contained in:
Aqr-K
2026-09-13 16:37:27 +08:00
committed by GitHub
parent 30d6d9e146
commit d44480d739
54 changed files with 2853 additions and 73 deletions

View File

@@ -570,6 +570,9 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
_admin_read("plugin.loglevel.get", sensitivity=ResultSensitivity.PRIVATE),
_write("plugin.loglevel.set"),
_write("plugin.loglevel.clear"),
_write("plugin.default_target.set"),
_write("plugin.default_target.clear"),
_write("plugin.instance.set_enabled"),
)
@@ -825,6 +828,15 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
"plugin.loglevel.clear": ApiOperationRoute(
"DELETE", "/api/v1/plugin/loglevel/{plugin_id}/{instance_id}"
),
"plugin.default_target.set": ApiOperationRoute(
"PUT", "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target"
),
"plugin.default_target.clear": ApiOperationRoute(
"DELETE", "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target"
),
"plugin.instance.set_enabled": ApiOperationRoute(
"POST", "/api/v1/plugin/instance/{instance_id}/enabled"
),
}

View File

@@ -231,6 +231,9 @@ OPERATION_DESCRIPTIONS = {
"plugin.loglevel.get": "List one plugin's instances, including the plugin itself, with each instance's configured and effective log level.",
"plugin.loglevel.set": "Set one plugin instance's log-level override, taking effect immediately without following the global log level.",
"plugin.loglevel.clear": "Clear one plugin instance's log-level override so it immediately follows the global log level again.",
"plugin.default_target.set": "Set one plugin instance as the plugin's default call target, automatically clearing any previous default.",
"plugin.default_target.clear": "Clear one plugin instance's default-call-target flag, only if it is the plugin's current default.",
"plugin.instance.set_enabled": "Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable.",
}
@@ -337,6 +340,8 @@ FIELD_DESCRIPTIONS = {
"include_usage": "Include the subscriptions or defaults that reference each rule group.",
"include_values": "Return complete setting values instead of discovery summaries.",
"instance_id": "Exact plugin instance ID returned by plugin.loglevel.get.",
"is_default_target": "Whether this plugin instance is the plugin's default call target, used when a caller does not specify an instance.",
"enabled": "Target enabled state; false stops the instance while keeping its configuration and display information.",
"is_active": "Whether the configured site is enabled.",
"jobid": "Exact scheduler job ID returned by scheduler.list.",
"key": "Optional exact plugin data key used to narrow the returned preview.",
@@ -660,6 +665,7 @@ MODEL_DESCRIPTIONS = {
"MediaSource": "Canonical metadata source identifier paired with a source-native media ID.",
"MediaType": "MoviePilot media type.",
"MusicRecognizeRequest": "Exact source-native recording or album identity to resolve into canonical music metadata.",
"PluginInstanceEnabledRequest": "One plugin instance's enable-or-disable request; disabling keeps its configuration for a later re-enable.",
"PluginInstanceLogLevelUpdateRequest": "One plugin instance's log-level override update request.",
"PluginSourceChangeRequest": "Explicit online-source change request guarded by the current identity revision.",
"PluginSourceInstallRequest": "Explicit online-source installation request for an unbound plugin.",

View File

@@ -3146,6 +3146,21 @@
"title": "PluginFoldersData",
"type": "object"
},
"PluginInstanceEnabledRequest": {
"description": "One plugin instance's enable-or-disable request; disabling keeps its configuration for a later re-enable.",
"properties": {
"enabled": {
"description": "Whether this category or classification rule participates in evaluation.",
"title": "Enabled",
"type": "boolean"
}
},
"required": [
"enabled"
],
"title": "PluginInstanceEnabledRequest",
"type": "object"
},
"PluginInstanceLogLevelUpdateRequest": {
"description": "One plugin instance's log-level override update request.",
"properties": {
@@ -9756,6 +9771,82 @@
"title": "plugin.data",
"type": "object"
},
{
"additionalProperties": false,
"description": "Clear one plugin instance's default-call-target flag, only if it is the plugin's current default. Method: DELETE. Path: /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target. Effect: reversible_write.",
"properties": {
"operation_id": {
"const": "plugin.default_target.clear",
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
"type": "string"
},
"path_params": {
"additionalProperties": false,
"description": "Resource identity placeholders for plugin.default_target.clear. Clear one plugin instance's default-call-target flag, only if it is the plugin's current default. Use only the named fields below.",
"properties": {
"instance_id": {
"description": "Exact plugin instance ID returned by plugin.loglevel.get.",
"title": "Instance Id",
"type": "string"
},
"plugin_id": {
"description": "Exact installed or marketplace plugin ID.",
"title": "Plugin Id",
"type": "string"
}
},
"required": [
"plugin_id",
"instance_id"
],
"type": "object"
}
},
"required": [
"operation_id",
"path_params"
],
"title": "plugin.default_target.clear",
"type": "object"
},
{
"additionalProperties": false,
"description": "Set one plugin instance as the plugin's default call target, automatically clearing any previous default. Method: PUT. Path: /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target. Effect: reversible_write.",
"properties": {
"operation_id": {
"const": "plugin.default_target.set",
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
"type": "string"
},
"path_params": {
"additionalProperties": false,
"description": "Resource identity placeholders for plugin.default_target.set. Set one plugin instance as the plugin's default call target, automatically clearing any previous default. Use only the named fields below.",
"properties": {
"instance_id": {
"description": "Exact plugin instance ID returned by plugin.loglevel.get.",
"title": "Instance Id",
"type": "string"
},
"plugin_id": {
"description": "Exact installed or marketplace plugin ID.",
"title": "Plugin Id",
"type": "string"
}
},
"required": [
"plugin_id",
"instance_id"
],
"type": "object"
}
},
"required": [
"operation_id",
"path_params"
],
"title": "plugin.default_target.set",
"type": "object"
},
{
"additionalProperties": false,
"description": "Create one named plugin folder. Method: POST. Path: /api/v1/plugin/folders/{folder_name}. Effect: reversible_write.",
@@ -10237,6 +10328,43 @@
"total_count_field": "collection.total_count"
}
},
{
"additionalProperties": false,
"description": "Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable. Method: POST. Path: /api/v1/plugin/instance/{instance_id}/enabled. Effect: reversible_write.",
"properties": {
"body": {
"$ref": "#/$defs/PluginInstanceEnabledRequest",
"description": "Request value for plugin.instance.set_enabled. Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable. Use the exact type and fields below."
},
"operation_id": {
"const": "plugin.instance.set_enabled",
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
"type": "string"
},
"path_params": {
"additionalProperties": false,
"description": "Resource identity placeholders for plugin.instance.set_enabled. Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable. Use only the named fields below.",
"properties": {
"instance_id": {
"description": "Exact plugin instance ID returned by plugin.loglevel.get.",
"title": "Instance Id",
"type": "string"
}
},
"required": [
"instance_id"
],
"type": "object"
}
},
"required": [
"operation_id",
"path_params",
"body"
],
"title": "plugin.instance.set_enabled",
"type": "object"
},
{
"additionalProperties": false,
"description": "Clear one plugin instance's log-level override so it immediately follows the global log level again. Method: DELETE. Path: /api/v1/plugin/loglevel/{plugin_id}/{instance_id}. Effect: reversible_write.",
@@ -16087,6 +16215,8 @@
"plugin.config.get",
"plugin.config.update",
"plugin.data",
"plugin.default_target.clear",
"plugin.default_target.set",
"plugin.folder.create",
"plugin.folder.delete",
"plugin.folder.plugin.assign",
@@ -16098,6 +16228,7 @@
"plugin.history",
"plugin.install",
"plugin.installed",
"plugin.instance.set_enabled",
"plugin.loglevel.clear",
"plugin.loglevel.get",
"plugin.loglevel.set",

View File

@@ -965,7 +965,11 @@ def uninstall_plugin(plugin_id: str, _: ApiPrincipal = Depends(get_current_activ
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
plugin_manager.delete_plugin_instance(plugin_id)
elif getattr(plugin_class, "is_clone", False):
else:
# 本体的装载判据在实例表的启用位上:不停用这一行,卸载后重启仍会
# 按已删除的包去加载它。业务参数保留,重装后用户的配置应当还在
plugin_manager.disable_plugin_host(plugin_id)
if not virtual_instance and getattr(plugin_class, "is_clone", False):
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
# 分身物理目录只能由包文件 owner 删除。

View File

@@ -0,0 +1,103 @@
"""插件实例的启停与默认调用目标接口。"""
from typing import Any
from fastapi import Depends, HTTPException
from sqlalchemy.exc import IntegrityError
from app.api.dependencies.auth import get_current_active_superuser
from app.api.principal import ApiPrincipal
from app.api.response import ResponseAPIRouter
from app.application.plugin.runtime import get_plugin_manager
from app.schemas.exception import PluginMutationRejectedError
from app.schemas.plugin import (
PluginInstanceEnabledRequest as _SchemaPluginInstanceEnabledRequest,
)
from app.schemas.response import Response as _SchemaResponse
router = ResponseAPIRouter()
@router.post( # type: ignore[misc]
"/instance/{instance_id}/enabled",
summary="启用或停用插件实例",
response_model=_SchemaResponse[None],
)
def set_plugin_instance_enabled(
instance_id: str,
request: _SchemaPluginInstanceEnabledRequest,
_: ApiPrincipal = Depends(get_current_active_superuser),
) -> Any:
"""
启用或停用一个实例,本体与分身共用这一个入口
停用只把启用位置假:业务参数与展示信息原样留在那一行,再次启用即恢复,因而它
与彻底清理是两件事——删行才会把配置一并抹掉。
"""
plugin_manager = get_plugin_manager()
try:
changed = plugin_manager.set_plugin_instance_enabled(instance_id, request.enabled)
except PluginMutationRejectedError as error:
return _SchemaResponse(success=False, message=str(error))
if not changed:
# 两条完整句子而不是拼接中文片段:片段会被当成占位值,翻译时落不到模式上
return _SchemaResponse(
success=False,
message=(
f"实例 {instance_id} 不存在或已处于启用状态"
if request.enabled
else f"实例 {instance_id} 不存在或已处于停用状态"
),
)
return _SchemaResponse(success=True)
@router.put( # type: ignore[misc]
"/instances/{plugin_id}/{instance_id}/default_target",
summary="设置插件实例的默认调用目标",
response_model=_SchemaResponse[None],
)
def set_plugin_instance_default_target(
plugin_id: str,
instance_id: str,
_: ApiPrincipal = Depends(get_current_active_superuser),
) -> Any:
"""
设置指定插件实例为默认调用目标,并自动清除同插件的旧默认
"""
try:
matched = get_plugin_manager().set_plugin_instance_default_target(
plugin_id, instance_id
)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
except IntegrityError as error:
# 「同一源插件至多一个默认目标」由条件唯一索引在库层强制。并发把不同实例
# 各自设为默认时,后提交的那个会撞上索引;这是可重试的竞争,不是服务端故障。
raise HTTPException(
status_code=409,
detail="默认调用目标正被并发修改,请重试",
) from error
if not matched:
raise HTTPException(status_code=404, detail=f"插件实例 {instance_id} 不存在")
return _SchemaResponse(success=True)
@router.delete( # type: ignore[misc]
"/instances/{plugin_id}/{instance_id}/default_target",
summary="清除插件实例的默认调用目标",
response_model=_SchemaResponse[None],
)
def clear_plugin_instance_default_target(
plugin_id: str,
instance_id: str,
_: ApiPrincipal = Depends(get_current_active_superuser),
) -> Any:
"""
清除指定插件实例的默认调用目标置位,仅当当前置位的正是该实例时才动作,重复调用保持幂等
"""
try:
get_plugin_manager().clear_plugin_instance_default_target(plugin_id, instance_id)
except LookupError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return _SchemaResponse(success=True)

View File

@@ -26,6 +26,7 @@ from app.api.endpoints import (
openai,
plugin,
pluginloglevel,
plugintarget,
recommend,
rule,
search,
@@ -75,6 +76,7 @@ API_V1_ROUTER_SPECS: tuple[RouterSpec, ...] = (
RouterSpec(llm.router, "/llm", ("llm",)),
RouterSpec(plugin.router, "/plugin", ("plugin",)),
RouterSpec(pluginloglevel.router, "/plugin", ("plugin",)),
RouterSpec(plugintarget.router, "/plugin", ("plugin",)),
RouterSpec(download.router, "/download", ("download",)),
RouterSpec(dashboard.router, "/dashboard", ("dashboard",)),
RouterSpec(storage.router, "/storage", ("storage",)),

View File

@@ -34,6 +34,7 @@ PluginRegistrationRefresher = Callable[[str], Awaitable[object]]
PluginMutationAdmission = Callable[[str], ContextManager[None]]
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
RestartRequiredRecorder = Callable[[str, tuple[str, ...]], None]
LoadableMarker = Callable[[str], None]
T = TypeVar("T")
@@ -162,6 +163,7 @@ class PluginInstallCommand:
*,
persistence: PluginPersistenceService,
installed_plugins_reader: InstalledPluginsReader,
loadable_marker: LoadableMarker,
plugin_ids_provider: PluginIdsProvider,
packages: PluginPackageTransactionPort,
install_reporter: InstallReporter,
@@ -177,6 +179,7 @@ class PluginInstallCommand:
"""保存单一安装事务所需的窄端口。"""
self.__persistence = persistence
self.__installed_plugins_reader = installed_plugins_reader
self.__loadable_marker = loadable_marker
self.__plugin_ids_provider = plugin_ids_provider
self.__packages = packages
self.__install_reporter = install_reporter
@@ -391,6 +394,8 @@ class PluginInstallCommand:
identity_target=state.target_identity,
)
)
# 装载判据在实例表启用位上,不登记则插件装完当次能跑、重启即消失
self.__loadable_marker(plugin_id)
state.stage = "persistent_backup_stage"
await self.__await_side_effect(

View File

@@ -339,7 +339,11 @@ async def uninstall_plugin_runtime(plugin_id: str) -> dict[str, Any]:
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
plugin_manager.delete_plugin_instance(plugin_id)
elif was_clone:
else:
# 本体的装载判据在实例表的启用位上:不停用这一行,卸载后重启仍会
# 按已删除的包去加载它。业务参数保留,重装后用户的配置应当还在
plugin_manager.disable_plugin_host(plugin_id)
if not virtual_instance and was_clone:
plugin_manager.delete_plugin_config(plugin_id, force=True)
plugin_manager.delete_plugin_data(plugin_id, force=True)
try:

View File

@@ -4,7 +4,16 @@ from __future__ import annotations
from typing import Any, List, Optional, Self, cast
from sqlalchemy import JSON, Index, String, UniqueConstraint, select
from sqlalchemy import (
JSON,
Boolean,
Index,
String,
UniqueConstraint,
column,
false,
select,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, Session, mapped_column
@@ -31,6 +40,20 @@ class PluginInstance(Base):
``log_expires_at`` 为空表示覆盖不过期。过期判定在读取时惰性执行,实现见
``app.runtime.log``,库里只存原样设置,不存已折算的结果。
``is_enabled`` 是「这份配置是否应当被实例化并启动」的唯一判据,也是卸载与恢复的
开关:置假即卸载,配置与展示信息原样留在这一行等待再次启用,因而不需要另设一个
卸载时间列去表达同一件事。行被删掉才是彻底清理。这一列的价值就是把「在册」与
「有配置」拆开——此前两者绑死,想保住配置就只能留着一个在跑的实例。
它与该实例此刻是否在运行无关——运行态由运行时持有、不落盘;也与插件自身在
``config_data`` 里按场景判定的业务开关无关。
``is_default_target`` 标记该实例是否为所属源插件的默认调用目标,即外部调用只给
插件 ID、没给实例 ID 时应当选中的那一行;与该实例是本体还是分身无关,两者都可能
被选为默认调用目标。「同一源插件至多一个默认调用目标」这条不变量由
``ux_plugininstance_default_target`` 条件唯一索引在数据库层强制,只索引置位的行,
不靠应用层纪律:置位要先清旧再置新,两条 DML 之间的窗口只有库层约束拦得住。
表名由 ``Base`` 按类名自动派生为小写 ``plugininstance``。
"""
@@ -40,6 +63,14 @@ class PluginInstance(Base):
plugin_name: Mapped[Optional[str]] = mapped_column(String(255))
plugin_desc: Mapped[Optional[str]] = mapped_column(String(255))
plugin_icon: Mapped[Optional[str]] = mapped_column(String(255))
# server_default 与迁移 DDL 保持一致:只写 Python 端 default 时 create_all 建出的
# 表不带 DEFAULT与迁移建出的表结构不同alembic 会一直报出差异
is_default_target: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
is_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=false()
)
log_level: Mapped[Optional[str]] = mapped_column(String(16))
log_expires_at: Mapped[Optional[str]] = mapped_column(String(40))
config_data: Mapped[Optional[Any]] = mapped_column(JSON)
@@ -49,6 +80,16 @@ class PluginInstance(Base):
__table_args__ = (
UniqueConstraint("instance_id", name="uq_plugininstance_instance_id"),
Index("ix_plugininstance_source_plugin_id", "source_plugin_id"),
# 条件谓词按方言各给一份:布尔列与 True 比较时 SQLite 编译为 ``IS 1``、
# PostgreSQL 编译为 ``IS true``。谓词整个丢失会退化成「每个源插件只能有一行
# 实例」,把插件分身整个锁死,因此不能只给一份共用
Index(
"ux_plugininstance_default_target",
"source_plugin_id",
unique=True,
sqlite_where=column("is_default_target", Boolean).is_(True),
postgresql_where=column("is_default_target", Boolean).is_(True),
),
)
@property
@@ -63,10 +104,15 @@ class PluginInstance(Base):
本体行会在插件首次存配置时被隐式建出,配置被删掉后若各列皆空就只剩身份列,
留着会让「有哪些插件登记过本体设置」的枚举逐步失真,因而可以回收。分身行不
适用:分身的存在本身就由这一行表达,清空设置不等于删除分身。
启用位必须计入:它是本体的装载判据,一个启用中的本体行清空配置后若被当成
空行回收掉,该插件下次启动就再也不会被加载。
"""
return not any(
(
self.config_data is not None,
self.is_default_target,
self.is_enabled,
self.log_level,
self.log_expires_at,
self.plugin_name,

View File

@@ -6,7 +6,7 @@ import copy
from datetime import datetime, timezone
from typing import Any, Optional, Union
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -90,6 +90,16 @@ class PluginInstanceOper(DbOper):
)
)
def list_enabled(self) -> list[PluginInstance]:
"""列举应当被实例化并启动的实例,供运行期批量装载使用。"""
return list(
self._execute_sync_query(
lambda session: session.execute(
select(PluginInstance).where(PluginInstance.is_enabled.is_(True))
).scalars().all()
)
)
def save(self, **fields: Any) -> PluginInstance:
"""按 ``instance_id`` 新增或更新一行,只写入本次给出的列。
@@ -144,6 +154,93 @@ class PluginInstanceOper(DbOper):
return bool(self._execute_sync_write(stage))
def set_enabled(self, *, instance_id: str, is_enabled: bool) -> bool:
"""写入启用位,它同时就是卸载与恢复的开关。
置假即卸载:业务参数与展示信息原样留在这一行等待再次启用,因而不需要另设
一个卸载时间列。同时清掉两项只对在册实例才有意义的状态——默认调用目标置位,
否则未指定实例的外部调用会被路由到一个不会被实例化的实例;日志等级覆盖,
它带失效时间、本就是临时调试设置,而运行期在停用时会同步清掉进程内的等级
覆盖表,库里留着只会让两边不一致。
:param instance_id: 实例 ID
:param is_enabled: 目标启用状态
:return: 该行是否存在
"""
def stage(session: Session) -> bool:
"""在同一事务内写入启用位,停用时一并清掉仅对在册实例有意义的状态。"""
record = PluginInstance.get_by_instance_id(session, instance_id)
if record is None:
return False
record.is_enabled = is_enabled
if not is_enabled:
record.is_default_target = False
record.log_level = None
record.log_expires_at = None
record.updated_at = _now()
return True
return bool(self._execute_sync_write(stage))
def set_default_target(self, source_plugin_id: str, instance_id: str) -> bool:
"""原子地把某源插件的默认调用目标改为指定实例,同一事务内清旧置新。
目标行须已经落盘:这里只按 ``instance_id`` 与 ``source_plugin_id`` 双重匹配
定位目标行,不做隐式创建;命中失败原样返回,不动同插件原有的置位。命中时
先清后置,两条 DML 处在同一 session、同一事务内提交中途不会出现两行同时
为真;并发写入下的唯一性最终由表上的条件唯一索引兜底。
:param source_plugin_id: 源插件 ID
:param instance_id: 要设为默认调用目标的实例 ID
:return: 目标行存在并已置位为 True目标行不存在时为 False
"""
def stage(session: Session) -> bool:
"""在同一事务内定位目标行、清除同插件其余置位、置位目标行。"""
target = session.execute(
select(PluginInstance).where(
PluginInstance.instance_id == instance_id,
PluginInstance.source_plugin_id == source_plugin_id,
)
).scalars().first()
if target is None:
return False
session.execute(
update(PluginInstance)
.where(
PluginInstance.source_plugin_id == source_plugin_id,
PluginInstance.instance_id != instance_id,
PluginInstance.is_default_target.is_(True),
)
.values(is_default_target=False)
)
target.is_default_target = True
target.updated_at = _now()
return True
return bool(self._execute_sync_write(stage))
def clear_default_target(self, source_plugin_id: str) -> None:
"""清除某源插件的默认调用目标置位,重复调用保持幂等。
置位被清掉后该行若已只剩身份列则不在这里回收:本体行的回收统一由清空业务
参数与清除日志等级两处判定,多一处入口只会让「行何时消失」变得难以预期。
"""
def stage(session: Session) -> None:
"""在同一事务内清除该源插件全部置位的行。"""
session.execute(
update(PluginInstance)
.where(
PluginInstance.source_plugin_id == source_plugin_id,
PluginInstance.is_default_target.is_(True),
)
.values(is_default_target=False, updated_at=_now())
)
self._execute_sync_write(stage)
def get_config_data(self, instance_id: str) -> Any:
"""读取某实例的业务参数;该行不存在或从未存过参数都返回 None。

View File

@@ -279,6 +279,7 @@
"验证失败": "Verification failed",
"没有传入仓库地址,无法正确安装插件,请检查配置": "No repository URL was provided, so the plugin cannot be installed. Please check the configuration",
"插件分身创建成功": "Plugin clone created successfully",
"默认调用目标正被并发修改,请重试": "The default call target is being modified concurrently, please retry",
"未识别到豆瓣媒体信息": "Unable to recognize Douban media information",
"未识别到TMDB媒体信息": "Unable to recognize TMDB media information",
"未知的媒体ID": "Unknown media ID",

View File

@@ -271,6 +271,7 @@
"验证失败": "驗證失敗",
"没有传入仓库地址,无法正确安装插件,请检查配置": "未傳入倉庫位址,無法正確安裝插件,請檢查設定",
"插件分身创建成功": "插件分身建立成功",
"默认调用目标正被并发修改,请重试": "預設呼叫目標正被並行修改,請重試",
"未识别到豆瓣媒体信息": "未識別到豆瓣媒體資訊",
"未识别到TMDB媒体信息": "未識別到 TMDB 媒體資訊",
"未知的媒体ID": "未知的媒體 ID",

View File

@@ -35,6 +35,7 @@ class PluginCatalogFacade:
plugin_attr: Callable[[str, str], Any],
plugin_instance: Callable[[str], Optional[PluginInstance]],
plugin_instances: Callable[[], dict[str, PluginInstance]],
host_instances: Callable[[], dict[str, PluginInstance]],
runtime_status: Callable[[str], Optional[PluginRuntimeStatus]],
log: Any,
) -> None:
@@ -51,6 +52,7 @@ class PluginCatalogFacade:
self._plugin_attr = plugin_attr
self._plugin_instance = plugin_instance
self._plugin_instances = plugin_instances
self._host_instances = host_instances
self._runtime_status = runtime_status
self._logger = log
@@ -74,6 +76,8 @@ class PluginCatalogFacade:
def local(self) -> list[Plugin]:
"""把已加载插件投影为本地插件目录 DTO。"""
installed = self._installed_ids()
# 本体行一次取全:逐张卡片各查一次会让插件列表的查询次数随插件数线性增长
host_instances = self._host_instances()
plugins: list[Plugin] = []
for plugin_id, plugin_class in self._classes().items():
plugin_instance = self._running().get(plugin_id)
@@ -97,7 +101,7 @@ class PluginCatalogFacade:
source_plugin_id=getattr(plugin_class, "plugin_source_id", None),
is_instance=instance is not None,
instance_mode=instance.mode if instance else None,
**self._instance_overlay(plugin_id),
**self._instance_overlay(plugin_id, instance or host_instances.get(plugin_id)),
)
if not self._auth_checker(plugin=plugin, source=plugin_class):
continue
@@ -113,6 +117,7 @@ class PluginCatalogFacade:
for plugin in self.local()
if plugin.installed and plugin.id
}
host_instances = self._host_instances()
result = []
for plugin_id in installed_ids:
plugin = local_by_id.get(plugin_id)
@@ -132,25 +137,37 @@ class PluginCatalogFacade:
),
is_instance=instance is not None,
instance_mode=instance.mode if instance else None,
**self._instance_overlay(plugin_id),
**self._instance_overlay(plugin_id, instance or host_instances.get(plugin_id)),
))
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
# 前端可用用户级 PluginOrder 覆盖plugin_order 只用于运行期插件发现顺序。
return result
@staticmethod
def _instance_overlay(instance_id: str) -> dict[str, Any]:
"""把该实例当前的日志等级覆盖投影为卡片列表的只读叠加字段。
def _instance_overlay(
instance_id: str,
record: Optional[PluginInstance],
) -> dict[str, Any]:
"""把该实例的默认目标置位与日志等级覆盖投影为卡片列表的只读叠加字段。
直接按实例 ID 查进程内覆盖缓存,不回表:本体与分身在覆盖表里共用同一个
命名空间,而卡片的 ``plugin_id`` 本身就是运行实例的 ID本体等于插件 ID、
分身等于分身实例 ID再去查一次实例行只会为同一个键多走一趟数据库。
日志等级直接按实例 ID 查进程内覆盖缓存,不回表:本体与分身在覆盖表里共用
同一个命名空间,而卡片的 ``plugin_id`` 本身就是运行实例的 ID本体等于插件
ID、分身等于分身实例 ID再去查一次实例行只会为同一个键多走一趟数据库。
默认目标置位与启用位是落盘状态、进程内没有副本,因而由调用方把已经取到的
实例行(分身来自遍历,本体来自批量取到的字典)传进来,同样不额外发查询;
没有对应行时按未置位、未启用处理——没有这一行就意味着它不会被装载,也不可能
是默认调用目标。
:param instance_id: 运行实例 ID
:param record: 该实例已在内存中的实例行,没有登记过时为 None
:return: 可直接展开进 ``Plugin(...)`` 构造参数的字段字典
"""
override = get_plugin_instance_log_level_override(instance_id)
return {"log_level_effective": override[0] if override is not None else None}
return {
"is_default_target": record.is_default_target if record else False,
"is_enabled": record.is_enabled if record else False,
"log_level_effective": override[0] if override is not None else None,
}
def local_version(self, plugin_id: str) -> Optional[str]:
"""读取指定已安装插件版本,不触发全量目录投影。"""

View File

@@ -66,6 +66,8 @@ class PluginCloneService:
plugin_name=name or None,
plugin_desc=description or None,
plugin_icon=icon or None,
# 新建的分身就是要拿去跑的;启用位是装载判据,留空会让它建出来却不加载
is_enabled=True,
)
self._save_instance(instance)

View File

@@ -35,12 +35,14 @@ class PluginDependencyService:
*,
system: Callable[[], PluginSystemServices],
instances: Optional[Callable[[], dict[str, PluginInstance]]] = None,
loadable_hosts: Optional[Callable[[], set[str]]] = None,
registry: Optional[PluginRegistry] = None,
log: Any,
) -> None:
"""保存插件系统、虚拟实例和运行状态端口。"""
"""保存插件系统、应装载实例、应装载本体和运行状态端口。"""
self._system = system
self._instances = instances or (lambda: {})
self._loadable_hosts = loadable_hosts
self._registry = registry
self._logger = log
@@ -95,10 +97,25 @@ class PluginDependencyService:
return self._complete_missing_install(missing, success, started_at)
def classify_plugins(self) -> PluginDependencyClassification:
"""分类物理插件,并把源码结论映射到全部虚拟实例。"""
"""分类应当装载的物理插件,并把源码结论映射到应当装载的虚拟实例。
物理插件那一层按安装清单划分,而安装清单只回答「包在不在磁盘上」;分类结果
随后会被逐个 ``start()``,因此这里必须先按启用位过滤掉停用的本体,否则停用
的插件会在开机与配置热重载时被重新拉起来,启用位形同虚设。三个桶一起过滤:
停用的插件既不该被装载,也不该为它安装缺失依赖。
"""
ready, missing_dependencies, missing_source = (
self._system().dependency.classify_plugins()
)
loadable = self._loadable_hosts() if self._loadable_hosts is not None else None
if loadable is not None:
ready = [plugin_id for plugin_id in ready if plugin_id in loadable]
missing_dependencies = [
plugin_id for plugin_id in missing_dependencies if plugin_id in loadable
]
missing_source = [
plugin_id for plugin_id in missing_source if plugin_id in loadable
]
ready = list(ready)
missing_dependencies = list(missing_dependencies)
missing_source = list(missing_source)

View File

@@ -63,7 +63,7 @@ class PluginLifecycle:
classes: dict[str, Any],
running: dict[str, Any],
load_plugins: Callable[[Optional[str], list[str], Callable[[Any], bool]], list[Any]],
installed_plugins: Callable[[], list[str]],
loadable_plugins: Callable[[], list[str]],
plugin_config: Callable[[str], dict],
auth_checker: Callable[[Any], bool],
clear_modules: Callable[[Optional[str]], Any],
@@ -81,7 +81,7 @@ class PluginLifecycle:
self._classes = classes
self._running = running
self._load_plugins = load_plugins
self._installed_plugins = installed_plugins
self._loadable_plugins = loadable_plugins
self._plugin_config = plugin_config
self._auth_checker = auth_checker
self._clear_modules = clear_modules
@@ -107,7 +107,7 @@ class PluginLifecycle:
plugin_id: Optional[str] = None,
) -> dict[str, PluginRuntimeStatus]:
"""加载并初始化插件,返回每个目标的明确运行结果。"""
installed_plugins = self._installed_plugins()
loadable_plugins = self._loadable_plugins()
results: dict[str, PluginRuntimeStatus] = {}
if plugin_id:
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
@@ -116,7 +116,7 @@ class PluginLifecycle:
"""判断模块是否具备宿主插件最小生命周期钩子。"""
return hasattr(module, "init_plugin") and hasattr(module, "plugin_name")
plugins = self._load_plugins(plugin_id, installed_plugins, check_module)
plugins = self._load_plugins(plugin_id, loadable_plugins, check_module)
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
for plugin in plugins:
current_id = plugin.__name__

View File

@@ -221,6 +221,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
self._plugin_sync = self._plugin_runtime.sync
self._plugin_clone = self._plugin_runtime.clone
self._plugin_log_level = self._plugin_runtime.log_level
self._plugin_default_target = self._plugin_runtime.default_target
self._plugin_classification = self._plugin_runtime.classification
# 事件总线只通过通用解析器访问运行中的插件实例。
eventmanager.register_handler_instance_resolver(
@@ -1298,6 +1299,90 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
self._plugin_log_level.clear_level(plugin_id, instance_id)
def mark_plugin_loadable(self, plugin_id: str) -> None:
"""
把源插件本体登记为应当装载,用于安装收尾
本体的装载判据在实例表的启用位上,安装清单只回答「包在不在磁盘上」。安装
完成时不登记这一行,插件靠定向重载当次能跑起来,重启后却不会再被加载。
:param plugin_id: 插件ID
"""
self._plugin_instance_store.enable_host(plugin_id)
def disable_plugin_host(self, plugin_id: str) -> bool:
"""
卸载收尾:停用源插件本体但保留其业务参数
业务参数是用户的数据,重装同名插件后应当还在;默认目标置位与日志等级覆盖
只对在册实例有意义,随停用一并清除,不会被下一次重装静默继承。
:param plugin_id: 插件ID
:return: 停用前它是否存在且处于启用状态
"""
changed = self._plugin_instance_store.disable_host(plugin_id)
clear_instance_log_level_override(plugin_id)
return changed
def set_plugin_instance_enabled(self, instance_id: str, enabled: bool) -> bool:
"""
启用或停用一个实例,本体与分身走同一个入口
启用位是「这份配置是否应当被实例化并启动」的唯一判据,本体与分身因而可以
共用一个开关调用方只给实例ID由本方法按它是分身还是本体分发。
停用不删任何设置:业务参数与展示信息原样留在那一行,再次启用即恢复;删行
才是彻底清理,不从这里走。
:param instance_id: 实例ID等于插件ID时表示本体自身
:param enabled: 目标启用状态
:return: 该实例存在且状态确实发生了变化
:raise PluginMutationRejectedError: 当前处于停机准入窗口
"""
store = self._plugin_instance_store
is_clone = store.get(instance_id) is not None
with self.mutation(f"{'启用' if enabled else '停用'}插件实例 {instance_id}"):
if is_clone:
changed = store.enable(instance_id) if enabled else store.disable(instance_id)
elif enabled:
changed = store.enable_host(instance_id)
else:
changed = store.disable_host(instance_id)
if not changed:
return False
if enabled:
self.reload_plugin(instance_id)
else:
# 停用要把运行态一并摘掉,否则这一轮进程里它还在跑,重启才真的停下来
self.remove_plugin(instance_id)
clear_instance_log_level_override(instance_id)
return True
def resolve_plugin_call_target(self, plugin_id: str) -> str:
"""
确定按插件ID发起、未指定实例的调用应当落到哪个实例
:param plugin_id: 插件ID也可以是调用方已经明确知道的具体实例ID
:return: 应当使用的实例ID
:raise LookupError: 该插件已有分身但未设置默认调用目标,或默认调用目标已停用
"""
return self._plugin_default_target.resolve(plugin_id)
def set_plugin_instance_default_target(self, plugin_id: str, instance_id: str) -> bool:
"""
设置指定插件实例为默认调用目标,并清除同插件的旧默认
:param plugin_id: 插件ID
:param instance_id: 实例ID
:return: 目标实例存在时为True指定的非本体实例不归属该插件时为False
:raise LookupError: 插件不存在,或 plugin_id 实为某个分身自身的实例ID
"""
return self._plugin_default_target.set_target(plugin_id, instance_id)
def clear_plugin_instance_default_target(self, plugin_id: str, instance_id: str) -> None:
"""
清除指定插件实例的默认调用目标置位,仅当当前置位的正是该实例时才动作
:param plugin_id: 插件ID
:param instance_id: 实例ID
:raise LookupError: 插件不存在,或 plugin_id 实为某个分身自身的实例ID
"""
self._plugin_default_target.clear_target(plugin_id, instance_id)
def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str,
name: str, description: str, version: str = None,
icon: str = None) -> Tuple[bool, str]:

View File

@@ -37,6 +37,7 @@ from app.runtime.extensions.plugin.sync import (
PluginSyncService,
)
from app.runtime.extensions.plugin.system import PluginSystemServices
from app.runtime.extensions.plugin.target import PluginDefaultTargetControl
from app.runtime.extensions.plugin.tools import PluginToolCatalog
from app.schemas.types import SystemConfigKey
@@ -101,6 +102,10 @@ class PluginRuntimeEnvironment:
remote_entry: PluginRemoteEntryBuilder
development: Callable[[], bool]
logger: Any
# 默认调用目标的置位与清除必须在库层一个事务内清旧置新,因而由组合根直接给出
# 原子写入端口,不经过按实例逐行读写的实例表端口
set_default_target: Callable[[str, str], bool]
clear_default_target: Callable[[str], None]
@dataclass(frozen=True, slots=True)
@@ -124,6 +129,7 @@ class PluginRuntime:
sync: PluginSyncService
clone: PluginCloneService
log_level: PluginLogLevelControl
default_target: PluginDefaultTargetControl
projection: PluginProjection
classification: PluginClassificationRegistry
recent_local_sync: dict[str, float]
@@ -173,7 +179,7 @@ def build_plugin_runtime(
def load_plugins(
plugin_id: Optional[str],
installed_plugins: list[str],
loadable_plugins: list[str],
validator: Callable[[Any], bool],
) -> list[Any]:
"""加载物理插件或虚拟实例,并保持持久化实例顺序。"""
@@ -181,9 +187,10 @@ def build_plugin_runtime(
instance = instances.get(plugin_id)
if instance:
return loader.load_instance(instance, validator)
return loader.load(plugin_id, installed_plugins, validator)
plugins = loader.load(None, installed_plugins, validator)
for instance in instances.all().values():
return loader.load(plugin_id, loadable_plugins, validator)
plugins = loader.load(None, loadable_plugins, validator)
# 只装载启用的配置:停用的分身仍登记在册、卡片可见,但不该被实例化
for instance in instances.enabled().values():
plugins.extend(loader.load_instance(instance, validator))
return plugins
@@ -191,9 +198,9 @@ def build_plugin_runtime(
classes=registry.classes,
running=registry.running,
load_plugins=load_plugins,
installed_plugins=lambda: environment.storage().read(
SystemConfigKey.UserInstalledPlugins
) or [],
# 本体的装载判据归口到实例表的启用位;安装清单只回答「包在不在磁盘上」,
# 它同时兼任运行开关时,「装着但先不跑」根本没有地方可以表达
loadable_plugins=lambda: list(instances.enabled_hosts()),
plugin_config=configs.read,
auth_checker=lambda plugin: access.check(plugin),
clear_modules=loader.clear_modules,
@@ -255,6 +262,7 @@ def build_plugin_runtime(
),
plugin_instance=instances.get,
plugin_instances=instances.all,
host_instances=instances.all_hosts,
runtime_status=registry.runtime_status,
log=environment.logger,
)
@@ -277,7 +285,9 @@ def build_plugin_runtime(
)
dependencies = PluginDependencyService(
system=environment.system,
instances=instances.all,
# 分类结果会被逐个 start(),因此两层都只能给出应当装载的那一部分
instances=instances.enabled,
loadable_hosts=lambda: set(instances.enabled_hosts()),
registry=registry,
log=environment.logger,
)
@@ -331,6 +341,16 @@ def build_plugin_runtime(
read_log_level=configs.read_log_level,
write_log_level=configs.write_log_level,
)
default_target = PluginDefaultTargetControl(
plugin_exists=lambda plugin_id: registry.plugin_class(plugin_id) is not None,
get_instance=instances.get,
instances_for_source=instances.for_source,
get_host_instance=instances.get_host,
save_host_instance=instances.save_host,
running=lambda: registry.running,
set_default_target=environment.set_default_target,
clear_default_target=environment.clear_default_target,
)
projection = PluginProjection(
registry.running,
environment.logger,
@@ -357,6 +377,7 @@ def build_plugin_runtime(
sync=sync,
clone=clone,
log_level=log_level,
default_target=default_target,
projection=projection,
classification=classification,
recent_local_sync=recent_local_sync,

View File

@@ -250,6 +250,7 @@ InstanceLister = Callable[[], "list[PluginInstance]"]
InstanceSourceLister = Callable[[str], "list[PluginInstance]"]
InstanceWriter = Callable[["PluginInstance"], None]
InstanceDeleter = Callable[[str], bool]
InstanceEnabler = Callable[[str, bool], bool]
def _empty_instance_get(_instance_id: str) -> PluginInstance | None:
@@ -276,6 +277,11 @@ def _ignore_instance_delete(_instance_id: str) -> bool:
return False
def _ignore_instance_enable(_instance_id: str, _is_enabled: bool) -> bool:
"""组合根尚未装配时报告启用位未写入。"""
return False
class PluginInstanceDirectory:
"""封装插件实例表的持久化能力。
@@ -283,6 +289,10 @@ class PluginInstanceDirectory:
``source_plugin_id`` 区分;本类不做角色过滤,角色隔离由调用方
``PluginInstanceStore``)负责,因为只有调用方知道当前服务的是分身清单还是
本体自身的那一行。
``list_all``/``get`` 一律返回全部登记行(含停用的),``list_enabled`` 才是运行期
装载的取数口:是否实例化由 ``is_enabled`` 单独表达,读取口不替调用方把停用的行
藏起来——藏起来会让卡片、卸载守卫和默认目标候选一并丢掉这些行。
"""
def __init__(
@@ -293,6 +303,8 @@ class PluginInstanceDirectory:
list_by_source: InstanceSourceLister = _empty_instance_list_by_source,
save: InstanceWriter = _ignore_instance_save,
delete: InstanceDeleter = _ignore_instance_delete,
list_enabled: InstanceLister = _empty_instance_list,
set_enabled: InstanceEnabler = _ignore_instance_enable,
) -> None:
"""保存由启动组合根提供的实例表读写函数。"""
self._get = get
@@ -300,6 +312,8 @@ class PluginInstanceDirectory:
self._list_by_source = list_by_source
self._save = save
self._delete = delete
self._list_enabled = list_enabled
self._set_enabled = set_enabled
def get(self, instance_id: str) -> PluginInstance | None:
"""按实例 ID 读取单条描述,不区分分身与本体。"""
@@ -321,6 +335,14 @@ class PluginInstanceDirectory:
"""按实例 ID 删除一行,连同其配置,返回删除前是否存在。"""
return self._delete(instance_id)
def list_enabled(self) -> list[PluginInstance]:
"""列出应当被实例化并启动的行,不含停用的,不区分分身与本体。"""
return self._list_enabled()
def set_enabled(self, instance_id: str, is_enabled: bool) -> bool:
"""写入启用位,返回该行是否存在。"""
return self._set_enabled(instance_id, is_enabled)
class _LegacyInstanceEntry(NamedTuple):
"""旧 systemconfig 单键里的一条实例描述,连同其原始载荷的内容指纹。"""
@@ -343,10 +365,16 @@ def _payload_fingerprint(payload: dict[str, Any]) -> str:
class PluginInstanceStore:
"""管理共享源码的分身实例描述,并把源插件本体自身的那一行隔离在视图之外
"""管理共享源码的分身实例描述,并把源插件本体自身的那一行单独成一组读写口
两类记录同存一张表,靠 ``instance_id`` 是否等于 ``source_plugin_id`` 区分:
本类只服务分身,本体行(它承载插件自身的业务参数)读不到也改不到。
``all()``/``get()``/``save()``/``delete()``/``for_source()``/``enabled()`` 只服务分身,
``all_hosts()``/``get_host()``/``save_host()``/``enabled_hosts()`` 只服务本体,任何
一侧都读不到、也改不到对方的记录——只有调用方知道自己要的是分身清单还是本体那一行。
除 ``enabled()``/``enabled_hosts()`` 外的读取口一律返回全部登记行(含停用的):
停用的实例仍是一份在册配置,卡片要看得见、卸载守卫要拦得住、默认调用目标的候选
清单也要列得出。是否应当被实例化单由 ``is_enabled`` 表达,运行期取数走前两者。
"""
def __init__(
@@ -465,6 +493,116 @@ class PluginInstanceStore:
return False
return self._directory().delete(record.instance_id)
def enabled(self) -> dict[str, PluginInstance]:
"""读取应当被实例化并启动的分身实例,不含停用的与本体记录。"""
self._ensure_bootstrapped()
return {
record.instance_id: record
for record in self._directory().list_enabled()
if not record.is_host
}
def enable(self, instance_id: str) -> bool:
"""启用指定分身实例,返回启用前它是否存在且处于停用状态。"""
self._ensure_bootstrapped()
record = self.get(instance_id)
if record is None or record.is_enabled:
return False
return self._directory().set_enabled(record.instance_id, True)
def disable(self, instance_id: str) -> bool:
"""停用指定分身实例,返回停用前它是否存在且处于启用状态。
停用不删行:业务参数、展示信息原样留在那一行,再次启用即恢复。要连配置一并
抹掉走 :meth:`delete`。
"""
self._ensure_bootstrapped()
record = self.get(instance_id)
if record is None or not record.is_enabled:
return False
return self._directory().set_enabled(record.instance_id, False)
def all_hosts(self) -> dict[str, PluginInstance]:
"""一次性读取全部源插件本体记录,不含分身实例。
供目录投影批量取数使用:按插件 ID 遍历卡片时只做内存字典查找,不再逐张卡片
各查一次数据库。
"""
self._ensure_bootstrapped()
return {
record.instance_id: record
for record in self._directory().list_all()
if record.is_host
}
def enabled_hosts(self) -> dict[str, PluginInstance]:
"""读取应当被装载的源插件本体记录,不含停用的与分身实例。
本体与分身在这里终于用同一个判据:``is_enabled`` 决定一份配置是否应当被
实例化并启动。安装清单退回去只回答「这个插件的包在不在磁盘上」,不再兼任
运行开关——两者本是两件事,一处安装记录同时充当开关会让「装着但先不跑」
无从表达。
"""
self._ensure_bootstrapped()
return {
record.instance_id: record
for record in self._directory().list_enabled()
if record.is_host
}
def enable_host(self, plugin_id: str) -> bool:
"""把源插件本体登记为应当装载,没有本体记录时按默认视图建出。
安装收尾必须调用:本体的装载判据已经归口到启用位,只往安装清单里加一条而
不建出这一行,插件会装完却不加载。
:param plugin_id: 插件 ID
:return: 本次是否改变了状态;本体已处于启用状态时为 False
"""
self._ensure_bootstrapped()
host = self.get_host(plugin_id)
if host is None:
self.save_host(
PluginInstance(
instance_id=plugin_id,
source_plugin_id=plugin_id,
is_enabled=True,
)
)
return True
if host.is_enabled:
return False
return self._directory().set_enabled(host.instance_id, True)
def disable_host(self, plugin_id: str) -> bool:
"""停用源插件本体但保留其全部设置,返回停用前它是否存在且处于启用状态。
业务参数保留——那是用户的数据,重装或重新启用后应当还在;默认目标置位与
日志等级覆盖由启用位置假时一并清掉,它们只对在册实例有意义。
"""
self._ensure_bootstrapped()
host = self.get_host(plugin_id)
if host is None or not host.is_enabled:
return False
return self._directory().set_enabled(host.instance_id, False)
def get_host(self, plugin_id: str) -> PluginInstance | None:
"""读取源插件本体自身那一行;该插件从未登记过任何设置时为 None。"""
self._ensure_bootstrapped()
record = self._directory().get(plugin_id)
return record if record is not None and record.is_host else None
def save_host(self, instance: PluginInstance) -> None:
"""新增或更新源插件本体自身那一行,本体的 ``source_plugin_id`` 恒等于自身 ID。
归一而不是拒绝:本体行的一对身份列必然相等,调用方传进来的 ``source_plugin_id``
对本体而言没有可选值,就地纠正比让调用方各自记住这条约束更不容易出错。
"""
self._ensure_bootstrapped()
self._directory().save(
instance.model_copy(update={"source_plugin_id": instance.instance_id})
)
def for_source(self, source_plugin_id: str) -> list[PluginInstance]:
"""按持久化顺序返回引用同一源码插件的全部分身,不含本体自身那一行。"""
self._ensure_bootstrapped()

View File

@@ -0,0 +1,206 @@
"""插件默认调用目标裁决:未指定实例的调用选择实例,以及默认目标的置位与清除。"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Optional
from app.schemas.plugin import PluginInstance
GetInstance = Callable[[str], Optional[PluginInstance]]
InstancesForSource = Callable[[str], list[PluginInstance]]
PluginExists = Callable[[str], bool]
RunningInstances = Callable[[], Mapping[str, Any]]
AtomicSetDefaultTarget = Callable[[str, str], bool]
ClearDefaultTarget = Callable[[str], None]
@dataclass(frozen=True)
class PluginCallCandidate:
"""一个插件实例在默认调用目标裁决中的可见状态。
``is_default_target`` 是用户选定的默认调用目标,与该实例当前是否在运行
无关;``is_running`` 是该实例当前是否在运行,与调用目标的选定无关。
"""
instance_id: str
is_running: bool
is_default_target: bool
def _ordered(candidates: list[PluginCallCandidate]) -> list[PluginCallCandidate]:
"""把候选实例按实例 ID 升序排列,使报错文案稳定可预期。"""
return sorted(candidates, key=lambda candidate: candidate.instance_id)
def _describe(candidates: list[PluginCallCandidate]) -> str:
"""列出可供显式指定的实例名及其运行状态。
:param candidates: 候选实例集合
:return: 形如 ``PluginA已启用、PluginAx2已停用`` 的描述,候选为空时为「无」
"""
if not candidates:
return ""
return "".join(
f"{candidate.instance_id}{'已启用' if candidate.is_running else '已停用'}"
for candidate in _ordered(candidates)
)
class PluginDefaultTargetControl:
"""裁决插件未指定实例时的调用目标,并管理默认调用目标的置位与清除。
一个插件按配置扇出多个实例后,「调用没指定实例」只允许两种结局:走用户
选定且正在运行的默认调用目标,或者报错。绝不按登记顺序取第一个,也绝不
随机挑一个——那会让同一次调用在不同时刻落到不同实例,行为不可复现,用户
也无从知道刚才究竟是哪个实例执行的。同理,默认目标停用时也不静默改走另
一个正在运行的实例:那等于用户停用了一个实例、调用却被悄悄改道,且不留
任何痕迹。只有本体、没有任何分身的插件不受这套机制约束,直接使用本体,
不要求显式设置默认目标,单实例场景不应被打扰。
"""
def __init__(
self,
*,
plugin_exists: PluginExists,
get_instance: GetInstance,
instances_for_source: InstancesForSource,
get_host_instance: GetInstance,
save_host_instance: Callable[[PluginInstance], None],
running: RunningInstances,
set_default_target: AtomicSetDefaultTarget,
clear_default_target: ClearDefaultTarget,
) -> None:
"""保存本体与分身的实例持久化端口、运行态端口和默认目标置位的原子写入端口。"""
self._plugin_exists = plugin_exists
self._get_instance = get_instance
self._instances_for_source = instances_for_source
self._get_host_instance = get_host_instance
self._save_host_instance = save_host_instance
self._running = running
self._set_default_target = set_default_target
self._clear_default_target = clear_default_target
@staticmethod
def _default_host_instance(plugin_id: str) -> PluginInstance:
"""本体从未被显式登记过任何设置时的默认视图。"""
return PluginInstance(
instance_id=plugin_id,
source_plugin_id=plugin_id,
)
def _host_instance(self, plugin_id: str) -> PluginInstance:
"""读取源插件本体的实例描述,从未登记过时给出默认视图。"""
return self._get_host_instance(plugin_id) or self._default_host_instance(plugin_id)
def _candidates(self, plugin_id: str) -> list[PluginCallCandidate]:
"""组装插件全部实例(含本体)在调用目标裁决中的可见状态。"""
running = self._running()
instances = [self._host_instance(plugin_id), *self._instances_for_source(plugin_id)]
return [
PluginCallCandidate(
instance_id=instance.instance_id,
is_running=self._is_enabled(running.get(instance.instance_id)),
is_default_target=instance.is_default_target,
)
for instance in instances
]
@staticmethod
def _is_enabled(plugin: Any) -> bool:
"""判断实例是否既已加载又处于启用状态。
已加载不等于已启用:用户在界面上关掉启用开关只会重新 ``init_plugin``
实例仍然留在运行表里。只看运行表会把停用实例当成可用的调用目标选中,
随后在调用点以「插件不存在」收场;而这套机制的语义是停用就直接报错、
要求显式指定实例。取不到状态时按可用处理,不因判据缺失打断调用。
"""
if plugin is None:
return False
get_state = getattr(plugin, "get_state", None)
if not callable(get_state):
return True
try:
return bool(get_state())
except Exception: # noqa: BLE001 - 单个实例取状态失败不能影响整体裁决
return False
def resolve(self, plugin_id: str) -> str:
"""确定按插件 ID 发起、未指定实例的调用应当落到哪个实例。
插件从未被创建过分身时直接返回插件 ID 本身(即本体),不查默认目标
置位——这也覆盖了调用方直接传入某个分身自身实例 ID 的情形:分身的
实例 ID 不会作为任何插件的源插件 ID 拥有分身,因而同样原样返回。
已有分身时必须命中已设置且正在运行的默认调用目标才会被采用。
:param plugin_id: 插件 ID也可以是调用方已经明确知道的具体实例 ID
:return: 应当使用的实例 ID
:raise LookupError: 已有分身但未设置默认调用目标,或默认调用目标已停用
"""
if not self._instances_for_source(plugin_id):
return plugin_id
candidates = self._candidates(plugin_id)
default = next(
(candidate for candidate in candidates if candidate.is_default_target), None
)
if default is not None and default.is_running:
return default.instance_id
candidate_desc = _describe(candidates)
if default is not None:
raise LookupError(
f"插件 {plugin_id} 的默认实例 {default.instance_id} 已停用,"
f"调用必须显式指定实例;可选实例:{candidate_desc}"
)
raise LookupError(
f"插件 {plugin_id} 未设置默认实例,调用必须显式指定实例;可选实例:{candidate_desc}"
)
def set_target(self, plugin_id: str, instance_id: str) -> bool:
"""把插件的默认调用目标改为指定实例,同一事务内清除同插件的旧置位。
``instance_id`` 等于插件 ID 时视为把本体设为默认目标;本体此前从未被
登记过任何设置时,先落盘一条默认视图的本体记录,确保随后的数据库级
清旧置新有行可操作——这与日志等级那处对本体的写入语义一致。
:param plugin_id: 插件 ID
:param instance_id: 要设为默认调用目标的实例 ID
:return: 目标实例存在时为 True指定的非本体实例不归属该插件时为 False
:raise LookupError: 插件不存在,或 ``plugin_id`` 实为某个分身自身的实例 ID
"""
if not self._plugin_exists(plugin_id):
raise LookupError(f"插件 {plugin_id} 不存在")
if self._get_instance(plugin_id) is not None:
raise LookupError(f"{plugin_id} 是分身实例,请使用源插件 ID 设置默认调用目标")
if instance_id == plugin_id:
if self._get_host_instance(plugin_id) is None:
self._save_host_instance(self._default_host_instance(plugin_id))
else:
instance = self._get_instance(instance_id)
if instance is None or instance.source_plugin_id != plugin_id:
return False
return self._set_default_target(plugin_id, instance_id)
def clear_target(self, plugin_id: str, instance_id: str) -> None:
"""清除插件的默认调用目标置位,仅当当前置位的正是指定实例时才动作。
请求清除的实例并非当前置位(含插件当前没有任何置位)时按空操作处理,
这是清除接口的幂等语义,不是「找不到就报错」。
:param plugin_id: 插件 ID
:param instance_id: 请求清除默认调用目标的实例 ID
:raise LookupError: 插件不存在,或 ``plugin_id`` 实为某个分身自身的实例 ID
"""
if not self._plugin_exists(plugin_id):
raise LookupError(f"插件 {plugin_id} 不存在")
if self._get_instance(plugin_id) is not None:
raise LookupError(f"{plugin_id} 是分身实例,请使用源插件 ID 清除默认调用目标")
current = next(
(candidate for candidate in self._candidates(plugin_id) if candidate.is_default_target),
None,
)
if current is None or current.instance_id != instance_id:
return
self._clear_default_target(plugin_id)

View File

@@ -353,6 +353,7 @@ SCHEMA_EXPORTS = {
'PluginFoldersData': ('app.schemas.plugin', 'PluginFoldersData'),
'PluginInstallOutcome': ('app.schemas.plugin', 'PluginInstallOutcome'),
'PluginInstance': ('app.schemas.plugin', 'PluginInstance'),
'PluginInstanceEnabledRequest': ('app.schemas.plugin', 'PluginInstanceEnabledRequest'),
'PluginInstanceLogLevel': ('app.schemas.plugin', 'PluginInstanceLogLevel'),
'PluginInstanceLogLevelOverview': ('app.schemas.plugin', 'PluginInstanceLogLevelOverview'),
'PluginInstanceLogLevelUpdateRequest': ('app.schemas.plugin', 'PluginInstanceLogLevelUpdateRequest'),

View File

@@ -61,6 +61,16 @@ class PluginInstance(BaseModel):
plugin_name: Optional[str] = Field(default=None, description="实例展示名称")
plugin_desc: Optional[str] = Field(default=None, description="实例展示描述")
plugin_icon: Optional[str] = Field(default=None, description="实例展示图标")
is_default_target: bool = Field(
default=False,
description="该实例是否为所属源插件的默认调用目标",
)
# 默认为真而库列默认为假:这个模型是运行时描述,被构造出来就是要拿去装载的;
# 库列默认为假则是为了让「只写了配置」这类隐式建出的行不因此变成可装载
is_enabled: bool = Field(
default=True,
description="这份配置是否应当被实例化并启动;置假即停用,配置与展示信息留存待再次启用",
)
@property
def is_host(self) -> bool:
@@ -76,6 +86,14 @@ class PluginInstance(BaseModel):
return "host" if self.is_host else "virtual"
class PluginInstanceEnabledRequest(BaseModel): # type: ignore[misc]
"""启用或停用一个插件实例的请求参数。"""
enabled: bool = Field(
description="目标启用状态;置假即停用,业务参数与展示信息原样留存等待再次启用"
)
class PluginInstanceLogLevel(BaseModel): # type: ignore[misc]
"""单个实例的日志等级设置与生效结果。"""
@@ -172,6 +190,10 @@ class Plugin(BaseModel):
is_instance: Optional[bool] = False
# 实例实现模式;存量物理分身为空
instance_mode: Optional[str] = None
# 该实例是否为所属源插件的默认调用目标
is_default_target: Optional[bool] = False
# 该实例是否应当被实例化并启动;与 state 不同,后者说的是此刻在不在跑
is_enabled: Optional[bool] = True
# 该实例当前生效的日志等级覆盖;未设置覆盖或覆盖已过期回落全局等级时为空
log_level_effective: Optional[str] = None

View File

@@ -262,7 +262,7 @@ def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance
"""把插件实例表的 ORM 行投影为运行时端口使用的 Pydantic 描述。
只投影描述符各列:业务参数走插件配置读取端口,运行时端口拿到的应当是一份实例
身份展示信息的视图。
身份展示信息与调用目标置位的视图。
"""
return PluginInstance(
instance_id=record.instance_id,
@@ -270,14 +270,22 @@ def _plugin_instance_from_record(record: PluginInstanceRecord) -> PluginInstance
plugin_name=record.plugin_name,
plugin_desc=record.plugin_desc,
plugin_icon=record.plugin_icon,
is_default_target=record.is_default_target,
is_enabled=record.is_enabled,
)
def _save_plugin_instance_record(instance: PluginInstance) -> None:
"""把运行时实例描述写入插件实例表,以实例 ID 为稳定键做新增或更新。
业务参数不在此列:它由插件自身通过插件配置写入端口落盘、不进运行时描述,原样
写回会把用户刚存的配置覆盖成空。
默认调用目标置位与启用位随描述一起写:读取口 ``_plugin_instance_from_record`` 会
把它们投影出来,读改写一轮下来原值原样回去,不存在被顺手抹掉的风险;漏写反而会让
「改个展示名」这种无关写入把用户选定的调用目标悄悄清掉,或是把新建的实例落成停用
——装载判据正是启用位,实例会建出来却永不加载。真正的置位与启停仍走各自的专用
端口,这里只负责不丢值。
业务参数与日志等级不在此列:它们由插件自身和日志等级控制面各自写入端口落盘、不进
运行时描述,原样写回会把用户刚存的配置覆盖成空。
"""
PluginInstanceOper().save(
instance_id=instance.instance_id,
@@ -285,9 +293,30 @@ def _save_plugin_instance_record(instance: PluginInstance) -> None:
plugin_name=instance.plugin_name,
plugin_desc=instance.plugin_desc,
plugin_icon=instance.plugin_icon,
is_default_target=instance.is_default_target,
is_enabled=instance.is_enabled,
)
def _set_plugin_default_target(source_plugin_id: str, instance_id: str) -> bool:
"""把某源插件的默认调用目标置为指定实例,清旧与置新在库层同一事务内完成。
不经实例表的逐行写入端口:那条路径按实例 ID 各写各的行,清旧与置新会落进两个
事务,中间窗口里两行同时为真,正好撞上「同一源插件至多一个默认目标」的条件
唯一索引。
:param source_plugin_id: 源插件 ID
:param instance_id: 要设为默认调用目标的实例 ID
:return: 目标行存在并已置位
"""
return PluginInstanceOper().set_default_target(source_plugin_id, instance_id)
def _clear_plugin_default_target(source_plugin_id: str) -> None:
"""清除某源插件的默认调用目标置位。"""
PluginInstanceOper().clear_default_target(source_plugin_id)
def _build_plugin_instance_directory() -> PluginInstanceDirectory:
"""把插件实例表端口装配到 db 层实现。"""
oper = PluginInstanceOper()
@@ -308,6 +337,13 @@ def _build_plugin_instance_directory() -> PluginInstanceDirectory:
],
save=_save_plugin_instance_record,
delete=oper.delete,
list_enabled=lambda: [
_plugin_instance_from_record(record) for record in oper.list_enabled()
],
set_enabled=lambda instance_id, is_enabled: oper.set_enabled(
instance_id=instance_id,
is_enabled=is_enabled,
),
)
@@ -346,6 +382,8 @@ def build_plugin_runtime_graph(host: PluginRuntimeHost) -> PluginRuntime:
remote_entry=host.get_plugin_remote_entry,
development=lambda: bool(get_runtime_setting('DEV')),
logger=logger,
set_default_target=_set_plugin_default_target,
clear_default_target=_clear_plugin_default_target,
),
tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS,
)
@@ -460,6 +498,7 @@ def configure_plugin_services() -> None:
installed_plugins_reader=lambda: get_configured_system_config().get(
SystemConfigKey.UserInstalledPlugins
) or [],
loadable_marker=plugin_manager.mark_plugin_loadable,
plugin_ids_provider=plugin_manager.get_plugin_ids,
packages=package_manager,
install_reporter=lambda plugin_id, repo_url: (

View File

@@ -42,7 +42,11 @@ class InvokePluginAction(BaseAction):
if not params.plugin_id or not params.action_id:
return context
try:
plugin_actions = get_plugin_manager().get_plugin_actions(params.plugin_id)
plugin_manager = get_plugin_manager()
# 只给了插件 ID 而该插件已有分身时,必须先裁决默认调用目标:按登记顺序
# 取第一个会让同一条工作流在不同时刻落到不同实例,执行结果不可复现
resolved_plugin_id = plugin_manager.resolve_plugin_call_target(params.plugin_id)
plugin_actions = plugin_manager.get_plugin_actions(resolved_plugin_id)
if not plugin_actions:
logger.error(f"插件不存在: {params.plugin_id}")
return context

View File

@@ -0,0 +1,110 @@
"""3.0.38 插件实例增加启用位,并把本体的装载判据搬到这一列上。
Revision ID: b7d1e4a9c206
Revises: e0e68cbd5756
Create Date: 2026-09-13
"""
import json
from datetime import datetime, timezone
import sqlalchemy as sa
from alembic import op
revision = "b7d1e4a9c206"
down_revision = "e0e68cbd5756"
branch_labels = None
depends_on = None
_TABLE = "plugininstance"
_INSTALLED_KEY = "UserInstalledPlugins"
def _table_names(connection) -> set:
"""读取当前数据库已有表名。"""
return set(sa.inspect(connection).get_table_names())
def _column_names(connection) -> set:
"""读取实例表已有列名;表不存在时为空集。"""
if _TABLE not in _table_names(connection):
return set()
return {column["name"] for column in sa.inspect(connection).get_columns(_TABLE)}
def _installed_plugin_ids(connection) -> list:
"""读取安装清单里的插件 ID载荷不是字符串数组时按空清单处理。"""
if "systemconfig" not in _table_names(connection):
return []
row = connection.execute(
sa.text("SELECT value FROM systemconfig WHERE key = :key"),
{"key": _INSTALLED_KEY},
).fetchone()
if row is None or row[0] is None:
return []
value = row[0]
if isinstance(value, (str, bytes, bytearray)):
try:
value = json.loads(value)
except (TypeError, ValueError):
return []
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, str) and item]
def upgrade() -> None:
"""加上启用位,并把存量的分身与已安装插件本体一并登记为启用。
存量行必须显式补成启用:这一列同时是本体的装载判据,列建出来默认为假而不回填,
升级后全部插件都会变成「装着但永不加载」。分身按「行存在即在册」的旧语义一律置真;
本体按安装清单补行并置真——清单在此之前一直兼任运行开关,两者在升级这一刻等价。
"""
connection = op.get_bind()
columns = _column_names(connection)
if not columns:
return
if "is_enabled" not in columns:
op.add_column(
_TABLE,
sa.Column(
"is_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
# 布尔列写 Python 布尔而不是整数字面量PostgreSQL 会拒绝 `= 1`,报
# column is of type boolean but expression is of type integer
connection.execute(
sa.text(f"UPDATE {_TABLE} SET is_enabled = :enabled"),
{"enabled": True},
)
now = datetime.now(timezone.utc).isoformat()
existing = {
row[0]
for row in connection.execute(
sa.text(f"SELECT instance_id FROM {_TABLE}")
).fetchall()
}
for plugin_id in _installed_plugin_ids(connection):
if plugin_id in existing:
continue
connection.execute(
sa.text(
f"INSERT INTO {_TABLE} "
"(instance_id, source_plugin_id, is_enabled, created_at, updated_at) "
"VALUES (:plugin_id, :plugin_id, :enabled, :now, :now)"
),
{"plugin_id": plugin_id, "enabled": True, "now": now},
)
existing.add(plugin_id)
def downgrade() -> None:
"""删除启用位;装载判据随之回落安装清单,停用状态无处表达因而丢弃。"""
if "is_enabled" in _column_names(op.get_bind()):
op.drop_column(_TABLE, "is_enabled")

View File

@@ -0,0 +1,64 @@
"""3.0.37 插件实例描述符增加默认调用目标标记。
Revision ID: e0e68cbd5756
Revises: 487f7e681955
Create Date: 2026-09-13
"""
import sqlalchemy as sa
from alembic import op
revision = "e0e68cbd5756"
down_revision = "487f7e681955"
branch_labels = None
depends_on = None
_TABLE = "plugininstance"
_DEFAULT_TARGET_INDEX = "ux_plugininstance_default_target"
def _column_names() -> set[str]:
"""读取当前表已有列名,兼容重复升级和已由 create_all 建出当前模型的场景。"""
return {column["name"] for column in sa.inspect(op.get_bind()).get_columns(_TABLE)}
def _index_names() -> set[str]:
"""读取当前表已有索引名,兼容重复升级和已由 create_all 建出当前模型的场景。"""
return {index["name"] for index in sa.inspect(op.get_bind()).get_indexes(_TABLE)}
def upgrade() -> None:
"""增加默认调用目标标记列,并建出「同一源插件至多一个默认调用目标」的条件唯一索引。
条件谓词按方言分别给出:布尔列与 ``True`` 比较SQLite 编译为 ``IS 1``
PostgreSQL 编译为 ``IS true``;谓词整个丢失会退化成「每个源插件只能有一行
实例」,把插件分身整个锁死,因此必须两个方言各给一份,不能只给一份共用。
"""
columns = _column_names()
if "is_default_target" not in columns:
op.add_column(
_TABLE,
sa.Column(
"is_default_target",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
if _DEFAULT_TARGET_INDEX not in _index_names():
op.create_index(
_DEFAULT_TARGET_INDEX,
_TABLE,
["source_plugin_id"],
unique=True,
sqlite_where=sa.column("is_default_target", sa.Boolean()).is_(True),
postgresql_where=sa.column("is_default_target", sa.Boolean()).is_(True),
)
def downgrade() -> None:
"""删除条件唯一索引与默认调用目标标记列。"""
if _DEFAULT_TARGET_INDEX in _index_names():
op.drop_index(_DEFAULT_TARGET_INDEX, table_name=_TABLE)
if "is_default_target" in _column_names():
op.drop_column(_TABLE, "is_default_target")

View File

@@ -756,8 +756,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1014 |
| 内部导入边 | 8,614 |
| Python 模块 | 1016 |
| 内部导入边 | 8,630 |
| 非平凡 SCC | 1精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53债务已清零53 条精确 containment |

View File

@@ -57,6 +57,30 @@ MCP 当前不会主动发送工具列表变更通知(`listChanged=false`)。
服务回调、插件自己声明的 HTTP 端点。插件自建的原生线程不继承这个绑定,其中的日志仍按
全局等级过滤,不要据此断定覆盖没有写进去。
### 插件实例默认调用目标
`plugin.default_target.set``plugin.default_target.clear` 使用源插件 ID 加实例 ID
指定「只给插件 ID、没给实例 ID 的调用应当落到哪个实例」。同一源插件至多一个默认调用
目标,置新会自动清掉旧的;清除只在当前置位的正是该实例时才动作,重复调用保持幂等。
一个插件有分身而未设置默认调用目标时,未指定实例的调用会直接报错,**不会**随机挑一个、
也不会按登记顺序取第一个:那会让同一次调用在不同时刻落到不同实例,既无法复现,也无从
知道刚才是哪个实例执行的。默认目标被停用时同样报错,不静默改走另一个正在运行的实例。
只有本体、没有任何分身的插件不受这套机制约束,不需要显式设置默认目标。
### 插件实例启停
`plugin.instance.set_enabled` 用实例 ID 启用或停用一个实例,本体与分身共用这一个入口
(实例 ID 等于插件 ID 时指的是本体自身)。
停用不是删除:业务参数与展示信息原样留在那一行等待再次启用,因而「停掉一个实例」与
「丢掉它的配置」终于是两件事——此前两者绑死,想保住配置就只能留着一个在跑的实例。
只有删掉整行才是彻底清理。停用会同时清掉该实例的默认调用目标置位与日志等级覆盖:
前者会把未指定实例的调用路由到一个不会被实例化的实例,后者本就是带失效时间的临时
调试设置。
是否启用与此刻是否在运行是两回事:前者是落盘的装载判据,后者由运行时持有、不落盘。
## 3.1 结构化 Agent 工具与完整参数合同
`tools/list` 会为以下四个正式入口返回可直接校验的 JSON Schema。每个入口都按 operation/action 生成 `oneOf` 分支,分支中包含必填字段、类型、默认值、枚举、嵌套对象和互斥/至少一项等跨字段规则;外部 MCP 客户端可以在一次 `tools/call` 中完成参数构造,不需要猜测 URL、HTTP 方法或第三方 SDK 参数。

View File

@@ -2,7 +2,7 @@
"disposition_counts": {
"alternate-auth-duplicate": 11,
"consolidated": 71,
"gateway": 221,
"gateway": 224,
"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": 222,
"gateway_operation_count": 224,
"matched_gateway_http_route_count": 221,
"openapi_operation_count": 404,
"gateway_http_route_count": 225,
"gateway_operation_count": 227,
"matched_gateway_http_route_count": 224,
"openapi_operation_count": 407,
"operations": [
{
"disposition": "consolidated",
@@ -2255,6 +2255,48 @@
"plugin"
]
},
{
"disposition": "gateway",
"method": "POST",
"operation_ids": [
"plugin.instance.set_enabled"
],
"owner": "moviepilot-api",
"path": "/api/v1/plugin/instance/{instance_id}/enabled",
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
"summary": "启用或停用插件实例",
"tags": [
"plugin"
]
},
{
"disposition": "gateway",
"method": "DELETE",
"operation_ids": [
"plugin.default_target.clear"
],
"owner": "moviepilot-api",
"path": "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target",
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
"summary": "清除插件实例的默认调用目标",
"tags": [
"plugin"
]
},
{
"disposition": "gateway",
"method": "PUT",
"operation_ids": [
"plugin.default_target.set"
],
"owner": "moviepilot-api",
"path": "/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target",
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
"summary": "设置插件实例的默认调用目标",
"tags": [
"plugin"
]
},
{
"disposition": "gateway",
"method": "GET",

View File

@@ -5,10 +5,10 @@
## Result
- OpenAPI HTTP operations: **404**
- Stable `moviepilot_api` operations: **224**
- Exact HTTP routes used by the gateway: **222**
- OpenAPI routes matched directly by the gateway: **221**
- OpenAPI HTTP operations: **407**
- Stable `moviepilot_api` operations: **227**
- Exact HTTP routes used by the gateway: **225**
- OpenAPI routes matched directly by the gateway: **224**
- 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` | 221 | Approved structured MoviePilot Agent operation. |
| `gateway` | 224 | Approved structured MoviePilot Agent operation. |
| `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. |
| `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. |
| `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. |
@@ -209,6 +209,9 @@
| `GET` | `/api/v1/plugin/history/{plugin_id}` | plugin | `gateway` | plugin.history | 获取插件更新说明 |
| `GET` | `/api/v1/plugin/install/{plugin_id}` | plugin | `gateway` | plugin.install | 安装插件 |
| `GET` | `/api/v1/plugin/installed` | plugin | `consolidated` | plugin.installed | 已安装插件 |
| `POST` | `/api/v1/plugin/instance/{instance_id}/enabled` | plugin | `gateway` | plugin.instance.set_enabled | 启用或停用插件实例 |
| `DELETE` | `/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target` | plugin | `gateway` | plugin.default_target.clear | 清除插件实例的默认调用目标 |
| `PUT` | `/api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target` | plugin | `gateway` | plugin.default_target.set | 设置插件实例的默认调用目标 |
| `GET` | `/api/v1/plugin/loglevel/{plugin_id}` | plugin | `gateway` | plugin.loglevel.get | 查询插件全部实例的日志等级设置 |
| `DELETE` | `/api/v1/plugin/loglevel/{plugin_id}/{instance_id}` | plugin | `gateway` | plugin.loglevel.clear | 清除插件实例的日志等级覆盖 |
| `PUT` | `/api/v1/plugin/loglevel/{plugin_id}/{instance_id}` | plugin | `gateway` | plugin.loglevel.set | 设置插件实例的日志等级覆盖 |

View File

@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 1014 / 8,614 | `dependency-baseline.json` 当前快照分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
| 宿主 Python 模块 / 内部依赖边 | 1016 / 8,630 | `dependency-baseline.json` 当前快照分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |

View File

@@ -453,4 +453,4 @@ can be accepted only once without Application knowing the configured backend.
- `settings.API_TOKEN` and other secret fields must not be included in log output or API responses.
- The `config list --show-secrets` flag exists specifically to gate secret visibility in the CLI.
*Last Updated: 2026-09-12*
*Last Updated: 2026-09-13*

View File

@@ -210,7 +210,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123"
- Purpose: Stores one row per shared-source plugin runtime instance, covering both clones and the host plugin itself (instance_id equals source_plugin_id, so equality identifies the host and inequality a clone), together with that instance's display overrides, its own log-level override and the moment that override expires, and the plugin's own configuration payload. A clone exists exactly while its row exists, so deleting the row uninstalls the clone and discards its configuration.
- Useful queries: Diagnosing clone naming and ownership, inspecting what a plugin or one of its clones is configured with, or finding which instance currently overrides the global log level and until when.
- Write boundary: Owned by the plugin instance, plugin configuration, and plugin log-level APIs; never edit rows directly.
- Columns: `id`, `instance_id`, `source_plugin_id`, `plugin_name`, `plugin_desc`, `plugin_icon`, `log_level`, `log_expires_at`, `config_data`, `created_at`, `updated_at`
- Columns: `id`, `instance_id`, `source_plugin_id`, `plugin_name`, `plugin_desc`, `plugin_icon`, `is_default_target`, `is_enabled`, `log_level`, `log_expires_at`, `config_data`, `created_at`, `updated_at`
### `site`
- Purpose: Stores private-tracker URLs, RSS, credentials, rate limits, proxy state, and downloader binding.

View File

@@ -61,7 +61,8 @@ allowed-api-operations: >-
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.loglevel.clear plugin.default_target.set plugin.default_target.clear
plugin.instance.set_enabled
---
# MoviePilot API

View File

@@ -39,6 +39,20 @@ Purpose: Read a bounded preview of one plugin's persisted data.
- `query`: `key` (string|null): Optional exact plugin data key used to narrow the returned preview.; `max_chars` (integer|null): Maximum number of serialized plugin-data characters to return.
- `body`: none
### `plugin.default_target.clear`
`DELETE /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target`; policy effect: `reversible_write`.
Purpose: Clear one plugin instance's default-call-target flag, only if it is the plugin's current default.
- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.loglevel.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID.
- `query`: none
- `body`: none
### `plugin.default_target.set`
`PUT /api/v1/plugin/instances/{plugin_id}/{instance_id}/default_target`; policy effect: `reversible_write`.
Purpose: Set one plugin instance as the plugin's default call target, automatically clearing any previous default.
- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.loglevel.get.; `plugin_id*` (string): Exact installed or marketplace plugin ID.
- `query`: none
- `body`: none
### `plugin.folder.create`
`POST /api/v1/plugin/folders/{folder_name}`; policy effect: `reversible_write`.
Purpose: Create one named plugin folder.
@@ -117,6 +131,13 @@ Purpose: List installed plugins and their runtime status.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `force` (boolean; default `False`): Force a marketplace refresh or plugin installation when true.; `max_results` (integer|null): Optional upper bound on plugin catalog results, from 1 to 200; omit it for the complete catalog.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.; `query` (string|null): Optional case-insensitive keyword matched against plugin ID, name, description, and author.; `state*` (string=installed): Literal installed, selecting only installed plugin catalog entries.
- `body`: none
### `plugin.instance.set_enabled`
`POST /api/v1/plugin/instance/{instance_id}/enabled`; policy effect: `reversible_write`.
Purpose: Enable or disable one plugin instance, host or clone; disabling only stops it running and keeps its configuration for a later re-enable.
- `path_params`: `instance_id*` (string): Exact plugin instance ID returned by plugin.loglevel.get.
- `query`: none
- `body`: `enabled*` (boolean): Whether this category or classification rule participates in evaluation.
### `plugin.loglevel.clear`
`DELETE /api/v1/plugin/loglevel/{plugin_id}/{instance_id}`; policy effect: `reversible_write`.
Purpose: Clear one plugin instance's log-level override so it immediately follows the global log level again.

View File

@@ -225,6 +225,10 @@ def configure_plugin_system_services():
)
from app.runtime.extensions.plugin.system import get_plugin_system
from app.runtime.extensions.service import ServiceConfigHelper
from app.startup.initializers.plugins import (
_clear_plugin_default_target,
_set_plugin_default_target,
)
configure_service_directory(
configs=ServiceConfigHelper.get_configs,
@@ -256,6 +260,8 @@ def configure_plugin_system_services():
plugin_manager_module.get_runtime_setting('DEV')
),
logger=plugin_manager_module.logger,
set_default_target=_set_plugin_default_target,
clear_default_target=_clear_plugin_default_target,
),
tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS,
)

View File

@@ -4,7 +4,7 @@
"app/application/messaging/site.py:SiteInteractionHandler": 579,
"app/application/messaging/skill.py:SkillInteractionHandler": 1101,
"app/application/messaging/subscribe.py:SubscribeInteractionHandler": 694,
"app/application/plugin/install.py:PluginInstallCommand": 739,
"app/application/plugin/install.py:PluginInstallCommand": 743,
"app/application/rss.py:RssHelper": 531,
"app/application/security/url.py:SecurityUtils": 771,
"app/application/torrent/download.py:TorrentHelper": 738,
@@ -58,7 +58,7 @@
"app/application/messaging/skill.py:SkillInteractionHandler._handle_text_interaction": 296,
"app/application/messaging/subscribe.py:SubscribeInteractionHandler._handle_text_interaction": 205,
"app/application/network.py:NetworkTestService._build_rules": 206,
"app/application/plugin/install.py:PluginInstallCommand.__execute_locked": 277,
"app/application/plugin/install.py:PluginInstallCommand.__execute_locked": 279,
"app/application/rss.py:RssHelper._parse_impl": 213,
"app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl": 241,
"app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl.__page_handler": 214,

View File

@@ -1077,8 +1077,8 @@
"runtime_only": true
}
},
"edge_count": 8614,
"edge_sha256": "70b80d471a4080e365c0fd45dd87ca2768aac360b406f58e645ab68805d587b0",
"edge_count": 8630,
"edge_sha256": "498714193dc86bf3580b72f889a8f9ce874cd5185e1bdaaf951d83c523d98b65",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -2718,6 +2718,18 @@
"app.api.endpoints.pluginloglevel -> app.schemas",
"app.api.endpoints.pluginloglevel -> app.schemas.plugin",
"app.api.endpoints.pluginloglevel -> app.schemas.response",
"app.api.endpoints.plugintarget -> app.api",
"app.api.endpoints.plugintarget -> app.api.dependencies",
"app.api.endpoints.plugintarget -> app.api.dependencies.auth",
"app.api.endpoints.plugintarget -> app.api.principal",
"app.api.endpoints.plugintarget -> app.api.response",
"app.api.endpoints.plugintarget -> app.application",
"app.api.endpoints.plugintarget -> app.application.plugin",
"app.api.endpoints.plugintarget -> app.application.plugin.runtime",
"app.api.endpoints.plugintarget -> app.schemas",
"app.api.endpoints.plugintarget -> app.schemas.exception",
"app.api.endpoints.plugintarget -> app.schemas.plugin",
"app.api.endpoints.plugintarget -> app.schemas.response",
"app.api.endpoints.recommend -> app.adapters",
"app.api.endpoints.recommend -> app.adapters.web",
"app.api.endpoints.recommend -> app.adapters.web.security",
@@ -3124,6 +3136,7 @@
"app.api.routers -> app.api.endpoints.openai",
"app.api.routers -> app.api.endpoints.plugin",
"app.api.routers -> app.api.endpoints.pluginloglevel",
"app.api.routers -> app.api.endpoints.plugintarget",
"app.api.routers -> app.api.endpoints.recommend",
"app.api.routers -> app.api.endpoints.rule",
"app.api.routers -> app.api.endpoints.search",
@@ -8336,6 +8349,7 @@
"app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.storage",
"app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.sync",
"app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.system",
"app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.target",
"app.runtime.extensions.plugin.runtime -> app.runtime.extensions.plugin.tools",
"app.runtime.extensions.plugin.runtime -> app.schemas",
"app.runtime.extensions.plugin.runtime -> app.schemas.types",
@@ -8352,6 +8366,8 @@
"app.runtime.extensions.plugin.sync -> app.runtime.extensions.plugin.system",
"app.runtime.extensions.plugin.sync -> app.schemas",
"app.runtime.extensions.plugin.sync -> app.schemas.plugin",
"app.runtime.extensions.plugin.target -> app.schemas",
"app.runtime.extensions.plugin.target -> app.schemas.plugin",
"app.runtime.extensions.plugin.tools -> app.runtime",
"app.runtime.extensions.plugin.tools -> app.runtime.extensions",
"app.runtime.extensions.plugin.tools -> app.runtime.extensions.plugin",
@@ -9695,7 +9711,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 1014,
"module_count": 1016,
"modules": [
"app",
"app.adapters",
@@ -9884,6 +9900,7 @@
"app.api.endpoints.plugin",
"app.api.endpoints.pluginfolder",
"app.api.endpoints.pluginloglevel",
"app.api.endpoints.plugintarget",
"app.api.endpoints.recommend",
"app.api.endpoints.rule",
"app.api.endpoints.search",
@@ -10547,6 +10564,7 @@
"app.runtime.extensions.plugin.storage",
"app.runtime.extensions.plugin.sync",
"app.runtime.extensions.plugin.system",
"app.runtime.extensions.plugin.target",
"app.runtime.extensions.plugin.tools",
"app.runtime.extensions.resource",
"app.runtime.extensions.service",

View File

@@ -6,7 +6,7 @@
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 550,
"loaded_app_module_count": 551,
"max_ms": 1293.338,
"median_ms": 1156.239,
"min_ms": 1102.806,
@@ -17,7 +17,7 @@
]
},
"app.factory": {
"loaded_app_module_count": 562,
"loaded_app_module_count": 563,
"max_ms": 1127.911,
"median_ms": 1122.382,
"min_ms": 1119.221,
@@ -28,7 +28,7 @@
]
},
"app.main": {
"loaded_app_module_count": 564,
"loaded_app_module_count": 565,
"max_ms": 1188.652,
"median_ms": 1183.509,
"min_ms": 1174.522,

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) == 139
assert len(API_OPERATION_SPECS) == 224
assert len(API_EXTENDED_OPERATION_SPECS) == 142
assert len(API_OPERATION_SPECS) == 227
assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES)
assert {
"download.list",

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"]) == 224
assert len(payload["skill"]["allowed_api_operations"]) == 227
assert "## API Category Index" in payload["content"]
assert "### `workflow.update`" not in payload["content"]
assert "api/workflow.md" in payload["supporting_files"]

View File

@@ -35,6 +35,7 @@ def _facade(**overrides):
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
@@ -80,6 +81,7 @@ async def test_async_online_normalizes_market_configuration(monkeypatch) -> None
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(info=lambda *_args: None),
)
@@ -120,6 +122,7 @@ def test_installed_catalog_keeps_plugins_that_are_not_loaded():
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=statuses.get,
log=SimpleNamespace(error=lambda *_args: None, info=lambda *_args: None),
)
@@ -162,6 +165,7 @@ def test_local_repository_failure_does_not_break_catalog_projection():
plugin_attr=lambda _plugin_id, _attr: None,
plugin_instance=lambda _plugin_id: None,
plugin_instances=lambda: {},
host_instances=lambda: {},
runtime_status=lambda _plugin_id: None,
log=SimpleNamespace(
error=lambda *_args: None,

View File

@@ -97,7 +97,7 @@ def _build_lifecycle(**overrides: Any) -> PluginLifecycle:
classes={},
running={},
load_plugins=lambda _plugin_id, _installed, _check: [],
installed_plugins=lambda: [],
loadable_plugins=lambda: [],
plugin_config=lambda _plugin_id: {},
auth_checker=lambda _plugin: True,
clear_modules=lambda _plugin_id: None,
@@ -141,7 +141,7 @@ def test_start_ensures_the_database_after_init_plugin():
plugin_cls = _make_plugin_class("DemoPlugin", calls=calls)
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: _recording_database(calls),
)
@@ -157,7 +157,7 @@ def test_start_passes_the_declared_models_and_migration_directory():
plugin_cls = _make_plugin_class("DemoPlugin", models=(ModelA,), migrations="m")
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: _recording_database(calls),
)
@@ -173,7 +173,7 @@ def test_start_reports_empty_declarations_for_plugins_without_a_database():
plugin_cls = _make_plugin_class("DemoPlugin", declare_hooks=False)
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: _recording_database(calls),
)
@@ -193,7 +193,7 @@ def test_plugin_failing_to_ensure_is_not_registered_as_running():
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: PluginDatabase(ensure=_raise_ensure),
)
@@ -209,7 +209,7 @@ def test_stop_releases_the_database_and_never_destroys_it():
plugin_cls = _make_plugin_class("DemoPlugin")
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: _recording_database(calls),
)
lifecycle.start("DemoPlugin")
@@ -227,7 +227,7 @@ def test_stop_without_plugin_id_releases_every_running_plugin():
plugin_b = _make_plugin_class("PluginB")
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_a, plugin_b],
installed_plugins=lambda: ["PluginA", "PluginB"],
loadable_plugins=lambda: ["PluginA", "PluginB"],
database=lambda: _recording_database(calls),
)
lifecycle.start()
@@ -244,7 +244,7 @@ def test_reload_releases_then_ensures_again():
plugin_cls = _make_plugin_class("DemoPlugin")
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: _recording_database(calls),
)
lifecycle.start("DemoPlugin")
@@ -267,7 +267,7 @@ def test_release_failure_does_not_block_unloading():
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: PluginDatabase(release=_raise_release),
)
lifecycle.start("DemoPlugin")
@@ -405,7 +405,7 @@ def test_start_releases_the_database_of_a_plugin_that_failed_to_load():
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: PluginDatabase(
ensure=_raise_ensure,
release=lambda plugin_id: calls.append(("release", plugin_id)),
@@ -429,7 +429,7 @@ def test_stop_all_releases_plugins_that_never_reached_the_running_registry():
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: PluginDatabase(
ensure=_raise_ensure,
release=lambda plugin_id: calls.append(("release", plugin_id)),
@@ -451,7 +451,7 @@ def test_stopping_an_already_stopped_plugin_stays_idempotent():
plugin_cls = _make_plugin_class("DemoPlugin")
lifecycle = _build_lifecycle(
load_plugins=lambda *_a, **_kw: [plugin_cls],
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
database=lambda: _recording_database(calls),
)
lifecycle.start("DemoPlugin")

View File

@@ -0,0 +1,415 @@
"""插件默认调用目标裁决与置位/清除测试。"""
from __future__ import annotations
import pytest
from app.runtime.extensions.plugin.target import PluginDefaultTargetControl
from app.schemas.plugin import PluginInstance
class _Harness:
"""组装 PluginDefaultTargetControl 依赖并记录调用轨迹的测试脚手架。"""
def __init__(
self,
*,
host_instance: PluginInstance | None = None,
clones: list[PluginInstance] | None = None,
plugin_exists: bool = True,
running_ids: set[str] | None = None,
set_result: bool = True,
) -> None:
self.host_instance = host_instance
self.clones = list(clones or [])
self.plugin_exists_flag = plugin_exists
self.running_ids = set(running_ids or set())
self.saved_hosts: list[PluginInstance] = []
self.set_calls: list[tuple[str, str]] = []
self.clear_calls: list[str] = []
self._set_result = set_result
def _get_instance(self, instance_id: str) -> PluginInstance | None:
for clone in self.clones:
if clone.instance_id == instance_id:
return clone
return None
def _instances_for_source(self, source_plugin_id: str) -> list[PluginInstance]:
return [clone for clone in self.clones if clone.source_plugin_id == source_plugin_id]
def _get_host_instance(self, plugin_id: str) -> PluginInstance | None:
if self.host_instance is not None and self.host_instance.instance_id == plugin_id:
return self.host_instance
return None
def _save_host_instance(self, instance: PluginInstance) -> None:
self.saved_hosts.append(instance)
self.host_instance = instance
def _running(self) -> dict[str, object]:
return {instance_id: object() for instance_id in self.running_ids}
def _set_default_target(self, plugin_id: str, instance_id: str) -> bool:
self.set_calls.append((plugin_id, instance_id))
return self._set_result
def _clear_default_target(self, plugin_id: str) -> None:
self.clear_calls.append(plugin_id)
def build(self) -> PluginDefaultTargetControl:
"""构造挂接本脚手架全部端口的裁决与置位控制器。"""
return PluginDefaultTargetControl(
plugin_exists=lambda _plugin_id: self.plugin_exists_flag,
get_instance=self._get_instance,
instances_for_source=self._instances_for_source,
get_host_instance=self._get_host_instance,
save_host_instance=self._save_host_instance,
running=self._running,
set_default_target=self._set_default_target,
clear_default_target=self._clear_default_target,
)
def _clone(instance_id: str, source_plugin_id: str, *, default: bool = False) -> PluginInstance:
"""构造一个分身实例描述。"""
return PluginInstance(
instance_id=instance_id,
source_plugin_id=source_plugin_id,
is_default_target=default,
)
def _host(plugin_id: str, *, default: bool = False) -> PluginInstance:
"""构造一个本体实例描述。"""
return PluginInstance(
instance_id=plugin_id,
source_plugin_id=plugin_id,
is_default_target=default,
)
# --------------------------------------------------------------------------- #
# resolve():单实例场景与显式实例直通
# --------------------------------------------------------------------------- #
def test_resolve_never_picks_the_first_instance_and_fails_deterministically():
"""有分身却未设默认目标时,重复调用必须次次报同一个错,绝不挑出任何实例。
这是本特性最核心的一条语义:随机挑或按登记顺序取第一个都会让同一次调用在
不同时刻落到不同实例,用户既无法复现、也无从知道刚才是哪个实例执行的。
因此这里既断言「不返回任何候选」,也断言多次调用的失败完全一致——一个会
「取第一个」的实现只会在第一次调用时暴露,之后看起来同样稳定。
"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA"),
_clone("PluginAx3", "PluginA"),
],
running_ids={"PluginA", "PluginAx2", "PluginAx3"},
)
control = harness.build()
messages = []
for _ in range(5):
with pytest.raises(LookupError) as excinfo:
control.resolve("PluginA")
messages.append(str(excinfo.value))
# 五次调用报出完全相同的错误,说明裁决不含任何随机或顺序依赖的挑选
assert len(set(messages)) == 1
assert "未设置默认实例" in messages[0]
# 没有任何一次调用把候选当成结果返回:三个候选都只出现在「可选实例」清单里
assert harness.set_calls == []
assert harness.clear_calls == []
def test_resolve_returns_plugin_id_when_no_clones_exist():
"""只有本体、没有任何分身时直接返回插件 ID不要求设置默认目标。"""
harness = _Harness(host_instance=_host("PluginA"), running_ids={"PluginA"})
assert harness.build().resolve("PluginA") == "PluginA"
def test_resolve_returns_argument_unchanged_when_it_is_already_a_clone_id():
"""传入的标识本身就是某个分身的实例 ID 时原样返回,该分身没有自己的下级分身。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
running_ids={"PluginAx2"},
)
assert harness.build().resolve("PluginAx2") == "PluginAx2"
# --------------------------------------------------------------------------- #
# resolve():有分身时的默认目标裁决
# --------------------------------------------------------------------------- #
def test_resolve_uses_enabled_default_target_among_clones():
"""已有分身且默认目标已启用时,未指定实例的调用落到该默认目标。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA", default=True),
_clone("PluginAx3", "PluginA"),
],
running_ids={"PluginAx2", "PluginAx3"},
)
assert harness.build().resolve("PluginA") == "PluginAx2"
def test_resolve_host_itself_can_be_default_target():
"""本体同样可以被选为默认调用目标,与分身地位相同。"""
harness = _Harness(
host_instance=_host("PluginA", default=True),
clones=[_clone("PluginAx2", "PluginA")],
running_ids={"PluginA", "PluginAx2"},
)
assert harness.build().resolve("PluginA") == "PluginA"
def test_resolve_raises_when_no_default_target_set():
"""已有分身但未设置默认目标时报错,且不得回退到任何一个候选。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA"), _clone("PluginAx3", "PluginA")],
running_ids={"PluginA", "PluginAx2", "PluginAx3"},
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
message = str(excinfo.value)
assert "PluginA" in message
assert "未设置默认实例" in message
assert "PluginA已启用" in message
assert "PluginAx2已启用" in message
assert "PluginAx3已启用" in message
def test_resolve_raises_when_default_target_disabled_and_does_not_fall_back():
"""默认目标已停用时必须报错,不得静默改走另一个正在运行的实例。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA", default=True),
_clone("PluginAx3", "PluginA"),
],
running_ids={"PluginAx3"},
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
message = str(excinfo.value)
assert "默认实例 PluginAx2 已停用" in message
assert "PluginAx3已启用" in message
def test_resolve_candidate_description_orders_alphabetically():
"""候选实例描述按实例 ID 升序排列,报错文案稳定可预期。"""
harness = _Harness(
host_instance=_host("PluginZ"),
clones=[_clone("PluginZb", "PluginZ"), _clone("PluginZa", "PluginZ")],
running_ids=set(),
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginZ")
assert str(excinfo.value).endswith(
"可选实例PluginZ已停用、PluginZa已停用、PluginZb已停用"
)
def test_resolve_treats_never_persisted_host_as_a_candidate():
"""本体从未被显式绑定过任何设置时,仍以默认视图参与候选而不是被略去。"""
harness = _Harness(
host_instance=None,
clones=[_clone("PluginAx2", "PluginA")],
running_ids={"PluginA"},
)
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
assert "PluginA已启用" in str(excinfo.value)
# --------------------------------------------------------------------------- #
# set_target()
# --------------------------------------------------------------------------- #
def test_set_target_upserts_never_persisted_host_before_setting():
"""本体从未落盘过时,设为默认目标前先落盘一条默认视图记录。"""
harness = _Harness(host_instance=None)
result = harness.build().set_target("PluginA", "PluginA")
assert result is True
assert len(harness.saved_hosts) == 1
assert harness.saved_hosts[0].instance_id == "PluginA"
assert harness.set_calls == [("PluginA", "PluginA")]
def test_set_target_does_not_resave_already_persisted_host():
"""本体已经落盘过时不重复保存,只转发置位调用。"""
harness = _Harness(host_instance=_host("PluginA"))
harness.build().set_target("PluginA", "PluginA")
assert harness.saved_hosts == []
assert harness.set_calls == [("PluginA", "PluginA")]
def test_set_target_delegates_clone_to_atomic_callable():
"""目标是已归属该插件的分身时,直接转发给原子置位端口。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
)
result = harness.build().set_target("PluginA", "PluginAx2")
assert result is True
assert harness.set_calls == [("PluginA", "PluginAx2")]
def test_set_target_rejects_instance_not_belonging_to_plugin():
"""目标实例不存在或归属另一个插件时拒绝,且不下发到原子置位端口。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginBx2", "PluginB")],
)
result = harness.build().set_target("PluginA", "PluginBx2")
assert result is False
assert harness.set_calls == []
def test_set_target_propagates_atomic_callable_failure():
"""原子置位端口报告目标不存在时如实透传,不伪装成功。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
set_result=False,
)
assert harness.build().set_target("PluginA", "PluginAx2") is False
def test_set_target_raises_when_plugin_missing():
"""插件本身不存在时拒绝设置默认目标。"""
harness = _Harness(plugin_exists=False)
with pytest.raises(LookupError):
harness.build().set_target("Missing", "Missing")
# --------------------------------------------------------------------------- #
# clear_target()
# --------------------------------------------------------------------------- #
def test_clear_target_clears_when_matching_current_default():
"""请求清除的实例正是当前默认目标时才真正清除。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA", default=True)],
)
harness.build().clear_target("PluginA", "PluginAx2")
assert harness.clear_calls == ["PluginA"]
def test_clear_target_is_noop_when_nothing_is_set():
"""插件当前没有任何默认目标置位时按空操作处理。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA")],
)
harness.build().clear_target("PluginA", "PluginAx2")
assert harness.clear_calls == []
def test_clear_target_does_not_touch_a_different_current_default():
"""请求清除的实例并非当前默认目标时,不得误清另一个实例的置位。"""
harness = _Harness(
host_instance=_host("PluginA"),
clones=[
_clone("PluginAx2", "PluginA", default=True),
_clone("PluginAx3", "PluginA"),
],
)
harness.build().clear_target("PluginA", "PluginAx3")
assert harness.clear_calls == []
def test_clear_target_raises_when_plugin_missing():
"""插件本身不存在时拒绝清除默认目标。"""
harness = _Harness(plugin_exists=False)
with pytest.raises(LookupError):
harness.build().clear_target("Missing", "Missing")
def test_resolve_treats_a_loaded_but_disabled_default_target_as_unavailable():
"""默认目标已加载但被用户停用时按「已停用」处理,要求显式指定实例。
界面上关掉启用开关只会重新 init_plugin实例仍留在运行表里只看运行表会把
停用实例当成可用目标选中,调用随后以「插件不存在」收场。
"""
class _Disabled:
"""模拟已加载但启用开关被关掉的实例。"""
@staticmethod
def get_state() -> bool:
"""返回停用状态。"""
return False
harness = _Harness(
host_instance=_host("PluginA"),
clones=[_clone("PluginAx2", "PluginA", default=True)],
running_ids={"PluginAx2"},
)
harness._running = lambda: {"PluginAx2": _Disabled()}
with pytest.raises(LookupError) as excinfo:
harness.build().resolve("PluginA")
assert "已停用" in str(excinfo.value)
def test_set_target_rejects_clone_own_id_as_plugin_id():
"""用分身自身实例 ID 当源插件 ID 置位必须拒绝,否则会把该行改写成本体记录。"""
harness = _Harness(clones=[_clone("PluginAx2", "PluginA")])
with pytest.raises(LookupError):
harness.build().set_target("PluginAx2", "PluginAx2")
assert harness.saved_hosts == []
assert harness.set_calls == []
def test_clear_target_rejects_clone_own_id_as_plugin_id():
"""清除置位同样拒绝分身自身实例 ID避免落到本体分支。"""
harness = _Harness(clones=[_clone("PluginAx2", "PluginA")])
with pytest.raises(LookupError):
harness.build().clear_target("PluginAx2", "PluginAx2")
assert harness.clear_calls == []

View File

@@ -302,6 +302,7 @@ def _command(
command = PluginInstallCommand(
persistence=persistence,
installed_plugins_reader=lambda: installed or [],
loadable_marker=lambda _plugin_id: calls.append("mark_loadable"),
plugin_ids_provider=lambda: plugin_ids or [],
packages=packages,
install_reporter=reporter or default_reporter,
@@ -413,6 +414,8 @@ async def test_success_commits_journal_before_report_and_cleans_package_snapshot
"package",
"receipt",
"journal_target",
# 本体的装载判据在实例表的启用位上;这一步缺了,插件装完当次能跑,重启即消失
"mark_loadable",
"stage_backup",
"activate_backup",
"target_reload",

View File

@@ -0,0 +1,135 @@
"""插件实例默认调用目标设置与清除接口测试。"""
from __future__ import annotations
import inspect
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from app.api.dependencies.auth import get_current_active_superuser
from app.api.endpoints import plugintarget as plugintarget_endpoint
from app.api.endpoints.plugintarget import (
clear_plugin_instance_default_target,
set_plugin_instance_default_target,
)
def _depends_default(func, parameter_name: str):
"""取出端点函数指定参数的 FastAPI Depends 默认值。"""
return inspect.signature(func).parameters[parameter_name].default
def _manager(**methods):
"""按方法名快速拼装一个鸭子类型的 Manager 替身。"""
return type("Manager", (), methods)()
def test_both_endpoints_require_superuser_dependency():
"""设为默认与清除默认两个端点都要求超级管理员。"""
for func in (set_plugin_instance_default_target, clear_plugin_instance_default_target):
depends = _depends_default(func, "_")
assert depends.dependency is get_current_active_superuser
# --------------------------------------------------------------------------- #
# PUT /instances/{plugin_id}/{instance_id}/default_target
# --------------------------------------------------------------------------- #
def test_put_delegates_to_manager_and_reports_success(monkeypatch):
"""设置请求原样转交给 Manager命中时返回成功。"""
calls: list = []
manager = _manager(
set_plugin_instance_default_target=lambda self, *a: (calls.append(a), True)[1]
)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
result = set_plugin_instance_default_target("DemoPlugin", "DemoPluginWork", None)
assert result.success is True
assert calls == [("DemoPlugin", "DemoPluginWork")]
def test_put_reports_missing_instance_as_404(monkeypatch):
"""目标实例不归属该插件时Manager 返回 False接口须映射为 404。"""
manager = _manager(set_plugin_instance_default_target=lambda self, *a: False)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
with pytest.raises(HTTPException) as excinfo:
set_plugin_instance_default_target("DemoPlugin", "Missing", None)
assert excinfo.value.status_code == 404
def test_put_reports_missing_plugin_as_404(monkeypatch):
"""插件本身不存在时返回 404。"""
def _raise(*_a):
raise LookupError("插件 Missing 不存在")
manager = _manager(set_plugin_instance_default_target=_raise)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
with pytest.raises(HTTPException) as excinfo:
set_plugin_instance_default_target("Missing", "Missing", None)
assert excinfo.value.status_code == 404
def test_put_maps_unique_index_violation_to_409(monkeypatch):
"""并发置位撞上条件唯一索引时映射为可重试的 409而不是 500。"""
def _raise(*_a):
raise IntegrityError("stmt", {}, Exception("unique"))
manager = _manager(set_plugin_instance_default_target=_raise)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
with pytest.raises(HTTPException) as excinfo:
set_plugin_instance_default_target("DemoPlugin", "DemoPluginWork", None)
assert excinfo.value.status_code == 409
# --------------------------------------------------------------------------- #
# DELETE /instances/{plugin_id}/{instance_id}/default_target
# --------------------------------------------------------------------------- #
def test_delete_delegates_to_manager_and_is_idempotent(monkeypatch):
"""清除请求原样转交给 Manager重复调用同样返回成功。"""
calls: list = []
manager = _manager(
clear_plugin_instance_default_target=lambda self, *a: calls.append(a)
)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
first = clear_plugin_instance_default_target("DemoPlugin", "DemoPluginWork", None)
second = clear_plugin_instance_default_target("DemoPlugin", "DemoPluginWork", None)
assert first.success is True
assert second.success is True
assert calls == [("DemoPlugin", "DemoPluginWork"), ("DemoPlugin", "DemoPluginWork")]
def test_delete_reports_missing_plugin_as_404(monkeypatch):
"""插件本身不存在时返回 404。"""
def _raise(*_a):
raise LookupError("插件 Missing 不存在")
manager = _manager(clear_plugin_instance_default_target=_raise)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
with pytest.raises(HTTPException) as excinfo:
clear_plugin_instance_default_target("Missing", "Missing", None)
assert excinfo.value.status_code == 404
def test_router_registers_default_target_paths():
"""路由器暴露设为默认与清除默认两个路径,注册在插件前缀下。"""
paths = {route.path for route in plugintarget_endpoint.router.routes}
assert "/instances/{plugin_id}/{instance_id}/default_target" in paths

View File

@@ -0,0 +1,177 @@
"""插件实例默认调用目标标记列与条件唯一索引 Alembic 迁移测试。"""
from __future__ import annotations
import importlib
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy.exc import IntegrityError
from app.db.models.plugininstance import PluginInstance
MIGRATION_MODULE = "database.versions.e0e68cbd5756_3_0_37"
def _bind_migration(monkeypatch, connection):
"""把迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION_MODULE)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
return migration
def _create_legacy_table(connection: sa.engine.Connection) -> None:
"""建出加列前的表结构,模拟迁移前的存量数据库。"""
now = datetime.now(timezone.utc).isoformat()
table = sa.Table(
"plugininstance",
sa.MetaData(),
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("instance_id", sa.String(length=128), nullable=False),
sa.Column("source_plugin_id", sa.String(length=128), nullable=False),
sa.Column("plugin_name", sa.String(length=255)),
sa.Column("plugin_desc", sa.String(length=255)),
sa.Column("plugin_icon", sa.String(length=255)),
sa.Column("log_level", sa.String(length=16)),
sa.Column("log_expires_at", sa.String(length=40)),
sa.Column("config_data", sa.JSON()),
sa.Column("created_at", sa.String(length=40), nullable=False),
sa.Column("updated_at", sa.String(length=40), nullable=False),
)
table.create(connection)
connection.execute(
table.insert().values(
instance_id="DemoPluginWork",
source_plugin_id="DemoPlugin",
created_at=now,
updated_at=now,
)
)
def test_default_target_migration_adds_column_and_keeps_existing_rows(monkeypatch) -> None:
"""新增列必须非空默认为假,且不得影响已有行;重复升级与完整回滚都要幂等。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_legacy_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
columns = {
column["name"]: column
for column in sa.inspect(connection).get_columns("plugininstance")
}
# 与该迁移自身的落点比对,而不是与持续演进的当前模型比对:后续迁移
# 还会继续给该表加列,本断言不应该随之跟着变红。
assert columns.keys() == {
"id",
"instance_id",
"source_plugin_id",
"plugin_name",
"plugin_desc",
"plugin_icon",
"log_level",
"log_expires_at",
"config_data",
"is_default_target",
"created_at",
"updated_at",
}
assert columns["is_default_target"]["nullable"] is False
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
row = connection.execute(sa.select(table)).mappings().one()
assert row["instance_id"] == "DemoPluginWork"
assert bool(row["is_default_target"]) is False
migration.downgrade()
remaining = {
column["name"] for column in sa.inspect(connection).get_columns("plugininstance")
}
assert "is_default_target" not in remaining
remaining_indexes = {
index["name"] for index in sa.inspect(connection).get_indexes("plugininstance")
}
assert "ux_plugininstance_default_target" not in remaining_indexes
migration.upgrade()
restored = {
column["name"] for column in sa.inspect(connection).get_columns("plugininstance")
}
assert "is_default_target" in restored
def test_default_target_migration_accepts_fresh_current_schema(monkeypatch) -> None:
"""create_all 已建当前表时重复升级不得因列或索引已存在而报错。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
PluginInstance.__table__.create(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
assert {
column["name"]
for column in sa.inspect(connection).get_columns("plugininstance")
} == {column.name for column in PluginInstance.__table__.columns}
assert "ux_plugininstance_default_target" in {
index["name"] for index in sa.inspect(connection).get_indexes("plugininstance")
}
def test_default_target_migration_index_rejects_a_second_default_target(monkeypatch) -> None:
"""迁移建出的条件唯一索引必须在真实数据库连接上拒绝第二条置位。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_legacy_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
now = datetime.now(timezone.utc).isoformat()
connection.execute(
table.insert().values(
instance_id="DemoPlugin",
source_plugin_id="DemoPlugin",
is_default_target=True,
created_at=now,
updated_at=now,
)
)
with pytest.raises(IntegrityError):
connection.execute(
table.update()
.where(table.c.instance_id == "DemoPluginWork")
.values(is_default_target=True)
)
def test_default_target_index_is_partial_in_both_dialects() -> None:
"""模型(``create_all`` 路径)建出的索引在两种方言下都必须带谓词。
本仓测试库是 SQLitePostgreSQL 分支只能靠编译期 DDL 证明:谓词整个丢失会
退化成「每个源插件只能有一行实例」,把插件分身整个锁死。
"""
index = next(
item for item in PluginInstance.__table__.indexes
if item.name == "ux_plugininstance_default_target"
)
ddl = sa.schema.CreateIndex(index)
assert str(ddl.compile(dialect=sqlite.dialect())).strip() == (
"CREATE UNIQUE INDEX ux_plugininstance_default_target "
"ON plugininstance (source_plugin_id) WHERE is_default_target IS 1"
)
assert str(ddl.compile(dialect=postgresql.dialect())).strip() == (
"CREATE UNIQUE INDEX ux_plugininstance_default_target "
"ON plugininstance (source_plugin_id) WHERE is_default_target IS true"
)

View File

@@ -0,0 +1,135 @@
"""插件实例默认调用目标置位与清除的数据访问层测试。"""
from __future__ import annotations
import pytest
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.db.models.plugininstance import PluginInstance
from app.db.oper.plugininstance import PluginInstanceOper
from app.db.uow import run_sync_transaction
_INSTANCE_IDS = (
"TargetOperHost",
"TargetOperHostX2",
"TargetOperHostX3",
"TargetOperOther",
)
@pytest.fixture(autouse=True)
def purge_written_rows():
"""删除用例写入的行,测试库在整个会话内共享,残留会干扰后续用例。"""
yield
def purge(session: Session) -> None:
"""按本文件使用的固定标识精确删除,不触碰其他用例的数据。"""
session.execute(
delete(PluginInstance).where(
PluginInstance.instance_id.in_(_INSTANCE_IDS)
)
)
run_sync_transaction(purge)
def _seed(oper: PluginInstanceOper) -> None:
"""建出一个本体和两个分身,供置位与清除用例共用。"""
oper.save(instance_id="TargetOperHost", source_plugin_id="TargetOperHost")
oper.save(instance_id="TargetOperHostX2", source_plugin_id="TargetOperHost")
oper.save(instance_id="TargetOperHostX3", source_plugin_id="TargetOperHost")
def test_set_default_target_marks_only_the_requested_row():
"""置位只落在目标行上,同插件其余行保持未置位。"""
oper = PluginInstanceOper()
_seed(oper)
assert oper.set_default_target("TargetOperHost", "TargetOperHostX2") is True
flags = {
record.instance_id: record.is_default_target
for record in oper.list_by_source("TargetOperHost")
}
assert flags == {
"TargetOperHost": False,
"TargetOperHostX2": True,
"TargetOperHostX3": False,
}
def test_set_default_target_clears_the_previous_default_in_one_transaction():
"""改置默认目标时旧置位必须一并清掉,不能出现两行同时为真。
「同一源插件至多一个默认目标」由条件唯一索引在库层强制,清旧与置新若分处两个
事务就会撞上索引;这条用例正是在真实连接上证明清旧置新落在同一次写入里。
"""
oper = PluginInstanceOper()
_seed(oper)
oper.set_default_target("TargetOperHost", "TargetOperHostX2")
assert oper.set_default_target("TargetOperHost", "TargetOperHostX3") is True
marked = [
record.instance_id
for record in oper.list_by_source("TargetOperHost")
if record.is_default_target
]
assert marked == ["TargetOperHostX3"]
def test_set_default_target_rejects_a_row_belonging_to_another_plugin():
"""目标行不归属该源插件时原样返回失败,且不动原有置位。"""
oper = PluginInstanceOper()
_seed(oper)
oper.save(instance_id="TargetOperOther", source_plugin_id="TargetOperOther")
oper.set_default_target("TargetOperHost", "TargetOperHostX2")
assert oper.set_default_target("TargetOperHost", "TargetOperOther") is False
marked = [
record.instance_id
for record in oper.list_by_source("TargetOperHost")
if record.is_default_target
]
assert marked == ["TargetOperHostX2"]
def test_set_default_target_reports_false_for_a_row_that_does_not_exist():
"""目标行尚未落盘时如实返回失败,不隐式建行。"""
oper = PluginInstanceOper()
_seed(oper)
assert oper.set_default_target("TargetOperHost", "TargetOperHostMissing") is False
assert oper.get("TargetOperHostMissing") is None
def test_clear_default_target_is_idempotent():
"""清除置位后重复调用保持幂等,不报错也不产生新的置位。"""
oper = PluginInstanceOper()
_seed(oper)
oper.set_default_target("TargetOperHost", "TargetOperHostX2")
oper.clear_default_target("TargetOperHost")
oper.clear_default_target("TargetOperHost")
assert not any(
record.is_default_target for record in oper.list_by_source("TargetOperHost")
)
def test_host_row_carrying_only_a_default_target_is_not_recycled_as_empty():
"""本体行只剩默认目标置位时不算「只剩身份列」,不能被空行回收清掉。
回收判据漏掉这一列会让用户把本体设为默认调用目标后,下一次清空配置就把置位
连同整行一起删掉,默认目标无声失效。
"""
oper = PluginInstanceOper()
_seed(oper)
oper.set_default_target("TargetOperHost", "TargetOperHost")
record = oper.get("TargetOperHost")
assert record is not None
assert record.is_default_target is True
assert record.carries_only_identity is False

View File

@@ -0,0 +1,137 @@
"""插件实例启停接口与依赖分类装载过滤测试。"""
from __future__ import annotations
import inspect
from types import SimpleNamespace
from app.api.dependencies.auth import get_current_active_superuser
from app.api.endpoints import plugintarget as plugintarget_endpoint
from app.api.endpoints.plugintarget import set_plugin_instance_enabled
from app.runtime.extensions.plugin.dependency import PluginDependencyService
from app.schemas.exception import PluginMutationRejectedError
from app.schemas.plugin import PluginInstance, PluginInstanceEnabledRequest
def _manager(**methods):
"""按方法名快速拼装一个鸭子类型的 Manager 替身。"""
return type("Manager", (), methods)()
def test_endpoint_requires_superuser_dependency():
"""启停端点要求超级管理员。"""
depends = inspect.signature(set_plugin_instance_enabled).parameters["_"].default
assert depends.dependency is get_current_active_superuser
def test_endpoint_delegates_to_manager_and_reports_success(monkeypatch):
"""请求原样转交给 Manager状态确实变化时返回成功。"""
calls: list = []
manager = _manager(
set_plugin_instance_enabled=lambda self, *a: (calls.append(a), True)[1]
)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
result = set_plugin_instance_enabled(
"DemoPluginWork", PluginInstanceEnabledRequest(enabled=False), None
)
assert result.success is True
assert calls == [("DemoPluginWork", False)]
def test_endpoint_reports_unchanged_state_without_pretending_success(monkeypatch):
"""实例不存在或已处于目标状态时如实回报失败,并给出完整句子而非中文片段。"""
manager = _manager(set_plugin_instance_enabled=lambda self, *a: False)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
disabled = set_plugin_instance_enabled(
"Missing", PluginInstanceEnabledRequest(enabled=False), None
)
enabled = set_plugin_instance_enabled(
"Missing", PluginInstanceEnabledRequest(enabled=True), None
)
assert disabled.success is False
assert disabled.message == "实例 Missing 不存在或已处于停用状态"
assert enabled.message == "实例 Missing 不存在或已处于启用状态"
def test_endpoint_reports_mutation_rejection_as_a_failed_response(monkeypatch):
"""处于停机准入窗口时返回失败响应而不是抛出。"""
def _raise(*_a):
raise PluginMutationRejectedError("插件正在停机")
manager = _manager(set_plugin_instance_enabled=_raise)
monkeypatch.setattr(plugintarget_endpoint, "get_plugin_manager", lambda: manager)
result = set_plugin_instance_enabled(
"DemoPluginWork", PluginInstanceEnabledRequest(enabled=True), None
)
assert result.success is False
assert "插件正在停机" in result.message
def test_router_registers_the_enabled_path():
"""路由器暴露启停路径,注册在插件前缀下。"""
paths = {route.path for route in plugintarget_endpoint.router.routes}
assert "/instance/{instance_id}/enabled" in paths
# --------------------------------------------------------------------------- #
# 依赖分类必须按装载判据过滤
# --------------------------------------------------------------------------- #
def test_classification_drops_hosts_that_are_not_loadable():
"""分类结果会被逐个 start(),停用的本体必须在这里就被剔除。
物理插件那一层按安装清单划分,而安装清单只回答「包在不在磁盘上」;不过滤的话
停用的插件会在开机与配置热重载时被重新拉起来,启用位形同虚设。
"""
system = SimpleNamespace(
dependency=SimpleNamespace(
classify_plugins=lambda: (
["PluginA", "PluginB"],
["PluginC"],
["PluginD"],
)
)
)
service = PluginDependencyService(
system=lambda: system,
instances=lambda: {
"PluginAx2": PluginInstance(
instance_id="PluginAx2", source_plugin_id="PluginA"
)
},
loadable_hosts=lambda: {"PluginA"},
log=SimpleNamespace(info=lambda *_a: None, error=lambda *_a: None),
)
classification = service.classify_plugins()
assert classification.ready == ("PluginA", "PluginAx2")
assert classification.missing_dependencies == ()
assert classification.missing_source == ()
def test_classification_without_a_loadable_port_keeps_every_plugin():
"""未装配装载判据端口时保持原分类,测试与旧调用方不因此丢掉插件。"""
system = SimpleNamespace(
dependency=SimpleNamespace(
classify_plugins=lambda: (["PluginA"], ["PluginC"], ["PluginD"])
)
)
service = PluginDependencyService(
system=lambda: system,
log=SimpleNamespace(info=lambda *_a: None, error=lambda *_a: None),
)
classification = service.classify_plugins()
assert classification.ready == ("PluginA",)
assert classification.missing_dependencies == ("PluginC",)
assert classification.missing_source == ("PluginD",)

View File

@@ -0,0 +1,299 @@
"""插件实例启停语义测试:数据访问层、实例存储与运行期装载取数。"""
from __future__ import annotations
import pytest
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.db.models.plugininstance import PluginInstance as PluginInstanceRecord
from app.db.oper.plugininstance import PluginInstanceOper
from app.db.uow import run_sync_transaction
from app.runtime.extensions.plugin.storage import PluginInstanceStore, PluginStorage
from app.schemas.plugin import PluginInstance
_INSTANCE_IDS = ("EnablementHost", "EnablementHostX2", "EnablementOther")
@pytest.fixture(autouse=True)
def purge_written_rows():
"""删除用例写入的行,测试库在整个会话内共享,残留会干扰后续用例。"""
yield
def purge(session: Session) -> None:
"""按本文件使用的固定标识精确删除,不触碰其他用例的数据。"""
session.execute(
delete(PluginInstanceRecord).where(
PluginInstanceRecord.instance_id.in_(_INSTANCE_IDS)
)
)
run_sync_transaction(purge)
# --------------------------------------------------------------------------- #
# PluginInstanceOper.set_enabled
# --------------------------------------------------------------------------- #
def test_set_enabled_keeps_configuration_when_disabling():
"""停用只翻启用位,业务参数与展示信息原样留在那一行等待再次启用。
这正是这一列存在的理由:把「在册」与「有配置」拆开,让用户能停掉一个实例而
不必在「删掉配置」和「留着它继续跑」之间二选一。
"""
oper = PluginInstanceOper()
oper.save(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
plugin_name="分身显示名",
is_enabled=True,
)
oper.save_config_data(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
config_data={"token": "kept"},
)
assert oper.set_enabled(instance_id="EnablementHostX2", is_enabled=False) is True
record = oper.get("EnablementHostX2")
assert record is not None
assert record.is_enabled is False
assert record.config_data == {"token": "kept"}
assert record.plugin_name == "分身显示名"
# 再次启用即恢复,配置不需要重填
assert oper.set_enabled(instance_id="EnablementHostX2", is_enabled=True) is True
restored = oper.get("EnablementHostX2")
assert restored is not None
assert restored.is_enabled is True
assert restored.config_data == {"token": "kept"}
def test_disabling_clears_default_target_and_log_level_override():
"""停用同时清掉默认调用目标置位与日志等级覆盖,两者只对在册实例才有意义。
不清默认目标,未指定实例的外部调用会被路由到一个不会被实例化的实例;不清日志
等级,运行期停用时清掉的进程内覆盖与库里留下的那份会长期不一致。
"""
oper = PluginInstanceOper()
oper.save(instance_id="EnablementHost", source_plugin_id="EnablementHost", is_enabled=True)
oper.save(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
is_enabled=True,
)
oper.set_default_target("EnablementHost", "EnablementHostX2")
oper.set_log_level(
instance_id="EnablementHostX2",
log_level="DEBUG",
log_expires_at=None,
)
oper.set_enabled(instance_id="EnablementHostX2", is_enabled=False)
record = oper.get("EnablementHostX2")
assert record is not None
assert record.is_default_target is False
assert record.log_level is None
assert record.log_expires_at is None
def test_set_enabled_reports_false_for_a_row_that_does_not_exist():
"""行不存在时如实返回失败,不隐式建行。"""
oper = PluginInstanceOper()
assert oper.set_enabled(instance_id="EnablementMissing", is_enabled=True) is False
assert oper.get("EnablementMissing") is None
def test_list_enabled_excludes_disabled_rows():
"""运行期装载取数只看启用中的行。"""
oper = PluginInstanceOper()
oper.save(instance_id="EnablementHost", source_plugin_id="EnablementHost", is_enabled=True)
oper.save(
instance_id="EnablementHostX2",
source_plugin_id="EnablementHost",
is_enabled=False,
)
enabled = {record.instance_id for record in oper.list_enabled()}
listed = {record.instance_id for record in oper.list_by_source("EnablementHost")}
assert "EnablementHost" in enabled
assert "EnablementHostX2" not in enabled
# 停用的行仍然在册:卡片要看得见、卸载守卫要拦得住
assert listed == {"EnablementHost", "EnablementHostX2"}
def test_an_enabled_host_row_is_never_recycled_as_empty():
"""启用中的本体行不算「只剩身份列」,清空配置不得把它连同装载判据一起回收。"""
oper = PluginInstanceOper()
oper.save(instance_id="EnablementHost", source_plugin_id="EnablementHost", is_enabled=True)
oper.save_config_data(
instance_id="EnablementHost",
source_plugin_id="EnablementHost",
config_data={"a": 1},
)
oper.clear_config_data("EnablementHost")
record = oper.get("EnablementHost")
assert record is not None, "启用中的本体行被当成空行回收,该插件将永不加载"
assert record.is_enabled is True
# --------------------------------------------------------------------------- #
# PluginInstanceStore分身与本体共用一个开关
# --------------------------------------------------------------------------- #
class _FakeDirectory:
"""在内存里模拟实例表读写的目录替身。"""
def __init__(self, records: dict[str, PluginInstance]) -> None:
self.records = records
def get(self, instance_id: str):
"""按实例 ID 读取单行。"""
return self.records.get(instance_id)
def list_all(self):
"""列出全部行,含停用的。"""
return list(self.records.values())
def list_by_source(self, source_plugin_id: str):
"""按源插件列出全部行。"""
return [
record
for record in self.records.values()
if record.source_plugin_id == source_plugin_id
]
def list_enabled(self):
"""列出启用中的行。"""
return [record for record in self.records.values() if record.is_enabled]
def save(self, instance: PluginInstance) -> None:
"""新增或更新一行。"""
self.records[instance.instance_id] = instance
def delete(self, instance_id: str) -> bool:
"""删除一行。"""
return self.records.pop(instance_id, None) is not None
def set_enabled(self, instance_id: str, is_enabled: bool) -> bool:
"""写入启用位。"""
record = self.records.get(instance_id)
if record is None:
return False
self.records[instance_id] = record.model_copy(update={"is_enabled": is_enabled})
return True
def _store(records: dict[str, PluginInstance]) -> tuple[PluginInstanceStore, _FakeDirectory]:
"""构造挂接内存目录、且旧键导入已标记完成的实例存储。"""
directory = _FakeDirectory(records)
storage = PluginStorage(read=lambda _key: True, write=lambda _key, _value: None)
return (
PluginInstanceStore(storage=lambda: storage, directory=lambda: directory),
directory,
)
def _clone(instance_id: str, source: str, *, enabled: bool = True) -> PluginInstance:
"""构造一个分身实例描述。"""
return PluginInstance(
instance_id=instance_id, source_plugin_id=source, is_enabled=enabled
)
def _host(plugin_id: str, *, enabled: bool = True) -> PluginInstance:
"""构造一个本体实例描述。"""
return PluginInstance(
instance_id=plugin_id, source_plugin_id=plugin_id, is_enabled=enabled
)
def test_enabled_hosts_is_the_host_loading_criterion():
"""本体的装载取数只给出启用中的本体,停用的本体不在其中。"""
store, _ = _store(
{
"PluginA": _host("PluginA"),
"PluginB": _host("PluginB", enabled=False),
"PluginAx2": _clone("PluginAx2", "PluginA"),
}
)
assert set(store.enabled_hosts()) == {"PluginA"}
# 分身不混进本体取数
assert set(store.enabled()) == {"PluginAx2"}
def test_disabled_clone_stays_registered_and_visible():
"""停用的分身仍在册:卡片列表、卸载守卫与默认目标候选都要看得见它。
读取口若替调用方把停用的行藏起来,卸载守卫会放过一个还留着行的分身,用户随后
只能看到一个既列不出来、也删不掉的孤儿实例。
"""
store, _ = _store(
{"PluginA": _host("PluginA"), "PluginAx2": _clone("PluginAx2", "PluginA")}
)
assert store.disable("PluginAx2") is True
assert set(store.all()) == {"PluginAx2"}
assert [record.instance_id for record in store.for_source("PluginA")] == ["PluginAx2"]
assert store.get("PluginAx2") is not None
assert set(store.enabled()) == set()
def test_enable_and_disable_report_whether_state_changed():
"""重复启用或重复停用报告未发生变化,调用方据此决定要不要重载运行态。"""
store, _ = _store(
{"PluginA": _host("PluginA"), "PluginAx2": _clone("PluginAx2", "PluginA")}
)
assert store.disable("PluginAx2") is True
assert store.disable("PluginAx2") is False
assert store.enable("PluginAx2") is True
assert store.enable("PluginAx2") is False
def test_enable_host_creates_the_row_when_the_plugin_has_none():
"""安装收尾时本体还没有行,必须按默认视图建出并置为启用。
只往安装清单里加一条而不建这一行,插件会装完当次靠定向重载跑起来、重启后再也
不加载。
"""
store, directory = _store({})
assert store.enable_host("PluginA") is True
record = directory.records["PluginA"]
assert record.is_host is True
assert record.is_enabled is True
assert store.enable_host("PluginA") is False
def test_disable_host_keeps_the_row_and_its_configuration():
"""停用本体保留整行,只翻启用位。"""
store, directory = _store({"PluginA": _host("PluginA")})
assert store.disable_host("PluginA") is True
assert store.disable_host("PluginA") is False
assert directory.records["PluginA"].is_enabled is False
assert store.get_host("PluginA") is not None
def test_host_and_clone_views_never_leak_into_each_other():
"""本体与分身两组读写口互不可见,避免按分身写入顶掉本体那一行。"""
store, _ = _store(
{"PluginA": _host("PluginA"), "PluginAx2": _clone("PluginAx2", "PluginA")}
)
assert store.get("PluginA") is None
assert store.get_host("PluginAx2") is None
assert set(store.all_hosts()) == {"PluginA"}
assert set(store.all()) == {"PluginAx2"}

View File

@@ -0,0 +1,152 @@
"""插件实例启用位 Alembic 迁移测试。"""
from __future__ import annotations
import importlib
import json
from datetime import datetime, timezone
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from app.db.models.plugininstance import PluginInstance
MIGRATION_MODULE = "database.versions.b7d1e4a9c206_3_0_38"
def _bind_migration(monkeypatch, connection):
"""把迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION_MODULE)
context = MigrationContext.configure(connection)
monkeypatch.setattr(migration, "op", Operations(context))
return migration
def _create_legacy_schema(connection: sa.engine.Connection, *, installed=None) -> sa.Table:
"""建出加列前的实例表与系统设置表,模拟迁移前的存量数据库。"""
now = datetime.now(timezone.utc).isoformat()
table = sa.Table(
"plugininstance",
sa.MetaData(),
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("instance_id", sa.String(length=128), nullable=False),
sa.Column("source_plugin_id", sa.String(length=128), nullable=False),
sa.Column("plugin_name", sa.String(length=255)),
sa.Column("plugin_desc", sa.String(length=255)),
sa.Column("plugin_icon", sa.String(length=255)),
sa.Column("is_default_target", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("log_level", sa.String(length=16)),
sa.Column("log_expires_at", sa.String(length=40)),
sa.Column("config_data", sa.JSON()),
sa.Column("created_at", sa.String(length=40), nullable=False),
sa.Column("updated_at", sa.String(length=40), nullable=False),
)
table.create(connection)
connection.execute(
table.insert().values(
instance_id="DemoPluginWork",
source_plugin_id="DemoPlugin",
created_at=now,
updated_at=now,
)
)
systemconfig = sa.Table(
"systemconfig",
sa.MetaData(),
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("key", sa.String(length=255)),
sa.Column("value", sa.Text()),
)
systemconfig.create(connection)
if installed is not None:
connection.execute(
systemconfig.insert().values(
key="UserInstalledPlugins",
value=json.dumps(installed),
)
)
return table
def test_enablement_migration_enables_existing_rows_and_backfills_hosts(monkeypatch) -> None:
"""存量分身一律置真,安装清单里的插件补出启用中的本体行;重复升级保持幂等。
列建出来默认为假而不回填,升级后全部插件都会变成「装着但永不加载」——这一列
同时是本体的装载判据,回填因此是迁移的正文而不是附带步骤。
"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_legacy_schema(connection, installed=["DemoPlugin", "OtherPlugin"])
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
rows = {
row["instance_id"]: row
for row in connection.execute(sa.select(table)).mappings().all()
}
assert set(rows) == {"DemoPluginWork", "DemoPlugin", "OtherPlugin"}
assert all(bool(row["is_enabled"]) for row in rows.values())
# 本体行按自身 ID 建出,两列身份相等
assert rows["OtherPlugin"]["source_plugin_id"] == "OtherPlugin"
# 已有的分身行没有被重复插入,也没有被改写归属
assert rows["DemoPluginWork"]["source_plugin_id"] == "DemoPlugin"
migration.downgrade()
assert "is_enabled" not in {
column["name"] for column in sa.inspect(connection).get_columns("plugininstance")
}
def test_enablement_migration_tolerates_a_missing_installed_list(monkeypatch) -> None:
"""安装清单不存在时只补启用位,不因读不到清单而中断升级。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_legacy_schema(connection, installed=None)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
rows = connection.execute(sa.select(table)).mappings().all()
assert [row["instance_id"] for row in rows] == ["DemoPluginWork"]
assert bool(rows[0]["is_enabled"]) is True
def test_enablement_migration_ignores_a_malformed_installed_list(monkeypatch) -> None:
"""安装清单不是字符串数组时按空清单处理,不能让升级崩在一条脏数据上。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_legacy_schema(connection, installed=None)
systemconfig = sa.Table("systemconfig", sa.MetaData(), autoload_with=connection)
connection.execute(
systemconfig.insert().values(key="UserInstalledPlugins", value="not-json")
)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
table = sa.Table("plugininstance", sa.MetaData(), autoload_with=connection)
assert [
row["instance_id"]
for row in connection.execute(sa.select(table)).mappings().all()
] == ["DemoPluginWork"]
def test_enablement_migration_accepts_fresh_current_schema(monkeypatch) -> None:
"""create_all 已建当前表时重复升级不得因列已存在而报错。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
PluginInstance.__table__.create(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
assert {
column["name"]
for column in sa.inspect(connection).get_columns("plugininstance")
} == {column.name for column in PluginInstance.__table__.columns}

View File

@@ -38,7 +38,7 @@ def _lifecycle(*, plugins, running=None):
classes=classes,
running=running,
load_plugins=lambda _pid, _installed, _check: list(plugins),
installed_plugins=lambda: ["DemoPluginWork"],
loadable_plugins=lambda: ["DemoPluginWork"],
plugin_config=lambda _pid: {},
auth_checker=lambda _plugin: True,
clear_modules=MagicMock(),

View File

@@ -41,7 +41,7 @@ def _lifecycle(
classes=classes,
running=running,
load_plugins=lambda _plugin_id, _installed, _check: list(plugins),
installed_plugins=lambda: ["DemoPlugin"],
loadable_plugins=lambda: ["DemoPlugin"],
plugin_config=lambda _plugin_id: {},
auth_checker=lambda _plugin: auth,
clear_modules=MagicMock(),

View File

@@ -52,3 +52,73 @@ def test_invoke_plugin_keeps_legacy_action_id_fallback() -> None:
_, result = _execute_with_action({"action_id": "cleanup"})
assert result.content == "before"
def test_invoke_plugin_dispatches_to_resolved_default_target() -> None:
"""插件 ID 有分身时,动作按裁决出的默认调用目标查询动作,而不是原样使用插件 ID。
这是历史工作流在源插件本体停用、仅分身启用后仍能继续工作的关键:存量工作流
保存的还是物理插件 ID必须经过默认调用目标裁决才能落到实际在跑的分身上。
"""
context = ActionContext(content="before")
action = {"id": "cleanup"}
action_fn = Mock(return_value=(True, context))
action["func"] = action_fn
plugin_manager = Mock()
plugin_manager.resolve_plugin_call_target.return_value = "plugin-a-clone"
plugin_manager.get_plugin_actions.return_value = [
{"plugin_id": "plugin-a-clone", "actions": [action]}
]
with patch(
"app.workflow.actions.get_configured_system_config",
return_value=Mock(),
), patch(
"app.workflow.actions.invoke_plugin.get_plugin_manager",
return_value=plugin_manager,
):
action_runner = InvokePluginAction("invoke")
action_runner.execute(
workflow_id=1,
params={
"plugin_id": "plugin-a",
"action_id": "cleanup",
"action_params": {},
},
context=context,
)
plugin_manager.resolve_plugin_call_target.assert_called_once_with("plugin-a")
plugin_manager.get_plugin_actions.assert_called_once_with("plugin-a-clone")
assert action_runner.success is True
def test_invoke_plugin_fails_gracefully_when_default_target_undecidable() -> None:
"""裁决报错(未设默认目标/默认目标已停用)时动作失败但不抛出,不误执行任何实例。"""
context = ActionContext(content="before")
plugin_manager = Mock()
plugin_manager.resolve_plugin_call_target.side_effect = LookupError(
"插件 plugin-a 未设置默认实例调用必须显式指定实例可选实例a已启用、b已启用"
)
with patch(
"app.workflow.actions.get_configured_system_config",
return_value=Mock(),
), patch(
"app.workflow.actions.invoke_plugin.get_plugin_manager",
return_value=plugin_manager,
):
action_runner = InvokePluginAction("invoke")
result = action_runner.execute(
workflow_id=1,
params={
"plugin_id": "plugin-a",
"action_id": "cleanup",
"action_params": {},
},
context=context,
)
plugin_manager.get_plugin_actions.assert_not_called()
assert action_runner.success is False
assert result.content == "before"