feat: Enhance plugin runtime configuration and hook management
- Added `inactive_plugins` field to `RunnerReadyPayload` and `ReloadPluginResultPayload` to track plugins that are not activated due to being disabled or unmet dependencies. - Introduced `InspectPluginConfigPayload` and `InspectPluginConfigResultPayload` for inspecting plugin configuration metadata. - Implemented `PluginActivationStatus` enum to better represent plugin activation states. - Updated `_activate_plugin` method to return activation status and handle inactive plugins accordingly. - Added hooks for send service to allow modification of messages before and after sending. - Created new runtime routes for listing hook specifications in the WebUI. - Refactored plugin configuration handling to utilize runtime inspection for better accuracy and flexibility. - Enhanced error handling and logging for plugin configuration operations.
This commit is contained in:
@@ -13,12 +13,13 @@ import pytest
|
||||
from src.plugin_runtime.component_query import component_query_service
|
||||
from src.plugin_runtime.protocol.envelope import (
|
||||
Envelope,
|
||||
InspectPluginConfigPayload,
|
||||
MessageType,
|
||||
RegisterPluginPayload,
|
||||
ValidatePluginConfigPayload,
|
||||
)
|
||||
from src.plugin_runtime.runner.runner_main import PluginRunner
|
||||
from src.webui.routers.plugin.config_routes import update_plugin_config
|
||||
from src.webui.routers.plugin.config_routes import get_plugin_config, get_plugin_config_schema, update_plugin_config
|
||||
from src.webui.routers.plugin.schemas import UpdatePluginConfigRequest
|
||||
|
||||
|
||||
@@ -56,6 +57,61 @@ class _DemoConfigPlugin:
|
||||
|
||||
self.received_config = config
|
||||
|
||||
def get_default_config(self) -> Dict[str, Any]:
|
||||
"""返回测试插件的默认配置。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 默认配置字典。
|
||||
"""
|
||||
|
||||
return {"plugin": {"enabled": True, "retry_count": 3}}
|
||||
|
||||
def get_webui_config_schema(
|
||||
self,
|
||||
*,
|
||||
plugin_id: str = "",
|
||||
plugin_name: str = "",
|
||||
plugin_version: str = "",
|
||||
plugin_description: str = "",
|
||||
plugin_author: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""返回测试插件的 WebUI 配置 Schema。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
plugin_name: 插件名称。
|
||||
plugin_version: 插件版本。
|
||||
plugin_description: 插件描述。
|
||||
plugin_author: 插件作者。
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 测试配置 Schema。
|
||||
"""
|
||||
|
||||
del plugin_name, plugin_description, plugin_author
|
||||
return {
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_info": {
|
||||
"name": "Demo",
|
||||
"version": plugin_version,
|
||||
"description": "",
|
||||
"author": "",
|
||||
},
|
||||
"sections": {
|
||||
"plugin": {
|
||||
"fields": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"label": "启用",
|
||||
"default": True,
|
||||
"ui_type": "switch",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {"type": "auto", "tabs": []},
|
||||
}
|
||||
|
||||
|
||||
class _StrictConfigPlugin:
|
||||
"""用于测试配置校验错误的伪插件。"""
|
||||
@@ -173,6 +229,63 @@ async def test_runner_validate_plugin_config_handler_returns_normalized_config(m
|
||||
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_inspect_plugin_config_handler_supports_unloaded_plugin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Runner 应支持对未加载插件执行冷检查。"""
|
||||
|
||||
plugin = _DemoConfigPlugin()
|
||||
runner = PluginRunner(
|
||||
host_address="ipc://unused",
|
||||
session_token="session-token",
|
||||
plugin_dirs=[],
|
||||
)
|
||||
meta = SimpleNamespace(
|
||||
plugin_id="demo.plugin",
|
||||
plugin_dir="/tmp/demo-plugin",
|
||||
instance=plugin,
|
||||
manifest=SimpleNamespace(
|
||||
name="Demo",
|
||||
description="",
|
||||
author=SimpleNamespace(name="tester"),
|
||||
),
|
||||
version="1.0.0",
|
||||
)
|
||||
purged_plugins: list[tuple[str, str]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_resolve_plugin_meta_for_config_request",
|
||||
lambda plugin_id: (meta, True, None) if plugin_id == "demo.plugin" else (None, False, "not-found"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runner._loader,
|
||||
"purge_plugin_modules",
|
||||
lambda plugin_id, plugin_dir: purged_plugins.append((plugin_id, plugin_dir)),
|
||||
)
|
||||
|
||||
envelope = Envelope(
|
||||
request_id=1,
|
||||
message_type=MessageType.REQUEST,
|
||||
method="plugin.inspect_config",
|
||||
plugin_id="demo.plugin",
|
||||
payload=InspectPluginConfigPayload(
|
||||
config_data={"plugin": {"enabled": False}},
|
||||
use_provided_config=True,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
response = await runner._handle_inspect_plugin_config(envelope)
|
||||
|
||||
assert response.error is None
|
||||
assert response.payload["success"] is True
|
||||
assert response.payload["enabled"] is False
|
||||
assert response.payload["normalized_config"] == {"plugin": {"enabled": False, "retry_count": 3}}
|
||||
assert response.payload["default_config"] == {"plugin": {"enabled": True, "retry_count": 3}}
|
||||
assert purged_plugins == [("demo.plugin", "/tmp/demo-plugin")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_validate_plugin_config_handler_returns_error_on_invalid_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -251,3 +364,73 @@ async def test_update_plugin_config_prefers_runtime_validation(
|
||||
with config_path.open("rb") as handle:
|
||||
saved_config = tomllib.load(handle)
|
||||
assert saved_config == {"plugin": {"enabled": False, "retry_count": 3}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_config_endpoints_use_runtime_inspection_for_unloaded_plugin(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""WebUI 在插件未加载时也应从代码定义返回配置与 Schema。"""
|
||||
|
||||
async def _mock_inspect_plugin_config(
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
use_provided_config: bool = False,
|
||||
) -> SimpleNamespace | None:
|
||||
"""返回运行时冷检查结果。
|
||||
|
||||
Args:
|
||||
plugin_id: 插件 ID。
|
||||
config_data: 可选配置。
|
||||
use_provided_config: 是否使用传入配置。
|
||||
|
||||
Returns:
|
||||
SimpleNamespace | None: 冷检查结果。
|
||||
"""
|
||||
|
||||
del config_data, use_provided_config
|
||||
if plugin_id != "demo.plugin":
|
||||
return None
|
||||
return SimpleNamespace(
|
||||
config_schema={
|
||||
"plugin_id": "demo.plugin",
|
||||
"plugin_info": {
|
||||
"name": "Demo",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
},
|
||||
"sections": {"plugin": {"fields": {}}},
|
||||
"layout": {"type": "auto", "tabs": []},
|
||||
},
|
||||
normalized_config={"plugin": {"enabled": True, "retry_count": 3}},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
fake_runtime_manager = SimpleNamespace(inspect_plugin_config=_mock_inspect_plugin_config)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"src.webui.routers.plugin.config_routes.require_plugin_token",
|
||||
lambda session: session or "session-token",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.webui.routers.plugin.config_routes.find_plugin_path_by_id",
|
||||
lambda plugin_id: tmp_path if plugin_id == "demo.plugin" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.plugin_runtime.integration.get_plugin_runtime_manager",
|
||||
lambda: fake_runtime_manager,
|
||||
)
|
||||
|
||||
schema_response = await get_plugin_config_schema("demo.plugin", maibot_session="session-token")
|
||||
config_response = await get_plugin_config("demo.plugin", maibot_session="session-token")
|
||||
|
||||
assert schema_response["success"] is True
|
||||
assert schema_response["schema"]["plugin_id"] == "demo.plugin"
|
||||
assert config_response == {
|
||||
"success": True,
|
||||
"config": {"plugin": {"enabled": True, "retry_count": 3}},
|
||||
"message": "配置文件不存在,已返回默认配置",
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
@@ -1405,6 +1405,57 @@ class TestComponentRegistry:
|
||||
assert warnings
|
||||
assert "plugin_a.broken" in warnings[0]
|
||||
|
||||
def test_register_hook_handler_rejects_unknown_hook(self):
|
||||
from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry
|
||||
from src.plugin_runtime.host.hook_spec_registry import HookSpecRegistry
|
||||
|
||||
reg = ComponentRegistry(hook_spec_registry=HookSpecRegistry())
|
||||
|
||||
with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"):
|
||||
reg.register_component(
|
||||
"broken_hook",
|
||||
"hook_handler",
|
||||
"plugin_a",
|
||||
{
|
||||
"hook": "chat.receive.unknown",
|
||||
"mode": "blocking",
|
||||
},
|
||||
)
|
||||
|
||||
def test_register_plugin_components_is_atomic_when_hook_invalid(self):
|
||||
from src.plugin_runtime.host.component_registry import ComponentRegistrationError, ComponentRegistry
|
||||
from src.plugin_runtime.host.hook_spec_registry import HookSpec, HookSpecRegistry
|
||||
|
||||
hook_spec_registry = HookSpecRegistry()
|
||||
hook_spec_registry.register_hook_spec(HookSpec(name="chat.receive.before_process"))
|
||||
reg = ComponentRegistry(hook_spec_registry=hook_spec_registry)
|
||||
reg.register_plugin_components(
|
||||
"plugin_a",
|
||||
[
|
||||
{"name": "cmd_old", "component_type": "command", "metadata": {"command_pattern": r"^/old"}},
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ComponentRegistrationError, match="未注册的 Hook"):
|
||||
reg.register_plugin_components(
|
||||
"plugin_a",
|
||||
[
|
||||
{
|
||||
"name": "hook_ok",
|
||||
"component_type": "hook_handler",
|
||||
"metadata": {"hook": "chat.receive.before_process", "mode": "blocking"},
|
||||
},
|
||||
{
|
||||
"name": "hook_bad",
|
||||
"component_type": "hook_handler",
|
||||
"metadata": {"hook": "chat.receive.missing", "mode": "blocking"},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert reg.get_component("plugin_a.cmd_old") is not None
|
||||
assert reg.get_component("plugin_a.hook_ok") is None
|
||||
|
||||
def test_query_by_type(self):
|
||||
from src.plugin_runtime.host.component_registry import ComponentRegistry
|
||||
|
||||
@@ -2142,6 +2193,18 @@ class TestPluginRuntimeHookEntry:
|
||||
assert result.kwargs["session_id"] == "s-1"
|
||||
assert ("b1", "builtin_guard") in call_log
|
||||
|
||||
def test_manager_lists_builtin_hook_specs(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""PluginRuntimeManager 应暴露内置 Hook 规格清单。"""
|
||||
|
||||
_ComponentRegistry, PluginRuntimeManager = self._import_manager_modules(monkeypatch)
|
||||
|
||||
manager = PluginRuntimeManager()
|
||||
hook_names = {spec.name for spec in manager.list_hook_specs()}
|
||||
|
||||
assert "chat.receive.before_process" in hook_names
|
||||
assert "send_service.before_send" in hook_names
|
||||
assert "maisaka.planner.after_response" in hook_names
|
||||
|
||||
|
||||
class TestRPCServer:
|
||||
"""RPC Server 代际保护测试"""
|
||||
@@ -2974,6 +3037,16 @@ class TestIntegration:
|
||||
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
|
||||
self.config_updates = []
|
||||
|
||||
async def inspect_plugin_config(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
use_provided_config: bool = False,
|
||||
) -> SimpleNamespace:
|
||||
"""返回测试用的配置解析结果。"""
|
||||
del config_data, use_provided_config
|
||||
return SimpleNamespace(enabled=True, normalized_config={"enabled": True}, plugin_id=plugin_id)
|
||||
|
||||
async def notify_plugin_config_updated(
|
||||
self,
|
||||
plugin_id,
|
||||
@@ -2997,6 +3070,110 @@ class TestIntegration:
|
||||
assert manager._builtin_supervisor.config_updates == [("test.alpha", {"enabled": True}, "", "self")]
|
||||
assert manager._third_party_supervisor.config_updates == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_plugin_config_changes_loads_unloaded_enabled_plugin(self, monkeypatch, tmp_path):
|
||||
from src.plugin_runtime import integration as integration_module
|
||||
from src.config.file_watcher import FileChange
|
||||
import json
|
||||
|
||||
thirdparty_root = tmp_path / "plugins"
|
||||
alpha_dir = thirdparty_root / "alpha"
|
||||
alpha_dir.mkdir(parents=True)
|
||||
(alpha_dir / "config.toml").write_text("[plugin]\nenabled = true\n", encoding="utf-8")
|
||||
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
class FakeSupervisor:
|
||||
def __init__(self, plugin_dirs):
|
||||
self._plugin_dirs = plugin_dirs
|
||||
self._registered_plugins = {}
|
||||
|
||||
async def inspect_plugin_config(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
use_provided_config: bool = False,
|
||||
) -> SimpleNamespace:
|
||||
"""返回测试用的启用配置快照。"""
|
||||
del config_data, use_provided_config
|
||||
return SimpleNamespace(enabled=True, normalized_config={"plugin": {"enabled": True}}, plugin_id=plugin_id)
|
||||
|
||||
manager = integration_module.PluginRuntimeManager()
|
||||
manager._started = True
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root])
|
||||
|
||||
load_calls = []
|
||||
|
||||
async def fake_load_plugin_globally(plugin_id: str, reason: str = "manual") -> bool:
|
||||
"""记录自动加载调用。"""
|
||||
load_calls.append((plugin_id, reason))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(manager, "load_plugin_globally", fake_load_plugin_globally)
|
||||
|
||||
await manager._handle_plugin_config_changes(
|
||||
"test.alpha",
|
||||
[FileChange(change_type=1, path=alpha_dir / "config.toml")],
|
||||
)
|
||||
|
||||
assert load_calls == [("test.alpha", "config_enabled")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_plugin_config_changes_unloads_loaded_disabled_plugin(self, monkeypatch, tmp_path):
|
||||
from src.plugin_runtime import integration as integration_module
|
||||
from src.config.file_watcher import FileChange
|
||||
import json
|
||||
|
||||
builtin_root = tmp_path / "src" / "plugins" / "built_in"
|
||||
alpha_dir = builtin_root / "alpha"
|
||||
alpha_dir.mkdir(parents=True)
|
||||
(alpha_dir / "config.toml").write_text("[plugin]\nenabled = false\n", encoding="utf-8")
|
||||
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
class FakeSupervisor:
|
||||
def __init__(self, plugin_dirs, plugins):
|
||||
self._plugin_dirs = plugin_dirs
|
||||
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
|
||||
|
||||
async def inspect_plugin_config(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config_data: Optional[Dict[str, Any]] = None,
|
||||
use_provided_config: bool = False,
|
||||
) -> SimpleNamespace:
|
||||
"""返回测试用的禁用配置快照。"""
|
||||
del config_data, use_provided_config
|
||||
return SimpleNamespace(
|
||||
enabled=False,
|
||||
normalized_config={"plugin": {"enabled": False}},
|
||||
plugin_id=plugin_id,
|
||||
)
|
||||
|
||||
manager = integration_module.PluginRuntimeManager()
|
||||
manager._started = True
|
||||
manager._builtin_supervisor = FakeSupervisor([builtin_root], ["test.alpha"])
|
||||
|
||||
reload_calls = []
|
||||
|
||||
async def fake_reload_plugins_globally(plugin_ids: Sequence[str], reason: str = "manual") -> bool:
|
||||
"""记录自动卸载调用。"""
|
||||
reload_calls.append((list(plugin_ids), reason))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(manager, "reload_plugins_globally", fake_reload_plugins_globally)
|
||||
|
||||
await manager._handle_plugin_config_changes(
|
||||
"test.alpha",
|
||||
[FileChange(change_type=1, path=alpha_dir / "config.toml")],
|
||||
)
|
||||
|
||||
assert reload_calls == [(["test.alpha"], "config_disabled")]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_main_config_reload_only_notifies_subscribers(self, monkeypatch):
|
||||
from src.plugin_runtime import integration as integration_module
|
||||
@@ -3108,6 +3285,55 @@ class TestIntegration:
|
||||
subscription["paths"][0] for subscription in manager._plugin_file_watcher.subscriptions
|
||||
} == {alpha_dir / "config.toml", beta_dir / "config.toml"}
|
||||
|
||||
def test_refresh_plugin_config_watch_subscriptions_includes_unloaded_plugins(self, tmp_path):
|
||||
from src.plugin_runtime import integration as integration_module
|
||||
import json
|
||||
|
||||
thirdparty_root = tmp_path / "plugins"
|
||||
alpha_dir = thirdparty_root / "alpha"
|
||||
beta_dir = thirdparty_root / "beta"
|
||||
alpha_dir.mkdir(parents=True)
|
||||
beta_dir.mkdir(parents=True)
|
||||
(alpha_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(beta_dir / "plugin.py").write_text("def create_plugin():\n return object()\n", encoding="utf-8")
|
||||
(alpha_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.alpha")), encoding="utf-8")
|
||||
(beta_dir / "_manifest.json").write_text(json.dumps(build_test_manifest("test.beta")), encoding="utf-8")
|
||||
|
||||
class FakeWatcher:
|
||||
def __init__(self):
|
||||
self.subscriptions = []
|
||||
|
||||
def subscribe(
|
||||
self,
|
||||
callback: Any,
|
||||
*,
|
||||
paths: Optional[Sequence[Path]] = None,
|
||||
change_types: Any = None,
|
||||
) -> str:
|
||||
"""记录新的监听订阅。"""
|
||||
del callback, change_types
|
||||
subscription_id = f"sub-{len(self.subscriptions) + 1}"
|
||||
self.subscriptions.append({"id": subscription_id, "paths": tuple(paths or ())})
|
||||
return subscription_id
|
||||
|
||||
def unsubscribe(self, subscription_id: str) -> bool:
|
||||
"""兼容 watcher 取消订阅接口。"""
|
||||
del subscription_id
|
||||
return True
|
||||
|
||||
class FakeSupervisor:
|
||||
def __init__(self, plugin_dirs, plugins):
|
||||
self._plugin_dirs = plugin_dirs
|
||||
self._registered_plugins = {plugin_id: object() for plugin_id in plugins}
|
||||
|
||||
manager = integration_module.PluginRuntimeManager()
|
||||
manager._plugin_file_watcher = FakeWatcher()
|
||||
manager._third_party_supervisor = FakeSupervisor([thirdparty_root], ["test.alpha"])
|
||||
|
||||
manager._refresh_plugin_config_watch_subscriptions()
|
||||
|
||||
assert set(manager._plugin_config_watcher_subscriptions.keys()) == {"test.alpha", "test.beta"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_component_reload_plugin_returns_failure_when_reload_rolls_back(self, monkeypatch):
|
||||
from src.plugin_runtime import integration as integration_module
|
||||
|
||||
Reference in New Issue
Block a user