diff --git a/.env.example b/.env.example index b30f76e68..5b9206086 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,8 @@ # 因此用户侧直接启动后端始终按生产环境处理。 # # 想临时按生产环境验证上报链路时,删除或重命名 .env 即可。 +# +# 由 AUTO-MAS-Runtime 监督启动时(进程环境变量 AUTO_MAS_SUPERVISED=1)上述 +# 开发环境行为不生效:端口固定 36163、关闭请求(/api/core/close)真实生效, +# 优先级高于本文件与 AUTO_MAS_ENV。 AUTO_MAS_ENV=development diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index 269893df6..abb55c7ad 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -28,6 +28,13 @@ permissions: contents: write actions: write +env: + # AUTO-MAS-Project/AUTO-MAS-Runtime 的发布版本,随桌面安装包一起分发。钉死具体版本、不追 + # latest:Runtime 有自己的发布节奏,本仓库的改动不应该在没有联调的情况下自动带出一个新 + # Runtime。Runtime 不自更新,只随这里构建的安装包整体升级;本仓库如有依赖 Runtime 新行为 + # 的改动(T13 系列),必须等 Runtime 一侧先发布对应版本,再手动把这个版本号提上去。 + RUNTIME_VERSION: v0.1.0-beta.8 + jobs: build: @@ -51,6 +58,52 @@ jobs: with: node-version: '20' + - name: 下载并校验 Runtime 可执行文件 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + $runtimeDir = Join-Path "${{ github.workspace }}" 'runtime' + New-Item -ItemType Directory -Force -Path $runtimeDir | Out-Null + + $exeName = "auto-mas-runtime-$env:RUNTIME_VERSION.exe" + + gh release download $env:RUNTIME_VERSION ` + -R AUTO-MAS-Project/AUTO-MAS-Runtime ` + -D $runtimeDir ` + -p $exeName ` + -p 'SHA256SUMS.txt' ` + --clobber + if ($LASTEXITCODE -ne 0) { + throw "下载 Runtime $env:RUNTIME_VERSION 失败" + } + + Push-Location $runtimeDir + try { + $sumsLine = Select-String -Path 'SHA256SUMS.txt' -Pattern ([regex]::Escape($exeName)) | + Select-Object -First 1 + if (-not $sumsLine) { + throw "SHA256SUMS.txt 中找不到 $exeName 对应的记录" + } + $expectedHash = ($sumsLine.Line -split '\s+')[0].ToUpperInvariant() + $actualHash = (Get-FileHash -Path $exeName -Algorithm SHA256).Hash.ToUpperInvariant() + + Write-Host "期望哈希: $expectedHash" + Write-Host "实际哈希: $actualHash" + + if ($actualHash -ne $expectedHash) { + throw "SHA-256 校验失败:$exeName 与 SHA256SUMS.txt 不匹配,可能下载损坏或被篡改" + } + + # 校验通过后改成稳定文件名,Electron 侧固定按 auto-mas-runtime.exe 定位 + # (见 frontend/electron/services/runtime/launchConfig.ts)。 + Move-Item -Path $exeName -Destination 'auto-mas-runtime.exe' -Force + Remove-Item 'SHA256SUMS.txt' + Write-Host "Runtime $env:RUNTIME_VERSION 校验通过:$runtimeDir\auto-mas-runtime.exe" + } finally { + Pop-Location + } + shell: pwsh + - name: 构建应用程序 env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -74,6 +127,17 @@ jobs: Move-Item -Path "dist\win-unpacked\AUTO-MAS.exe" -Destination "..\AUTO-MAS.exe" shell: pwsh + - name: 放入 Runtime 可执行文件 + run: | + # 不经 electron-builder 的 extraResources 声明:本地没有预先下载 runtime\auto-mas-runtime.exe + # 时会让 yarn build 直接报错退出。构建产物出来后按 environment.zip 同样的方式原样拷入 + # resources 目录,Lite 与 Full 安装包都直接打包 dist\win-unpacked\* 的全部内容,因此 + # 两者会一起带上它。 + $target = 'frontend\dist\win-unpacked\resources' + New-Item -ItemType Directory -Force -Path $target | Out-Null + Copy-Item -Path 'runtime\auto-mas-runtime.exe' -Destination (Join-Path $target 'auto-mas-runtime.exe') -Force + shell: pwsh + - name: 上传未签名主程序 id: upload-unsigned-main-program uses: actions/upload-artifact@v7.0.1 diff --git a/.github/workflows/check-uv-lock.yml b/.github/workflows/check-uv-lock.yml new file mode 100644 index 000000000..31968ba74 --- /dev/null +++ b/.github/workflows/check-uv-lock.yml @@ -0,0 +1,47 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +# Contact: DLmaster_361@163.com + +name: 检查 uv 锁文件 + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [dev] + +permissions: + contents: read + +jobs: + check-uv-lock: + name: 检查 uv.lock 与依赖清单一致 + runs-on: ubuntu-latest + + steps: + - name: 检出代码 + uses: actions/checkout@v7.0.1 + + - name: 安装 uv + uses: astral-sh/setup-uv@v10.0.1 + with: + version: "0.12.3" + + - name: 检查 uv.lock 是否与清单一致 + run: uv lock --check diff --git a/.github/workflows/check-version-json.yml b/.github/workflows/check-version-json.yml index 5ce5dcc5c..b30d1ab05 100644 --- a/.github/workflows/check-version-json.yml +++ b/.github/workflows/check-version-json.yml @@ -37,16 +37,25 @@ jobs: - name: 检出代码 uses: actions/checkout@v7.0.1 + - name: 设置 Python 环境 + uses: actions/setup-python@v7.0.0 + with: + python-version: '3.12' + - name: 检查 version.json 语法 run: python -m json.tool res/version.json > /dev/null - name: 检查版本号一致性 run: | + python -m pip install --quiet packaging==25.0 python - <<'PY' import ast import json + import tomllib from pathlib import Path + from packaging.version import Version + with Path("res/version.json").open(encoding="utf-8") as file: version_json = json.load(file) @@ -78,17 +87,34 @@ jobs: if app_config_version is None: raise SystemExit("未在 app/core/config.py 的 AppConfig 中找到 VERSION") + with Path("pyproject.toml").open("rb") as file: + pyproject_toml = tomllib.load(file) + pyproject_version = pyproject_toml["project"]["version"] + versions = { "res/version.json": version_json["version"], "frontend/package.json": package_json["version"], "app/core/config.py": app_config_version, + "pyproject.toml": pyproject_version, } - if len(set(versions.values())) != 1: + # 前三处要求逐字相同;pyproject.toml 走 PEP 440(如 5.5.0b3), + # 去掉前导 v 后按 packaging 规范化比较(5.5.0-beta.3 与 5.5.0b3 规范化后相等)。 + literal_versions = { + path: value for path, value in versions.items() if path != "pyproject.toml" + } + is_consistent = len(set(literal_versions.values())) == 1 + if is_consistent: + shared_version = next(iter(literal_versions.values())) + is_consistent = Version(shared_version.removeprefix("v")) == Version( + pyproject_version + ) + + if not is_consistent: details = "\n".join( f"- {path}: {version}" for path, version in versions.items() ) - raise SystemExit(f"三处版本号不一致:\n{details}") + raise SystemExit(f"版本号不一致:\n{details}") print(f"版本号一致: {app_config_version}") PY diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..28d9a01b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60a19904f..3123162e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,24 @@ Welcome to contribute to the AUTO-MAS project! Before participating in developme - [AUTO-MAS Developer Documentation](https://doc.auto-mas.top/developer/). +# 开发环境 / Development Environment + +后端依赖由 `pyproject.toml` + `uv.lock` 管理: + +1. 安装 [uv](https://docs.astral.sh/uv/getting-started/installation/)。 +2. 在仓库根目录执行 `uv sync`,会按 `uv.lock` 在 `.venv` 中创建与锁定版本一致的环境。 +3. 执行 `uv run main.py` 启动。 + +`requirements.txt` 目前与 `pyproject.toml` 并存,修改依赖时请同步更新两者。**修改 `pyproject.toml` 后必须重新执行 `uv lock`,并将更新后的 `uv.lock` 一并提交**,否则 CI 的锁文件检查会失败。`dev` 及各开发分支负责更新 `uv.lock`;发布分支只消费锁文件,不在其上重新解析依赖。 + +Backend dependencies are managed via `pyproject.toml` + `uv.lock`: + +1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/). +2. Run `uv sync` in the repository root; it creates a `.venv` matching the locked versions in `uv.lock`. +3. Start the app with `uv run main.py`. + +`requirements.txt` currently coexists with `pyproject.toml` — update both when changing dependencies. **After editing `pyproject.toml`, you must re-run `uv lock` and commit the updated `uv.lock`**, or the CI lockfile check will fail. `dev` and other development branches keep `uv.lock` up to date; release branches only consume the lockfile and never re-resolve it. + # 重要事项 / Important Terms 您通过任意方式提交代码到 **AUTO-MAS-Project** 下属任意仓库,即代表您理解并同意以下条款: diff --git a/app/api/core.py b/app/api/core.py index 8428fce5e..e03d1f439 100644 --- a/app/api/core.py +++ b/app/api/core.py @@ -35,7 +35,7 @@ from app.services import System from app.models.schema import * from app.api.ws_command import ws_command -from app.utils import get_logger +from app.utils import get_logger, is_supervised router = APIRouter(prefix="/api/core", tags=["核心信息"]) logger = get_logger("DEV") @@ -48,12 +48,32 @@ class WebSocketMetaOut(BaseModel): wsPath: str = Field(default="/api/core/ws", description="主 WebSocket 路径") +# AUTO-MAS-Runtime 健康检查协议版本:固定返回后端自身支持的版本,不回显监督器注入值, +# 协议升级后只有如此监督器才能检出版本不兼容。 +HEALTH_PROTOCOL_VERSION = 1 + + class BackendHealthOut(BaseModel): """后端核心服务与后台初始化状态。""" ready: bool = Field(description="核心 API 是否可用") backgroundStatus: str = Field(description="后台初始化状态") backgroundError: str | None = Field(default=None, description="后台初始化失败原因") + protocol: int = Field(description="后端自身支持的健康检查协议版本") + version: str = Field(description="后端版本号") + commit: str = Field(description="后端所在提交哈希,未受监督或监督器未注入时为空") + + + +def _resolve_injected_identity(env_name: str) -> str | None: + """受监督时读取监督器注入的期望身份值。 + + 未受监督、或对应环境变量缺失/为空字符串时返回 None,交由调用方回退默认值。 + """ + + if not is_supervised(): + return None + return os.getenv(env_name, "") or None @router.get( @@ -63,12 +83,20 @@ class BackendHealthOut(BaseModel): status_code=200, ) async def get_health(request: Request) -> BackendHealthOut: - """返回核心 API 与后台初始化状态。""" + """返回核心 API 与后台初始化状态,供 AUTO-MAS-Runtime 等外部监督器判定就绪与身份。 + + version/commit 受监督且监督器注入了期望值时原样回显,否则分别回退到本地版本号 + 与空字符串;commit 不通过 Git 推断,只能来自监督器注入。 + """ return BackendHealthOut( ready=True, backgroundStatus=getattr(request.app.state, "background_status", "starting"), backgroundError=getattr(request.app.state, "background_error", None), + protocol=HEALTH_PROTOCOL_VERSION, + version=_resolve_injected_identity("AUTO_MAS_EXPECTED_VERSION") + or Config.VERSION, + commit=_resolve_injected_identity("AUTO_MAS_EXPECTED_COMMIT") or "", ) @@ -77,8 +105,16 @@ def is_backend_dev_mode() -> bool: dev 分支的 AUTO_MAS_DEV 标记“由前端拉起”(跳过自行提权),生产环境同样为 1, 不能作为开发模式依据;以 main.py 启动时归一化的 AUTO_MAS_ENV 为准。 + + 受 AUTO-MAS-Runtime 监督时优先级高于 AUTO_MAS_DEV 与 AUTO_MAS_ENV,恒为 + False:监督器依赖 /api/core/close 真正退出进程,若判定为开发模式, + _shutdown_backend() 只做轻量清理、不设 should_exit,关闭请求就会永远 + 不生效,5 秒后被监督器硬杀。 """ + if is_supervised(): + return False + raw = str(os.getenv("AUTO_MAS_ENV", "")).strip().lower() return raw in {"dev", "development"} diff --git a/app/core/config.py b/app/core/config.py index 8031e8353..b7bdf300d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -76,7 +76,7 @@ Webhook, ) from app.models.schema import PlanComboxConsumer -from app.utils import get_logger +from app.utils import get_logger, is_supervised, resource_path from app.utils.constants import ( MAA_DEPOT_EXCLUDED_ITEM_IDS, RESOURCE_STAGE_DATE_TEXT, @@ -87,6 +87,7 @@ UTC8, ) from app.utils.io import write_file +from app.utils.paths import SOURCE_ROOT from app.utils.platform import IS_WINDOWS # 孤儿 venv 的宽限期:刚动过的一律不碰,避免与正在准备环境的运行抢。 @@ -292,7 +293,7 @@ def __init__(self) -> None: self._inject_truststore() self.notify_env = Environment( - loader=FileSystemLoader(str(Path.cwd() / "res/html")) + loader=FileSystemLoader(str(resource_path("html"))) ) @staticmethod @@ -349,7 +350,9 @@ def _get_repo(self) -> Any: try: from git import Repo - self._repo = Repo(Path.cwd()) + # .git 随源码走:受 AUTO-MAS-Runtime 监督时源码在 /repo/, + # 工作目录(app-root)下没有仓库,不能再按 Path.cwd() 打开 + self._repo = Repo(SOURCE_ROOT) except Exception as e: logger.warning(f"Git仓库初始化失败: {e}") self._repo = None @@ -717,7 +720,19 @@ async def check_data(self) -> None: logger.success("数据文件版本更新完成") async def get_git_version(self) -> tuple[bool, str, str]: - """获取Git版本信息,如果Git不可用则返回默认值""" + """获取Git版本信息,如果Git不可用则返回默认值。 + + 受 AUTO-MAS-Runtime 监督时后端不是更新主体:更新由 Runtime 整体替换 + repo/ 完成、不在旧仓库上 fetch,比对远端分支判定“需要更新”没有意义, + 一律视为最新。managed 模式直接回显 Runtime 从校验过的仓库注入的 HEAD, + 不依赖 Runtime 布局里并不存在的 git 命令行;development 模式无注入 + 身份,仍从源码目录读取 Git 信息用于展示。 + """ + + supervised = is_supervised() + expected_commit = os.getenv("AUTO_MAS_EXPECTED_COMMIT", "") + if supervised and expected_commit: + return True, expected_commit, "unknown" def _get_git_info(): @@ -751,7 +766,7 @@ def _get_git_info(): is_latest, commit_hash, commit_time = await self.loop.run_in_executor( None, _get_git_info ) - return is_latest, commit_hash, commit_time + return is_latest or supervised, commit_hash, commit_time async def add_script( self, diff --git a/app/core/maa_manager.py b/app/core/maa_manager.py index 953d9e43a..d0d6132e9 100644 --- a/app/core/maa_manager.py +++ b/app/core/maa_manager.py @@ -45,7 +45,7 @@ from .config import Config from app.models.emulator import DeviceInfo -from app.utils import get_logger +from app.utils import get_logger, resource_path logger = get_logger("MaaFW管理") @@ -70,7 +70,7 @@ def __init__(self): encoding="utf-8", ) Toolkit.init_option(Path.cwd()) - self.resource.post_bundle(Path.cwd() / "res/MaaFW").wait() + self.resource.post_bundle(resource_path("MaaFW")).wait() @staticmethod async def do_job(job: Job | JobWithResult) -> Any: diff --git a/app/services/notification.py b/app/services/notification.py index e984cef2f..a87f17c71 100644 --- a/app/services/notification.py +++ b/app/services/notification.py @@ -30,7 +30,6 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formataddr -from pathlib import Path from typing import Literal from urllib.parse import urlparse @@ -38,7 +37,7 @@ from plyer import notification from app.models.config import Webhook -from app.utils import LazyProxy, get_logger +from app.utils import LazyProxy, get_logger, resource_path from app.utils.constants import UTC4 logger = get_logger("通知服务") @@ -127,7 +126,7 @@ async def push_plyer(self, title: str, message: str, ticker: str, t: int) -> Non title=clip_notify_text(title, PLYER_TITLE_LIMIT), message=clip_notify_text(message, PLYER_MESSAGE_LIMIT), app_name="AUTO-MAS", - app_icon=(Path.cwd() / "res/icons/AUTO-MAS.ico").as_posix(), + app_icon=resource_path("icons", "AUTO-MAS.ico").as_posix(), timeout=t, ticker=ticker, toast=True, diff --git a/app/services/wuthering_waves_updater.py b/app/services/wuthering_waves_updater.py index b1136e676..8b1f92bbd 100644 --- a/app/services/wuthering_waves_updater.py +++ b/app/services/wuthering_waves_updater.py @@ -61,7 +61,7 @@ ) _HPATCHZ_ZIP_SHA256 = "77f141386e5d8f785c1c846e10fbbc19b6c05aa00e3f59cc44670fb3f0e2ae94" _HPATCHZ_MEMBER = "windows64/hpatchz.exe" -_HPATCHZ_CACHE_DIR = Path.cwd() / "environment/hpatchz" +_HPATCHZ_CACHE_DIR = Path.cwd() / "data" / "cache" / "hpatchz" # 暂存区放在安装目录内,确保与游戏目录同卷,move 才是原子改名而非跨卷复制。 _STAGING_DIR_NAME = "_mas_update" diff --git a/app/task/BetterGI/tools/account_switch.py b/app/task/BetterGI/tools/account_switch.py index 2044f954a..6d6d9fed7 100644 --- a/app/task/BetterGI/tools/account_switch.py +++ b/app/task/BetterGI/tools/account_switch.py @@ -42,7 +42,7 @@ from pathlib import Path from typing import Any -from app.utils import get_logger +from app.utils import get_logger, resource_path from app.utils.io import read_file, write_file from .one_dragon import GLOBAL_CONFIG_LOCK @@ -57,7 +57,7 @@ _SCRIPT_GROUP_REL_DIR = Path("User") / "ScriptGroup" # 内置资源目录(随 MAS 版本同步;含配置组模板,脚本本体不再内置) -_RES_TEMPLATE_DIR = Path.cwd() / "res" / "templates" / "BetterGI" +_RES_TEMPLATE_DIR = resource_path("templates", "BetterGI") # 切换账号脚本在 BetterGI 脚本仓库中的相对路径(repo 下),"js" 前缀映射到 User/JsScript _SCRIPT_REPO_PATH = "js/SwitchAccountMultipleMode" diff --git a/app/task/BetterGI/tools/one_dragon.py b/app/task/BetterGI/tools/one_dragon.py index 7416ece61..38094e5c4 100644 --- a/app/task/BetterGI/tools/one_dragon.py +++ b/app/task/BetterGI/tools/one_dragon.py @@ -34,6 +34,7 @@ from typing import Any from app.models.config import _BGI_BUILTIN_ONE_DRAGON_GROUPS +from app.utils import resource_path from app.utils.io import read_file, write_file # 8 个内置一条龙配置组:单一来源为 app/models/config.py 的 _BGI_BUILTIN_ONE_DRAGON_GROUPS, @@ -49,7 +50,7 @@ _ONE_DRAGON_REL_DIR = Path("User") / "OneDragon" # 内置种子模板(随 MAS 版本同步) -_RES_TEMPLATE_DIR = Path.cwd() / "res" / "templates" / "BetterGI" +_RES_TEMPLATE_DIR = resource_path("templates", "BetterGI") _SEED_TEMPLATE = _RES_TEMPLATE_DIR / "OneDragon" / "默认配置.json" # 空配置名的显式兜底配置名 diff --git a/app/task/HSR/tools/sra_runtime.py b/app/task/HSR/tools/sra_runtime.py index 1baf92334..b07471fc7 100644 --- a/app/task/HSR/tools/sra_runtime.py +++ b/app/task/HSR/tools/sra_runtime.py @@ -31,7 +31,7 @@ from typing import Any, Awaitable, Callable from app.utils import ProcessManager, decode_bytes, get_logger -from app.utils.io import atomic_write, read_file, write_file +from app.utils.io import atomic_write, migrate_legacy_dir, read_file, write_file from .log_detect import ( can_read_stream_live, @@ -727,17 +727,19 @@ async def run_sra_config( def _sra_temp_path(script_uid: str, user_uid: str, module_key: str) -> Path: + """HSR SRA 临时配置文件路径。 + + 落在受保护的 ``data/`` 下,避免与 AUTO-MAS-Runtime 监督器接管的 + ``runtime/`` 撞名;首次访问时把用户机器上已有的旧 + ``runtime/hsr/sra-config`` 整体迁移过来。 + """ + from app.core import Config - return ( - Config.config_path.parent - / "runtime" - / "hsr" - / "sra-config" - / script_uid - / user_uid - / f"{module_key}.json" - ) + app_root = Config.config_path.parent + sra_config_dir = app_root / "data" / "hsr" / "sra-config" + migrate_legacy_dir(app_root / "runtime" / "hsr" / "sra-config", sra_config_dir) + return sra_config_dir / script_uid / user_uid / f"{module_key}.json" def _build_sra_base_config(name: str) -> dict: diff --git a/app/task/MaaEnd/tools/login.py b/app/task/MaaEnd/tools/login.py index c8732474a..afe2b37bc 100644 --- a/app/task/MaaEnd/tools/login.py +++ b/app/task/MaaEnd/tools/login.py @@ -50,11 +50,11 @@ from rapidocr_onnxruntime import RapidOCR from app.models.emulator import DeviceInfo -from app.utils import get_logger +from app.utils import get_logger, resource_path logger = get_logger("终末地登录") -_IMAGE_ROOT = Path.cwd() / "res/MaaFW/image/EndFieldPC" +_IMAGE_ROOT = resource_path("MaaFW", "image", "EndFieldPC") _TEMPLATES = { "logout": ( _IMAGE_ROOT / "登出-1080p.png", diff --git a/app/task/MaaFW/tools/core/automas_maafw_agent_env/env.py b/app/task/MaaFW/tools/core/automas_maafw_agent_env/env.py index d27c51b87..d29318ba7 100644 --- a/app/task/MaaFW/tools/core/automas_maafw_agent_env/env.py +++ b/app/task/MaaFW/tools/core/automas_maafw_agent_env/env.py @@ -12,7 +12,7 @@ from typing import Callable from .models import MaaFWAgentCommandPlan, MaaFWAgentEnvPrepareResult -from .planner import MaaFWAgentEnvError, venv_python_exe +from .planner import MaaFWAgentEnvError, venv_base_python_missing, venv_python_exe AGENT_BOOTSTRAP_PACKAGE = "json-with-comments" @@ -298,7 +298,13 @@ def _prepare_isolated_venv_env( def _is_valid_venv_path(venv_path: Path) -> bool: - return venv_python_exe(venv_path).is_file() and (venv_path / "pyvenv.cfg").is_file() + if not ( + venv_python_exe(venv_path).is_file() and (venv_path / "pyvenv.cfg").is_file() + ): + return False + # 文件都在不代表能用:引导用的基解释器(受管模式下常是 sys.executable + # 所在的监督器管理 venv)事后被删掉重建过的话,这个 venv 也已经失效。 + return not venv_base_python_missing(venv_path) def _ensure_isolated_venv( @@ -385,6 +391,12 @@ def _should_rebuild_isolated_venv( project_path: Path, log: Callable[[str], None], ) -> bool: + if venv_path.exists() and venv_base_python_missing(venv_path): + log( + f"[Python环境] 隔离 venv 的基解释器已不存在(pyvenv.cfg 的 home 已" + f"失效),将重建: {venv_path}" + ) + return True if venv_path.exists() and not _is_valid_venv_path(venv_path): log("[Python环境] 隔离 venv 不完整,将重建") return True @@ -685,6 +697,15 @@ def _python_supports_venv(python: str) -> bool: def _find_uv_executable() -> str | None: + # 受管模式(AUTO-MAS-Runtime 监督后端)下没有便携 Python,监督器改为 + # 用 AUTO_MAS_UV_EXE 注入它已校验过的 uv 路径,也不会把这个 uv 加进 + # PATH——优先信它,找不到再退回便携路径与 PATH 查找。 + configured_uv = os.environ.get("AUTO_MAS_UV_EXE") + if configured_uv: + configured_path = Path(configured_uv) + if configured_path.is_file(): + return str(configured_path.resolve()) + portable_uv = Path.cwd() / "environment" / "python" / "Scripts" / "uv.exe" if portable_uv.is_file(): return str(portable_uv) diff --git a/app/task/MaaFW/tools/core/automas_maafw_agent_env/planner.py b/app/task/MaaFW/tools/core/automas_maafw_agent_env/planner.py index 859def5a3..48d759da9 100644 --- a/app/task/MaaFW/tools/core/automas_maafw_agent_env/planner.py +++ b/app/task/MaaFW/tools/core/automas_maafw_agent_env/planner.py @@ -99,6 +99,37 @@ def venv_python_exe(venv_path: str | Path) -> Path: return path / "bin" / "python" +def venv_base_python_missing(venv_path: str | Path) -> bool: + """venv 自己的文件都在,但 pyvenv.cfg 里 home 指向的基解释器已经不存在。 + + venv 的 python.exe(Windows 上)在启动时要靠 pyvenv.cfg 的 home 才能定位 + 标准库/DLL 所在目录,不是自包含的——home 目录被删掉后 venv 会静默失效, + 即使 venv 自己的文件一个没少。这种情况出现在:引导用的基解释器本身是另 + 一个 venv(比如受管模式下落到 sys.executable 的那个监督器管理的 + venv),而它之后被整个删掉重建过。 + + home 行缺失或读不出时不判定失效,交给调用方已有的结构完整性检查处理。 + """ + + try: + text = (Path(venv_path) / "pyvenv.cfg").read_text( + encoding="utf-8", errors="replace" + ) + except OSError: + return False + + home: str | None = None + for line in text.splitlines(): + key, sep, value = line.partition("=") + if sep and key.strip().casefold() == "home": + home = value.strip() + if not home: + return False + + exe_name = "python.exe" if os.name == "nt" else "python" + return not (Path(home) / exe_name).is_file() + + def _coerce_agent_configs( raw_agent: MaaFWAgent | list[MaaFWAgent] diff --git a/app/task/MaaFW/tools/core/automas_maafw_runner/run_plan.py b/app/task/MaaFW/tools/core/automas_maafw_runner/run_plan.py index 1e007fb15..57997e80a 100644 --- a/app/task/MaaFW/tools/core/automas_maafw_runner/run_plan.py +++ b/app/task/MaaFW/tools/core/automas_maafw_runner/run_plan.py @@ -30,6 +30,7 @@ normalize_snapshot, normalize_task_execution_payload, ) +from app.utils import resource_path from .models import ( MaaFWResolvedPath, @@ -677,7 +678,7 @@ def _build_pi_env( def _load_client_version() -> str: - version_path = Path.cwd() / "res" / "version.json" + version_path = resource_path("version.json") try: data = json.loads(version_path.read_text(encoding="utf-8")) version = data.get("version") diff --git a/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/cache.py b/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/cache.py index f326b6e9c..64d6447e2 100644 --- a/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/cache.py +++ b/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/cache.py @@ -8,10 +8,10 @@ from typing import Any from .installer import ( - UV_CACHE_RELATIVE_PATH, _clean_process_environment, _find_uv_executable, _uv_version, + resolve_uv_cache_dir, ) @@ -35,7 +35,13 @@ def prune_uv_cache( """ root = Path(pool_root).resolve() - cache_path = root / UV_CACHE_RELATIVE_PATH + cache_path = resolve_uv_cache_dir(root) + try: + relative_to_pool = cache_path.relative_to(root).as_posix() + except ValueError: + # 受监督时 cache_path 可能是 Runtime 注入的共享缓存目录,不在 pool_root + # 之内——不是错误,只是「相对池目录」这个概念本身不适用。 + relative_to_pool = None result: dict[str, Any] = { "kind": "uv", "scope": "pool", @@ -43,7 +49,7 @@ def prune_uv_cache( "attempted": False, "status": "preview" if dry_run else "pending", "cachePath": str(cache_path), - "relativeToPool": UV_CACHE_RELATIVE_PATH.as_posix(), + "relativeToPool": relative_to_pool, "previewExact": False, "observedAt": _format_time(), } diff --git a/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/installer.py b/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/installer.py index 9373c96ea..afe83ce42 100644 --- a/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/installer.py +++ b/app/task/MaaFW/tools/core/automas_maafw_runtime_pool/installer.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import os import platform import re @@ -15,6 +16,8 @@ from packaging.version import InvalidVersion, Version +logger = logging.getLogger("automas.maafw.runtime_pool.installer") + RUNTIME_INSTALL_TIMEOUT_SECONDS = 300 RUNTIME_AUDIT_TIMEOUT_SECONDS = 60 VENV_PROBE_TIMEOUT_SECONDS = 30 @@ -29,11 +32,169 @@ # 另一条路(python-build-standalone 的 GitHub Release),此前没有任何镜像开关—— # 受限网络下这一步要么慢到超时,要么拿到不完整的解释器。UV_* 不在 # _clean_process_environment 的剔除名单里,所以显式设置的 UV_PYTHON_INSTALL_MIRROR -# 优先,本变量只作兜底。 +# 优先,其次是本变量,最后是 Runtime 注入的 AUTO_MAS_MIRROR_PYTHON 有序列表 +# (见 _resolve_python_mirror_candidates)。 AUTO_MAS_UV_PYTHON_INSTALL_MIRROR_ENV = "AUTO_MAS_UV_PYTHON_INSTALL_MIRROR" +# 以下四个变量由 Runtime 监督器在受监督时注入(契约见 +# doc/契约补充-v1-增补1.md C11 与「新增注入环境变量」一节);未受监督时均不设置, +# 行为回退到本文件原有的池本地目录 / 单值镜像逻辑。变量名与取值格式已冻结, +# 改名或改格式须先改 Runtime 侧契约文档。 +AUTO_MAS_UV_CACHE_DIR_ENV = "AUTO_MAS_UV_CACHE_DIR" +AUTO_MAS_UV_PYTHON_INSTALL_DIR_ENV = "AUTO_MAS_UV_PYTHON_INSTALL_DIR" +AUTO_MAS_MIRROR_PACKAGE_INDEX_ENV = "AUTO_MAS_MIRROR_PACKAGE_INDEX" +AUTO_MAS_MIRROR_PYTHON_ENV = "AUTO_MAS_MIRROR_PYTHON" RUNTIME_POOL_STAGING_DIRECTORY_NAME = ".staging" SUPPORTED_CPYTHON_MINORS = ((3, 12), (3, 13)) + +def _resolve_injected_pool_directory( + pool_root: str | Path, + *, + env_name: str, + relative_default: Path, + label: str, +) -> tuple[Path, bool]: + """解析一个可能被 Runtime 注入覆盖的池托管目录。 + + 非空且为绝对路径、父目录存在时采用注入值(返回的 ``injected`` 为 + ``True``);未设置该变量时按池本地默认目录静默回退(未受监督的今天没有 + 变化);设置了但无效(相对路径、父目录不存在)时同样回退,但记一条 + warning——这属于配置错误,不应该被默默吞掉。两种回退情形 ``injected`` + 都是 ``False``:调用方(尤其是 ``_canonicalize_pool_paths``)需要这个 + 事实来判断是否可以放行「路径落在 pool_root 之外」。 + """ + + default_dir = (Path(pool_root).resolve() / relative_default).resolve() + raw_value = os.environ.get(env_name) + if raw_value is None: + return default_dir, False + candidate_text = raw_value.strip() + if not candidate_text: + return default_dir, False + candidate = Path(candidate_text) + if not candidate.is_absolute(): + logger.warning( + "%s环境变量 %s 不是绝对路径,已忽略注入值并回退到池本地目录:%s", + label, + env_name, + candidate_text, + ) + return default_dir, False + if not candidate.parent.exists(): + logger.warning( + "%s环境变量 %s 的父目录不存在,已忽略注入值并回退到池本地目录:%s", + label, + env_name, + candidate_text, + ) + return default_dir, False + return candidate.resolve(), True + + +def _resolve_uv_cache_dir_with_source(pool_root: str | Path) -> tuple[Path, bool]: + return _resolve_injected_pool_directory( + pool_root, + env_name=AUTO_MAS_UV_CACHE_DIR_ENV, + relative_default=UV_CACHE_RELATIVE_PATH, + label="uv 缓存目录", + ) + + +def _resolve_python_install_dir_with_source(pool_root: str | Path) -> tuple[Path, bool]: + return _resolve_injected_pool_directory( + pool_root, + env_name=AUTO_MAS_UV_PYTHON_INSTALL_DIR_ENV, + relative_default=UV_PYTHON_RELATIVE_PATH, + label="Python 安装目录", + ) + + +def resolve_uv_cache_dir(pool_root: str | Path) -> Path: + """解析 MaaFW 运行池实际使用的 uv 缓存目录。 + + 受监督时优先复用 Runtime 经 ``AUTO_MAS_UV_CACHE_DIR`` 注入的受管缓存目录, + 与 Runtime 主项目共用一份 wheel 缓存;未设置、相对路径或父目录不存在时 + 视为无效注入,回退到池本地目录 ``/cache/uv``。 + + 只要路径,不关心是否命中注入;需要一并知道「是否注入」时改用 + ``_resolve_uv_cache_dir_with_source``(例如 ``_canonicalize_pool_paths`` + 据此判断能否放行「落在 pool_root 之外」)。 + """ + + path, _injected = _resolve_uv_cache_dir_with_source(pool_root) + return path + + +def resolve_python_install_dir(pool_root: str | Path) -> Path: + """解析 MaaFW 运行池实际使用的 Python 安装目录。 + + 受监督时优先复用 Runtime 经 ``AUTO_MAS_UV_PYTHON_INSTALL_DIR`` 注入的受管 + Python 安装目录,与 Runtime 主项目共用同一份解释器;未设置、相对路径或 + 父目录不存在时视为无效注入,回退到池本地目录 ``/python``。 + + 只要路径,不关心是否命中注入;需要一并知道「是否注入」时改用 + ``_resolve_python_install_dir_with_source``。 + """ + + path, _injected = _resolve_python_install_dir_with_source(pool_root) + return path + + +def _split_semicolon_list(raw: str | None) -> list[str]: + """按 ``;`` 切分一份有序源列表,去首尾空白、丢弃空项;不做去重。""" + + if not raw: + return [] + return [item.strip() for item in raw.split(";") if item.strip()] + + +def _build_ordered_candidates(*groups: Sequence[str | None]) -> list[str]: + """按给定顺序合并多组候选值,跳过空白项并做跨组保序去重。""" + + ordered: list[str] = [] + seen: set[str] = set() + for group in groups: + for raw in group: + value = str(raw or "").strip() + if not value or value in seen: + continue + seen.add(value) + ordered.append(value) + return ordered + + +def resolve_package_index_candidates() -> list[str] | None: + """解析 Python 包索引的有序候选列表,供调用方按序重试。 + + 优先级:单值 ``AUTO_MAS_UV_INDEX_URL``(若设置,作为用户显式首选)在前, + 随后追加 Runtime 经 ``AUTO_MAS_MIRROR_PACKAGE_INDEX`` 注入的 ``;`` 分隔有序 + 列表;跨两者保序去重。全部为空时返回 ``None``,调用方应沿用 uv 默认行为。 + """ + + ordered = _build_ordered_candidates( + [os.environ.get(AUTO_MAS_UV_INDEX_URL_ENV)], + _split_semicolon_list(os.environ.get(AUTO_MAS_MIRROR_PACKAGE_INDEX_ENV)), + ) + return ordered or None + + +def _resolve_python_mirror_candidates(*, explicit_mirror: str | None) -> list[str] | None: + """解析 Python 解释器分发源的有序候选列表,供 ``uv python install`` 按序重试。 + + 优先级:调用方已解析出的显式 ``UV_PYTHON_INSTALL_MIRROR``(若有)最高, + 其次是单值 ``AUTO_MAS_UV_PYTHON_INSTALL_MIRROR``,最后追加 Runtime 经 + ``AUTO_MAS_MIRROR_PYTHON`` 注入的 ``;`` 分隔有序列表;全体保序去重。 + 全部为空时返回 ``None``,调用方应沿用 uv 默认行为(不设置该变量)。 + """ + + ordered = _build_ordered_candidates( + [explicit_mirror], + [os.environ.get(AUTO_MAS_UV_PYTHON_INSTALL_MIRROR_ENV)], + _split_semicolon_list(os.environ.get(AUTO_MAS_MIRROR_PYTHON_ENV)), + ) + return ordered or None + + # 探针里必须真的 import ctypes:MaaFW 的 Python 绑定第一行就是 ``import ctypes``, # 而 ABI 那几项(version/soabi/platform)全部来自解释器二进制,标准库那一半坏了 # 照样报得一模一样。真机上就这么漏过去过——探测全绿,worker 起来才在 @@ -120,11 +281,22 @@ def resolve_python_interpreter( } root = Path(pool_root).resolve() + python_root, python_root_injected = _resolve_python_install_dir_with_source(root) + cache_dir, cache_dir_injected = _resolve_uv_cache_dir_with_source(root) root, python_root, cache_dir = _canonicalize_pool_paths( root, - root / UV_PYTHON_RELATIVE_PATH, - root / UV_CACHE_RELATIVE_PATH, + python_root, + cache_dir, + python_injected=python_root_injected, + cache_injected=cache_dir_injected, ) + # 下面每个下游调用内部都会用同一对 pool_root/python_root/cache_dir 再次 + # 调用 _canonicalize_pool_paths 做防御性复核;不带上这两个标记,复核会用 + # 默认值 False,把受监督时合法的「路径落在 pool_root 之外」当成 bug 拒掉。 + path_injected_kwargs = { + "python_injected": python_root_injected, + "cache_injected": cache_dir_injected, + } uv_executable = _find_uv_executable(sys.executable) if uv_executable is None: if allow_install: @@ -140,6 +312,7 @@ def resolve_python_interpreter( pool_root=root, python_root=python_root, cache_dir=cache_dir, + **path_injected_kwargs, ) if executable is None: continue @@ -166,6 +339,7 @@ def resolve_python_interpreter( python_root=python_root, cache_dir=cache_dir, only_installed=True, + **path_injected_kwargs, ) if installed_target is None: return None @@ -175,6 +349,7 @@ def resolve_python_interpreter( pool_root=root, python_root=python_root, cache_dir=cache_dir, + **path_injected_kwargs, ) if executable is None: return None @@ -200,6 +375,7 @@ def resolve_python_interpreter( python_root=python_root, cache_dir=cache_dir, only_installed=False, + **path_injected_kwargs, ) if selected_download is None: raise RuntimeError( @@ -215,6 +391,7 @@ def resolve_python_interpreter( pool_root=root, python_root=python_root, cache_dir=cache_dir, + **path_injected_kwargs, ) executable = _find_pool_managed_python( uv_executable, @@ -222,6 +399,7 @@ def resolve_python_interpreter( pool_root=root, python_root=python_root, cache_dir=cache_dir, + **path_injected_kwargs, ) if executable is None: raise RuntimeError( @@ -265,7 +443,7 @@ def install_python_runtime( _verify_runtime_identity(Path(bootstrap), identity) resolved_cwd = Path(cwd).resolve() if cwd is not None else Path.cwd() pool_root = _runtime_pool_root(environment_path) - uv_cache_dir = (pool_root / UV_CACHE_RELATIVE_PATH).resolve() + uv_cache_dir = resolve_uv_cache_dir(pool_root) uv_executable = _find_uv_executable(bootstrap) if uv_executable is not None: uv_cache_dir.mkdir(parents=True, exist_ok=True) @@ -286,7 +464,7 @@ def install_python_runtime( probe = _verify_runtime_identity(python_executable, identity) log(f"[MaaFW Runtime Pool] 安装依赖: {', '.join(requirements)}") if uv_executable is not None: - _install_requirements_with_uv( + index_metadata = _install_requirements_with_uv( uv_executable, python_executable, requirements, @@ -308,10 +486,19 @@ def install_python_runtime( ) dependency_installer = "pip" resolved_requirements = _resolved_requirements(python_executable) + index_metadata = None _verify_maafw_importable(python_executable) version = _installed_maafw_version(python_executable) installer_name = "uv" if uv_executable is not None else "pip" - installer_metadata = { + cache_relative_to_pool: str | None = None + if uv_executable is not None: + try: + cache_relative_to_pool = uv_cache_dir.relative_to(pool_root).as_posix() + except ValueError: + # 受监督时 uv_cache_dir 可能是 Runtime 注入的共享目录,不在 pool_root + # 之内——这不是错误,只是「相对池目录」这个概念本身不适用。 + cache_relative_to_pool = None + installer_metadata: dict[str, Any] = { "installer": { "name": installer_name, "version": ( @@ -332,14 +519,14 @@ def install_python_runtime( "scope": "pool" if uv_executable is not None else "external", "shared": uv_executable is not None, "path": str(uv_cache_dir) if uv_executable is not None else None, - "relativeToPool": ( - UV_CACHE_RELATIVE_PATH.as_posix() if uv_executable is not None else None - ), + "relativeToPool": cache_relative_to_pool, }, "link": { "mode": UV_LINK_MODE if uv_executable is not None else "pip-default", }, } + if index_metadata is not None: + installer_metadata["index"] = index_metadata return { "pythonExecutable": str(python_executable), "pythonVersion": probe.get("version") or platform.python_version(), @@ -558,11 +745,15 @@ def _find_pool_managed_python( pool_root: Path, python_root: Path, cache_dir: Path, + python_injected: bool = False, + cache_injected: bool = False, ) -> Path | None: pool_root, python_root, cache_dir = _canonicalize_pool_paths( pool_root, python_root, cache_dir, + python_injected=python_injected, + cache_injected=cache_injected, ) try: result = subprocess.run( @@ -619,29 +810,56 @@ def _install_pool_managed_python( pool_root: Path, python_root: Path, cache_dir: Path, + python_injected: bool = False, + cache_injected: bool = False, ) -> None: + """按 ``_resolve_python_mirror_candidates`` 的顺序重试同一条安装命令。 + + 命令本身不含镜像参数——uv 只认 ``UV_PYTHON_INSTALL_MIRROR`` 环境变量, + 因此每次重试只换 env 里这一个键,命令行不变。 + """ + pool_root, python_root, cache_dir = _canonicalize_pool_paths( pool_root, python_root, cache_dir, + python_injected=python_injected, + cache_injected=cache_injected, ) - _run( - [ - uv_executable, - "python", - "install", - f"cpython-{target_version}", - "--install-dir", - str(python_root), - "--no-bin", - "--no-registry", - "--cache-dir", - str(cache_dir), - "--no-progress", - ], + base_env = _uv_environment(cache_dir, UV_LINK_MODE) + base_env["UV_PYTHON_INSTALL_DIR"] = str(python_root) + explicit_mirror = str(base_env.get("UV_PYTHON_INSTALL_MIRROR") or "").strip() or None + candidates = _resolve_python_mirror_candidates(explicit_mirror=explicit_mirror) + + command = [ + uv_executable, + "python", + "install", + f"cpython-{target_version}", + "--install-dir", + str(python_root), + "--no-bin", + "--no-registry", + "--cache-dir", + str(cache_dir), + "--no-progress", + ] + + def _build_env(source: str | None) -> dict[str, str]: + env = dict(base_env) + if source: + env["UV_PYTHON_INSTALL_MIRROR"] = source + else: + env.pop("UV_PYTHON_INSTALL_MIRROR", None) + return env + + _run_with_source_rotation( + lambda _source: command, + candidates, cwd=pool_root, - env=_pool_python_environment(python_root, cache_dir), + build_env=_build_env, timeout=UV_PYTHON_INSTALL_TIMEOUT_SECONDS, + failure_label="MaaFW runtime Python 安装", ) @@ -654,6 +872,8 @@ def _select_uv_python_version( python_root: Path, cache_dir: Path, only_installed: bool, + python_injected: bool = False, + cache_injected: bool = False, ) -> str | None: """Select the newest real uv catalog version satisfying a patch range.""" @@ -661,6 +881,8 @@ def _select_uv_python_version( pool_root, python_root, cache_dir, + python_injected=python_injected, + cache_injected=cache_injected, ) scope_flag = "--only-installed" if only_installed else "--only-downloads" @@ -748,16 +970,39 @@ def _canonicalize_pool_paths( pool_root: Path, python_root: Path, cache_dir: Path, + *, + python_injected: bool = False, + cache_injected: bool = False, ) -> tuple[Path, Path, Path]: - """Normalize uv-managed paths and keep them inside the owning pool.""" + """Normalize uv-managed paths and keep non-injected ones inside the owning pool. + + ``python_root``/``cache_dir`` normally derive from ``pool_root`` (via + ``UV_PYTHON_RELATIVE_PATH`` / ``UV_CACHE_RELATIVE_PATH``) and must stay + inside it; this containment check is a defensive sanity net against that + invariant ever breaking, and it still applies unconditionally when the + caller does not say otherwise — this is the ``False`` default for both + flags, i.e. the strict/pre-C11 behaviour. + + Under supervision, ``resolve_python_install_dir``/``resolve_uv_cache_dir`` + (via their ``_with_source`` variants) may legitimately return a + Runtime-injected shared directory outside ``pool_root`` instead + (``AUTO_MAS_UV_PYTHON_INSTALL_DIR`` / ``AUTO_MAS_UV_CACHE_DIR``, C11). + Callers that resolved a path this way must say so via + ``python_injected``/``cache_injected`` so *that* path's containment check + is skipped — every path that was not resolved from an injected env var + (including a caller that simply omits these flags) is still asserted + exactly as before. + """ resolved_pool = Path(pool_root).resolve() resolved_python = Path(python_root).resolve() resolved_cache = Path(cache_dir).resolve() - for label, candidate in ( - ("python", resolved_python), - ("cache", resolved_cache), + for label, candidate, injected in ( + ("python", resolved_python, python_injected), + ("cache", resolved_cache, cache_injected), ): + if injected: + continue if not _path_is_within(candidate, resolved_pool): raise RuntimeError( f"runtime pool {label} path escapes the pool: {candidate}" @@ -836,17 +1081,24 @@ def _install_requirements_with_uv( cache_dir: Path, link_mode: str, cwd: Path, -) -> None: - index_args: list[str] = [] - if not any( - str(os.environ.get(name) or "").strip() - for name in ("UV_INDEX_URL", "UV_DEFAULT_INDEX") - ): - index_url = str(os.environ.get(AUTO_MAS_UV_INDEX_URL_ENV) or "").strip() - if index_url: - index_args = ["--index-url", index_url] - _run( - [ +) -> dict[str, Any] | None: + """按 ``resolve_package_index_candidates()`` 的顺序重试同一条安装命令。 + + 用户已经显式设置 ``UV_INDEX_URL``/``UV_DEFAULT_INDEX`` 时,沿用 uv 自身对 + 这两个环境变量的解析,不参与本机制的候选与重试(尊重更明确的显式配置)。 + 返回实际生效的索引来源与尝试序号,供调用方写入 ``installer_metadata``; + 未使用候选列表(未配置任何镜像/单值索引,或命中上面的显式旁路)时返回 + ``None``。 + """ + + env = _uv_install_environment( + python_executable.parent.parent, + cache_dir, + link_mode, + ) + + def _base_command(index_args: list[str]) -> list[str]: + return [ uv_executable, "pip", "install", @@ -860,14 +1112,29 @@ def _install_requirements_with_uv( "--quiet", *index_args, *requirements, - ], - cwd=cwd, - env=_uv_install_environment( - python_executable.parent.parent, - cache_dir, - link_mode, + ] + + if any( + str(os.environ.get(name) or "").strip() + for name in ("UV_INDEX_URL", "UV_DEFAULT_INDEX") + ): + _run(_base_command([]), cwd=cwd, env=env) + return None + + candidates = resolve_package_index_candidates() + source, attempt = _run_with_source_rotation( + lambda index_source: _base_command( + ["--index-url", index_source] if index_source else [] ), + candidates, + cwd=cwd, + build_env=lambda _source: env, + timeout=RUNTIME_INSTALL_TIMEOUT_SECONDS, + failure_label="MaaFW runtime 依赖安装", ) + if source is None: + return None + return {"source": source, "attempt": attempt} def _install_requirements_with_pip( @@ -1005,6 +1272,67 @@ def _run( ) +def _run_with_source_rotation( + build_command: Callable[[str | None], list[str]], + candidates: Sequence[str] | None, + *, + cwd: Path, + build_env: Callable[[str | None], dict[str, str]], + timeout: int, + failure_label: str, +) -> tuple[str | None, int]: + """按候选源顺序重试同一条安装命令,返回 (实际使用的源, 尝试序号)。 + + ``candidates`` 为 ``None``/空时只按「不指定源」跑一次,行为与未下发候选 + 列表时完全一致;返回的源是 ``None``,序号是 1。 + + 某次尝试以非零退出码结束(命令确实跑完了,只是失败)就记一条 warning + (含失败来源与 stderr 尾部)后换下一个候选;全部候选都失败则抛出最后一次 + 的 ``RuntimeError``。超时或进程本身无法启动(``subprocess.TimeoutExpired`` + 以外的异常,例如可执行文件不存在)视为该次尝试之外的问题,不换源,直接 + 向上抛出——换一个包索引或分发源不可能修好「uv 都跑不起来」。 + """ + + attempts: list[str | None] = list(candidates) if candidates else [None] + last_error: RuntimeError | None = None + for attempt_index, source in enumerate(attempts, start=1): + command = build_command(source) + env = build_env(source) + try: + result = subprocess.run( + command, + capture_output=True, + timeout=timeout, + text=True, + encoding="utf-8", + errors="replace", + cwd=cwd, + env=env, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"{failure_label}超时: {command[:3]}") from exc + if result.returncode == 0: + return source, attempt_index + detail = (result.stderr or result.stdout or "").strip() + last_error = RuntimeError( + f"{failure_label}失败 (exit={result.returncode}): {detail[:800]}" + ) + if attempt_index < len(attempts): + logger.warning( + "%s失败,换下一个源重试(失败源:%s,第 %d/%d 次尝试):%s", + failure_label, + source or "默认", + attempt_index, + len(attempts), + detail[-400:], + ) + if last_error is None: + # attempts 至少一项,循环体必然至少跑过一次并设置过 last_error; + # 走到这里说明调用方式本身有 bug。 + raise RuntimeError(f"{failure_label}重试逻辑内部错误:候选列表为空") + raise last_error + + def _clean_process_environment() -> dict[str, str]: env = os.environ.copy() for name in ( diff --git a/app/task/MaaFW/tools/embedded/runner_task.py b/app/task/MaaFW/tools/embedded/runner_task.py index 716ef9f78..333c3ac56 100644 --- a/app/task/MaaFW/tools/embedded/runner_task.py +++ b/app/task/MaaFW/tools/embedded/runner_task.py @@ -26,6 +26,7 @@ from app.task.general.tools import execute_script_task from app.utils import ProcessInfo, ProcessManager, get_logger from app.utils.constants import UTC4 +from app.utils.io import migrate_legacy_dir from app.task.MaaFW.tools.core.automas_maafw_controller_win32.service import ( MaaFWWin32ControllerService, ) @@ -1069,7 +1070,7 @@ async def release_after_prepare() -> None: f"v{runner_environment.maafw_version.lstrip('v')}" ) payload = service.create_job_payload(runner_plan, device_config) - work_dir = Path.cwd() / "runtime" / "maafw_runner_jobs" + work_dir = _maafw_runner_jobs_dir() job_path = await asyncio.to_thread( service.write_job_file, payload, work_dir ) @@ -1863,6 +1864,19 @@ def _append_log(self, message: str) -> None: self.script_info.log = str(message) +def _maafw_runner_jobs_dir() -> Path: + """MaaFW 任务 job 文件的落盘目录。 + + 落在受保护的 ``data/`` 下,避免与 AUTO-MAS-Runtime 监督器接管的 + ``runtime/`` 撞名;首次访问时把用户机器上已有的旧 + ``runtime/maafw_runner_jobs`` 整体迁移过来。 + """ + + new_dir = Path.cwd() / "data" / "maafw_runner_jobs" + migrate_legacy_dir(Path.cwd() / "runtime" / "maafw_runner_jobs", new_dir) + return new_dir + + def _find_controller( interface_model: MaaFWInterface, controller_name: str ) -> MaaFWController: diff --git a/app/task/Okww/push_log.py b/app/task/Okww/push_log.py index 43bfa0913..43735750b 100644 --- a/app/task/Okww/push_log.py +++ b/app/task/Okww/push_log.py @@ -24,18 +24,17 @@ from pathlib import Path from app.log_box.logtype import LogType +from app.utils import resource_path # ok-ww 自带翻译文件相对路径(从 RootPath 派生,不硬编码绝对路径) OKWW_REL_I18N_PO = "data/apps/ok-ww/repo/i18n/zh_CN/LC_MESSAGES/ok.po" def _okww_supplement_po() -> Path: - """AutoMAS 项目自带的补充翻译 .po(res/ 内置资源,运行时以工作目录解析, - 随打包资源分发,不依赖源码树路径;.po 为可读源码,可直接维护)。 - - 在调用时求值而非 import 时,避免依赖模块 import 时刻的工作目录。 + """AutoMAS 项目自带的补充翻译 .po(res/ 内置资源,按源码位置解析,不随 + 工作目录变化;.po 为可读源码,可直接维护)。 """ - return Path.cwd() / "res" / "i18n" / "okww.po" + return resource_path("i18n", "okww.po") # 推送规则:(匹配正则, 提取表达式 [, 日志类型]);匹配与提取均在翻译后行。 diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 32f07b4b7..647fd626c 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -26,12 +26,14 @@ from .constants import * from .logger import get_logger +from .paths import resource_path from .security import ( dpapi_encrypt, dpapi_decrypt, format_exception_reason, sanitize_log_message, ) +from .supervision import is_supervised _LAZY_EXPORTS = { "LogMonitor": (".LogMonitor", "LogMonitor"), @@ -136,6 +138,8 @@ def __getattribute__(self, name: str): "dpapi_decrypt", "format_exception_reason", "sanitize_log_message", + "is_supervised", + "resource_path", "strptime", "MumuManager", "LDManager", diff --git a/app/utils/io.py b/app/utils/io.py index ec6c9ce4d..370eb738f 100644 --- a/app/utils/io.py +++ b/app/utils/io.py @@ -24,6 +24,7 @@ import json import json5 import os +import shutil import threading import tomllib from contextlib import suppress @@ -33,8 +34,11 @@ import tomli_w import yaml +from .logger import get_logger from .tools import decode_bytes +logger = get_logger("路径迁移") + # 格式后缀 -> (dump: (dict, encoding)->bytes, load: bytes->dict) # 若要扩展格式, 直接改此表 _CODECS: dict[str, tuple[Any, Any]] = { @@ -158,3 +162,31 @@ def write_file( if not isinstance(payload, str): raise ValueError(f"不支持的配置文件格式 `{_suffix}`,且内容非字符串") atomic_write(path, payload.encode(encoding)) + + +def migrate_legacy_dir(old_path: Path, new_path: Path) -> bool: + """ + 首次访问时把整个旧目录搬迁到新路径, 用于落盘目录改名/搬家场景 + + 仅在新路径不存在且旧路径存在时执行, 天然只做一次: 一旦新路径落地 + (搬迁成功, 或调用方在此之后自行创建), 后续调用即判定新路径已存在而跳过。 + 迁移失败 (如跨设备移动出错) 只记 warning, 不向上抛出, 不阻塞调用方 + 继续在新路径上创建目录、写入文件。 + + Args: + old_path: 旧目录路径 + new_path: 新目录路径 + + Returns: + bool: 是否实际执行了搬迁 + """ + if new_path.exists() or not old_path.exists(): + return False + try: + new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(old_path), str(new_path)) + except Exception as exc: # noqa: BLE001 - 迁移失败不应阻塞调用方后续访问 + logger.warning(f"旧目录迁移失败,将继续使用新路径:{old_path} -> {new_path}:{exc}") + return False + logger.info(f"旧目录已迁移:{old_path} -> {new_path}") + return True diff --git a/app/utils/paths.py b/app/utils/paths.py new file mode 100644 index 000000000..da5031cf2 --- /dev/null +++ b/app/utils/paths.py @@ -0,0 +1,46 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + + +# Contact: DLmaster_361@163.com + + +from pathlib import Path + +# app/utils/paths.py → app/utils → app → 仓库根,与 main.py 里 current_dir 的 +# 计算方式同源,只是从这个文件自己的位置往上数三层。 +SOURCE_ROOT = Path(__file__).resolve().parents[2] + + +def resource_path(*parts: str) -> Path: + """解析源码内置资源(res/ 下的图片、音效、模板、词表等)的绝对路径。 + + 受 AUTO-MAS-Runtime 监督时,工作目录是 /,源码在其 + /repo/ 子目录,监督器整体替换 repo/ 来更新,因此两者不再相等, + 不能再用 Path.cwd() 定位内置资源;只有随源码分发、随源码更新的只读资源 + 才走这里——用户数据(config/data/history/script/debug/plugins)必须继续 + 相对 Path.cwd() 解析,否则监督器更新 repo/ 时会把用户数据一并冲掉。 + + Args: + *parts: 相对 res/ 目录的路径片段,如 resource_path("images", "materials")。 + + Returns: + 拼接后的绝对路径,不检查是否存在。 + """ + + return SOURCE_ROOT.joinpath("res", *parts) diff --git a/app/utils/platform/common/process.py b/app/utils/platform/common/process.py index c0b61257f..82fddef9d 100644 --- a/app/utils/platform/common/process.py +++ b/app/utils/platform/common/process.py @@ -31,12 +31,15 @@ from datetime import datetime, timedelta from pathlib import Path +from app.utils import get_logger from app.utils.platform import window from app.utils.platform.common.errors import UnsupportedPlatformError from app.utils.platform.process import platform_process from .process_runner import ProcessResult, ProcessRunner +logger = get_logger("进程管理") + @dataclass class ProcessInfo: @@ -260,15 +263,44 @@ async def open_process( stderr = asyncio.subprocess.PIPE drain_streams.append("stderr") - self.process = await asyncio.create_subprocess_exec( - program, - *args, - cwd=cwd or (Path(program).parent if Path(program).is_file() else None), - stdin=stdin, - stdout=stdout, - stderr=stderr, - creationflags=platform_process.creation_flags, - ) + resolved_cwd = cwd or (Path(program).parent if Path(program).is_file() else None) + try: + self.process = await asyncio.create_subprocess_exec( + program, + *args, + cwd=resolved_cwd, + stdin=stdin, + stdout=stdout, + stderr=stderr, + creationflags=platform_process.creation_flags, + ) + except OSError as exc: + # 受 AUTO-MAS-Runtime 监督时 creation_flags 带 + # CREATE_BREAKAWAY_FROM_JOB(详见 WindowsProcessPlatform),让模拟 + # 器/游戏之类的外部进程不随后端一起被 Job 回收。但父进程若恰好处 + # 在一个不允许 breakaway 的 Job 里,CreateProcess 会以 + # ERROR_ACCESS_DENIED(WinError 5,映射为 PermissionError)失败—— + # 去掉该位重试一次,此时子进程会留在当前 Job 里。 + breakaway_flag = getattr(subprocess, "CREATE_BREAKAWAY_FROM_JOB", 0) + if ( + os.name != "nt" + or not (platform_process.creation_flags & breakaway_flag) + or getattr(exc, "winerror", None) != 5 + ): + raise + logger.warning( + f"带 CREATE_BREAKAWAY_FROM_JOB 启动子进程被拒绝(WinError 5)," + f"父进程所在 Job 不允许脱离,去掉该标志重试: {program}" + ) + self.process = await asyncio.create_subprocess_exec( + program, + *args, + cwd=resolved_cwd, + stdin=stdin, + stdout=stdout, + stderr=stderr, + creationflags=platform_process.creation_flags & ~breakaway_flag, + ) # 启动协程消费管道流以防止阻塞 if drain_streams: diff --git a/app/utils/platform/windows/process.py b/app/utils/platform/windows/process.py index 3742c40b3..fdc5f1dd3 100644 --- a/app/utils/platform/windows/process.py +++ b/app/utils/platform/windows/process.py @@ -5,11 +5,21 @@ class WindowsProcessPlatform: - creation_flags = subprocess.CREATE_NO_WINDOW + # 后端被 AUTO-MAS-Runtime 用 Job Object 监督(KILL_ON_JOB_CLOSE)时,这两 + # 组标志启动的都是不该随后端一起被回收的进程——模拟器/游戏/外部脚本,以及 + # 自更新时接替当前进程的安装程序——所以都叠加 CREATE_BREAKAWAY_FROM_JOB + # 显式脱离。注意 DETACHED_PROCESS 本身不会让子进程脱离 Job,必须靠这个 + # 标志才行,不要以为 DETACHED_PROCESS 已经处理了这件事。 + # + # 父进程若恰好处在一个不允许 breakaway 的 Job 里,带这个标志的 + # CreateProcess 会以 ERROR_ACCESS_DENIED(WinError 5)失败; + # ProcessManager.open_process 对此有去掉该位重试一次的兜底。 + creation_flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_BREAKAWAY_FROM_JOB detached_flags = ( subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW + | subprocess.CREATE_BREAKAWAY_FROM_JOB ) async def open_protocol(self, protocol_url: str) -> None: diff --git a/app/utils/supervision.py b/app/utils/supervision.py new file mode 100644 index 000000000..acef4e3e8 --- /dev/null +++ b/app/utils/supervision.py @@ -0,0 +1,38 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team + +# This file is part of AUTO-MAS. + +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. + +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + + +# Contact: DLmaster_361@163.com + + +import os + + +def is_supervised() -> bool: + """识别当前进程是否处于外部监督器(AUTO-MAS-Runtime)托管之下。 + + Runtime 用 Windows Job Object 托管后端进程树,拉起时注入 + AUTO_MAS_SUPERVISED=1,健康检查固定打 36163,关闭时依赖 /api/core/close + 真正生效。判据按其契约要求精确匹配字符串 "1",不做 true/yes 等宽松解析。 + + main.py 与 app/api/core.py 都据此判断是否遵守受监督约定 + (不自行提权、端口固定、关闭请求真实生效),因此放在两者都能直接 + 依赖的 app.utils 里,避免互相导入。 + """ + + return os.getenv("AUTO_MAS_SUPERVISED") == "1" diff --git a/frontend/electron/ipc/initializationHandlers.ts b/frontend/electron/ipc/initializationHandlers.ts index fec0c25fb..2239e58a6 100644 --- a/frontend/electron/ipc/initializationHandlers.ts +++ b/frontend/electron/ipc/initializationHandlers.ts @@ -3,11 +3,34 @@ * 使用新的服务 */ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain, BrowserWindow, IpcMainInvokeEvent, app } from 'electron' +import * as path from 'path' import { getAppRoot } from '../services/environmentService' import { InitializationService, BackendService } from '../services' import { getLogger } from '../services/logger' import type { ApiEndpoints, MirrorConfig } from '../services/mirrorService' +import type { RuntimeLaunchMode } from '../services/runtime' +import { resolveRuntimeLaunchConfig, resolveRuntimeLaunchMode } from '../services/runtime' +import type { + CriticalFilesCheck, + InitializationRunStage, + RuntimeRetryMode, + RuntimeStageOutcome, +} from '../services/runtimeInitializationService' +import { + listRuntimeMappableMirrorKeys, + mapDoctorChecksToCriticalFiles, +} from '../services/runtimeInitializationService' +import type { + RuntimeUpdateOutcome, + RuntimeUpdateRetryAction, +} from '../services/runtimeUpdateService' +import { + cancelBackendUpdate, + resetRuntimeUpdateSession, + retryBackendUpdate, + updateBackendViaRuntime, +} from '../services/runtimeUpdateService' const logger = getLogger('初始化处理器') const mirrorTypes = new Set(['python', 'get_pip', 'git', 'repo', 'pip_mirror']) @@ -19,6 +42,19 @@ const isMirrorType = (value: unknown): value is keyof MirrorConfig => const isApiEndpointKey = (value: unknown): value is keyof ApiEndpoints => typeof value === 'string' && apiEndpointKeys.has(value as keyof ApiEndpoints) +/** 更新流程的进度事件通道,与初始化的分段进度通道分开,互不干扰。 */ +export const BACKEND_UPDATE_PROGRESS_CHANNEL = 'backend-update-progress' + +const retryActions = new Set([ + 'workspace-sync', + 'dependencies-sync', + 'dependencies-rebuild', + 'repair', +]) + +const isRetryAction = (value: unknown): value is RuntimeUpdateRetryAction => + typeof value === 'string' && retryActions.has(value as RuntimeUpdateRetryAction) + // 全局实例 let initService: InitializationService | null = null let backendService: BackendService | null = null @@ -50,8 +86,109 @@ export function getBackendService(): BackendService { return backendService } +/** + * 主进程与渲染进程共用的后端端点解析。 + * + * Runtime 监督链路就绪后必须用它在 `state:running` 事件里下发的 baseUrl(协议 v1 固定 + * 36163),不能再按 `resolveHttpPort()` 的开发/正式分流算端口;旧链路仍走镜像源服务。 + */ +function resolveApiEndpoints(): ApiEndpoints { + const initService = getInitService() + // 完整初始化流程用 InitializationService 内部的实例启动后端,backend-start 用模块级实例, + // 两条路径都可能持有 Runtime 句柄,取先就绪的那个。 + const runtimeEndpoints = + getBackendService().getRuntimeApiEndpoints() ?? + initService.getBackendService().getRuntimeApiEndpoints() + if (runtimeEndpoints) return runtimeEndpoints + + return initService.getMirrorService().getApiEndpoints() +} + export function getLocalApiEndpoint(): string { - return getInitService().getMirrorService().getApiEndpoint('local') + return resolveApiEndpoints().local +} + +/** + * Runtime 链路下的单步执行与重试。 + * + * 界面上的「重试」与「切换镜像后重试」用的都是下面这几个安装 handler,Runtime 链路下 + * 它们转成对应的下层命令(`environment ensure` / `workspace sync` / `dependencies sync`), + * 选了镜像源则整条 `bootstrap` 带 `--mirror` 重跑。返回 null 表示灰度开关关闭,走旧链路。 + * + * 逐步进度通道是按步骤分的,渲染进程只看进度数值不看段名,所以这里只转发本段的进度, + * 否则整条 bootstrap 重跑时 `mirror` / `pip` / `git` 的完成进度会把当前步骤误标成完成。 + * + * `rebuild` 对应界面上「重建环境」这个按钮:它与「重试」共用本通道,只是要求 Runtime + * 走重建版本的下层命令。IPC 上只传一个布尔量,主进程里统一按 + * {@link RuntimeRetryMode} 表达;不传或传 false 都退回 `auto`,仍按上一次失败的 + * remediation 决定,不把普通「重试」硬钉成「不重建」。 + */ +function toRetryMode(rebuild?: boolean): RuntimeRetryMode { + return rebuild ? 'rebuild' : 'auto' +} + +async function runStageViaRuntime( + event: IpcMainInvokeEvent, + stage: InitializationRunStage, + progressChannel: string, + selectedMirror?: string, + rebuild?: boolean +): Promise { + const initService = getInitService() + return initService.retryStageViaRuntime( + stage, + progress => { + if (progress.stage !== stage) return + event.sender.send(progressChannel, progress) + }, + selectedMirror, + toRetryMode(rebuild) + ) +} + +/** + * 界面开局要问一次的 Runtime 上下文。 + * + * 三件事各有各的来源,但界面在同一时刻需要它们:走没走 Runtime(决定步骤标签与哪些段 + * 直接置完成)、失败时「打开日志」拿不到 Runtime 日志路径要退回哪个文件、 + * 「换镜像重试」在 Runtime 下该列哪些镜像键。 + */ +export interface RuntimeInitContext { + mode: RuntimeLaunchMode + /** 本程序自己的日志文件,Runtime 没给 `logPath` 时的回退。 */ + fallbackLogPath: string + /** 各段在 Runtime 链路下映射得到的旧镜像键;空数组表示该段不展示镜像选择。 */ + mirrorKeys: Record +} + +export function resolveRuntimeInitContext(): RuntimeInitContext { + return { + // 灰度开关升级为三级来源后要按 appRoot 读持久化设置,不能再零参解析。 + mode: resolveRuntimeLaunchMode(getAppRoot()), + fallbackLogPath: path.join(path.dirname(app.getPath('exe')), 'debug', 'frontend.log'), + mirrorKeys: listRuntimeMappableMirrorKeys(), + } +} + +/** + * Runtime 链路下的关键文件检查。 + * + * 旧链路数 exe 文件,新链路问 Runtime `doctor`:受管布局里 `repo` 缺失就是没装过。 + * 返回 null 表示灰度开关关闭或 doctor 没跑成,调用方继续走旧的文件存在性检查。 + */ +export async function checkCriticalFilesViaRuntime(): Promise { + const runtimeService = getInitService().getRuntimeService() + if (!runtimeService) return null + + const checks = await runtimeService.doctor() + if (!checks) { + logger.warn('Runtime doctor 未给出检查结果,按未初始化处理') + return { pythonExists: false, pipExists: false, gitExists: false, mainPyExists: false } + } + + const result = mapDoctorChecksToCriticalFiles(checks) + logger.info(`Runtime doctor 检查结果 - 受管仓库${result.mainPyExists ? '已就绪' : '缺失'}`) + return result } /** @@ -77,10 +214,20 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { // ==================== Python 安装 ==================== - ipcMain.handle('install-python', async (event, selectedMirror?: string) => { + ipcMain.handle('install-python', async (event, selectedMirror?: string, rebuild?: boolean) => { if (selectedMirror) { logger.info(`使用指定镜像源安装Python: ${selectedMirror}`) } + + const runtimeOutcome = await runStageViaRuntime( + event, + 'python', + 'python-progress', + selectedMirror, + rebuild + ) + if (runtimeOutcome) return runtimeOutcome + const appRoot = getAppRoot() const initService = getInitService() const mirrorService = initService.getMirrorService() @@ -101,10 +248,20 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { // ==================== Pip 安装 ==================== - ipcMain.handle('install-pip', async (event, selectedMirror?: string) => { + ipcMain.handle('install-pip', async (event, selectedMirror?: string, rebuild?: boolean) => { if (selectedMirror) { logger.info(`使用指定镜像源安装Pip: ${selectedMirror}`) } + + const runtimeOutcome = await runStageViaRuntime( + event, + 'pip', + 'pip-progress', + selectedMirror, + rebuild + ) + if (runtimeOutcome) return runtimeOutcome + const appRoot = getAppRoot() const initService = getInitService() const mirrorService = initService.getMirrorService() @@ -125,10 +282,20 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { // ==================== Git 安装 ==================== - ipcMain.handle('install-git', async (event, selectedMirror?: string) => { + ipcMain.handle('install-git', async (event, selectedMirror?: string, rebuild?: boolean) => { if (selectedMirror) { logger.info(`使用指定镜像源安装Git: ${selectedMirror}`) } + + const runtimeOutcome = await runStageViaRuntime( + event, + 'git', + 'git-progress', + selectedMirror, + rebuild + ) + if (runtimeOutcome) return runtimeOutcome + const appRoot = getAppRoot() const initService = getInitService() const mirrorService = initService.getMirrorService() @@ -151,10 +318,21 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { ipcMain.handle( 'pull-repository', - async (event, targetBranch: string = 'dev', selectedMirror?: string) => { + async (event, targetBranch: string = 'dev', selectedMirror?: string, rebuild?: boolean) => { if (selectedMirror) { logger.info(`使用指定镜像源拉取源码: ${selectedMirror}`) } + + // Runtime 链路的目标分支由目标版本推导(`release/<版本>`),targetBranch 不参与。 + const runtimeOutcome = await runStageViaRuntime( + event, + 'repository', + 'repository-progress', + selectedMirror, + rebuild + ) + if (runtimeOutcome) return runtimeOutcome + const appRoot = getAppRoot() const initService = getInitService(targetBranch) const mirrorService = initService.getMirrorService() @@ -176,27 +354,40 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { // ==================== 依赖安装 ==================== - ipcMain.handle('install-dependencies', async (event, selectedMirror?: string) => { - if (selectedMirror) { - logger.info(`使用指定镜像源安装依赖: ${selectedMirror}`) - } - const appRoot = getAppRoot() - const initService = getInitService() - const mirrorService = initService.getMirrorService() + ipcMain.handle( + 'install-dependencies', + async (event, selectedMirror?: string, rebuild?: boolean) => { + if (selectedMirror) { + logger.info(`使用指定镜像源安装依赖: ${selectedMirror}`) + } - const { DependencyService } = await import('../services/dependencyService') - const depService = new DependencyService(appRoot, mirrorService) + const runtimeOutcome = await runStageViaRuntime( + event, + 'dependency', + 'dependency-progress', + selectedMirror, + rebuild + ) + if (runtimeOutcome) return runtimeOutcome - const result = await depService.installDependencies(progress => { - event.sender.send('dependency-progress', progress) - }, selectedMirror) + const appRoot = getAppRoot() + const initService = getInitService() + const mirrorService = initService.getMirrorService() - if (!result.success) { - logger.error(`依赖安装失败: ${result.error}`) - } + const { DependencyService } = await import('../services/dependencyService') + const depService = new DependencyService(appRoot, mirrorService) - return result - }) + const result = await depService.installDependencies(progress => { + event.sender.send('dependency-progress', progress) + }, selectedMirror) + + if (!result.success) { + logger.error(`依赖安装失败: ${result.error}`) + } + + return result + } + ) // ==================== 获取镜像源列表 ==================== @@ -215,17 +406,11 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { ipcMain.handle('get-api-endpoint', async (_event, key: unknown) => { if (!isApiEndpointKey(key)) throw new TypeError(`不支持的 API 端点: ${String(key)}`) - const initService = getInitService() - const mirrorService = initService.getMirrorService() - - return mirrorService.getApiEndpoint(key) + return resolveApiEndpoints()[key] }) ipcMain.handle('get-api-endpoints', async () => { - const initService = getInitService() - const mirrorService = initService.getMirrorService() - - return mirrorService.getApiEndpoints() + return resolveApiEndpoints() }) // ==================== 完整初始化流程(保留用于兼容) ==================== @@ -336,11 +521,58 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { return backend.getStatus() }) + // ==================== Runtime 链路的后端更新 ==================== + + // 标题栏更新入口走哪条链路由 `get-runtime-launch-mode` 决定(off 走原有下载安装包流程, + // development 直接禁用,managed 走下面的 update-backend-via-runtime);该通道由 main.ts + // 统一注册,返回持久化设置与生效来源,这里不再重复注册。 + ipcMain.handle( + 'update-backend-via-runtime', + async (event, targetVersion: unknown): Promise => { + logger.info(`收到 Runtime 后端更新请求,目标版本: ${String(targetVersion)}`) + + const result = await updateBackendViaRuntime( + typeof targetVersion === 'string' ? targetVersion : '', + progress => event.sender.send(BACKEND_UPDATE_PROGRESS_CHANNEL, progress), + { + backend: getBackendService(), + launchConfig: resolveRuntimeLaunchConfig(getAppRoot()), + } + ) + + if (!result.success) { + logger.error(`Runtime 后端更新失败(${result.phase}): ${result.error}`) + } + return result + } + ) + + ipcMain.handle( + 'retry-backend-update', + async (event, action: unknown): Promise => { + if (!isRetryAction(action)) throw new TypeError(`不支持的重试入口: ${String(action)}`) + + logger.info(`重试 Runtime 后端更新: ${action}`) + const result = await retryBackendUpdate(action, progress => + event.sender.send(BACKEND_UPDATE_PROGRESS_CHANNEL, progress) + ) + + if (!result.success) { + logger.error(`Runtime 后端更新重试失败(${result.phase}): ${result.error}`) + } + return result + } + ) + + ipcMain.handle('cancel-backend-update', () => cancelBackendUpdate()) + // ==================== 清理 ==================== ipcMain.handle('cleanup', async () => { logger.info('清理初始化资源') + resetRuntimeUpdateSession() + if (backendService) { await backendService.cleanup() backendService = null @@ -360,6 +592,8 @@ export function registerInitializationHandlers(_mainWindow: BrowserWindow) { export async function cleanupInitializationResources() { logger.info('清理初始化资源') + resetRuntimeUpdateSession() + if (backendService) { await backendService.cleanup() backendService = null diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index c4b8e1b11..6a8418fe1 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -22,8 +22,10 @@ import * as path from 'path' import { checkEnvironment, getAppRoot } from './services/environmentService' import { registerInitializationHandlers, + checkCriticalFilesViaRuntime, getBackendService, getLocalApiEndpoint, + resolveRuntimeInitContext, } from './ipc/initializationHandlers' import { registerFileHandlers } from './ipc/fileHandlers' import { registerOkwwPathDiscoveryHandlers } from './ipc/okwwPathDiscoveryHandlers' @@ -46,6 +48,11 @@ import { setMainTelemetryEnabled, } from './services/sentry' import { applyInstanceIdentity, resolveStopAllTasksShortcut } from './services/instanceConfig' +import { + PersistedRuntimeLaunchMode, + isPersistedRuntimeLaunchMode, + resolveRuntimeLaunchModeDetail, +} from './services/runtime' import AdmZip = require('adm-zip') // 开发环境切换到独立的 userData 目录(必须在 app ready 之前) @@ -145,7 +152,18 @@ let forceKillPromise: Promise | null = null async function forceKillRelatedProcesses(): Promise { if (forceKillPromise) return forceKillPromise forceKillPromise = (async () => { - const result = await getBackendService().forceStopBackend() + const backendService = getBackendService() + + // Runtime 监督链路:只向监督进程发 shutdown(有上限,超时才 kill Runtime 进程本身), + // 后端进程树由 Runtime 的 Job Object 收走,不再走 processManager 的全局清理。 + if (backendService.isRuntimeSupervised()) { + const result = await backendService.stopBackend() + if (!result.success) throw new Error(result.error || '未知错误') + logger.info('Runtime 监督的后端已关闭') + return + } + + const result = await backendService.forceStopBackend() if (!result.success) throw new Error(result.error || '未知错误') logger.info('所有相关进程已清理') })().finally(() => { @@ -230,6 +248,10 @@ interface AppConfig { Function: { IfEnableTelemetry: boolean } + // Runtime 灰度开关的持久化设置,见 services/runtime/launchConfig.ts 的三级优先级说明。 + Runtime: { + LaunchMode: PersistedRuntimeLaunchMode + } [key: string]: unknown } @@ -254,6 +276,9 @@ const defaultConfig: AppConfig = { Function: { IfEnableTelemetry: true, }, + Runtime: { + LaunchMode: 'auto', + }, } //加载配置 @@ -483,6 +508,10 @@ function finishCoordinatedQuit(): void { } async function shouldPreserveBackendForDevMode(): Promise { + // 受 Runtime 监督的后端归 Runtime 生命周期管,即便是 development 模式也必须随之关闭, + // 否则 Electron 退出后会遗留一个没人负责的监督进程。 + if (getBackendService().isRuntimeSupervised()) return false + const backendDevMode = await getBackendService().getBackendDevMode() if (backendDevMode !== null) return backendDevMode return Boolean(process.env.VITE_DEV_SERVER_URL) || !app.isPackaged @@ -1482,9 +1511,16 @@ ipcMain.handle('check-environment', async () => { return checkEnvironment(appRoot) }) +// Runtime 上下文 - 初始化界面开局问一次:走没走 Runtime、回退日志文件、可用镜像键 +ipcMain.handle('get-runtime-init-context', async () => resolveRuntimeInitContext()) + // 关键文件检查 - 每次都重新检查exe文件是否存在 ipcMain.handle('check-critical-files', async () => { try { + // Runtime 链路不再有 environment/python 这套目录,改问 Runtime doctor 要受管布局。 + const runtimeCheck = await checkCriticalFilesViaRuntime() + if (runtimeCheck) return runtimeCheck + const appRoot = getAppRoot() // 检查Python可执行文件 @@ -1811,6 +1847,40 @@ ipcMain.handle('set-initialized-version', async (_event, version: string) => { } }) +// Runtime 灰度开关:持久化设置 + 当前生效值(供设置界面展示来源与效果,重启后生效) +ipcMain.handle('get-runtime-launch-mode', async () => { + try { + const config = loadConfig() + const persisted = isPersistedRuntimeLaunchMode(config.Runtime?.LaunchMode) + ? config.Runtime.LaunchMode + : 'auto' + const resolution = resolveRuntimeLaunchModeDetail(getAppRoot()) + return { persisted, mode: resolution.mode, source: resolution.source } + } catch (error) { + logger.error('读取 Runtime 启动方式失败', error) + return { persisted: 'auto', mode: 'off', source: 'default' } + } +}) + +ipcMain.handle('set-runtime-launch-mode', async (_event, mode: unknown) => { + if (!isPersistedRuntimeLaunchMode(mode)) { + throw new TypeError(`不支持的 Runtime 启动方式: ${String(mode)}`) + } + + try { + const config = loadConfig() + config.Runtime = { ...config.Runtime, LaunchMode: mode } + saveConfig(config) + logger.info(`Runtime 启动方式已设置为: ${mode}`) + + const resolution = resolveRuntimeLaunchModeDetail(getAppRoot()) + return { persisted: mode, mode: resolution.mode, source: resolution.source } + } catch (error) { + logger.error('保存 Runtime 启动方式失败', error) + throw error + } +}) + // 管理员权限相关 ipcMain.handle('check-admin', () => { return isRunningAsAdmin() diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index 6fd486b65..b1be1b31a 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -90,6 +90,10 @@ contextBridge.exposeInMainWorld('electronAPI', { setInitializedVersion: (version: string) => ipcRenderer.invoke('set-initialized-version', version), + // Runtime 灰度开关:持久化设置 + 当前生效值与来源 + getRuntimeLaunchMode: () => ipcRenderer.invoke('get-runtime-launch-mode'), + setRuntimeLaunchMode: (mode: string) => ipcRenderer.invoke('set-runtime-launch-mode', mode), + // 托盘设置实时更新 updateTraySettings: (uiSettings: unknown) => ipcRenderer.invoke('update-tray-settings', uiSettings), @@ -224,15 +228,20 @@ contextBridge.exposeInMainWorld('electronAPI', { // ==================== 初始化 API ==================== // 单步初始化API + // rebuild 对应界面「重建环境」按钮,只在 Runtime 链路下有意义(走 repair / dependencies rebuild) initMirrors: () => ipcRenderer.invoke('init-mirrors'), - installPython: (selectedMirror?: string) => ipcRenderer.invoke('install-python', selectedMirror), - installPip: (selectedMirror?: string) => ipcRenderer.invoke('install-pip', selectedMirror), - installGit: (selectedMirror?: string) => ipcRenderer.invoke('install-git', selectedMirror), - pullRepository: (targetBranch?: string, selectedMirror?: string) => - ipcRenderer.invoke('pull-repository', targetBranch, selectedMirror), - installDependencies: (selectedMirror?: string) => - ipcRenderer.invoke('install-dependencies', selectedMirror), + installPython: (selectedMirror?: string, rebuild?: boolean) => + ipcRenderer.invoke('install-python', selectedMirror, rebuild), + installPip: (selectedMirror?: string, rebuild?: boolean) => + ipcRenderer.invoke('install-pip', selectedMirror, rebuild), + installGit: (selectedMirror?: string, rebuild?: boolean) => + ipcRenderer.invoke('install-git', selectedMirror, rebuild), + pullRepository: (targetBranch?: string, selectedMirror?: string, rebuild?: boolean) => + ipcRenderer.invoke('pull-repository', targetBranch, selectedMirror, rebuild), + installDependencies: (selectedMirror?: string, rebuild?: boolean) => + ipcRenderer.invoke('install-dependencies', selectedMirror, rebuild), getMirrors: (type: string) => ipcRenderer.invoke('get-mirrors', type), + getRuntimeInitContext: () => ipcRenderer.invoke('get-runtime-init-context'), // API 端点获取 getApiEndpoint: (key: string) => ipcRenderer.invoke('get-api-endpoint', key), @@ -251,6 +260,18 @@ contextBridge.exposeInMainWorld('electronAPI', { backendRestart: () => ipcRenderer.invoke('backend-restart'), backendStatus: () => ipcRenderer.invoke('backend-status'), + // Runtime 链路的后端更新(启动模式复用上面的 getRuntimeLaunchMode) + updateBackendViaRuntime: (targetVersion: string) => + ipcRenderer.invoke('update-backend-via-runtime', targetVersion), + retryBackendUpdate: (action: string) => ipcRenderer.invoke('retry-backend-update', action), + cancelBackendUpdate: () => ipcRenderer.invoke('cancel-backend-update'), + onBackendUpdateProgress: (callback: (progress: unknown) => void) => { + ipcRenderer.on('backend-update-progress', (_, progress) => callback(progress)) + }, + removeBackendUpdateProgressListener: () => { + ipcRenderer.removeAllListeners('backend-update-progress') + }, + // 清理资源 cleanup: () => ipcRenderer.invoke('cleanup'), diff --git a/frontend/electron/services/backendService.test.ts b/frontend/electron/services/backendService.test.ts new file mode 100644 index 000000000..d6c6cf43c --- /dev/null +++ b/frontend/electron/services/backendService.test.ts @@ -0,0 +1,446 @@ +import { spawn } from 'child_process' +import { EventEmitter } from 'node:events' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { BackendService } from './backendService' +import { RUNTIME_EXE_ENV, RUNTIME_MODE_ENV, RuntimeClient } from './runtime' + +vi.mock('child_process', () => ({ spawn: vi.fn() })) +// resolveRuntimeLaunchMode 的构建默认值这一级要读 app.isPackaged;本文件全部用例都显式 +// 设置 RUNTIME_MODE_ENV 走环境变量这一级,isPackaged 固定 false 即可,不需要逐用例切换。 +vi.mock('electron', () => ({ app: { isPackaged: false } })) +vi.mock('../utils/processManager', () => ({ + killAllRelatedProcesses: vi.fn(async () => undefined), +})) +vi.mock('./logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) +// Sentry 埋点与本模块逻辑无关,直通即可。 +vi.mock('./sentry', () => ({ + observeMainOperation: async ( + _name: string, + _op: string, + _attributes: unknown, + operation: () => Promise + ) => operation(), + recordMainCount: vi.fn(), + recordMainDuration: vi.fn(), +})) +vi.mock('./environmentService', () => ({ isDevelopmentEnvironment: () => true })) +vi.mock('./instanceConfig', () => ({ resolveHttpPort: vi.fn(() => 36164) })) + +const { killAllRelatedProcesses } = await import('../utils/processManager') +const { resolveHttpPort } = await import('./instanceConfig') + +const spawnMock = vi.mocked(spawn) +const killAllMock = vi.mocked(killAllRelatedProcesses) +const resolveHttpPortMock = vi.mocked(resolveHttpPort) + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'runtime', '__fixtures__') + +/** 夹具由本机构建的 auto-mas-runtime.exe 真实跑出来,不是手写的。 */ +function fixtureLines(name: string): string[] { + return readFileSync(join(fixturesDir, name), 'utf8') + .split('\n') + .filter(line => line.trim() !== '') + .map(line => `${line}\n`) +} + +// ==================== 假子进程 ==================== + +class FakeReadable extends EventEmitter { + setEncoding(): this { + return this + } + + feed(text: string): void { + this.emit('data', text) + } +} + +class FakeWritable extends EventEmitter { + readonly chunks: string[] = [] + destroyed = false + writableEnded = false + + write(chunk: string): boolean { + this.chunks.push(chunk) + return true + } +} + +class FakeChild extends EventEmitter { + readonly stdout = new FakeReadable() + readonly stderr = new FakeReadable() + readonly stdin = new FakeWritable() + readonly pid = 4242 + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + killed = false + + kill(signal?: NodeJS.Signals): boolean { + if (this.killed || this.exitCode !== null) return true + this.killed = true + this.close(null, signal ?? 'SIGTERM') + return true + } + + close(code: number | null, signal: NodeJS.Signals | null = null): void { + this.exitCode = code + this.signalCode = signal + this.emit('close', code, signal) + this.emit('exit', code, signal) + } +} + +function mockSpawn(): FakeChild { + const child = new FakeChild() + spawnMock.mockReturnValue(child as never) + return child +} + +// ==================== 夹具与桩 ==================== + +const APP_ROOT = 'D:\\AUTO-MAS' +// Runtime 与 python 可执行文件的存在性都用 fs 判断,借用一定存在的 node 自身路径。 +const EXISTING_EXE = process.execPath +const LOCAL_ENDPOINT = 'http://127.0.0.1:36164' + +const mirrorServiceStub = { + getApiEndpoint: (key: string) => (key === 'local' ? LOCAL_ENDPOINT : 'ws://127.0.0.1:36164'), + getApiEndpoints: () => ({ local: LOCAL_ENDPOINT, websocket: 'ws://127.0.0.1:36164' }), +} + +function createService(): BackendService { + return new BackendService(APP_ROOT, mirrorServiceStub as never) +} + +const operationId = '01M1F6M33JFZZ7Y85BE5S849ZN' +const base = { protocol: 1, operationId, timestamp: '2026-09-01T21:20:03.442+02:00' } + +function line(event: Record): string { + return `${JSON.stringify(event)}\n` +} + +const helloLine = line({ + ...base, + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'backend supervise', + capabilities: ['stdin.cancel', 'state.v1', 'log.stream'], +}) + +const runningStateLine = line({ + ...base, + type: 'state', + sequence: 3, + stage: 'backend.run', + status: 'running', + message: '后端运行中', + details: { baseUrl: 'http://127.0.0.1:36163', pid: 9001 }, +}) + +const stoppedResultLine = line({ + ...base, + type: 'result', + sequence: 9, + success: true, + code: 'OK', + stage: 'backend.shutdown', + status: 'stopped', + message: '后端已停止', + retryable: false, + remediation: [], + details: {}, +}) + +function logLine(stream: 'stdout' | 'stderr', message: string, sequence: number): string { + return line({ ...base, type: 'log', sequence, source: 'backend', stream, message }) +} + +/** 等 Runtime 或 python 被 spawn 出来,再往假子进程里喂数据。 */ +async function waitForSpawn(): Promise { + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled()) + return spawnMock.mock.results[0].value as FakeChild +} + +function spawnedArgs(): string[] { + return spawnMock.mock.calls[0][1] as string[] +} + +function spawnedEnv(): NodeJS.ProcessEnv { + return (spawnMock.mock.calls[0][2] as { env: NodeJS.ProcessEnv }).env +} + +const fetchMock = vi.fn() + +beforeEach(() => { + spawnMock.mockReset() + killAllMock.mockClear() + resolveHttpPortMock.mockClear() + fetchMock.mockReset() + // 旧链路启动前会探测是否已有后端:默认不可达。 + fetchMock.mockImplementation(async (url: unknown) => { + if (String(url).includes('/api/core/health')) { + return { ok: true, json: async () => ({ ready: true }) } + } + throw new Error('connect ECONNREFUSED') + }) + vi.stubGlobal('fetch', fetchMock) + delete process.env[RUNTIME_MODE_ENV] + delete process.env[RUNTIME_EXE_ENV] +}) + +afterEach(() => { + vi.unstubAllGlobals() + delete process.env[RUNTIME_MODE_ENV] + delete process.env[RUNTIME_EXE_ENV] +}) + +// ==================== 旧链路 ==================== + +describe('灰度开关关闭时', () => { + it('startBackend 仍自行 spawn python,并且不构造 Runtime 客户端', async () => { + const service = createService() + const superviseSpy = vi.spyOn(RuntimeClient.prototype, 'supervise') + mockSpawn() + + const result = await service.startBackend({ + pythonPath: EXISTING_EXE, + mainPyPath: EXISTING_EXE, + timeout: 5000, + }) + + expect(result).toEqual({ success: true }) + expect(superviseSpy).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledOnce() + expect(spawnMock.mock.calls[0][0]).toBe(EXISTING_EXE) + // Runtime 链路固定带 --output ndjson,旧链路只传 main.py。 + expect(spawnedArgs()).toEqual([EXISTING_EXE]) + // 旧链路仍按 createBackendEnvironment 注入端口与开发标记。 + expect(resolveHttpPortMock).toHaveBeenCalled() + expect(spawnedEnv().AUTO_MAS_DEV).toBe('1') + expect(spawnedEnv().AUTO_MAS_HTTP_PORT).toBe('36164') + expect(service.isRuntimeSupervised()).toBe(false) + expect(service.getRuntimeApiEndpoints()).toBeNull() + }) +}) + +// ==================== Runtime 监督链路 ==================== + +describe('development 模式', () => { + beforeEach(() => { + process.env[RUNTIME_MODE_ENV] = 'development' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + }) + + it('就绪事件到达后 resolve,端点取自 Runtime 下发的 baseUrl', async () => { + const service = createService() + mockSpawn() + + const pending = service.startBackend() + const child = await waitForSpawn() + child.stdout.feed(helloLine + runningStateLine) + const result = await pending + + expect(result).toEqual({ success: true }) + expect(spawnMock.mock.calls[0][0]).toBe(EXISTING_EXE) + expect(spawnedArgs()).toEqual([ + '--app-root', + APP_ROOT, + '--output', + 'ndjson', + '--protocol', + '1', + 'backend', + 'supervise', + '--mode', + 'development', + '--repo', + APP_ROOT, + ]) + + // WS 根地址由 baseUrl 派生,不按 resolveHttpPort 另算。 + expect(service.getRuntimeApiEndpoints()).toEqual({ + local: 'http://127.0.0.1:36163', + websocket: 'ws://127.0.0.1:36163', + }) + expect(service.getStatus()).toMatchObject({ isRunning: true, pid: 4242 }) + expect(service.isRuntimeSupervised()).toBe(true) + + // Runtime 原样继承宿主环境,Electron 不再注入这三个变量。 + expect(resolveHttpPortMock).not.toHaveBeenCalled() + expect(spawnedEnv().AUTO_MAS_HTTP_PORT).toBeUndefined() + expect(spawnedEnv().AUTO_MAS_DEV).toBeUndefined() + expect(spawnedEnv().AUTO_MAS_ENV).toBeUndefined() + + child.stdout.feed(stoppedResultLine) + child.close(0) + }) + + it('就绪前的失败 result 透传错误码,并把两路日志组成整块文本', async () => { + const service = createService() + mockSpawn() + + const pending = service.startBackend() + const child = await waitForSpawn() + + // 真实夹具:--repo 指向不存在的目录,Runtime 在 backend.spawn 阶段直接失败。 + const [fixtureHello, ...fixtureRest] = fixtureLines('supervise-dev-repo-missing.ndjson') + child.stdout.feed(fixtureHello) + child.stdout.feed(logLine('stdout', 'AUTO-MAS backend starting', 2)) + child.stdout.feed(logLine('stderr', 'Traceback (most recent call last):', 3)) + child.stdout.feed(fixtureRest.join('')) + child.close(2) + + const result = await pending + + expect(result.success).toBe(false) + expect(result.code).toBe('INVALID_ARGUMENT') + expect(result.retryable).toBe(false) + expect(result.remediation).toEqual(['run-doctor']) + expect(result.error).toBe('开发源码目录无效') + expect(result.logs).toBe( + '[stdout]\nAUTO-MAS backend starting\n\n[stderr]\nTraceback (most recent call last):' + ) + expect(service.getRuntimeApiEndpoints()).toBeNull() + expect(killAllMock).not.toHaveBeenCalled() + }) + + it('stopBackend 只发一次 shutdown,不做任何进程清理', async () => { + const service = createService() + mockSpawn() + + const pendingStart = service.startBackend() + const child = await waitForSpawn() + child.stdout.feed(helloLine + runningStateLine) + await pendingStart + + const pendingStop = service.stopBackend() + await vi.waitFor(() => expect(child.stdin.chunks).toHaveLength(1)) + + const payload = JSON.parse(child.stdin.chunks[0].trimEnd()) + expect(payload).toMatchObject({ protocol: 1, command: 'shutdown' }) + + child.stdout.feed(stoppedResultLine) + child.close(0) + + expect(await pendingStop).toEqual({ success: true }) + expect(child.stdin.chunks).toHaveLength(1) + // 进程树归 Runtime 的 Job Object 管,这里不许再有 scoped taskkill, + // 也不再自己发 POST /api/core/close。 + expect(killAllMock).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + expect(child.killed).toBe(false) + expect(service.getRuntimeApiEndpoints()).toBeNull() + expect(service.getStatus().isRunning).toBe(false) + }) + + it('Runtime 未给终态就退出时归为 RUNTIME_EXITED_UNEXPECTEDLY,诊断输出并入 stderr 块', async () => { + const service = createService() + mockSpawn() + + const pending = service.startBackend() + const child = await waitForSpawn() + child.stdout.feed(helloLine) + child.stdout.feed(logLine('stdout', 'AUTO-MAS backend starting', 2)) + child.stderr.feed('auto-mas-runtime: 后端进程树清理失败\n') + child.close(60) + + const result = await pending + + expect(result.success).toBe(false) + expect(result.code).toBe('RUNTIME_EXITED_UNEXPECTEDLY') + expect(result.retryable).toBe(true) + expect(result.logs).toBe( + '[stdout]\nAUTO-MAS backend starting\n\n[stderr]\nauto-mas-runtime: 后端进程树清理失败' + ) + expect(killAllMock).not.toHaveBeenCalled() + }) + + it('迟迟不就绪时请求关闭 Runtime,并以它给出的终态报告失败', async () => { + const service = createService() + mockSpawn() + + const pending = service.startBackend({ timeout: 20 }) + const child = await waitForSpawn() + child.stdout.feed(helloLine) + + // 超时后本模块只发 shutdown,不 kill;这里模拟 Runtime 响应关闭并给出终态。 + await vi.waitFor(() => expect(child.stdin.chunks).toHaveLength(1)) + expect(JSON.parse(child.stdin.chunks[0].trimEnd())).toMatchObject({ command: 'shutdown' }) + child.stdout.feed( + line({ + ...base, + type: 'result', + sequence: 4, + success: false, + code: 'BACKEND_HEALTH_TIMEOUT', + stage: 'backend.health', + status: 'backend_failed', + message: '后端健康检查超时', + retryable: true, + remediation: ['restart-backend', 'open-log'], + details: {}, + }) + ) + child.close(60) + + const result = await pending + + expect(result.success).toBe(false) + expect(result.code).toBe('BACKEND_HEALTH_TIMEOUT') + expect(result.remediation).toEqual(['restart-backend', 'open-log']) + expect(child.killed).toBe(false) + expect(killAllMock).not.toHaveBeenCalled() + }) + + it('找不到 Runtime 可执行文件时按 RUNTIME_NOT_FOUND 失败,不回退旧链路', async () => { + delete process.env[RUNTIME_EXE_ENV] + const service = createService() + + const result = await service.startBackend({ + pythonPath: EXISTING_EXE, + mainPyPath: EXISTING_EXE, + }) + + expect(result.success).toBe(false) + expect(result.code).toBe('RUNTIME_NOT_FOUND') + expect(result.retryable).toBe(false) + expect(result.remediation).toEqual(['update-desktop', 'contact-support']) + // 一次生命周期只走一条链路:既没有 spawn python,也没有 spawn Runtime。 + expect(spawnMock).not.toHaveBeenCalled() + expect(killAllMock).not.toHaveBeenCalled() + }) +}) + +describe('managed 模式', () => { + it('不传 --repo,其余流程与 development 一致', async () => { + process.env[RUNTIME_MODE_ENV] = 'managed' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + const service = createService() + mockSpawn() + + const pending = service.startBackend() + const child = await waitForSpawn() + child.stdout.feed(helloLine + runningStateLine) + + expect(await pending).toEqual({ success: true }) + expect(spawnedArgs().slice(-4)).toEqual(['backend', 'supervise', '--mode', 'managed']) + expect(spawnedArgs()).not.toContain('--repo') + + child.stdout.feed(stoppedResultLine) + child.close(0) + }) +}) diff --git a/frontend/electron/services/backendService.ts b/frontend/electron/services/backendService.ts index ccbc06e2a..86ce1c31f 100644 --- a/frontend/electron/services/backendService.ts +++ b/frontend/electron/services/backendService.ts @@ -12,12 +12,31 @@ import { killAllRelatedProcesses } from '../utils/processManager' import { MirrorService } from './mirrorService' import { isDevelopmentEnvironment } from './environmentService' import { resolveHttpPort } from './instanceConfig' +import { + RUNTIME_CLIENT_ERROR_DEFINITIONS, + RuntimeRemediation, + RuntimeRunResult, + RuntimeSuperviseHandle, + RuntimeSupervisedLaunchConfig, + createRuntimeClient, + formatStartupLogs, + isRuntimeClientError, + readRuntimeBaseUrl, + resolveRuntimeLaunchConfig, + resolveRuntimeLaunchMode, +} from './runtime' import { getLogger } from './logger' import { observeMainOperation, recordMainCount, recordMainDuration } from './sentry' const logger = getLogger('后端服务') const BACKEND_UNAVAILABLE_CONFIRMATIONS = 3 +// Runtime 链路等待 state:running 的兜底上限。正常情况下 Runtime 自己的健康超时会先给出 +// BACKEND_HEALTH_TIMEOUT,这个上限只防止 Runtime 既不就绪也不给终态时把启动流程挂死。 +const RUNTIME_READY_TIMEOUT_MS = 180000 +// 等待 Runtime 完成关闭的上限,超时由客户端 kill Runtime 进程本身,进程树由其 Job Object 收走。 +const RUNTIME_SHUTDOWN_TIMEOUT_MS = 30000 + // ==================== 类型定义 ==================== export interface BackendStatus { @@ -38,6 +57,23 @@ export interface BackendStartResult { success: boolean error?: string logs?: string + /** Runtime 链路的结构化结果码,供界面决定重试或修复;旧链路不产生。 */ + code?: string + retryable?: boolean + remediation?: RuntimeRemediation[] +} + +/** 渲染进程实际使用的后端地址。 */ +export interface BackendApiEndpoints { + local: string + websocket: string +} + +/** 由 Runtime 下发的 baseUrl 派生 WebSocket 根地址,不自行假定端口。 */ +function deriveWebsocketEndpoint(baseUrl: string): string { + const url = new URL(baseUrl) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.toString().replace(/\/$/, '') } export interface BackendStopResult { @@ -67,6 +103,9 @@ export class BackendService { private forceStopFlight: Promise | null = null private forceStopRequested = false private lastKnownBackendDevMode: boolean | null = null + // Runtime 监督链路的句柄与就绪地址;旧链路下始终为 null。 + private runtimeHandle: RuntimeSuperviseHandle | null = null + private runtimeBaseUrl: string | null = null private readonly startupHealthPath = '/api/core/health' @@ -130,6 +169,13 @@ export class BackendService { } private async startBackendProcess(options?: BackendStartOptions): Promise { + // 灰度开关打开后整条生命周期都走 Runtime 监督链路,绝不与旧链路混用:两条链路的端口、 + // 关闭语义与进程归属都不同,中途回退只会留下无人负责的后端进程。 + const launchConfig = resolveRuntimeLaunchConfig(this.appRoot) + if (launchConfig.mode !== 'off') { + return this.startBackendViaRuntime(launchConfig, options) + } + // 检查是否已经在运行 if (this.isTrackedProcessRunning()) { logger.info('后端服务已在运行,等待健康检查') @@ -228,6 +274,262 @@ export class BackendService { } } + // ==================== Runtime 监督链路 ==================== + + /** + * 经 `auto-mas-runtime.exe backend supervise` 启动后端。 + * + * 与旧链路的三点区别: + * 1. 不注入 `AUTO_MAS_DEV` / `AUTO_MAS_HTTP_PORT` / `AUTO_MAS_ENV`:受监督后端的端口与身份 + * 由 Runtime 注入的 `AUTO_MAS_SUPERVISED` 一组变量决定,这里再注入只会互相打架; + * 2. 就绪以 `state:running` 事件为准,而不是解析 stdout 里的 `Uvicorn running`; + * 3. 后端地址取事件里的 `details.baseUrl`,不按 `resolveHttpPort()` 自行拼装。 + */ + private async startBackendViaRuntime( + config: RuntimeSupervisedLaunchConfig, + options?: BackendStartOptions + ): Promise { + if (this.runtimeHandle) { + logger.info('Runtime 已在监督后端,跳过重复启动') + return { success: true } + } + + const runtimePath = config.runtimePath + if (!runtimePath) { + // 灰度期一次生命周期只走一条链路,找不到可执行文件时直接失败展示,不回退旧链路。 + const definition = RUNTIME_CLIENT_ERROR_DEFINITIONS.RUNTIME_NOT_FOUND + const message = `找不到 Runtime 可执行文件,无法以 ${config.mode} 模式启动后端` + logger.error(message) + return { + success: false, + error: message, + code: definition.code, + retryable: definition.retryable, + remediation: [...definition.remediation], + } + } + + logger.info( + `经 Runtime 启动后端 - 模式: ${config.mode}, Runtime: ${runtimePath}, ` + + `应用根目录: ${config.appRoot}${config.repo ? `, 源码目录: ${config.repo}` : ''}` + ) + + // 后端 stdout / stderr 由 Runtime 逐行包装成 log 事件转发,这里按流分开累积, + // 失败时组装成现有失败界面直接展示的整块文本。 + const stdoutLines: string[] = [] + const stderrLines: string[] = [] + let resolveReady: (baseUrl: string) => void = () => undefined + const ready = new Promise<{ baseUrl: string }>(resolve => { + resolveReady = baseUrl => resolve({ baseUrl }) + }) + + // 遥测开关(AUTO_MAS_TELEMETRY)由 createRuntimeClient 统一注入,见 runtimeEnv.ts。 + const client = createRuntimeClient({ runtimePath, appRoot: config.appRoot }) + let handle: RuntimeSuperviseHandle + try { + handle = await client.supervise({ + mode: config.mode, + repo: config.repo, + onLog: event => { + if (event.stream === 'stderr') { + stderrLines.push(event.message) + return + } + stdoutLines.push(event.message) + }, + onState: event => { + if (event.status !== 'running') return + const baseUrl = readRuntimeBaseUrl(event.details) + if (baseUrl) resolveReady(baseUrl) + }, + }) + } catch (error) { + // 握手阶段的失败(可执行文件缺失、spawn 被拒、协议不匹配、参数错误)都在这里收敛。 + return this.buildRuntimeStartFailure(error, stdoutLines, stderrLines) + } + + const timeoutMs = options?.timeout || RUNTIME_READY_TIMEOUT_MS + let timer: NodeJS.Timeout | undefined + const timedOut = new Promise<'timeout'>(resolve => { + timer = setTimeout(() => resolve('timeout'), timeoutMs) + timer.unref?.() + }) + const ended = handle.completion.then( + result => ({ result }), + (error: unknown) => ({ error }) + ) + + let outcome: 'timeout' | { baseUrl: string } | { result: RuntimeRunResult } | { error: unknown } + try { + outcome = await Promise.race([ready, ended, timedOut]) + } finally { + if (timer) clearTimeout(timer) + } + + if (outcome !== 'timeout' && 'baseUrl' in outcome) { + this.adoptRuntimeHandle(handle, outcome.baseUrl) + logger.info(`后端服务启动成功,Runtime PID: ${handle.pid},后端地址: ${outcome.baseUrl}`) + return { success: true } + } + + if (outcome === 'timeout') { + logger.error(`等待 Runtime 报告后端就绪超过 ${timeoutMs}ms,请求关闭 Runtime`) + try { + const settled = await handle.shutdown({ timeoutMs: RUNTIME_SHUTDOWN_TIMEOUT_MS }) + return this.buildRuntimeStartFailure(settled, stdoutLines, stderrLines) + } catch (error) { + return this.buildRuntimeStartFailure(error, stdoutLines, stderrLines) + } + } + + // 就绪前拿到终态或调用侧异常:后端没起来,按失败展示。 + const reason = 'result' in outcome ? outcome.result : outcome.error + return this.buildRuntimeStartFailure(reason, stdoutLines, stderrLines) + } + + /** 记下监督句柄与后端地址,并在 Runtime 结束时清理状态。 */ + private adoptRuntimeHandle(handle: RuntimeSuperviseHandle, baseUrl: string): void { + this.runtimeHandle = handle + this.runtimeBaseUrl = baseUrl + this.startTime = new Date() + this.notifyStatusChange() + + const onFinished = (): void => { + if (this.runtimeHandle !== handle) return + logger.info('Runtime 监督进程已结束,清理后端运行状态') + this.clearRuntimeState(handle) + } + void handle.completion.then(onFinished, onFinished) + } + + private clearRuntimeState(handle: RuntimeSuperviseHandle): void { + if (this.runtimeHandle !== handle) return + this.runtimeHandle = null + this.runtimeBaseUrl = null + this.startTime = null + this.notifyStatusChange() + } + + /** + * 把 Runtime 的失败终态或调用侧异常转成与旧链路同形的启动失败结果。 + * + * `logs` 保持 `[stdout]…\n\n[stderr]…` 的整块格式,现有失败界面不需要改动;`code` / + * `retryable` / `remediation` 供界面判断可用操作,界面不解析日志或中文文案。 + */ + private buildRuntimeStartFailure( + reason: RuntimeRunResult | unknown, + stdoutLines: string[], + stderrLines: string[] + ): BackendStartResult { + if (isRuntimeClientError(reason)) { + const logs = this.formatRuntimeStartupLogs(stdoutLines, stderrLines, reason.details.stderr) + logger.error(`Runtime 调用失败: ${reason.code} ${reason.message}`) + return { + success: false, + error: reason.message, + logs, + code: reason.code, + retryable: reason.retryable, + remediation: [...reason.remediation], + } + } + + if (this.isRuntimeRunResult(reason)) { + const logs = this.formatRuntimeStartupLogs(stdoutLines, stderrLines, reason.stderr) + const message = reason.result.message || `后端在就绪前结束(${reason.code})` + logger.error(`后端服务启动失败: ${reason.code} ${message}`) + return { + success: false, + error: message, + logs, + code: reason.code, + retryable: reason.result.retryable, + remediation: [...reason.result.remediation], + } + } + + const message = reason instanceof Error ? reason.message : String(reason) + logger.error(`后端服务启动失败: ${message}`) + return { + success: false, + error: message, + logs: this.formatRuntimeStartupLogs(stdoutLines, stderrLines), + } + } + + private isRuntimeRunResult(value: unknown): value is RuntimeRunResult { + return typeof value === 'object' && value !== null && 'result' in value && 'code' in value + } + + /** Runtime 自身的 stderr 诊断并入 `[stderr]` 块,避免后端没起来时失败界面一片空白。 */ + private formatRuntimeStartupLogs( + stdoutLines: string[], + stderrLines: string[], + runtimeStderr?: string + ): string | undefined { + const diagnostics = runtimeStderr?.trimEnd() + const merged = diagnostics ? [...stderrLines, ...diagnostics.split(/\r?\n/)] : stderrLines + return formatStartupLogs(stdoutLines, merged) + } + + /** + * 经 Runtime 停止后端:只向 stdin 发 shutdown,不再 taskkill python。 + * + * 关闭超时由 `handle.shutdown` 兜底 kill Runtime 进程本身,后端进程树由 Runtime 的 + * Job Object 收走,这里不触碰 processManager 的全局清理。 + */ + private async stopBackendViaRuntime(): Promise { + const handle = this.runtimeHandle + if (!handle) { + logger.info('Runtime 链路未持有监督句柄,无需停止后端') + return { success: true } + } + + logger.info(`向 Runtime 发送 shutdown,Runtime PID: ${handle.pid}`) + try { + const outcome = await handle.shutdown({ timeoutMs: RUNTIME_SHUTDOWN_TIMEOUT_MS }) + if (!outcome.success) { + logger.warn(`Runtime 关闭后端报告失败: ${outcome.code} ${outcome.result.message}`) + } else { + logger.info('Runtime 已确认后端关闭') + } + // completion 兑现即意味着 Runtime 已给出终态且进程已退出,进程树随之清理完毕。 + this.clearRuntimeState(handle) + return { success: true } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`Runtime 关闭后端失败: ${errorMsg}`) + this.clearRuntimeState(handle) + return { success: false, error: errorMsg } + } + } + + /** + * 本次生命周期是否走 Runtime 监督链路。 + * + * 已持有句柄时必然是;尚未启动成功时以灰度开关为准,避免在新链路下误用旧链路的 + * scoped taskkill 清理。 + */ + isRuntimeSupervised(): boolean { + return this.runtimeHandle !== null || resolveRuntimeLaunchMode(this.appRoot) !== 'off' + } + + /** Runtime 就绪后下发的后端地址;旧链路或尚未就绪时返回 null,由调用方回退原有端点。 */ + getRuntimeApiEndpoints(): BackendApiEndpoints | null { + if (!this.runtimeBaseUrl) return null + return { + local: this.runtimeBaseUrl, + websocket: deriveWebsocketEndpoint(this.runtimeBaseUrl), + } + } + + /** 后端 HTTP 根地址:Runtime 就绪后以它下发的为准,否则用镜像源服务的端点。 */ + private resolveLocalApiEndpoint(): string { + return this.runtimeBaseUrl ?? this.mirrorService.getApiEndpoint('local') + } + + // ==================== 旧链路 ==================== + private async prepareUntrackedBackendForStart(): Promise { const apiEndpoint = this.mirrorService.getApiEndpoint('local') const metaUrl = `${apiEndpoint}/api/core/ws_meta` @@ -280,7 +582,7 @@ export class BackendService { * 读取后端权威开发模式;暂时不可达时回退最近一次成功结果。 */ async getBackendDevMode(): Promise { - const apiEndpoint = this.mirrorService.getApiEndpoint('local') + const apiEndpoint = this.resolveLocalApiEndpoint() const metaUrl = `${apiEndpoint}/api/core/ws_meta` try { @@ -355,6 +657,10 @@ export class BackendService { } private async stopBackendInternal(): Promise { + if (this.isRuntimeSupervised()) { + return this.stopBackendViaRuntime() + } + const pid = this.backendProcess?.pid const hasTrackedProcess = this.isTrackedProcessRunning() let metaUrl: string | null = null @@ -590,6 +896,15 @@ export class BackendService { * 获取后端状态 */ getStatus(): BackendStatus { + // Runtime 链路下追踪的是监督进程,后端进程树归 Runtime 管,这里不持有它的 PID。 + if (this.runtimeHandle) { + return { + isRunning: true, + pid: this.runtimeHandle.pid, + startTime: this.startTime || undefined, + } + } + const isRunning = this.isTrackedProcessRunning() return { diff --git a/frontend/electron/services/index.ts b/frontend/electron/services/index.ts index 09fda4d56..f8ac59c4d 100644 --- a/frontend/electron/services/index.ts +++ b/frontend/electron/services/index.ts @@ -69,6 +69,42 @@ export { InitializationResult, } from './initializationService' +// Runtime 初始化链路(灰度开关打开后顶掉五步安装链) +export { + BootstrapProgressBridge, + BootstrapProgressUpdate, + CriticalFilesCheck, + InitializationRunStage, + InitializationStage, + InitializationStageStatus, + RuntimeDoctorCheck, + RuntimeInitializationService, + RuntimeRetryMode, + RuntimeStageOutcome, + mapDoctorChecksToCriticalFiles, + mapMirrorSelection, + mapRuntimeStage, + toRuntimeVersion, +} from './runtimeInitializationService' + +// Runtime 后端更新链路(停机 → bootstrap → 重新监督) +export { + BackendUpdateController, + RuntimeUpdateDependencies, + RuntimeUpdateOutcome, + RuntimeUpdatePhase, + RuntimeUpdateProgress, + RuntimeUpdateRetryAction, + RuntimeUpdateStage, + cancelBackendUpdate, + describeRetryAction, + normalizeRuntimeUpdateVersion, + resetRuntimeUpdateSession, + resolveRetryActions, + retryBackendUpdate, + updateBackendViaRuntime, +} from './runtimeUpdateService' + // 后端服务 export { BackendService, @@ -76,3 +112,23 @@ export { BackendStartOptions, BackendStatusCallback, } from './backendService' + +// AUTO-MAS Runtime 客户端(NDJSON 协议 v1) +export { + RuntimeClient, + RuntimeClientError, + RuntimeClientErrorCode, + RuntimeClientOptions, + RuntimeEvent, + RuntimeHelloEvent, + RuntimeLogsByOperation, + RuntimeProgressEvent, + RuntimeResultEvent, + RuntimeRunOptions, + RuntimeRunResult, + RuntimeStateEvent, + RuntimeSuperviseHandle, + RuntimeSuperviseOptions, + formatStartupLogs, + isRuntimeClientError, +} from './runtime' diff --git a/frontend/electron/services/initializationService.test.ts b/frontend/electron/services/initializationService.test.ts new file mode 100644 index 000000000..b386ccf9c --- /dev/null +++ b/frontend/electron/services/initializationService.test.ts @@ -0,0 +1,337 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { InitializationService, InitializationProgress } from './initializationService' +import { RUNTIME_EXE_ENV, RUNTIME_MODE_ENV } from './runtime' +import type { RuntimeEvent, RuntimeRunOptions } from './runtime' + +// ==================== 旧链路各服务的桩 ==================== + +const installCalls: string[] = [] + +function installerStub(name: string) { + return class { + async install(onProgress?: (progress: unknown) => void) { + installCalls.push(name) + onProgress?.({ progress: 100, message: `${name} 完成` }) + return { success: true } + } + } +} + +vi.mock('./mirrorService', () => ({ + MirrorService: class { + async initialize() { + installCalls.push('mirror') + } + }, +})) +vi.mock('./environmentService', () => ({ + PythonInstaller: installerStub('python'), + PipInstaller: installerStub('pip'), + GitInstaller: installerStub('git'), +})) +vi.mock('./repositoryService', () => ({ + RepositoryService: class { + async pullRepository(onProgress?: (progress: unknown) => void) { + installCalls.push('repository') + onProgress?.({ progress: 100, message: '源码拉取完成' }) + return { success: true } + } + }, +})) +vi.mock('./dependencyService', () => ({ + DependencyService: class { + async installDependencies(onProgress?: (progress: unknown) => void) { + installCalls.push('dependency') + onProgress?.({ progress: 100, message: '依赖安装完成' }) + return { success: true } + } + }, +})) +vi.mock('./backendService', () => ({ + BackendService: class { + async startBackend() { + installCalls.push('backend') + return { success: true } + } + getStatus() { + return { isRunning: true, pid: 4242 } + } + }, +})) +vi.mock('./logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) +vi.mock('electron', () => ({ app: { getVersion: () => '5.5.0-beta.3' } })) + +// ==================== 假 RuntimeClient ==================== + +const base = { + protocol: 1, + operationId: '01M1F6M33JFZZ7Y85BE5S849ZN', + timestamp: '2026-09-01T22:03:00.000+02:00', +} + +const bootstrapEvents = [ + { + ...base, + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'bootstrap', + capabilities: [], + }, + { + ...base, + type: 'state', + sequence: 2, + stage: 'uv.check', + status: 'preparing_uv', + message: '正在准备固定版本 uv', + details: {}, + }, + { + ...base, + type: 'state', + sequence: 3, + stage: 'workspace.check', + status: 'syncing_repository', + message: '正在同步后端仓库', + details: {}, + }, + { + ...base, + type: 'state', + sequence: 4, + stage: 'dependencies.sync', + status: 'syncing_environment', + message: '正在同步锁定依赖', + details: {}, + }, + { + ...base, + type: 'result', + sequence: 5, + success: true, + code: 'OK', + stage: 'bootstrap', + status: 'ready_to_start', + message: '运行环境准备完成', + retryable: false, + remediation: [], + details: {}, + }, +] as unknown as RuntimeEvent[] + +const { runtimeClientCalls } = vi.hoisted(() => ({ runtimeClientCalls: [] as string[][] })) + +vi.mock('./runtime', async importActual => { + const actual = await importActual() + + // 假客户端必须定义在工厂里:vi.mock 会被提升到文件顶部,引用外层变量会踩到 TDZ。 + class FakeRuntimeClient { + constructor(readonly options: { runtimePath: string; appRoot: string }) {} + + async run(command: string[], options: RuntimeRunOptions = {}) { + runtimeClientCalls.push(command) + for (const event of bootstrapEvents) { + if (event.type === 'state') options.onState?.(event) + } + const result = bootstrapEvents[bootstrapEvents.length - 1] + return { + hello: bootstrapEvents[0], + result, + success: true, + code: 'OK', + events: bootstrapEvents, + warnings: [], + errors: [], + logs: {}, + protocolErrors: [], + exitCode: 0, + signal: null, + stderr: '', + argv: command, + durationMs: 1, + } + } + } + + return { ...actual, RuntimeClient: FakeRuntimeClient } +}) + +// runtimeInitializationService.ts 的默认客户端工厂现在经 createRuntimeClient(见 +// runtime/runtimeClientFactory.ts)统一注入遥测环境变量,它从 './client'(相对 +// runtime/ 目录,即 './runtime/client')直接拿 RuntimeClient,不经过上面这层 +// './runtime' 桶文件的重导出。只替身 './runtime' 拦不到这次真实构造,这里必须把 +// 同一个假类也接到 './runtime/client' 上,否则会去 spawn 一个真的子进程。 +vi.mock('./runtime/client', async importActual => { + const actual = await importActual() + + class FakeRuntimeClient { + constructor(readonly options: { runtimePath: string; appRoot: string }) {} + + async run(command: string[], options: RuntimeRunOptions = {}) { + runtimeClientCalls.push(command) + for (const event of bootstrapEvents) { + if (event.type === 'state') options.onState?.(event) + } + const result = bootstrapEvents[bootstrapEvents.length - 1] + return { + hello: bootstrapEvents[0], + result, + success: true, + code: 'OK', + events: bootstrapEvents, + warnings: [], + errors: [], + logs: {}, + protocolErrors: [], + exitCode: 0, + signal: null, + stderr: '', + argv: command, + durationMs: 1, + } + } + } + + return { ...actual, RuntimeClient: FakeRuntimeClient } +}) + +// ==================== 用例 ==================== + +const APP_ROOT = 'D:\\AUTO-MAS' +// 灰度开关要求 Runtime 可执行文件真实存在,借用一定存在的 node 自身路径。 +const EXISTING_EXE = process.execPath + +function collect(): { + progress: InitializationProgress[] + onProgress: (p: InitializationProgress) => void +} { + const progress: InitializationProgress[] = [] + return { progress, onProgress: p => progress.push(p) } +} + +beforeEach(() => { + installCalls.length = 0 + runtimeClientCalls.length = 0 + delete process.env[RUNTIME_MODE_ENV] + delete process.env[RUNTIME_EXE_ENV] +}) + +afterEach(() => { + delete process.env[RUNTIME_MODE_ENV] + delete process.env[RUNTIME_EXE_ENV] +}) + +describe('灰度开关关闭时', () => { + it('initialize 仍逐段调用旧链路,且不构造 Runtime 客户端', async () => { + const { progress, onProgress } = collect() + + const result = await new InitializationService(APP_ROOT).initialize(onProgress) + + expect(result.success).toBe(true) + expect(installCalls).toEqual([ + 'mirror', + 'python', + 'pip', + 'git', + 'repository', + 'dependency', + 'backend', + ]) + expect(runtimeClientCalls).toHaveLength(0) + // 旧链路不产生段状态与结构化结果码 + expect(progress.every(p => p.status === undefined)).toBe(true) + expect(result.code).toBeUndefined() + }) +}) + +describe('development 模式', () => { + beforeEach(() => { + process.env[RUNTIME_MODE_ENV] = 'development' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + }) + + it('六个准备段各收到一个完成进度,随后进入 backend 段', async () => { + const { progress, onProgress } = collect() + + const result = await new InitializationService(APP_ROOT).initialize(onProgress) + + expect(result.success).toBe(true) + expect(result.completedStages).toContain('backend') + // 一个安装器都没跑,只起了后端 + expect(installCalls).toEqual(['backend']) + expect(runtimeClientCalls).toHaveLength(0) + + const skipped = progress.filter(p => p.message === '由 Runtime development 模式接管,跳过') + expect(skipped.map(p => p.stage)).toEqual([ + 'mirror', + 'python', + 'pip', + 'git', + 'repository', + 'dependency', + ]) + expect(skipped.every(p => p.status === 'completed' && p.progress === 100)).toBe(true) + + expect(progress.filter(p => p.stage === 'backend').map(p => p.status)).toEqual([ + 'started', + 'completed', + ]) + expect(progress[progress.length - 1].stage).toBe('complete') + }) +}) + +describe('managed 模式', () => { + beforeEach(() => { + process.env[RUNTIME_MODE_ENV] = 'managed' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + }) + + it('一次 bootstrap 顶掉五步安装链,段序为三段 started→completed 后进 backend', async () => { + const { progress, onProgress } = collect() + + const result = await new InitializationService(APP_ROOT).initialize(onProgress) + + expect(result.success).toBe(true) + expect(runtimeClientCalls).toEqual([['bootstrap', '--version', 'v5.5.0-beta.3']]) + expect(installCalls).toEqual(['backend']) + + for (const stage of ['mirror', 'pip', 'git'] as const) { + const takeover = progress.filter(p => p.stage === stage) + expect(takeover).toHaveLength(1) + expect(takeover[0]).toMatchObject({ status: 'completed', message: '由 Runtime 接管' }) + } + + for (const stage of ['python', 'repository', 'dependency'] as const) { + const statuses = progress.filter(p => p.stage === stage).map(p => p.status) + expect(statuses[0]).toBe('started') + expect(statuses[statuses.length - 1]).toBe('completed') + } + + expect(progress.filter(p => p.stage === 'backend').map(p => p.status)).toEqual([ + 'started', + 'completed', + ]) + expect(progress[progress.length - 1].stage).toBe('complete') + }) + + it('找不到 Runtime 可执行文件时按 RUNTIME_NOT_FOUND 失败,不回退旧链路', async () => { + delete process.env[RUNTIME_EXE_ENV] + + const result = await new InitializationService(APP_ROOT).initialize(() => undefined) + + expect(result.success).toBe(false) + expect(result.code).toBe('RUNTIME_NOT_FOUND') + expect(installCalls).toEqual([]) + }) +}) diff --git a/frontend/electron/services/initializationService.ts b/frontend/electron/services/initializationService.ts index ecbc777ca..42b0e9e67 100644 --- a/frontend/electron/services/initializationService.ts +++ b/frontend/electron/services/initializationService.ts @@ -8,6 +8,18 @@ import { PythonInstaller, PipInstaller, GitInstaller } from './environmentServic import { RepositoryService } from './repositoryService' import { DependencyService } from './dependencyService' import { BackendService } from './backendService' +import { RuntimeLaunchMode, RuntimeRemediation, resolveRuntimeLaunchConfig } from './runtime' +import { + BootstrapProgressUpdate, + INITIALIZATION_STAGE_INDEX, + InitializationRunStage, + InitializationStage, + InitializationStageStatus, + RuntimeInitializationService, + RuntimeRetryMode, + RuntimeStageOutcome, + emitDevelopmentSkipProgress, +} from './runtimeInitializationService' // 导入日志服务 import { getLogger } from './logger' @@ -16,11 +28,15 @@ const logger = getLogger('初始化服务') // ==================== 类型定义 ==================== export interface InitializationProgress { - stage: 'mirror' | 'python' | 'pip' | 'git' | 'repository' | 'dependency' | 'backend' | 'complete' + stage: InitializationStage stageIndex: number totalStages: number progress: number message: string + /** Runtime 链路给出的机器可读段状态;旧链路不产生,界面按缺省处理。 */ + status?: InitializationStageStatus + /** 本次进度来自哪条链路;旧链路不产生,界面按 `off` 处理。 */ + runtimeMode?: RuntimeLaunchMode details?: { checkInfo?: unknown // 可以是 EnvironmentCheckResult, RepositoryCheckResult, 或 DependencyCheckResult currentMirror?: string @@ -38,6 +54,12 @@ export interface InitializationResult { error?: string completedStages: string[] failedStage?: string + /** 以下五项只有 Runtime 链路产生,旧链路保持 undefined;界面(W9d)按需消费。 */ + code?: string + retryable?: boolean + remediation?: RuntimeRemediation[] + logs?: string + logPath?: string } // ==================== 初始化服务类 ==================== @@ -47,6 +69,8 @@ export class InitializationService { private mirrorService: MirrorService private backendService: BackendService private targetBranch: string + /** Runtime 链路的编排器;灰度开关关闭时始终为 null。 */ + private runtimeService: RuntimeInitializationService | null = null constructor(appRoot: string, targetBranch: string = 'dev') { this.appRoot = appRoot @@ -55,6 +79,28 @@ export class InitializationService { this.targetBranch = targetBranch } + /** + * 取本次生命周期的 Runtime 编排器;灰度开关关闭时返回 null。 + * + * 单步重试与 doctor 走的是另外的 IPC 入口,需要复用同一个实例才能记住上一次失败 + * 给出的处置动作(决定重试用 `dependencies sync` 还是 `dependencies rebuild`)。 + */ + getRuntimeService(): RuntimeInitializationService | null { + const launchConfig = resolveRuntimeLaunchConfig(this.appRoot) + if (launchConfig.mode === 'off') { + this.runtimeService = null + return null + } + + if (!this.runtimeService || this.runtimeService.launchConfig.mode !== launchConfig.mode) { + this.runtimeService = new RuntimeInitializationService({ + launchConfig, + mirrorService: this.mirrorService, + }) + } + return this.runtimeService + } + /** * 执行完整的初始化流程 */ @@ -65,6 +111,16 @@ export class InitializationService { const completedStages: string[] = [] const totalStages = startBackend ? 7 : 6 + // 灰度开关打开后整条初始化都走 Runtime,绝不与旧链路混用:两条链路的目录布局、 + // Python 来源与依赖管理器都不同,中途混用只会装出一个谁都不认的环境。 + const launchConfig = resolveRuntimeLaunchConfig(this.appRoot) + if (launchConfig.mode === 'development') { + return this.initializeViaDevelopmentRuntime(onProgress, startBackend) + } + if (launchConfig.mode === 'managed') { + return this.initializeViaRuntime(onProgress, startBackend) + } + try { // 阶段 1: 初始化镜像源配置 onProgress?.({ @@ -308,6 +364,194 @@ export class InitializationService { } } + // ==================== Runtime 初始化链路 ==================== + + /** 把 Runtime 的段进度补齐成现有 7 段进度的形状。 */ + private forwardRuntimeProgress( + onProgress: InitializationProgressCallback | undefined, + totalStages: number + ): (update: BootstrapProgressUpdate) => void { + const runtimeMode = resolveRuntimeLaunchConfig(this.appRoot).mode + return update => + onProgress?.({ + stage: update.stage, + stageIndex: INITIALIZATION_STAGE_INDEX[update.stage], + totalStages, + progress: update.progress, + message: update.message, + status: update.status, + runtimeMode, + }) + } + + /** + * development 模式:跳过全部安装步骤,直接起后端。 + * + * 开发检出自带 `.venv`,Runtime 的 development 模式只监督这份源码,既不创建也不更新它。 + */ + private async initializeViaDevelopmentRuntime( + onProgress?: InitializationProgressCallback, + startBackend: boolean = true + ): Promise { + logger.info('Runtime development 模式:跳过全部安装步骤') + const totalStages = startBackend ? 7 : 6 + const completedStages = ['mirror', 'python', 'pip', 'git', 'repository', 'dependency'] + + emitDevelopmentSkipProgress(this.forwardRuntimeProgress(onProgress, totalStages)) + + if (!startBackend) { + this.emitComplete(onProgress, totalStages) + return { success: true, completedStages } + } + + return this.startBackendStage(onProgress, totalStages, completedStages) + } + + /** + * managed 模式:一次 `bootstrap --version <应用自身版本>` 顶掉原来的五步链。 + * + * 目标版本用应用自身版本(Runtime 据此拼 `release/<版本>` 分支名);更新流程的目标版本 + * 由更新任务另行给出,不走这里。 + */ + private async initializeViaRuntime( + onProgress?: InitializationProgressCallback, + startBackend: boolean = true + ): Promise { + const runtimeService = this.getRuntimeService() + if (!runtimeService) { + // getRuntimeService 只在 off 模式返回 null,这里进不来;留一个显式失败而不是断言。 + return { success: false, error: 'Runtime 链路未启用', completedStages: [] } + } + + logger.info('Runtime managed 模式:以 bootstrap 完成全部准备工作') + const totalStages = startBackend ? 7 : 6 + const completedStages: string[] = [] + + const outcome = await runtimeService.bootstrap( + this.forwardRuntimeProgress(onProgress, totalStages) + ) + + if (!outcome.success) { + return this.buildRuntimeFailure(outcome, completedStages) + } + + completedStages.push('mirror', 'python', 'pip', 'git', 'repository', 'dependency') + + if (!startBackend) { + this.emitComplete(onProgress, totalStages) + return { success: true, completedStages } + } + + return this.startBackendStage(onProgress, totalStages, completedStages) + } + + /** Runtime 链路的后端段:仍由 backendService 起 `backend supervise`。 */ + private async startBackendStage( + onProgress: InitializationProgressCallback | undefined, + totalStages: number, + completedStages: string[] + ): Promise { + onProgress?.({ + stage: 'backend', + stageIndex: INITIALIZATION_STAGE_INDEX.backend, + totalStages, + progress: 0, + message: '正在启动后端服务...', + status: 'started', + }) + + const backendResult = await this.backendService.startBackend() + if (!backendResult.success) { + onProgress?.({ + stage: 'backend', + stageIndex: INITIALIZATION_STAGE_INDEX.backend, + totalStages, + progress: 0, + message: backendResult.error ?? '后端启动失败', + status: 'failed', + }) + return { + success: false, + error: backendResult.error, + completedStages, + failedStage: 'backend', + code: backendResult.code, + retryable: backendResult.retryable, + remediation: backendResult.remediation, + logs: backendResult.logs, + } + } + + const status = this.backendService.getStatus() + onProgress?.({ + stage: 'backend', + stageIndex: INITIALIZATION_STAGE_INDEX.backend, + totalStages, + progress: 100, + message: `后端服务已启动,PID: ${status.pid}`, + status: 'completed', + }) + completedStages.push('backend') + + this.emitComplete(onProgress, totalStages) + return { success: true, completedStages } + } + + private emitComplete( + onProgress: InitializationProgressCallback | undefined, + totalStages: number + ): void { + onProgress?.({ + stage: 'complete', + stageIndex: totalStages, + totalStages, + progress: 100, + message: '初始化完成', + status: 'completed', + }) + } + + /** Runtime 失败转成现有失败形状,额外带上结构化字段供 W9d 使用。 */ + private buildRuntimeFailure( + outcome: RuntimeStageOutcome, + completedStages: string[] + ): InitializationResult { + return { + success: false, + error: outcome.error, + completedStages, + failedStage: outcome.failedStage, + code: outcome.code, + retryable: outcome.retryable, + remediation: outcome.remediation, + logs: outcome.logs, + logPath: outcome.logPath, + } + } + + /** + * Runtime 链路下的单步重试。 + * + * 由各步 IPC handler(`install-python` / `pull-repository` / `install-dependencies` 等) + * 复用;灰度开关关闭时返回 null,调用方继续走旧链路。 + */ + async retryStageViaRuntime( + stage: InitializationRunStage, + onProgress?: InitializationProgressCallback, + mirrorKey?: string, + mode: RuntimeRetryMode = 'auto' + ): Promise { + const runtimeService = this.getRuntimeService() + if (!runtimeService) return null + + return runtimeService.retryStage( + stage, + this.forwardRuntimeProgress(onProgress, 7), + mirrorKey, + mode + ) + } + /** * 仅更新源码和依赖(用于已初始化的环境) */ diff --git a/frontend/electron/services/runtime/__fixtures__/bootstrap-success.ndjson b/frontend/electron/services/runtime/__fixtures__/bootstrap-success.ndjson new file mode 100644 index 000000000..4592b6bce --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/bootstrap-success.ndjson @@ -0,0 +1,80 @@ +{"protocol":1,"type":"hello","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":1,"timestamp":"2026-09-01T22:03:15.6211608+02:00","runtimeVersion":"dev","command":"bootstrap","capabilities":["stdin.cancel","state.v1"]} +{"protocol":1,"type":"state","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":2,"timestamp":"2026-09-01T22:03:15.6956788+02:00","stage":"uv.check","status":"preparing_uv","message":"正在准备固定版本 uv","details":{}} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":3,"timestamp":"2026-09-01T22:03:15.7068255+02:00","stage":"uv.download","status":"running","message":"正在准备固定版本 uv"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":4,"timestamp":"2026-09-01T22:03:21.2924597+02:00","stage":"uv.verify","status":"succeeded","message":"固定版本 uv 已校验"} +{"protocol":1,"type":"state","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":5,"timestamp":"2026-09-01T22:03:21.2980255+02:00","stage":"workspace.check","status":"syncing_repository","message":"正在同步后端仓库","details":{}} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":6,"timestamp":"2026-09-01T22:03:21.2980255+02:00","stage":"workspace.clone","status":"running","message":"正在同步后端仓库"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":7,"timestamp":"2026-09-01T22:03:21.3931609+02:00","stage":"workspace.clone","status":"running","message":"正在获取后端仓库"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":8,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":9,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":10,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":11,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":12,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":13,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":14,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":15,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":16,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":17,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":18,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":19,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":20,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":21,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":22,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":23,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":24,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":25,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":26,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":27,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":28,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":29,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":30,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":31,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":32,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":33,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":34,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":35,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":36,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":37,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":38,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":39,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":40,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":41,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":42,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":43,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":44,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":45,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":46,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":47,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":48,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":49,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":50,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":51,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":52,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":53,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":54,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":55,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":56,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":57,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":58,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":59,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":60,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":61,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":62,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":63,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":64,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":65,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":66,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":67,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":68,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":69,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":70,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":71,"timestamp":"2026-09-01T22:03:23.2605263+02:00","stage":"workspace.clone","status":"running","message":"正在接收后端仓库数据"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":72,"timestamp":"2026-09-01T22:03:31.1692342+02:00","stage":"workspace.clone","status":"succeeded","message":"后端仓库获取完成"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":73,"timestamp":"2026-09-01T22:03:31.2388351+02:00","stage":"workspace.clone","status":"succeeded","message":"后端仓库已同步"} +{"protocol":1,"type":"state","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":74,"timestamp":"2026-09-01T22:03:31.2388351+02:00","stage":"python.check","status":"preparing_python","message":"正在准备受管 Python","details":{}} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":75,"timestamp":"2026-09-01T22:03:31.2433372+02:00","stage":"python.check","status":"running","message":"正在读取项目 Python 契约"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":76,"timestamp":"2026-09-01T22:03:31.2522225+02:00","stage":"python.install","status":"running","message":"正在准备受管 Python"} +{"protocol":1,"type":"progress","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":77,"timestamp":"2026-09-01T22:03:33.7390994+02:00","stage":"python.install","status":"succeeded","message":"受管 Python 已就绪"} +{"protocol":1,"type":"state","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":78,"timestamp":"2026-09-01T22:03:33.7481047+02:00","stage":"dependencies.sync","status":"syncing_environment","message":"正在同步锁定依赖","details":{}} +{"protocol":1,"type":"state","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":79,"timestamp":"2026-09-01T22:03:38.6642389+02:00","stage":"dependencies.sync","status":"ready_to_start","message":"运行环境已就绪","details":{}} +{"protocol":1,"type":"result","operationId":"01M1F936H536YEB3RQ199EYK09","sequence":80,"timestamp":"2026-09-01T22:03:38.6642389+02:00","success":true,"code":"OK","stage":"bootstrap","status":"ready_to_start","message":"运行环境准备完成","retryable":false,"remediation":[],"details":{"branch":"release/v9.9.9-alpha.1","commit":"24ff2b42879d572514933f89159de6d10ae02537","lockfileChecked":true,"pythonVersion":"3.12.13","synchronized":true,"uvExecutable":"D:\\AUTO-MAS\\runtime\\tools\\uv\\0.12.3\\uv.exe","uvVersion":"0.12.3","version":"v9.9.9-alpha.1"}} diff --git a/frontend/electron/services/runtime/__fixtures__/cancelled-with-warning.ndjson b/frontend/electron/services/runtime/__fixtures__/cancelled-with-warning.ndjson new file mode 100644 index 000000000..e4b6bfe11 --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/cancelled-with-warning.ndjson @@ -0,0 +1,4 @@ +{"protocol":1,"type":"hello","operationId":"01M1F6TXKYB01SPW8PFXMH2VQ8","sequence":1,"timestamp":"2026-09-01T21:23:47.1987172+02:00","runtimeVersion":"dev","command":"dependencies check","capabilities":["stdin.cancel"]} +{"protocol":1,"type":"warning","operationId":"01M1F6TXKYB01SPW8PFXMH2VQ8","sequence":2,"timestamp":"2026-09-01T21:23:47.2652172+02:00","code":"INVALID_CONTROL_COMMAND","stage":"dependencies.check","message":"已忽略无效的 stdin 控制命令","retryable":false,"remediation":["update-desktop"],"details":{"lineBytes":15,"reason":"invalid_json"}} +{"protocol":1,"type":"error","operationId":"01M1F6TXKYB01SPW8PFXMH2VQ8","sequence":3,"timestamp":"2026-09-01T21:23:47.2652172+02:00","code":"OPERATION_CANCELLED","stage":"workspace.check","message":"操作已取消","retryable":true,"remediation":["retry"],"details":{"controlCommandId":"01M1F6TXJ78W0PEM4J9J7W00T4"}} +{"protocol":1,"type":"result","operationId":"01M1F6TXKYB01SPW8PFXMH2VQ8","sequence":4,"timestamp":"2026-09-01T21:23:47.2687696+02:00","success":false,"code":"OPERATION_CANCELLED","stage":"workspace.check","status":"cancelled","message":"操作已取消","retryable":true,"remediation":["retry"],"details":{"controlCommandId":"01M1F6TXJ78W0PEM4J9J7W00T4","warningCount":1,"warnings":[{"code":"INVALID_CONTROL_COMMAND","stage":"dependencies.check","message":"已忽略无效的 stdin 控制命令","retryable":false,"remediation":["update-desktop"],"details":{"lineBytes":15,"reason":"invalid_json"}}],"warningsTruncated":false}} diff --git a/frontend/electron/services/runtime/__fixtures__/dependencies-check-failed.ndjson b/frontend/electron/services/runtime/__fixtures__/dependencies-check-failed.ndjson new file mode 100644 index 000000000..aecc7075c --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/dependencies-check-failed.ndjson @@ -0,0 +1,3 @@ +{"protocol":1,"type":"hello","operationId":"01M1F6Q1AQ51DN4MJ4EFQ2NBXZ","sequence":1,"timestamp":"2026-09-01T21:21:39.9275138+02:00","runtimeVersion":"dev","command":"dependencies check","capabilities":["stdin.cancel"]} +{"protocol":1,"type":"error","operationId":"01M1F6Q1AQ51DN4MJ4EFQ2NBXZ","sequence":2,"timestamp":"2026-09-01T21:21:40.0010137+02:00","code":"GIT_REPOSITORY_INVALID","stage":"workspace.check","message":"受管仓库尚未就绪","retryable":true,"remediation":["retry-sync"],"details":{"reason":"missing"}} +{"protocol":1,"type":"result","operationId":"01M1F6Q1AQ51DN4MJ4EFQ2NBXZ","sequence":3,"timestamp":"2026-09-01T21:21:40.0015137+02:00","success":false,"code":"GIT_REPOSITORY_INVALID","stage":"workspace.check","status":"failed","message":"受管仓库尚未就绪","retryable":true,"remediation":["retry-sync"],"details":{"reason":"missing"}} diff --git a/frontend/electron/services/runtime/__fixtures__/doctor.ndjson b/frontend/electron/services/runtime/__fixtures__/doctor.ndjson new file mode 100644 index 000000000..01938f16b --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/doctor.ndjson @@ -0,0 +1,21 @@ +{"protocol":1,"type":"hello","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":1,"timestamp":"2026-09-01T21:19:45.423936+02:00","runtimeVersion":"dev","command":"doctor","capabilities":[]} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":2,"timestamp":"2026-09-01T21:19:45.4980011+02:00","stage":"doctor","status":"running","message":"应用根目录"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":3,"timestamp":"2026-09-01T21:19:45.4985008+02:00","stage":"doctor","status":"succeeded","message":"应用根目录:目录存在"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":4,"timestamp":"2026-09-01T21:19:45.4985008+02:00","stage":"doctor","status":"running","message":"受管目录布局"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":5,"timestamp":"2026-09-01T21:19:45.4985008+02:00","stage":"doctor","status":"skipped","message":"受管目录布局:受管目录 4 个中 4 个缺失"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":6,"timestamp":"2026-09-01T21:19:45.4985008+02:00","stage":"doctor","status":"running","message":"uv 工具"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":7,"timestamp":"2026-09-01T21:19:45.4985008+02:00","stage":"doctor","status":"skipped","message":"uv 工具:未安装受管 uv"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":8,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"running","message":"受管 Python"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":9,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"skipped","message":"受管 Python:未安装受管 Python"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":10,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"running","message":"受管仓库"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":11,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"skipped","message":"受管仓库:repo 目录不存在"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":12,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"running","message":"主项目虚拟环境"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":13,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"skipped","message":"主项目虚拟环境:目录不存在"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":14,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"running","message":"运行时状态文件"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":15,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"skipped","message":"运行时状态文件:runtime-state 存在缺失文件"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":16,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"running","message":"并发锁占用"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":17,"timestamp":"2026-09-01T21:19:45.4990011+02:00","stage":"doctor","status":"succeeded","message":"并发锁占用:锁占用探测完成"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":18,"timestamp":"2026-09-01T21:19:45.4995008+02:00","stage":"doctor","status":"running","message":"磁盘剩余空间"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":19,"timestamp":"2026-09-01T21:19:45.4995008+02:00","stage":"doctor","status":"succeeded","message":"磁盘剩余空间:磁盘剩余空间可用"} +{"protocol":1,"type":"progress","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":20,"timestamp":"2026-09-01T21:19:45.4995008+02:00","stage":"doctor","status":"skipped","message":"诊断汇总:共 9 项,正常 3 项,缺失 6 项,异常 0 项"} +{"protocol":1,"type":"result","operationId":"01M1F6KHGFXJQ4AXEQZY3AC5Z3","sequence":21,"timestamp":"2026-09-01T21:19:45.4995008+02:00","success":true,"code":"OK","stage":"doctor","status":"succeeded","message":"诊断完成","retryable":false,"remediation":[],"details":{"checks":[{"details":{},"id":"app-root","message":"目录存在","name":"应用根目录","status":"ok"},{"details":{"logs":"missing","repo":"missing","runtime":"missing","runtime-state":"missing"},"id":"layout","message":"受管目录 4 个中 4 个缺失","name":"受管目录布局","status":"missing"},{"details":{},"id":"uv","message":"未安装受管 uv","name":"uv 工具","status":"missing"},{"details":{},"id":"python","message":"未安装受管 Python","name":"受管 Python","status":"missing"},{"details":{},"id":"repo","message":"repo 目录不存在","name":"受管仓库","status":"missing"},{"details":{},"id":"venv","message":"目录不存在","name":"主项目虚拟环境","status":"missing"},{"details":{"backend":"missing","environment":"missing","mutation":"missing","update":"missing"},"id":"runtime-state","message":"runtime-state 存在缺失文件","name":"运行时状态文件","status":"missing"},{"details":{"backend":false,"backendRecovered":false,"mutation":false,"mutationRecovered":false},"id":"mutex","message":"锁占用探测完成","name":"并发锁占用","status":"ok"},{"details":{"freeBytes":1236010582016},"id":"disk","message":"磁盘剩余空间可用","name":"磁盘剩余空间","status":"ok"}],"summary":{"error":0,"missing":6,"ok":3,"total":9}}} diff --git a/frontend/electron/services/runtime/__fixtures__/protocol-2-mismatch.stderr.txt b/frontend/electron/services/runtime/__fixtures__/protocol-2-mismatch.stderr.txt new file mode 100644 index 000000000..977e95f32 --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/protocol-2-mismatch.stderr.txt @@ -0,0 +1 @@ +auto-mas-runtime: protocol version mismatch diff --git a/frontend/electron/services/runtime/__fixtures__/supervise-dev-repo-missing.ndjson b/frontend/electron/services/runtime/__fixtures__/supervise-dev-repo-missing.ndjson new file mode 100644 index 000000000..b789faf7d --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/supervise-dev-repo-missing.ndjson @@ -0,0 +1,3 @@ +{"protocol":1,"type":"hello","operationId":"01M1F8J75E7HK5XF9YWX0GSYSW","sequence":1,"timestamp":"2026-09-01T21:53:59.2140742+02:00","runtimeVersion":"dev","command":"backend supervise","capabilities":["stdin.cancel","state.v1","log.stream"]} +{"protocol":1,"type":"error","operationId":"01M1F8J75E7HK5XF9YWX0GSYSW","sequence":2,"timestamp":"2026-09-01T21:53:59.2675743+02:00","code":"INVALID_ARGUMENT","stage":"backend.spawn","message":"开发源码目录无效","retryable":false,"remediation":["run-doctor"],"details":{"field":"repo","reason":"missing"}} +{"protocol":1,"type":"result","operationId":"01M1F8J75E7HK5XF9YWX0GSYSW","sequence":3,"timestamp":"2026-09-01T21:53:59.2675743+02:00","success":false,"code":"INVALID_ARGUMENT","stage":"backend.spawn","status":"backend_failed","message":"开发源码目录无效","retryable":false,"remediation":["run-doctor"],"details":{"field":"repo","reason":"missing"}} diff --git a/frontend/electron/services/runtime/__fixtures__/supervise-invalid-mode.ndjson b/frontend/electron/services/runtime/__fixtures__/supervise-invalid-mode.ndjson new file mode 100644 index 000000000..b50a3becf --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/supervise-invalid-mode.ndjson @@ -0,0 +1,3 @@ +{"protocol":1,"type":"hello","operationId":"01M1F6M33JFZZ7Y85BE5S849ZN","sequence":1,"timestamp":"2026-09-01T21:20:03.4428696+02:00","runtimeVersion":"dev","command":"backend supervise","capabilities":["stdin.cancel","state.v1","log.stream"]} +{"protocol":1,"type":"error","operationId":"01M1F6M33JFZZ7Y85BE5S849ZN","sequence":2,"timestamp":"2026-09-01T21:20:03.5166395+02:00","code":"INVALID_ARGUMENT","stage":"backend.spawn","message":"必须显式指定后端运行模式","retryable":false,"remediation":["run-doctor"],"details":{"field":"mode"}} +{"protocol":1,"type":"result","operationId":"01M1F6M33JFZZ7Y85BE5S849ZN","sequence":3,"timestamp":"2026-09-01T21:20:03.5166395+02:00","success":false,"code":"INVALID_ARGUMENT","stage":"backend.spawn","status":"failed","message":"必须显式指定后端运行模式","retryable":false,"remediation":["run-doctor"],"details":{"field":"mode"}} diff --git a/frontend/electron/services/runtime/__fixtures__/unknown-command.stderr.txt b/frontend/electron/services/runtime/__fixtures__/unknown-command.stderr.txt new file mode 100644 index 000000000..ed664a4da --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/unknown-command.stderr.txt @@ -0,0 +1 @@ +auto-mas-runtime: unknown command "nosuchcmd" for "auto-mas-runtime" diff --git a/frontend/electron/services/runtime/__fixtures__/version.ndjson b/frontend/electron/services/runtime/__fixtures__/version.ndjson new file mode 100644 index 000000000..34024fb5f --- /dev/null +++ b/frontend/electron/services/runtime/__fixtures__/version.ndjson @@ -0,0 +1,3 @@ +{"protocol":1,"type":"hello","operationId":"01M1F6K7P71J6TMW6J45CJNS63","sequence":1,"timestamp":"2026-09-01T21:19:35.3678603+02:00","runtimeVersion":"dev","command":"version","capabilities":[]} +{"protocol":1,"type":"progress","operationId":"01M1F6K7P71J6TMW6J45CJNS63","sequence":2,"timestamp":"2026-09-01T21:19:35.4085068+02:00","stage":"runtime.handshake","status":"succeeded","message":"Runtime dev,协议 1"} +{"protocol":1,"type":"result","operationId":"01M1F6K7P71J6TMW6J45CJNS63","sequence":3,"timestamp":"2026-09-01T21:19:35.4085068+02:00","success":true,"code":"OK","stage":"runtime.handshake","status":"succeeded","message":"版本信息查询完成","retryable":false,"remediation":[],"details":{"buildDate":"","commit":"","goVersion":"go1.26.5","protocolVersion":1,"runtimeVersion":"dev"}} diff --git a/frontend/electron/services/runtime/client.test.ts b/frontend/electron/services/runtime/client.test.ts new file mode 100644 index 000000000..918fffada --- /dev/null +++ b/frontend/electron/services/runtime/client.test.ts @@ -0,0 +1,865 @@ +import { spawn } from 'child_process' +import { EventEmitter } from 'node:events' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + RuntimeClient, + buildRuntimeArgs, + collectRuntimeLogs, + createCommandId, + formatStartupLogs, + readRuntimeBaseUrl, + serializeControlCommand, +} from './client' +import { + RUNTIME_CLIENT_ERROR_DEFINITIONS, + RuntimeClientError, + RuntimeEvent, + isRetryableRuntimeCode, + lookupRuntimeErrorDefinition, +} from './protocol' + +vi.mock('child_process', () => ({ spawn: vi.fn() })) +// logger 会拉起 electron-log,与本模块逻辑无关,直接替换掉。 +vi.mock('../logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '__fixtures__') + +/** 夹具由本机构建的 auto-mas-runtime.exe 真实跑出来,不是手写的。 */ +function fixture(name: string): string { + return readFileSync(join(fixturesDir, name), 'utf8') +} + +// ==================== 假子进程 ==================== + +/** 同步投递数据的假可读流,避免测试里跟 PassThrough 的异步节奏纠缠。 */ +class FakeReadable extends EventEmitter { + setEncoding(): this { + return this + } + + feed(text: string): void { + this.emit('data', text) + } +} + +class FakeWritable extends EventEmitter { + readonly chunks: string[] = [] + destroyed = false + writableEnded = false + + write(chunk: string): boolean { + this.chunks.push(chunk) + return true + } +} + +class FakeChild extends EventEmitter { + readonly stdout = new FakeReadable() + readonly stderr = new FakeReadable() + readonly stdin = new FakeWritable() + readonly pid = 4242 + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + killed = false + + kill(signal?: NodeJS.Signals): boolean { + if (this.killed || this.exitCode !== null) return true + this.killed = true + this.close(null, signal ?? 'SIGTERM') + return true + } + + close(code: number | null, signal: NodeJS.Signals | null = null): void { + this.exitCode = code + this.signalCode = signal + this.emit('close', code, signal) + } +} + +const spawnMock = vi.mocked(spawn) + +function mockSpawn(): FakeChild { + const child = new FakeChild() + spawnMock.mockReturnValue(child as never) + return child +} + +// Runtime 可执行文件的存在性用 fs.existsSync 判断,这里借用一定存在的 node 自身路径。 +const RUNTIME_PATH = process.execPath +const APP_ROOT = 'D:\\AUTO-MAS' + +function createClient(overrides: Record = {}) { + return new RuntimeClient({ runtimePath: RUNTIME_PATH, appRoot: APP_ROOT, ...overrides }) +} + +function line(event: Record): string { + return `${JSON.stringify(event)}\n` +} + +beforeEach(() => { + spawnMock.mockReset() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +// ==================== 参数与工具 ==================== + +describe('buildRuntimeArgs', () => { + it('机器调用固定带上 ndjson 与协议 1,全局选项在子命令之前', () => { + const args = buildRuntimeArgs( + { + runtimePath: RUNTIME_PATH, + appRoot: APP_ROOT, + mirrors: [ + { kind: 'git', key: 'ghproxy' }, + { kind: 'uv', key: 'tuna' }, + ], + mirrorOnly: true, + offline: false, + }, + ['bootstrap', '--version', 'v5.5.0-beta.3'] + ) + + expect(args).toEqual([ + '--app-root', + APP_ROOT, + '--output', + 'ndjson', + '--protocol', + '1', + '--mirror', + 'git=ghproxy', + '--mirror', + 'uv=tuna', + '--mirror-only', + 'bootstrap', + '--version', + 'v5.5.0-beta.3', + ]) + }) + + it('offline 作为独立标志追加', () => { + const args = buildRuntimeArgs({ runtimePath: RUNTIME_PATH, appRoot: APP_ROOT, offline: true }, [ + 'doctor', + ]) + + expect(args).toContain('--offline') + expect(args.at(-1)).toBe('doctor') + }) +}) + +describe('createCommandId', () => { + it('生成 Runtime validOperationID 认可的 26 位 ULID', () => { + const id = createCommandId(1_767_000_000_000) + + // Runtime 要求:长度 26、Crockford base32 字母表、首字符不大于 '7'。 + expect(id).toHaveLength(26) + expect(id).toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/) + }) + + it('同一毫秒内也不重复', () => { + const ids = new Set(Array.from({ length: 200 }, () => createCommandId(1_767_000_000_000))) + + expect(ids.size).toBe(200) + }) +}) + +describe('serializeControlCommand', () => { + it('每行一个 JSON 对象且必须以换行结尾', () => { + const text = serializeControlCommand({ + protocol: 1, + command: 'cancel', + commandId: '01M1F6TJTKFC1M6DWM8C9AXZCK', + }) + + expect(text).toBe( + '{"protocol":1,"command":"cancel","commandId":"01M1F6TJTKFC1M6DWM8C9AXZCK"}\n' + ) + }) +}) + +describe('formatStartupLogs', () => { + it('沿用 backendService 的整块展示格式', () => { + expect(formatStartupLogs(['第一行', '第二行'], ['报错'])).toBe( + '[stdout]\n第一行\n第二行\n\n[stderr]\n报错' + ) + }) + + it('单侧为空时只输出另一侧,两侧都空返回 undefined', () => { + expect(formatStartupLogs(['只有标准输出'], [])).toBe('[stdout]\n只有标准输出') + expect(formatStartupLogs([], ['只有标准错误'])).toBe('[stderr]\n只有标准错误') + expect(formatStartupLogs([], [])).toBeUndefined() + expect(formatStartupLogs([' ', ''], ['\n'])).toBeUndefined() + }) +}) + +describe('collectRuntimeLogs', () => { + it('按 operationId 分组,stdout 与 stderr 各自保序', () => { + const events = [ + { type: 'log', operationId: 'A', stream: 'stdout', message: 'a1' }, + { type: 'log', operationId: 'A', stream: 'stderr', message: 'e1' }, + { type: 'log', operationId: 'B', stream: 'stdout', message: 'b1' }, + { type: 'log', operationId: 'A', stream: 'stdout', message: 'a2' }, + { type: 'log', operationId: 'A', stream: 'unknown', message: 'x1' }, + ] as unknown as RuntimeEvent[] + + expect(collectRuntimeLogs(events)).toEqual({ + A: { stdout: ['a1', 'a2'], stderr: ['e1'], other: ['x1'] }, + B: { stdout: ['b1'], stderr: [], other: [] }, + }) + }) +}) + +describe('readRuntimeBaseUrl', () => { + it('从 details 读取后端基地址,缺失时返回 undefined', () => { + expect(readRuntimeBaseUrl({ baseUrl: 'http://127.0.0.1:36163' })).toBe('http://127.0.0.1:36163') + expect(readRuntimeBaseUrl({})).toBeUndefined() + expect(readRuntimeBaseUrl({ baseUrl: '' })).toBeUndefined() + expect(readRuntimeBaseUrl({ baseUrl: 36163 })).toBeUndefined() + }) +}) + +// ==================== run ==================== + +describe('RuntimeClient.run', () => { + it('跑通真实 version 输出并返回终态 result', async () => { + const client = createClient({ env: { AUTO_MAS_TELEMETRY: 'disabled' } }) + const child = mockSpawn() + const progress: string[] = [] + + const pending = client.run(['version'], { + onProgress: event => progress.push(`${event.stage}:${event.status}`), + }) + child.stdout.feed(fixture('version.ndjson')) + child.close(0) + const outcome = await pending + + expect(outcome.success).toBe(true) + expect(outcome.code).toBe('OK') + expect(outcome.hello.command).toBe('version') + expect(outcome.hello.capabilities).toEqual([]) + expect(outcome.result.details.runtimeVersion).toBe('dev') + expect(outcome.exitCode).toBe(0) + expect(progress).toEqual(['runtime.handshake:succeeded']) + + // 必须用参数数组启动,不能拼 shell 字符串。 + expect(spawnMock).toHaveBeenCalledTimes(1) + const [command, args, options] = spawnMock.mock.calls[0] + expect(command).toBe(RUNTIME_PATH) + expect(args).toEqual([ + '--app-root', + APP_ROOT, + '--output', + 'ndjson', + '--protocol', + '1', + 'version', + ]) + expect(options).toMatchObject({ windowsHide: true }) + expect((options as { env: NodeJS.ProcessEnv }).env.AUTO_MAS_TELEMETRY).toBe('disabled') + expect((options as { shell?: unknown }).shell).toBeUndefined() + }) + + it('doctor 的进度事件全部透出,终态仍为成功', async () => { + const client = createClient() + const child = mockSpawn() + const seen: RuntimeEvent[] = [] + + const pending = client.run(['doctor'], { onEvent: event => seen.push(event) }) + child.stdout.feed(fixture('doctor.ndjson')) + child.close(0) + const outcome = await pending + + expect(seen).toHaveLength(21) + expect(outcome.events.filter(event => event.type === 'progress')).toHaveLength(19) + expect(outcome.success).toBe(true) + expect(outcome.result.stage).toBe('doctor') + }) + + it('半行分片到达时能正确拼接', async () => { + const client = createClient() + const child = mockSpawn() + const raw = fixture('version.ndjson') + const cut = Math.floor(raw.length / 3) + + const pending = client.run(['version']) + child.stdout.feed(raw.slice(0, cut)) + child.stdout.feed(raw.slice(cut, cut * 2)) + child.stdout.feed(raw.slice(cut * 2)) + child.close(0) + + await expect(pending).resolves.toMatchObject({ success: true, code: 'OK' }) + }) + + it('失败 result 不抛异常,由调用方读 result.code', async () => { + const client = createClient() + const child = mockSpawn() + + const pending = client.run(['backend', 'supervise']) + child.stdout.feed(fixture('supervise-invalid-mode.ndjson')) + child.close(2) + const outcome = await pending + + expect(outcome.success).toBe(false) + expect(outcome.code).toBe('INVALID_ARGUMENT') + expect(outcome.errors).toHaveLength(1) + expect(outcome.result.remediation).toEqual(['run-doctor']) + // 退出码只做粗分类,精确原因看 result.code。 + expect(outcome.exitCode).toBe(2) + }) + + it('可重试的 Runtime 失败照样正常返回,retryable 与 remediation 取自 result', async () => { + const client = createClient() + const child = mockSpawn() + + const pending = client.run(['dependencies', 'check']) + child.stdout.feed(fixture('dependencies-check-failed.ndjson')) + child.close(40) + const outcome = await pending + + expect(outcome.success).toBe(false) + expect(outcome.code).toBe('GIT_REPOSITORY_INVALID') + expect(outcome.result.retryable).toBe(true) + expect(outcome.result.remediation).toEqual(['retry-sync']) + expect(isRetryableRuntimeCode('GIT_REPOSITORY_INVALID')).toBe(true) + // hello.capabilities 随命令变化,不能从命令名推断。 + expect(outcome.hello.capabilities).toEqual(['stdin.cancel']) + }) + + it('warning 单独归集并保留在 result.details.warnings 里', async () => { + const client = createClient() + const child = mockSpawn() + + const pending = client.run(['dependencies', 'check']) + child.stdout.feed(fixture('cancelled-with-warning.ndjson')) + child.close(130) + const outcome = await pending + + expect(outcome.warnings.map(item => item.code)).toEqual(['INVALID_CONTROL_COMMAND']) + expect(outcome.code).toBe('OPERATION_CANCELLED') + expect(outcome.result.details.warningCount).toBe(1) + }) + + it('坏 JSON 行让本次调用失败,并带上原始行', async () => { + const client = createClient() + const child = mockSpawn() + const protocolErrors: RuntimeClientError[] = [] + + const pending = client.run(['version'], { + onProtocolError: error => protocolErrors.push(error), + }) + const [helloLine] = fixture('version.ndjson').trim().split('\n') + child.stdout.feed(`${helloLine}\n{"protocol":1,\n`) + + await expect(pending).rejects.toMatchObject({ + code: 'RUNTIME_PROTOCOL_ERROR', + retryable: false, + details: { line: '{"protocol":1,' }, + }) + expect(protocolErrors).toHaveLength(1) + expect(child.killed).toBe(true) + }) + + it('hello 迟迟不来时报 RUNTIME_HANDSHAKE_TIMEOUT', async () => { + const client = createClient() + const child = mockSpawn() + + const pending = client.run(['version'], { handshakeTimeoutMs: 20 }) + child.stderr.feed('auto-mas-runtime: 卡住了\n') + + await expect(pending).rejects.toMatchObject({ + code: 'RUNTIME_HANDSHAKE_TIMEOUT', + retryable: true, + }) + expect(child.killed).toBe(true) + }) + + it('hello.protocol 不是 1 时报 RUNTIME_PROTOCOL_MISMATCH', async () => { + const client = createClient() + const child = mockSpawn() + + const pending = client.run(['version']) + child.stdout.feed( + line({ + protocol: 2, + type: 'hello', + operationId: '01M1F6K7P71J6TMW6J45CJNS63', + sequence: 1, + timestamp: '2026-09-01T21:19:35.367+02:00', + runtimeVersion: 'dev', + command: 'version', + capabilities: [], + }) + ) + + await expect(pending).rejects.toMatchObject({ + code: 'RUNTIME_PROTOCOL_MISMATCH', + retryable: false, + details: { actualProtocol: 2, expectedProtocol: 1 }, + }) + }) + + it('协议不匹配时 Runtime 只给退出码 10 和 stderr,同样归为 RUNTIME_PROTOCOL_MISMATCH', async () => { + // 实测行为:--protocol 2 时 stdout 全空,没有 hello/result, + // stderr 一行诊断,退出码 10。见 __fixtures__/protocol-2-mismatch.stderr.txt。 + const client = createClient() + const child = mockSpawn() + const stderr = fixture('protocol-2-mismatch.stderr.txt') + + const pending = client.run(['version']) + child.stderr.feed(stderr) + child.close(10) + + await expect(pending).rejects.toMatchObject({ + code: 'RUNTIME_PROTOCOL_MISMATCH', + details: { exitCode: 10, stderr }, + }) + }) + + it('进程在 result 之前退出时报 RUNTIME_EXITED_UNEXPECTEDLY 并带退出码与 stderr', async () => { + const client = createClient() + const child = mockSpawn() + const [helloLine] = fixture('version.ndjson').trim().split('\n') + + const pending = client.run(['doctor']) + child.stdout.feed(`${helloLine}\n`) + child.stderr.feed('panic: 崩了\n') + child.close(50) + + await expect(pending).rejects.toMatchObject({ + code: 'RUNTIME_EXITED_UNEXPECTEDLY', + retryable: true, + details: { exitCode: 50, stderr: 'panic: 崩了\n' }, + }) + }) + + it('未知子命令这类参数错误也走 RUNTIME_EXITED_UNEXPECTEDLY', async () => { + // 实测:未知子命令 stdout 全空、stderr 一行诊断、退出码 2,不承诺 hello/result。 + const client = createClient() + const child = mockSpawn() + + const pending = client.run(['nosuchcmd']) + child.stderr.feed(fixture('unknown-command.stderr.txt')) + child.close(2) + + await expect(pending).rejects.toMatchObject({ + code: 'RUNTIME_EXITED_UNEXPECTEDLY', + details: { exitCode: 2 }, + }) + }) + + it('可执行文件不存在时报 RUNTIME_NOT_FOUND 且不 spawn', async () => { + const client = createClient({ runtimePath: join(fixturesDir, '不存在的-runtime.exe') }) + + await expect(client.run(['version'])).rejects.toMatchObject({ code: 'RUNTIME_NOT_FOUND' }) + expect(spawnMock).not.toHaveBeenCalled() + }) + + it('spawn 抛错时按 errno 区分 RUNTIME_NOT_FOUND 与 RUNTIME_SPAWN_FAILED', async () => { + const client = createClient() + + const denied = mockSpawn() + const deniedPending = client.run(['version']) + denied.emit('error', Object.assign(new Error('permission denied'), { code: 'EACCES' })) + await expect(deniedPending).rejects.toMatchObject({ code: 'RUNTIME_SPAWN_FAILED' }) + + const missing = mockSpawn() + const missingPending = client.run(['version']) + missing.emit('error', Object.assign(new Error('no such file'), { code: 'ENOENT' })) + await expect(missingPending).rejects.toMatchObject({ code: 'RUNTIME_NOT_FOUND' }) + }) + + it('log 事件按 operationId 聚合,可直接喂给 formatStartupLogs', async () => { + // log 事件只在 backend supervise 转发受管进程输出时出现,这里用协议结构合成。 + const client = createClient() + const child = mockSpawn() + const operationId = '01M1F6M33JFZZ7Y85BE5S849ZN' + const base = { protocol: 1, operationId, timestamp: '2026-09-01T21:20:03.442+02:00' } + + const pending = client.run(['backend', 'supervise']) + child.stdout.feed( + [ + line({ + ...base, + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'backend supervise', + capabilities: ['stdin.cancel', 'state.v1', 'log.stream'], + }), + line({ + ...base, + type: 'log', + sequence: 2, + source: 'backend', + stream: 'stdout', + message: 'INFO 启动中', + }), + line({ + ...base, + type: 'log', + sequence: 3, + source: 'backend', + stream: 'stderr', + message: 'Traceback', + }), + line({ + ...base, + type: 'log', + sequence: 4, + source: 'backend', + stream: 'stdout', + message: 'INFO 就绪', + }), + line({ + ...base, + type: 'result', + sequence: 5, + success: true, + code: 'OK', + stage: 'backend.shutdown', + status: 'stopped', + message: '后端已停止', + retryable: false, + remediation: [], + details: {}, + }), + ].join('') + ) + child.close(0) + const outcome = await pending + + expect(outcome.logs[operationId]).toEqual({ + stdout: ['INFO 启动中', 'INFO 就绪'], + stderr: ['Traceback'], + other: [], + }) + expect( + formatStartupLogs(outcome.logs[operationId].stdout, outcome.logs[operationId].stderr) + ).toBe('[stdout]\nINFO 启动中\nINFO 就绪\n\n[stderr]\nTraceback') + }) + + it('INTERNAL_ERROR 按不可重试处理,且与 OUTPUT_WRITE_FAILED 文案不同', async () => { + const client = createClient() + const child = mockSpawn() + const base = { + protocol: 1, + operationId: '01M1F6M33JFZZ7Y85BE5S849ZN', + timestamp: '2026-09-01T21:20:03.442+02:00', + } + + const pending = client.run(['repair']) + child.stdout.feed( + [ + line({ + ...base, + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'repair', + capabilities: ['stdin.cancel'], + }), + line({ + ...base, + type: 'result', + sequence: 2, + success: false, + code: 'INTERNAL_ERROR', + stage: 'repair', + status: 'failed', + message: '内部错误', + retryable: false, + remediation: ['open-log', 'contact-support'], + details: {}, + }), + ].join('') + ) + child.close(20) + const outcome = await pending + + expect(outcome.success).toBe(false) + expect(outcome.result.retryable).toBe(false) + expect(isRetryableRuntimeCode('INTERNAL_ERROR')).toBe(false) + + const internal = lookupRuntimeErrorDefinition('INTERNAL_ERROR') + const outputWrite = lookupRuntimeErrorDefinition('OUTPUT_WRITE_FAILED') + expect(internal).toMatchObject({ + retryable: false, + exitCode: 20, + remediation: ['open-log', 'contact-support'], + }) + // 两者行为四元组相同,文案必须区分「Runtime 有 bug」与「输出通道坏了」。 + expect(internal?.remediation).toEqual(outputWrite?.remediation) + expect(internal?.summary).not.toBe(outputWrite?.summary) + }) + + it('未知错误码按不可重试兜底', () => { + expect(isRetryableRuntimeCode('SOME_FUTURE_CODE')).toBe(false) + expect(lookupRuntimeErrorDefinition('SOME_FUTURE_CODE')).toBeUndefined() + }) +}) + +// ==================== supervise ==================== + +describe('RuntimeClient.supervise', () => { + const operationId = '01M1F6M33JFZZ7Y85BE5S849ZN' + const base = { protocol: 1, operationId, timestamp: '2026-09-01T21:20:03.442+02:00' } + + const helloLine = line({ + ...base, + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'backend supervise', + capabilities: ['stdin.cancel', 'state.v1', 'log.stream'], + }) + + const runningStateLine = line({ + ...base, + type: 'state', + sequence: 2, + stage: 'backend.run', + status: 'running', + message: '后端运行中', + details: { baseUrl: 'http://127.0.0.1:36163', pid: 9001 }, + }) + + const stoppedResultLine = line({ + ...base, + type: 'result', + sequence: 3, + success: true, + code: 'OK', + stage: 'backend.shutdown', + status: 'stopped', + message: '后端已停止', + retryable: false, + remediation: [], + details: {}, + }) + + it('握手后返回句柄,state 事件带出 baseUrl', async () => { + const client = createClient() + const child = mockSpawn() + const states: string[] = [] + + const pendingHandle = client.supervise({ + mode: 'managed', + onState: event => states.push(`${event.status}:${readRuntimeBaseUrl(event.details)}`), + }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + child.stdout.feed(runningStateLine) + + expect(handle.hello.command).toBe('backend supervise') + expect(handle.capabilities).toEqual(['stdin.cancel', 'state.v1', 'log.stream']) + expect(handle.pid).toBe(4242) + expect(states).toEqual(['running:http://127.0.0.1:36163']) + expect(spawnMock.mock.calls[0][1]).toEqual([ + '--app-root', + APP_ROOT, + '--output', + 'ndjson', + '--protocol', + '1', + 'backend', + 'supervise', + '--mode', + 'managed', + ]) + + child.stdout.feed(stoppedResultLine) + child.close(0) + await handle.completion + }) + + it('development 模式带上 --repo', async () => { + const client = createClient() + const child = mockSpawn() + + const pendingHandle = client.supervise({ mode: 'development', repo: 'D:\\src\\AUTO-MAS' }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + + expect(spawnMock.mock.calls[0][1].slice(-4)).toEqual([ + '--mode', + 'development', + '--repo', + 'D:\\src\\AUTO-MAS', + ]) + + child.stdout.feed(stoppedResultLine) + child.close(0) + await handle.completion + }) + + it('shutdown 写出合法控制行,并在收到 result 与进程退出后 resolve', async () => { + const client = createClient() + const child = mockSpawn() + + const pendingHandle = client.supervise({ mode: 'managed' }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + + const pendingShutdown = handle.shutdown({ timeoutMs: 1_000 }) + + expect(child.stdin.chunks).toHaveLength(1) + const written = child.stdin.chunks[0] + expect(written.endsWith('\n')).toBe(true) + const payload = JSON.parse(written.trimEnd()) + expect(Object.keys(payload).sort()).toEqual(['command', 'commandId', 'protocol']) + expect(payload).toMatchObject({ protocol: 1, command: 'shutdown' }) + expect(payload.commandId).toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/) + + child.stdout.feed(stoppedResultLine) + child.close(0) + const outcome = await pendingShutdown + + expect(outcome.success).toBe(true) + expect(outcome.result.status).toBe('stopped') + expect(child.killed).toBe(false) + }) + + it('重复 shutdown 只写一次控制行', async () => { + const client = createClient() + const child = mockSpawn() + + const pendingHandle = client.supervise({ mode: 'managed' }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + + const first = handle.shutdown({ timeoutMs: 1_000 }) + const second = handle.shutdown({ timeoutMs: 1_000 }) + + expect(child.stdin.chunks).toHaveLength(1) + + child.stdout.feed(stoppedResultLine) + child.close(0) + await Promise.all([first, second]) + }) + + it('status 与 cancel 各自生成独立 commandId', async () => { + const client = createClient() + const child = mockSpawn() + + const pendingHandle = client.supervise({ mode: 'managed' }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + + const statusId = handle.status() + const cancelId = handle.cancel() + + expect(statusId).not.toBe(cancelId) + expect(child.stdin.chunks.map(chunk => JSON.parse(chunk.trimEnd()).command)).toEqual([ + 'status', + 'cancel', + ]) + + child.stdout.feed(stoppedResultLine) + child.close(0) + await handle.completion + }) + + it('shutdown 超时才 kill,并以 RUNTIME_EXITED_UNEXPECTEDLY 收尾', async () => { + const client = createClient() + const child = mockSpawn() + + const pendingHandle = client.supervise({ mode: 'managed' }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + + const pendingShutdown = handle.shutdown({ timeoutMs: 20 }) + expect(child.killed).toBe(false) + + await expect(pendingShutdown).rejects.toMatchObject({ + code: 'RUNTIME_EXITED_UNEXPECTEDLY', + }) + expect(child.killed).toBe(true) + }) + + it('握手后的坏行不掀翻正在运行的后端,只记录并回调', async () => { + const client = createClient() + const child = mockSpawn() + const protocolErrors: RuntimeClientError[] = [] + + const pendingHandle = client.supervise({ + mode: 'managed', + onProtocolError: error => protocolErrors.push(error), + }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + + child.stdout.feed('这行不是 JSON\n') + expect(protocolErrors).toHaveLength(1) + expect(protocolErrors[0].code).toBe('RUNTIME_PROTOCOL_ERROR') + expect(child.killed).toBe(false) + + child.stdout.feed(stoppedResultLine) + child.close(0) + const outcome = await handle.completion + + expect(outcome.success).toBe(true) + expect(outcome.protocolErrors).toHaveLength(1) + }) + + it('onEvent 订阅可取消', async () => { + const client = createClient() + const child = mockSpawn() + const seen: string[] = [] + + const pendingHandle = client.supervise({ mode: 'managed' }) + child.stdout.feed(helloLine) + const handle = await pendingHandle + + const unsubscribe = handle.onEvent(event => seen.push(event.type)) + child.stdout.feed(runningStateLine) + unsubscribe() + child.stdout.feed(stoppedResultLine) + child.close(0) + await handle.completion + + expect(seen).toEqual(['state']) + }) +}) + +describe('RuntimeClientError', () => { + it('六个调用侧错误码都有 retryable 与 remediation 定义', () => { + const codes = Object.keys(RUNTIME_CLIENT_ERROR_DEFINITIONS) + + expect(codes.sort()).toEqual([ + 'RUNTIME_EXITED_UNEXPECTEDLY', + 'RUNTIME_HANDSHAKE_TIMEOUT', + 'RUNTIME_NOT_FOUND', + 'RUNTIME_PROTOCOL_ERROR', + 'RUNTIME_PROTOCOL_MISMATCH', + 'RUNTIME_SPAWN_FAILED', + ]) + for (const definition of Object.values(RUNTIME_CLIENT_ERROR_DEFINITIONS)) { + expect(definition.remediation.length).toBeGreaterThan(0) + expect(definition.summary).not.toBe('') + } + }) + + it('未给 message 时回落到该错误码的固定摘要', () => { + const error = new RuntimeClientError('RUNTIME_NOT_FOUND') + + expect(error.name).toBe('RuntimeClientError') + expect(error.message).toBe('找不到 Runtime 可执行文件') + expect(error.retryable).toBe(false) + }) +}) diff --git a/frontend/electron/services/runtime/client.ts b/frontend/electron/services/runtime/client.ts new file mode 100644 index 000000000..629854243 --- /dev/null +++ b/frontend/electron/services/runtime/client.ts @@ -0,0 +1,805 @@ +/** + * AUTO-MAS Runtime 客户端 + * + * 以「可执行文件路径 + 参数数组」spawn `auto-mas-runtime.exe`,固定使用 + * `--output ndjson --protocol 1` 机器调用模式,解析 NDJSON 事件流,并通过 stdin + * 逐行下发控制命令。严禁拼接 shell 命令字符串。 + * + * 两种调用形态: + * - `run()`:一次性命令(version / doctor / bootstrap / workspace sync / … ), + * 执行完退出,返回终态 `result`; + * - `supervise()`:`backend supervise` 长驻形态,返回句柄,可发 status / cancel / + * shutdown,并等待最终 `result` 与进程退出。 + * + * 本模块只做客户端封装,不负责替换现有初始化与后端服务。 + */ + +import { ChildProcessWithoutNullStreams, spawn } from 'child_process' +import { randomBytes } from 'crypto' +import { EventEmitter } from 'events' +import * as fs from 'fs' + +import { getLogger } from '../logger' +import { NdjsonEventStream, NdjsonItem } from './ndjson' +import { + RUNTIME_EXIT_CODES, + RUNTIME_PROTOCOL_VERSION, + RuntimeCapability, + RuntimeClientError, + RuntimeCode, + RuntimeControlCommand, + RuntimeControlKind, + RuntimeErrorEvent, + RuntimeEvent, + RuntimeHelloEvent, + RuntimeLogEvent, + RuntimeProgressEvent, + RuntimeResultEvent, + RuntimeStateEvent, + RuntimeWarningEvent, +} from './protocol' + +const logger = getLogger('Runtime客户端') + +/** 等待 hello 的默认超时。Runtime 参数解析后立刻发 hello,10 秒足够宽裕。 */ +export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000 + +/** 收到 result 之后仍等待进程退出的宽限时间,超时则强制结束进程。 */ +export const DEFAULT_RESULT_SETTLE_TIMEOUT_MS = 5_000 + +/** `shutdown()` 等待最终 result 与进程退出的默认超时,超时才 kill。 */ +export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000 + +/** 判定失败并结束进程后,等待 close 事件的兜底时间,避免调用方永久挂起。 */ +const FAIL_SETTLE_GRACE_MS = 2_000 + +// ==================== 选项与结果 ==================== + +/** 镜像源选择,对应 `--mirror =`。 */ +export interface RuntimeMirrorSelection { + kind: 'git' | 'uv' | 'python' | 'package-index' + key: string +} + +export interface RuntimeClientOptions { + /** `auto-mas-runtime.exe` 的绝对路径。 */ + runtimePath: string + /** 传给 `--app-root` 的应用根目录。 */ + appRoot: string + /** + * 覆盖或追加到 `process.env` 的环境变量,例如 `AUTO_MAS_TELEMETRY=disabled`。 + * 值为 undefined 的键会被删除。 + */ + env?: NodeJS.ProcessEnv + /** 全局镜像源选择,可重复。 */ + mirrors?: RuntimeMirrorSelection[] + /** `--mirror-only`:只用配置的镜像源,排除官方源兜底。 */ + mirrorOnly?: boolean + /** `--offline`:禁止任何网络尝试。与 mirror 选项互斥,冲突由 Runtime 判定。 */ + offline?: boolean + /** 子进程工作目录,默认继承当前进程。 */ + cwd?: string + handshakeTimeoutMs?: number +} + +/** + * 一次性命令在途时的控制入口。 + * + * `run()` 本身只返回终态,长驻的 `supervise()` 才有句柄;更新流程需要在 `bootstrap` + * 跑到一半时下发 stdin `cancel`(Runtime 保证克隆未完成时保留旧 `repo/`),所以这里 + * 把同一个会话的控制通道以回调形式交出去,握手成功后回调一次。 + */ +export interface RuntimeRunControl { + readonly pid: number | undefined + /** 下发一条控制命令,返回本次生成的 commandId。 */ + sendControl(command: RuntimeControlKind): string + /** 请求取消,返回 commandId。 */ + cancel(): string + /** 强制结束 Runtime 进程,只在兜底路径使用。 */ + kill(signal?: NodeJS.Signals): void +} + +export interface RuntimeRunOptions { + onEvent?: (event: RuntimeEvent) => void + onProgress?: (event: RuntimeProgressEvent) => void + onState?: (event: RuntimeStateEvent) => void + onLog?: (event: RuntimeLogEvent) => void + onWarning?: (event: RuntimeWarningEvent) => void + /** Runtime 输出的 error 事件(不是调用侧错误)。 */ + onRuntimeError?: (event: RuntimeErrorEvent) => void + /** NDJSON 行解析失败。默认会让本次调用直接失败。 */ + onProtocolError?: (error: RuntimeClientError) => void + handshakeTimeoutMs?: number + /** 握手成功后再出现坏行时不再中断本次调用,只记录并回调。默认 false。 */ + tolerateProtocolErrors?: boolean + /** 握手成功后回调一次,交出本次调用的控制入口;调用失败于握手阶段时不会被调用。 */ + onStarted?: (control: RuntimeRunControl) => void +} + +/** 单个 operationId 下按流分组、保序的日志行。 */ +export interface RuntimeLogBucket { + stdout: string[] + stderr: string[] + /** stream 既不是 stdout 也不是 stderr 时的兜底分组。 */ + other: string[] +} + +export type RuntimeLogsByOperation = Record + +export interface RuntimeRunResult { + hello: RuntimeHelloEvent + result: RuntimeResultEvent + /** `result.success`,避免调用方重复取值。 */ + success: boolean + /** `result.code`,成功时为 `OK`。精确原因只能读它,不能读退出码。 */ + code: RuntimeCode + events: RuntimeEvent[] + warnings: RuntimeWarningEvent[] + errors: RuntimeErrorEvent[] + /** 按 operationId 聚合的 log 事件。 */ + logs: RuntimeLogsByOperation + /** 握手后被容忍的坏行;默认配置下不会有,因为坏行会直接让调用失败。 */ + protocolErrors: RuntimeClientError[] + exitCode: number | null + signal: NodeJS.Signals | null + /** Runtime 自身的 stderr 诊断输出。 */ + stderr: string + /** 实际使用的参数数组,便于排查。 */ + argv: string[] + durationMs: number +} + +export interface RuntimeSuperviseOptions extends RuntimeRunOptions { + /** `managed` 或 `development`;Runtime 要求显式指定,不提供默认值。 */ + mode: 'managed' | 'development' + /** development 模式的源码目录。 */ + repo?: string +} + +export interface RuntimeShutdownOptions { + /** 等待最终 result 与进程退出的超时,超时才 kill。 */ + timeoutMs?: number +} + +/** `backend supervise` 的长驻句柄。 */ +export interface RuntimeSuperviseHandle { + readonly hello: RuntimeHelloEvent + readonly pid: number | undefined + readonly capabilities: RuntimeCapability[] + /** 最终 result 与进程退出后 resolve;未拿到 result 就退出则 reject。 */ + readonly completion: Promise + /** 订阅全部事件,返回取消订阅函数。 */ + onEvent(listener: (event: RuntimeEvent) => void): () => void + /** 下发一条控制命令,返回本次生成的 commandId。 */ + sendControl(command: RuntimeControlKind): string + /** 请求一次只读状态快照,返回 commandId。 */ + status(): string + /** 请求取消,返回 commandId。 */ + cancel(): string + /** 发 shutdown 后等待最终 result 与进程退出,超时才 kill。 */ + shutdown(options?: RuntimeShutdownOptions): Promise + /** 强制结束 Runtime 进程,只在兜底路径使用。 */ + kill(signal?: NodeJS.Signals): void +} + +/** RuntimeClient 上的事件名。刻意不用 `error`,避免 EventEmitter 的抛异常语义。 */ +export interface RuntimeClientEventMap { + event: [RuntimeEvent] + progress: [RuntimeProgressEvent] + state: [RuntimeStateEvent] + log: [RuntimeLogEvent] + warning: [RuntimeWarningEvent] + 'runtime-error': [RuntimeErrorEvent] + result: [RuntimeResultEvent] + 'protocol-error': [RuntimeClientError] +} + +// ==================== 工具函数 ==================== + +const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' +const ULID_TIMESTAMP_LENGTH = 10 +const ULID_RANDOM_LENGTH = 16 + +/** + * 生成规范 ULID 作为控制命令的 commandId。 + * + * Runtime 用 `validOperationID` 校验 commandId:必须是 26 位 Crockford base32、 + * 首字符不大于 `7`。架构设计文档只写了「调用方生成的唯一 id」,实际不能用 UUID。 + */ +export function createCommandId(now: number = Date.now()): string { + let timestamp = '' + let remaining = Math.max(0, Math.floor(now)) + for (let i = 0; i < ULID_TIMESTAMP_LENGTH; i += 1) { + timestamp = ULID_ALPHABET[remaining % 32] + timestamp + remaining = Math.floor(remaining / 32) + } + + // 256 是 32 的整数倍,取模不引入偏置。 + const entropy = randomBytes(ULID_RANDOM_LENGTH) + let random = '' + for (let i = 0; i < ULID_RANDOM_LENGTH; i += 1) { + random += ULID_ALPHABET[entropy[i] % 32] + } + + return timestamp + random +} + +/** 序列化一条 stdin 控制命令,末尾必须带换行符。 */ +export function serializeControlCommand(command: RuntimeControlCommand): string { + return `${JSON.stringify(command)}\n` +} + +/** 拼装完整参数数组:全局选项在前,子命令在后。 */ +export function buildRuntimeArgs(options: RuntimeClientOptions, command: string[]): string[] { + const args = [ + '--app-root', + options.appRoot, + '--output', + 'ndjson', + '--protocol', + String(RUNTIME_PROTOCOL_VERSION), + ] + + for (const mirror of options.mirrors ?? []) { + args.push('--mirror', `${mirror.kind}=${mirror.key}`) + } + if (options.mirrorOnly) { + args.push('--mirror-only') + } + if (options.offline) { + args.push('--offline') + } + + return [...args, ...command] +} + +/** + * 把启动阶段采集到的两路输出拼成一整块展示文本。 + * + * 与 `backendService.ts` 的同名逻辑保持完全一致的展示格式,后续替换后端服务时沿用。 + */ +export function formatStartupLogs( + stdoutLines: readonly string[], + stderrLines: readonly string[] +): string | undefined { + const sections: string[] = [] + const stdout = stdoutLines.join('\n').trimEnd() + const stderr = stderrLines.join('\n').trimEnd() + + if (stdout) { + sections.push(`[stdout]\n${stdout}`) + } + + if (stderr) { + sections.push(`[stderr]\n${stderr}`) + } + + return sections.length > 0 ? sections.join('\n\n') : undefined +} + +/** 按 operationId 聚合 log 事件,stdout / stderr 分组且各自保序。 */ +export function collectRuntimeLogs(events: readonly RuntimeEvent[]): RuntimeLogsByOperation { + const logs: RuntimeLogsByOperation = {} + for (const event of events) { + if (event.type !== 'log') continue + const bucket = (logs[event.operationId] ??= { stdout: [], stderr: [], other: [] }) + if (event.stream === 'stdout') { + bucket.stdout.push(event.message) + } else if (event.stream === 'stderr') { + bucket.stderr.push(event.message) + } else { + bucket.other.push(event.message) + } + } + return logs +} + +/** + * 从 `state` / `result` 事件的 details 中读取后端基地址。 + * + * 契约 v1 固定为 `http://127.0.0.1:36163`,但调用方必须消费 Runtime 下发的值, + * 不能自行假定 localhost 或端口。 + */ +export function readRuntimeBaseUrl(details: Record): string | undefined { + const baseUrl = details.baseUrl + return typeof baseUrl === 'string' && baseUrl.length > 0 ? baseUrl : undefined +} + +function mergeEnv(overrides?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env } + for (const [key, value] of Object.entries(overrides ?? {})) { + if (value === undefined) { + delete env[key] + continue + } + env[key] = value + } + return env +} + +// ==================== 会话 ==================== + +interface SessionOptions extends RuntimeRunOptions { + /** 握手完成后遇到坏行是否仍视为致命错误。 */ + fatalProtocolErrorsAfterHello: boolean +} + +/** 一次 Runtime 调用的内部状态机,run 与 supervise 共用。 */ +class RuntimeSession { + readonly argv: string[] + readonly child: ChildProcessWithoutNullStreams + readonly hello: Promise + readonly completion: Promise + + private readonly listeners = new Set<(event: RuntimeEvent) => void>() + private readonly stdoutStream = new NdjsonEventStream() + private readonly events: RuntimeEvent[] = [] + private readonly warnings: RuntimeWarningEvent[] = [] + private readonly errors: RuntimeErrorEvent[] = [] + private readonly protocolErrors: RuntimeClientError[] = [] + private readonly stderrChunks: string[] = [] + private readonly startedAt = Date.now() + + private helloEvent: RuntimeHelloEvent | undefined + private resultEvent: RuntimeResultEvent | undefined + private failure: RuntimeClientError | undefined + private settled = false + private handshakeTimer: NodeJS.Timeout | undefined + private resultSettleTimer: NodeJS.Timeout | undefined + private failSettleTimer: NodeJS.Timeout | undefined + + private resolveHello!: (value: RuntimeHelloEvent) => void + private rejectHello!: (reason: unknown) => void + private resolveCompletion!: (value: RuntimeRunResult) => void + private rejectCompletion!: (reason: unknown) => void + + constructor( + private readonly clientOptions: RuntimeClientOptions, + command: string[], + private readonly options: SessionOptions, + private readonly emitter: EventEmitter + ) { + this.argv = buildRuntimeArgs(clientOptions, command) + + this.hello = new Promise((resolve, reject) => { + this.resolveHello = resolve + this.rejectHello = reject + }) + this.completion = new Promise((resolve, reject) => { + this.resolveCompletion = resolve + this.rejectCompletion = reject + }) + // hello 与 completion 会同时失败,而 run() 只 await 其中一个; + // 这里各挂一个空 catch 标记为已处理,避免 unhandled rejection 警告。 + this.hello.catch(() => undefined) + this.completion.catch(() => undefined) + + if (!fs.existsSync(clientOptions.runtimePath)) { + throw new RuntimeClientError( + 'RUNTIME_NOT_FOUND', + `找不到 Runtime 可执行文件:${clientOptions.runtimePath}`, + { runtimePath: clientOptions.runtimePath, argv: this.argv } + ) + } + + logger.debug(`启动 Runtime:${clientOptions.runtimePath} ${this.argv.join(' ')}`) + + this.child = spawn(clientOptions.runtimePath, this.argv, { + cwd: clientOptions.cwd, + env: mergeEnv(clientOptions.env), + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }) as ChildProcessWithoutNullStreams + + this.child.stdout?.setEncoding('utf8') + this.child.stderr?.setEncoding('utf8') + this.child.stdout?.on('data', chunk => this.consumeStdout(chunk)) + this.child.stderr?.on('data', chunk => this.stderrChunks.push(String(chunk))) + this.child.stdin?.on('error', error => { + logger.warn(`向 Runtime stdin 写入失败:${String(error)}`) + }) + this.child.on('error', error => this.onSpawnError(error)) + this.child.on('close', (code, signal) => this.onClose(code, signal)) + + const timeoutMs = + options.handshakeTimeoutMs ?? clientOptions.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS + this.handshakeTimer = setTimeout(() => { + this.fail( + new RuntimeClientError( + 'RUNTIME_HANDSHAKE_TIMEOUT', + `等待 Runtime hello 事件超过 ${timeoutMs}ms`, + { runtimePath: clientOptions.runtimePath, argv: this.argv, stderr: this.stderr() } + ) + ) + }, timeoutMs) + this.handshakeTimer.unref?.() + } + + addListener(listener: (event: RuntimeEvent) => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + sendControl(command: RuntimeControlKind): string { + const commandId = createCommandId() + const payload: RuntimeControlCommand = { + protocol: RUNTIME_PROTOCOL_VERSION, + command, + commandId, + } + + const stdin = this.child.stdin + if (!stdin || stdin.destroyed || stdin.writableEnded) { + logger.warn(`Runtime stdin 已关闭,控制命令 ${command} 未能下发`) + return commandId + } + + stdin.write(serializeControlCommand(payload)) + logger.debug(`已下发控制命令 ${command},commandId=${commandId}`) + return commandId + } + + kill(signal?: NodeJS.Signals): void { + if (this.child.exitCode === null && !this.child.killed) { + this.child.kill(signal) + } + } + + private stderr(): string { + return this.stderrChunks.join('') + } + + private consumeStdout(chunk: string | Buffer): void { + for (const item of this.stdoutStream.push(chunk)) { + this.consumeItem(item) + } + } + + private consumeItem(item: NdjsonItem): void { + if (item.kind === 'unknown') { + logger.debug(`忽略未知类型的 Runtime 事件:${item.line}`) + return + } + + if (item.kind === 'error') { + this.onProtocolError(item.error) + return + } + + this.dispatch(item.event) + } + + private onProtocolError(error: RuntimeClientError): void { + this.protocolErrors.push(error) + logger.error(`Runtime NDJSON 解析失败:${error.message}`) + this.options.onProtocolError?.(error) + this.emitter.emit('protocol-error', error) + + if (!this.helloEvent || this.options.fatalProtocolErrorsAfterHello) { + this.fail(error) + } + } + + private dispatch(event: RuntimeEvent): void { + this.events.push(event) + + switch (event.type) { + case 'hello': + this.onHello(event) + break + case 'progress': + this.options.onProgress?.(event) + this.emitter.emit('progress', event) + break + case 'state': + this.options.onState?.(event) + this.emitter.emit('state', event) + break + case 'log': + this.options.onLog?.(event) + this.emitter.emit('log', event) + break + case 'warning': + this.warnings.push(event) + this.options.onWarning?.(event) + this.emitter.emit('warning', event) + break + case 'error': + this.errors.push(event) + this.options.onRuntimeError?.(event) + this.emitter.emit('runtime-error', event) + break + case 'result': + this.onResult(event) + break + } + + this.options.onEvent?.(event) + this.emitter.emit('event', event) + for (const listener of this.listeners) { + listener(event) + } + } + + private onHello(event: RuntimeHelloEvent): void { + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = undefined + } + + if (event.protocol !== RUNTIME_PROTOCOL_VERSION) { + this.fail( + new RuntimeClientError( + 'RUNTIME_PROTOCOL_MISMATCH', + `Runtime 握手协议版本为 ${event.protocol},本程序要求 ${RUNTIME_PROTOCOL_VERSION}`, + { + actualProtocol: event.protocol, + expectedProtocol: RUNTIME_PROTOCOL_VERSION, + runtimePath: this.clientOptions.runtimePath, + argv: this.argv, + } + ) + ) + return + } + + this.helloEvent = event + logger.info( + `Runtime ${event.runtimeVersion} 已握手,命令 ${event.command},能力 [${event.capabilities.join(', ')}]` + ) + this.resolveHello(event) + } + + private onResult(event: RuntimeResultEvent): void { + this.resultEvent = event + this.emitter.emit('result', event) + // 协议规定 result 之后不再有任何事件,进程应随即退出;给一段宽限再兜底 kill。 + this.resultSettleTimer = setTimeout(() => { + logger.warn('Runtime 输出 result 后未按期退出,强制结束进程') + this.kill() + }, DEFAULT_RESULT_SETTLE_TIMEOUT_MS) + this.resultSettleTimer.unref?.() + } + + private onSpawnError(error: NodeJS.ErrnoException): void { + const code = error.code === 'ENOENT' ? 'RUNTIME_NOT_FOUND' : 'RUNTIME_SPAWN_FAILED' + this.fail( + new RuntimeClientError( + code, + `启动 Runtime 失败:${error.message}`, + { runtimePath: this.clientOptions.runtimePath, argv: this.argv, stderr: this.stderr() }, + { cause: error } + ) + ) + } + + private onClose(exitCode: number | null, signal: NodeJS.Signals | null): void { + for (const item of this.stdoutStream.flush()) { + this.consumeItem(item) + } + + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = undefined + } + if (this.resultSettleTimer) { + clearTimeout(this.resultSettleTimer) + this.resultSettleTimer = undefined + } + if (this.failSettleTimer) { + clearTimeout(this.failSettleTimer) + this.failSettleTimer = undefined + } + + if (this.settled) return + + if (this.failure) { + this.settle(this.failure, exitCode, signal) + return + } + + if (this.helloEvent && this.resultEvent) { + this.settled = true + this.resolveCompletion(this.buildResult(this.helloEvent, this.resultEvent, exitCode, signal)) + return + } + + // 参数解析失败或协议不匹配时 Runtime 不承诺 hello/result,只有 stderr 与退出码。 + // 实测 `--protocol 2` 就是 stdout 全空、stderr 一行诊断、退出码 10。 + const mismatched = !this.helloEvent && exitCode === RUNTIME_EXIT_CODES.protocolMismatch + const error = mismatched + ? new RuntimeClientError( + 'RUNTIME_PROTOCOL_MISMATCH', + `Runtime 以协议不兼容退出(退出码 ${exitCode})`, + { + exitCode, + signal, + stderr: this.stderr(), + expectedProtocol: RUNTIME_PROTOCOL_VERSION, + runtimePath: this.clientOptions.runtimePath, + argv: this.argv, + } + ) + : new RuntimeClientError( + 'RUNTIME_EXITED_UNEXPECTEDLY', + `Runtime 未输出最终 result 就退出(退出码 ${exitCode}${signal ? `,信号 ${signal}` : ''})`, + { + exitCode, + signal, + stderr: this.stderr(), + runtimePath: this.clientOptions.runtimePath, + argv: this.argv, + } + ) + + this.settle(error, exitCode, signal) + } + + /** 记录致命错误并结束进程;真正 reject 发生在进程 close 时,以便带上退出码。 */ + private fail(error: RuntimeClientError): void { + if (this.settled || this.failure) return + this.failure = error + if (this.handshakeTimer) { + clearTimeout(this.handshakeTimer) + this.handshakeTimer = undefined + } + this.rejectHello(error) + this.kill() + if (this.settled) return + + // 进程若不响应结束信号,close 可能迟迟不来;兜底收敛避免调用方永久挂起。 + this.failSettleTimer = setTimeout(() => { + this.settle(error, this.child.exitCode, this.child.signalCode) + }, FAIL_SETTLE_GRACE_MS) + this.failSettleTimer.unref?.() + } + + private settle( + error: RuntimeClientError, + exitCode: number | null, + signal: NodeJS.Signals | null + ) { + if (this.settled) return + this.settled = true + if (error.details.exitCode === undefined) { + error.details.exitCode = exitCode + error.details.signal = signal + error.details.stderr = error.details.stderr || this.stderr() + } + this.rejectHello(error) + this.rejectCompletion(error) + } + + private buildResult( + hello: RuntimeHelloEvent, + result: RuntimeResultEvent, + exitCode: number | null, + signal: NodeJS.Signals | null + ): RuntimeRunResult { + return { + hello, + result, + success: result.success, + code: result.code, + events: this.events, + warnings: this.warnings, + errors: this.errors, + logs: collectRuntimeLogs(this.events), + protocolErrors: this.protocolErrors, + exitCode, + signal, + stderr: this.stderr(), + argv: this.argv, + durationMs: Date.now() - this.startedAt, + } + } +} + +// ==================== 客户端 ==================== + +/** + * Runtime 客户端。 + * + * 除了每次调用可传的回调,实例本身也是 EventEmitter,转发 `event`、`progress`、 + * `state`、`log`、`warning`、`runtime-error`、`result` 与 `protocol-error`。 + * 刻意不使用 `error` 事件名,避免无监听者时 EventEmitter 直接抛出。 + */ +export class RuntimeClient extends EventEmitter { + constructor(private readonly options: RuntimeClientOptions) { + super() + } + + get runtimePath(): string { + return this.options.runtimePath + } + + get appRoot(): string { + return this.options.appRoot + } + + /** + * 执行一次性命令并等待终态 `result`。 + * + * @param command 子命令与其参数,例如 `['workspace', 'sync', '--version', 'v5.5.0']`。 + * @throws {RuntimeClientError} 见 `RuntimeClientErrorCode`。Runtime 自己报告的失败 + * 不会抛异常,而是以 `result.success === false` 返回,由调用方读 `result.code`。 + */ + async run(command: string[], options: RuntimeRunOptions = {}): Promise { + const session = new RuntimeSession( + this.options, + command, + { + ...options, + fatalProtocolErrorsAfterHello: !options.tolerateProtocolErrors, + }, + this + ) + + await session.hello + options.onStarted?.({ + pid: session.child.pid, + sendControl: control => session.sendControl(control), + cancel: () => session.sendControl('cancel'), + kill: signal => session.kill(signal), + }) + return session.completion + } + + /** + * 启动 `backend supervise` 长驻形态,握手成功后返回句柄。 + * + * 句柄的 `completion` 在最终 `result` 与进程退出后 resolve。握手之后出现的坏行 + * 不会中断被监督的后端,只记录并回调,避免为一行脏输出杀掉正在运行的后端。 + */ + async supervise(options: RuntimeSuperviseOptions): Promise { + const command = ['backend', 'supervise', '--mode', options.mode] + if (options.repo) { + command.push('--repo', options.repo) + } + + const session = new RuntimeSession( + this.options, + command, + { + ...options, + fatalProtocolErrorsAfterHello: options.tolerateProtocolErrors === false, + }, + this + ) + + const hello = await session.hello + let shutdownRequested = false + + return { + hello, + pid: session.child.pid, + capabilities: hello.capabilities, + completion: session.completion, + onEvent: listener => session.addListener(listener), + sendControl: command => session.sendControl(command), + status: () => session.sendControl('status'), + cancel: () => session.sendControl('cancel'), + kill: signal => session.kill(signal), + shutdown: async ({ timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS } = {}) => { + if (!shutdownRequested) { + shutdownRequested = true + session.sendControl('shutdown') + } + + const timer = setTimeout(() => { + logger.warn(`Runtime 在 ${timeoutMs}ms 内未完成关闭,强制结束进程`) + session.kill() + }, timeoutMs) + timer.unref?.() + + try { + return await session.completion + } finally { + clearTimeout(timer) + } + }, + } + } +} diff --git a/frontend/electron/services/runtime/index.ts b/frontend/electron/services/runtime/index.ts new file mode 100644 index 000000000..9a93bbda0 --- /dev/null +++ b/frontend/electron/services/runtime/index.ts @@ -0,0 +1,124 @@ +/** + * AUTO-MAS Runtime 客户端 - 统一导出 + * + * 使用示例: + * + * ```typescript + * import { createRuntimeClient } from './runtime' + * + * // 遥测开关(AUTO_MAS_TELEMETRY)由 createRuntimeClient 统一注入,不需要调用方自己判断; + * // 显式传 env 仍可覆盖它。 + * const client = createRuntimeClient({ runtimePath, appRoot }) + * + * const outcome = await client.run(['doctor'], { + * onProgress: event => console.log(event.stage, event.status, event.message), + * }) + * + * if (!outcome.success) { + * // 精确原因读 result.code,不要读退出码,也不要解析 message + * console.error(outcome.code, outcome.result.remediation) + * } + * ``` + */ + +export { + DEFAULT_HANDSHAKE_TIMEOUT_MS, + DEFAULT_RESULT_SETTLE_TIMEOUT_MS, + DEFAULT_SHUTDOWN_TIMEOUT_MS, + RuntimeClient, + RuntimeClientEventMap, + RuntimeClientOptions, + RuntimeLogBucket, + RuntimeLogsByOperation, + RuntimeMirrorSelection, + RuntimeRunControl, + RuntimeRunOptions, + RuntimeRunResult, + RuntimeShutdownOptions, + RuntimeSuperviseHandle, + RuntimeSuperviseOptions, + buildRuntimeArgs, + collectRuntimeLogs, + createCommandId, + formatStartupLogs, + readRuntimeBaseUrl, + serializeControlCommand, +} from './client' + +export { + RUNTIME_EXECUTABLE_NAME, + RUNTIME_EXE_ENV, + RUNTIME_MODE_ENV, + PersistedRuntimeLaunchMode, + RuntimeDisabledLaunchConfig, + RuntimeLaunchConfig, + RuntimeLaunchMode, + RuntimeLaunchModeResolution, + RuntimeLaunchModeSource, + RuntimeSupervisedLaunchConfig, + isPersistedRuntimeLaunchMode, + resolveRuntimeExecutable, + resolveRuntimeLaunchConfig, + resolveRuntimeLaunchMode, + resolveRuntimeLaunchModeDetail, +} from './launchConfig' + +export { NdjsonEventStream, NdjsonItem, parseRuntimeEventLine } from './ndjson' + +export { RUNTIME_TELEMETRY_ENV, buildRuntimeEnv } from './runtimeEnv' + +export { createRuntimeClient } from './runtimeClientFactory' + +export { + RUNTIME_CAPABILITIES, + RUNTIME_CLIENT_ERROR_DEFINITIONS, + RUNTIME_ERROR_CODES, + RUNTIME_EXIT_CODES, + RUNTIME_OK_CODE, + RUNTIME_PROGRESS_STATUSES, + RUNTIME_PROTOCOL_VERSION, + RUNTIME_REMEDIATIONS, + RUNTIME_STAGES, + RUNTIME_STATE_STATUSES, + RuntimeCapability, + RuntimeClientError, + RuntimeClientErrorCode, + RuntimeClientErrorDefinition, + RuntimeClientErrorDetails, + RuntimeCode, + RuntimeControlCommand, + RuntimeControlKind, + RuntimeErrorDefinition, + RuntimeErrorEvent, + RuntimeEvent, + RuntimeEventCommon, + RuntimeEventType, + RuntimeHelloEvent, + RuntimeKnownCapability, + RuntimeKnownErrorCode, + RuntimeKnownProgressStatus, + RuntimeKnownRemediation, + RuntimeKnownStage, + RuntimeKnownStateStatus, + RuntimeLogEvent, + RuntimeProgressEvent, + RuntimeProgressStatus, + RuntimeRemediation, + RuntimeResultEvent, + RuntimeResultStatus, + RuntimeStage, + RuntimeStateEvent, + RuntimeStateStatus, + RuntimeWarningEvent, + RuntimeWarningSummary, + isKnownRuntimeCapability, + isKnownRuntimeCode, + isKnownRuntimeProgressStatus, + isKnownRuntimeRemediation, + isKnownRuntimeStage, + isKnownRuntimeStateStatus, + isRetryableRuntimeCode, + isRuntimeClientError, + isRuntimeResultEvent, + lookupRuntimeErrorDefinition, +} from './protocol' diff --git a/frontend/electron/services/runtime/launchConfig.test.ts b/frontend/electron/services/runtime/launchConfig.test.ts new file mode 100644 index 000000000..31195c5e8 --- /dev/null +++ b/frontend/electron/services/runtime/launchConfig.test.ts @@ -0,0 +1,295 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + RUNTIME_EXE_ENV, + RUNTIME_MODE_ENV, + isPersistedRuntimeLaunchMode, + resolveRuntimeExecutable, + resolveRuntimeLaunchConfig, + resolveRuntimeLaunchMode, + resolveRuntimeLaunchModeDetail, +} from './launchConfig' + +const warn = vi.fn() + +vi.mock('../logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: (...args: unknown[]) => warn(...args), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) + +vi.mock('electron', () => ({ app: { isPackaged: false } })) + +// vi.mock 的 electron 桩是普通可变对象,isPackaged 直接在测试间改写它来切换构建默认值。 +const { app: electronApp } = await import('electron') + +function setPackaged(packaged: boolean): void { + ;(electronApp as unknown as { isPackaged: boolean }).isPackaged = packaged +} + +// 一定存在的可执行文件,用来代替尚未捆绑的 auto-mas-runtime.exe。 +const EXISTING_EXE = process.execPath + +/** 每个用例一个独立目录,避免真实文件系统读写互相污染。 */ +let appRoot: string + +function writePersistedLaunchMode(value: unknown): void { + const configDir = path.join(appRoot, 'config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'frontend_config.json'), + JSON.stringify({ Runtime: { LaunchMode: value } }), + 'utf8' + ) +} + +beforeEach(() => { + setPackaged(false) + warn.mockClear() + delete process.env[RUNTIME_MODE_ENV] + delete process.env[RUNTIME_EXE_ENV] + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-mas-launch-config-')) +}) + +afterEach(() => { + delete process.env[RUNTIME_MODE_ENV] + delete process.env[RUNTIME_EXE_ENV] + fs.rmSync(appRoot, { recursive: true, force: true }) +}) + +describe('resolveRuntimeLaunchModeDetail:优先级矩阵', () => { + it('三级都未设置时落到构建默认值(未打包 → off)', () => { + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'off', source: 'default' }) + }) + + it('环境变量覆盖持久化设置', () => { + writePersistedLaunchMode('managed') + process.env[RUNTIME_MODE_ENV] = 'development' + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ + mode: 'development', + source: 'env', + }) + expect(warn).not.toHaveBeenCalled() + }) + + it('未设环境变量时持久化设置覆盖构建默认值', () => { + writePersistedLaunchMode('managed') + setPackaged(false) // 构建默认值本应是 off,验证确实是设置项在生效 + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'managed', source: 'setting' }) + }) + + it('持久化设置为 auto 时落到构建默认值', () => { + writePersistedLaunchMode('auto') + setPackaged(true) + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'managed', source: 'default' }) + }) + + it('环境变量非法值 warn 后按持久化设置处理', () => { + writePersistedLaunchMode('development') + process.env[RUNTIME_MODE_ENV] = 'supervised' + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ + mode: 'development', + source: 'setting', + }) + expect(warn).toHaveBeenCalledOnce() + expect(String(warn.mock.calls[0][0])).toContain('supervised') + }) + + it('持久化设置非法值 warn 后按构建默认值处理', () => { + writePersistedLaunchMode('supervised') + setPackaged(true) + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'managed', source: 'default' }) + expect(warn).toHaveBeenCalledOnce() + expect(String(warn.mock.calls[0][0])).toContain('supervised') + }) + + it('环境变量与持久化设置都非法时,两级各 warn 一次后落到构建默认值', () => { + writePersistedLaunchMode('nonsense') + process.env[RUNTIME_MODE_ENV] = 'nonsense' + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'off', source: 'default' }) + expect(warn).toHaveBeenCalledTimes(2) + }) + + it('持久化设置文件不存在时直接落到构建默认值,不记 warning', () => { + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'off', source: 'default' }) + expect(warn).not.toHaveBeenCalled() + }) + + it('持久化设置文件 JSON 损坏时记 warning 并落到构建默认值', () => { + const configDir = path.join(appRoot, 'config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'frontend_config.json'), '{not json', 'utf8') + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'off', source: 'default' }) + expect(warn).toHaveBeenCalledOnce() + }) + + it('空串环境变量按未设置处理,不记 warning,落到持久化设置', () => { + writePersistedLaunchMode('managed') + process.env[RUNTIME_MODE_ENV] = ' ' + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'managed', source: 'setting' }) + expect(warn).not.toHaveBeenCalled() + }) + + it('三个合法环境变量取值都能解析,大小写与空白不敏感', () => { + process.env[RUNTIME_MODE_ENV] = 'off' + expect(resolveRuntimeLaunchMode(appRoot)).toBe('off') + + process.env[RUNTIME_MODE_ENV] = ' development ' + expect(resolveRuntimeLaunchMode(appRoot)).toBe('development') + + process.env[RUNTIME_MODE_ENV] = 'MANAGED' + expect(resolveRuntimeLaunchMode(appRoot)).toBe('managed') + + expect(warn).not.toHaveBeenCalled() + }) +}) + +describe('resolveRuntimeLaunchModeDetail:构建默认值的四种组合', () => { + it('打包 + 已捆绑 Runtime → managed', () => { + setPackaged(true) + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'managed', source: 'default' }) + }) + + it('打包 + 未捆绑 Runtime → off', () => { + setPackaged(true) + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'off', source: 'default' }) + }) + + it('未打包 + 已捆绑 Runtime → off(源码开发默认仍走旧链路)', () => { + setPackaged(false) + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'off', source: 'default' }) + }) + + it('未打包 + 未捆绑 Runtime → off', () => { + setPackaged(false) + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ mode: 'off', source: 'default' }) + }) +}) + +describe('isPersistedRuntimeLaunchMode', () => { + it('接受 auto/off/development/managed,拒绝其它取值与非字符串', () => { + expect(isPersistedRuntimeLaunchMode('auto')).toBe(true) + expect(isPersistedRuntimeLaunchMode('off')).toBe(true) + expect(isPersistedRuntimeLaunchMode('development')).toBe(true) + expect(isPersistedRuntimeLaunchMode('managed')).toBe(true) + expect(isPersistedRuntimeLaunchMode('supervised')).toBe(false) + expect(isPersistedRuntimeLaunchMode(undefined)).toBe(false) + expect(isPersistedRuntimeLaunchMode(123)).toBe(false) + }) +}) + +describe('持久化设置读写往返', () => { + it('set 写入的形状能被 resolveRuntimeLaunchModeDetail 读回(模拟 main.ts 的 loadConfig/saveConfig)', () => { + // main.ts 的 saveConfig 是整份 AppConfig 回写,这里只关心 Runtime 节点,其余字段不影响解析。 + const wholeConfig = { + UI: { IfShowTray: false }, + Start: { IfSelfStart: false }, + Update: { IfAutoUpdate: false }, + Function: { IfEnableTelemetry: true }, + Runtime: { LaunchMode: 'development' }, + } + const configDir = path.join(appRoot, 'config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync( + path.join(configDir, 'frontend_config.json'), + JSON.stringify(wholeConfig, null, 2), + 'utf8' + ) + + expect(resolveRuntimeLaunchModeDetail(appRoot)).toEqual({ + mode: 'development', + source: 'setting', + }) + }) +}) + +describe('resolveRuntimeExecutable', () => { + it('环境变量指向的文件存在时直接使用', () => { + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeExecutable()).toBe(EXISTING_EXE) + }) + + it('环境变量指向的文件不存在时记 warning,并因未捆绑而返回 null', () => { + process.env[RUNTIME_EXE_ENV] = 'D:\\nowhere\\auto-mas-runtime.exe' + + expect(resolveRuntimeExecutable()).toBeNull() + expect(warn).toHaveBeenCalledOnce() + }) + + it('未指定且安装包未捆绑时返回 null', () => { + expect(resolveRuntimeExecutable()).toBeNull() + }) +}) + +describe('resolveRuntimeLaunchConfig', () => { + it('off 模式不去定位可执行文件', () => { + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchConfig(appRoot)).toEqual({ + mode: 'off', + runtimePath: null, + appRoot, + }) + }) + + it('development 模式把当前 appRoot 作为 --repo', () => { + process.env[RUNTIME_MODE_ENV] = 'development' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchConfig(appRoot)).toEqual({ + mode: 'development', + runtimePath: EXISTING_EXE, + appRoot, + repo: appRoot, + }) + }) + + it('managed 模式不传 --repo', () => { + process.env[RUNTIME_MODE_ENV] = 'managed' + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchConfig(appRoot)).toEqual({ + mode: 'managed', + runtimePath: EXISTING_EXE, + appRoot, + repo: undefined, + }) + }) + + it('持久化设置也能驱动 resolveRuntimeLaunchConfig(不只是环境变量)', () => { + writePersistedLaunchMode('managed') + process.env[RUNTIME_EXE_ENV] = EXISTING_EXE + + expect(resolveRuntimeLaunchConfig(appRoot)).toEqual({ + mode: 'managed', + runtimePath: EXISTING_EXE, + appRoot, + repo: undefined, + }) + }) +}) diff --git a/frontend/electron/services/runtime/launchConfig.ts b/frontend/electron/services/runtime/launchConfig.ts new file mode 100644 index 000000000..ad0d89c1f --- /dev/null +++ b/frontend/electron/services/runtime/launchConfig.ts @@ -0,0 +1,226 @@ +/** + * Runtime 后端监督链路的灰度开关与可执行文件定位 + * + * 灰度期同时存在两条后端启动链路: + * - `off`(默认):Electron 自己 spawn `python.exe`,就绪靠健康检查,停止靠 scoped taskkill; + * - `development` / `managed`:交给 `auto-mas-runtime.exe backend supervise` 监督。 + * + * 一次生命周期只走一条链路:模式非 `off` 却找不到可执行文件时,按 `RUNTIME_NOT_FOUND` + * 失败并展示,绝不静默回退旧链路——否则用户会在不知情的情况下拿到另一套端口与关闭语义。 + * + * 灰度开关的来源分三级,优先级从高到低: + * 1. 环境变量 `AUTO_MAS_RUNTIME_MODE`; + * 2. 设置界面持久化的用户选择(`/config/frontend_config.json` 的 + * `Runtime.LaunchMode`,与 `main.ts` 的 `loadConfig()/saveConfig()` 同一份文件); + * 3. 构建默认值:打包安装且已捆绑 Runtime 时默认 `managed`,否则 `off`——即打包安装且带 + * Runtime 的用户默认走新链路,开发者跑源码默认仍走旧链路,除非显式设了环境变量。 + * + * 任一级取值非法都记 warning 后落到下一级,不再像早前只有环境变量一级时那样直接判 `off`。 + */ + +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +import { getLogger } from '../logger' + +const logger = getLogger('Runtime启动配置') + +/** 捆绑在安装包 resources 目录下的 Runtime 文件名。 */ +export const RUNTIME_EXECUTABLE_NAME = 'auto-mas-runtime.exe' + +/** 灰度开关的环境变量名。 */ +export const RUNTIME_MODE_ENV = 'AUTO_MAS_RUNTIME_MODE' + +/** 手动指定 Runtime 可执行文件路径的环境变量名。 */ +export const RUNTIME_EXE_ENV = 'AUTO_MAS_RUNTIME_EXE' + +/** + * 后端启动链路。 + * + * `development` 监督开发者自己的源码检出(要求 `/main.py`、`/pyproject.toml` + * 与已存在的 `/.venv`,Runtime 不创建它们);`managed` 监督 Runtime 自己维护的受管工作区。 + */ +export type RuntimeLaunchMode = 'off' | 'development' | 'managed' + +const RUNTIME_LAUNCH_MODES: readonly RuntimeLaunchMode[] = ['off', 'development', 'managed'] + +/** 持久化设置比运行时开关多一个哨兵值:`auto` 表示不覆盖,跟随构建默认值。 */ +export type PersistedRuntimeLaunchMode = RuntimeLaunchMode | 'auto' + +const PERSISTED_RUNTIME_LAUNCH_MODES: readonly PersistedRuntimeLaunchMode[] = [ + 'auto', + ...RUNTIME_LAUNCH_MODES, +] + +/** 最终生效值来自哪一级,供设置界面展示「当前由环境变量强制」之类的说明。 */ +export type RuntimeLaunchModeSource = 'env' | 'setting' | 'default' + +/** 一次解析的完整结果:生效模式 + 来源。 */ +export interface RuntimeLaunchModeResolution { + mode: RuntimeLaunchMode + source: RuntimeLaunchModeSource +} + +/** 灰度开关关闭时的定位信息:不去找可执行文件。 */ +export interface RuntimeDisabledLaunchConfig { + mode: 'off' + runtimePath: null + appRoot: string +} + +/** 走 Runtime 监督链路时的定位信息。 */ +export interface RuntimeSupervisedLaunchConfig { + mode: Exclude + /** 找不到可执行文件时为 null,由调用方按 `RUNTIME_NOT_FOUND` 处理。 */ + runtimePath: string | null + /** 传给 `--app-root`。 */ + appRoot: string + /** `development` 模式传给 `--repo`;`managed` 模式不传。 */ + repo?: string +} + +/** 一次启动所需的全部定位信息,按 `mode` 判别。 */ +export type RuntimeLaunchConfig = RuntimeDisabledLaunchConfig | RuntimeSupervisedLaunchConfig + +function isRuntimeLaunchMode(value: string): value is RuntimeLaunchMode { + return (RUNTIME_LAUNCH_MODES as readonly string[]).includes(value) +} + +/** 供 IPC 校验渲染进程传入值使用。 */ +export function isPersistedRuntimeLaunchMode(value: unknown): value is PersistedRuntimeLaunchMode { + return ( + typeof value === 'string' && + (PERSISTED_RUNTIME_LAUNCH_MODES as readonly string[]).includes(value) + ) +} + +/** 持久化设置文件路径,须与 `main.ts` 的 `loadConfig()`/`saveConfig()` 保持一致。 */ +function resolveSettingsPath(appRoot: string): string { + return path.join(appRoot, 'config', 'frontend_config.json') +} + +/** + * 读取持久化设置里的启动方式。 + * + * 文件不存在、字段缺失、类型不对或 JSON 损坏都视为「未设置」而不是报错——设置文件在用户 + * 从未碰过这一项时本就可能没有 `Runtime` 节点,这不是异常情况。 + */ +function readPersistedLaunchMode(appRoot: string): string | undefined { + try { + const settingsPath = resolveSettingsPath(appRoot) + if (!fs.existsSync(settingsPath)) return undefined + + const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + Runtime?: { LaunchMode?: unknown } + } + const value = parsed.Runtime?.LaunchMode + return typeof value === 'string' ? value : undefined + } catch (error) { + logger.warn( + `读取持久化的 Runtime 启动方式设置失败,改按构建默认值处理: ${ + error instanceof Error ? error.message : String(error) + }` + ) + return undefined + } +} + +/** 构建默认值:打包安装且已捆绑 Runtime 才默认切新链路,源码开发默认走旧链路。 */ +function resolveBuildDefaultLaunchMode(): RuntimeLaunchMode { + const packaged = Boolean(app?.isPackaged) + return packaged && resolveRuntimeExecutable() !== null ? 'managed' : 'off' +} + +/** + * 解析灰度开关,并带上生效来源。 + * + * 优先级:环境变量 `AUTO_MAS_RUNTIME_MODE` > 持久化的用户设置 > 构建默认值。任一级取值 + * 非法都记 warning 后落到下一级,而不是直接判 `off`——最终结果永远来自某一级的合法取值, + * 不会因为拼错一个单词就整体失效。 + */ +export function resolveRuntimeLaunchModeDetail(appRoot: string): RuntimeLaunchModeResolution { + const rawEnv = process.env[RUNTIME_MODE_ENV] + if (rawEnv !== undefined && rawEnv.trim() !== '') { + const normalizedEnv = rawEnv.trim().toLowerCase() + if (isRuntimeLaunchMode(normalizedEnv)) { + return { mode: normalizedEnv, source: 'env' } + } + logger.warn( + `${RUNTIME_MODE_ENV} 取值非法:${rawEnv},改按持久化设置处理(可选值:off/development/managed)` + ) + } + + const rawSetting = readPersistedLaunchMode(appRoot) + if (rawSetting !== undefined && rawSetting.trim() !== '') { + const normalizedSetting = rawSetting.trim().toLowerCase() + if (normalizedSetting !== 'auto') { + if (isRuntimeLaunchMode(normalizedSetting)) { + return { mode: normalizedSetting, source: 'setting' } + } + logger.warn(`持久化的 Runtime 启动方式设置非法:${rawSetting},改按构建默认值处理`) + } + // normalizedSetting === 'auto':用户显式选择跟随构建默认值,直接走下一级。 + } + + return { mode: resolveBuildDefaultLaunchMode(), source: 'default' } +} + +/** 只要最终生效模式时用这个;需要在界面上展示来源时用 `resolveRuntimeLaunchModeDetail`。 */ +export function resolveRuntimeLaunchMode(appRoot: string): RuntimeLaunchMode { + return resolveRuntimeLaunchModeDetail(appRoot).mode +} + +function isExistingFile(candidate: string): boolean { + try { + return fs.statSync(candidate).isFile() + } catch { + return false + } +} + +/** + * 定位 `auto-mas-runtime.exe`。 + * + * 优先用环境变量显式指定的路径,其次查安装包捆绑位置 `process.resourcesPath`。 + * 尚未捆绑时返回 null,由调用方转成 `RUNTIME_NOT_FOUND`。 + */ +export function resolveRuntimeExecutable(): string | null { + const configured = process.env[RUNTIME_EXE_ENV]?.trim() + if (configured) { + if (isExistingFile(configured)) { + return path.resolve(configured) + } + logger.warn(`${RUNTIME_EXE_ENV} 指向的文件不存在:${configured}`) + } + + // 非 Electron 环境(单元测试)下 resourcesPath 不存在。 + const resourcesPath = typeof process.resourcesPath === 'string' ? process.resourcesPath : '' + if (resourcesPath) { + const bundled = path.join(resourcesPath, RUNTIME_EXECUTABLE_NAME) + if (isExistingFile(bundled)) { + return bundled + } + } + + return null +} + +/** + * 汇总本次启动的模式与路径。 + * + * `development` 的 `--repo` 就是当前 appRoot:开发者跑的就是这份源码检出。 + */ +export function resolveRuntimeLaunchConfig(appRoot: string): RuntimeLaunchConfig { + const mode = resolveRuntimeLaunchMode(appRoot) + if (mode === 'off') { + return { mode, runtimePath: null, appRoot } + } + + return { + mode, + runtimePath: resolveRuntimeExecutable(), + appRoot, + repo: mode === 'development' ? appRoot : undefined, + } +} diff --git a/frontend/electron/services/runtime/ndjson.test.ts b/frontend/electron/services/runtime/ndjson.test.ts new file mode 100644 index 000000000..f89303dd2 --- /dev/null +++ b/frontend/electron/services/runtime/ndjson.test.ts @@ -0,0 +1,210 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +import { NdjsonEventStream, parseRuntimeEventLine } from './ndjson' +import { RuntimeClientError } from './protocol' + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), '__fixtures__') + +/** 夹具由本机构建的 auto-mas-runtime.exe 真实跑出来,不是手写的。 */ +function fixture(name: string): string { + return readFileSync(join(fixturesDir, name), 'utf8') +} + +function eventsOf(items: ReturnType) { + return items.flatMap(item => (item.kind === 'event' ? [item.event] : [])) +} + +describe('parseRuntimeEventLine', () => { + it('解析真实 version 输出的三条事件', () => { + const lines = fixture('version.ndjson').trim().split('\n') + const events = lines.map(line => parseRuntimeEventLine(line)) + + expect(events).toHaveLength(3) + const [hello, progress, result] = events + + expect(hello).toMatchObject({ + type: 'hello', + protocol: 1, + sequence: 1, + command: 'version', + runtimeVersion: 'dev', + capabilities: [], + }) + expect(progress).toMatchObject({ + type: 'progress', + stage: 'runtime.handshake', + status: 'succeeded', + }) + expect(result).toMatchObject({ + type: 'result', + success: true, + code: 'OK', + stage: 'runtime.handshake', + status: 'succeeded', + remediation: [], + }) + expect(result?.type === 'result' && result.details.protocolVersion).toBe(1) + }) + + it('解析真实 doctor 输出并保留 details 结构', () => { + const lines = fixture('doctor.ndjson').trim().split('\n') + const events = lines.map(line => parseRuntimeEventLine(line)) + + expect(events).toHaveLength(21) + expect(events.filter(event => event?.type === 'progress')).toHaveLength(19) + + const result = events.at(-1) + expect(result?.type).toBe('result') + if (result?.type !== 'result') throw new Error('最后一条应为 result') + expect(result.success).toBe(true) + expect(result.details.summary).toMatchObject({ total: 9, ok: 3, missing: 6, error: 0 }) + }) + + it('解析 supervise 的 hello 能力与失败 result', () => { + const lines = fixture('supervise-invalid-mode.ndjson').trim().split('\n') + const [hello, error, result] = lines.map(line => parseRuntimeEventLine(line)) + + expect(hello?.type === 'hello' && hello.capabilities).toEqual([ + 'stdin.cancel', + 'state.v1', + 'log.stream', + ]) + expect(error).toMatchObject({ + type: 'error', + code: 'INVALID_ARGUMENT', + stage: 'backend.spawn', + retryable: false, + remediation: ['run-doctor'], + details: { field: 'mode' }, + }) + // 失败 result 复述主错误的稳定四元组,调用方只消费终点事件即可。 + // status 这里是进度语义的 failed,而不是生命周期状态:Runtime 还没进入后端生命周期 + // 就因参数错误结束了。同一命令走到 managed 分支失败时该字段会是 backend_failed。 + expect(result).toMatchObject({ + type: 'result', + success: false, + code: 'INVALID_ARGUMENT', + status: 'failed', + retryable: false, + remediation: ['run-doctor'], + }) + }) + + it('解析取消场景里的 warning 与 result 汇总', () => { + const lines = fixture('cancelled-with-warning.ndjson').trim().split('\n') + const events = lines.map(line => parseRuntimeEventLine(line)) + const warning = events.find(event => event?.type === 'warning') + const result = events.at(-1) + + expect(warning).toMatchObject({ + code: 'INVALID_CONTROL_COMMAND', + retryable: false, + remediation: ['update-desktop'], + details: { reason: 'invalid_json' }, + }) + if (result?.type !== 'result') throw new Error('最后一条应为 result') + expect(result.code).toBe('OPERATION_CANCELLED') + expect(result.status).toBe('cancelled') + expect(result.details.warningCount).toBe(1) + expect(result.details.controlCommandId).toEqual(expect.any(String)) + }) + + it('坏 JSON 行抛出 RUNTIME_PROTOCOL_ERROR 并带上原始行', () => { + expect(() => parseRuntimeEventLine('{"protocol":1,')).toThrowError(RuntimeClientError) + + try { + parseRuntimeEventLine('not json at all') + throw new Error('应当抛出') + } catch (error) { + expect(error).toBeInstanceOf(RuntimeClientError) + const clientError = error as RuntimeClientError + expect(clientError.code).toBe('RUNTIME_PROTOCOL_ERROR') + expect(clientError.retryable).toBe(false) + expect(clientError.details.line).toBe('not json at all') + } + }) + + it('合法 JSON 但不是对象或缺少公共字段同样报协议错误', () => { + expect(() => parseRuntimeEventLine('[1,2,3]')).toThrowError(/不是 JSON 对象/) + expect(() => parseRuntimeEventLine('"hello"')).toThrowError(/不是 JSON 对象/) + expect(() => parseRuntimeEventLine('{"type":"hello"}')).toThrowError(/缺少 protocol 或 type/) + expect(() => parseRuntimeEventLine('{"protocol":1}')).toThrowError(/缺少 protocol 或 type/) + }) + + it('未知事件类型返回 undefined 而不是拒绝整条协议', () => { + expect(parseRuntimeEventLine('{"protocol":1,"type":"future-event"}')).toBeUndefined() + }) + + it('容器字段缺失时归一为空对象/空数组', () => { + const event = parseRuntimeEventLine( + '{"protocol":1,"type":"error","code":"INTERNAL_ERROR","stage":"doctor","message":"x"}' + ) + + expect(event).toMatchObject({ remediation: [], details: {}, retryable: false }) + }) +}) + +describe('NdjsonEventStream', () => { + it('跨 chunk 拼接半行', () => { + const [helloLine, progressLine] = fixture('version.ndjson').trim().split('\n') + const stream = new NdjsonEventStream() + + const half = helloLine.slice(0, 40) + expect(stream.push(half)).toEqual([]) + expect(stream.pending).toBe(half) + + const items = stream.push(`${helloLine.slice(40)}\n${progressLine}\n`) + const events = eventsOf(items) + + expect(events).toHaveLength(2) + expect(events[0].type).toBe('hello') + expect(events[1].type).toBe('progress') + expect(stream.pending).toBe('') + }) + + it('一次 chunk 内的多行与 CRLF 都能切开', () => { + const lines = fixture('version.ndjson').trim().split('\n') + const stream = new NdjsonEventStream() + + const events = eventsOf(stream.push(`${lines.join('\r\n')}\r\n`)) + + expect(events.map(event => event.type)).toEqual(['hello', 'progress', 'result']) + }) + + it('空行与纯空白行被跳过', () => { + const [helloLine] = fixture('version.ndjson').trim().split('\n') + const stream = new NdjsonEventStream() + + const items = stream.push(`\n\r\n \n${helloLine}\n\n`) + + expect(items).toHaveLength(1) + expect(items[0].kind).toBe('event') + }) + + it('坏行产生 error 条目但不影响后续行', () => { + const [helloLine, progressLine] = fixture('version.ndjson').trim().split('\n') + const stream = new NdjsonEventStream() + + const items = stream.push(`${helloLine}\n{"protocol":1,\n${progressLine}\n`) + + expect(items.map(item => item.kind)).toEqual(['event', 'error', 'event']) + const bad = items[1] + expect(bad.kind === 'error' && bad.error.code).toBe('RUNTIME_PROTOCOL_ERROR') + expect(bad.line).toBe('{"protocol":1,') + }) + + it('flush 处理结尾没有换行符的最后一行', () => { + const [helloLine] = fixture('version.ndjson').trim().split('\n') + const stream = new NdjsonEventStream() + + expect(stream.push(helloLine)).toEqual([]) + const flushed = stream.flush() + + expect(flushed).toHaveLength(1) + expect(flushed[0].kind).toBe('event') + expect(stream.flush()).toEqual([]) + }) +}) diff --git a/frontend/electron/services/runtime/ndjson.ts b/frontend/electron/services/runtime/ndjson.ts new file mode 100644 index 000000000..a5b9d2fdd --- /dev/null +++ b/frontend/electron/services/runtime/ndjson.ts @@ -0,0 +1,255 @@ +/** + * Runtime NDJSON 输出的逐行解析器 + * + * 只负责把子进程 stdout 的字节流切成行、把每行反序列化成协议事件。 + * 处理三件事:chunk 边界上的半行、空行、CRLF 换行。 + * 单行解析失败不会被吞掉,而是产生一条 `RUNTIME_PROTOCOL_ERROR` 条目交给调用方。 + */ + +import { + RuntimeClientError, + RuntimeErrorEvent, + RuntimeEvent, + RuntimeHelloEvent, + RuntimeLogEvent, + RuntimeProgressEvent, + RuntimeResultEvent, + RuntimeStateEvent, + RuntimeWarningEvent, +} from './protocol' + +/** 一次解析产出的条目。 */ +export type NdjsonItem = + | { kind: 'event'; event: RuntimeEvent; line: string } + /** 行本身合法但 `type` 不在协议 v1 的事件全集内,按「忽略未知」处理。 */ + | { kind: 'unknown'; line: string } + | { kind: 'error'; error: RuntimeClientError; line: string } + +const EVENT_TYPES = new Set(['hello', 'progress', 'state', 'log', 'warning', 'error', 'result']) + +function asRecord(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined +} + +function asString(value: unknown, fallback = ''): string { + return typeof value === 'string' ? value : fallback +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function asOptionalNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function asBoolean(value: unknown): boolean { + return value === true +} + +/** 容器字段协议上恒为对象/数组且不为 null,这里仍做一次防御性归一。 */ +function asDetails(value: unknown): Record { + return asRecord(value) ?? {} +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : [] +} + +/** + * 把一行 JSON 反序列化成协议事件。 + * + * @throws {RuntimeClientError} `RUNTIME_PROTOCOL_ERROR`——行不是 JSON 对象, + * 或缺少 `protocol`/`type` 这两个所有事件都必须携带的公共字段。 + */ +export function parseRuntimeEventLine(line: string): RuntimeEvent | undefined { + let decoded: unknown + try { + decoded = JSON.parse(line) + } catch (error) { + throw new RuntimeClientError( + 'RUNTIME_PROTOCOL_ERROR', + `Runtime 输出了无法解析为 JSON 的行:${truncate(line)}`, + { line }, + { cause: error } + ) + } + + const raw = asRecord(decoded) + if (!raw) { + throw new RuntimeClientError( + 'RUNTIME_PROTOCOL_ERROR', + `Runtime 输出的行不是 JSON 对象:${truncate(line)}`, + { line } + ) + } + + if (typeof raw.type !== 'string' || typeof raw.protocol !== 'number') { + throw new RuntimeClientError( + 'RUNTIME_PROTOCOL_ERROR', + `Runtime 事件缺少 protocol 或 type 字段:${truncate(line)}`, + { line } + ) + } + + if (!EVENT_TYPES.has(raw.type)) { + return undefined + } + + const common = { + protocol: raw.protocol, + operationId: asString(raw.operationId), + sequence: asNumber(raw.sequence), + timestamp: asString(raw.timestamp), + } + + switch (raw.type) { + case 'hello': + return { + ...common, + type: 'hello', + runtimeVersion: asString(raw.runtimeVersion), + command: asString(raw.command), + capabilities: asStringArray(raw.capabilities), + } satisfies RuntimeHelloEvent + + case 'progress': + return { + ...common, + type: 'progress', + stage: asString(raw.stage), + status: asString(raw.status), + message: asString(raw.message), + current: asOptionalNumber(raw.current), + total: asOptionalNumber(raw.total), + percent: asOptionalNumber(raw.percent), + } satisfies RuntimeProgressEvent + + case 'state': + return { + ...common, + type: 'state', + stage: asString(raw.stage), + status: asString(raw.status), + message: asString(raw.message), + details: asDetails(raw.details), + } satisfies RuntimeStateEvent + + case 'log': + return { + ...common, + type: 'log', + source: asString(raw.source), + stream: asString(raw.stream), + message: asString(raw.message), + } satisfies RuntimeLogEvent + + case 'warning': + return { + ...common, + type: 'warning', + code: asString(raw.code), + stage: asString(raw.stage), + message: asString(raw.message), + retryable: asBoolean(raw.retryable), + remediation: asStringArray(raw.remediation), + details: asDetails(raw.details), + } satisfies RuntimeWarningEvent + + case 'error': + return { + ...common, + type: 'error', + code: asString(raw.code), + stage: asString(raw.stage), + message: asString(raw.message), + retryable: asBoolean(raw.retryable), + remediation: asStringArray(raw.remediation), + details: asDetails(raw.details), + } satisfies RuntimeErrorEvent + + default: + return { + ...common, + type: 'result', + success: asBoolean(raw.success), + code: asString(raw.code), + stage: asString(raw.stage), + status: asString(raw.status), + message: asString(raw.message), + retryable: asBoolean(raw.retryable), + remediation: asStringArray(raw.remediation), + details: asDetails(raw.details), + } satisfies RuntimeResultEvent + } +} + +const MAX_ECHO_LENGTH = 200 + +function truncate(line: string): string { + return line.length > MAX_ECHO_LENGTH ? `${line.slice(0, MAX_ECHO_LENGTH)}…` : line +} + +/** + * NDJSON 增量解析器。 + * + * 逐个 chunk 喂入,返回本次能确定的完整条目;进程退出后调用 `flush()` + * 处理最后一行没有换行符的情况。 + */ +export class NdjsonEventStream { + private buffer = '' + + /** 尚未凑齐换行符的半行内容,仅供诊断。 */ + get pending(): string { + return this.buffer + } + + push(chunk: string | Buffer): NdjsonItem[] { + this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8') + + const items: NdjsonItem[] = [] + let newlineIndex = this.buffer.indexOf('\n') + + while (newlineIndex !== -1) { + const line = this.buffer.slice(0, newlineIndex) + this.buffer = this.buffer.slice(newlineIndex + 1) + const item = toItem(line) + if (item) { + items.push(item) + } + newlineIndex = this.buffer.indexOf('\n') + } + + return items + } + + /** 处理并清空残留缓冲。没有残留或残留全是空白时返回空数组。 */ + flush(): NdjsonItem[] { + const rest = this.buffer + this.buffer = '' + const item = toItem(rest) + return item ? [item] : [] + } +} + +/** 把一行原始文本转成条目;空行(含 CRLF 造成的空行)直接跳过。 */ +function toItem(rawLine: string): NdjsonItem | undefined { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + if (line.trim().length === 0) { + return undefined + } + + try { + const event = parseRuntimeEventLine(line) + return event ? { kind: 'event', event, line } : { kind: 'unknown', line } + } catch (error) { + if (error instanceof RuntimeClientError) { + return { kind: 'error', error, line } + } + throw error + } +} diff --git a/frontend/electron/services/runtime/protocol.ts b/frontend/electron/services/runtime/protocol.ts new file mode 100644 index 000000000..86add32a0 --- /dev/null +++ b/frontend/electron/services/runtime/protocol.ts @@ -0,0 +1,935 @@ +/** + * AUTO-MAS Runtime NDJSON 协议(protocol v1)类型定义 + * + * 字段名与取值以 AUTO-MAS-Runtime 的 `internal/protocol/*.go` 为准, + * 语义参照 `doc/架构设计.md`「NDJSON 公共结构」至「错误码全集」各节。 + * + * 使用约定: + * - 一律按 `type`/`code`/`success`/`stage`/`status` 等机器字段判断业务状态, + * `message` 只用于展示,禁止解析中文文案做判定; + * - 未知的 stage、state、capability、remediation 与 code 必须忽略而不是拒绝整条协议, + * 因此这些字面量类型都是「开放联合」:既保留字面量补全,也接受未来新增的字符串。 + */ + +/** 本客户端实现并要求的协议版本。 */ +export const RUNTIME_PROTOCOL_VERSION = 1 + +/** 开放字面量联合:保留已知取值的补全,同时接受协议后续追加的新取值。 */ +type OpenUnion = T | (string & Record) + +// ==================== 事件类型 ==================== + +/** 事件判别字段 `type` 的全集。 */ +export type RuntimeEventType = + | 'hello' + | 'progress' + | 'state' + | 'log' + | 'warning' + | 'error' + | 'result' + +// ==================== 阶段与状态字面量 ==================== + +/** 协议 v1 的稳定 stage 标识(values.go: Stage)。 */ +export type RuntimeKnownStage = + | 'runtime.handshake' + | 'doctor' + | 'bootstrap' + | 'repair' + | 'cleanup' + | 'uv.check' + | 'uv.download' + | 'uv.verify' + | 'workspace.check' + | 'workspace.clone' + | 'workspace.verify' + | 'workspace.swap' + | 'workspace.cleanup' + | 'python.check' + | 'python.install' + | 'dependencies.check' + | 'dependencies.sync' + | 'dependencies.rebuild' + | 'backend.spawn' + | 'backend.health' + | 'backend.run' + | 'backend.restart' + | 'backend.shutdown' + | 'backend.cleanup' + +export type RuntimeStage = OpenUnion + +export const RUNTIME_STAGES: readonly RuntimeKnownStage[] = [ + 'runtime.handshake', + 'doctor', + 'bootstrap', + 'repair', + 'cleanup', + 'uv.check', + 'uv.download', + 'uv.verify', + 'workspace.check', + 'workspace.clone', + 'workspace.verify', + 'workspace.swap', + 'workspace.cleanup', + 'python.check', + 'python.install', + 'dependencies.check', + 'dependencies.sync', + 'dependencies.rebuild', + 'backend.spawn', + 'backend.health', + 'backend.run', + 'backend.restart', + 'backend.shutdown', + 'backend.cleanup', +] + +/** `progress.status` 的全集(values.go: ProgressStatus)。 */ +export type RuntimeKnownProgressStatus = + | 'pending' + | 'running' + | 'succeeded' + | 'skipped' + | 'failed' + | 'cancelled' + +export type RuntimeProgressStatus = OpenUnion + +export const RUNTIME_PROGRESS_STATUSES: readonly RuntimeKnownProgressStatus[] = [ + 'pending', + 'running', + 'succeeded', + 'skipped', + 'failed', + 'cancelled', +] + +/** `state.status` 的全集,即 Runtime 生命周期状态(values.go: StateStatus)。 */ +export type RuntimeKnownStateStatus = + | 'uninitialized' + | 'preparing_uv' + | 'syncing_repository' + | 'preparing_python' + | 'syncing_environment' + | 'ready_to_start' + | 'starting_backend' + | 'running' + | 'restarting' + | 'stopping_backend' + | 'environment_broken' + | 'backend_failed' + | 'stopped' + +export type RuntimeStateStatus = OpenUnion + +export const RUNTIME_STATE_STATUSES: readonly RuntimeKnownStateStatus[] = [ + 'uninitialized', + 'preparing_uv', + 'syncing_repository', + 'preparing_python', + 'syncing_environment', + 'ready_to_start', + 'starting_backend', + 'running', + 'restarting', + 'stopping_backend', + 'environment_broken', + 'backend_failed', + 'stopped', +] + +/** + * `result.status` 的取值域。 + * + * Go 侧该字段是裸 `string`(event.go: ResultEvent.Status),实测一次性命令写入 + * 进度语义的 `succeeded`/`failed`/`cancelled`,`backend supervise` 写入生命周期语义的 + * `backend_failed` 等,因此这里是两个集合的并集,不能只按生命周期状态解读。 + */ +export type RuntimeResultStatus = RuntimeProgressStatus | RuntimeStateStatus + +/** `hello.capabilities` 的稳定能力标识(values.go: Capability)。 */ +export type RuntimeKnownCapability = 'stdin.cancel' | 'state.v1' | 'log.stream' + +export type RuntimeCapability = OpenUnion + +export const RUNTIME_CAPABILITIES: readonly RuntimeKnownCapability[] = [ + 'stdin.cancel', + 'state.v1', + 'log.stream', +] + +/** 稳定的处置动作标识(errors.go: Remediation)。 */ +export type RuntimeKnownRemediation = + | 'retry' + | 'retry-sync' + | 'retry-other-mirror' + | 'rebuild-environment' + | 'stop-backend' + | 'restart-backend' + | 'select-version' + | 'update-desktop' + | 'run-doctor' + | 'cleanup' + | 'open-log' + | 'contact-support' + +export type RuntimeRemediation = OpenUnion + +export const RUNTIME_REMEDIATIONS: readonly RuntimeKnownRemediation[] = [ + 'retry', + 'retry-sync', + 'retry-other-mirror', + 'rebuild-environment', + 'stop-backend', + 'restart-backend', + 'select-version', + 'update-desktop', + 'run-doctor', + 'cleanup', + 'open-log', + 'contact-support', +] + +// ==================== 错误码 ==================== + +/** Runtime 侧稳定错误码全集(errors.go: Code,不含 `OK`)。 */ +export type RuntimeKnownErrorCode = + | 'INVALID_ARGUMENT' + | 'INVALID_CONTROL_COMMAND' + | 'INVALID_VERSION' + | 'UNSUPPORTED_MODE' + | 'PROTOCOL_MISMATCH' + | 'OPERATION_CANCELLED' + | 'OUTPUT_WRITE_FAILED' + | 'INTERNAL_ERROR' + | 'PATH_OUTSIDE_MANAGED_ROOT' + | 'UNSAFE_REPARSE_POINT' + | 'DIRECTORY_OCCUPIED' + | 'MUTATION_IN_PROGRESS' + | 'BACKEND_ALREADY_RUNNING' + | 'BACKEND_STILL_RUNNING' + | 'MUTEX_OPERATION_FAILED' + | 'STATE_WRITE_FAILED' + | 'UPDATE_STATE_AMBIGUOUS' + | 'NETWORK_UNAVAILABLE' + | 'MIRROR_EXHAUSTED' + | 'GIT_BRANCH_NOT_FOUND' + | 'GIT_REMOTE_RESOLVE_FAILED' + | 'GIT_CLONE_FAILED' + | 'GIT_REPOSITORY_INVALID' + | 'GIT_VERSION_MISMATCH' + | 'GIT_REPO_SWAP_FAILED' + | 'GIT_REPO_CLEANUP_FAILED' + | 'UV_DOWNLOAD_FAILED' + | 'UV_CHECKSUM_MISMATCH' + | 'UV_VERSION_MISMATCH' + | 'UV_EXEC_FAILED' + | 'PYTHON_VERSION_FILE_MISSING' + | 'PYTHON_VERSION_INVALID' + | 'PYTHON_VERSION_UNSUPPORTED' + | 'PYTHON_VERSION_INCOMPATIBLE' + | 'PYTHON_INSTALL_FAILED' + | 'PYTHON_VERSION_MISMATCH' + | 'LOCKFILE_MISSING' + | 'LOCKFILE_OUTDATED' + | 'DEPENDENCY_SYNC_FAILED' + | 'ENVIRONMENT_BROKEN' + | 'ENVIRONMENT_REBUILD_FAILED' + | 'BACKEND_ENTRY_NOT_FOUND' + | 'BACKEND_SPAWN_FAILED' + | 'BACKEND_EXITED_BEFORE_READY' + | 'BACKEND_HEALTH_TIMEOUT' + | 'BACKEND_HEALTH_INVALID' + | 'BACKEND_IDENTITY_MISMATCH' + | 'BACKEND_EXITED_UNEXPECTEDLY' + | 'BACKEND_RESTART_FAILED' + | 'BACKEND_SHUTDOWN_FAILED' + | 'BACKEND_FORCE_TERMINATED' + +/** 成功结果固定使用的结果码。 */ +export const RUNTIME_OK_CODE = 'OK' + +export type RuntimeCode = OpenUnion + +/** + * 调用侧错误码。Runtime 自己不会输出这些码,它们只在 Runtime 尚未进入协议、 + * 或协议流本身不可信时由本模块产生。 + */ +export type RuntimeClientErrorCode = + | 'RUNTIME_NOT_FOUND' + | 'RUNTIME_SPAWN_FAILED' + | 'RUNTIME_HANDSHAKE_TIMEOUT' + | 'RUNTIME_PROTOCOL_ERROR' + | 'RUNTIME_PROTOCOL_MISMATCH' + | 'RUNTIME_EXITED_UNEXPECTEDLY' + +/** 退出码只做粗分类,精确原因必须读 `result.code`。 */ +export const RUNTIME_EXIT_CODES = { + success: 0, + invalidArgument: 2, + protocolMismatch: 10, + preconditionFailed: 20, + networkFailure: 30, + gitFailure: 40, + environmentFailure: 50, + backendFailure: 60, + operationConflict: 70, + operationCancelled: 130, +} as const + +/** 一个错误码的稳定行为四元组,外加区分用途的中文摘要。 */ +export interface RuntimeErrorDefinition { + code: RuntimeKnownErrorCode + exitCode: number + retryable: boolean + remediation: readonly RuntimeKnownRemediation[] + /** 展示与日志用的简短说明;同 remediation 的错误码也必须给出不同文案。 */ + summary: string +} + +const ERROR_DEFINITION_LIST: readonly RuntimeErrorDefinition[] = [ + { + code: 'INVALID_ARGUMENT', + exitCode: 2, + retryable: false, + remediation: ['run-doctor'], + summary: 'Runtime 参数不合法', + }, + { + code: 'INVALID_CONTROL_COMMAND', + exitCode: 0, + retryable: false, + remediation: ['update-desktop'], + summary: 'Runtime 忽略了一条无效的 stdin 控制命令', + }, + { + code: 'INVALID_VERSION', + exitCode: 2, + retryable: false, + remediation: ['select-version'], + summary: '目标版本号不合法', + }, + { + code: 'UNSUPPORTED_MODE', + exitCode: 2, + retryable: false, + remediation: ['update-desktop'], + summary: 'Runtime 不支持该运行模式', + }, + { + code: 'PROTOCOL_MISMATCH', + exitCode: 10, + retryable: false, + remediation: ['update-desktop'], + summary: 'Runtime 协议版本与本程序不兼容', + }, + { + code: 'OPERATION_CANCELLED', + exitCode: 130, + retryable: true, + remediation: ['retry'], + summary: '操作已被取消', + }, + { + code: 'OUTPUT_WRITE_FAILED', + exitCode: 20, + retryable: false, + remediation: ['open-log', 'contact-support'], + summary: 'Runtime 协议输出通道写入失败', + }, + { + code: 'INTERNAL_ERROR', + exitCode: 20, + retryable: false, + remediation: ['open-log', 'contact-support'], + summary: 'Runtime 内部故障(Runtime 自身缺陷,不是输出通道问题)', + }, + { + code: 'PATH_OUTSIDE_MANAGED_ROOT', + exitCode: 70, + retryable: false, + remediation: ['run-doctor'], + summary: '目标路径不在受管根目录内', + }, + { + code: 'UNSAFE_REPARSE_POINT', + exitCode: 70, + retryable: false, + remediation: ['contact-support'], + summary: '路径上存在不安全的重解析点', + }, + { + code: 'DIRECTORY_OCCUPIED', + exitCode: 70, + retryable: true, + remediation: ['retry'], + summary: '目标目录被占用', + }, + { + code: 'MUTATION_IN_PROGRESS', + exitCode: 70, + retryable: true, + remediation: ['retry'], + summary: '已有变更操作正在进行', + }, + { + code: 'BACKEND_ALREADY_RUNNING', + exitCode: 70, + retryable: false, + remediation: [], + summary: '后端已在运行', + }, + { + code: 'BACKEND_STILL_RUNNING', + exitCode: 70, + retryable: true, + remediation: ['stop-backend'], + summary: '后端仍在运行,需先停止', + }, + { + code: 'MUTEX_OPERATION_FAILED', + exitCode: 70, + retryable: true, + remediation: ['retry', 'run-doctor'], + summary: '并发锁操作失败', + }, + { + code: 'STATE_WRITE_FAILED', + exitCode: 70, + retryable: true, + remediation: ['retry', 'run-doctor'], + summary: 'Runtime 状态文件写入失败', + }, + { + code: 'UPDATE_STATE_AMBIGUOUS', + exitCode: 70, + retryable: false, + remediation: ['run-doctor', 'contact-support'], + summary: '更新事务状态不明确', + }, + { + code: 'NETWORK_UNAVAILABLE', + exitCode: 30, + retryable: true, + remediation: ['retry', 'run-doctor'], + summary: '网络不可用', + }, + { + code: 'MIRROR_EXHAUSTED', + exitCode: 30, + retryable: true, + remediation: ['retry-other-mirror'], + summary: '所有镜像源均已尝试失败', + }, + { + code: 'GIT_BRANCH_NOT_FOUND', + exitCode: 40, + retryable: false, + remediation: ['select-version'], + summary: '目标发布分支不存在', + }, + { + code: 'GIT_REMOTE_RESOLVE_FAILED', + exitCode: 30, + retryable: true, + remediation: ['retry-other-mirror'], + summary: '解析 Git 远端失败', + }, + { + code: 'GIT_CLONE_FAILED', + exitCode: 30, + retryable: true, + remediation: ['retry-other-mirror'], + summary: 'Git 克隆失败', + }, + { + code: 'GIT_REPOSITORY_INVALID', + exitCode: 40, + retryable: true, + remediation: ['retry-sync'], + summary: '受管仓库不完整或不可用', + }, + { + code: 'GIT_VERSION_MISMATCH', + exitCode: 40, + retryable: false, + remediation: ['contact-support'], + summary: '仓库版本与目标版本不一致', + }, + { + code: 'GIT_REPO_SWAP_FAILED', + exitCode: 40, + retryable: true, + remediation: ['retry', 'run-doctor'], + summary: '仓库目录替换失败', + }, + { + code: 'GIT_REPO_CLEANUP_FAILED', + exitCode: 40, + retryable: true, + remediation: ['cleanup', 'open-log'], + summary: '仓库临时目录清理失败', + }, + { + code: 'UV_DOWNLOAD_FAILED', + exitCode: 30, + retryable: true, + remediation: ['retry-other-mirror'], + summary: 'uv 下载失败', + }, + { + code: 'UV_CHECKSUM_MISMATCH', + exitCode: 40, + retryable: true, + remediation: ['retry-other-mirror', 'contact-support'], + summary: 'uv 校验和不匹配', + }, + { + code: 'UV_VERSION_MISMATCH', + exitCode: 20, + retryable: false, + remediation: ['update-desktop'], + summary: 'uv 版本与 Runtime 要求不符', + }, + { + code: 'UV_EXEC_FAILED', + exitCode: 50, + retryable: true, + remediation: ['run-doctor', 'open-log'], + summary: 'uv 执行失败', + }, + { + code: 'PYTHON_VERSION_FILE_MISSING', + exitCode: 20, + retryable: false, + remediation: ['contact-support'], + summary: '仓库缺少 .python-version', + }, + { + code: 'PYTHON_VERSION_INVALID', + exitCode: 20, + retryable: false, + remediation: ['contact-support'], + summary: '.python-version 内容不合法', + }, + { + code: 'PYTHON_VERSION_UNSUPPORTED', + exitCode: 20, + retryable: false, + remediation: ['update-desktop'], + summary: 'Runtime 不支持该 Python 版本', + }, + { + code: 'PYTHON_VERSION_INCOMPATIBLE', + exitCode: 20, + retryable: false, + remediation: ['contact-support'], + summary: 'Python 版本与主项目不兼容', + }, + { + code: 'PYTHON_INSTALL_FAILED', + exitCode: 50, + retryable: true, + remediation: ['retry-other-mirror', 'open-log'], + summary: 'Python 安装失败', + }, + { + code: 'PYTHON_VERSION_MISMATCH', + exitCode: 50, + retryable: true, + remediation: ['rebuild-environment'], + summary: '环境内 Python 版本与目标不一致', + }, + { + code: 'LOCKFILE_MISSING', + exitCode: 20, + retryable: false, + remediation: ['contact-support'], + summary: '缺少 uv.lock', + }, + { + code: 'LOCKFILE_OUTDATED', + exitCode: 20, + retryable: false, + remediation: ['contact-support'], + summary: 'uv.lock 与 pyproject.toml 不同步', + }, + { + code: 'DEPENDENCY_SYNC_FAILED', + exitCode: 50, + retryable: true, + remediation: ['retry-sync', 'rebuild-environment', 'open-log'], + summary: '主项目依赖同步失败', + }, + { + code: 'ENVIRONMENT_BROKEN', + exitCode: 50, + retryable: true, + remediation: ['retry-sync', 'rebuild-environment'], + summary: '主项目环境已损坏', + }, + { + code: 'ENVIRONMENT_REBUILD_FAILED', + exitCode: 50, + retryable: true, + remediation: ['run-doctor', 'open-log'], + summary: '主项目环境重建失败', + }, + { + code: 'BACKEND_ENTRY_NOT_FOUND', + exitCode: 20, + retryable: false, + remediation: ['retry-sync', 'contact-support'], + summary: '后端入口文件不存在', + }, + { + code: 'BACKEND_SPAWN_FAILED', + exitCode: 60, + retryable: true, + remediation: ['run-doctor', 'open-log'], + summary: '后端进程创建失败', + }, + { + code: 'BACKEND_EXITED_BEFORE_READY', + exitCode: 60, + retryable: true, + remediation: ['restart-backend', 'open-log'], + summary: '后端在就绪前退出', + }, + { + code: 'BACKEND_HEALTH_TIMEOUT', + exitCode: 60, + retryable: true, + remediation: ['restart-backend', 'open-log'], + summary: '后端健康检查超时', + }, + { + code: 'BACKEND_HEALTH_INVALID', + exitCode: 60, + retryable: true, + remediation: ['restart-backend', 'open-log'], + summary: '后端健康响应无效', + }, + { + code: 'BACKEND_IDENTITY_MISMATCH', + exitCode: 60, + retryable: false, + remediation: ['retry-sync', 'contact-support'], + summary: '后端身份校验不通过', + }, + { + code: 'BACKEND_EXITED_UNEXPECTEDLY', + exitCode: 60, + retryable: true, + remediation: ['restart-backend', 'open-log'], + summary: '后端意外退出', + }, + { + code: 'BACKEND_RESTART_FAILED', + exitCode: 60, + retryable: true, + remediation: ['restart-backend', 'rebuild-environment'], + summary: '后端自动重启失败', + }, + { + code: 'BACKEND_SHUTDOWN_FAILED', + exitCode: 60, + retryable: true, + remediation: ['retry', 'open-log'], + summary: '后端关闭或进程树清理失败', + }, + { + code: 'BACKEND_FORCE_TERMINATED', + exitCode: 0, + retryable: false, + remediation: ['open-log'], + summary: '后端优雅关闭超时后被强制结束', + }, +] + +const ERROR_DEFINITION_INDEX = new Map( + ERROR_DEFINITION_LIST.map(definition => [definition.code, definition]) +) + +export const RUNTIME_ERROR_CODES: readonly RuntimeKnownErrorCode[] = ERROR_DEFINITION_LIST.map( + definition => definition.code +) + +/** 查表得到某个 Runtime 错误码的稳定行为;未知码返回 undefined。 */ +export function lookupRuntimeErrorDefinition(code: string): RuntimeErrorDefinition | undefined { + return ERROR_DEFINITION_INDEX.get(code) +} + +/** 调用侧错误码的行为定义,语义由本模块自行约定(Runtime 不产生这些码)。 */ +export interface RuntimeClientErrorDefinition { + code: RuntimeClientErrorCode + retryable: boolean + remediation: readonly RuntimeKnownRemediation[] + summary: string +} + +export const RUNTIME_CLIENT_ERROR_DEFINITIONS: Readonly< + Record +> = { + RUNTIME_NOT_FOUND: { + code: 'RUNTIME_NOT_FOUND', + retryable: false, + remediation: ['update-desktop', 'contact-support'], + summary: '找不到 Runtime 可执行文件', + }, + RUNTIME_SPAWN_FAILED: { + code: 'RUNTIME_SPAWN_FAILED', + retryable: true, + remediation: ['retry', 'open-log'], + summary: 'Runtime 进程创建失败', + }, + RUNTIME_HANDSHAKE_TIMEOUT: { + code: 'RUNTIME_HANDSHAKE_TIMEOUT', + retryable: true, + remediation: ['retry', 'open-log'], + summary: '等待 Runtime hello 事件超时', + }, + RUNTIME_PROTOCOL_ERROR: { + code: 'RUNTIME_PROTOCOL_ERROR', + retryable: false, + remediation: ['update-desktop', 'contact-support'], + summary: 'Runtime 输出不符合 NDJSON 协议', + }, + RUNTIME_PROTOCOL_MISMATCH: { + code: 'RUNTIME_PROTOCOL_MISMATCH', + retryable: false, + remediation: ['update-desktop'], + summary: 'Runtime 协议版本与本程序不一致', + }, + RUNTIME_EXITED_UNEXPECTEDLY: { + code: 'RUNTIME_EXITED_UNEXPECTEDLY', + retryable: true, + remediation: ['retry', 'open-log'], + summary: 'Runtime 未输出最终结果就退出', + }, +} + +// ==================== 事件结构 ==================== + +/** 所有事件共享的公共字段(event.go: Common)。 */ +export interface RuntimeEventCommon { + protocol: number + type: RuntimeEventType + operationId: string + sequence: number + timestamp: string +} + +/** 首个事件,公告 Runtime 版本与本次操作支持的能力。 */ +export interface RuntimeHelloEvent extends RuntimeEventCommon { + type: 'hello' + runtimeVersion: string + command: string + capabilities: RuntimeCapability[] +} + +/** 可量化的阶段进度。`current`/`total`/`percent` 只在总量可知时出现。 */ +export interface RuntimeProgressEvent extends RuntimeEventCommon { + type: 'progress' + stage: RuntimeStage + status: RuntimeProgressStatus + message: string + current?: number + total?: number + percent?: number +} + +/** 生命周期状态迁移或只读状态快照。 */ +export interface RuntimeStateEvent extends RuntimeEventCommon { + type: 'state' + stage: RuntimeStage + status: RuntimeStateStatus + message: string + details: Record +} + +/** + * 受管进程转发出来的一行日志。 + * + * `source`(如 `runtime`、`backend`)与 `stream`(`stdout`/`stderr`)在 Go 侧都是裸 + * `string`,架构设计文档的事件表未列出这两个字段。 + */ +export interface RuntimeLogEvent extends RuntimeEventCommon { + type: 'log' + source: string + stream: string + message: string +} + +/** 不终止顶层操作的警告,字段与 error 相同。 */ +export interface RuntimeWarningEvent extends RuntimeEventCommon { + type: 'warning' + code: RuntimeCode + stage: RuntimeStage + message: string + retryable: boolean + remediation: RuntimeRemediation[] + details: Record +} + +/** 操作的主错误。 */ +export interface RuntimeErrorEvent extends RuntimeEventCommon { + type: 'error' + code: RuntimeCode + stage: RuntimeStage + message: string + retryable: boolean + remediation: RuntimeRemediation[] + details: Record +} + +/** 顶层操作的最终结果,成功时 `code` 固定为 `OK`。 */ +export interface RuntimeResultEvent extends RuntimeEventCommon { + type: 'result' + success: boolean + code: RuntimeCode + stage: RuntimeStage + status: RuntimeResultStatus + message: string + retryable: boolean + remediation: RuntimeRemediation[] + details: Record +} + +/** 按 `type` 判别的事件联合。 */ +export type RuntimeEvent = + | RuntimeHelloEvent + | RuntimeProgressEvent + | RuntimeStateEvent + | RuntimeLogEvent + | RuntimeWarningEvent + | RuntimeErrorEvent + | RuntimeResultEvent + +/** `result.details.warnings` 中的 warning 快照(event.go: WarningSummary)。 */ +export interface RuntimeWarningSummary { + code: RuntimeCode + stage: RuntimeStage + message: string + retryable: boolean + remediation: RuntimeRemediation[] + details: Record +} + +// ==================== 标准输入控制命令 ==================== + +/** stdin 控制命令类型。`shutdown` 与 `status` 只对 `backend supervise` 有意义。 */ +export type RuntimeControlKind = 'cancel' | 'shutdown' | 'status' + +/** + * 一条 stdin 控制命令。 + * + * Runtime 的解码器拒绝未知字段,因此这三个字段既是全部必填项也是全部允许项 + * (control.go: decodeControlFields)。`commandId` 必须是规范 ULID。 + */ +export interface RuntimeControlCommand { + protocol: number + command: RuntimeControlKind + commandId: string +} + +// ==================== 类型守卫 ==================== + +export function isKnownRuntimeStage(value: string): value is RuntimeKnownStage { + return (RUNTIME_STAGES as readonly string[]).includes(value) +} + +export function isKnownRuntimeProgressStatus(value: string): value is RuntimeKnownProgressStatus { + return (RUNTIME_PROGRESS_STATUSES as readonly string[]).includes(value) +} + +export function isKnownRuntimeStateStatus(value: string): value is RuntimeKnownStateStatus { + return (RUNTIME_STATE_STATUSES as readonly string[]).includes(value) +} + +export function isKnownRuntimeCapability(value: string): value is RuntimeKnownCapability { + return (RUNTIME_CAPABILITIES as readonly string[]).includes(value) +} + +export function isKnownRuntimeRemediation(value: string): value is RuntimeKnownRemediation { + return (RUNTIME_REMEDIATIONS as readonly string[]).includes(value) +} + +export function isKnownRuntimeCode(value: string): boolean { + return value === RUNTIME_OK_CODE || ERROR_DEFINITION_INDEX.has(value) +} + +/** + * 判断一个 Runtime 错误码是否可重试。 + * + * 未知错误码按不可重试处理:宁可少给一个重试按钮,也不要让用户在真正不可恢复的 + * 故障上反复重试。`INTERNAL_ERROR` 在表中即为不可重试。 + */ +export function isRetryableRuntimeCode(code: string): boolean { + return ERROR_DEFINITION_INDEX.get(code)?.retryable ?? false +} + +/** 事件是否为终态 `result`。 */ +export function isRuntimeResultEvent(event: RuntimeEvent): event is RuntimeResultEvent { + return event.type === 'result' +} + +// ==================== 调用侧错误 ==================== + +export interface RuntimeClientErrorDetails { + /** Runtime 进程退出码,未退出时为 undefined。 */ + exitCode?: number | null + /** 结束进程的信号。 */ + signal?: NodeJS.Signals | null + /** Runtime 自身的 stderr 诊断输出(不是被监督进程的日志)。 */ + stderr?: string + /** 触发 RUNTIME_PROTOCOL_ERROR 的原始行。 */ + line?: string + /** 握手拿到的协议版本,用于 RUNTIME_PROTOCOL_MISMATCH。 */ + actualProtocol?: number + expectedProtocol?: number + /** Runtime 可执行文件路径。 */ + runtimePath?: string + /** 本次调用的命令与参数。 */ + argv?: string[] +} + +/** + * 调用侧统一错误。 + * + * 只承载六个调用侧错误码;Runtime 自己输出的错误(含 `INTERNAL_ERROR`)走 + * `result`/`error` 事件,由调用方按 `code` + `retryable` + `remediation` 处理, + * 不会被包装成本类型。 + */ +export class RuntimeClientError extends Error { + readonly code: RuntimeClientErrorCode + readonly retryable: boolean + readonly remediation: readonly RuntimeKnownRemediation[] + readonly details: RuntimeClientErrorDetails + + constructor( + code: RuntimeClientErrorCode, + message?: string, + details: RuntimeClientErrorDetails = {}, + options?: { cause?: unknown } + ) { + const definition = RUNTIME_CLIENT_ERROR_DEFINITIONS[code] + super(message || definition.summary) + this.name = 'RuntimeClientError' + this.code = code + this.retryable = definition.retryable + this.remediation = definition.remediation + this.details = details + if (options && 'cause' in options) { + // Electron 的 Node 支持 Error.cause,但 tsconfig 目标是 ES2020,这里手动挂载。 + ;(this as { cause?: unknown }).cause = options.cause + } + } +} + +export function isRuntimeClientError(value: unknown): value is RuntimeClientError { + return value instanceof RuntimeClientError +} diff --git a/frontend/electron/services/runtime/runtimeClientFactory.test.ts b/frontend/electron/services/runtime/runtimeClientFactory.test.ts new file mode 100644 index 000000000..2411f1ed6 --- /dev/null +++ b/frontend/electron/services/runtime/runtimeClientFactory.test.ts @@ -0,0 +1,139 @@ +import { spawn } from 'child_process' +import { EventEmitter } from 'node:events' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RuntimeClient } from './client' +import { createRuntimeClient } from './runtimeClientFactory' + +vi.mock('child_process', () => ({ spawn: vi.fn() })) +vi.mock('../logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) + +// ==================== 假子进程(只需要能收一条 hello 完成握手,不关心后续) ==================== + +class FakeReadable extends EventEmitter { + setEncoding(): this { + return this + } + + feed(text: string): void { + this.emit('data', text) + } +} + +class FakeWritable extends EventEmitter { + write(): boolean { + return true + } +} + +class FakeChild extends EventEmitter { + readonly stdout = new FakeReadable() + readonly stderr = new FakeReadable() + readonly stdin = new FakeWritable() + readonly pid = 4242 + exitCode: number | null = null + signalCode: NodeJS.Signals | null = null + killed = false + + kill(): boolean { + this.killed = true + return true + } +} + +const spawnMock = vi.mocked(spawn) +const RUNTIME_PATH = process.execPath + +function spawnedEnv(): NodeJS.ProcessEnv { + return (spawnMock.mock.calls[0][2] as { env: NodeJS.ProcessEnv }).env +} + +/** 喂一条最小 hello 事件,让 run() 的握手立刻完成,避免留下 10s 的悬空握手计时器。 */ +function feedHello(): void { + const child = spawnMock.mock.results[0].value as FakeChild + const hello = { + protocol: 1, + operationId: '01M1F6M33JFZZ7Y85BE5S849ZN', + timestamp: '2026-09-01T21:20:03.442+02:00', + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'doctor', + capabilities: [], + } + child.stdout.feed(`${JSON.stringify(hello)}\n`) +} + +let appRoot: string + +function writeBackendConfig(value: unknown): void { + const configDir = path.join(appRoot, 'config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'Config.json'), JSON.stringify(value), 'utf8') +} + +beforeEach(() => { + spawnMock.mockReset() + spawnMock.mockReturnValue(new FakeChild() as never) + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-mas-runtime-factory-')) +}) + +afterEach(() => { + fs.rmSync(appRoot, { recursive: true, force: true }) +}) + +describe('createRuntimeClient', () => { + it('返回 RuntimeClient 实例', () => { + const client = createRuntimeClient({ runtimePath: RUNTIME_PATH, appRoot }) + expect(client).toBeInstanceOf(RuntimeClient) + }) + + it('关闭遥测时,spawn 出的 Runtime 子进程环境带 AUTO_MAS_TELEMETRY=disabled', () => { + writeBackendConfig({ Function: { IfEnableTelemetry: false } }) + + void createRuntimeClient({ runtimePath: RUNTIME_PATH, appRoot }) + .run(['doctor']) + .catch(() => undefined) + feedHello() + + expect(spawnedEnv().AUTO_MAS_TELEMETRY).toBe('disabled') + }) + + it('开启遥测时,不设 AUTO_MAS_TELEMETRY', () => { + writeBackendConfig({ Function: { IfEnableTelemetry: true } }) + + void createRuntimeClient({ runtimePath: RUNTIME_PATH, appRoot }) + .run(['doctor']) + .catch(() => undefined) + feedHello() + + expect(spawnedEnv().AUTO_MAS_TELEMETRY).toBeUndefined() + }) + + it('调用方显式传入的 env 优先于遥测开关注入的默认值', () => { + writeBackendConfig({ Function: { IfEnableTelemetry: false } }) + + void createRuntimeClient({ + runtimePath: RUNTIME_PATH, + appRoot, + env: { AUTO_MAS_TELEMETRY: undefined }, + }) + .run(['doctor']) + .catch(() => undefined) + feedHello() + + expect(spawnedEnv().AUTO_MAS_TELEMETRY).toBeUndefined() + }) +}) diff --git a/frontend/electron/services/runtime/runtimeClientFactory.ts b/frontend/electron/services/runtime/runtimeClientFactory.ts new file mode 100644 index 000000000..42ea7fd26 --- /dev/null +++ b/frontend/electron/services/runtime/runtimeClientFactory.ts @@ -0,0 +1,18 @@ +/** + * RuntimeClient 构造工厂 + * + * `runtimeInitializationService.ts`(W9b)与 `backendService.ts`(W9c)各有一处构造 + * `RuntimeClient` 的地方,此前分别裸 `new RuntimeClient(...)`,遥测开关这类需要每次构造都 + * 生效的策略只能各写一份。收敛到这里统一注入 `buildRuntimeEnv()`,两个调用点都改成调这个 + * 工厂,不改它们原有的控制流程。调用方显式传入的 `env` 优先于这里注入的默认值。 + */ + +import { RuntimeClient, RuntimeClientOptions } from './client' +import { buildRuntimeEnv } from './runtimeEnv' + +export function createRuntimeClient(options: RuntimeClientOptions): RuntimeClient { + return new RuntimeClient({ + ...options, + env: { ...buildRuntimeEnv(options.appRoot), ...options.env }, + }) +} diff --git a/frontend/electron/services/runtime/runtimeEnv.test.ts b/frontend/electron/services/runtime/runtimeEnv.test.ts new file mode 100644 index 000000000..8eb8bf4e2 --- /dev/null +++ b/frontend/electron/services/runtime/runtimeEnv.test.ts @@ -0,0 +1,79 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RUNTIME_TELEMETRY_ENV, buildRuntimeEnv } from './runtimeEnv' + +const warn = vi.fn() + +vi.mock('../logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: (...args: unknown[]) => warn(...args), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) + +let appRoot: string + +function writeBackendConfig(value: unknown): void { + const configDir = path.join(appRoot, 'config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'Config.json'), JSON.stringify(value), 'utf8') +} + +beforeEach(() => { + warn.mockClear() + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-mas-runtime-env-')) +}) + +afterEach(() => { + fs.rmSync(appRoot, { recursive: true, force: true }) +}) + +describe('buildRuntimeEnv', () => { + it('Config.json 不存在时按开启处理,不设 AUTO_MAS_TELEMETRY', () => { + expect(buildRuntimeEnv(appRoot)).toEqual({}) + expect(warn).not.toHaveBeenCalled() + }) + + it('IfEnableTelemetry 缺失时按开启处理', () => { + writeBackendConfig({ Function: {} }) + + expect(buildRuntimeEnv(appRoot)).toEqual({}) + }) + + it('IfEnableTelemetry 为 true 时按开启处理', () => { + writeBackendConfig({ Function: { IfEnableTelemetry: true } }) + + expect(buildRuntimeEnv(appRoot)).toEqual({}) + }) + + it('IfEnableTelemetry 为 false 时透传 AUTO_MAS_TELEMETRY=disabled', () => { + writeBackendConfig({ Function: { IfEnableTelemetry: false } }) + + expect(buildRuntimeEnv(appRoot)).toEqual({ [RUNTIME_TELEMETRY_ENV]: 'disabled' }) + }) + + it('结果里不含任何 offline 相关的键——遥测与联网开关是两回事', () => { + writeBackendConfig({ Function: { IfEnableTelemetry: false } }) + + const env = buildRuntimeEnv(appRoot) + expect(Object.keys(env)).toEqual([RUNTIME_TELEMETRY_ENV]) + expect(env).not.toHaveProperty('offline') + expect(env).not.toHaveProperty('--offline') + }) + + it('Config.json 损坏时记 warning 并按开启处理', () => { + const configDir = path.join(appRoot, 'config') + fs.mkdirSync(configDir, { recursive: true }) + fs.writeFileSync(path.join(configDir, 'Config.json'), '{not json', 'utf8') + + expect(buildRuntimeEnv(appRoot)).toEqual({}) + expect(warn).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/electron/services/runtime/runtimeEnv.ts b/frontend/electron/services/runtime/runtimeEnv.ts new file mode 100644 index 000000000..6a5c6ce04 --- /dev/null +++ b/frontend/electron/services/runtime/runtimeEnv.ts @@ -0,0 +1,53 @@ +/** + * Runtime 子进程的环境变量覆盖 + * + * 目前只有遥测一项:用户关闭匿名遥测时透传 `AUTO_MAS_TELEMETRY=disabled` 给 + * `auto-mas-runtime.exe`,让 Runtime 自己的上报也一并关闭;开启时不设该变量——不是显式 + * 清空,只是不去覆盖 Runtime 自己的默认值。`--offline` 是完全独立的网络开关(禁止任何联网 + * 尝试),不能拿来当遥测开关用。 + * + * 遥测开关的权威来源是后端持久化的 `GlobalConfig.Function.IfEnableTelemetry` + * (`/config/Config.json`),与 Electron 主进程自身 Sentry 开关(见 `../sentry.ts` + * 的 `configureMainSentry`)读的是同一份配置、同一条「非 false 即视为开启」规则。 + */ + +import * as fs from 'fs' +import * as path from 'path' + +import { getLogger } from '../logger' + +const logger = getLogger('Runtime环境变量') + +/** 透传给 Runtime 的遥测开关环境变量名。 */ +export const RUNTIME_TELEMETRY_ENV = 'AUTO_MAS_TELEMETRY' + +/** + * 读取后端持久化配置里的遥测开关。 + * + * 文件不存在、字段缺失或 JSON 损坏都按开启处理——只有明确写了 `false` 才是用户关闭过。 + */ +function isTelemetryEnabled(appRoot: string): boolean { + try { + const configPath = path.join(appRoot, 'config', 'Config.json') + if (!fs.existsSync(configPath)) return true + + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + Function?: { IfEnableTelemetry?: unknown } + } + return parsed.Function?.IfEnableTelemetry !== false + } catch (error) { + logger.warn( + `读取遥测开关失败,按开启处理: ${error instanceof Error ? error.message : String(error)}` + ) + return true + } +} + +/** + * 构建传给 `RuntimeClient` 的环境变量覆盖。 + * + * 关闭遥测时返回 `{ AUTO_MAS_TELEMETRY: 'disabled' }`;开启时返回空对象(不设该变量)。 + */ +export function buildRuntimeEnv(appRoot: string): NodeJS.ProcessEnv { + return isTelemetryEnabled(appRoot) ? {} : { [RUNTIME_TELEMETRY_ENV]: 'disabled' } +} diff --git a/frontend/electron/services/runtimeInitializationService.test.ts b/frontend/electron/services/runtimeInitializationService.test.ts new file mode 100644 index 000000000..6d0f7394c --- /dev/null +++ b/frontend/electron/services/runtimeInitializationService.test.ts @@ -0,0 +1,643 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + BootstrapProgressBridge, + BootstrapProgressUpdate, + MirrorLookup, + RUNTIME_TAKEOVER_MESSAGE, + RuntimeInitializationService, + emitDevelopmentSkipProgress, + mapDoctorChecksToCriticalFiles, + mapMirrorSelection, + mapRuntimeStage, + mapRuntimeStageToInitializationStage, + toRuntimeVersion, +} from './runtimeInitializationService' +import type { RuntimeEvent, RuntimeRunOptions, RuntimeSupervisedLaunchConfig } from './runtime' +import type { MirrorConfig, MirrorSource } from './mirrorService' + +vi.mock('electron', () => ({ app: { getVersion: () => '5.5.0-beta.3' } })) +vi.mock('./logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) + +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), 'runtime', '__fixtures__') + +/** 夹具由本机构建的 auto-mas-runtime.exe 真实跑出来,不是手写的。 */ +function fixtureEvents(name: string): RuntimeEvent[] { + return readFileSync(join(fixturesDir, name), 'utf8') + .split('\n') + .filter(line => line.trim() !== '') + .map(line => JSON.parse(line) as RuntimeEvent) +} + +// ==================== 假 RuntimeClient ==================== + +const APP_ROOT = 'D:\\AUTO-MAS' +const RUNTIME_PATH = 'D:\\AUTO-MAS\\runtime\\auto-mas-runtime.exe' + +interface FakeCall { + command: string[] + mirrors: { kind: string; key: string }[] +} + +/** 记录每次调用的 argv 与镜像选项,并按脚本回放事件。 */ +class FakeRuntimeClient { + static calls: FakeCall[] = [] + /** 依次消费:每次 run 取一条脚本,用完则复用最后一条。 */ + static scripts: { events: RuntimeEvent[]; throws?: unknown }[] = [] + + constructor(readonly options: { runtimePath: string; appRoot: string; mirrors?: unknown[] }) {} + + async run(command: string[], options: RuntimeRunOptions = {}) { + FakeRuntimeClient.calls.push({ + command, + mirrors: (this.options.mirrors ?? []) as { kind: string; key: string }[], + }) + + const script = + FakeRuntimeClient.scripts[ + Math.min(FakeRuntimeClient.calls.length - 1, FakeRuntimeClient.scripts.length - 1) + ] + if (!script) throw new Error('测试未准备事件脚本') + if (script.throws) throw script.throws + + let result: RuntimeEvent | undefined + const errors: RuntimeEvent[] = [] + for (const event of script.events) { + switch (event.type) { + case 'progress': + options.onProgress?.(event) + break + case 'state': + options.onState?.(event) + break + case 'log': + options.onLog?.(event) + break + case 'error': + errors.push(event) + options.onRuntimeError?.(event) + break + case 'result': + result = event + break + } + } + + if (!result || result.type !== 'result') throw new Error('测试脚本缺少 result 事件') + return { + hello: script.events[0], + result, + success: result.success, + code: result.code, + events: script.events, + warnings: [], + errors, + logs: {}, + protocolErrors: [], + exitCode: result.success ? 0 : 50, + signal: null, + stderr: '', + argv: command, + durationMs: 1, + } + } +} + +// ==================== 假 MirrorService ==================== + +/** key/name 组合与 mirrorService.ts 的默认配置一致,覆盖测试要用到的三类、含中文 name。 */ +const FAKE_MIRROR_SOURCES: Readonly> = { + python: [ + { key: 'aliyun', name: '阿里云镜像', url: '', type: 'mirror', description: '' }, + { key: 'official', name: 'Python 官方', url: '', type: 'official', description: '' }, + ], + repo: [ + { key: 'cnb', name: 'CNB 官方镜像', url: '', type: 'mirror', description: '' }, + { key: 'github', name: 'GitHub 官方', url: '', type: 'official', description: '' }, + { + key: 'ghproxy_edgeone', + name: 'gh-proxy (EdgeOne)', + url: '', + type: 'mirror', + description: '', + }, + { key: 'ghfast', name: 'ghfast 镜像', url: '', type: 'mirror', description: '' }, + ], + pip_mirror: [ + { key: 'aliyun', name: '阿里云', url: '', type: 'mirror', description: '' }, + { key: 'tsinghua', name: '清华大学', url: '', type: 'mirror', description: '' }, + { key: 'ustc', name: '中科大', url: '', type: 'mirror', description: '' }, + { key: 'official', name: 'PyPI 官方', url: '', type: 'official', description: '' }, + ], +} + +/** 只实现 mapMirrorSelection 需要的 getMirrors,不碰真实 MirrorService 的文件读写。 */ +function fakeMirrorService(): MirrorLookup { + return { + getMirrors: (type: keyof MirrorConfig) => FAKE_MIRROR_SOURCES[type] ?? [], + } +} + +function createService( + overrides: Partial = {}, + mirrorService: MirrorLookup = fakeMirrorService() +) { + const launchConfig: RuntimeSupervisedLaunchConfig = { + mode: 'managed', + runtimePath: RUNTIME_PATH, + appRoot: APP_ROOT, + ...overrides, + } + return new RuntimeInitializationService({ + launchConfig, + mirrorService, + createClient: options => new FakeRuntimeClient(options) as never, + }) +} + +const base = { + protocol: 1, + operationId: '01M1F6M33JFZZ7Y85BE5S849ZN', + timestamp: '2026-09-01T22:03:00.000+02:00', +} + +const helloEvent = { + ...base, + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'bootstrap', + capabilities: [], +} as unknown as RuntimeEvent + +function okResult(stage: string): RuntimeEvent { + return { + ...base, + type: 'result', + sequence: 99, + success: true, + code: 'OK', + stage, + status: 'succeeded', + message: '完成', + retryable: false, + remediation: [], + details: {}, + } as unknown as RuntimeEvent +} + +beforeEach(() => { + FakeRuntimeClient.calls = [] + FakeRuntimeClient.scripts = [{ events: [helloEvent, okResult('bootstrap')] }] +}) + +// ==================== 阶段映射 ==================== + +describe('阶段映射', () => { + it('uv 与 python 都落在 python 段,仓库与依赖各自成段', () => { + expect(mapRuntimeStage('uv.check')).toBe('python') + expect(mapRuntimeStage('uv.download')).toBe('python') + expect(mapRuntimeStage('uv.verify')).toBe('python') + expect(mapRuntimeStage('python.check')).toBe('python') + expect(mapRuntimeStage('python.install')).toBe('python') + expect(mapRuntimeStage('workspace.clone')).toBe('repository') + expect(mapRuntimeStage('workspace.swap')).toBe('repository') + expect(mapRuntimeStage('dependencies.sync')).toBe('dependency') + expect(mapRuntimeStage('dependencies.rebuild')).toBe('dependency') + expect(mapRuntimeStage('backend.health')).toBe('backend') + }) + + it('未知 stage 落到通用段而不是抛错', () => { + expect(() => mapRuntimeStage('quantum.entangle')).not.toThrow() + expect(mapRuntimeStage('quantum.entangle')).toBe('python') + expect(mapRuntimeStage('bootstrap')).toBe('python') + expect(mapRuntimeStageToInitializationStage('bootstrap')).toBeNull() + expect(mapRuntimeStageToInitializationStage('quantum.entangle')).toBeNull() + }) + + it('真实 bootstrap 事件流里的每个 stage 都有显式对应', () => { + const stages = new Set() + for (const event of fixtureEvents('bootstrap-success.ndjson')) { + if ('stage' in event && typeof event.stage === 'string') stages.add(event.stage) + } + + // 顶层 result 用的 `bootstrap` 本来就没有对应的界面段,其余必须全部命中。 + const unmapped = [...stages].filter( + stage => mapRuntimeStageToInitializationStage(stage) === null + ) + expect(unmapped).toEqual(['bootstrap']) + expect(stages).toContain('dependencies.sync') + expect(stages).toContain('workspace.clone') + }) +}) + +describe('目标版本', () => { + it('补齐 Runtime 要求的 v 前缀', () => { + expect(toRuntimeVersion('5.5.0-beta.3')).toBe('v5.5.0-beta.3') + expect(toRuntimeVersion('v5.5.0-beta.3')).toBe('v5.5.0-beta.3') + }) +}) + +describe('镜像源映射', () => { + it('选中值本来就是 key 时按 key 解析,只映射语义对得上的键,其余返回 null', () => { + const mirrors = fakeMirrorService() + expect(mapMirrorSelection(mirrors, 'repository', 'cnb')).toEqual({ kind: 'git', key: 'cnb' }) + expect(mapMirrorSelection(mirrors, 'repository', 'github')).toEqual({ + kind: 'git', + key: 'github', + }) + expect(mapMirrorSelection(mirrors, 'python', 'official')).toEqual({ + kind: 'python', + key: 'github', + }) + + // Runtime 的 git 目录里没有这些源 + expect(mapMirrorSelection(mirrors, 'repository', 'ghproxy_edgeone')).toBeNull() + expect(mapMirrorSelection(mirrors, 'repository', 'ghfast')).toBeNull() + // 旧 python 类是 python.org 分发源,其余键在 Runtime 里没有对应物 + expect(mapMirrorSelection(mirrors, 'python', 'aliyun')).toBeNull() + // official 对应 Runtime 的 pypi,键名对不上,不映射 + expect(mapMirrorSelection(mirrors, 'dependency', 'official')).toBeNull() + expect(mapMirrorSelection(mirrors, 'git', 'autonas')).toBeNull() + expect(mapMirrorSelection(mirrors, 'repository', '')).toBeNull() + }) + + it('选中值是旧链路存的 name 时也能解析——渲染进程存进 state.selectedMirror 的就是 name', () => { + const mirrors = fakeMirrorService() + // MirrorRotationService.execute(..., preferredMirrorName) 按 mirror.name 匹配; + // 用 name 或用 key 解析到同一个 MirrorSource,映射结果应当一致 + expect(mapMirrorSelection(mirrors, 'repository', 'CNB 官方镜像')).toEqual({ + kind: 'git', + key: 'cnb', + }) + expect(mapMirrorSelection(mirrors, 'python', 'Python 官方')).toEqual({ + kind: 'python', + key: 'github', + }) + }) + + it('旧镜像列表里根本找不到这个选中值时返回 null', () => { + const mirrors = fakeMirrorService() + expect(mapMirrorSelection(mirrors, 'repository', '不存在的镜像')).toBeNull() + expect(mapMirrorSelection(mirrors, 'dependency', 'unknown-key')).toBeNull() + }) + + it('T13.4 起 dependency 段可以传 package-index,但只映射键名相同的三项', () => { + const mirrors = fakeMirrorService() + expect(mapMirrorSelection(mirrors, 'dependency', 'aliyun')).toEqual({ + kind: 'package-index', + key: 'aliyun', + }) + expect(mapMirrorSelection(mirrors, 'dependency', 'tsinghua')).toEqual({ + kind: 'package-index', + key: 'tsinghua', + }) + expect(mapMirrorSelection(mirrors, 'dependency', 'ustc')).toEqual({ + kind: 'package-index', + key: 'ustc', + }) + // 用 name 选中同样生效 + expect(mapMirrorSelection(mirrors, 'dependency', '清华大学')).toEqual({ + kind: 'package-index', + key: 'tsinghua', + }) + }) +}) + +// ==================== 进度桥接 ==================== + +describe('进度桥接', () => { + it('回放真实事件流时三段各出现一次 started 与 completed,且段序不倒退', () => { + const updates: BootstrapProgressUpdate[] = [] + const bridge = new BootstrapProgressBridge(update => updates.push(update)) + bridge.takeOver() + + for (const event of fixtureEvents('bootstrap-success.ndjson')) { + if (event.type === 'progress') bridge.observe(event.stage, event.message, event.percent) + if (event.type === 'state') bridge.observe(event.stage, event.message) + } + bridge.finish('运行环境准备完成') + + const started = updates.filter(u => u.status === 'started').map(u => u.stage) + expect(started).toEqual(['python', 'repository', 'dependency']) + + for (const stage of ['python', 'repository', 'dependency'] as const) { + expect(updates.filter(u => u.stage === stage && u.status === 'completed')).toHaveLength(1) + } + + // 真实顺序是 uv → 仓库 → Python → 依赖,python.* 落在仓库之后也不能把段拉回去 + const pythonInstall = updates.find(u => u.message === '正在准备受管 Python') + expect(pythonInstall?.stage).toBe('repository') + }) + + it('没有 percent 时段内停在 10%,段结束才 100%', () => { + const updates: BootstrapProgressUpdate[] = [] + const bridge = new BootstrapProgressBridge(update => updates.push(update)) + + bridge.observe('uv.download', '正在准备固定版本 uv') + bridge.observe('uv.verify', '固定版本 uv 已校验') + expect(updates.map(u => u.progress)).toEqual([10, 10]) + + bridge.observe('workspace.clone', '正在同步后端仓库', 42.86) + bridge.observe('workspace.clone', '正在接收后端仓库数据', 63.4) + expect(updates[2]).toMatchObject({ stage: 'python', status: 'completed', progress: 100 }) + expect(updates[3]).toMatchObject({ stage: 'repository', status: 'started', progress: 10 }) + expect(updates[4]).toMatchObject({ stage: 'repository', status: 'running', progress: 63 }) + }) + + it('还没进过任何段时不会顺手把前面的段报成完成', () => { + const updates: BootstrapProgressUpdate[] = [] + const bridge = new BootstrapProgressBridge(update => updates.push(update)) + + bridge.observe('dependencies.sync', '正在同步锁定依赖') + expect(updates).toEqual([ + { stage: 'dependency', status: 'started', progress: 10, message: '正在同步锁定依赖' }, + ]) + }) +}) + +describe('development 模式跳过', () => { + it('六个准备段各发一个完成', () => { + const updates: BootstrapProgressUpdate[] = [] + emitDevelopmentSkipProgress(update => updates.push(update)) + + expect(updates.map(u => u.stage)).toEqual([ + 'mirror', + 'python', + 'pip', + 'git', + 'repository', + 'dependency', + ]) + expect(updates.every(u => u.status === 'completed' && u.progress === 100)).toBe(true) + expect(updates[0].message).toBe('由 Runtime development 模式接管,跳过') + }) +}) + +// ==================== bootstrap ==================== + +describe('bootstrap', () => { + it('argv 是 bootstrap --version v<应用版本>,且没有对应物的三段立刻置完成', async () => { + const updates: BootstrapProgressUpdate[] = [] + FakeRuntimeClient.scripts = [ + { events: fixtureEvents('bootstrap-success.ndjson') as RuntimeEvent[] }, + ] + + const outcome = await createService().bootstrap(update => updates.push(update)) + + expect(outcome.success).toBe(true) + expect(FakeRuntimeClient.calls).toHaveLength(1) + expect(FakeRuntimeClient.calls[0].command).toEqual(['bootstrap', '--version', 'v5.5.0-beta.3']) + expect(FakeRuntimeClient.calls[0].mirrors).toEqual([]) + + for (const stage of ['mirror', 'pip', 'git'] as const) { + const takeover = updates.filter(u => u.stage === stage) + expect(takeover).toHaveLength(1) + expect(takeover[0]).toMatchObject({ status: 'completed', message: RUNTIME_TAKEOVER_MESSAGE }) + } + + for (const stage of ['python', 'repository', 'dependency'] as const) { + const statuses = updates.filter(u => u.stage === stage).map(u => u.status) + expect(statuses[0]).toBe('started') + expect(statuses[statuses.length - 1]).toBe('completed') + } + }) + + it('依赖同步失败时失败段是 dependency,结构化字段与日志整块透传', async () => { + const operationId = base.operationId + FakeRuntimeClient.scripts = [ + { + events: [ + helloEvent, + { + ...base, + type: 'log', + sequence: 2, + source: 'runtime', + stream: 'stdout', + message: 'Resolved 120 packages', + }, + { + ...base, + type: 'log', + sequence: 3, + source: 'runtime', + stream: 'stderr', + message: 'error: distribution not found', + }, + { + ...base, + type: 'error', + sequence: 4, + code: 'DEPENDENCY_SYNC_FAILED', + stage: 'dependencies.sync', + message: 'Python 依赖安装失败', + retryable: true, + remediation: ['retry', 'switch-mirror', 'rebuild-environment'], + details: { operationId }, + }, + { + ...base, + type: 'result', + sequence: 5, + success: false, + code: 'DEPENDENCY_SYNC_FAILED', + // result 上带的是顶层 stage,失败段必须取主错误事件的 stage + stage: 'bootstrap', + status: 'environment_broken', + message: 'Python 依赖同步失败', + retryable: true, + remediation: ['retry', 'switch-mirror', 'rebuild-environment'], + details: {}, + }, + ] as unknown as RuntimeEvent[], + }, + ] + + const updates: BootstrapProgressUpdate[] = [] + const outcome = await createService().bootstrap(update => updates.push(update)) + + expect(outcome.success).toBe(false) + expect(outcome.failedStage).toBe('dependency') + expect(outcome.code).toBe('DEPENDENCY_SYNC_FAILED') + expect(outcome.retryable).toBe(true) + expect(outcome.remediation).toEqual(['retry', 'switch-mirror', 'rebuild-environment']) + expect(outcome.logs).toContain('[stdout]') + expect(outcome.logs).toContain('Resolved 120 packages') + expect(outcome.logs).toContain('[stderr]') + expect(outcome.logs).toContain('error: distribution not found') + expect(updates[updates.length - 1]).toMatchObject({ stage: 'dependency', status: 'failed' }) + }) + + it('找不到可执行文件时按 RUNTIME_NOT_FOUND 失败,不构造客户端', async () => { + const outcome = await createService({ runtimePath: null }).bootstrap(() => undefined) + + expect(outcome.success).toBe(false) + expect(outcome.code).toBe('RUNTIME_NOT_FOUND') + expect(outcome.retryable).toBe(false) + expect(FakeRuntimeClient.calls).toHaveLength(0) + }) +}) + +// ==================== 单步重试 ==================== + +describe('单步重试', () => { + it('依赖段重试走 dependencies sync', async () => { + FakeRuntimeClient.scripts = [{ events: [helloEvent, okResult('dependencies.sync')] }] + + const outcome = await createService().retryStage('dependency', () => undefined) + + expect(outcome.success).toBe(true) + expect(FakeRuntimeClient.calls[0].command).toEqual(['dependencies', 'sync']) + }) + + it('上一次失败要求重建环境时依赖段改走 dependencies rebuild', async () => { + const service = createService() + FakeRuntimeClient.scripts = [ + { + events: [ + helloEvent, + { + ...base, + type: 'result', + sequence: 5, + success: false, + code: 'DEPENDENCY_SYNC_FAILED', + stage: 'dependencies.sync', + status: 'environment_broken', + message: 'Python 依赖同步失败', + retryable: true, + remediation: ['retry-sync', 'rebuild-environment', 'open-log'], + details: {}, + }, + ] as unknown as RuntimeEvent[], + }, + { events: [helloEvent, okResult('dependencies.rebuild')] }, + ] + + await service.bootstrap(() => undefined) + const outcome = await service.retryStage('dependency', () => undefined) + + expect(outcome.success).toBe(true) + expect(FakeRuntimeClient.calls[1].command).toEqual(['dependencies', 'rebuild']) + }) + + it('python 段重试走 environment ensure,要求重建环境时走 repair', async () => { + const service = createService() + FakeRuntimeClient.scripts = [{ events: [helloEvent, okResult('uv.check')] }] + await service.retryStage('python', () => undefined) + expect(FakeRuntimeClient.calls[0].command).toEqual(['environment', 'ensure']) + + FakeRuntimeClient.scripts = [ + { + events: [ + helloEvent, + { + ...base, + type: 'result', + sequence: 4, + success: false, + code: 'PYTHON_VERSION_MISMATCH', + stage: 'python.check', + status: 'environment_broken', + message: '环境内 Python 版本与目标不一致', + retryable: true, + remediation: ['rebuild-environment'], + details: {}, + }, + ] as unknown as RuntimeEvent[], + }, + { events: [helloEvent, okResult('repair')] }, + ] + FakeRuntimeClient.calls = [] + await service.bootstrap(() => undefined) + await service.retryStage('python', () => undefined) + expect(FakeRuntimeClient.calls[1].command).toEqual(['repair']) + }) + + it('仓库段重试走 workspace sync --version', async () => { + FakeRuntimeClient.scripts = [{ events: [helloEvent, okResult('workspace.clone')] }] + + await createService().retryStage('repository', () => undefined) + + expect(FakeRuntimeClient.calls[0].command).toEqual([ + 'workspace', + 'sync', + '--version', + 'v5.5.0-beta.3', + ]) + }) + + it('切换镜像后整条 bootstrap 重跑并带上 --mirror', async () => { + FakeRuntimeClient.scripts = [{ events: [helloEvent, okResult('bootstrap')] }] + + await createService().retryStage('repository', () => undefined, 'cnb') + + expect(FakeRuntimeClient.calls[0].command).toEqual(['bootstrap', '--version', 'v5.5.0-beta.3']) + expect(FakeRuntimeClient.calls[0].mirrors).toEqual([{ kind: 'git', key: 'cnb' }]) + }) + + it('镜像键映射不到时仍重跑 bootstrap 但不传 --mirror', async () => { + FakeRuntimeClient.scripts = [{ events: [helloEvent, okResult('bootstrap')] }] + + // official 在 pip_mirror 里能解析到,但键名对不上 Runtime 的 pypi,映射表里没有它 + await createService().retryStage('dependency', () => undefined, 'official') + + expect(FakeRuntimeClient.calls[0].command[0]).toBe('bootstrap') + expect(FakeRuntimeClient.calls[0].mirrors).toEqual([]) + }) + + it('mirror / pip / git 三段直接按成功返回,不启动 Runtime', async () => { + const service = createService() + const updates: BootstrapProgressUpdate[] = [] + + for (const stage of ['mirror', 'pip', 'git'] as const) { + const outcome = await service.retryStage(stage, update => updates.push(update)) + expect(outcome.success).toBe(true) + } + + expect(FakeRuntimeClient.calls).toHaveLength(0) + expect(updates.map(u => u.stage)).toEqual(['mirror', 'pip', 'git']) + }) +}) + +// ==================== doctor ==================== + +describe('doctor', () => { + it('layout.repo 缺失映射成需要初始化', async () => { + FakeRuntimeClient.scripts = [{ events: fixtureEvents('doctor.ndjson') as RuntimeEvent[] }] + + const checks = await createService().doctor() + expect(checks).toBeDefined() + expect(FakeRuntimeClient.calls[0].command).toEqual(['doctor']) + + const critical = mapDoctorChecksToCriticalFiles(checks ?? []) + expect(critical.mainPyExists).toBe(false) + expect(critical.pythonExists).toBe(false) + // 新链路不装 pip、不装 Git,这两项不参与判定 + expect(critical.pipExists).toBe(true) + expect(critical.gitExists).toBe(true) + }) + + it('layout.repo 就绪时不再要求初始化', () => { + const critical = mapDoctorChecksToCriticalFiles([ + { id: 'layout', name: '受管目录布局', message: '', status: 'ok', details: { repo: 'ok' } }, + { id: 'python', name: '受管 Python', message: '', status: 'ok', details: {} }, + ]) + + expect(critical.mainPyExists).toBe(true) + expect(critical.pythonExists).toBe(true) + }) +}) diff --git a/frontend/electron/services/runtimeInitializationService.ts b/frontend/electron/services/runtimeInitializationService.ts new file mode 100644 index 000000000..1f74fd79c --- /dev/null +++ b/frontend/electron/services/runtimeInitializationService.ts @@ -0,0 +1,804 @@ +/** + * Runtime 初始化链路 + * + * 灰度开关打开后,首次初始化的「装 Python → 装 pip → 装 Git → clone 仓库 → pip 装依赖」 + * 五步链换成一次 `auto-mas-runtime.exe bootstrap --version <目标版本>`:Runtime 内部按 + * uv → 仓库 → Python → 依赖的顺序完成全部准备工作,Electron 只负责把它的 stage 映射回 + * 现有初始化界面的 7 段模型,再另行启动后端(`backend supervise` 由 backendService 负责)。 + * + * 本模块只做映射与编排,不改动旧链路的任何一行;旧链路仍由 initializationService 直接调用 + * environmentService / repositoryService / dependencyService。 + */ + +import { app } from 'electron' + +import { getLogger } from './logger' +import { MirrorConfig, MirrorService } from './mirrorService' +import { + RUNTIME_CLIENT_ERROR_DEFINITIONS, + RuntimeClient, + RuntimeClientOptions, + RuntimeMirrorSelection, + RuntimeRemediation, + RuntimeRunControl, + RuntimeRunResult, + RuntimeStage, + RuntimeSupervisedLaunchConfig, + createRuntimeClient, + formatStartupLogs, + isRuntimeClientError, +} from './runtime' + +const logger = getLogger('Runtime初始化') + +// ==================== 阶段模型 ==================== + +/** 现有初始化界面的 7 段模型,新链路必须映射成它才能被界面直接消费。 */ +export type InitializationStage = + | 'mirror' + | 'python' + | 'pip' + | 'git' + | 'repository' + | 'dependency' + | 'backend' + | 'complete' + +/** + * 段状态。旧链路只发进度百分比,新链路额外给出机器可读的段状态, + * 界面(W9d)据此判断段的开始与结束,不解析中文文案。 + */ +export type InitializationStageStatus = 'started' | 'running' | 'completed' | 'failed' + +/** 真正执行的段,不含只表示整条流程结束的 `complete`。 */ +export type InitializationRunStage = Exclude + +/** 段在现有界面里的固定序号(`complete` 由调用方按 totalStages 填)。 */ +export const INITIALIZATION_STAGE_INDEX: Readonly> = { + mirror: 1, + python: 2, + pip: 3, + git: 4, + repository: 5, + dependency: 6, + backend: 7, +} + +/** + * Runtime stage 前缀到界面段的显式对应。 + * + * `uv.*` 与 `python.*` 都落在 `python` 段:新链路里 uv 是 Python 环境的一部分,界面上 + * 没有单独的「uv」步骤。`backend.*` 只可能出现在 `backend supervise` 里,列在这里是为了 + * 让映射函数对全量 stage 都有确定结果。 + */ +const RUNTIME_STAGE_PREFIX_MAP: readonly (readonly [string, InitializationRunStage])[] = [ + ['uv.', 'python'], + ['python.', 'python'], + ['workspace.', 'repository'], + ['dependencies.', 'dependency'], + ['backend.', 'backend'], +] + +/** + * 没有显式对应时落到的通用段。 + * + * `bootstrap` / `repair` / `doctor` 这类顶层 stage 与协议后续新增的 stage 都走这里: + * 协议要求调用方对未知 stage 使用通用展示而不是拒绝整个协议,所以这里绝不抛错。 + */ +export const FALLBACK_INITIALIZATION_STAGE: InitializationRunStage = 'python' + +/** 查显式对应;没有对应物时返回 null,供调用方区分「映射到了」与「兜底」。 */ +export function mapRuntimeStageToInitializationStage( + stage: RuntimeStage +): InitializationRunStage | null { + for (const [prefix, mapped] of RUNTIME_STAGE_PREFIX_MAP) { + if (stage.startsWith(prefix)) return mapped + } + return null +} + +/** 把 Runtime stage 映射成界面段,未知 stage 落到通用段。 */ +export function mapRuntimeStage(stage: RuntimeStage): InitializationRunStage { + return mapRuntimeStageToInitializationStage(stage) ?? FALLBACK_INITIALIZATION_STAGE +} + +// ==================== 镜像源映射 ==================== + +/** + * 旧链路镜像键到 Runtime `--mirror <类型>=<键>` 的显式映射。 + * + * 两套键名不是一套东西,逐项对照 Runtime 的 `internal/mirror/defaults.go` 后只有下面 + * 几项语义对得上,映射不到的一律不传 `--mirror`,交给 Runtime 自己按内置目录轮换: + * - 旧 `python` 类是 python.org 的分发源,Runtime 的 `python` 类只有 GitHub 上的 + * python-build-standalone,只有「官方」这一项对得上; + * - 旧 `repo` 类的 gitee / gh-proxy 各变体 / ghfast 在 Runtime 的 `git` 类里没有对应源; + * - 旧 `git` 类是「去哪下 git.exe」,Runtime 内置 Go Git 不再安装 Git,没有对应物; + * - 旧 `pip_mirror` 类对应 Runtime 的 `package-index`。T13.4 之前 Runtime 在 + * `bootstrap` / `dependencies *` / `repair` 上显式指定 `--mirror package-index=<键>` + * 一律返回 INVALID_ARGUMENT;T13.4 起改为允许显式指定(改写的是锁文件里的索引副本, + * 不覆盖 `uv.lock` 冻结的 registry URL),依赖段因此也能换镜像了——但只映射键名字面 + * 相同的三项(aliyun / tsinghua / ustc)。旧 `official` 对应 Runtime 的 `pypi`,键名 + * 对不上,不假定两边是同一个源,仍然不映射。 + */ +const MIRROR_KEY_MAP: Readonly< + Partial>>> +> = { + python: { + official: { kind: 'python', key: 'github' }, + }, + repository: { + cnb: { kind: 'git', key: 'cnb' }, + github: { kind: 'git', key: 'github' }, + }, + dependency: { + aliyun: { kind: 'package-index', key: 'aliyun' }, + tsinghua: { kind: 'package-index', key: 'tsinghua' }, + ustc: { kind: 'package-index', key: 'ustc' }, + }, +} + +/** 可供解析旧镜像选中值的最小依赖形状;`MirrorService` 结构上天然满足它。 */ +export type MirrorLookup = Pick + +/** + * `MIRROR_KEY_MAP` 的段与旧链路镜像类型(`mirrorService.ts` 的 `MirrorConfig` 键)的对应。 + * + * 只列 `RUNTIME_BOOTSTRAP_STAGE_ORDER` 里真正会传镜像选择的三段;`mirror` / `pip` / `git` + * 在新链路没有对应物,`retryStage` 里直接短路,走不到这张表。 + */ +const LEGACY_MIRROR_TYPE_BY_STAGE: Readonly< + Partial> +> = { + python: 'python', + repository: 'repo', + dependency: 'pip_mirror', +} + +/** + * 把界面选中的旧镜像标识解析成 `MirrorSource.key`。 + * + * 旧链路的 `MirrorRotationService.execute(..., preferredMirrorName)` 按 `mirror.name` + * 匹配,渲染进程存的选中值也是 `name`(`Initialization/index.vue` 的 `convertMirror` + * 甚至把展示层的 `key` 字段本身都填成了 `name`);但 `MIRROR_KEY_MAP` 是按 + * `MirrorSource.key` 建的表。两种取值都可能传进来,按 key 或 name 任一命中即可。 + */ +function resolveMirrorSourceKey( + mirrorService: MirrorLookup, + stage: InitializationRunStage, + selected: string +): string | null { + const legacyType = LEGACY_MIRROR_TYPE_BY_STAGE[stage] + if (!legacyType) return null + const source = mirrorService + .getMirrors(legacyType) + .find(m => m.key === selected || m.name === selected) + return source?.key ?? null +} + +/** + * 把界面上选中的旧镜像标识(`MirrorSource.key` 或 `name`)转成 Runtime 的镜像选择; + * 解析不到旧镜像源、或解析到了但映射表没有对应项时都返回 null。 + */ +export function mapMirrorSelection( + mirrorService: MirrorLookup, + stage: InitializationRunStage, + selected: string | undefined +): RuntimeMirrorSelection | null { + const trimmed = selected?.trim() + if (!trimmed) return null + const key = resolveMirrorSourceKey(mirrorService, stage, trimmed) + if (!key) return null + return MIRROR_KEY_MAP[stage]?.[key] ?? null +} + +/** + * 各段在 Runtime 链路下映射得到的旧镜像键,供界面过滤「换镜像重试」的候选列表。 + * + * 界面不该把 Runtime 根本收不下的镜像源摆出来(选了也只会被忽略),也不该自己抄一份 + * 键名,所以这里把上面那张映射表的键原样导出,映射表是唯一真相源。列表为空的段 + * (`dependency` 等)在 Runtime 模式下不展示镜像选择。 + */ +export function listRuntimeMappableMirrorKeys(): Record { + const result = {} as Record + for (const stage of Object.keys(INITIALIZATION_STAGE_INDEX) as InitializationRunStage[]) { + result[stage] = Object.keys(MIRROR_KEY_MAP[stage] ?? {}) + } + return result +} + +// ==================== 目标版本 ==================== + +/** + * 补齐 Runtime 要求的 `v` 前缀。 + * + * Runtime 用目标版本拼 `release/<版本>` 分支名,版本号必须以 `v` 开头; + * Electron 的 `app.getVersion()` 给的是不带 `v` 的 `5.5.0-beta.3`。 + */ +export function toRuntimeVersion(raw: string): string { + const trimmed = raw.trim() + return trimmed.startsWith('v') ? trimmed : `v${trimmed}` +} + +/** 首次安装的目标版本就是应用自身版本;更新流程的目标版本由更新任务另行给出。 */ +export function resolveRuntimeTargetVersion(): string { + return toRuntimeVersion(app.getVersion()) +} + +// ==================== 进度桥接 ==================== + +export interface BootstrapProgressUpdate { + stage: InitializationRunStage + status: InitializationStageStatus + progress: number + message: string +} + +/** bootstrap 实际经过的三个界面段,按现有界面的固定先后顺序排列。 */ +export const RUNTIME_BOOTSTRAP_STAGE_ORDER: readonly InitializationRunStage[] = [ + 'python', + 'repository', + 'dependency', +] + +/** 新链路没有对应物、进入 bootstrap 时立刻置为完成的三段。 */ +export const RUNTIME_TAKEOVER_STAGES: readonly InitializationRunStage[] = ['mirror', 'pip', 'git'] + +export const RUNTIME_TAKEOVER_MESSAGE = '由 Runtime 接管' +export const RUNTIME_DEVELOPMENT_SKIP_MESSAGE = '由 Runtime development 模式接管,跳过' + +/** 段刚开始时的粗略进度。Runtime 不给细粒度百分比时段内一直停在这个值。 */ +const STAGE_STARTED_PROGRESS = 10 + +/** + * 把 Runtime 的 progress / state 事件桥接成现有 7 段进度。 + * + * 只往前走,不回退:真实 bootstrap 的顺序是 uv → 仓库 → Python → 依赖(本仓库 + * `runtime/__fixtures__/bootstrap-success.ndjson` 是真机跑出来的),而 `uv.*` 与 + * `python.*` 都映射到 `python` 段,直接按事件重开段会让界面从「拉取源码」倒退回 + * 「安装 Python」。落后于当前段的事件仍会展示 Runtime 自己的文案,只是挂在当前段上。 + * + * 进度百分比只用 Runtime 真给的 `percent`:实测整条成功 bootstrap 的 73 条 progress + * 事件没有一条带 `percent` / `current` / `total`,依赖同步阶段更是一条 progress 都没有, + * 所以这里不编造段内百分比,段开始 10%、段结束 100%。 + */ +export class BootstrapProgressBridge { + private index = -1 + private closed = false + + constructor(private readonly emit: (update: BootstrapProgressUpdate) => void) {} + + /** 当前所在的段;尚未收到任何可映射事件时为 null。 */ + get currentStage(): InitializationRunStage | null { + return this.index < 0 ? null : RUNTIME_BOOTSTRAP_STAGE_ORDER[this.index] + } + + /** 进入 bootstrap:三个没有对应物的段立刻各发一个完成。 */ + takeOver(): void { + for (const stage of RUNTIME_TAKEOVER_STAGES) { + this.emit({ stage, status: 'completed', progress: 100, message: RUNTIME_TAKEOVER_MESSAGE }) + } + } + + /** 消费一条 Runtime 事件。 */ + observe(stage: RuntimeStage, message: string, percent?: number): void { + if (this.closed) return + + const mapped = mapRuntimeStage(stage) + const wanted = RUNTIME_BOOTSTRAP_STAGE_ORDER.indexOf(mapped) + // 落后段(含兜底段与 backend.*)挂在当前段上;一条事件都还没来过时从第一段开始。 + const target = wanted > this.index ? wanted : Math.max(this.index, 0) + + if (target > this.index) { + this.closeStagesBefore(target) + this.index = target + this.emit({ + stage: RUNTIME_BOOTSTRAP_STAGE_ORDER[target], + status: 'started', + progress: STAGE_STARTED_PROGRESS, + message, + }) + return + } + + this.emit({ + stage: RUNTIME_BOOTSTRAP_STAGE_ORDER[target], + status: 'running', + progress: percent === undefined ? STAGE_STARTED_PROGRESS : clampPercent(percent), + message, + }) + } + + /** bootstrap 成功:把还没关掉的段补成完成。 */ + finish(message: string): void { + if (this.closed) return + this.closeStagesBefore(RUNTIME_BOOTSTRAP_STAGE_ORDER.length, message) + this.index = RUNTIME_BOOTSTRAP_STAGE_ORDER.length + this.closed = true + } + + /** bootstrap 失败:在失败段上打一个 failed,后续事件不再发。 */ + fail(stage: InitializationRunStage, message: string): void { + if (this.closed) return + this.closed = true + this.emit({ stage, status: 'failed', progress: 0, message }) + } + + /** + * 把 [当前段, target) 之间的段全部置为完成。 + * + * 还没进过任何段时什么都不发:单步重试只会跑到某一段,不能顺手把它前面那些 + * 本次根本没执行的段也报成完成。 + */ + private closeStagesBefore(target: number, message = '完成'): void { + if (this.index < 0) return + for (let i = this.index; i < target; i += 1) { + this.emit({ + stage: RUNTIME_BOOTSTRAP_STAGE_ORDER[i], + status: 'completed', + progress: 100, + message, + }) + } + } +} + +function clampPercent(percent: number): number { + if (!Number.isFinite(percent)) return STAGE_STARTED_PROGRESS + return Math.min(100, Math.max(0, Math.round(percent))) +} + +// ==================== 结果 ==================== + +/** Runtime 链路的失败细节,与旧链路的失败形状叠加,界面(W9d)按需消费。 */ +export interface RuntimeStageOutcome { + success: boolean + error?: string + /** Runtime 的结构化结果码;旧链路不产生。 */ + code?: string + retryable?: boolean + remediation?: RuntimeRemediation[] + /** `[stdout]…\n\n[stderr]…` 整块文本,与旧链路失败界面的展示格式一致。 */ + logs?: string + /** + * Runtime 按命令与日期轮转的日志文件路径(`result.details.logPath`),供界面 + * 「打开日志」使用;不是每条命令都写日志文件,所以可能没有。 + */ + logPath?: string + /** 映射后的失败段名。 */ + failedStage?: InitializationRunStage +} + +/** + * 从事件 details 里读 Runtime 自己的轮转日志路径。 + * + * `details` 是裸 `Record`,Runtime 只在写了日志文件的命令上放 `logPath`, + * 所以拿不到就返回 undefined,由界面退回自己的日志文件。 + */ +export function readRuntimeLogPath(details: Record): string | undefined { + const logPath = details.logPath + return typeof logPath === 'string' && logPath.length > 0 ? logPath : undefined +} + +/** 可注入的客户端工厂,便于单元测试替换掉真实子进程。 */ +export type RuntimeClientFactory = (options: RuntimeClientOptions) => RuntimeClient + +/** + * 单步重试的处置强度。 + * + * `auto` 按上一次失败给出的 remediation 决定,是初始化界面「重试」按钮的行为; + * 更新流程要在界面上同时摆出「重试同步」与「重建环境」两个按钮,所以还能显式指定。 + */ +export type RuntimeRetryMode = 'auto' | 'sync' | 'rebuild' + +export interface RuntimeInitializationOptions { + launchConfig: RuntimeSupervisedLaunchConfig + /** 解析镜像选择要用到的旧 `MirrorService`;复用调用方已有的实例,这里不再新建。 */ + mirrorService: MirrorLookup + createClient?: RuntimeClientFactory + /** + * 本实例的目标版本,省略时用应用自身版本。 + * + * 首次安装装的就是应用自身版本;更新流程要装的是另一个版本,用同一个编排器但换目标, + * `bootstrap` 与 `workspace sync` 的 `--version` 都跟着它走。 + */ + targetVersion?: string +} + +// 走统一工厂而不是裸 new RuntimeClient:遥测开关(AUTO_MAS_TELEMETRY)由 createRuntimeClient +// 注入,这里不用再重复读一遍配置。 +const defaultClientFactory: RuntimeClientFactory = options => createRuntimeClient(options) + +/** + * Runtime 初始化链路的编排入口。 + * + * 只持有本次生命周期的启动配置与「上一次失败给出的处置动作」,进程与协议细节全部在 + * RuntimeClient 里,后端启动仍由 backendService 负责(W9c)。 + */ +export class RuntimeInitializationService { + private readonly createClient: RuntimeClientFactory + private readonly mirrorService: MirrorLookup + /** 各段上一次失败给出的 remediation,决定单步重试用普通重试还是重建环境。 */ + private readonly lastRemediation = new Map() + /** 在途命令的控制入口,用于下发 stdin `cancel`;没有命令在跑时为 null。 */ + private activeControl: RuntimeRunControl | null = null + + constructor(private readonly options: RuntimeInitializationOptions) { + this.createClient = options.createClient ?? defaultClientFactory + this.mirrorService = options.mirrorService + } + + get launchConfig(): RuntimeSupervisedLaunchConfig { + return this.options.launchConfig + } + + /** 本实例的目标版本;省略时退回应用自身版本。 */ + get targetVersion(): string { + return this.options.targetVersion ?? resolveRuntimeTargetVersion() + } + + /** + * 向在途命令下发 stdin `cancel`;没有命令在跑时返回 false。 + * + * 只是「请求」取消:Runtime 在提交点之后的迟到取消不会把已激活的现场伪装成取消, + * 最终结局仍以它给出的 `result` 为准。 + */ + cancel(): boolean { + const control = this.activeControl + if (!control) return false + control.cancel() + logger.info('已向在途 Runtime 命令下发 cancel') + return true + } + + /** + * 跑一次 `bootstrap --version <目标版本>`,把阶段映射进 `onProgress`。 + * + * bootstrap 只做准备工作,不启动后端;成功后由调用方另行启动 `backend supervise`。 + */ + async bootstrap( + onProgress: (update: BootstrapProgressUpdate) => void, + mirror?: RuntimeMirrorSelection | null + ): Promise { + const version = this.targetVersion + const bridge = new BootstrapProgressBridge(onProgress) + bridge.takeOver() + + const outcome = await this.execute(['bootstrap', '--version', version], mirror, bridge) + if (outcome.success) { + bridge.finish('运行环境准备完成') + } else { + bridge.fail( + outcome.failedStage ?? FALLBACK_INITIALIZATION_STAGE, + outcome.error ?? '初始化失败' + ) + } + return outcome + } + + /** + * 单步重试。 + * + * - 用户选了镜像源:镜像是全局选项,只能整条 `bootstrap` 重跑(映射不到就不传 + * `--mirror`,用 Runtime 自己的默认轮换); + * - 没选镜像源:走该段对应的下层命令,处置强度按 `mode` 决定。 + * + * `mirror` / `pip` / `git` 三段在新链路没有对应物,直接按成功返回。 + * + * `mode` 显式覆盖上一次失败留下的判断:初始化界面的「重建环境」按钮传 `rebuild`, + * 普通「重试」按钮走默认的 `auto`,两个按钮才不会做同一件事。 + */ + async retryStage( + stage: InitializationRunStage, + onProgress: (update: BootstrapProgressUpdate) => void, + mirrorKey?: string, + mode: RuntimeRetryMode = 'auto' + ): Promise { + if (stage === 'mirror' || stage === 'pip' || stage === 'git') { + logger.info(`${stage} 段在 Runtime 链路没有对应物,直接跳过`) + onProgress({ + stage, + status: 'completed', + progress: 100, + message: RUNTIME_TAKEOVER_MESSAGE, + }) + return { success: true } + } + + if (mirrorKey?.trim()) { + const mirror = mapMirrorSelection(this.mirrorService, stage, mirrorKey) + if (!mirror) { + logger.info(`镜像源 ${mirrorKey} 在 Runtime 目录里没有对应源,按默认轮换重跑 bootstrap`) + } + return this.bootstrap(onProgress, mirror) + } + + const command = this.resolveRetryCommand(stage, mode) + if (!command) { + logger.warn(`未知的重试段 ${stage},按整条 bootstrap 重跑`) + return this.bootstrap(onProgress) + } + + const bridge = new BootstrapProgressBridge(onProgress) + const outcome = await this.execute(command, null, bridge) + if (outcome.success) { + onProgress({ stage, status: 'completed', progress: 100, message: '完成' }) + } else { + bridge.fail(outcome.failedStage ?? stage, outcome.error ?? '重试失败') + } + return outcome + } + + /** + * 单步重试用的下层命令。 + * + * `python` 段的下层命令是 `environment ensure`,它只准备并校验固定版本 uv;本段还覆盖 + * 由 bootstrap 内部完成的 `uv python install`,所以要重建环境时直接用整体 `repair`, + * 而不是只重跑 uv 那半截。 + * + * `sync` / `rebuild` 由调用方显式给出时以它为准(界面上「重试」与「重建环境」是两个 + * 按钮);`auto` 沿用上一次失败的 remediation。更新流程要拿本次会话实际会跑的命令给 + * 界面看,所以这个方法是公开的。 + */ + resolveRetryCommand( + stage: InitializationRunStage, + mode: RuntimeRetryMode = 'auto' + ): string[] | null { + const needsRebuild = + mode === 'auto' + ? (this.lastRemediation.get(stage)?.includes('rebuild-environment') ?? false) + : mode === 'rebuild' + + switch (stage) { + case 'python': + return needsRebuild ? ['repair'] : ['environment', 'ensure'] + case 'repository': + return ['workspace', 'sync', '--version', this.targetVersion] + case 'dependency': + return needsRebuild ? ['dependencies', 'rebuild'] : ['dependencies', 'sync'] + default: + return null + } + } + + /** + * 问 Runtime `doctor` 要一份受管布局体检结果。 + * + * 返回 undefined 表示 doctor 自己没跑成,调用方按「查不出来」处理。 + */ + async doctor(): Promise { + const runtimePath = this.options.launchConfig.runtimePath + if (!runtimePath) return undefined + + try { + const client = this.createClient({ runtimePath, appRoot: this.options.launchConfig.appRoot }) + const outcome = await client.run(['doctor']) + if (!outcome.success) { + logger.warn(`Runtime doctor 报告失败: ${outcome.code} ${outcome.result.message}`) + return undefined + } + return parseDoctorChecks(outcome.result.details) + } catch (error) { + logger.warn( + `Runtime doctor 调用失败: ${error instanceof Error ? error.message : String(error)}` + ) + return undefined + } + } + + /** 跑一条 Runtime 命令,把事件桥接进进度,把失败转成现有失败形状。 */ + private async execute( + command: string[], + mirror: RuntimeMirrorSelection | null | undefined, + bridge: BootstrapProgressBridge + ): Promise { + const runtimePath = this.options.launchConfig.runtimePath + if (!runtimePath) { + // 灰度期一次生命周期只走一条链路,找不到可执行文件时直接失败展示,不回退旧链路。 + const definition = RUNTIME_CLIENT_ERROR_DEFINITIONS.RUNTIME_NOT_FOUND + const message = `找不到 Runtime 可执行文件,无法以 ${this.options.launchConfig.mode} 模式初始化` + logger.error(message) + return { + success: false, + error: message, + code: definition.code, + retryable: definition.retryable, + remediation: [...definition.remediation], + } + } + + // Runtime 把 uv / git 的原始输出逐行包成 log 事件转发,按流分开累积, + // 失败时组装成现有失败界面直接展示的整块文本。 + const stdoutLines: string[] = [] + const stderrLines: string[] = [] + + const client = this.createClient({ + runtimePath, + appRoot: this.options.launchConfig.appRoot, + mirrors: mirror ? [mirror] : undefined, + }) + + logger.info( + `执行 Runtime 命令: ${command.join(' ')}${mirror ? `(镜像 ${mirror.kind}=${mirror.key})` : ''}` + ) + + let outcome: RuntimeRunResult + try { + outcome = await client.run(command, { + onStarted: control => { + this.activeControl = control + }, + onProgress: event => bridge.observe(event.stage, event.message, event.percent), + onState: event => bridge.observe(event.stage, event.message), + onLog: event => { + if (event.stream === 'stderr') { + stderrLines.push(event.message) + return + } + stdoutLines.push(event.message) + }, + }) + } catch (error) { + if (isRuntimeClientError(error)) { + logger.error(`Runtime 调用失败: ${error.code} ${error.message}`) + return { + success: false, + error: error.message, + code: error.code, + retryable: error.retryable, + remediation: [...error.remediation], + logs: mergeRuntimeLogs(stdoutLines, stderrLines, error.details.stderr), + failedStage: bridge.currentStage ?? FALLBACK_INITIALIZATION_STAGE, + } + } + const message = error instanceof Error ? error.message : String(error) + logger.error(`Runtime 调用失败: ${message}`) + return { + success: false, + error: message, + logs: mergeRuntimeLogs(stdoutLines, stderrLines), + failedStage: bridge.currentStage ?? FALLBACK_INITIALIZATION_STAGE, + } + } finally { + this.activeControl = null + } + + if (outcome.success) { + logger.info(`Runtime 命令完成: ${command.join(' ')}`) + return { success: true } + } + + // 失败段优先取主错误事件的 stage:result 上带的可能是 `bootstrap` 这种顶层 stage。 + const errorEvent = outcome.errors[outcome.errors.length - 1] + const runtimeStage = errorEvent?.stage ?? outcome.result.stage + const failedStage = + mapRuntimeStageToInitializationStage(runtimeStage) ?? + bridge.currentStage ?? + FALLBACK_INITIALIZATION_STAGE + const remediation = [...outcome.result.remediation] + this.lastRemediation.set(failedStage, remediation) + + const message = outcome.result.message || `Runtime 命令失败(${outcome.code})` + logger.error(`Runtime 命令失败: ${outcome.code} ${message}`) + return { + success: false, + error: message, + code: outcome.code, + retryable: outcome.result.retryable, + remediation, + logs: mergeRuntimeLogs(stdoutLines, stderrLines, outcome.stderr), + logPath: readRuntimeLogPath(outcome.result.details), + failedStage, + } + } +} + +/** Runtime 自身的 stderr 诊断并入 `[stderr]` 块,避免失败界面一片空白。 */ +function mergeRuntimeLogs( + stdoutLines: string[], + stderrLines: string[], + runtimeStderr?: string +): string | undefined { + const diagnostics = runtimeStderr?.trimEnd() + const merged = diagnostics ? [...stderrLines, ...diagnostics.split(/\r?\n/)] : stderrLines + return formatStartupLogs(stdoutLines, merged) +} + +// ==================== doctor ==================== + +/** `doctor` 结果里的单项检查(result.details.checks)。 */ +export interface RuntimeDoctorCheck { + id: string + name: string + message: string + /** 实测取值为 `ok` / `missing` / `error`。 */ + status: string + details: Record +} + +function parseDoctorChecks(details: Record): RuntimeDoctorCheck[] | undefined { + const checks = details.checks + if (!Array.isArray(checks)) return undefined + + const parsed: RuntimeDoctorCheck[] = [] + for (const raw of checks) { + if (typeof raw !== 'object' || raw === null) continue + const entry = raw as Record + if (typeof entry.id !== 'string' || typeof entry.status !== 'string') continue + parsed.push({ + id: entry.id, + name: typeof entry.name === 'string' ? entry.name : entry.id, + message: typeof entry.message === 'string' ? entry.message : '', + status: entry.status, + details: + typeof entry.details === 'object' && entry.details !== null + ? (entry.details as Record) + : {}, + }) + } + return parsed +} + +/** 旧 `check-critical-files` 的返回形状。 */ +export interface CriticalFilesCheck { + pythonExists: boolean + pipExists: boolean + gitExists: boolean + mainPyExists: boolean + /** + * doctor 的逐项检查原文,只有 Runtime 链路产生。 + * + * 四个布尔量只回答「要不要初始化」,界面的「运行诊断」要展示的是每一项到底怎么了, + * 所以原样带上而不是再压缩一次。 + */ + runtimeChecks?: RuntimeDoctorCheck[] +} + +/** + * 把 doctor 的检查项映射成旧的四个布尔量。 + * + * 这四个布尔量存在的唯一目的是回答「要不要初始化」,新链路里权威答案只有一个: + * `layout.repo` 缺失就是没装过。另外两项在新链路里没有对应物——不再单独装 pip(uv 管依赖), + * 也不再安装 Git(Runtime 内置 Go Git)——恒为 true,不参与判定。 + */ +export function mapDoctorChecksToCriticalFiles(checks: RuntimeDoctorCheck[]): CriticalFilesCheck { + const byId = new Map(checks.map(check => [check.id, check])) + const layoutRepo = byId.get('layout')?.details?.repo + const repoPresent = layoutRepo !== undefined ? layoutRepo !== 'missing' : false + + return { + pythonExists: byId.get('python')?.status === 'ok', + pipExists: true, + gitExists: true, + mainPyExists: repoPresent, + runtimeChecks: checks, + } +} + +// ==================== development 模式 ==================== + +/** + * development 模式跳过全部安装步骤。 + * + * 开发检出自带 `.venv`,Runtime 的 development 模式只监督这份源码,不创建也不更新它, + * 所以六个准备段各发一个完成,直接进后端段。 + */ +export function emitDevelopmentSkipProgress( + onProgress: (update: BootstrapProgressUpdate) => void +): void { + const stages: InitializationRunStage[] = [ + 'mirror', + 'python', + 'pip', + 'git', + 'repository', + 'dependency', + ] + for (const stage of stages) { + onProgress({ + stage, + status: 'completed', + progress: 100, + message: RUNTIME_DEVELOPMENT_SKIP_MESSAGE, + }) + } +} diff --git a/frontend/electron/services/runtimeUpdateService.test.ts b/frontend/electron/services/runtimeUpdateService.test.ts new file mode 100644 index 000000000..026453074 --- /dev/null +++ b/frontend/electron/services/runtimeUpdateService.test.ts @@ -0,0 +1,570 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + cancelBackendUpdate, + describeRetryAction, + normalizeRuntimeUpdateVersion, + resetRuntimeUpdateSession, + resolveRetryActions, + retryBackendUpdate, + updateBackendViaRuntime, + type BackendUpdateController, + type RuntimeUpdateProgress, +} from './runtimeUpdateService' +import { RuntimeInitializationService } from './runtimeInitializationService' +import type { RuntimeEvent, RuntimeLaunchConfig, RuntimeRunOptions } from './runtime' + +vi.mock('electron', () => ({ app: { getVersion: () => '5.5.0-beta.3' } })) +vi.mock('./logger', () => ({ + getLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }), +})) + +const APP_ROOT = 'D:\\AUTO-MAS' +const RUNTIME_PATH = 'D:\\AUTO-MAS\\runtime\\auto-mas-runtime.exe' +const TARGET = 'v5.6.0' + +/** 停机、Runtime 命令与重启共用一条调用流水,顺序断言只看它。 */ +let callLog: string[] = [] + +// ==================== 假件 ==================== + +const base = { + protocol: 1, + operationId: '01M1F6M33JFZZ7Y85BE5S849ZN', + timestamp: '2026-09-01T22:03:00.000+02:00', +} + +const helloEvent = { + ...base, + type: 'hello', + sequence: 1, + runtimeVersion: 'dev', + command: 'bootstrap', + capabilities: ['stdin.cancel'], +} as unknown as RuntimeEvent + +function okResult(stage = 'bootstrap'): RuntimeEvent { + return { + ...base, + type: 'result', + sequence: 99, + success: true, + code: 'OK', + stage, + status: 'succeeded', + message: '完成', + retryable: false, + remediation: [], + details: {}, + } as unknown as RuntimeEvent +} + +function failResult(options: { + stage: string + code: string + message: string + remediation: string[] + logPath?: string +}): RuntimeEvent[] { + return [ + { + ...base, + type: 'error', + sequence: 40, + code: options.code, + stage: options.stage, + message: options.message, + retryable: true, + remediation: options.remediation, + details: {}, + } as unknown as RuntimeEvent, + { + ...base, + type: 'result', + sequence: 99, + success: false, + // 顶层 result 带的是 `bootstrap`,失败段必须从 error 事件上取。 + stage: 'bootstrap', + code: options.code, + status: 'failed', + message: options.message, + retryable: true, + remediation: options.remediation, + details: options.logPath ? { logPath: options.logPath } : {}, + } as unknown as RuntimeEvent, + ] +} + +function progressEvent(stage: string, message: string): RuntimeEvent { + return { + ...base, + type: 'progress', + sequence: 20, + stage, + status: 'running', + message, + } as unknown as RuntimeEvent +} + +function logEvent(stream: 'stdout' | 'stderr', message: string): RuntimeEvent { + return { + ...base, + type: 'log', + sequence: 10, + stage: 'dependencies.sync', + stream, + message, + } as unknown as RuntimeEvent +} + +/** 按脚本回放事件,并把每次 argv 记进公共流水。 */ +class FakeRuntimeClient { + static scripts: RuntimeEvent[][] = [] + static index = 0 + + constructor(readonly options: { runtimePath: string; appRoot: string }) {} + + async run(command: string[], options: RuntimeRunOptions = {}) { + callLog.push(`run:${command.join(' ')}`) + const script = + FakeRuntimeClient.scripts[ + Math.min(FakeRuntimeClient.index++, FakeRuntimeClient.scripts.length - 1) + ] + if (!script) throw new Error('测试未准备事件脚本') + + options.onStarted?.({ + pid: 4242, + sendControl: () => 'CMD', + cancel: () => { + callLog.push('stdin:cancel') + return 'CMD' + }, + kill: () => undefined, + }) + + let result: RuntimeEvent | undefined + const errors: RuntimeEvent[] = [] + for (const event of script) { + switch (event.type) { + case 'progress': + options.onProgress?.(event) + break + case 'state': + options.onState?.(event) + break + case 'log': + options.onLog?.(event) + break + case 'error': + errors.push(event) + options.onRuntimeError?.(event) + break + case 'result': + result = event + break + } + } + + if (!result || result.type !== 'result') throw new Error('测试脚本缺少 result 事件') + return { + hello: script[0], + result, + success: result.success, + code: result.code, + events: script, + warnings: [], + errors, + logs: {}, + protocolErrors: [], + exitCode: result.success ? 0 : 50, + signal: null, + stderr: '', + argv: command, + durationMs: 1, + } + } +} + +interface FakeBackendOptions { + stop?: { success: boolean; error?: string } + start?: { success: boolean; error?: string; logs?: string; code?: string } + onStop?: () => void +} + +function createBackend(options: FakeBackendOptions = {}): BackendUpdateController { + return { + async stopBackend() { + callLog.push('stopBackend') + options.onStop?.() + return options.stop ?? { success: true } + }, + async startBackend() { + callLog.push('startBackend') + return options.start ?? { success: true } + }, + } +} + +function managedConfig(): RuntimeLaunchConfig { + return { mode: 'managed', runtimePath: RUNTIME_PATH, appRoot: APP_ROOT } +} + +function developmentConfig(): RuntimeLaunchConfig { + return { mode: 'development', runtimePath: RUNTIME_PATH, appRoot: APP_ROOT, repo: APP_ROOT } +} + +function createDeps(backend: BackendUpdateController, launchConfig: RuntimeLaunchConfig) { + return { + backend, + launchConfig, + createRuntimeService: ( + options: ConstructorParameters[0] + ) => + new RuntimeInitializationService({ + ...options, + createClient: clientOptions => new FakeRuntimeClient(clientOptions) as never, + }), + } +} + +const progressUpdates: RuntimeUpdateProgress[] = [] +const collect = (update: RuntimeUpdateProgress): void => { + progressUpdates.push(update) +} + +beforeEach(() => { + callLog = [] + progressUpdates.length = 0 + FakeRuntimeClient.scripts = [[helloEvent, okResult()]] + FakeRuntimeClient.index = 0 + resetRuntimeUpdateSession() +}) + +// ==================== 版本号 ==================== + +describe('目标版本规范化', () => { + it('补齐 v 前缀并去掉首尾空白', () => { + expect(normalizeRuntimeUpdateVersion('5.6.0')).toBe('v5.6.0') + expect(normalizeRuntimeUpdateVersion('v5.6.0')).toBe('v5.6.0') + expect(normalizeRuntimeUpdateVersion(' v5.5.0-beta.3 ')).toBe('v5.5.0-beta.3') + expect(normalizeRuntimeUpdateVersion('5.5.0-beta.3')).toBe('v5.5.0-beta.3') + }) + + it('路径分隔符、空白与 .. 一律判非法', () => { + expect(normalizeRuntimeUpdateVersion('release/v5.6.0')).toBeNull() + expect(normalizeRuntimeUpdateVersion('v5.6.0/../main')).toBeNull() + expect(normalizeRuntimeUpdateVersion('v5.6.0\\evil')).toBeNull() + expect(normalizeRuntimeUpdateVersion('v5.6 .0')).toBeNull() + expect(normalizeRuntimeUpdateVersion('..')).toBeNull() + expect(normalizeRuntimeUpdateVersion('')).toBeNull() + expect(normalizeRuntimeUpdateVersion(' ')).toBeNull() + expect(normalizeRuntimeUpdateVersion('latest')).toBeNull() + expect(normalizeRuntimeUpdateVersion(undefined)).toBeNull() + expect(normalizeRuntimeUpdateVersion(5.6)).toBeNull() + }) + + it('非法版本直接拒绝,不动后端也不调 Runtime', async () => { + const outcome = await updateBackendViaRuntime( + 'release/v5.6.0', + collect, + createDeps(createBackend(), managedConfig()) + ) + + expect(outcome.success).toBe(false) + expect(outcome.phase).toBe('shutdown') + expect(outcome.code).toBe('INVALID_VERSION') + expect(callLog).toEqual([]) + }) +}) + +// ==================== 三步顺序 ==================== + +describe('停机 → bootstrap → 重新监督', () => { + it('严格按顺序执行,bootstrap 带规范化后的目标版本', async () => { + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(createBackend(), managedConfig()) + ) + + expect(outcome.success).toBe(true) + expect(callLog).toEqual(['stopBackend', `run:bootstrap --version ${TARGET}`, 'startBackend']) + }) + + it('首尾各补一个停机与重启的进度态,中间沿用初始化界面的段', async () => { + await updateBackendViaRuntime('5.6.0', collect, createDeps(createBackend(), managedConfig())) + + expect(progressUpdates[0]).toEqual({ + stage: 'shutdown', + status: 'started', + progress: 0, + message: '正在停止当前后端', + }) + expect(progressUpdates.at(-1)).toMatchObject({ stage: 'restart', status: 'completed' }) + // W9b 的接管逻辑照搬:mirror / pip / git 在进 bootstrap 时立刻置完成。 + const takenOver = progressUpdates.filter(update => update.message === '由 Runtime 接管') + expect(takenOver.map(update => update.stage)).toEqual(['mirror', 'pip', 'git']) + }) + + it('停不掉旧后端时结局是 shutdown,bootstrap 根本不跑', async () => { + const backend = createBackend({ stop: { success: false, error: '关闭超时' } }) + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(backend, managedConfig()) + ) + + expect(outcome).toMatchObject({ + success: false, + phase: 'shutdown', + error: '关闭超时', + retryable: true, + remediation: ['stop-backend'], + }) + expect(callLog).toEqual(['stopBackend']) + expect(progressUpdates.at(-1)).toMatchObject({ stage: 'shutdown', status: 'failed' }) + }) +}) + +// ==================== bootstrap 失败 ==================== + +describe('bootstrap 失败的两种现场', () => { + it('克隆失败:旧 repo 保留,重试入口是 workspace sync --version', async () => { + FakeRuntimeClient.scripts = [ + [ + helloEvent, + ...failResult({ + stage: 'workspace.clone', + code: 'GIT_CLONE_FAILED', + message: '浅克隆 release/v5.6.0 失败', + remediation: ['retry', 'retry-other-mirror'], + logPath: 'D:\\AUTO-MAS\\logs\\runtime\\bootstrap-20260901.log', + }), + ], + ] + + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(createBackend(), managedConfig()) + ) + + expect(outcome).toMatchObject({ + success: false, + phase: 'bootstrap', + code: 'GIT_CLONE_FAILED', + retryable: true, + remediation: ['retry', 'retry-other-mirror'], + logPath: 'D:\\AUTO-MAS\\logs\\runtime\\bootstrap-20260901.log', + retryActions: ['workspace-sync'], + }) + // 后端没能起回来,也不该被偷偷拉起。 + expect(callLog).toEqual(['stopBackend', `run:bootstrap --version ${TARGET}`]) + expect(describeRetryAction('workspace-sync')).toEqual([ + 'workspace', + 'sync', + '--version', + TARGET, + ]) + }) + + it('依赖同步失败:environment_broken,三个重试入口各自对应正确的命令', async () => { + FakeRuntimeClient.scripts = [ + [ + helloEvent, + logEvent('stdout', 'Resolved 210 packages'), + logEvent('stderr', 'error: failed to build wheel'), + ...failResult({ + stage: 'dependencies.sync', + code: 'DEPENDENCY_SYNC_FAILED', + message: 'uv sync 失败', + remediation: ['retry-sync', 'rebuild-environment'], + }), + ], + ] + + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(createBackend(), managedConfig()) + ) + + expect(outcome).toMatchObject({ + success: false, + phase: 'bootstrap', + code: 'DEPENDENCY_SYNC_FAILED', + remediation: ['retry-sync', 'rebuild-environment'], + retryActions: ['dependencies-sync', 'dependencies-rebuild', 'repair'], + }) + expect(outcome.logs).toContain('[stdout]') + expect(outcome.logs).toContain('[stderr]') + + expect(describeRetryAction('dependencies-sync')).toEqual(['dependencies', 'sync']) + expect(describeRetryAction('dependencies-rebuild')).toEqual(['dependencies', 'rebuild']) + expect(describeRetryAction('repair')).toEqual(['repair']) + }) + + it('单步重试成功后继续把后端拉起来', async () => { + FakeRuntimeClient.scripts = [ + [ + helloEvent, + ...failResult({ + stage: 'dependencies.sync', + code: 'DEPENDENCY_SYNC_FAILED', + message: 'uv sync 失败', + remediation: ['retry-sync', 'rebuild-environment'], + }), + ], + [helloEvent, okResult('dependencies.sync')], + ] + + await updateBackendViaRuntime('5.6.0', collect, createDeps(createBackend(), managedConfig())) + callLog = [] + + const retried = await retryBackendUpdate('dependencies-sync', collect) + + expect(retried.success).toBe(true) + // 上一次失败给了 rebuild-environment,但显式选「重试同步」时不能被改写成 rebuild。 + expect(callLog).toEqual(['run:dependencies sync', 'startBackend']) + }) + + it('失败段到重试入口的映射', () => { + expect(resolveRetryActions('repository')).toEqual(['workspace-sync']) + expect(resolveRetryActions('dependency')).toEqual([ + 'dependencies-sync', + 'dependencies-rebuild', + 'repair', + ]) + expect(resolveRetryActions('python')).toEqual(['repair']) + expect(resolveRetryActions(undefined)).toEqual(['repair']) + }) +}) + +// ==================== 新后端起不来 ==================== + +describe('重新监督失败', () => { + it('结局是 restart,展示 formatStartupLogs 的整块日志', async () => { + const backend = createBackend({ + start: { + success: false, + error: '后端在就绪前结束(BACKEND_EXITED_BEFORE_READY)', + code: 'BACKEND_EXITED_BEFORE_READY', + logs: '[stdout]\nINFO 启动中\n\n[stderr]\nModuleNotFoundError: no module named app', + }, + }) + + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(backend, managedConfig()) + ) + + expect(outcome).toMatchObject({ + success: false, + phase: 'restart', + code: 'BACKEND_EXITED_BEFORE_READY', + }) + expect(outcome.logs).toContain('[stdout]') + expect(outcome.logs).toContain('[stderr]') + expect(callLog).toEqual(['stopBackend', `run:bootstrap --version ${TARGET}`, 'startBackend']) + expect(progressUpdates.at(-1)).toMatchObject({ stage: 'restart', status: 'failed' }) + }) +}) + +// ==================== 取消 ==================== + +describe('取消更新', () => { + it('bootstrap 开始前取消:不跑 Runtime 命令,把旧后端拉回来', async () => { + const backend = createBackend({ + onStop: () => { + cancelBackendUpdate() + }, + }) + + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(backend, managedConfig()) + ) + + expect(outcome).toMatchObject({ success: false, phase: 'shutdown', cancelled: true }) + expect(callLog).toEqual(['stopBackend', 'startBackend']) + }) + + it('bootstrap 进行中取消:走 stdin cancel,结局仍按 Runtime 给的 result 算', async () => { + FakeRuntimeClient.scripts = [ + [ + helloEvent, + progressEvent('workspace.clone', '正在克隆 release/v5.6.0'), + ...failResult({ + stage: 'workspace.clone', + code: 'OPERATION_CANCELLED', + message: '操作已取消', + remediation: ['retry'], + }), + ], + ] + + const outcome = await updateBackendViaRuntime( + '5.6.0', + update => { + collect(update) + // 克隆刚开始时按下取消,等价于用户在进度弹窗上点「取消更新」。 + if (update.stage === 'repository' && update.status === 'started') cancelBackendUpdate() + }, + createDeps(createBackend(), managedConfig()) + ) + + expect(callLog).toEqual(['stopBackend', `run:bootstrap --version ${TARGET}`, 'stdin:cancel']) + expect(outcome).toMatchObject({ + success: false, + phase: 'bootstrap', + cancelled: true, + code: 'OPERATION_CANCELLED', + retryActions: ['workspace-sync'], + }) + }) + + it('没有进行中的会话时取消不受理', () => { + expect(cancelBackendUpdate()).toEqual({ accepted: false, forwarded: false }) + }) +}) + +// ==================== 模式分流 ==================== + +describe('模式分流', () => { + it('development 模式直接返回不支持,一条 Runtime 命令都不发', async () => { + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(createBackend(), developmentConfig()) + ) + + expect(outcome).toMatchObject({ + success: false, + unsupported: true, + code: 'RUNTIME_UPDATE_UNSUPPORTED', + retryable: false, + }) + expect(callLog).toEqual([]) + expect(progressUpdates).toEqual([]) + }) + + it('灰度开关关闭时同样不接管', async () => { + const outcome = await updateBackendViaRuntime( + '5.6.0', + collect, + createDeps(createBackend(), { mode: 'off', runtimePath: null, appRoot: APP_ROOT }) + ) + + expect(outcome.unsupported).toBe(true) + expect(callLog).toEqual([]) + }) +}) diff --git a/frontend/electron/services/runtimeUpdateService.ts b/frontend/electron/services/runtimeUpdateService.ts new file mode 100644 index 000000000..b945ac2c1 --- /dev/null +++ b/frontend/electron/services/runtimeUpdateService.ts @@ -0,0 +1,418 @@ +/** + * Runtime 链路的后端更新 + * + * 灰度开关打开后,后端更新不再由 Python 侧自己下载 `UpdatePack_<版本>.zip` 再拉起 + * Inno Setup 安装器整包替换,而是由 Electron 做停机与更新编排者,严格按三步走: + * + * 1. 向当前 `backend supervise` 的 stdin 发 `shutdown`,等最终 `result` 与进程退出 + * (backendService 的 `stopBackend()` 已封装)——`workspace sync` 发现后端仍在跑会 + * 直接返回 `BACKEND_STILL_RUNNING`,所以这一步必须真的等到退出; + * 2. `bootstrap --version v<新版本>`:临时目录浅克隆 `release/<新版本>`、校验通过后整体 + * 替换 `repo/`,再同步 Python 与依赖; + * 3. 重新 `backend supervise`(backendService 的 `startBackend()`)。 + * + * 三步各自的失败后果完全不同,所以失败结果里带 `phase`,界面据此给出不同的处置入口, + * 见 `RuntimeUpdatePhase`。进度桥接、阶段映射与单步重试全部复用初始化链路 + * (runtimeInitializationService),这里只做编排,不重写一套。 + */ + +import type { BackendStartResult, BackendStopResult } from './backendService' +import { getLogger } from './logger' +import { MirrorService } from './mirrorService' +import type { RuntimeLaunchConfig, RuntimeRemediation } from './runtime' +import { + InitializationRunStage, + InitializationStageStatus, + RuntimeInitializationOptions, + RuntimeInitializationService, + RuntimeRetryMode, + RuntimeStageOutcome, + toRuntimeVersion, +} from './runtimeInitializationService' + +const logger = getLogger('Runtime更新') + +// ==================== 阶段与结局 ==================== + +/** + * 更新失败的三类结局。 + * + * - `shutdown`:旧后端还在(没停掉,或者根本没开始动作,例如版本号非法)。源码与环境 + * 一动没动,用户可以直接取消更新继续用旧版本; + * - `bootstrap`:源码可能已经被整体替换、环境可能是 `environment_broken`。Runtime 只在 + * 克隆或校验失败时才保证保留旧 `repo/`;一旦 `uv sync` 失败,源码已经是新版本而环境 + * 坏了,回不去,只能重试同步或重建环境; + * - `restart`:源码与环境都已就位,但新后端没起来。展示 `formatStartupLogs` 的整块日志。 + */ +export type RuntimeUpdatePhase = 'shutdown' | 'bootstrap' | 'restart' + +/** 更新流程的进度段:首尾两段是更新独有的,中间七段与初始化界面完全一致。 */ +export type RuntimeUpdateStage = 'shutdown' | InitializationRunStage | 'restart' + +export interface RuntimeUpdateProgress { + stage: RuntimeUpdateStage + status: InitializationStageStatus + progress: number + message: string +} + +/** + * 失败后可用的重试入口。 + * + * 全部由初始化链路的单步重试执行,不另写命令: + * - `workspace-sync` → `workspace sync --version v<目标版本>` + * - `dependencies-sync` → `dependencies sync` + * - `dependencies-rebuild` → `dependencies rebuild` + * - `repair` → `repair` + */ +export type RuntimeUpdateRetryAction = + | 'workspace-sync' + | 'dependencies-sync' + | 'dependencies-rebuild' + | 'repair' + +/** 重试入口到初始化链路单步重试参数的映射。 */ +const RETRY_ACTION_MAP: Readonly< + Record +> = { + 'workspace-sync': { stage: 'repository', mode: 'auto' }, + 'dependencies-sync': { stage: 'dependency', mode: 'sync' }, + 'dependencies-rebuild': { stage: 'dependency', mode: 'rebuild' }, + repair: { stage: 'python', mode: 'rebuild' }, +} + +export interface RuntimeUpdateOutcome { + success: boolean + /** 失败时必有;成功时不写。 */ + phase?: RuntimeUpdatePhase + error?: string + /** Runtime 的结构化结果码,原样透传,不做翻译。 */ + code?: string + retryable?: boolean + remediation?: RuntimeRemediation[] + /** `[stdout]…\n\n[stderr]…` 整块文本。 */ + logs?: string + /** Runtime 自己的轮转日志路径。 */ + logPath?: string + /** 本次失败可用的重试入口,按推荐顺序排列。 */ + retryActions?: RuntimeUpdateRetryAction[] + /** 用户主动取消。 */ + cancelled?: boolean + /** 当前模式根本不支持自动更新(development 或灰度开关关闭)。 */ + unsupported?: boolean +} + +/** 更新流程只用到 backendService 的这两个方法,测试直接给桩。 */ +export interface BackendUpdateController { + stopBackend(): Promise + startBackend(): Promise +} + +export interface RuntimeUpdateDependencies { + backend: BackendUpdateController + /** 本次生命周期的启动模式与 Runtime 路径,由调用方解析后传入。 */ + launchConfig: RuntimeLaunchConfig + /** 省略时用真实的初始化编排器。 */ + createRuntimeService?: (options: RuntimeInitializationOptions) => RuntimeInitializationService +} + +// ==================== 版本号 ==================== + +/** + * 目标版本的合法形态:`v` 加点分数字,可跟一段预发布/构建后缀。 + * + * Runtime 把它拼进 `release/<版本>` 分支名,非法字符必须在这里挡掉,不能交给 Runtime + * 报错——一个带 `/` 的版本号会变成另一个分支名,而不是一个报错。 + */ +const RUNTIME_VERSION_PATTERN = /^v\d+(\.\d+)*([-+][0-9A-Za-z.-]+)?$/ + +/** + * 把 `/api/update/check` 给的版本号规范成 Runtime 要的 `v`。 + * + * MirrorChyan 返回的 `version_name` 可能带 `v` 也可能不带(本仓库的发布标签是带的), + * 统一补齐;含路径分隔符、空白或 `..` 的一律判非法,返回 null。 + */ +export function normalizeRuntimeUpdateVersion(raw: unknown): string | null { + if (typeof raw !== 'string') return null + + const trimmed = raw.trim() + if (!trimmed) return null + // 路径穿越与分隔符先于格式校验挡掉,避免任何形态被拼进分支名。 + if (/[\s/\\]/.test(trimmed) || trimmed.includes('..')) return null + + const normalized = toRuntimeVersion(trimmed) + return RUNTIME_VERSION_PATTERN.test(normalized) ? normalized : null +} + +// ==================== 会话 ==================== + +/** + * 一次更新会话。 + * + * 单步重试要和它前面那次 bootstrap 用同一个编排器实例(后者记着上一次失败给出的 + * remediation,也记着本次的目标版本),所以会话在模块级保留到下一次更新开始。 + */ +interface UpdateSession { + version: string + runtimeService: RuntimeInitializationService + backend: BackendUpdateController + cancelRequested: boolean +} + +let session: UpdateSession | null = null + +/** 仅供测试与应用退出清场。 */ +export function resetRuntimeUpdateSession(): void { + session = null +} + +const defaultRuntimeServiceFactory = ( + options: RuntimeInitializationOptions +): RuntimeInitializationService => new RuntimeInitializationService(options) + +// ==================== 编排 ==================== + +const STOP_MESSAGE = '正在停止当前后端' +const STOP_DONE_MESSAGE = '后端已停止' +const RESTART_MESSAGE = '正在重新启动后端' +const RESTART_DONE_MESSAGE = '后端已重新启动' +export const RUNTIME_UPDATE_UNSUPPORTED_CODE = 'RUNTIME_UPDATE_UNSUPPORTED' +export const RUNTIME_UPDATE_INVALID_VERSION_CODE = 'INVALID_VERSION' + +/** + * 走 Runtime 链路更新后端:停机 → bootstrap → 重新监督。 + * + * @param targetVersion `/api/update/check` 给的目标版本,带不带 `v` 都行。 + * @param onProgress 首段 `shutdown`、中间七段沿用初始化界面的段模型、末段 `restart`。 + */ +export async function updateBackendViaRuntime( + targetVersion: string, + onProgress: (update: RuntimeUpdateProgress) => void, + deps: RuntimeUpdateDependencies +): Promise { + const { launchConfig } = deps + + if (launchConfig.mode === 'off') { + logger.info('灰度开关关闭,Runtime 更新链路不可用') + return unsupported('灰度开关关闭时后端更新仍走原有的下载安装包流程') + } + + if (launchConfig.mode === 'development') { + // 开发检出是开发者自己的源码,Runtime 只监督它,绝不替换。 + logger.info('development 模式不支持自动更新后端源码') + return unsupported('开发模式下 Runtime 不管理源码,请自行更新本地检出') + } + + const version = normalizeRuntimeUpdateVersion(targetVersion) + if (!version) { + const message = `目标版本号非法:${String(targetVersion)}` + logger.error(message) + return { + success: false, + phase: 'shutdown', + error: message, + code: RUNTIME_UPDATE_INVALID_VERSION_CODE, + retryable: false, + remediation: ['select-version'], + } + } + + const runtimeService = (deps.createRuntimeService ?? defaultRuntimeServiceFactory)({ + launchConfig, + targetVersion: version, + // 更新流程不按段重试,这里的镜像查找只为满足构造契约;镜像配置与初始化流程同源。 + mirrorService: new MirrorService(launchConfig.appRoot), + }) + const current: UpdateSession = { + version, + runtimeService, + backend: deps.backend, + cancelRequested: false, + } + session = current + + logger.info(`开始经 Runtime 更新后端到 ${version}`) + + // ---------- 1. 停机 ---------- + onProgress({ stage: 'shutdown', status: 'started', progress: 0, message: STOP_MESSAGE }) + const stopResult = await deps.backend.stopBackend() + if (!stopResult.success) { + const message = stopResult.error ?? '停止当前后端失败' + logger.error(`更新中止:${message}`) + onProgress({ stage: 'shutdown', status: 'failed', progress: 0, message }) + return { + success: false, + phase: 'shutdown', + error: message, + retryable: true, + remediation: ['stop-backend'], + } + } + onProgress({ stage: 'shutdown', status: 'completed', progress: 100, message: STOP_DONE_MESSAGE }) + + // 停机期间按了取消:源码一动没动,直接把旧后端拉回来。 + if (current.cancelRequested) { + logger.info('更新在 bootstrap 开始前被取消,重新启动旧后端') + return finishCancelledBeforeBootstrap(current, onProgress) + } + + // ---------- 2. bootstrap ---------- + const bootstrapOutcome = await runtimeService.bootstrap(update => onProgress(update)) + if (!bootstrapOutcome.success) { + return buildBootstrapFailure(bootstrapOutcome, current.cancelRequested) + } + + // ---------- 3. 重新监督 ---------- + return restartBackend(current, onProgress) +} + +/** + * 单步重试:只重跑失败的那一段,成功后继续把后端拉起来。 + * + * 必须在同一次更新会话内调用——重试用的目标版本与「上次失败要不要重建环境」都存在 + * 那个会话的编排器实例里。 + */ +export async function retryBackendUpdate( + action: RuntimeUpdateRetryAction, + onProgress: (update: RuntimeUpdateProgress) => void +): Promise { + const current = session + if (!current) { + const message = '没有进行中的更新会话,请重新发起更新' + logger.warn(message) + return { success: false, phase: 'bootstrap', error: message, retryable: false } + } + + const mapped = RETRY_ACTION_MAP[action] + current.cancelRequested = false + logger.info(`重试更新入口 ${action}(段 ${mapped.stage},模式 ${mapped.mode})`) + + const outcome = await current.runtimeService.retryStage( + mapped.stage, + update => onProgress(update), + undefined, + mapped.mode + ) + if (!outcome.success) { + return buildBootstrapFailure(outcome, current.cancelRequested) + } + + return restartBackend(current, onProgress) +} + +/** 本次会话实际会执行的命令,供界面与测试确认重试入口没接错。 */ +export function describeRetryAction(action: RuntimeUpdateRetryAction): string[] | null { + const current = session + if (!current) return null + const mapped = RETRY_ACTION_MAP[action] + return current.runtimeService.resolveRetryCommand(mapped.stage, mapped.mode) +} + +/** + * 请求取消。 + * + * 只在停机之前或 bootstrap 尚未替换 `repo/` 时有意义:Runtime 保证克隆未完成时保留旧 + * 仓库,提交点之后的迟到取消不会把已激活的现场伪装成取消,结局仍以它的 `result` 为准。 + */ +export function cancelBackendUpdate(): { accepted: boolean; forwarded: boolean } { + const current = session + if (!current) return { accepted: false, forwarded: false } + + current.cancelRequested = true + const forwarded = current.runtimeService.cancel() + logger.info(`已受理更新取消请求${forwarded ? ',并已下发 stdin cancel' : ''}`) + return { accepted: true, forwarded } +} + +// ==================== 内部 ==================== + +function unsupported(message: string): RuntimeUpdateOutcome { + return { + success: false, + phase: 'shutdown', + unsupported: true, + error: message, + code: RUNTIME_UPDATE_UNSUPPORTED_CODE, + retryable: false, + } +} + +/** 取消发生在 bootstrap 之前:后端已经停了,得把它按原样拉回来。 */ +async function finishCancelledBeforeBootstrap( + current: UpdateSession, + onProgress: (update: RuntimeUpdateProgress) => void +): Promise { + const restarted = await restartBackend(current, onProgress) + if (!restarted.success) return { ...restarted, cancelled: true } + return { success: false, phase: 'shutdown', cancelled: true, error: '更新已取消' } +} + +async function restartBackend( + current: UpdateSession, + onProgress: (update: RuntimeUpdateProgress) => void +): Promise { + onProgress({ stage: 'restart', status: 'started', progress: 0, message: RESTART_MESSAGE }) + + const startResult = await current.backend.startBackend() + if (!startResult.success) { + const message = startResult.error ?? '后端启动失败' + logger.error(`更新后重新启动后端失败: ${message}`) + onProgress({ stage: 'restart', status: 'failed', progress: 0, message }) + return { + success: false, + phase: 'restart', + error: message, + code: startResult.code, + retryable: startResult.retryable, + remediation: startResult.remediation, + logs: startResult.logs, + } + } + + onProgress({ + stage: 'restart', + status: 'completed', + progress: 100, + message: RESTART_DONE_MESSAGE, + }) + logger.info(`后端已更新到 ${current.version} 并重新启动`) + return { success: true } +} + +/** bootstrap 或单步重试失败:结局一律是 `bootstrap`,附上该段对应的重试入口。 */ +function buildBootstrapFailure( + outcome: RuntimeStageOutcome, + cancelled: boolean +): RuntimeUpdateOutcome { + return { + success: false, + phase: 'bootstrap', + error: outcome.error, + code: outcome.code, + retryable: outcome.retryable, + remediation: outcome.remediation, + logs: outcome.logs, + logPath: outcome.logPath, + retryActions: resolveRetryActions(outcome.failedStage), + ...(cancelled ? { cancelled: true } : {}), + } +} + +/** + * 失败段到重试入口。 + * + * 仓库段失败时旧 `repo/` 仍在,只要重跑 `workspace sync`;依赖段失败时源码已经是新版本 + * 而环境标记为 `environment_broken`,退不回去,只能重试同步、重建依赖或整体修复。 + */ +export function resolveRetryActions( + failedStage: InitializationRunStage | undefined +): RuntimeUpdateRetryAction[] { + switch (failedStage) { + case 'repository': + return ['workspace-sync'] + case 'dependency': + return ['dependencies-sync', 'dependencies-rebuild', 'repair'] + default: + return ['repair'] + } +} diff --git a/frontend/src/components/TitleBar.vue b/frontend/src/components/TitleBar.vue index 81b1400d7..fc35d9398 100644 --- a/frontend/src/components/TitleBar.vue +++ b/frontend/src/components/TitleBar.vue @@ -24,7 +24,14 @@ 检测到更新 {{ updateInfo.latest_version }} 请尽快更新 + {{ t('comp.backendUpdateDevUnsupported') }} + + @@ -64,6 +71,70 @@ + + + +
+ + + +
+
@@ -75,6 +146,7 @@ import { updateInfo, backendUpdateInfo } from '@/composables/useVersionService' import { useUpdateModal } from '@/composables/useUpdateChecker' import { useAppInitialization } from '@/composables/useAppInitialization' import { useUpdateDownload } from '@/composables/useUpdateDownload' +import { useBackendRuntimeUpdate } from '@/composables/useBackendRuntimeUpdate' import { useUiPreferences } from '@/composables/useUiPreferences' import { useSchedulerLogic } from '@/views/scheduler/useSchedulerLogic' import { @@ -87,6 +159,8 @@ import { Modal } from 'ant-design-vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' +import type { RuntimeUpdateRetryAction } from '@/types/electron' + const { t } = useI18n() const logger = window.electronAPI.getLogger('标题栏') @@ -104,6 +178,56 @@ const { open: openDownloadModal, } = useUpdateDownload() +const { + ensureLaunchMode, + isRuntimeManaged, + isRuntimeDevelopment, + modalVisible: updateModalVisible, + running: updateRunning, + cancelling: updateCancelling, + restartingBackend: updateRestartingBackend, + targetVersion: updateTargetVersion, + currentMessage: updateCurrentMessage, + overallPercent: updateOverallPercent, + outcome: updateOutcome, + start: startRuntimeUpdate, + retry: retryUpdate, + cancel: cancelUpdate, + restartBackend: restartBackendAfterUpdate, + close: closeUpdateModal, +} = useBackendRuntimeUpdate() + +const updateAlertType = computed(() => { + const result = updateOutcome.value + if (!result) return 'info' + if (result.success) return 'success' + return result.cancelled ? 'warning' : 'error' +}) + +// 三类失败结局各有各的后果,文案不能共用一句「更新失败」。 +const updateAlertMessage = computed(() => { + const result = updateOutcome.value + if (!result) return '' + if (result.success) return t('comp.backendUpdateSucceeded') + if (result.cancelled) return t('comp.backendUpdateCancelled') + if (result.unsupported) return t('comp.backendUpdateUnsupportedMode') + + if (result.phase === 'shutdown') return t('comp.backendUpdateFailedShutdown') + if (result.phase === 'restart') return t('comp.backendUpdateFailedRestart') + return t('comp.backendUpdateFailedBootstrap') +}) + +// 常量数组要放进 computed,否则切换语言后按钮文案不跟着变。 +const retryActionLabels = computed>(() => ({ + 'workspace-sync': t('comp.backendUpdateRetryWorkspaceSync'), + 'dependencies-sync': t('comp.backendUpdateRetryDependenciesSync'), + 'dependencies-rebuild': t('comp.backendUpdateRetryDependenciesRebuild'), + repair: t('comp.backendUpdateRetryRepair'), +})) + +const retryActionLabel = (action: RuntimeUpdateRetryAction): string => + retryActionLabels.value[action] + const downloadHint = computed(() => { if (downloadStatus.value === 'completed') return '下载完成,点击安装' if (downloadStatus.value === 'switchingSource') return '正在切换至 CNB 源' @@ -146,6 +270,14 @@ const handleAppUpdateClick = () => { showUpdateModal(updateInfo.value.update_info || {}, updateInfo.value.latest_version || '') } +/** + * Runtime 链路的目标版本。 + * + * `/api/update/check` 返回的 `latest_version` 就是发布标签;还没查到时退回应用自身版本, + * 主进程会再做一次规范化与合法性校验。 + */ +const resolveRuntimeUpdateVersion = (): string => updateInfo.value?.latest_version || version + // 处理后端更新点击 const handleBackendUpdateClick = () => { Modal.confirm({ @@ -155,6 +287,12 @@ const handleBackendUpdateClick = () => { cancelText: t('comp.cancel'), centered: true, onOk: async () => { + // Runtime 监督链路下走「停机 → bootstrap → 重新监督」,不再跳初始化页整包更新。 + if (isRuntimeManaged.value) { + await startRuntimeUpdate(resolveRuntimeUpdateVersion()) + return + } + try { logger.info('开始更新后端') @@ -308,6 +446,9 @@ onMounted(async () => { // 监听托盘动作请求(启动任务 / 退出 / 重启) removeTrayActionListener = window.electronAPI?.onTrayActionRequest?.(handleTrayActionRequest) + // 后端更新入口按启动链路分流,模式一个生命周期只查一次 + await ensureLaunchMode() + try { const config = await window.electronAPI?.loadConfig() syncUiPreferences(config?.UI) @@ -541,6 +682,57 @@ onBeforeUnmount(() => { transform: scale(0.98); } +/* development 模式下 Runtime 不管理源码,入口只展示不可点 */ +.update-hint.disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.update-hint.disabled:hover { + transform: none; + filter: none; +} + +.backend-update-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.backend-update-message { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + color: var(--ant-color-text-secondary); +} + +.backend-update-meta { + margin: 0; + font-size: 12px; + word-break: break-all; + color: var(--ant-color-text-secondary); +} + +.backend-update-logs { + max-height: 220px; + margin: 0; + padding: 8px; + overflow: auto; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + background: var(--ant-color-fill-quaternary); + border-radius: 6px; +} + +.backend-update-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + .update-hint:hover { transform: scale(1.02); filter: drop-shadow(0 0 8px rgba(255, 64, 129, 0.7)); diff --git a/frontend/src/composables/useBackendRuntimeUpdate.ts b/frontend/src/composables/useBackendRuntimeUpdate.ts new file mode 100644 index 000000000..39b3f97d2 --- /dev/null +++ b/frontend/src/composables/useBackendRuntimeUpdate.ts @@ -0,0 +1,228 @@ +/** + * Runtime 链路的后端更新 + * + * 灰度开关打开(`managed`)时,标题栏的「检测到后端更新」入口不再走「关后端 → 跳初始化页 + * → 后端自己下整包 → 拉安装器」那条链,而是让主进程按 停机 → `bootstrap --version` → + * 重新监督 三步完成,本模块只负责把进度与三类失败结局搬到界面上。 + * + * `development` 模式下 Runtime 只监督开发者自己的检出、不碰源码,入口直接禁用。 + * 灰度开关关闭(`off`)时本模块不参与,标题栏仍走原有流程。 + */ + +import { computed, ref } from 'vue' + +import type { + RuntimeLaunchMode, + RuntimeUpdateOutcome, + RuntimeUpdateRetryAction, + RuntimeUpdateStage, +} from '@/types/electron' +import { reconnectNow } from '@/services/websocket/connection' +import { getBackendVersion } from './useVersionService' + +const logger = window.electronAPI.getLogger('后端更新') + +/** + * 进度条的段序。 + * + * 中间六段就是初始化界面那六段(`mirror` / `pip` / `git` 在 Runtime 链路没有对应物, + * 主进程进 bootstrap 时立刻置完成),首尾两段是更新独有的停机与重启。 + */ +const UPDATE_STAGE_ORDER: readonly RuntimeUpdateStage[] = [ + 'shutdown', + 'mirror', + 'python', + 'pip', + 'git', + 'repository', + 'dependency', + 'restart', +] + +// 模块级状态:标题栏入口与进度弹窗共用同一份。 +const launchMode = ref(null) +const modalVisible = ref(false) +const running = ref(false) +const cancelling = ref(false) +const targetVersion = ref('') +const currentMessage = ref('') +const currentStage = ref(null) +const completedStages = ref([]) +const outcome = ref(null) +const restartingBackend = ref(false) + +let progressListenerAttached = false + +/** 只查一次:灰度开关是进程启动时定下的,一个生命周期内不会变。 */ +async function ensureLaunchMode(): Promise { + if (launchMode.value) return launchMode.value + try { + // 灰度开关升级为三级来源后返回的是 {persisted, mode, source},这里只关心生效值。 + launchMode.value = (await window.electronAPI.getRuntimeLaunchMode()).mode + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`获取 Runtime 启动模式失败,按 off 处理: ${errorMsg}`) + launchMode.value = 'off' + } + return launchMode.value +} + +function attachProgressListener(): void { + if (progressListenerAttached) return + window.electronAPI.onBackendUpdateProgress(progress => { + currentStage.value = progress.stage + currentMessage.value = progress.message + if (progress.status === 'completed' && !completedStages.value.includes(progress.stage)) { + completedStages.value = [...completedStages.value, progress.stage] + } + }) + progressListenerAttached = true +} + +function detachProgressListener(): void { + if (!progressListenerAttached) return + window.electronAPI.removeBackendUpdateProgressListener?.() + progressListenerAttached = false +} + +function resetProgress(): void { + currentStage.value = null + currentMessage.value = '' + completedStages.value = [] + outcome.value = null + cancelling.value = false +} + +/** 更新成功后把连接与版本信息拉回来,不必跳初始化页。 */ +async function refreshAfterUpdate(): Promise { + try { + await reconnectNow('后端更新完成') + await getBackendVersion() + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`更新后刷新后端状态失败: ${errorMsg}`) + } +} + +async function settle(result: RuntimeUpdateOutcome): Promise { + outcome.value = result + running.value = false + cancelling.value = false + detachProgressListener() + if (result.success) await refreshAfterUpdate() +} + +export function useBackendRuntimeUpdate() { + const isRuntimeManaged = computed(() => launchMode.value === 'managed') + const isRuntimeDevelopment = computed(() => launchMode.value === 'development') + + /** 完成的段数占总段数,Runtime 不给细粒度百分比,这里也不编。 */ + const overallPercent = computed(() => { + const done = completedStages.value.filter(stage => UPDATE_STAGE_ORDER.includes(stage)).length + return Math.round((done / UPDATE_STAGE_ORDER.length) * 100) + }) + + const stageOrder = computed(() => UPDATE_STAGE_ORDER) + + async function start(version: string): Promise { + if (running.value) return + + resetProgress() + targetVersion.value = version + modalVisible.value = true + running.value = true + attachProgressListener() + + logger.info(`开始经 Runtime 更新后端到 ${version}`) + try { + await settle(await window.electronAPI.updateBackendViaRuntime(version)) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`Runtime 更新后端失败: ${errorMsg}`) + await settle({ success: false, phase: 'bootstrap', error: errorMsg }) + } + } + + async function retry(action: RuntimeUpdateRetryAction): Promise { + if (running.value) return + + outcome.value = null + cancelling.value = false + running.value = true + attachProgressListener() + + logger.info(`重试后端更新: ${action}`) + try { + await settle(await window.electronAPI.retryBackendUpdate(action)) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`重试后端更新失败: ${errorMsg}`) + await settle({ success: false, phase: 'bootstrap', error: errorMsg }) + } + } + + async function cancel(): Promise { + if (!running.value) return + cancelling.value = true + try { + await window.electronAPI.cancelBackendUpdate() + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`取消后端更新失败: ${errorMsg}`) + cancelling.value = false + } + } + + /** `restart` 结局下的兜底:源码与依赖都已就位,只是后端没起来,再拉一次。 */ + async function restartBackend(): Promise { + restartingBackend.value = true + try { + const result = await window.electronAPI.backendStart() + if (result.success) { + outcome.value = { success: true } + await refreshAfterUpdate() + return + } + outcome.value = { + success: false, + phase: 'restart', + error: result.error, + logs: result.logs, + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + outcome.value = { success: false, phase: 'restart', error: errorMsg } + } finally { + restartingBackend.value = false + } + } + + function close(): void { + if (running.value) return + modalVisible.value = false + resetProgress() + } + + return { + launchMode, + ensureLaunchMode, + isRuntimeManaged, + isRuntimeDevelopment, + modalVisible, + running, + cancelling, + restartingBackend, + targetVersion, + currentStage, + currentMessage, + completedStages, + stageOrder, + overallPercent, + outcome, + start, + retry, + cancel, + restartBackend, + close, + } +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 982848e88..1780ac521 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -183,6 +183,26 @@ export default { updateHasFinishedDownloading: 'The update has finished downloading. Install it now?', minimize: 'Minimize', backendUpdateAvailableClick: 'A backend update is available — click to update', + backendUpdateDevUnsupported: + 'A backend update is available — automatic updates are off in development mode', + backendUpdateTitle: 'Updating the backend to {version}', + backendUpdateSucceeded: 'The backend has been updated and restarted', + backendUpdateCancelled: 'The update was cancelled; the backend is unchanged', + backendUpdateFailedShutdown: + 'The running backend could not be stopped, so the update never started. The current version still works.', + backendUpdateFailedBootstrap: + 'Syncing the source or the dependencies failed. Retry with one of the options below.', + backendUpdateFailedRestart: + 'The source and the dependencies are in place, but the new backend did not start', + backendUpdateUnsupportedMode: 'This mode does not support updating the backend automatically', + backendUpdateRetryWorkspaceSync: 'Sync the source again', + backendUpdateRetryDependenciesSync: 'Retry the dependency sync', + backendUpdateRetryDependenciesRebuild: 'Rebuild the dependency environment', + backendUpdateRetryRepair: 'Repair the whole runtime environment', + backendUpdateRestartBackend: 'Start the backend again', + backendUpdateCancelAction: 'Cancel the update', + backendUpdateErrorCode: 'Error code', + backendUpdateLogPath: 'Log file', closingBackend: 'Closing the backend...', startingBackend: 'Starting the backend...', lightTheme: 'Light theme', @@ -1837,6 +1857,24 @@ export default { }, init: { unknownStepP0: 'Unknown step: {p0}', + runtime: { + takenOver: 'Handled by the runtime', + preparingEnv: 'Preparing the runtime environment', + }, + failure: { + retryOtherMirror: 'Retry with another mirror', + rebuildEnvironment: 'Rebuild the environment', + openLog: 'Open the log', + runDoctor: 'Run diagnostics', + internalErrorNotice: 'This is an internal runtime error. Please report it with the log.', + contactSupportNotice: 'Please report this problem to the developers with the log.', + logTitle: 'Failure log', + openLogFailed: 'Could not open the log: {error}', + doctorTitle: 'Environment diagnostics', + doctorEmpty: 'Diagnostics returned no checks', + doctorFailed: 'Diagnostics failed: {error}', + doctorRunning: 'Running diagnostics...', + }, common: { currentMirror: 'Currently using: {mirror}', }, @@ -2668,6 +2706,19 @@ export default { exportOkNte: 'Export an OK-NTE issue bundle', devSection: 'Developer options', openDevTools: 'Open DevTools', + runtimeLaunchMode: 'Backend launch mode', + runtimeLaunchModeTip: + 'A staged-rollout option: choose whether the backend is supervised by Runtime or launched the old way. Takes effect after restarting the app.', + runtimeLaunchModeAuto: 'Auto', + runtimeLaunchModeOff: 'Legacy path', + runtimeLaunchModeDevelopment: 'Supervised by Runtime (development)', + runtimeLaunchModeManaged: 'Supervised by Runtime (managed)', + runtimeLaunchModeSourceEnv: 'forced by environment variable', + runtimeLaunchModeSourceSetting: 'this setting', + runtimeLaunchModeSourceDefault: 'build default', + runtimeLaunchModeEffective: 'Currently effective: {mode} (source: {source})', + runtimeLaunchModeRestartHint: 'Takes effect after restarting the app', + runtimeLaunchModeSaveFailed: 'Failed to save the backend launch mode', }, others: { updateSection: 'Updates', diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index c550f95a9..bcae6e28a 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -186,6 +186,26 @@ export default { updateHasFinishedDownloading: '更新のダウンロードが完了しました。今すぐインストールしますか?', minimize: '最小化', backendUpdateAvailableClick: 'バックエンドの更新があります。クリックして更新してください', + backendUpdateDevUnsupported: + 'バックエンドの更新があります。開発モードでは自動更新に対応していません', + backendUpdateTitle: 'バックエンドを {version} に更新します', + backendUpdateSucceeded: 'バックエンドを更新して再起動しました', + backendUpdateCancelled: '更新をキャンセルしました。バックエンドは元のままです', + backendUpdateFailedShutdown: + '実行中のバックエンドを停止できず、更新は開始されていません。現在のバージョンはそのまま使用できます', + backendUpdateFailedBootstrap: + 'ソースまたは依存関係の同期に失敗しました。以下のいずれかの方法で再試行してください', + backendUpdateFailedRestart: + 'ソースと依存関係は揃いましたが、新しいバックエンドを起動できませんでした', + backendUpdateUnsupportedMode: '現在のモードではバックエンドの自動更新に対応していません', + backendUpdateRetryWorkspaceSync: 'ソースを同期し直す', + backendUpdateRetryDependenciesSync: '依存関係の同期を再試行', + backendUpdateRetryDependenciesRebuild: '依存環境を再構築', + backendUpdateRetryRepair: '実行環境をまとめて修復', + backendUpdateRestartBackend: 'バックエンドを起動し直す', + backendUpdateCancelAction: '更新をキャンセル', + backendUpdateErrorCode: 'エラーコード', + backendUpdateLogPath: 'ログファイル', closingBackend: 'バックエンドを終了しています...', startingBackend: 'バックエンドを起動しています...', lightTheme: 'ライトテーマ', @@ -1853,6 +1873,24 @@ export default { }, init: { unknownStepP0: '不明なステップ: {p0}', + runtime: { + takenOver: 'ランタイムが担当', + preparingEnv: '実行環境を準備', + }, + failure: { + retryOtherMirror: '別のミラーで再試行', + rebuildEnvironment: '環境を再構築', + openLog: 'ログを開く', + runDoctor: '診断を実行', + internalErrorNotice: 'ランタイム内部のエラーです。ログを添えて報告してください', + contactSupportNotice: 'この問題はログを添えて開発者に報告してください', + logTitle: '失敗ログ', + openLogFailed: 'ログを開けませんでした: {error}', + doctorTitle: '実行環境の診断', + doctorEmpty: '診断結果に項目がありません', + doctorFailed: '診断の実行に失敗しました: {error}', + doctorRunning: '実行環境を診断しています...', + }, common: { currentMirror: '使用中: {mirror}', }, @@ -2724,6 +2762,19 @@ export default { exportOkNte: 'OK-NTE の問題報告パッケージを書き出す', devSection: '開発者向けオプション', openDevTools: '開発者ツールを開く', + runtimeLaunchMode: 'バックエンドの起動方式', + runtimeLaunchModeTip: + '段階的検証中のオプションです。バックエンドを Runtime に監督させて起動するか、従来どおりの方式にするかを選べます。アプリの再起動後に反映されます。', + runtimeLaunchModeAuto: '自動', + runtimeLaunchModeOff: '従来の方式', + runtimeLaunchModeDevelopment: 'Runtime が監督(development)', + runtimeLaunchModeManaged: 'Runtime が監督(managed)', + runtimeLaunchModeSourceEnv: '環境変数による強制', + runtimeLaunchModeSourceSetting: 'この設定項目', + runtimeLaunchModeSourceDefault: 'ビルドの既定値', + runtimeLaunchModeEffective: '現在の適用値: {mode}(適用元: {source})', + runtimeLaunchModeRestartHint: 'アプリの再起動後に反映されます', + runtimeLaunchModeSaveFailed: 'バックエンドの起動方式の保存に失敗しました', }, others: { updateSection: '更新', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 38c8eddff..36398f899 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -185,6 +185,22 @@ export default { updateHasFinishedDownloading: '更新包已下载完成,是否立即安装?', minimize: '最小化', backendUpdateAvailableClick: '检测到后端更新,点击以更新后端', + backendUpdateDevUnsupported: '检测到后端更新,开发模式不支持自动更新', + backendUpdateTitle: '更新后端到 {version}', + backendUpdateSucceeded: '后端已更新并重新启动', + backendUpdateCancelled: '更新已取消,后端保持原版本', + backendUpdateFailedShutdown: '未能停止当前后端,更新没有开始,可以继续使用当前版本', + backendUpdateFailedBootstrap: '源码或依赖同步失败,可用下面的方式重试', + backendUpdateFailedRestart: '源码与依赖已就位,但新后端没能启动', + backendUpdateUnsupportedMode: '当前模式不支持自动更新后端', + backendUpdateRetryWorkspaceSync: '重新同步源码', + backendUpdateRetryDependenciesSync: '重试依赖同步', + backendUpdateRetryDependenciesRebuild: '重建依赖环境', + backendUpdateRetryRepair: '整体修复运行环境', + backendUpdateRestartBackend: '重新启动后端', + backendUpdateCancelAction: '取消更新', + backendUpdateErrorCode: '错误码', + backendUpdateLogPath: '日志文件', closingBackend: '正在关闭后端应用...', startingBackend: '正在启动后端服务...', lightTheme: '浅色主题', @@ -1758,6 +1774,24 @@ export default { }, init: { unknownStepP0: '未知步骤: {p0}', + runtime: { + takenOver: '由运行时接管', + preparingEnv: '准备运行环境', + }, + failure: { + retryOtherMirror: '换镜像重试', + rebuildEnvironment: '重建环境', + openLog: '打开日志', + runDoctor: '运行诊断', + internalErrorNotice: '这是运行时内部错误,请携带日志反馈', + contactSupportNotice: '这个问题需要携带日志反馈给开发者', + logTitle: '失败日志', + openLogFailed: '打开日志失败: {error}', + doctorTitle: '运行环境诊断', + doctorEmpty: '诊断没有给出任何检查项', + doctorFailed: '运行诊断失败: {error}', + doctorRunning: '正在诊断运行环境...', + }, common: { currentMirror: '当前使用: {mirror}', }, @@ -2574,6 +2608,19 @@ export default { exportOkNte: '导出 OK-NTE 问题包', devSection: '开发者选项', openDevTools: '打开开发者工具', + runtimeLaunchMode: '后端运行方式', + runtimeLaunchModeTip: + '灰度验证阶段的选项:选择后端交由 Runtime 监督启动,还是沿用旧的进程管理方式;改动重启应用后生效。', + runtimeLaunchModeAuto: '自动', + runtimeLaunchModeOff: '旧链路', + runtimeLaunchModeDevelopment: '由 Runtime 监督(development)', + runtimeLaunchModeManaged: '由 Runtime 监督(managed)', + runtimeLaunchModeSourceEnv: '环境变量强制', + runtimeLaunchModeSourceSetting: '本项设置', + runtimeLaunchModeSourceDefault: '构建默认值', + runtimeLaunchModeEffective: '当前生效:{mode}(来源:{source})', + runtimeLaunchModeRestartHint: '重启应用后生效', + runtimeLaunchModeSaveFailed: '保存后端运行方式失败', }, others: { updateSection: '更新配置', diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index aec041497..d7f01b9dd 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -42,6 +42,120 @@ export interface ElectronMirrorSource { export type ElectronMirrorType = 'python' | 'get_pip' | 'git' | 'repo' | 'pip_mirror' export type ElectronApiEndpointKey = 'local' | 'websocket' +// ==================== Runtime 后端更新 ==================== + +/** 后端启动链路:`off` 走原有的自行启动 python.exe,其余两种由 Runtime 监督。 */ +export type RuntimeLaunchMode = 'off' | 'development' | 'managed' + +/** 更新失败的三类结局,界面据此决定给什么按钮。 */ +export type RuntimeUpdatePhase = 'shutdown' | 'bootstrap' | 'restart' + +export type RuntimeUpdateRetryAction = + | 'workspace-sync' + | 'dependencies-sync' + | 'dependencies-rebuild' + | 'repair' + +export type RuntimeUpdateStage = + | 'shutdown' + | 'mirror' + | 'python' + | 'pip' + | 'git' + | 'repository' + | 'dependency' + | 'backend' + | 'restart' + +export interface RuntimeUpdateProgress { + stage: RuntimeUpdateStage + status: 'started' | 'running' | 'completed' | 'failed' + progress: number + message: string +} + +export interface RuntimeUpdateOutcome { + success: boolean + phase?: RuntimeUpdatePhase + error?: string + code?: string + retryable?: boolean + remediation?: string[] + logs?: string + logPath?: string + retryActions?: RuntimeUpdateRetryAction[] + cancelled?: boolean + unsupported?: boolean +} + +// ==================== Runtime 初始化界面 ==================== + +/** + * Runtime 灰度开关的三态。 + * + * `off` 是原有的自装 Python / pip / Git 链路,另外两态由 auto-mas-runtime.exe 接管; + * 主进程没给这个字段时(旧版本主进程、旧链路进度)界面一律按 `off` 处理。 + */ +export type RuntimeInitMode = 'off' | 'development' | 'managed' + +/** 初始化界面开局问一次的 Runtime 上下文。 */ +export interface RuntimeInitContext { + mode: RuntimeInitMode + /** Runtime 没给 logPath 时「打开日志」退回的文件。 */ + fallbackLogPath: string + /** 各段在 Runtime 链路下可选的镜像键;空数组表示该段不展示镜像选择。 */ + mirrorKeys: Record +} + +/** Runtime doctor 的单项检查结果。 */ +export interface RuntimeDoctorCheck { + id: string + name: string + message: string + /** 实测取值为 `ok` / `missing` / `error`。 */ + status: string + details: Record +} + +/** + * Runtime 链路失败时随结果一起给出的结构化字段。 + * + * 旧链路一律缺省,所以全是可选的;界面按这些机器字段决定给哪些按钮, + * 绝不解析 `error` 里的中文文案。 + */ +export interface RuntimeFailureFields { + /** Runtime 结果码,如 `MIRROR_EXHAUSTED` / `INTERNAL_ERROR`。 */ + code?: string + retryable?: boolean + /** 处置动作,如 `retry` / `retry-other-mirror` / `open-log`;未知取值忽略即可。 */ + remediation?: string[] + /** `[stdout]…\n\n[stderr]…` 整块文本。 */ + logs?: string + /** Runtime 本次操作的日志文件路径。 */ + logPath?: string +} + +/** 单步安装与重试的返回形状:旧链路只有前两项,Runtime 链路额外带结构化字段。 */ +export type InstallStageResult = { + success: boolean + error?: string +} & RuntimeFailureFields + +// ==================== Runtime 灰度开关 ==================== + +/** Runtime 灰度开关的持久化设置取值;`auto` 表示跟随构建默认值。 */ +export type RuntimeLaunchModeSetting = 'auto' | 'off' | 'development' | 'managed' +/** 最终生效值来自哪一级。 */ +export type RuntimeLaunchModeSource = 'env' | 'setting' | 'default' + +export interface RuntimeLaunchModeState { + /** 持久化设置里存的原始值,用于回填选择控件。 */ + persisted: RuntimeLaunchModeSetting + /** 本次实际生效的模式(`auto` 已被解析成具体值)。 */ + mode: 'off' | 'development' | 'managed' + source: RuntimeLaunchModeSource +} + export interface ElectronAPI { openDevTools: () => Promise selectFolder: () => Promise @@ -77,11 +191,14 @@ export interface ElectronAPI { pythonExists: boolean gitExists: boolean mainPyExists: boolean + pipExists?: boolean + /** doctor 的逐项检查,只有 Runtime 链路产生,供失败态的「运行诊断」展示。 */ + runtimeChecks?: RuntimeDoctorCheck[] }> checkGitUpdate: () => Promise<{ hasUpdate: boolean; error?: string }> downloadPython: (mirror?: string) => Promise downloadGit: () => Promise - installDependencies: (mirror?: string) => Promise + // installDependencies 的权威声明在下面的「单步初始化API」里,这里原有的一份签名已过时 cloneBackend: (repoUrl?: string) => Promise updateBackend: (repoUrl?: string) => Promise startBackend: () => Promise<{ success: boolean; error?: string; logs?: string }> @@ -129,6 +246,11 @@ export interface ElectronAPI { getInitializedVersion: () => Promise setInitializedVersion: (version: string) => Promise + // Runtime 灰度开关:持久化设置 + 当前生效值与来源,重启后生效。 + // `mode` 同时是标题栏更新入口走哪条链路的判据。 + getRuntimeLaunchMode: () => Promise + setRuntimeLaunchMode: (mode: RuntimeLaunchModeSetting) => Promise + // 托盘设置 updateTraySettings: (uiSettings: unknown) => Promise updateTrayConfig: (trayItems: unknown) => Promise @@ -211,17 +333,22 @@ export interface ElectronAPI { // 单步初始化API initMirrors: () => Promise<{ success: boolean; error?: string }> - installPython: (selectedMirror?: string) => Promise<{ success: boolean; error?: string }> - installPip: (selectedMirror?: string) => Promise<{ success: boolean; error?: string }> - installGit: (selectedMirror?: string) => Promise<{ success: boolean; error?: string }> + // rebuild 对应失败态的「重建环境」按钮,只在 Runtime 链路下有意义 + installPython: (selectedMirror?: string, rebuild?: boolean) => Promise + installPip: (selectedMirror?: string, rebuild?: boolean) => Promise + installGit: (selectedMirror?: string, rebuild?: boolean) => Promise pullRepository: ( targetBranch?: string, - selectedMirror?: string - ) => Promise<{ success: boolean; error?: string }> + selectedMirror?: string, + rebuild?: boolean + ) => Promise installDependencies: ( - selectedMirror?: string - ) => Promise<{ success: boolean; error?: string; skipped?: boolean }> + selectedMirror?: string, + rebuild?: boolean + ) => Promise getMirrors: (type: ElectronMirrorType) => Promise + /** 初始化界面开局问一次:走没走 Runtime、回退日志文件、各段可用镜像键。 */ + getRuntimeInitContext?: () => Promise // API 端点获取 getApiEndpoint: (key: ElectronApiEndpointKey) => Promise @@ -231,12 +358,14 @@ export interface ElectronAPI { initialize: ( targetBranch?: string, startBackend?: boolean - ) => Promise<{ - success: boolean - error?: string - completedStages: string[] - failedStage?: string - }> + ) => Promise< + { + success: boolean + error?: string + completedStages: string[] + failedStage?: string + } & RuntimeFailureFields + > // 仅更新模式 updateOnly: (targetBranch?: string) => Promise<{ @@ -247,9 +376,9 @@ export interface ElectronAPI { }> // 后端服务管理 - backendStart: () => Promise<{ success: boolean; error?: string; logs?: string }> + backendStart: () => Promise backendStop: () => Promise<{ success: boolean; error?: string }> - backendRestart: () => Promise<{ success: boolean; error?: string; logs?: string }> + backendRestart: () => Promise backendStatus: () => Promise<{ isRunning: boolean pid?: number @@ -259,6 +388,13 @@ export interface ElectronAPI { error?: string }> + // Runtime 链路的后端更新(启动模式统一走上面的 getRuntimeLaunchMode) + updateBackendViaRuntime: (targetVersion: string) => Promise + retryBackendUpdate: (action: RuntimeUpdateRetryAction) => Promise + cancelBackendUpdate: () => Promise<{ accepted: boolean; forwarded: boolean }> + onBackendUpdateProgress: (callback: (progress: RuntimeUpdateProgress) => void) => void + removeBackendUpdateProgressListener?: () => void + // 清理资源 cleanup: () => Promise<{ success: boolean }> @@ -282,6 +418,10 @@ export interface ElectronAPI { totalStages: number progress: number message: string + /** Runtime 链路给出的机器可读段状态;旧链路不产生。 */ + status?: 'started' | 'running' | 'completed' | 'failed' + /** 本条进度来自哪条链路;旧链路不产生,按 off 处理。 */ + runtimeMode?: RuntimeInitMode }) => void ) => void removeInitializationProgressListener?: () => void diff --git a/frontend/src/utils/initializationDecision.test.ts b/frontend/src/utils/initializationDecision.test.ts new file mode 100644 index 000000000..90bdbeb64 --- /dev/null +++ b/frontend/src/utils/initializationDecision.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { decideFailureActions, filterRuntimeMirrors } from './initializationDecision' +import type { FailureActionKind } from './initializationDecision' + +const kinds = (context: Parameters[0]): FailureActionKind[] => + decideFailureActions(context).actions.map(action => action.kind) + +describe('decideFailureActions', () => { + // 下面几条的 code / retryable / remediation 组合都抄自 + // electron/services/runtime/protocol.ts 的错误码定义表,不是编出来的。 + it('retry 与 retry-sync 都收敛成同一个「重试」', () => { + expect(kinds({ code: 'DIRECTORY_OCCUPIED', retryable: true, remediation: ['retry'] })).toEqual([ + 'retry', + ]) + expect( + kinds({ + code: 'GIT_REPOSITORY_INVALID', + retryable: true, + remediation: ['retry-sync'], + stage: 'repository', + }) + ).toEqual(['retry']) + }) + + it('retry-other-mirror 给出换镜像按钮并展开镜像面板', () => { + const plan = decideFailureActions({ + code: 'MIRROR_EXHAUSTED', + retryable: true, + remediation: ['retry-other-mirror'], + stage: 'repository', + runtimeMode: 'managed', + }) + + expect(plan.actions.map(action => action.kind)).toEqual(['retry-other-mirror']) + expect(plan.actions[0].labelKey).toBe('init.failure.retryOtherMirror') + expect(plan.showMirrorSelection).toBe(true) + expect(plan.legacy).toBe(false) + }) + + // 依赖段的锁文件冻结在 PyPI,但 Runtime 同步时改写锁副本里的下载地址参与镜像轮换, + // 显式选中的镜像排在最前,所以依赖段照样给换镜像按钮。 + it('Runtime 下依赖段也能换镜像', () => { + const plan = decideFailureActions({ + code: 'DEPENDENCY_SYNC_FAILED', + retryable: true, + remediation: ['retry-other-mirror'], + stage: 'dependency', + runtimeMode: 'managed', + }) + + expect(plan.actions.map(action => action.kind)).toEqual(['retry-other-mirror']) + expect(plan.showMirrorSelection).toBe(true) + }) + + // `pip` 段在 Runtime 下根本不执行,也没有对应的镜像目录,给了按钮只会弹出空面板。 + it('Runtime 下没有镜像可换的段把换镜像降级成普通重试', () => { + const plan = decideFailureActions({ + code: 'UV_EXEC_FAILED', + retryable: true, + remediation: ['retry-other-mirror'], + stage: 'pip', + runtimeMode: 'managed', + }) + + expect(plan.actions.map(action => action.kind)).toEqual(['retry']) + expect(plan.showMirrorSelection).toBe(false) + }) + + it('rebuild-environment 与 open-log 按 remediation 的顺序排列', () => { + expect( + kinds({ + code: 'DEPENDENCY_SYNC_FAILED', + retryable: true, + remediation: ['retry-sync', 'rebuild-environment', 'open-log'], + stage: 'dependency', + runtimeMode: 'managed', + }) + ).toEqual(['retry', 'rebuild-environment', 'open-log']) + }) + + it('run-doctor 给出诊断按钮', () => { + expect( + kinds({ + code: 'UV_EXEC_FAILED', + retryable: true, + remediation: ['run-doctor', 'open-log'], + stage: 'python', + runtimeMode: 'managed', + }) + ).toEqual(['run-doctor', 'open-log']) + }) + + it('contact-support 带出日志按钮与一段提示,可重试时不影响重试', () => { + const plan = decideFailureActions({ + code: 'UV_CHECKSUM_MISMATCH', + retryable: true, + remediation: ['retry-other-mirror', 'contact-support'], + stage: 'python', + runtimeMode: 'managed', + }) + + expect(plan.actions.map(action => action.kind)).toEqual(['retry-other-mirror', 'open-log']) + expect(plan.notice).toBe('contact-support') + }) + + it('INTERNAL_ERROR 只给打开日志,并说明是运行时内部错误', () => { + const plan = decideFailureActions({ + code: 'INTERNAL_ERROR', + retryable: false, + remediation: ['open-log', 'contact-support'], + stage: 'python', + runtimeMode: 'managed', + }) + + expect(plan.actions.map(action => action.kind)).toEqual(['open-log']) + expect(plan.notice).toBe('internal-error') + expect(plan.showMirrorSelection).toBe(false) + }) + + // Runtime 万一把 INTERNAL_ERROR 标成可重试,界面也不能给重试按钮。 + it('INTERNAL_ERROR 即使被标为可重试也不给重试', () => { + expect( + kinds({ + code: 'INTERNAL_ERROR', + retryable: true, + remediation: ['retry', 'open-log'], + runtimeMode: 'managed', + }) + ).toEqual(['open-log']) + }) + + it('retryable=false 屏蔽全部重试类按钮,只留下非重试动作', () => { + expect( + kinds({ + code: 'BACKEND_IDENTITY_MISMATCH', + retryable: false, + remediation: ['retry-sync', 'run-doctor', 'open-log'], + stage: 'backend', + runtimeMode: 'managed', + }) + ).toEqual(['run-doctor', 'open-log']) + }) + + it('重试全被屏蔽后至少留一个打开日志', () => { + const plan = decideFailureActions({ + code: 'PYTHON_VERSION_MISMATCH', + retryable: false, + remediation: ['rebuild-environment'], + stage: 'python', + runtimeMode: 'managed', + }) + + expect(plan.actions.map(action => action.kind)).toEqual(['open-log']) + expect(plan.legacy).toBe(false) + }) + + it('旧链路缺字段时保持现有行为:重试加镜像面板', () => { + const plan = decideFailureActions({ stage: 'python' }) + + expect(plan.actions).toEqual([ + { kind: 'retry-other-mirror', labelKey: 'init.step.retryWithMirror' }, + ]) + expect(plan.showMirrorSelection).toBe(true) + expect(plan.legacy).toBe(true) + expect(plan.notice).toBeNull() + }) + + it('未知 code 与未知 remediation 都退回现有行为', () => { + expect(decideFailureActions({ code: 'SOMETHING_NEW', retryable: true }).legacy).toBe(true) + // update-desktop / select-version 这类动作界面做不了,全都认不出来就当旧链路 + expect( + decideFailureActions({ + code: 'GIT_BRANCH_NOT_FOUND', + retryable: true, + remediation: ['select-version', 'not-a-real-remediation'], + }).legacy + ).toBe(true) + }) + + it('认不出来又不可重试时只给日志,不给一个空界面', () => { + const plan = decideFailureActions({ + code: 'PROTOCOL_MISMATCH', + retryable: false, + remediation: ['update-desktop'], + runtimeMode: 'managed', + }) + + expect(plan.actions.map(action => action.kind)).toEqual(['open-log']) + expect(plan.notice).toBe('contact-support') + }) + + it('认识的动作留下,不认识的忽略', () => { + expect( + kinds({ + code: 'GIT_REPO_CLEANUP_FAILED', + retryable: true, + remediation: ['cleanup', 'open-log'], + stage: 'repository', + runtimeMode: 'managed', + }) + ).toEqual(['open-log']) + }) + + it('后端段的 restart-backend 也是重试', () => { + expect( + kinds({ + code: 'BACKEND_HEALTH_TIMEOUT', + retryable: true, + remediation: ['restart-backend', 'open-log'], + stage: 'backend', + runtimeMode: 'managed', + }) + ).toEqual(['retry', 'open-log']) + }) + + it('同一个动作出现两次只给一个按钮', () => { + expect( + kinds({ + code: 'DEPENDENCY_SYNC_FAILED', + retryable: true, + remediation: ['retry', 'retry-sync', 'open-log'], + stage: 'dependency', + runtimeMode: 'managed', + }) + ).toEqual(['retry', 'open-log']) + }) +}) + +describe('filterRuntimeMirrors', () => { + const mirrors = [{ key: 'cnb' }, { key: 'github' }, { key: '阿里云' }] + const mirrorKeys = { python: ['official'], repository: ['cnb', 'github'], dependency: [] } + + it('旧链路原样返回', () => { + expect(filterRuntimeMirrors(mirrors, 'repository', 'off', mirrorKeys)).toEqual(mirrors) + expect(filterRuntimeMirrors(mirrors, 'repository', undefined, undefined)).toEqual(mirrors) + }) + + it('Runtime 下只留映射得到的镜像键', () => { + expect(filterRuntimeMirrors(mirrors, 'repository', 'managed', mirrorKeys)).toEqual([ + { key: 'cnb' }, + { key: 'github' }, + ]) + }) + + it('Runtime 下依赖段与未知段一个都不展示', () => { + expect(filterRuntimeMirrors(mirrors, 'dependency', 'managed', mirrorKeys)).toEqual([]) + expect(filterRuntimeMirrors(mirrors, 'pip', 'managed', mirrorKeys)).toEqual([]) + }) +}) diff --git a/frontend/src/utils/initializationDecision.ts b/frontend/src/utils/initializationDecision.ts index f59fde03d..dd6ea8246 100644 --- a/frontend/src/utils/initializationDecision.ts +++ b/frontend/src/utils/initializationDecision.ts @@ -1,3 +1,5 @@ +import type { RuntimeInitMode } from '@/types/electron' + export type InitializationDecisionMode = 'skip-home' | 'full-init' | 'force-backend-update' export interface InitializationDecision { @@ -8,10 +10,9 @@ export interface InitializationDecision { forceBackendUpdate: boolean } -const logger = window.electronAPI.getLogger('初始化决策') - export async function getInitializationDecision(): Promise { const api = window.electronAPI + const logger = api.getLogger('初始化决策') const currentVersion = import.meta.env.VITE_APP_VERSION const forceBackendUpdate = sessionStorage.getItem('forceBackendUpdate') === 'true' const disableSkip = sessionStorage.getItem('disableInitializationSkip') === 'true' @@ -81,3 +82,197 @@ export async function getInitializationDecision(): Promise> = { + retry: 'retry', + 'retry-sync': 'retry', + 'restart-backend': 'retry', + 'retry-other-mirror': 'retry-other-mirror', + 'rebuild-environment': 'rebuild-environment', + 'open-log': 'open-log', + 'run-doctor': 'run-doctor', +} + +/** 会真的再跑一次安装的动作,`retryable === false` 时全部屏蔽。 */ +const RETRY_KINDS = new Set([ + 'retry', + 'retry-other-mirror', + 'rebuild-environment', +]) + +const ACTION_LABEL_KEYS: Readonly> = { + retry: 'init.step.retry', + 'retry-other-mirror': 'init.failure.retryOtherMirror', + 'rebuild-environment': 'init.failure.rebuildEnvironment', + 'open-log': 'init.failure.openLog', + 'run-doctor': 'init.failure.runDoctor', +} + +/** Runtime 自身的缺陷,重试多少次都是同一个结果。 */ +const INTERNAL_ERROR_CODE = 'INTERNAL_ERROR' + +function toAction(kind: FailureActionKind): FailureAction { + return { kind, labelKey: ACTION_LABEL_KEYS[kind] } +} + +function pushUnique(kinds: FailureActionKind[], kind: FailureActionKind): void { + if (!kinds.includes(kind)) kinds.push(kind) +} + +/** 旧链路的老样子:一个「用选中的镜像源重试」加一整块镜像面板。 */ +function legacyPlan(): FailureActionPlan { + return { + actions: [{ kind: 'retry-other-mirror', labelKey: 'init.step.retryWithMirror' }], + showMirrorSelection: true, + notice: null, + legacy: true, + } +} + +/** + * 按失败结果里的机器字段决定失败态给哪些按钮。 + * + * 纯函数,不碰 DOM 也不读全局状态,界面只负责渲染返回的动作列表并把点击接回对应通道。 + * 三条硬规则: + * - `retryable === false` 不出任何重试类按钮,哪怕 remediation 里写了; + * - `INTERNAL_ERROR` 一律按不可重试处理,只给日志和一句「请携带日志反馈」; + * - 认不出来的 remediation 按协议要求忽略,一条都认不出来时退回旧链路的现有行为。 + */ +export function decideFailureActions(context: FailureContext): FailureActionPlan { + const remediation = context.remediation ?? [] + + // 旧链路:既没有结果码也没有处置动作,什么都不改。 + if (!context.code && remediation.length === 0) { + return legacyPlan() + } + + const isInternalError = context.code === INTERNAL_ERROR_CODE + const retryAllowed = context.retryable !== false && !isInternalError + // 主进程没给模式(旧版本主进程)时按旧链路处理,镜像面板照常可用。 + const canSwitchMirror = + context.runtimeMode === undefined || + context.runtimeMode === 'off' || + (context.stage !== undefined && RUNTIME_MIRROR_STAGES.has(context.stage)) + + const kinds: FailureActionKind[] = [] + let notice: FailureNoticeKind | null = null + let recognized = 0 + + for (const item of remediation) { + if (item === 'contact-support') { + recognized += 1 + notice = notice ?? 'contact-support' + pushUnique(kinds, 'open-log') + continue + } + + const mapped = REMEDIATION_ACTIONS[item] + if (!mapped) continue + recognized += 1 + // 该段没有可换的镜像时降级成普通重试,免得弹出一个空的镜像面板。 + pushUnique(kinds, mapped === 'retry-other-mirror' && !canSwitchMirror ? 'retry' : mapped) + } + + if (isInternalError) { + notice = 'internal-error' + pushUnique(kinds, 'open-log') + } + + // 一条都没认出来(只给了未知 code、或 remediation 全是界面管不了的动作)。 + if (recognized === 0) { + if (retryAllowed) return legacyPlan() + return { + actions: [toAction('open-log')], + showMirrorSelection: false, + notice: notice ?? 'contact-support', + legacy: false, + } + } + + const allowed = retryAllowed ? kinds : kinds.filter(kind => !RETRY_KINDS.has(kind)) + // 重试全被屏蔽后可能一个按钮都不剩,日志任何时候都能打开,也是反馈时唯一有用的东西。 + if (allowed.length === 0) allowed.push('open-log') + + return { + actions: allowed.map(toAction), + showMirrorSelection: allowed.includes('retry-other-mirror'), + notice, + legacy: false, + } +} + +/** + * 过滤「换镜像重试」的候选列表。 + * + * Runtime 只收得下自己镜像目录里有对应源的那几个键,键名由主进程从 W9b 的映射表原样导出 + * (`mirrorKeys`),界面不自己抄一份。旧链路原样返回。 + */ +export function filterRuntimeMirrors( + mirrors: readonly T[], + stage: string, + runtimeMode: RuntimeInitMode | undefined, + mirrorKeys: Readonly> | undefined +): T[] { + if (runtimeMode === undefined || runtimeMode === 'off') return [...mirrors] + + const allowed = new Set(mirrorKeys?.[stage] ?? []) + return mirrors.filter(mirror => allowed.has(mirror.key)) +} diff --git a/frontend/src/views/Initialization/components/StepPanel.test.ts b/frontend/src/views/Initialization/components/StepPanel.test.ts new file mode 100644 index 000000000..12a3a6ad1 --- /dev/null +++ b/frontend/src/views/Initialization/components/StepPanel.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest' +import { createSSRApp, defineComponent, h } from 'vue' +import { renderToString } from '@vue/server-renderer' +import { createI18n } from 'vue-i18n' +import zhCN from '@/i18n/locales/zh-CN' +import { decideFailureActions } from '@/utils/initializationDecision' +import StepPanel from './StepPanel.vue' + +// 仓库没有 @vue/test-utils,也没有 DOM 环境,用 vue 自带的 SSR 渲染器出一份 HTML, +// 断言「给定失败对象时渲染出哪些按钮」。 +const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + fallbackLocale: 'zh-CN', + missingWarn: false, + fallbackWarn: false, + messages: { 'zh-CN': zhCN }, +}) + +// ant-design-vue 的组件换成占位实现:默认插槽照渲染,文案在 prop 上的(alert / result) +// 也一并吐出来,这样断言的就是 StepPanel 自己决定展示什么,与 antd 的实现无关。 +const stub = (name: string) => + defineComponent({ + name, + inheritAttrs: false, + setup(_props, { slots, attrs }) { + const text = ['message', 'description', 'title', 'sub-title'].map(key => + attrs[key] === undefined ? null : h('span', null, String(attrs[key])) + ) + return () => h('div', { class: name }, [...text, slots.default?.()]) + }, + }) + +const ANTD_STUBS = ['a-alert', 'a-button', 'a-card', 'a-progress', 'a-result', 'a-space', 'a-tag'] + +/** 按渲染顺序取出所有按钮的文案。 */ +const buttonLabels = (html: string): string[] => + [...html.matchAll(/
]*>(.*?)<\/div>/g)].map(match => + match[1].replace(//g, '').trim() + ) + +async function renderFailedPanel(props: Record): Promise { + const app = createSSRApp(StepPanel, { + title: '依赖安装', + status: 'failed', + message: '主项目依赖同步失败', + ...props, + }) + app.use(i18n) + for (const name of ANTD_STUBS) app.component(name, stub(name)) + return renderToString(app) +} + +describe('StepPanel 失败态', () => { + it('按 remediation 渲染出对应的按钮集合,并整块展示日志', async () => { + const plan = decideFailureActions({ + code: 'DEPENDENCY_SYNC_FAILED', + retryable: true, + remediation: ['retry-sync', 'rebuild-environment', 'open-log'], + stage: 'dependency', + runtimeMode: 'managed', + }) + + const html = await renderFailedPanel({ + failureActions: plan.actions, + failureNotice: plan.notice, + showMirrorSelection: plan.showMirrorSelection, + failureLogs: '[stdout]\nresolved 1 package\n\n[stderr]\nnetwork unreachable', + }) + + // 依赖段在 Runtime 下换不了镜像,所以是普通重试而不是换镜像重试,也不带镜像面板 + expect(buttonLabels(html)).toEqual(['重试', '重建环境', '打开日志']) + expect(html).not.toContain('请选择镜像源重试') + // 失败日志仍整块展示 + expect(html).toContain('network unreachable') + expect(html).toContain('失败日志') + }) + + it('INTERNAL_ERROR 只给打开日志,并附上内部错误说明', async () => { + const plan = decideFailureActions({ + code: 'INTERNAL_ERROR', + retryable: false, + remediation: ['open-log', 'contact-support'], + stage: 'python', + runtimeMode: 'managed', + }) + + const html = await renderFailedPanel({ + title: '准备运行环境', + failureActions: plan.actions, + failureNotice: plan.notice, + showMirrorSelection: plan.showMirrorSelection, + }) + + expect(buttonLabels(html)).toEqual(['打开日志']) + expect(html).toContain('这是运行时内部错误,请携带日志反馈') + }) + + it('旧链路缺字段时仍是「用选中的镜像源重试」加镜像面板', async () => { + const plan = decideFailureActions({ stage: 'repository' }) + + const html = await renderFailedPanel({ + title: '源码拉取', + failureActions: plan.actions, + failureNotice: plan.notice, + showMirrorSelection: plan.showMirrorSelection, + mirrors: [ + { key: 'cnb', name: 'CNB 官方镜像', url: '', type: 'mirror', description: '国内直连' }, + ], + }) + + expect(buttonLabels(html)).toEqual(['使用选中的镜像源重试']) + expect(html).toContain('请选择镜像源重试') + expect(html).toContain('CNB 官方镜像') + }) + + it('运行诊断的逐项结果按 status 字段着色展示', async () => { + const html = await renderFailedPanel({ + failureActions: [{ kind: 'run-doctor', labelKey: 'init.failure.runDoctor' }], + doctorChecks: [ + { id: 'layout', name: '受管布局', message: 'repo 缺失', status: 'missing', details: {} }, + { id: 'python', name: 'Python', message: '3.12.6', status: 'ok', details: {} }, + ], + }) + + expect(buttonLabels(html)).toEqual(['运行诊断']) + expect(html).toContain('运行环境诊断') + expect(html).toContain('受管布局') + expect(html).toContain('repo 缺失') + expect(html).toContain('3.12.6') + }) +}) diff --git a/frontend/src/views/Initialization/components/StepPanel.vue b/frontend/src/views/Initialization/components/StepPanel.vue index 69c73a498..4db2e2319 100644 --- a/frontend/src/views/Initialization/components/StepPanel.vue +++ b/frontend/src/views/Initialization/components/StepPanel.vue @@ -173,18 +173,20 @@ />
- -
+ +
+ + + -
+

{{ t('init.step.chooseMirrorRetry') }}

@@ -237,35 +239,54 @@
+
-
- - - {{ t('init.step.skip') }} - - - {{ t('init.step.retryWithMirror') }} - - -
- {{ t('init.step.autoRetryIn', { seconds: countdown }) }} + +
+
{{ t('init.failure.doctorTitle') }}
+
+ {{ t('init.failure.doctorRunning') }} +
+
+
+ {{ check.name }} + {{ check.message || check.status }}
+
{{ t('init.failure.doctorEmpty') }}
-
- -
- - - + +
+ + + {{ t('init.step.skip') }} + + + {{ t(action.labelKey) }} + + +
+ {{ t('init.step.autoRetryIn', { seconds: countdown }) }} +
+
+ + + +
{{ failureLogs }}
+
@@ -274,6 +295,12 @@ import { useI18n } from 'vue-i18n' import { computed } from 'vue' import type { MirrorConfig } from '@/types/mirror' +import type { RuntimeDoctorCheck } from '@/types/electron' +import type { + FailureAction, + FailureActionKind, + FailureNoticeKind, +} from '@/utils/initializationDecision' interface CheckInfo { // 环境检查信息(Python/Pip/Git) @@ -318,6 +345,17 @@ interface Props { operationDesc?: string checkInfo?: CheckInfo mirrorProgress?: MirrorProgress + /** 失败时该给哪些按钮,由 decideFailureActions 算好,本组件只负责渲染。 */ + failureActions?: FailureAction[] + /** 按钮之外还要说的一句话;不需要时为 null。 */ + failureNotice?: FailureNoticeKind | null + /** `[stdout]… + +[stderr]…` 整块失败日志。 */ + failureLogs?: string + /** 运行诊断的逐项结果;未诊断过为 null。 */ + doctorChecks?: RuntimeDoctorCheck[] | null + doctorRunning?: boolean } const { t } = useI18n() @@ -342,14 +380,31 @@ const props = withDefaults(defineProps(), { operationDesc: '', checkInfo: undefined, mirrorProgress: undefined, + failureActions: () => [], + failureNotice: null, + failureLogs: '', + doctorChecks: null, + doctorRunning: false, }) defineEmits<{ 'update:selected-mirror': [value: string] - retry: [] + action: [kind: FailureActionKind] skip: [] }>() +// 词表只能在 setup 里取,模板里按种类查这一句提示。 +const noticeText = computed(() => { + switch (props.failureNotice) { + case 'internal-error': + return t('init.failure.internalErrorNotice') + case 'contact-support': + return t('init.failure.contactSupportNotice') + default: + return '' + } +}) + const mirrorMirrors = computed(() => props.mirrors.filter((m: MirrorConfig) => m.type === 'mirror')) const officialMirrors = computed(() => props.mirrors.filter((m: MirrorConfig) => m.type === 'official') @@ -421,18 +476,6 @@ const officialMirrors = computed(() => padding: 8px; } -.simple-failed-state { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 20px; - overflow-y: auto; - overflow-x: hidden; - width: 100%; -} - .status-text { font-size: 16px; color: var(--ant-color-text); @@ -550,6 +593,49 @@ const officialMirrors = computed(() => margin-top: 20px; } +.doctor-checks { + display: flex; + flex-direction: column; + gap: 6px; +} + +.doctor-check { + display: flex; + align-items: baseline; + gap: 8px; +} + +.doctor-check-message { + font-size: 13px; + color: var(--ant-color-text-secondary); + word-break: break-word; +} + +.failed-log-card { + width: 100%; + min-height: 0; + overflow: hidden; +} + +.failed-log-card :deep(.ant-card-body) { + padding: 0; +} + +.failure-log-output { + margin: 0; + max-height: 260px; + width: 100%; + overflow: auto; + padding: 12px 16px; + background: var(--ant-color-bg-container); + color: var(--ant-color-text); + font-family: Consolas, 'Courier New', monospace; + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + .countdown-text { font-size: 14px; color: var(--ant-color-text-secondary); diff --git a/frontend/src/views/Initialization/index.vue b/frontend/src/views/Initialization/index.vue index de0f49714..d3cd2cc8c 100644 --- a/frontend/src/views/Initialization/index.vue +++ b/frontend/src/views/Initialization/index.vue @@ -7,7 +7,7 @@ - +
@@ -16,7 +16,7 @@ :is="currentStepComponent" v-bind="currentStepProps" @update:selected-mirror="handleMirrorSelect" - @retry="handleRetry" + @action="handleFailureAction" @skip="handleSkip" @complete="handleBackendComplete" @error="handleBackendError" @@ -52,7 +52,19 @@ import { enterApp, forceEnterApp } from '@/utils/appEntry.ts' import { getBackendVersion } from '@/composables/useVersionService' import StepPanel from './components/StepPanel.vue' import BackendStartStep from './components/BackendStartStep.vue' +import { decideFailureActions, filterRuntimeMirrors } from '@/utils/initializationDecision' +import type { + FailureAction, + FailureActionKind, + FailureNoticeKind, +} from '@/utils/initializationDecision' import type { MirrorConfig } from '@/types/mirror' +import type { + InstallStageResult, + RuntimeDoctorCheck, + RuntimeFailureFields, + RuntimeInitMode, +} from '@/types/electron' defineOptions({ name: 'InitializationPage' }) @@ -113,95 +125,18 @@ interface StepState { current: number total: number } + /** 以下几项由失败结果里的机器字段算出,旧链路下退化成原来的「重试 + 镜像面板」。 */ + failureActions: FailureAction[] + failureNotice: FailureNoticeKind | null + failureLogs: string + failureLogPath: string + doctorChecks: RuntimeDoctorCheck[] | null + doctorRunning: boolean } -const stepStates = ref>({ - python: { - status: 'waiting', - message: '', - progress: 0, - showMirrorSelection: false, - mirrors: [], - selectedMirror: '', - countdown: 0, - currentMirror: '', - downloadSpeed: '', - downloadSize: '', - installMessage: '', - installProgress: 0, - deployMessage: '', - deployProgress: 0, - operationDesc: '', - }, - pip: { - status: 'waiting', - message: '', - progress: 0, - showMirrorSelection: false, - mirrors: [], - selectedMirror: '', - countdown: 0, - currentMirror: '', - downloadSpeed: '', - downloadSize: '', - installMessage: '', - installProgress: 0, - deployMessage: '', - deployProgress: 0, - operationDesc: '', - }, - git: { - status: 'waiting', - message: '', - progress: 0, - showMirrorSelection: false, - mirrors: [], - selectedMirror: '', - countdown: 0, - currentMirror: '', - downloadSpeed: '', - downloadSize: '', - installMessage: '', - installProgress: 0, - deployMessage: '', - deployProgress: 0, - operationDesc: '', - }, - repository: { - status: 'waiting', - message: '', - progress: 0, - showMirrorSelection: false, - mirrors: [], - selectedMirror: '', - countdown: 0, - currentMirror: '', - downloadSpeed: '', - downloadSize: '', - installMessage: '', - installProgress: 0, - deployMessage: '', - deployProgress: 0, - operationDesc: '', - }, - dependency: { - status: 'waiting', - message: '', - progress: 0, - showMirrorSelection: false, - mirrors: [], - selectedMirror: '', - countdown: 0, - currentMirror: '', - downloadSpeed: '', - downloadSize: '', - installMessage: '', - installProgress: 0, - deployMessage: '', - deployProgress: 0, - operationDesc: '', - }, - backend: { +/** 六个步骤的初始状态一模一样,逐个抄一遍只会在加字段时漏掉其中一份。 */ +function createStepState(): StepState { + return { status: 'waiting', message: '', progress: 0, @@ -217,12 +152,60 @@ const stepStates = ref>({ deployMessage: '', deployProgress: 0, operationDesc: '', - }, + failureActions: [], + failureNotice: null, + failureLogs: '', + failureLogPath: '', + doctorChecks: null, + doctorRunning: false, + } +} + +const stepStates = ref>({ + python: createStepState(), + pip: createStepState(), + git: createStepState(), + repository: createStepState(), + dependency: createStepState(), + backend: createStepState(), }) // 倒计时定时器 let countdownTimer: ReturnType | null = null +// ==================== Runtime 链路 ==================== + +const runtimeMode = ref('off') +const runtimeMirrorKeys = ref>({}) +const runtimeFallbackLogPath = ref('') + +/** + * Runtime 接管后不再执行的段。 + * + * uv 与 Python 合并进 python 段,pip 由 uv 管、Git 由 Runtime 内置,都不再单独安装。 + * `mirror` 段不在界面的步骤条上,列在这里只是让判定覆盖主进程发得出的全部段名。 + */ +const RUNTIME_TAKEOVER_STEPS = new Set(['mirror', 'pip', 'git']) + +/** 会真的再跑一次安装的动作,自动重试只挑这几种。 */ +const RETRY_ACTION_KINDS = new Set([ + 'retry', + 'retry-other-mirror', + 'rebuild-environment', +]) + +function isRuntimeTakenOver(stepKey: string): boolean { + return runtimeMode.value !== 'off' && RUNTIME_TAKEOVER_STEPS.has(stepKey) +} + +/** 步骤条上的标题:Runtime 接管后 uv 与 Python 合成一段,另外两段直接说明由谁负责。 */ +function stepTitleKey(stepKey: string): string { + if (runtimeMode.value === 'off') return `init.steps.${stepKey}` + if (stepKey === 'python') return 'init.runtime.preparingEnv' + if (RUNTIME_TAKEOVER_STEPS.has(stepKey)) return 'init.runtime.takenOver' + return `init.steps.${stepKey}` +} + // ==================== 计算属性 ==================== const currentStep = computed(() => steps[currentStepIndex.value]) @@ -239,7 +222,7 @@ const currentStepProps = computed(() => { const step = currentStep.value return { - title: t(`init.steps.${step.key}`), + title: t(stepTitleKey(step.key)), status: state.status, message: state.message, progress: state.progress, @@ -249,9 +232,15 @@ const currentStepProps = computed(() => { | 'exception' | 'success', successTitle: `${step.title}完成`, - showMirrorSelection: state.showMirrorSelection, // 所有步骤失败时都显示镜像源选择 + showMirrorSelection: state.showMirrorSelection, // 由 decideFailureActions 决定,旧链路下仍是失败即显示 showSkipButton: step.canSkip && state.status === 'failed', // 只有可跳过的步骤且失败时才显示跳过按钮 - mirrors: state.mirrors, + // Runtime 收不下的镜像源不摆出来:选了也只会被忽略,键名以主进程给的映射表为准 + mirrors: filterRuntimeMirrors( + state.mirrors, + step.key, + runtimeMode.value, + runtimeMirrorKeys.value + ), selectedMirror: state.selectedMirror, countdown: state.countdown, currentMirror: state.currentMirror, @@ -264,6 +253,11 @@ const currentStepProps = computed(() => { operationDesc: state.operationDesc, checkInfo: state.checkInfo, mirrorProgress: state.mirrorProgress, + failureActions: state.failureActions, + failureNotice: state.failureNotice, + failureLogs: state.failureLogs, + doctorChecks: state.doctorChecks, + doctorRunning: state.doctorRunning, } }) @@ -385,34 +379,78 @@ function handleProgress(stepKey: string, progressData: any) { } } +/** 把失败结果里的机器字段落进步骤状态,并算出该给哪些按钮。 */ +function applyFailure(state: StepState, stepKey: string, failure: RuntimeFailureFields) { + const plan = decideFailureActions({ + code: failure.code, + retryable: failure.retryable, + remediation: failure.remediation, + stage: stepKey, + runtimeMode: runtimeMode.value, + }) + + state.failureActions = plan.actions + state.failureNotice = plan.notice + state.showMirrorSelection = plan.showMirrorSelection + state.failureLogs = failure.logs ?? '' + state.failureLogPath = failure.logPath ?? '' + state.doctorChecks = null + state.doctorRunning = false + + logger.info( + `[${stepKey}] 失败处置 - code: ${failure.code ?? '无'}, retryable: ${failure.retryable ?? '无'}, ` + + `动作: ${plan.actions.map(action => action.kind).join(', ') || '无'}` + ) + return plan +} + +/** Runtime 接管的段没有对应的安装动作,直接置完成,不必往主进程跑一趟。 */ +function markStepTakenOver(state: StepState) { + state.status = 'success' + state.progress = 100 + state.message = t('init.runtime.takenOver') + state.showMirrorSelection = false + state.countdown = 0 + state.failureActions = [] + state.failureNotice = null +} + // 执行单个步骤 -async function executeStep(stepKey: string): Promise { +async function executeStep(stepKey: string, rebuild: boolean = false): Promise { const state = stepStates.value[stepKey] + + if (isRuntimeTakenOver(stepKey)) { + logger.info(`步骤 ${stepKey} 由 Runtime 接管,直接置为完成`) + markStepTakenOver(state) + return true + } + state.status = 'processing' state.progress = 0 state.message = t('init.msg.running') + // 失败结果上的机器字段:抛异常前先接住,catch 里统一算按钮 + let failure: RuntimeFailureFields = {} + try { - let result: any + const api = window.electronAPI + let result: InstallStageResult switch (stepKey) { case 'python': - result = await (window.electronAPI as any).installPython(state.selectedMirror) + result = await api.installPython(state.selectedMirror, rebuild) break case 'pip': - result = await (window.electronAPI as any).installPip(state.selectedMirror) + result = await api.installPip(state.selectedMirror, rebuild) break case 'git': - result = await (window.electronAPI as any).installGit(state.selectedMirror) + result = await api.installGit(state.selectedMirror, rebuild) break case 'repository': - result = await (window.electronAPI as any).pullRepository( - targetBranch.value, - state.selectedMirror - ) + result = await api.pullRepository(targetBranch.value, state.selectedMirror, rebuild) break case 'dependency': - result = await (window.electronAPI as any).installDependencies(state.selectedMirror) + result = await api.installDependencies(state.selectedMirror, rebuild) break case 'backend': // 后端启动由BackendStartStep组件处理 @@ -443,6 +481,7 @@ async function executeStep(stepKey: string): Promise { return true } else { + failure = result throw new Error(result.error || t('init.msg.execFailed')) } } catch (error) { @@ -451,10 +490,14 @@ async function executeStep(stepKey: string): Promise { state.status = 'failed' state.message = errorMsg - state.showMirrorSelection = true - // 开始倒计时 - startCountdown(stepKey) + const plan = applyFailure(state, stepKey, failure) + + // 只有真会重跑安装的动作才值得自动重试;不可重试的失败干等 60 秒没有意义 + const autoAction = plan.actions.find(action => RETRY_ACTION_KINDS.has(action.kind)) + if (autoAction) { + startCountdown(stepKey, autoAction.kind === 'rebuild-environment') + } return false } @@ -521,6 +564,8 @@ async function handleSkip() { state.message = t('init.msg.skipped') state.showMirrorSelection = false state.countdown = 0 + state.failureActions = [] + state.failureNotice = null message.warning(t('init.msg.skippedStep', { step: t(`init.steps.${currentStep.value.key}`) })) @@ -553,7 +598,7 @@ async function handleSkip() { } } -async function handleRetry() { +async function handleRetry(rebuild: boolean = false) { const stepKey = currentStep.value.key const state = stepStates.value[stepKey] @@ -567,11 +612,17 @@ async function handleRetry() { // 重置状态 state.showMirrorSelection = false state.countdown = 0 + state.failureActions = [] + state.failureNotice = null + state.failureLogs = '' + state.doctorChecks = null - logger.info(`重试 ${stepKey},使用镜像源: ${state.selectedMirror}`) + logger.info( + `重试 ${stepKey},使用镜像源: ${state.selectedMirror}${rebuild ? '(重建环境)' : ''}` + ) // 重新执行当前步骤 - const success = await executeStep(stepKey) + const success = await executeStep(stepKey, rebuild) if (success) { // 继续执行后续步骤 @@ -631,7 +682,7 @@ function handleBackendError(error: string) { stepStatus.value = 'error' } -function startCountdown(stepKey: string) { +function startCountdown(stepKey: string, rebuild: boolean = false) { const state = stepStates.value[stepKey] if (!state) return @@ -644,12 +695,80 @@ function startCountdown(stepKey: string) { clearInterval(countdownTimer) countdownTimer = null } - // 自动重试 - handleRetry() + // 自动重试:用决策给出的第一个重试类动作,不硬当作普通重试 + handleRetry(rebuild) } }, 1000) } +// ==================== 失败态动作 ==================== + +/** + * 分发失败态按钮。 + * + * 按钮是什么、有几个由 decideFailureActions 决定,这里只把 kind 接回对应的通道, + * 不再按文案或中文 message 判断任何东西。 + */ +async function handleFailureAction(kind: FailureActionKind) { + const state = stepStates.value[currentStep.value.key] + if (!state) return + + switch (kind) { + case 'open-log': + await openFailureLog(state) + return + case 'run-doctor': + await runRuntimeDoctor(state) + return + // 三种重试共用一条通道,只有要不要重建环境这一个区别 + case 'retry': + case 'retry-other-mirror': + await handleRetry(false) + return + case 'rebuild-environment': + await handleRetry(true) + return + } +} + +/** 打开日志:优先 Runtime 本次操作的日志文件,没有就退回本程序自己的日志。 */ +async function openFailureLog(state: StepState) { + const target = state.failureLogPath || runtimeFallbackLogPath.value + + if (!target) { + logger.error('没有可打开的日志文件路径') + message.error(t('init.failure.openLogFailed', { error: t('init.msg.execFailed') })) + return + } + + try { + logger.info(`打开日志文件: ${target}`) + await window.electronAPI.openFile(target) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`打开日志失败: ${errorMsg}`) + message.error(t('init.failure.openLogFailed', { error: errorMsg })) + } +} + +/** 运行诊断:check-critical-files 在 Runtime 链路下问的就是 Runtime doctor。 */ +async function runRuntimeDoctor(state: StepState) { + state.doctorRunning = true + + try { + const result = await window.electronAPI.checkCriticalFiles() + state.doctorChecks = result.runtimeChecks ?? [] + logger.info(`运行诊断完成,检查项: ${state.doctorChecks.length}`) + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.error(`运行诊断失败: ${errorMsg}`) + message.error(t('init.failure.doctorFailed', { error: errorMsg })) + state.doctorChecks = [] + } finally { + state.doctorRunning = false + } +} + async function handleForceEnterConfirm() { forceEnterVisible.value = false logger.info('用户确认跳过初始化') @@ -738,6 +857,30 @@ onMounted(async () => { return } + // Runtime 上下文决定步骤标签、哪些段不再执行、失败时日志开哪个文件、能换哪些镜像; + // 拿不到就按旧链路走,界面与原来完全一致。 + try { + const context = await api.getRuntimeInitContext?.() + if (context) { + runtimeMode.value = context.mode + runtimeMirrorKeys.value = context.mirrorKeys ?? {} + runtimeFallbackLogPath.value = context.fallbackLogPath ?? '' + logger.info(`Runtime 初始化模式: ${context.mode}`) + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`读取 Runtime 上下文失败,按旧链路处理: ${errorMsg}`) + } + + // Runtime 接管的段没有对应的安装动作,进界面就置成完成,不让它们空转一遍 + if (runtimeMode.value !== 'off') { + for (const step of steps) { + if (RUNTIME_TAKEOVER_STEPS.has(step.key)) { + markStepTakenOver(stepStates.value[step.key]) + } + } + } + // 检查是否为强制后端更新模式(从标题栏触发) const forceBackendUpdate = sessionStorage.getItem('forceBackendUpdate') === 'true' if (forceBackendUpdate) { diff --git a/frontend/src/views/setting/TabAdvanced.vue b/frontend/src/views/setting/TabAdvanced.vue index c35db1334..e741e8ea1 100644 --- a/frontend/src/views/setting/TabAdvanced.vue +++ b/frontend/src/views/setting/TabAdvanced.vue @@ -1,8 +1,9 @@