fix: 完善插件恢复与运行态收敛 (#6376)

This commit is contained in:
InfinityPacer
2026-08-21 18:09:29 +08:00
committed by GitHub
parent 2d80f9f04f
commit b9bbc8913c
32 changed files with 1823 additions and 163 deletions
+30 -1
View File
@@ -11,7 +11,7 @@ from app.runtime.config import settings
from app.runtime.extensions.plugin.contracts import supports_plugin_hook
from app.runtime.extensions.plugin.storage import PluginStorage
from app.runtime.extensions.plugin.system import PluginSystemServices
from app.schemas.plugin import Plugin
from app.schemas.plugin import Plugin, PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
@@ -31,6 +31,7 @@ class PluginCatalogFacade:
map_plugin: Callable[..., Optional[Plugin]],
auth_checker: Callable[..., bool],
plugin_attr: Callable[[str, str], Any],
runtime_status: Callable[[str], Optional[PluginRuntimeStatus]],
log: Any,
) -> None:
"""保存注册表、目录服务和插件外部系统端口。"""
@@ -44,6 +45,7 @@ class PluginCatalogFacade:
self._map_plugin = map_plugin
self._auth_checker = auth_checker
self._plugin_attr = plugin_attr
self._runtime_status = runtime_status
self._logger = log
def online(self, force: bool = False) -> list[Plugin]:
@@ -70,6 +72,7 @@ class PluginCatalogFacade:
id=plugin_id,
installed=plugin_id in installed,
state=self._safe_state(plugin_id, plugin_instance),
runtime_status=self._runtime_status(plugin_id),
has_page=supports_plugin_hook(plugin_class, "get_page"),
plugin_public_key=getattr(plugin_class, "plugin_public_key", None),
plugin_name=getattr(plugin_class, "plugin_name", None),
@@ -88,6 +91,32 @@ class PluginCatalogFacade:
plugins.sort(key=lambda item: getattr(item, "plugin_order", 0))
return plugins
def installed(self) -> list[Plugin]:
"""按安装清单投影插件,未加载项目仍返回可观察占位卡片。"""
installed_ids = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
local_by_id = {
plugin.id: plugin
for plugin in self.local()
if plugin.installed and plugin.id
}
result = []
for plugin_id in installed_ids:
plugin = local_by_id.get(plugin_id)
if plugin:
result.append(plugin)
continue
result.append(Plugin(
id=plugin_id,
plugin_name=plugin_id,
installed=True,
state=False,
runtime_status=self._runtime_status(plugin_id),
is_local=True,
))
# 展示顺序由持久化安装清单保留,避免后台恢复或占位卡片出现后改变用户看到的位置。
# 前端可用用户级 PluginOrder 覆盖,plugin_order 只用于运行期插件发现顺序。
return result
def local_version(self, plugin_id: str) -> Optional[str]:
"""读取指定已安装插件版本,不触发全量目录投影。"""
installed = self._storage().read(SystemConfigKey.UserInstalledPlugins) or []
+37 -4
View File
@@ -2,11 +2,29 @@
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from app.runtime.extensions.plugin.system import PluginSystemServices
@dataclass(frozen=True)
class PluginDependencyInstallResult:
"""记录插件依赖检查结果,区分无缺失、安装成功和安装失败。"""
missing: list[str]
success: bool
@dataclass(frozen=True)
class PluginDependencyClassification:
"""按当前源码和 Python 环境划分已安装插件。"""
ready: tuple[str, ...]
missing_dependencies: tuple[str, ...]
missing_source: tuple[str, ...]
class PluginDependencyService:
"""执行缺失插件依赖的发现和安装,不参与插件生命周期。"""
@@ -20,12 +38,12 @@ class PluginDependencyService:
self._system = system
self._logger = log
def install_missing(self) -> list[str]:
"""安装当前环境缺失的插件依赖并返回检查到的依赖名"""
def install_missing_with_status(self) -> PluginDependencyInstallResult:
"""安装缺失依赖并返回安装器的明确结果"""
installer = self._system().dependency
missing = installer.find_missing()
if not missing:
return missing
return PluginDependencyInstallResult(missing=[], success=True)
self._logger.debug(f"检测到缺失的依赖项: {missing}")
self._logger.info(f"开始安装缺失的依赖项,共 {len(missing)} 个...")
started = time.time()
@@ -39,4 +57,19 @@ class PluginDependencyService:
self._logger.warning(
f"存在缺失依赖项安装失败,请尝试手动安装,总耗时:{elapsed:.2f}"
)
return missing
return PluginDependencyInstallResult(missing=missing, success=success)
def install_missing(self) -> list[str]:
"""安装当前环境缺失的插件依赖并保持历史列表返回合同。"""
return self.install_missing_with_status().missing
def classify_plugins(self) -> PluginDependencyClassification:
"""返回启动编排使用的轻量插件分类。"""
ready, missing_dependencies, missing_source = (
self._system().dependency.classify_plugins()
)
return PluginDependencyClassification(
ready=tuple(ready),
missing_dependencies=tuple(missing_dependencies),
missing_source=tuple(missing_source),
)
+35 -5
View File
@@ -6,6 +6,8 @@ import traceback
from collections.abc import Callable
from typing import Any, Optional
from app.schemas.plugin import PluginRuntimeStatus
class PluginLifecycle:
"""管理插件发现、初始化、启停和热重载,不持有市场或 HTTP 路由职责。"""
@@ -23,6 +25,7 @@ class PluginLifecycle:
clear_tools: Callable[[], None],
enable_events: Callable[[Any], None],
disable_events: Callable[[Any], None],
runtime_status_writer: Callable[[str, PluginRuntimeStatus], None],
log: Any,
event_sender: Callable[..., Any],
) -> None:
@@ -37,12 +40,19 @@ class PluginLifecycle:
self._clear_tools = clear_tools
self._enable_events = enable_events
self._disable_events = disable_events
self._runtime_status_writer = runtime_status_writer
self._logger = log
self._event_sender = event_sender
def start(self, plugin_id: Optional[str] = None) -> None:
"""加载并初始化指定插件或全部已安装插件。"""
def start(
self,
plugin_id: Optional[str] = None,
) -> dict[str, PluginRuntimeStatus]:
"""加载并初始化插件,返回每个目标的明确运行结果。"""
installed_plugins = self._installed_plugins()
results: dict[str, PluginRuntimeStatus] = {}
if plugin_id:
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
def check_module(module: Any) -> bool:
"""判断模块是否具备宿主插件最小生命周期钩子。"""
@@ -58,6 +68,9 @@ class PluginLifecycle:
if not self._auth_checker(plugin):
if current_id in self._classes:
self._classes[current_id] = plugin
status = PluginRuntimeStatus.BLOCKED_BY_POLICY
self._runtime_status_writer(current_id, status)
results[current_id] = status
continue
self._classes[current_id] = plugin
instance = plugin()
@@ -70,11 +83,22 @@ class PluginLifecycle:
self._enable_events(plugin)
else:
self._disable_events(plugin)
status = PluginRuntimeStatus.ACTIVE
self._runtime_status_writer(current_id, status)
results[current_id] = status
except Exception as error: # noqa: BLE001
status = PluginRuntimeStatus.LOAD_FAILED
self._runtime_status_writer(current_id, status)
results[current_id] = status
self._logger.error(
f"加载插件 {current_id} 出错:{error} - {traceback.format_exc()}"
)
if plugin_id and plugin_id not in results:
status = PluginRuntimeStatus.LOAD_FAILED
self._runtime_status_writer(plugin_id, status)
results[plugin_id] = status
self._clear_tools()
return results
def initialize(self, plugin_id: str, config: dict) -> None:
"""重新应用指定插件配置并刷新事件注册状态。"""
@@ -115,11 +139,17 @@ class PluginLifecycle:
self._clear_tools()
self._logger.info("插件停止完成")
def reload(self, plugin_id: str, reload_event: Any) -> None:
"""重启指定插件并广播插件重载事件。"""
def reload(
self,
plugin_id: str,
reload_event: Any,
) -> PluginRuntimeStatus:
"""重启指定插件并返回本次加载结果。"""
self._runtime_status_writer(plugin_id, PluginRuntimeStatus.READY)
self.stop(plugin_id)
self.start(plugin_id)
status = self.start(plugin_id)[plugin_id]
self._event_sender(reload_event, data={"plugin_id": plugin_id})
return status
def _stop_plugin(self, plugin: Any) -> None:
"""按插件旧 ABI 顺序关闭资源和服务。"""
+8
View File
@@ -10,6 +10,7 @@ from typing import Any, Optional
FederatedChangeResolver = Callable[[Path], Optional[tuple[str, Optional[dict], bool]]]
RuntimePluginResolver = Callable[[Path], Optional[str]]
MonitorSuppression = Callable[[str], bool]
LocalCandidateResolver = Callable[[Path], Optional[dict]]
LocalPluginSync = Callable[[str, Optional[dict]], bool]
PluginReloader = Callable[[str], Any]
@@ -80,6 +81,7 @@ class PluginChangeMonitor:
dependency_manifest_status: DependencyManifestStatus,
watch: WatchFunction,
log: Any,
monitor_suppressed: Optional[MonitorSuppression] = None,
) -> None:
"""保存监控路径、变化解析器和副作用回调。"""
self._runtime_root = runtime_root
@@ -88,6 +90,7 @@ class PluginChangeMonitor:
self._recent_sync = recent_sync
self._federated_change = federated_change
self._runtime_plugin = runtime_plugin
self._monitor_suppressed = monitor_suppressed or (lambda _plugin_id: False)
self._local_candidate = local_candidate
self._sync_local = sync_local
self._reload_plugin = reload_plugin
@@ -150,6 +153,11 @@ class PluginChangeMonitor:
if event_path.suffix != ".py":
continue
runtime_plugin_id = self._runtime_plugin(event_path)
if runtime_plugin_id and self._monitor_suppressed(runtime_plugin_id):
self._logger.debug(
f"插件 {runtime_plugin_id} 正在写入,跳过本批文件监控重载"
)
continue
candidate = (
self._local_candidate(event_path)
if not runtime_plugin_id
+47 -1
View File
@@ -2,6 +2,8 @@
from typing import Any, Dict, Optional
from app.schemas.plugin import PluginRuntimeStatus
class PluginRegistry:
"""集中持有插件类和运行实例,并为读取方提供稳定快照。"""
@@ -10,6 +12,9 @@ class PluginRegistry:
"""创建彼此独立但生命周期一致的类表和实例表。"""
self._classes: Dict[str, Any] = {}
self._running: Dict[str, Any] = {}
self._runtime_statuses: Dict[str, PluginRuntimeStatus] = {}
self._settling = False
self._generation = 0
@property
def classes(self) -> Dict[str, Any]:
@@ -45,12 +50,53 @@ class PluginRegistry:
"""复制运行实例表,避免插件重载期间迭代失效。"""
return dict(self._running)
def set_runtime_status(
self,
plugin_id: str,
status: PluginRuntimeStatus,
) -> None:
"""记录插件当前状态,并在实际变化时推进前端刷新代次。"""
if self._runtime_statuses.get(plugin_id) == status:
return
self._runtime_statuses[plugin_id] = status
self._generation += 1
def runtime_status(self, plugin_id: str) -> Optional[PluginRuntimeStatus]:
"""读取指定插件状态。"""
return self._runtime_statuses.get(plugin_id)
def runtime_status_snapshot(self) -> Dict[str, PluginRuntimeStatus]:
"""复制插件状态表,避免后台加载期间迭代失效。"""
return dict(self._runtime_statuses)
def set_settling(self, settling: bool) -> None:
"""标记启动后的插件源码与依赖收敛任务是否仍在执行。"""
if self._settling == settling:
return
self._settling = settling
self._generation += 1
@property
def settling(self) -> bool:
"""返回插件后台收敛任务是否仍在执行。"""
return self._settling
@property
def generation(self) -> int:
"""返回状态变化代次,供读取方识别刷新边界。"""
return self._generation
def remove(self, plugin_id: str) -> None:
"""同时移除指定插件类运行实例。"""
"""同时移除指定插件类运行实例和状态"""
self._classes.pop(plugin_id, None)
self._running.pop(plugin_id, None)
if self._runtime_statuses.pop(plugin_id, None) is not None:
self._generation += 1
def clear(self) -> None:
"""原地清空注册表,保持外部持有的兼容字典引用有效。"""
self._classes.clear()
self._running.clear()
if self._runtime_statuses:
self._runtime_statuses.clear()
self._generation += 1
+117 -9
View File
@@ -1,5 +1,7 @@
import asyncio
import posixpath
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple
@@ -7,6 +9,7 @@ from watchfiles import watch
from app.schemas.plugin import Plugin as _SchemaPlugin
from app.schemas.plugin import PluginDashboard as _SchemaPluginDashboard
from app.schemas.plugin import PluginRuntimeStatus
from app.foundation.crypto import RSAUtils
from app.foundation.singleton import Singleton
from app.foundation.version import compare_version
@@ -34,7 +37,11 @@ from app.runtime.extensions.plugin.clone import PluginCloneService
from app.runtime.extensions.plugin.access import PluginAccessPolicy
from app.runtime.extensions.plugin.catalog import PluginCatalogFacade
from app.runtime.extensions.plugin.paths import PluginPathResolver
from app.runtime.extensions.plugin.dependency import PluginDependencyService
from app.runtime.extensions.plugin.dependency import (
PluginDependencyClassification,
PluginDependencyInstallResult,
PluginDependencyService,
)
from app.runtime.extensions.plugin.storage import PluginConfigStore
from app.schemas.types import EventType, SystemConfigKey
@@ -151,10 +158,13 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
map_plugin=lambda **kwargs: self._process_plugin_info(**kwargs),
auth_checker=lambda **kwargs: self.__set_and_check_auth_level(**kwargs),
plugin_attr=lambda pid, attr: self.get_plugin_attr(pid, attr),
runtime_status=self._plugin_registry.runtime_status,
log=logger,
)
# 本地插件同步写入运行目录后的短时忽略窗口
self._recent_local_sync: Dict[str, float] = {}
self._monitor_suppression_lock = threading.Lock()
self._suppressed_monitor_plugins: Dict[str, int] = {}
self._plugin_paths = PluginPathResolver(
runtime_root=settings.ROOT_PATH / "app" / "plugins",
running=lambda: self._running_plugins,
@@ -205,6 +215,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
clear_tools=self.clear_plugin_agent_tools_cache,
enable_events=eventmanager.enable_event_handler,
disable_events=eventmanager.disable_event_handler,
runtime_status_writer=self._plugin_registry.set_runtime_status,
log=logger,
event_sender=eventmanager.send_event,
)
@@ -298,17 +309,19 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""按最新系统配置完整重启插件。"""
# 停止已有插件
self.stop()
# 启动插件
self.start()
classification = self.classify_plugins()
self.apply_plugin_dependency_classification(classification)
for plugin_id in classification.ready:
self.start(plugin_id)
def start(self, pid: Optional[str] = None):
def start(self, pid: Optional[str] = None) -> Dict[str, PluginRuntimeStatus]:
"""
启动加载插件
:param pid: 插件ID,为空加载所有插件
"""
_legacy_diagnostics_configurator(enabled=settings.DEBUG, emitter=logger.warning)
self._plugin_lifecycle.start(pid)
return self._plugin_lifecycle.start(pid)
def init_plugin(self, plugin_id: str, conf: dict):
"""
@@ -385,7 +398,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
def start_monitor(self):
"""按当前配置启动插件文件修改监测。"""
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
if (
not self.is_plugin_settling()
and (settings.DEV or settings.PLUGIN_AUTO_RELOAD)
):
self._plugin_monitor.start()
def reload_monitor(self):
@@ -393,7 +409,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
重新加载插件文件修改监测
"""
self._plugin_monitor.reload(
enabled=settings.DEV or settings.PLUGIN_AUTO_RELOAD
enabled=(
not self.is_plugin_settling()
and (settings.DEV or settings.PLUGIN_AUTO_RELOAD)
)
)
def stop_monitor(self):
@@ -413,6 +432,7 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
recent_sync=self._recent_local_sync,
federated_change=self._get_federated_plugin_change,
runtime_plugin=self._get_plugin_id_from_path,
monitor_suppressed=self.is_plugin_monitor_suppressed,
local_candidate=self._get_local_plugin_candidate_from_path,
sync_local=self._sync_local_plugin_if_installed,
reload_plugin=self.reload_plugin,
@@ -454,19 +474,43 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
return self._local_plugin_sync.sync(pid, candidate)
@contextmanager
def suppress_plugin_monitor(self, plugin_id: str):
"""在插件目录原子更新期间阻止文件监控抢先重载半成品。"""
normalized_id = plugin_id.lower()
with self._monitor_suppression_lock:
self._suppressed_monitor_plugins[normalized_id] = (
self._suppressed_monitor_plugins.get(normalized_id, 0) + 1
)
try:
yield
finally:
with self._monitor_suppression_lock:
count = self._suppressed_monitor_plugins.get(normalized_id, 0)
if count <= 1:
self._suppressed_monitor_plugins.pop(normalized_id, None)
else:
self._suppressed_monitor_plugins[normalized_id] = count - 1
def is_plugin_monitor_suppressed(self, plugin_id: str) -> bool:
"""判断指定插件是否处于安装或替换写入阶段。"""
with self._monitor_suppression_lock:
return self._suppressed_monitor_plugins.get(plugin_id.lower(), 0) > 0
def remove_plugin(self, plugin_id: str):
"""
从内存中移除一个插件
:param plugin_id: 插件ID
"""
self._plugin_lifecycle.stop(plugin_id)
self._plugin_registry.remove(plugin_id)
def reload_plugin(self, plugin_id: str):
def reload_plugin(self, plugin_id: str) -> PluginRuntimeStatus:
"""
将一个插件重新加载到内存
:param plugin_id: 插件ID
"""
self._plugin_lifecycle.reload(plugin_id, EventType.PluginReload)
return self._plugin_lifecycle.reload(plugin_id, EventType.PluginReload)
@staticmethod
def _clear_plugin_modules(plugin_id: Optional[str] = None):
@@ -499,6 +543,66 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
log=logger,
).install_missing()
@staticmethod
def install_plugin_missing_dependencies_with_status() -> PluginDependencyInstallResult:
"""安装插件缺失依赖并返回缺失项及安装成功状态。"""
return PluginDependencyService(
system=get_plugin_system,
log=logger,
).install_missing_with_status()
@staticmethod
def classify_plugins() -> PluginDependencyClassification:
"""按源码和依赖是否就绪划分已安装插件。"""
return PluginDependencyService(
system=get_plugin_system,
log=logger,
).classify_plugins()
def apply_plugin_dependency_classification(
self,
classification: PluginDependencyClassification,
) -> None:
"""把源码和依赖分类写入运行状态,已激活插件保持当前结果。"""
running_ids = set(self._plugin_registry.running_ids())
for plugin_id in classification.missing_source:
self._plugin_registry.set_runtime_status(
plugin_id,
PluginRuntimeStatus.SOURCE_MISSING,
)
for plugin_id in classification.missing_dependencies:
self._plugin_registry.set_runtime_status(
plugin_id,
PluginRuntimeStatus.DEPENDENCY_PENDING,
)
for plugin_id in classification.ready:
current_status = self._plugin_registry.runtime_status(plugin_id)
if (
plugin_id in running_ids
and current_status is not PluginRuntimeStatus.DEPENDENCY_PENDING
):
continue
self._plugin_registry.set_runtime_status(
plugin_id,
PluginRuntimeStatus.READY,
)
def set_plugin_settling(self, settling: bool) -> None:
"""更新启动后的插件恢复任务状态。"""
self._plugin_registry.set_settling(settling)
def get_plugin_runtime_statuses(self) -> Dict[str, PluginRuntimeStatus]:
"""返回插件运行状态快照。"""
return self._plugin_registry.runtime_status_snapshot()
def get_plugin_runtime_generation(self) -> int:
"""返回插件状态变化代次。"""
return self._plugin_registry.generation
def is_plugin_settling(self) -> bool:
"""返回插件源码和依赖是否仍在后台恢复。"""
return self._plugin_registry.settling
def get_plugin_config(self, pid: str) -> dict:
"""
获取插件配置
@@ -777,6 +881,10 @@ class PluginManager(ConfigReloadMixin, metaclass=Singleton):
"""
return self._plugin_catalog_view.local()
def get_installed_plugins(self) -> List[_SchemaPlugin]:
"""按安装清单返回插件,即使运行时尚未加载也保留卡片。"""
return self._plugin_catalog_view.installed()
def get_local_plugin_version(self, pid: str) -> Optional[str]:
"""
获取指定已安装插件的本地版本,不触发全部插件的状态、页面和权限计算。