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()