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
+22 -4
View File
@@ -12,6 +12,7 @@ import tempfile
import threading
import time
import traceback
import uuid
import zipfile
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Dict, List, Optional, Tuple, Set, Callable, Awaitable, Sequence
@@ -1129,21 +1130,38 @@ class PluginHelper(metaclass=WeakSingleton):
backup_root = settings.CONFIG_PATH / "plugins_backup"
backup_dir = backup_root / pid.lower()
staging_dir = backup_root / f".{pid.lower()}.tmp-{uuid.uuid4().hex}"
previous_dir = backup_root / f".{pid.lower()}.old-{uuid.uuid4().hex}"
try:
backup_root.mkdir(parents=True, exist_ok=True)
if backup_dir.exists():
shutil.rmtree(backup_dir, ignore_errors=True)
shutil.copytree(
plugin_dir,
backup_dir,
dirs_exist_ok=True,
staging_dir,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store")
)
if backup_dir.exists():
backup_dir.replace(previous_dir)
staging_dir.replace(backup_dir)
if previous_dir.exists():
shutil.rmtree(previous_dir, ignore_errors=True)
logger.info(f"已刷新插件备份: {pid}")
return True
except Exception as e:
if not backup_dir.exists() and previous_dir.exists():
try:
previous_dir.replace(backup_dir)
except Exception as rollback_error:
logger.error(
f"恢复插件旧备份失败,已保留恢复材料 {previous_dir}: "
f"{rollback_error}"
)
logger.error(f"刷新插件备份失败: {pid} - {e}")
return False
finally:
if staging_dir.exists():
shutil.rmtree(staging_dir, ignore_errors=True)
if backup_dir.exists() and previous_dir.exists():
shutil.rmtree(previous_dir, ignore_errors=True)
def __collect_plugin_wheels_dirs(self) -> List[Path]:
"""
+33
View File
@@ -297,6 +297,39 @@ class PluginDependencyInstaller:
logger.error(f"收集所有需要安装或更新的依赖项时发生错误:{err}")
return []
def classify_plugins(self) -> tuple[list[str], list[str], list[str]]:
"""按源码和依赖状态划分已安装插件。"""
ready: list[str] = []
missing_dependencies: list[str] = []
missing_source: list[str] = []
installed_packages = self._installed_packages()
for plugin_id in self._installed_plugins_provider() or []:
plugin_dir = self._plugin_dir / plugin_id.lower()
if not plugin_dir.is_dir():
missing_source.append(plugin_id)
continue
try:
manifest = load_dependency_manifest(plugin_dir)
requirements = [] if manifest is None else [
requirement
for requirement in manifest.dependencies
if not requirement.marker or requirement.marker.evaluate()
]
except PluginDependencyManifestError as error:
logger.error(f"插件 {plugin_id} 依赖清单无效:{error}")
missing_dependencies.append(plugin_id)
continue
if all(
self._requirement_satisfied(requirement, installed_packages)
for requirement in requirements
):
ready.append(plugin_id)
else:
missing_dependencies.append(plugin_id)
return ready, missing_dependencies, missing_source
def _wheels_dirs(self) -> list[Path]:
"""收集已安装插件附带的本地 wheels 目录。"""
result = []
+29 -26
View File
@@ -11,6 +11,7 @@ from app.application.configuration import get_configured_system_config as System
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.external.market import PluginHelper
from app.adapters.system.plugin.package import PluginPackageManager
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
# 默认只向智能体返回一个可读预览,避免超大插件数据挤爆上下文窗口。
@@ -79,10 +80,11 @@ def refresh_plugin_registrations(plugin_id: str) -> None:
register_plugin_api(plugin_id)
def reload_plugin_runtime(plugin_id: str) -> None:
def reload_plugin_runtime(plugin_id: str) -> PluginRuntimeStatus:
"""重载插件实例并重新注册其命令、定时任务和 API。"""
get_plugin_manager().reload_plugin(plugin_id)
runtime_status = get_plugin_manager().reload_plugin(plugin_id)
refresh_plugin_registrations(plugin_id)
return runtime_status
def summarize_plugin(plugin: Any) -> dict[str, Any]:
@@ -349,30 +351,31 @@ async def install_plugin_runtime(
target_id,
)
result = await PluginInstallCommand(
installed_plugins_reader=lambda: SystemConfigOper().get(
SystemConfigKey.UserInstalledPlugins
) or [],
installed_plugins_writer=save_installed_plugins,
plugin_ids_provider=plugin_manager.get_plugin_ids,
compatibility_checker=skip_compatibility_check,
package_installer=install_package,
package_checkpointer=package_manager.async_checkpoint,
package_committer=package_manager.async_commit,
package_rollback=package_manager.async_rollback,
install_reporter=lambda target_id, target_repo: (
MoviePilotServerHelper.async_install_plugin_reg(
plugin_id=target_id,
repo_url=target_repo,
)
),
plugin_reloader=reload_runtime,
registration_refresher=refresh_registrations,
).execute(
plugin_id=plugin_id,
repo_url=repo_url,
force=force,
)
with plugin_manager.suppress_plugin_monitor(plugin_id):
result = await PluginInstallCommand(
installed_plugins_reader=lambda: SystemConfigOper().get(
SystemConfigKey.UserInstalledPlugins
) or [],
installed_plugins_writer=save_installed_plugins,
plugin_ids_provider=plugin_manager.get_plugin_ids,
compatibility_checker=skip_compatibility_check,
package_installer=install_package,
package_checkpointer=package_manager.async_checkpoint,
package_committer=package_manager.async_commit,
package_rollback=package_manager.async_rollback,
install_reporter=lambda target_id, target_repo: (
MoviePilotServerHelper.async_install_plugin_reg(
plugin_id=target_id,
repo_url=target_repo,
)
),
plugin_reloader=reload_runtime,
registration_refresher=refresh_registrations,
).execute(
plugin_id=plugin_id,
repo_url=repo_url,
force=force,
)
return result.success, result.message, result.refreshed_only
+19 -1
View File
@@ -12,6 +12,7 @@ from app.agent.tools.impl._plugin_tool_utils import (
reload_plugin_runtime,
)
from app.runtime.log import logger
from app.schemas.plugin import PluginRuntimeStatus
class ReloadPluginInput(BaseModel):
@@ -57,9 +58,26 @@ class ReloadPluginTool(MoviePilotTool):
ensure_ascii=False,
)
reload_plugin_runtime(plugin_id)
runtime_status = reload_plugin_runtime(plugin_id)
refreshed_plugin = get_plugin_snapshot(plugin_id) or plugin_info
if runtime_status is not PluginRuntimeStatus.ACTIVE:
return json.dumps(
{
"success": False,
**refreshed_plugin,
"runtime_status": runtime_status,
"message": (
"未通过用户认证,请查看日志"
if runtime_status is PluginRuntimeStatus.BLOCKED_BY_POLICY
else "插件加载失败,请查看插件日志"
),
},
ensure_ascii=False,
indent=2,
default=str,
)
return json.dumps(
{
"success": True,
+50 -9
View File
@@ -20,6 +20,8 @@ from app.schemas.plugin import PluginRatingMap as _SchemaPluginRatingMap
from app.schemas.plugin import PluginRatingRequest as _SchemaPluginRatingRequest
from app.schemas.plugin import PluginReleaseData as _SchemaPluginReleaseData
from app.schemas.plugin import PluginRemoteInfo as _SchemaPluginRemoteInfo
from app.schemas.plugin import PluginRuntimeStatus as _SchemaPluginRuntimeStatus
from app.schemas.plugin import PluginRuntimeSummary as _SchemaPluginRuntimeSummary
from app.schemas.plugin import PluginSidebarNavItem as _SchemaPluginSidebarNavItem
from app.schemas.response import Response as _SchemaResponse
from app.schemas.token import TokenPayload as _SchemaTokenPayload
@@ -254,7 +256,7 @@ async def all_plugins(
# 已安装插件
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
if state == "installed":
return installed_plugins
return plugin_manager.get_installed_plugins()
# 未安装的本地插件
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
@@ -306,6 +308,34 @@ async def installed(_: ApiPrincipal = Depends(get_current_active_superuser_async
return get_configured_system_config().get(SystemConfigKey.UserInstalledPlugins) or []
@router.get(
"/runtime",
summary="插件运行时收敛状态",
response_model=_SchemaPluginRuntimeSummary,
)
async def runtime_status(
_: ApiPrincipal = Depends(get_current_active_superuser_async),
) -> _SchemaPluginRuntimeSummary:
"""返回插件页轮询所需的轻量状态摘要。"""
plugin_manager = PluginManager()
statuses = plugin_manager.get_plugin_runtime_statuses()
pending = {
_SchemaPluginRuntimeStatus.SOURCE_MISSING,
_SchemaPluginRuntimeStatus.DEPENDENCY_PENDING,
_SchemaPluginRuntimeStatus.READY,
}
failed = {
_SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY,
_SchemaPluginRuntimeStatus.LOAD_FAILED,
}
return _SchemaPluginRuntimeSummary(
ready=not plugin_manager.is_plugin_settling(),
generation=plugin_manager.get_plugin_runtime_generation(),
pending_count=sum(status in pending for status in statuses.values()),
failed_count=sum(status in failed for status in statuses.values()),
)
@router.get("/history/{plugin_id}", summary="获取插件更新说明", response_model=_SchemaPlugin)
async def plugin_history(
plugin_id: str,
@@ -474,10 +504,19 @@ def reload_plugin(
重新加载插件
"""
# 重新加载插件
PluginManager().reload_plugin(plugin_id)
runtime_status = PluginManager().reload_plugin(plugin_id)
# 注册插件服务
register_plugin(plugin_id)
return _SchemaResponse(success=True)
if runtime_status is _SchemaPluginRuntimeStatus.ACTIVE:
return _SchemaResponse(success=True)
return _SchemaResponse(
success=False,
message=(
"未通过用户认证,请查看日志"
if runtime_status is _SchemaPluginRuntimeStatus.BLOCKED_BY_POLICY
else "插件加载失败,请查看插件日志"
),
)
@router.get("/install/{plugin_id}", summary="安装插件", response_model=_SchemaResponse[None])
@@ -493,6 +532,7 @@ async def install(
"""
plugin_helper = PluginHelper()
package_manager = PluginPackageManager(plugin_helper)
plugin_manager = PluginManager()
async def save_installed_plugins(plugin_ids: List[str]) -> object:
"""保存安装用例确认后的插件列表。"""
@@ -543,12 +583,13 @@ async def install(
plugin_reloader=reload_runtime,
registration_refresher=refresh_registrations,
)
result = await command.execute(
plugin_id=plugin_id,
repo_url=repo_url,
release_version=release_version,
force=bool(force),
)
with plugin_manager.suppress_plugin_monitor(plugin_id):
result = await command.execute(
plugin_id=plugin_id,
repo_url=repo_url,
release_version=release_version,
force=bool(force),
)
if not result.success:
return _SchemaResponse(success=False, message=result.message)
return _SchemaResponse(success=True)
+1 -19
View File
@@ -356,27 +356,9 @@ class PluginInstallCommand:
dependency_supported=False,
errors=tuple(errors),
)
rollback_message = []
rollback_message.append("插件文件已恢复" if file_restored else "插件文件恢复失败")
if installed_list_persisted:
rollback_message.append(
"已安装列表已恢复"
if installed_list_restored
else "已安装列表恢复失败"
)
if runtime_touched:
rollback_message.append(
"旧运行态已恢复" if runtime_restored else "旧运行态恢复失败"
)
rollback_message.append(
"旧路由和服务注册已恢复"
if registrations_restored
else "旧路由和服务注册恢复失败"
)
rollback_message.append("Python依赖变更不支持自动回滚")
return PluginInstallResult(
success=False,
message=f"{message}{''.join(rollback_message)}",
message=message,
package_installed=package_installed,
installed_list_persisted=installed_list_persisted,
failure_stage=stage,
+180 -43
View File
@@ -1,12 +1,13 @@
import errno
import json
import re
import shutil
import uuid
from pathlib import Path
from typing import Union, Optional
from app.chain import ChainBase
from app.runtime.config import settings
from app.application.plugin.runtime import get_plugin_manager
from app.runtime.state import SystemHelper
from app.runtime.log import logger
from app.schemas.message import Message
@@ -22,6 +23,7 @@ class SystemChain(ChainBase):
"""
_restart_file = "__system_restart__"
_plugin_restore_pending_file = "__plugin_restore_pending__"
def remote_clear_cache(self, channel: NotificationChannel, userid: Union[int, str], source: Optional[str] = None):
"""
@@ -77,31 +79,46 @@ class SystemChain(ChainBase):
# 确保备份目录存在
backup_dir.mkdir(parents=True, exist_ok=True)
pending_file = backup_dir / SystemChain._plugin_restore_pending_file
pending_items = (
SystemChain.__read_plugin_restore_pending(pending_file)
if pending_file.exists()
else None
)
# 需要排除的文件和目录
exclude_items = {"__init__.py", "__pycache__", ".DS_Store"}
backup_failed = False
# 遍历插件目录,备份除排除项外的所有内容
for item in plugins_dir.iterdir():
if item.name in exclude_items:
continue
# 失败项目的原快照是下一次恢复的唯一材料,关停备份不能覆盖它。
if pending_file.exists() and (
pending_items is None or item.name in pending_items
):
logger.debug(f"插件 {item.name} 有待重试恢复标记,保留原快照")
continue
target_path = backup_dir / item.name
# 如果是目录
if item.is_dir():
if target_path.exists():
continue
shutil.copytree(item, target_path)
logger.debug(f"已备份插件目录: {item.name}")
# 如果是文件
elif item.is_file():
if target_path.exists():
continue
shutil.copy2(item, target_path)
logger.info(f"备份插件文件: {item.name}")
try:
SystemChain.__replace_snapshot(
item,
target_path,
ignore=shutil.ignore_patterns(
"__pycache__", "*.pyc", ".DS_Store"
) if item.is_dir() else None,
)
logger.debug(f"已备份插件项目: {item.name}")
except Exception as e:
backup_failed = True
logger.error(f"备份插件 {item.name} 失败: {e}")
logger.info(f"插件备份完成,备份位置: {backup_dir}")
if backup_failed:
logger.warning(f"插件备份部分失败,保留可用旧快照: {backup_dir}")
else:
logger.info(f"插件备份完成,备份位置: {backup_dir}")
except Exception as e:
logger.error(f"插件备份失败: {str(e)}")
@@ -124,44 +141,164 @@ class SystemChain(ChainBase):
logger.info("插件备份目录不存在,跳过恢复")
return
# 系统被重置才恢复插件
if SystemHelper().is_system_reset():
pending_file = backup_dir / SystemChain._plugin_restore_pending_file
# 确保插件目录存在
plugins_dir.mkdir(parents=True, exist_ok=True)
# 系统重置或上次恢复未完成时才消费备份。
system_reset = SystemHelper().is_system_reset()
should_restore = system_reset or pending_file.exists()
if not should_restore:
logger.info("当前不是系统重置,保留插件备份供后续重置使用")
return
# 遍历备份目录,恢复所有内容
restored_count = 0
for item in backup_dir.iterdir():
target_path = plugins_dir / item.name
try:
# 如果是目录,且目录内有内容
if item.is_dir() and any(item.iterdir()):
if target_path.exists():
shutil.rmtree(target_path)
shutil.copytree(item, target_path)
logger.debug(f"已恢复插件目录: {item.name}")
restored_count += 1
# 如果是文件
elif item.is_file():
shutil.copy2(item, target_path)
logger.debug(f"已恢复插件文件: {item.name}")
restored_count += 1
except Exception as e:
logger.error(f"恢复插件 {item.name} 时发生错误: {str(e)}")
# 确保插件目录存在
plugins_dir.mkdir(parents=True, exist_ok=True)
# 遍历备份目录,恢复所有内容
restored_count = 0
restore_failed = False
failed_items: dict[str, bool] = {}
pending_items = (
SystemChain.__read_plugin_restore_pending(pending_file)
if pending_file.exists() and not system_reset
else None
)
for item in backup_dir.iterdir():
if (
item.name == SystemChain._plugin_restore_pending_file
or item.name.startswith(".")
):
continue
target_path = plugins_dir / item.name
if pending_items is not None:
if item.name not in pending_items:
continue
if not pending_items[item.name] and target_path.exists():
logger.info(f"插件 {item.name} 已在恢复失败后重新安装,跳过备份覆盖")
continue
target_existed = target_path.exists()
try:
if item.is_dir() or item.is_file():
SystemChain.__replace_snapshot(item, target_path)
logger.debug(f"已恢复插件文件: {item.name}")
restored_count += 1
except Exception as e:
restore_failed = True
failed_items[item.name] = target_existed
logger.error(f"恢复插件 {item.name} 时发生错误: {str(e)}")
continue
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
# 安装缺少的依赖
get_plugin_manager().install_plugin_missing_dependencies()
if restore_failed:
if SystemChain.__write_plugin_restore_pending(pending_file, failed_items):
logger.warning("插件恢复未完成,保留备份并标记为下次启动重试")
else:
logger.warning("插件恢复未完成,已保留备份,但无法写入下次启动重试标记")
return
# 删除备份目录
# 源码恢复完成后即可消费备份;依赖由启动后的统一后台任务处理。
try:
shutil.rmtree(backup_dir)
logger.info(f"已删除插件备份目录: {backup_dir}")
except Exception as e:
logger.warning(f"删除备份目录失败: {str(e)}")
if backup_dir.exists():
SystemChain.__write_plugin_restore_pending(pending_file, {})
@staticmethod
def __read_plugin_restore_pending(pending_file: Path) -> Optional[dict[str, bool]]:
"""读取仍需恢复的插件项目;无效内容按全部项目重试。"""
try:
payload = json.loads(pending_file.read_text(encoding="utf-8"))
failed_items = payload.get("failed_items")
if not isinstance(failed_items, dict):
return None
return {
str(name): target_existed
for name, target_existed in failed_items.items()
if isinstance(name, str) and isinstance(target_existed, bool)
}
except (OSError, ValueError, TypeError):
return None
@staticmethod
def __write_plugin_restore_pending(
pending_file: Path,
failed_items: dict[str, bool],
) -> bool:
"""记录失败项目及其原目标状态,供普通重启继续未完成恢复。"""
try:
pending_file.write_text(
json.dumps({"failed_items": failed_items}, ensure_ascii=False),
encoding="utf-8",
)
return True
except Exception as e:
logger.error(f"写入插件恢复重试标记失败: {e}")
return False
@staticmethod
def __replace_snapshot(source: Path, target: Path, *, ignore=None) -> None:
"""复制到同级临时路径后替换目标,避免失败时丢失旧快照。"""
target.parent.mkdir(parents=True, exist_ok=True)
suffix = uuid.uuid4().hex
staging = target.with_name(f".{target.name}.tmp-{suffix}")
previous = target.with_name(f".{target.name}.old-{suffix}")
previous_available = False
published = False
try:
if source.is_dir():
shutil.copytree(source, staging, ignore=ignore)
else:
shutil.copy2(source, staging)
if target.exists():
try:
target.replace(previous)
except OSError as error:
if error.errno != errno.EXDEV:
raise
# overlayfs 可能拒绝把镜像层目录直接 rename 到可写层,
# 先复制旧目标保留恢复材料,再删除旧目录继续发布快照。
if target.is_dir():
shutil.copytree(target, previous, symlinks=True)
else:
shutil.copy2(target, previous, follow_symlinks=False)
previous_available = True
SystemChain.__remove_snapshot_path(target)
else:
previous_available = True
staging.replace(target)
published = True
except Exception:
if previous_available and not published:
try:
SystemChain.__remove_snapshot_path(target)
previous.replace(target)
previous_available = False
except Exception as rollback_error:
logger.error(
f"恢复旧快照失败,已保留恢复材料 {previous}: "
f"{rollback_error}"
)
raise
finally:
if staging.is_dir():
shutil.rmtree(staging, ignore_errors=True)
elif staging.exists():
staging.unlink(missing_ok=True)
if published and previous.exists():
if previous.is_dir():
shutil.rmtree(previous, ignore_errors=True)
else:
previous.unlink(missing_ok=True)
@staticmethod
def __remove_snapshot_path(path: Path) -> None:
"""删除待替换目标,保留失败回滚所需的旧快照副本。"""
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
elif path.exists() or path.is_symlink():
path.unlink()
def __get_version_message(self) -> str:
"""
+4 -4
View File
@@ -1,6 +1,7 @@
import copy
import threading
import traceback
from concurrent.futures import Future
from typing import Any, Union, Dict, Optional
from app.chain import ChainBase
@@ -154,12 +155,11 @@ class Command(metaclass=Singleton):
# 初始化命令
self.init_commands()
def init_commands(self, pid: Optional[str] = None) -> None:
def init_commands(self, pid: Optional[str] = None) -> Future:
"""
初始化菜单命令
提交菜单命令重建任务,并返回可等待的完成信号。
"""
# 使用线程池提交后台任务,避免引起阻塞
ThreadHelper().submit(self.__init_commands_background, pid)
return ThreadHelper().submit(self.__init_commands_background, pid)
def __init_commands_background(self, pid: Optional[str] = None) -> None:
"""
+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]:
"""
获取指定已安装插件的本地版本,不触发全部插件的状态、页面和权限计算。
+2
View File
@@ -259,6 +259,8 @@ SCHEMA_EXPORTS = {
'PluginReleaseData': ('app.schemas.plugin', 'PluginReleaseData'),
'PluginReleaseItem': ('app.schemas.plugin', 'PluginReleaseItem'),
'PluginRemoteInfo': ('app.schemas.plugin', 'PluginRemoteInfo'),
'PluginRuntimeStatus': ('app.schemas.plugin', 'PluginRuntimeStatus'),
'PluginRuntimeSummary': ('app.schemas.plugin', 'PluginRuntimeSummary'),
'PluginSidebarNavItem': ('app.schemas.plugin', 'PluginSidebarNavItem'),
'PluginWorkflowActionGroup': ('app.schemas.workflow', 'PluginWorkflowActionGroup'),
'ProcessInfo': ('app.schemas.dashboard', 'ProcessInfo'),
+23
View File
@@ -1,3 +1,4 @@
from enum import Enum as _Enum
from typing import Optional, List, Dict, Union
from pydantic import BaseModel, Field, RootModel
@@ -5,6 +6,17 @@ from pydantic import BaseModel, Field, RootModel
from app.schemas.common import JsonData
class PluginRuntimeStatus(str, _Enum):
"""插件从源码准备到运行激活的六类状态。"""
SOURCE_MISSING = "source_missing"
DEPENDENCY_PENDING = "dependency_pending"
READY = "ready"
ACTIVE = "active"
BLOCKED_BY_POLICY = "blocked_by_policy"
LOAD_FAILED = "load_failed"
class Plugin(BaseModel):
"""
插件信息
@@ -34,6 +46,8 @@ class Plugin(BaseModel):
installed: Optional[bool] = False
# 运行状态
state: Optional[bool] = False
# 插件源码、依赖和运行时加载状态
runtime_status: Optional[PluginRuntimeStatus] = None
# 是否有详情页面
has_page: Optional[bool] = False
# 是否有新版本
@@ -60,6 +74,15 @@ class Plugin(BaseModel):
plugin_public_key: Optional[str] = None
class PluginRuntimeSummary(BaseModel):
"""插件后台收敛状态和前端刷新代次。"""
ready: bool = Field(description="本轮插件源码、依赖和加载是否已收敛")
generation: int = Field(description="插件运行状态变化代次")
pending_count: int = Field(description="仍处于准备阶段的插件数量")
failed_count: int = Field(description="加载失败或被策略阻止的插件数量")
class PluginDashboard(Plugin):
"""
插件仪表盘
+5 -3
View File
@@ -1,3 +1,5 @@
from concurrent.futures import Future
from app.application.commands import register_command_class
from app.command import Command
@@ -19,8 +21,8 @@ def stop_command():
pass
def restart_command():
def restart_command() -> Future:
"""
命令
命令并返回完成信号
"""
Command().init_commands()
return Command().init_commands()
+15 -9
View File
@@ -25,6 +25,7 @@ except Exception:
pass
from app.chain.system import SystemChain
from app.application.plugin.runtime import get_plugin_manager
from app.runtime.config import global_vars, settings
from app.adapters.external.server import MoviePilotServerHelper
from app.runtime.state import SystemHelper
@@ -35,6 +36,7 @@ from app.startup.modules_initializer import init_modules, stop_modules
from app.startup.monitor_initializer import stop_monitor, init_monitor
from app.startup.plugins_initializer import (
configure_plugin_services,
execute_task,
init_plugins,
stop_plugins,
sync_plugins,
@@ -67,11 +69,18 @@ async def init_extra():
SystemHelper().set_system_modified()
SystemChain().restart_finish()
return
if await sync_plugins():
# 重新注册插件定时服务
init_plugin_scheduler()
# 重新注册命令
restart_command()
plugin_manager = get_plugin_manager()
try:
if await sync_plugins():
await execute_task(
global_vars.loop,
init_plugin_scheduler,
"插件定时服务刷新",
)
await asyncio.wrap_future(restart_command())
finally:
plugin_manager.set_plugin_settling(False)
plugin_manager.start_monitor()
# 设置系统已修改标志
SystemHelper().set_system_modified()
# 重启完成
@@ -320,12 +329,9 @@ async def lifespan(app: FastAPI):
finally:
print("Shutting down...")
global_vars.stop_system()
# 取消同步插件任务
# 插件恢复会在线程池中修改源码与依赖,必须完成后再进入资源关闭阶段。
try:
sync_plugins_task.cancel()
await sync_plugins_task
except asyncio.CancelledError:
pass
except Exception as e:
print(str(e))
try:
+87 -16
View File
@@ -16,6 +16,7 @@ from app.runtime.extensions.plugin_manager import (
configure_plugin_resource_import_preparer,
configure_site_auth_level_provider,
)
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
from app.application.plugin.catalog import PluginCatalogService
from app.adapters.external.plugin.client import PluginMarketClient
from app.runtime.extensions.plugin.storage import (
@@ -42,6 +43,7 @@ from app.db.oper.plugindata import PluginDataOper
from app.db.oper.systemconfig import SystemConfigOper
from app.runtime.log import logger
from app.foundation.version import compare_version
from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
@@ -121,46 +123,105 @@ async def sync_plugins() -> bool:
"""
初始化安装插件并动态注册后台任务及API
"""
plugin_manager = None
try:
configure_plugin_services()
loop = global_vars.loop
plugin_manager = PluginManager()
plugin_manager.set_plugin_settling(True)
sync_result = await execute_task(loop, plugin_manager.sync, "插件同步到本地")
resolved_dependencies = await execute_task(loop, plugin_manager.install_plugin_missing_dependencies,
"缺失依赖项安装")
# 判断是否需要进行插件初始化
if not sync_result and not resolved_dependencies:
logger.debug("没有新的插件同步到本地或缺失依赖项需要安装")
dependency_result = await execute_task(
loop,
plugin_manager.install_plugin_missing_dependencies_with_status,
"缺失依赖项安装",
)
if dependency_result is None:
return False
if not isinstance(dependency_result, PluginDependencyInstallResult):
logger.error("缺失依赖项安装返回了无效结果,跳过插件重新初始化")
return False
previous_statuses = plugin_manager.get_plugin_runtime_statuses()
classification = plugin_manager.classify_plugins()
plugin_manager.apply_plugin_dependency_classification(classification)
if not dependency_result.success:
logger.error("缺失依赖项安装未完成,将继续激活当前已就绪插件")
changed_ids = await execute_task(
loop,
lambda: _activate_ready_plugins(
plugin_manager,
classification.ready,
sync_result or [],
previous_statuses,
),
"插件运行态激活",
)
if changed_ids is None:
return False
# 继续执行后续的插件初始化步骤
logger.info("正在重新初始化插件")
# 重新初始化插件
plugin_manager.init_config()
# 重新注册插件API
register_plugin_api()
logger.info("所有插件初始化完成")
if not changed_ids:
logger.debug("没有新的插件进入可运行状态")
return False
for plugin_id in changed_ids:
register_plugin_api(plugin_id)
if dependency_result.success:
logger.info(f"后台插件加载完成,共处理 {len(changed_ids)} 个插件")
else:
logger.warning(
f"缺失依赖项仍未全部恢复,已激活 {len(changed_ids)} 个就绪插件"
)
return True
except Exception as e:
logger.error(f"插件初始化过程中出现异常: {e}")
return False
def _activate_ready_plugins(
plugin_manager: PluginManager,
ready_ids: tuple[str, ...],
synced_ids: list[str],
previous_statuses: dict[str, PluginRuntimeStatus],
) -> list[str]:
"""在线程池中完成插件导入和初始化,避免阻塞 Web 事件循环。"""
running_ids = set(plugin_manager.running_plugins)
synced = set(synced_ids)
changed_ids: list[str] = []
for plugin_id in ready_ids:
dependency_recovered = (
previous_statuses.get(plugin_id)
is PluginRuntimeStatus.DEPENDENCY_PENDING
)
if plugin_id in running_ids and (plugin_id in synced or dependency_recovered):
plugin_manager.reload_plugin(plugin_id)
changed_ids.append(plugin_id)
continue
if plugin_id not in running_ids:
plugin_manager.start(plugin_id)
changed_ids.append(plugin_id)
return changed_ids
async def execute_task(loop, task_func, task_name):
"""
执行后台任务
"""
try:
result = await loop.run_in_executor(None, task_func)
if isinstance(result, list) and result:
logger.debug(f"{task_name} 已完成,共处理 {len(result)} 个项目")
if isinstance(result, PluginDependencyInstallResult):
processed_count = len(result.missing)
elif isinstance(result, list):
processed_count = len(result)
else:
processed_count = 0
if processed_count:
logger.debug(f"{task_name} 已完成,共处理 {processed_count} 个项目")
else:
logger.debug(f"没有新的 {task_name} 需要处理")
return result
except Exception as e:
logger.error(f"{task_name} 时发生错误:{e}", exc_info=True)
return []
return None
def init_plugins():
@@ -169,9 +230,19 @@ def init_plugins():
"""
configure_plugin_services()
plugin_manager = PluginManager()
plugin_manager.start()
classification = plugin_manager.classify_plugins()
plugin_manager.apply_plugin_dependency_classification(classification)
plugin_manager.set_plugin_settling(True)
for plugin_id in classification.ready:
plugin_manager.start(plugin_id)
register_plugin_api()
plugin_manager.start_monitor()
logger.info(
"插件启动分类:立即加载=%s,等待依赖=%s,等待源码=%s",
len(classification.ready),
len(classification.missing_dependencies),
len(classification.missing_source),
)
def stop_plugins():