feat: Enhance plugin runtime with new component registry and workflow executor
- Introduced `ComponentRegistry` for managing plugin components with support for registration, enabling/disabling, and querying by type and plugin. - Added `EventDispatcher` to handle event distribution to registered event handlers, supporting both blocking and non-blocking execution. - Implemented `WorkflowExecutor` to manage a linear workflow execution across multiple stages, including command routing and error handling. - Created `ManifestValidator` for validating plugin manifests against required fields and version compatibility. - Updated `RPCClient` to use `MsgPackCodec` for message encoding. - Enhanced `PluginRunner` to support lifecycle hooks for plugins, including `on_load` and `on_unload`. - Added sys.path isolation to restrict plugin access to only necessary directories.
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
|
||||
在 Runner 进程中负责发现和加载插件。
|
||||
插件通过 SDK 编写,不再 import src.*。
|
||||
支持:manifest 校验、依赖解析(拓扑排序)、生命周期钩子。
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import importlib
|
||||
@@ -13,6 +15,8 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from src.plugin_runtime.runner.manifest_validator import ManifestValidator
|
||||
|
||||
logger = logging.getLogger("plugin_runtime.runner.plugin_loader")
|
||||
|
||||
|
||||
@@ -32,6 +36,20 @@ class PluginMeta:
|
||||
self.manifest = manifest
|
||||
self.version = manifest.get("version", "1.0.0")
|
||||
self.capabilities_required = manifest.get("capabilities", [])
|
||||
self.dependencies: list[str] = self._extract_dependencies(manifest)
|
||||
|
||||
@staticmethod
|
||||
def _extract_dependencies(manifest: dict[str, Any]) -> list[str]:
|
||||
raw = manifest.get("dependencies", [])
|
||||
result: list[str] = []
|
||||
for dep in raw:
|
||||
if isinstance(dep, str):
|
||||
result.append(dep.strip())
|
||||
elif isinstance(dep, dict):
|
||||
name = str(dep.get("name", "")).strip()
|
||||
if name:
|
||||
result.append(name)
|
||||
return result
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
@@ -43,19 +61,22 @@ class PluginLoader:
|
||||
- plugin.py: 插件入口模块(导出 create_plugin 工厂函数)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, host_version: str = ""):
|
||||
self._loaded_plugins: dict[str, PluginMeta] = {}
|
||||
self._failed_plugins: dict[str, str] = {}
|
||||
self._manifest_validator = ManifestValidator(host_version=host_version)
|
||||
|
||||
def discover_and_load(self, plugin_dirs: list[str]) -> list[PluginMeta]:
|
||||
"""扫描多个目录并加载所有插件
|
||||
"""扫描多个目录并加载所有插件(含依赖排序和 manifest 校验)
|
||||
|
||||
Args:
|
||||
plugin_dirs: 插件目录列表
|
||||
|
||||
Returns:
|
||||
成功加载的插件元数据列表
|
||||
成功加载的插件元数据列表(按依赖顺序)
|
||||
"""
|
||||
results = []
|
||||
# 第一阶段:发现并校验 manifest
|
||||
candidates: dict[str, tuple[str, dict[str, Any], str]] = {} # id -> (dir, manifest, plugin_path)
|
||||
for base_dir in plugin_dirs:
|
||||
if not os.path.isdir(base_dir):
|
||||
logger.warning(f"插件目录不存在: {base_dir}")
|
||||
@@ -73,12 +94,40 @@ class PluginLoader:
|
||||
continue
|
||||
|
||||
try:
|
||||
meta = self._load_single_plugin(plugin_dir, manifest_path, plugin_path)
|
||||
if meta:
|
||||
self._loaded_plugins[meta.plugin_id] = meta
|
||||
results.append(meta)
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"加载插件失败 [{plugin_dir}]: {e}", exc_info=True)
|
||||
self._failed_plugins[entry] = f"manifest 解析失败: {e}"
|
||||
logger.error(f"插件 {entry} manifest 解析失败: {e}")
|
||||
continue
|
||||
|
||||
if not self._manifest_validator.validate(manifest):
|
||||
errors = "; ".join(self._manifest_validator.errors)
|
||||
self._failed_plugins[entry] = f"manifest 校验失败: {errors}"
|
||||
continue
|
||||
|
||||
plugin_id = manifest.get("name", entry)
|
||||
candidates[plugin_id] = (plugin_dir, manifest, plugin_path)
|
||||
|
||||
# 第二阶段:依赖解析(拓扑排序)
|
||||
load_order, failed_deps = self._resolve_dependencies(candidates)
|
||||
|
||||
for pid, reason in failed_deps.items():
|
||||
self._failed_plugins[pid] = reason
|
||||
logger.error(f"插件 {pid} 依赖解析失败: {reason}")
|
||||
|
||||
# 第三阶段:按依赖顺序加载
|
||||
results = []
|
||||
for plugin_id in load_order:
|
||||
plugin_dir, manifest, plugin_path = candidates[plugin_id]
|
||||
try:
|
||||
meta = self._load_single_plugin(plugin_id, plugin_dir, manifest, plugin_path)
|
||||
if meta:
|
||||
self._loaded_plugins[meta.plugin_id] = meta
|
||||
results.append(meta)
|
||||
except Exception as e:
|
||||
self._failed_plugins[plugin_id] = str(e)
|
||||
logger.error(f"加载插件失败 [{plugin_id}]: {e}", exc_info=True)
|
||||
|
||||
return results
|
||||
|
||||
@@ -90,15 +139,78 @@ class PluginLoader:
|
||||
"""列出所有已加载的插件 ID"""
|
||||
return list(self._loaded_plugins.keys())
|
||||
|
||||
def _load_single_plugin(self, plugin_dir: str, manifest_path: str, plugin_path: str) -> PluginMeta | None:
|
||||
@property
|
||||
def failed_plugins(self) -> dict[str, str]:
|
||||
return dict(self._failed_plugins)
|
||||
|
||||
# ──── 依赖解析 ────────────────────────────────────────────
|
||||
|
||||
def _resolve_dependencies(
|
||||
self,
|
||||
candidates: dict[str, tuple[str, dict[str, Any], str]],
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
"""拓扑排序解析加载顺序,返回 (有序列表, 失败项 {id: reason})。"""
|
||||
available = set(candidates.keys())
|
||||
dep_graph: dict[str, set[str]] = {}
|
||||
failed: dict[str, str] = {}
|
||||
|
||||
for pid, (_, manifest, _) in candidates.items():
|
||||
raw_deps = manifest.get("dependencies", [])
|
||||
resolved: set[str] = set()
|
||||
missing: list[str] = []
|
||||
for dep in raw_deps:
|
||||
dep_name = dep if isinstance(dep, str) else str(dep.get("name", ""))
|
||||
dep_name = dep_name.strip()
|
||||
if not dep_name or dep_name == pid:
|
||||
continue
|
||||
if dep_name in available:
|
||||
resolved.add(dep_name)
|
||||
else:
|
||||
missing.append(dep_name)
|
||||
if missing:
|
||||
failed[pid] = f"缺少依赖: {', '.join(missing)}"
|
||||
dep_graph[pid] = resolved
|
||||
|
||||
# 移除失败项
|
||||
for pid in failed:
|
||||
dep_graph.pop(pid, None)
|
||||
|
||||
# Kahn 拓扑排序
|
||||
indegree = {pid: len(deps) for pid, deps in dep_graph.items()}
|
||||
reverse: dict[str, set[str]] = {pid: set() for pid in dep_graph}
|
||||
for pid, deps in dep_graph.items():
|
||||
for d in deps:
|
||||
if d in reverse:
|
||||
reverse[d].add(pid)
|
||||
|
||||
queue = deque(sorted(pid for pid, deg in indegree.items() if deg == 0))
|
||||
sorted_order: list[str] = []
|
||||
|
||||
while queue:
|
||||
current = queue.popleft()
|
||||
sorted_order.append(current)
|
||||
for dependent in sorted(reverse.get(current, [])):
|
||||
indegree[dependent] -= 1
|
||||
if indegree[dependent] == 0:
|
||||
queue.append(dependent)
|
||||
|
||||
cycle_plugins = {pid for pid, deg in indegree.items() if deg > 0}
|
||||
for pid in cycle_plugins:
|
||||
failed[pid] = "检测到循环依赖"
|
||||
|
||||
return sorted_order, failed
|
||||
|
||||
# ──── 单个插件加载 ────────────────────────────────────────
|
||||
|
||||
def _load_single_plugin(
|
||||
self,
|
||||
plugin_id: str,
|
||||
plugin_dir: str,
|
||||
manifest: dict[str, Any],
|
||||
plugin_path: str,
|
||||
) -> PluginMeta | None:
|
||||
"""加载单个插件"""
|
||||
# 1. 读取 manifest
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
plugin_id = os.path.basename(plugin_dir)
|
||||
|
||||
# 2. 动态导入插件模块
|
||||
# 动态导入插件模块
|
||||
module_name = f"_maibot_plugin_{plugin_id}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, plugin_path)
|
||||
if spec is None or spec.loader is None:
|
||||
@@ -109,7 +221,7 @@ class PluginLoader:
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# 3. 调用工厂函数创建插件实例
|
||||
# 调用工厂函数创建插件实例
|
||||
create_plugin = getattr(module, "create_plugin", None)
|
||||
if create_plugin is None:
|
||||
logger.error(f"插件 {plugin_id} 缺少 create_plugin 工厂函数")
|
||||
|
||||
Reference in New Issue
Block a user