From d44480d739245ab5cfc9f75131908cbdbca8a59b Mon Sep 17 00:00:00 2001 From: Aqr-K <95741669+Aqr-K@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:37:27 +0800 Subject: [PATCH] Merge pull request #6666 from Aqr-K/feat/plugin-instance-target-enablement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(plugin): 插件实例默认调用目标与启停,未指定实例时不再兜底取第一个 --- app/agent/policy/api.py | 12 + app/agent/policy/mcp.py | 6 + .../policy/resources/api_mcp_schema.json | 131 ++++++ app/api/endpoints/plugin.py | 6 +- app/api/endpoints/plugintarget.py | 103 +++++ app/api/routers.py | 2 + app/application/plugin/install.py | 5 + app/application/plugin/management.py | 6 +- app/db/models/plugininstance.py | 48 +- app/db/oper/plugininstance.py | 99 ++++- app/locales/en-US.json | 1 + app/locales/zh-TW.json | 1 + app/runtime/extensions/plugin/catalog.py | 33 +- app/runtime/extensions/plugin/clone.py | 2 + app/runtime/extensions/plugin/dependency.py | 21 +- app/runtime/extensions/plugin/lifecycle.py | 8 +- app/runtime/extensions/plugin/manager.py | 85 ++++ app/runtime/extensions/plugin/runtime.py | 37 +- app/runtime/extensions/plugin/storage.py | 142 +++++- app/runtime/extensions/plugin/target.py | 206 +++++++++ app/schemas/exports.py | 1 + app/schemas/plugin.py | 22 + app/startup/initializers/plugins.py | 45 +- app/workflow/actions/invoke_plugin.py | 6 +- database/versions/b7d1e4a9c206_3_0_38.py | 110 +++++ database/versions/e0e68cbd5756_3_0_37.py | 64 +++ docs/architecture-overview.md | 4 +- docs/mcp-api.md | 24 + docs/refactor/agent-api-surface-audit.json | 52 ++- docs/refactor/agent-api-surface-audit.md | 13 +- docs/refactor/optimization-checklist.md | 2 +- docs/rules/10-data-and-persistent.md | 2 +- skills/database-operation/SKILL.md | 2 +- skills/moviepilot-api/SKILL.md | 3 +- skills/moviepilot-api/api/plugin.md | 21 + tests/conftest.py | 6 + .../architecture/complexity-v2-baseline.json | 4 +- .../architecture/dependency-baseline.json | 24 +- .../startup-performance-baseline.json | 6 +- tests/test_agent_api_gateway.py | 4 +- tests/test_agent_skills_middleware.py | 2 +- tests/test_plugin_catalog_runtime.py | 4 + tests/test_plugin_database_lifecycle.py | 24 +- tests/test_plugin_default_target.py | 415 ++++++++++++++++++ tests/test_plugin_install_command.py | 3 + ...lugin_instance_default_target_endpoints.py | 135 ++++++ ...lugin_instance_default_target_migration.py | 177 ++++++++ ...est_plugin_instance_default_target_oper.py | 135 ++++++ .../test_plugin_instance_enabled_endpoint.py | 137 ++++++ tests/test_plugin_instance_enablement.py | 299 +++++++++++++ ...st_plugin_instance_enablement_migration.py | 152 +++++++ ...plugin_instance_log_context_entrypoints.py | 2 +- tests/test_plugin_lifecycle_status.py | 2 +- tests/test_workflow_invoke_plugin.py | 70 +++ 54 files changed, 2853 insertions(+), 73 deletions(-) create mode 100644 app/api/endpoints/plugintarget.py create mode 100644 app/runtime/extensions/plugin/target.py create mode 100644 database/versions/b7d1e4a9c206_3_0_38.py create mode 100644 database/versions/e0e68cbd5756_3_0_37.py create mode 100644 tests/test_plugin_default_target.py create mode 100644 tests/test_plugin_instance_default_target_endpoints.py create mode 100644 tests/test_plugin_instance_default_target_migration.py create mode 100644 tests/test_plugin_instance_default_target_oper.py create mode 100644 tests/test_plugin_instance_enabled_endpoint.py create mode 100644 tests/test_plugin_instance_enablement.py create mode 100644 tests/test_plugin_instance_enablement_migration.py diff --git a/app/agent/policy/api.py b/app/agent/policy/api.py index 90799d7c2..d567e4b1f 100644 --- a/app/agent/policy/api.py +++ b/app/agent/policy/api.py @@ -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" + ), } diff --git a/app/agent/policy/mcp.py b/app/agent/policy/mcp.py index 868b4a849..31dc0f8d2 100644 --- a/app/agent/policy/mcp.py +++ b/app/agent/policy/mcp.py @@ -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.", diff --git a/app/agent/policy/resources/api_mcp_schema.json b/app/agent/policy/resources/api_mcp_schema.json index d11c470c3..3770ac710 100644 --- a/app/agent/policy/resources/api_mcp_schema.json +++ b/app/agent/policy/resources/api_mcp_schema.json @@ -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", diff --git a/app/api/endpoints/plugin.py b/app/api/endpoints/plugin.py index cd9b3fdfe..e66e9df0f 100644 --- a/app/api/endpoints/plugin.py +++ b/app/api/endpoints/plugin.py @@ -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 删除。 diff --git a/app/api/endpoints/plugintarget.py b/app/api/endpoints/plugintarget.py new file mode 100644 index 000000000..347b0c5fb --- /dev/null +++ b/app/api/endpoints/plugintarget.py @@ -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) diff --git a/app/api/routers.py b/app/api/routers.py index 13ab4e1b4..4d249f4d7 100644 --- a/app/api/routers.py +++ b/app/api/routers.py @@ -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",)), diff --git a/app/application/plugin/install.py b/app/application/plugin/install.py index 4d933cdf2..b62c03199 100644 --- a/app/application/plugin/install.py +++ b/app/application/plugin/install.py @@ -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( diff --git a/app/application/plugin/management.py b/app/application/plugin/management.py index d8386cb2e..2e42f382d 100644 --- a/app/application/plugin/management.py +++ b/app/application/plugin/management.py @@ -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: diff --git a/app/db/models/plugininstance.py b/app/db/models/plugininstance.py index 1b847b8c9..76d0b20ee 100644 --- a/app/db/models/plugininstance.py +++ b/app/db/models/plugininstance.py @@ -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, diff --git a/app/db/oper/plugininstance.py b/app/db/oper/plugininstance.py index b8482c074..596db9f8c 100644 --- a/app/db/oper/plugininstance.py +++ b/app/db/oper/plugininstance.py @@ -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。 diff --git a/app/locales/en-US.json b/app/locales/en-US.json index 59f347d02..fd0f6468b 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -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", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 5e934f578..3cd543759 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -271,6 +271,7 @@ "验证失败": "驗證失敗", "没有传入仓库地址,无法正确安装插件,请检查配置": "未傳入倉庫位址,無法正確安裝插件,請檢查設定", "插件分身创建成功": "插件分身建立成功", + "默认调用目标正被并发修改,请重试": "預設呼叫目標正被並行修改,請重試", "未识别到豆瓣媒体信息": "未識別到豆瓣媒體資訊", "未识别到TMDB媒体信息": "未識別到 TMDB 媒體資訊", "未知的媒体ID": "未知的媒體 ID", diff --git a/app/runtime/extensions/plugin/catalog.py b/app/runtime/extensions/plugin/catalog.py index e23286b9a..5a2739bcb 100644 --- a/app/runtime/extensions/plugin/catalog.py +++ b/app/runtime/extensions/plugin/catalog.py @@ -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]: """读取指定已安装插件版本,不触发全量目录投影。""" diff --git a/app/runtime/extensions/plugin/clone.py b/app/runtime/extensions/plugin/clone.py index cdbe0cd94..4ae0f8040 100644 --- a/app/runtime/extensions/plugin/clone.py +++ b/app/runtime/extensions/plugin/clone.py @@ -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) diff --git a/app/runtime/extensions/plugin/dependency.py b/app/runtime/extensions/plugin/dependency.py index d5bdd418c..a600ab95d 100644 --- a/app/runtime/extensions/plugin/dependency.py +++ b/app/runtime/extensions/plugin/dependency.py @@ -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) diff --git a/app/runtime/extensions/plugin/lifecycle.py b/app/runtime/extensions/plugin/lifecycle.py index c751bdb0b..8d9d48673 100644 --- a/app/runtime/extensions/plugin/lifecycle.py +++ b/app/runtime/extensions/plugin/lifecycle.py @@ -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__ diff --git a/app/runtime/extensions/plugin/manager.py b/app/runtime/extensions/plugin/manager.py index 4416914fd..ce964613f 100644 --- a/app/runtime/extensions/plugin/manager.py +++ b/app/runtime/extensions/plugin/manager.py @@ -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]: diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py index 3742e5661..e5ce483ea 100644 --- a/app/runtime/extensions/plugin/runtime.py +++ b/app/runtime/extensions/plugin/runtime.py @@ -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, diff --git a/app/runtime/extensions/plugin/storage.py b/app/runtime/extensions/plugin/storage.py index e5ad9f0a1..f35e2eefe 100644 --- a/app/runtime/extensions/plugin/storage.py +++ b/app/runtime/extensions/plugin/storage.py @@ -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() diff --git a/app/runtime/extensions/plugin/target.py b/app/runtime/extensions/plugin/target.py new file mode 100644 index 000000000..65a161b64 --- /dev/null +++ b/app/runtime/extensions/plugin/target.py @@ -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) diff --git a/app/schemas/exports.py b/app/schemas/exports.py index 05f0ab361..4bf18b01b 100644 --- a/app/schemas/exports.py +++ b/app/schemas/exports.py @@ -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'), diff --git a/app/schemas/plugin.py b/app/schemas/plugin.py index 62873c99f..5a72ab880 100644 --- a/app/schemas/plugin.py +++ b/app/schemas/plugin.py @@ -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 diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index 31be37f9f..0bf4f7ef0 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -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: ( diff --git a/app/workflow/actions/invoke_plugin.py b/app/workflow/actions/invoke_plugin.py index 3d1cefdc5..ab18f52f6 100644 --- a/app/workflow/actions/invoke_plugin.py +++ b/app/workflow/actions/invoke_plugin.py @@ -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 diff --git a/database/versions/b7d1e4a9c206_3_0_38.py b/database/versions/b7d1e4a9c206_3_0_38.py new file mode 100644 index 000000000..76ebb4a93 --- /dev/null +++ b/database/versions/b7d1e4a9c206_3_0_38.py @@ -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") diff --git a/database/versions/e0e68cbd5756_3_0_37.py b/database/versions/e0e68cbd5756_3_0_37.py new file mode 100644 index 000000000..90f5e8a76 --- /dev/null +++ b/database/versions/e0e68cbd5756_3_0_37.py @@ -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") diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index a92ce81e2..4fdc9e612 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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) | diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 4a8f3b398..b9f5196bc 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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 参数。 diff --git a/docs/refactor/agent-api-surface-audit.json b/docs/refactor/agent-api-surface-audit.json index 701a4cef5..535a786c9 100644 --- a/docs/refactor/agent-api-surface-audit.json +++ b/docs/refactor/agent-api-surface-audit.json @@ -2,7 +2,7 @@ "disposition_counts": { "alternate-auth-duplicate": 11, "consolidated": 71, - "gateway": 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", diff --git a/docs/refactor/agent-api-surface-audit.md b/docs/refactor/agent-api-surface-audit.md index 2c72df585..b57d2f224 100644 --- a/docs/refactor/agent-api-surface-audit.md +++ b/docs/refactor/agent-api-surface-audit.md @@ -5,10 +5,10 @@ ## Result -- OpenAPI HTTP operations: **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 | 设置插件实例的日志等级覆盖 | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 288d0f349..df77bc77d 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -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 等基线均为零 | diff --git a/docs/rules/10-data-and-persistent.md b/docs/rules/10-data-and-persistent.md index 6ba4c41fe..effaab25d 100644 --- a/docs/rules/10-data-and-persistent.md +++ b/docs/rules/10-data-and-persistent.md @@ -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* diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index 7442873a1..4ae5c966a 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -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. diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index f286dddad..7bc448e77 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -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 diff --git a/skills/moviepilot-api/api/plugin.md b/skills/moviepilot-api/api/plugin.md index f11ec5dc6..e281d17bf 100644 --- a/skills/moviepilot-api/api/plugin.md +++ b/skills/moviepilot-api/api/plugin.md @@ -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. diff --git a/tests/conftest.py b/tests/conftest.py index ef73fc537..28ad84007 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, ) diff --git a/tests/fixtures/architecture/complexity-v2-baseline.json b/tests/fixtures/architecture/complexity-v2-baseline.json index 45c411be5..bed375d07 100644 --- a/tests/fixtures/architecture/complexity-v2-baseline.json +++ b/tests/fixtures/architecture/complexity-v2-baseline.json @@ -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, diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 870eeb87d..f01d63f52 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/fixtures/architecture/startup-performance-baseline.json b/tests/fixtures/architecture/startup-performance-baseline.json index 2ce4b39ea..deec9a795 100644 --- a/tests/fixtures/architecture/startup-performance-baseline.json +++ b/tests/fixtures/architecture/startup-performance-baseline.json @@ -6,7 +6,7 @@ "repeat": 3, "targets": { "app.startup.lifecycle": { - "loaded_app_module_count": 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, diff --git a/tests/test_agent_api_gateway.py b/tests/test_agent_api_gateway.py index 0bef554b9..681074b0a 100644 --- a/tests/test_agent_api_gateway.py +++ b/tests/test_agent_api_gateway.py @@ -34,8 +34,8 @@ def test_api_operation_registry_matches_migration_batches() -> None: assert len(API_PARITY_OPERATION_SPECS) == 15 assert len(API_MUSIC_OPERATION_SPECS) == 10 assert len(API_SYSTEM_OPERATION_SPECS) == 7 - assert len(API_EXTENDED_OPERATION_SPECS) == 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", diff --git a/tests/test_agent_skills_middleware.py b/tests/test_agent_skills_middleware.py index 87baaed9f..a06092e63 100644 --- a/tests/test_agent_skills_middleware.py +++ b/tests/test_agent_skills_middleware.py @@ -155,7 +155,7 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None: assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES assert payload["truncated"] is False assert payload["truncation_message"] is None - assert len(payload["skill"]["allowed_api_operations"]) == 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"] diff --git a/tests/test_plugin_catalog_runtime.py b/tests/test_plugin_catalog_runtime.py index 78052dcf6..5a7b9cfc9 100644 --- a/tests/test_plugin_catalog_runtime.py +++ b/tests/test_plugin_catalog_runtime.py @@ -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, diff --git a/tests/test_plugin_database_lifecycle.py b/tests/test_plugin_database_lifecycle.py index 0db78c28d..b21cf6dbf 100644 --- a/tests/test_plugin_database_lifecycle.py +++ b/tests/test_plugin_database_lifecycle.py @@ -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") diff --git a/tests/test_plugin_default_target.py b/tests/test_plugin_default_target.py new file mode 100644 index 000000000..de04b849e --- /dev/null +++ b/tests/test_plugin_default_target.py @@ -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 == [] diff --git a/tests/test_plugin_install_command.py b/tests/test_plugin_install_command.py index 7eb0de273..6aa2828db 100644 --- a/tests/test_plugin_install_command.py +++ b/tests/test_plugin_install_command.py @@ -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", diff --git a/tests/test_plugin_instance_default_target_endpoints.py b/tests/test_plugin_instance_default_target_endpoints.py new file mode 100644 index 000000000..8c2bb34d6 --- /dev/null +++ b/tests/test_plugin_instance_default_target_endpoints.py @@ -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 diff --git a/tests/test_plugin_instance_default_target_migration.py b/tests/test_plugin_instance_default_target_migration.py new file mode 100644 index 000000000..ca387cddf --- /dev/null +++ b/tests/test_plugin_instance_default_target_migration.py @@ -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`` 路径)建出的索引在两种方言下都必须带谓词。 + + 本仓测试库是 SQLite,PostgreSQL 分支只能靠编译期 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" + ) diff --git a/tests/test_plugin_instance_default_target_oper.py b/tests/test_plugin_instance_default_target_oper.py new file mode 100644 index 000000000..49e29de39 --- /dev/null +++ b/tests/test_plugin_instance_default_target_oper.py @@ -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 diff --git a/tests/test_plugin_instance_enabled_endpoint.py b/tests/test_plugin_instance_enabled_endpoint.py new file mode 100644 index 000000000..53267c361 --- /dev/null +++ b/tests/test_plugin_instance_enabled_endpoint.py @@ -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",) diff --git a/tests/test_plugin_instance_enablement.py b/tests/test_plugin_instance_enablement.py new file mode 100644 index 000000000..ad9a32af0 --- /dev/null +++ b/tests/test_plugin_instance_enablement.py @@ -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"} diff --git a/tests/test_plugin_instance_enablement_migration.py b/tests/test_plugin_instance_enablement_migration.py new file mode 100644 index 000000000..d5d81bc17 --- /dev/null +++ b/tests/test_plugin_instance_enablement_migration.py @@ -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} diff --git a/tests/test_plugin_instance_log_context_entrypoints.py b/tests/test_plugin_instance_log_context_entrypoints.py index f18d9ee1d..ce8c66f22 100644 --- a/tests/test_plugin_instance_log_context_entrypoints.py +++ b/tests/test_plugin_instance_log_context_entrypoints.py @@ -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(), diff --git a/tests/test_plugin_lifecycle_status.py b/tests/test_plugin_lifecycle_status.py index e0dfca3a6..fb9823706 100644 --- a/tests/test_plugin_lifecycle_status.py +++ b/tests/test_plugin_lifecycle_status.py @@ -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(), diff --git a/tests/test_workflow_invoke_plugin.py b/tests/test_workflow_invoke_plugin.py index 0e8938b18..a113947c7 100644 --- a/tests/test_workflow_invoke_plugin.py +++ b/tests/test_workflow_invoke_plugin.py @@ -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"