ForcePilot/backend/package/yuxi/channel/infrastructure/plugin/registry_impl.py
Kris 9e503becd3
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat(plugin): 实现完整的插件注册管理系统
新增了插件相关的完整领域模型、应用服务、基础设施实现,包括:
1. 插件状态、注册模式、来源等基础枚举和数据结构
2. 插件清单解析、发现、加载工具类
3. 插件注册表领域服务和内存存储实现
4. 插件相关的命令、查询、事件定义
5. 插件REST API接口和DTO映射
6. 集成了原有通道适配器到插件系统
7. 新增内置插件注册和自动发现能力
2026-05-31 16:44:13 +08:00

213 lines
8.1 KiB
Python

from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from yuxi.channel.domain.exception.plugin_exception import PluginRegistrationException
from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId
from yuxi.channel.domain.model.plugin_registry.plugin_registration_mode import PluginRegistrationMode
from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.infrastructure.plugin.api_guard import ApiGuard
from yuxi.channel.infrastructure.plugin.discovery import PluginCandidate, PluginDiscovery
from yuxi.channel.infrastructure.plugin.loader import PluginLoader
from yuxi.channel.infrastructure.plugin.manifest_parser import ManifestParser
logger = logging.getLogger(__name__)
_registry_instance: PluginRegistryImpl | None = None
def get_registry() -> PluginRegistryImpl | None:
return _registry_instance
def set_registry(instance: PluginRegistryImpl | None) -> None:
global _registry_instance
_registry_instance = instance
@dataclass
class PluginRegistryImplConfig:
workspace_dir: str = "plugins"
installed_dir: str = "installed/plugins"
class PluginRegistryImpl:
def __init__(
self,
config: PluginRegistryImplConfig | None = None,
manifest_parser: ManifestParser | None = None,
loader: PluginLoader | None = None,
discovery: PluginDiscovery | None = None,
channel_config: object | None = None,
sse_endpoint: object | None = None,
):
self._config = config or PluginRegistryImplConfig()
self._registry = PluginRegistry()
self._manifest_parser = manifest_parser or ManifestParser()
self._loader = loader or PluginLoader()
self._discovery = discovery or PluginDiscovery(self._manifest_parser)
self._api_guard = ApiGuard()
self._channel_config = channel_config
self._sse_endpoint = sse_endpoint
set_registry(self)
@property
def registry(self) -> PluginRegistry:
return self._registry
async def register_plugin(
self,
candidate: PluginCandidate,
mode: PluginRegistrationMode = PluginRegistrationMode.FULL,
) -> PluginRecord:
manifest = await self._manifest_parser.parse_file(candidate.manifest_path) if candidate.manifest_path else None
if manifest is None and candidate.manifest_data:
manifest = self._manifest_parser.parse(candidate.manifest_data)
if manifest is None:
raise PluginRegistrationException(candidate.plugin_id)
record = PluginRecord(
record_id=PluginRecordId.generate(),
plugin_id=manifest.manifest.id,
name=manifest.manifest.name,
version=manifest.manifest.version,
channel_type=manifest.manifest.channel_type,
source=candidate.source,
manifest=manifest.manifest,
capabilities=manifest.manifest.capabilities,
config_schema=manifest.manifest.config_schema,
)
snapshot = self._registry.snapshot()
try:
if candidate.source.entry_file:
module = await self._loader.load(candidate.source.entry_file)
adapter_cls = self._loader.extract_adapter_class(module)
if adapter_cls:
record.set_adapter_class(adapter_cls)
api = self._api_guard.create(record.plugin_id)
await self._registry.register(record, api)
api.close()
if mode == PluginRegistrationMode.FULL and candidate.source.enabled:
config = self._resolve_config(record)
record.set_config(config)
from yuxi.channel.domain.service.plugin_registry_domain_service import PluginRegistryDomainService
PluginRegistryDomainService.transition_to_configured(record)
await self._registry.activate(record.plugin_id, **config)
except Exception as exc:
self._registry.restore(snapshot)
record.mark_error(str(exc))
raise PluginRegistrationException(record.plugin_id) from exc
return record
def register_builtin_adapter(self, name: str, adapter_cls: type[ChannelAdapterPort]) -> None:
existing = self._registry.get_record(name)
if existing:
return
from yuxi.channel.domain.model.plugin_registry.plugin_registry_factory import PluginRegistryFactory
caps = extract_capabilities(adapter_cls)
record = PluginRegistryFactory.create_builtin(
plugin_id=name,
name=name.capitalize(),
channel_type=name,
adapter_class=adapter_cls,
capabilities=caps,
)
self._registry.register_builtin(record)
def get_registered_adapter_classes(self) -> dict[str, type[ChannelAdapterPort]]:
result: dict[str, type[ChannelAdapterPort]] = {}
for record in self._registry.list_records():
if record.adapter_class:
result[record.channel_type] = record.adapter_class
return result
async def discover_and_register_all(
self,
mode: PluginRegistrationMode = PluginRegistrationMode.DISCOVERY,
) -> list[PluginRecord]:
candidates = await self._discovery.discover_all()
records = []
for candidate in candidates:
try:
record = await self.register_plugin(candidate, mode=mode)
records.append(record)
except Exception as exc:
logger.warning("failed to register plugin %s: %s", candidate.plugin_id, exc)
return records
def resolve_config(self, record: PluginRecord) -> dict:
return self._resolve_config(record)
def _resolve_config(self, record: PluginRecord) -> dict:
from yuxi.channel.channels._registry import get_channel_meta
default_config = record.get_default_config()
yaml_override = {}
if self._channel_config and hasattr(self._channel_config, "get_channel_config"):
yaml_override = self._channel_config.get_channel_config(record.channel_type)
merged = {**default_config, **yaml_override}
meta = get_channel_meta(record.channel_type)
for dep in meta.get("infra_dependencies", []):
if dep == "sse_push" and self._sse_endpoint and "sse_push" not in merged:
merged["sse_push"] = self._sse_endpoint
if dep == "hook_mappings" and self._channel_config:
hook_mappings = getattr(self._channel_config, "hook_mappings", None)
if hook_mappings and "mappings" not in merged:
merged["mappings"] = hook_mappings
env_mapping = meta.get("env_mapping", {})
if env_mapping:
env_overrides = self._apply_env_overrides_generic(env_mapping)
merged.update(env_overrides)
return merged
@staticmethod
def _apply_env_overrides_generic(env_mapping: dict[str, str]) -> dict:
overrides: dict = {}
for env_var, config_key in env_mapping.items():
value = os.getenv(env_var, "")
if value:
overrides[config_key] = value
return overrides
def extract_capabilities(adapter_cls: type) -> ChannelCapabilities:
from yuxi.channel.channels._registry import get_channel_meta
meta = get_channel_meta(adapter_cls.__name__.lower().replace("adapter", ""))
config_cls = meta.get("config_cls")
if config_cls:
try:
instance = config_cls()
if hasattr(instance, "capabilities"):
return instance.capabilities
except Exception:
pass
try:
instance = adapter_cls.__new__(adapter_cls)
if hasattr(instance, "capabilities"):
caps = instance.capabilities
if isinstance(caps, ChannelCapabilities):
return caps
except Exception:
pass
return ChannelCapabilities()