diff --git a/flocks/cli/commands/doctor.py b/flocks/cli/commands/doctor.py index e6e9a9dec..940561006 100644 --- a/flocks/cli/commands/doctor.py +++ b/flocks/cli/commands/doctor.py @@ -2,8 +2,8 @@ from __future__ import annotations +import asyncio import os -import shlex import shutil import subprocess import sys @@ -18,14 +18,12 @@ def doctor_command() -> None: - """Run the source installer from the Flocks source directory.""" + """Repair the active source installation using the shared core installer.""" source_root = _find_source_root() - script = _select_source_install_script(source_root) - command = _build_source_install_command(script) env = _build_source_install_env() console.print(f"[cyan]Flocks source directory:[/cyan] {source_root}") - console.print(f"[cyan]Source install command:[/cyan] {_format_command(command)}") + console.print("[cyan]Repairing Flocks core installation...[/cyan]") if _needs_windows_handoff(): _start_windows_handoff(source_root, env=env) @@ -35,12 +33,41 @@ def doctor_command() -> None: ) return - _run_source_install(command, source_root=source_root, env=env) + _run_core_install(source_root, env=env) console.print("[green]安装正常[/green]") _print_service_diagnosis() +def _run_core_install(source_root: Path, *, env: dict[str, str] | None = None) -> None: + """Run the shared updater/doctor core installation entry point.""" + from flocks.updater import updater + + uv_path = shutil.which("uv") + if not uv_path: + script_name = "install.ps1" if _is_windows() else "install.sh" + console.print( + f"[red]uv was not found. Run scripts/{script_name} to install system prerequisites.[/red]" + ) + raise typer.Exit(1) + + install_env = env or os.environ + try: + asyncio.run( + updater.install_or_repair_source( + install_root=source_root, + uv_path=uv_path, + version=updater.get_current_version(), + uv_default_index=install_env.get("FLOCKS_UV_DEFAULT_INDEX"), + npm_registry=install_env.get("FLOCKS_NPM_REGISTRY"), + sync_timeout=300, + ) + ) + except RuntimeError as error: + console.print(f"[red]Core installation repair failed: {error}[/red]") + raise typer.Exit(1) from error + + def _find_source_root(start: Path | None = None) -> Path: """Find the repository root that owns the source install scripts.""" current = (start or Path(__file__)).resolve() @@ -55,44 +82,6 @@ def _find_source_root(start: Path | None = None) -> Path: raise typer.BadParameter("Could not locate the Flocks source directory.") -def _select_source_install_script(source_root: Path) -> Path: - """Select the platform-specific source install script.""" - suffix = ".ps1" if _is_windows() else ".sh" - script = source_root / "scripts" / f"install{suffix}" - - if not script.is_file(): - raise typer.BadParameter(f"Source installer not found: {script}") - - return script - - -def _build_source_install_command(script: Path) -> list[str]: - """Build the subprocess command for the selected installer.""" - if script.suffix == ".ps1": - powershell = _find_powershell() - return [ - powershell, - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script), - ] - - return ["bash", str(script)] - - -def _run_source_install(command: list[str], *, source_root: Path, env: dict[str, str] | None) -> None: - """Run the selected source installer synchronously.""" - try: - subprocess.run(command, cwd=source_root, check=True, env=env) - except FileNotFoundError as error: - console.print(f"[red]Failed to start installer: {error}[/red]") - raise typer.Exit(1) from error - except subprocess.CalledProcessError as error: - raise typer.Exit(error.returncode or 1) from error - - def _build_source_install_env() -> dict[str, str] | None: """Build installer environment from the persisted install language.""" if not is_cn_install_language(): @@ -195,11 +184,6 @@ def _service_status_is_healthy(status_lines: list[str]) -> bool: return backend_running and webui_running -def _format_command(command: list[str]) -> str: - """Return a shell-readable representation of the command.""" - return " ".join(shlex.quote(part) for part in command) - - def _is_windows() -> bool: """Return whether the current platform should use PowerShell installers.""" return sys.platform.startswith("win") diff --git a/flocks/cli/commands/update.py b/flocks/cli/commands/update.py index c288b9755..fd9978bb7 100644 --- a/flocks/cli/commands/update.py +++ b/flocks/cli/commands/update.py @@ -35,7 +35,7 @@ def update_command( async def _update(check: bool, yes: bool, force: bool = False, region: str | None = None) -> None: - from flocks.updater import build_updated_frontend, check_update, perform_update, detect_deploy_mode + from flocks.updater import check_update, detect_deploy_mode, perform_update from flocks.cli.install_profile import is_cn_install_language if region is None and is_cn_install_language(): @@ -97,26 +97,15 @@ async def _load_update_info(selected_region: str | None): console.print("[yellow]已取消[/yellow]") return - from flocks.cli.service_manager import ServiceError, stop_all - - try: - stop_all(console) - except ServiceError as error: - console.print(f"[red]执行 flocks stop 失败:{error}[/red]") - raise typer.Exit(1) - - append_upgrade_text_log(f"OK cli_update_stopped_services_before_upgrade version={version_to_apply}") - console.print("[green]✓ 已执行 flocks stop[/green]") console.print() stage_labels = { "fetching": "下载最新源码包", "backing_up": "备份当前版本", - "applying": f"应用 v{info.latest_version}", - "syncing": "同步依赖", + "applying": "应用新版本", "restarting": "重启服务", "done": "完成", } - total_steps = 6 + total_steps = 3 seen_stages: set[str] = set() step = 0 active_stage: str | None = None @@ -137,8 +126,9 @@ def _finish_active(success: bool = True) -> None: tarball_url=info.tarball_url, bundle_sha256=info.bundle_sha256, bundle_format=info.bundle_format, - restart=False, + restart=True, region=region, + wait_for_handoff=True, ): if progress.stage == "error": _finish_active(success=False) @@ -149,6 +139,9 @@ def _finish_active(success: bool = True) -> None: _finish_active(success=True) continue + if progress.stage == "restarting": + continue + if progress.stage not in seen_stages: _finish_active(success=True) seen_stages.add(progress.stage) @@ -157,23 +150,8 @@ def _finish_active(success: bool = True) -> None: console.print(f"[cyan][{step}/{total_steps}] {label}...[/cyan] ", end="") active_stage = progress.stage - step += 1 - console.print(f"[cyan][{step}/{total_steps}] 构建前端...[/cyan] ", end="") - try: - await build_updated_frontend(region=region) - except Exception as error: - console.print("[red]✗[/red]") - console.print(f"\n[red]✗ 前端构建失败:{error}[/red]") - raise typer.Exit(1) - console.print("[green]✓[/green]") - - step += 1 - console.print(f"[cyan][{step}/{total_steps}] 完成[/cyan] ", end="") - console.print("[green]✓[/green]") - append_upgrade_text_log(f"OK cli_update_completed version={version_to_apply}") console.print(f"\n[green]✓ 升级完成 → v{version_to_apply}[/green]") - console.print("[dim]如需恢复服务,请执行 [bold]flocks start[/bold][/dim]") def _print_version_table(info) -> None: diff --git a/flocks/cli/service_control.py b/flocks/cli/service_control.py index 34320fa9f..50bd7ae74 100644 --- a/flocks/cli/service_control.py +++ b/flocks/cli/service_control.py @@ -183,23 +183,6 @@ def request_restart_webui( return parse_supervisor_status(data) -def request_prepare_upgrade(*, paths=None, timeout: float | None = 30.0) -> SupervisorStatus: - """Ask the supervisor daemon to pause managed services for upgrade handoff.""" - payload = _post_control_json("/upgrade/prepare", paths=paths, timeout=timeout) - return parse_supervisor_status(payload) - - -def request_resume_upgrade( - config: ServiceConfig, - *, - paths=None, - timeout: float | None = 180.0, -) -> SupervisorStatus: - """Ask the supervisor daemon to resume managed services after upgrade handoff.""" - payload = _post_control_json("/upgrade/resume", payload=service_config_payload(config), paths=paths, timeout=timeout) - return parse_supervisor_status(payload) - - def read_logs( *, service: str, diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 46134d52f..bf6aeb03c 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -89,21 +89,6 @@ class RuntimeRecord: started_at: float | None = None -@dataclass(frozen=True) -class UpgradeRuntimeInfo: - payload_present: bool = False - pid_file_present: bool = False - upgrade_pid: int | None = None - frontend_host: str | None = None - frontend_port: int | None = None - listener_pids: tuple[int, ...] = () - page_active: bool = False - - @property - def has_artifacts(self) -> bool: - return self.payload_present or self.pid_file_present - - def repo_root() -> Path: """Return the installed repository root.""" override = os.getenv("FLOCKS_REPO_ROOT") @@ -723,51 +708,6 @@ def _console_print(console, message: str) -> None: console.print(message) -def _read_upgrade_runtime_info(frontend_port: int | None = None) -> UpgradeRuntimeInfo: - try: - from flocks.updater import updater as updater_module - - payload = updater_module.read_upgrade_runtime_state(frontend_port=frontend_port) - except Exception: - return UpgradeRuntimeInfo(frontend_port=frontend_port) - - listener_pids = tuple(int(pid) for pid in payload.get("listener_pids", []) if isinstance(pid, int)) - return UpgradeRuntimeInfo( - payload_present=bool(payload.get("payload_present")), - pid_file_present=bool(payload.get("pid_file_present")), - upgrade_pid=payload.get("upgrade_pid") if isinstance(payload.get("upgrade_pid"), int) else None, - frontend_host=payload.get("frontend_host") if isinstance(payload.get("frontend_host"), str) else None, - frontend_port=payload.get("frontend_port") if isinstance(payload.get("frontend_port"), int) else frontend_port, - listener_pids=listener_pids, - page_active=bool(payload.get("page_active")), - ) - - -def _resolve_upgrade_runtime(console, *, frontend_port: int, attempt_recover: bool) -> dict[str, object]: - upgrade_info = _read_upgrade_runtime_info(frontend_port) - if not upgrade_info.has_artifacts: - return {"action": "noop", "error": None} - - from flocks.updater import updater as updater_module - - _console_print(console, "[flocks] 检测到升级临时页残留,正在尝试恢复或清理...") - result = updater_module.resolve_upgrade_runtime_state( - attempt_recover=attempt_recover, - frontend_port=upgrade_info.frontend_port or frontend_port, - ) - - action = str(result.get("action") or "noop") - error = result.get("error") - if action == "recovered": - _console_print(console, "[flocks] 已恢复未完成升级,正式 WebUI 将继续接管端口。") - elif action != "noop": - _console_print(console, "[flocks] 已清理升级临时页残留。") - - if isinstance(error, str) and error: - _console_print(console, f"[flocks] 未完成升级的自动恢复失败,已清理临时升级页: {error}") - return result - - def cleanup_stale_pid_file(pid_file: Path) -> None: """Remove pid files that no longer point to running processes.""" if not pid_file.exists(): @@ -1569,7 +1509,6 @@ def _start_all_without_stop(config: ServiceConfig, console) -> None: def _start_all_unlocked(config: ServiceConfig, console, *, paths: RuntimePaths) -> None: """Ensure the supervisor daemon is running; caller must hold lifecycle lock.""" - _resolve_upgrade_runtime(console, frontend_port=config.frontend_port, attempt_recover=False) if supervisor_is_running(paths): status = None try: diff --git a/flocks/cli/service_supervisor.py b/flocks/cli/service_supervisor.py index e94397300..4a615b59a 100644 --- a/flocks/cli/service_supervisor.py +++ b/flocks/cli/service_supervisor.py @@ -119,8 +119,6 @@ def __init__( self._shutdown_requested = threading.Event() self._server: ThreadingHTTPServer | None = None self._server_thread: threading.Thread | None = None - self._backend_paused = False - self._webui_paused = False self.backend = ManagedService( name="backend", label="后端", @@ -277,15 +275,6 @@ def do_POST(self) -> None: if parsed.path == "/stop/webui": self._send_json({"error": "static WebUI is served by Flocks service and cannot be stopped separately"}, status=409) return - if parsed.path == "/upgrade/prepare": - daemon.prepare_upgrade(reason="control upgrade prepare") - self._send_json(daemon.status_payload()) - return - if parsed.path == "/upgrade/resume": - daemon.update_config(payload) - daemon.resume_upgrade(reason="control upgrade resume") - self._send_json(daemon.status_payload()) - return self._send_json({"error": "not found"}, status=404) except _CLIENT_DISCONNECT_ERRORS: return @@ -320,8 +309,8 @@ def status_payload(self) -> dict[str, object]: "state": "stopping" if self._shutdown_requested.is_set() else "running", "log_path": str(supervisor_log_path(self.paths)), }, - "backend": _service_payload(self.backend, paused=self._backend_paused), - "webui": _service_payload(self.webui, paused=self._webui_paused), + "backend": _service_payload(self.backend), + "webui": _service_payload(self.webui), "config": service_config_payload(self.config), } @@ -411,22 +400,18 @@ def _log_paths_for_service(self, service_name: str) -> list[tuple[str, Path]]: def restart_all(self, *, reason: str) -> None: with self._lock: - self._backend_paused = False - self._webui_paused = False self._restart_service(self.backend, reason=reason, immediate=True) self._start_backend_locked(immediate=True) self._sync_static_webui_state() def restart_backend(self, *, reason: str) -> None: with self._lock: - self._backend_paused = False self._restart_service(self.backend, reason=reason, immediate=True) self._start_backend_locked(immediate=True) self._sync_static_webui_state() def restart_webui(self, *, reason: str, force_frontend_build: bool = False) -> None: with self._lock: - self._webui_paused = False if force_frontend_build: from flocks.cli.service_config import with_frontend_build @@ -435,27 +420,6 @@ def restart_webui(self, *, reason: str, force_frontend_build: bool = False) -> N self._start_backend_locked(immediate=True) self._sync_static_webui_state() - def prepare_upgrade(self, *, reason: str) -> None: - with self._lock: - self._backend_paused = True - self._webui_paused = True - _daemon_log("service_pause", {"service": "backend", "reason": reason}) - _daemon_log("service_pause", {"service": "webui", "reason": reason}) - self.backend.last_error = reason - self.webui.last_error = reason - self._stop_service(self.backend) - self.webui.state = "paused" - - def resume_upgrade(self, *, reason: str) -> None: - with self._lock: - self._backend_paused = False - self._webui_paused = False - _daemon_log("service_resume", {"service": "backend", "reason": reason}) - _daemon_log("service_resume", {"service": "webui", "reason": reason}) - self._probe_backend_locked() - self._start_backend_locked(immediate=True) - self._sync_static_webui_state() - def shutdown_children(self) -> None: with self._lock: self._stop_service(self.backend) @@ -463,10 +427,8 @@ def shutdown_children(self) -> None: def tick(self) -> None: with self._lock: - if not self._backend_paused: - self._probe_backend_locked() - if not self._backend_paused: - self._start_backend_locked(immediate=False) + self._probe_backend_locked() + self._start_backend_locked(immediate=False) self._sync_static_webui_state() def _restart_service(self, service: ManagedService, *, reason: str, immediate: bool) -> None: @@ -547,9 +509,6 @@ def _sync_static_webui_state(self) -> None: self.webui.log_path = self.paths.backend_log self.webui.process = None self.webui.command = () - if self._webui_paused: - self.webui.state = "paused" - return if self.backend.state == "healthy": self.webui.state = "static" self.webui.last_error = None diff --git a/flocks/server/app.py b/flocks/server/app.py index 78271d130..770959016 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -472,18 +472,6 @@ async def _delayed_trigger_runtime_start() -> None: except Exception as e: log.warning("workflow.trigger_runtime.start_failed", {"error": str(e)}) - try: - from flocks.updater.updater import recover_upgrade_state - - await _run_startup_phase( - log, - "updater.recover_upgrade_state", - lambda: asyncio.to_thread(recover_upgrade_state), - ) - log.info("updater.recovery.checked") - except Exception as e: - log.warning("updater.recovery.failed", {"error": str(e)}) - blocking_startup_ms = int((time.perf_counter() - startup_started_at) * 1000) log.info("server.startup.ready", { "blocking_duration_ms": blocking_startup_ms, diff --git a/flocks/server/routes/console_upgrade.py b/flocks/server/routes/console_upgrade.py index 107140f35..93b74dfb2 100644 --- a/flocks/server/routes/console_upgrade.py +++ b/flocks/server/routes/console_upgrade.py @@ -525,11 +525,18 @@ async def _run_auto_upgrade_install(record: dict[str, Any]) -> dict[str, Any]: final_stage = "" final_message = "" - async for progress in perform_pro_bundle_install(restart=False): + async for progress in perform_pro_bundle_install(restart=True): final_stage = progress.stage final_message = progress.message if progress.stage == "error": raise ValueError(progress.message) + if progress.stage == "restarting": + details["auto_install_result"] = "restarting" + details["auto_install_message"] = progress.message + record["updated_at"] = datetime.now(UTC).isoformat() + request_id = str(record.get("request_id") or "").strip() + if request_id: + await Storage.set(_request_key(request_id), record, "json") await _maybe_activate_pro_license(record, allow_fallback=False) await _maybe_refresh_pro_license(record) diff --git a/flocks/updater/__init__.py b/flocks/updater/__init__.py index 0ba8cc329..512b9d4b6 100644 --- a/flocks/updater/__init__.py +++ b/flocks/updater/__init__.py @@ -2,8 +2,8 @@ Flocks Updater Provides self-update capability via GitHub releases. -Downloads source archives, backs up the current installation, -and replaces source files — no git binary required at runtime. +Downloads source archives and delegates source replacement to a detached +handoff — no git binary required at runtime. """ from flocks.updater.deploy import DeployMode, detect_deploy_mode @@ -13,6 +13,7 @@ check_update, get_current_version, get_latest_release, + install_or_repair_source, perform_pro_bundle_downgrade, perform_update, perform_pro_bundle_install, @@ -28,6 +29,7 @@ "check_update", "get_current_version", "get_latest_release", + "install_or_repair_source", "perform_update", "perform_pro_bundle_install", "perform_pro_bundle_downgrade", diff --git a/flocks/updater/restart_handoff.py b/flocks/updater/restart_handoff.py index 0d429a4db..f763d8970 100644 --- a/flocks/updater/restart_handoff.py +++ b/flocks/updater/restart_handoff.py @@ -1,16 +1,10 @@ -"""Restart handoff helper for the self-updater. - -The updater process owns the backend port while it is spawning the restart -command. Starting the new backend before that process has fully exited can race -with port release. This helper is spawned instead; it waits for the old backend -to exit, clears any remaining backend listener, runs post-apply upgrade tasks, -and then starts the real restart command. -""" +"""Detached restart and source-upgrade handoff helper.""" from __future__ import annotations import argparse import asyncio +import json import shutil import subprocess import time @@ -18,6 +12,7 @@ from typing import Sequence from flocks.cli import service_manager +from flocks.updater import updater as updater_module from flocks.utils.log import append_upgrade_text_log DEFAULT_PARENT_TIMEOUT_SECONDS = 20.0 @@ -27,6 +22,13 @@ DEFAULT_POLL_INTERVAL_SECONDS = 0.25 +class _NullConsole: + """Discard service-manager progress output in the detached helper.""" + + def print(self, *_args, **_kwargs) -> None: + return None + + def _record_handoff_log(message: str) -> None: append_upgrade_text_log(f"restart_handoff {message}") @@ -74,50 +76,73 @@ def _ensure_backend_port_free(backend_port: int) -> bool: def _stop_supervisor_before_restart( *, + daemon_pid: int | None = None, + backend_port: int | None = None, + service_ports: Sequence[int] = (), + force_daemon_stop: bool = False, timeout_seconds: float = SUPERVISOR_STOP_TIMEOUT_SECONDS, poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, ) -> bool: from flocks.cli import service_control paths = service_manager.runtime_paths() - if not service_control.supervisor_is_running(paths): - return True - - try: - service_control.request_stop(paths=paths, timeout=timeout_seconds) - except Exception as exc: - _record_handoff_log(f"supervisor_stop_request_failed error={exc}") - return False + ports = {port for port in (backend_port, *service_ports) if port is not None} + control_running = service_control.supervisor_is_running(paths) + daemon_running = service_manager.pid_is_running(daemon_pid) + if control_running or daemon_running: + try: + service_control.request_stop(paths=paths, timeout=timeout_seconds) + except Exception as exc: + _record_handoff_log(f"supervisor_stop_request_failed error={exc}") + if not force_daemon_stop or daemon_pid is None: + return False + try: + service_manager._terminate_orphan_pid(daemon_pid, "daemon", _NullConsole()) + except Exception as terminate_exc: + _record_handoff_log(f"supervisor_force_stop_failed error={terminate_exc}") + return False deadline = time.monotonic() + timeout_seconds while time.monotonic() < deadline: - if not service_control.supervisor_is_running(paths): + control_stopped = not service_control.supervisor_is_running(paths) + daemon_stopped = not service_manager.pid_is_running(daemon_pid) + ports_stopped = all(not _backend_port_in_use(port) for port in ports) + if control_stopped and daemon_stopped and ports_stopped: return True time.sleep(poll_interval_seconds) - return not service_control.supervisor_is_running(paths) + return ( + not service_control.supervisor_is_running(paths) + and not service_manager.pid_is_running(daemon_pid) + and all(not _backend_port_in_use(port) for port in ports) + ) def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Flocks restart handoff helper") - parser.add_argument("--parent-pid", type=int, required=True) + parser.add_argument("--mode", choices=("restart", "upgrade"), default="restart") + parser.add_argument("--parent-pid", type=int) parser.add_argument("--backend-host", required=True) parser.add_argument("--backend-port", type=int, required=True) parser.add_argument("--frontend-host", required=True) parser.add_argument("--frontend-port", type=int, required=True) parser.add_argument("--backend-pid-file") parser.add_argument("--install-root", required=True) + parser.add_argument("--content-root") + parser.add_argument("--backup-path") + parser.add_argument("--was-running", action="store_true") + parser.add_argument("--daemon-pid", type=int) + parser.add_argument("--service-config-json") parser.add_argument("--uv-path", required=True) parser.add_argument("--sync-timeout", type=int, required=True) parser.add_argument("--version", required=True) parser.add_argument("--current-version", required=True) - parser.add_argument("--backup-path") parser.add_argument("--uv-default-index") parser.add_argument("--npm-registry") parser.add_argument("--pro-wheel-path") parser.add_argument("--pro-bundle-manifest-path") parser.add_argument("--bundle-sha256") parser.add_argument("--cleanup-dir") - parser.add_argument("--prepare-handover", action="store_true") + parser.add_argument("--prepare-handover", action="store_true", help=argparse.SUPPRESS) parser.add_argument("restart_argv", nargs=argparse.REMAINDER) args = parser.parse_args(argv) if args.restart_argv and args.restart_argv[0] == "--": @@ -126,23 +151,177 @@ def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def _run_upgrade_tasks(args: argparse.Namespace) -> str | None: - from flocks.updater import updater - - return asyncio.run( - updater.run_handoff_upgrade_tasks( - install_root=Path(args.install_root), - uv_path=args.uv_path, - version=args.version, - uv_default_index=args.uv_default_index, - npm_registry=args.npm_registry, - pro_wheel_path=Path(args.pro_wheel_path) if args.pro_wheel_path else None, - pro_bundle_manifest_path=( - Path(args.pro_bundle_manifest_path) if args.pro_bundle_manifest_path else None - ), - bundle_sha256=args.bundle_sha256, - sync_timeout=args.sync_timeout, + try: + asyncio.run( + updater_module.install_or_repair_source( + install_root=Path(args.install_root), + uv_path=args.uv_path, + version=args.version, + uv_default_index=args.uv_default_index, + npm_registry=args.npm_registry, + pro_wheel_path=Path(args.pro_wheel_path) if args.pro_wheel_path else None, + pro_bundle_manifest_path=( + Path(args.pro_bundle_manifest_path) if args.pro_bundle_manifest_path else None + ), + bundle_sha256=args.bundle_sha256, + sync_timeout=args.sync_timeout, + ) ) + except RuntimeError as exc: + return str(exc) + return None + + +def _service_config_from_args(args: argparse.Namespace) -> service_manager.ServiceConfig: + """Load the captured service config without consulting the old daemon.""" + from flocks.cli.service_config import service_config_from_payload + + try: + payload = json.loads(args.service_config_json or "") + except json.JSONDecodeError as exc: + raise ValueError(f"invalid service config JSON: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError("service config JSON must contain an object") + return service_config_from_payload(payload) + + +def _validate_simple_upgrade_args(args: argparse.Namespace) -> None: + """Validate all inputs before stopping or changing the active install.""" + if not args.content_root: + raise ValueError("upgrade content root is missing") + content_root = Path(args.content_root) + if not content_root.is_dir(): + raise ValueError(f"upgrade content root does not exist: {content_root}") + if not args.backup_path: + raise ValueError("upgrade backup path is missing") + backup_path = Path(args.backup_path) + if not backup_path.is_file(): + raise ValueError(f"upgrade backup does not exist: {backup_path}") + if args.was_running and not args.restart_argv: + raise ValueError("running service requires a restart runtime") + _service_config_from_args(args) + + +def _service_ports(args: argparse.Namespace) -> tuple[int, ...]: + """Return every port that must be released before source replacement.""" + config = _service_config_from_args(args) + return tuple(sorted({config.backend_port, config.frontend_port})) + + +def _stop_services_before_upgrade(args: argparse.Namespace) -> bool: + """Stop managed services and wait for the captured daemon and port to exit.""" + try: + service_manager.stop_all(_NullConsole()) + except service_manager.ServiceError as exc: + _record_handoff_log(f"service_stop_failed error={exc}") + return False + return _stop_supervisor_before_restart( + daemon_pid=args.daemon_pid, + backend_port=args.backend_port, + service_ports=_service_ports(args), + ) + + +def _apply_new_source(args: argparse.Namespace) -> None: + """Replace the active source tree with the staged source tree.""" + if not args.content_root: + raise RuntimeError("upgrade content root is missing") + content_root = Path(args.content_root) + if not content_root.is_dir(): + raise RuntimeError(f"upgrade content root does not exist: {content_root}") + updater_module._replace_install_dir(content_root, Path(args.install_root)) + + +def _build_captured_start_argv(args: argparse.Namespace) -> list[str]: + """Build ``flocks start`` directly from the pre-upgrade config snapshot.""" + if not args.restart_argv: + return [] + config = _service_config_from_args(args) + argv = [ + args.restart_argv[0], + "-m", + "flocks.cli.main", + "start", + "--host", + config.frontend_host, + "--port", + str(config.frontend_port), + ] + if config.no_browser: + argv.append("--no-browser") + if config.skip_frontend_build: + argv.append("--skip-webui-build") + if config.legacy_backend_host is not None: + argv.extend(["--server-host", config.legacy_backend_host]) + if config.legacy_backend_port is not None: + argv.extend(["--server-port", str(config.legacy_backend_port)]) + return argv + + +def _start_service_after_upgrade(args: argparse.Namespace) -> tuple[bool, str, str]: + """Synchronously restore the service only when it ran before the upgrade.""" + if not args.was_running: + return True, "", "" + start_argv = _build_captured_start_argv(args) + if not start_argv: + _record_handoff_log("missing_restart_argv") + return False, "", "missing restart runtime" + completed = subprocess.run( + start_argv, + cwd=Path(args.install_root), + capture_output=True, + text=False, + check=False, + ) + stdout = updater_module._clean_process_output(completed.stdout) + stderr = updater_module._clean_process_output(completed.stderr) + if completed.returncode == 0: + return True, stdout, stderr + _record_handoff_log( + f"restart_failed returncode={completed.returncode} stdout={stdout} stderr={stderr}" ) + return False, stdout, stderr + + +def _write_upgrade_result( + *, + args: argparse.Namespace, + phase: str, + failed_stage: str | None = None, + error: str | None = None, + backup_path: Path | None = None, + stdout: str | None = None, + stderr: str | None = None, +) -> None: + """Persist a result record without driving automatic recovery.""" + try: + config_payload = json.loads(args.service_config_json or "{}") + except json.JSONDecodeError: + config_payload = {} + payload = { + "phase": phase, + "version": args.version, + "current_version": args.current_version, + "was_running": args.was_running, + "service_config": config_payload, + } + if error: + payload["last_error"] = error + if failed_stage: + payload["failed_stage"] = failed_stage + if backup_path is not None: + payload["backup_path"] = str(backup_path) + if stdout: + payload["stdout"] = stdout + if stderr: + payload["stderr"] = stderr + try: + updater_module._write_upgrade_result_state(payload) + except Exception as exc: + try: + _record_handoff_log(f"upgrade_result_write_failed phase={phase} error={exc}") + except Exception: + pass def _report_pending_pro_bundle_install_receipt(args: argparse.Namespace) -> None: @@ -161,39 +340,86 @@ def _report_pending_pro_bundle_install_receipt(args: argparse.Namespace) -> None _record_handoff_log("install_receipt_report_skipped") -def _rollback_failed_upgrade(args: argparse.Namespace, error: str) -> None: - from flocks.updater import updater - - _record_handoff_log(f"upgrade_tasks_failed error={error}") - backup_path = Path(args.backup_path) if args.backup_path else None +def _run_simple_upgrade(args: argparse.Namespace) -> int: + """Run stop, source replacement, installation, and restart in order.""" try: - updater._rollback_failed_update( - backup_path, - Path(args.install_root), - args.current_version, + _validate_simple_upgrade_args(args) + except ValueError as exc: + error = str(exc) + _record_handoff_log(f"upgrade_validation_failed error={error}") + _write_upgrade_result( + args=args, + phase="failed", + failed_stage="validation", + error=error, ) - except Exception as exc: - _record_handoff_log(f"rollback_failed error={exc}") + _cleanup_dir(args.cleanup_dir) + return 1 + + if args.parent_pid is not None and not _wait_for_parent_exit(args.parent_pid): + error = f"parent exit timed out: {args.parent_pid}" + _record_handoff_log(error) + _write_upgrade_result(args=args, phase="failed", failed_stage="wait_parent", error=error) + return 1 + + _write_upgrade_result(args=args, phase="running") + if not _stop_services_before_upgrade(args): + error = "service stop timed out" + _record_handoff_log(error) + _write_upgrade_result(args=args, phase="failed", failed_stage="stop", error=error) + return 1 -def _prepare_upgrade_handover(args: argparse.Namespace) -> bool: - from flocks.updater import updater + backup_path = Path(args.backup_path) try: - updater._prepare_upgrade_handover(args.version) + _apply_new_source(args) except Exception as exc: - _record_handoff_log(f"prepare_handover_failed error={exc}") - return False - return True - - -def _rollback_upgrade_handover() -> None: - from flocks.updater import updater + error = str(exc) + _record_handoff_log(f"source_replace_failed error={error}") + _write_upgrade_result( + args=args, + phase="failed", + failed_stage="source_replace", + error=error, + backup_path=backup_path, + ) + return 1 try: - updater.rollback_upgrade_handover() + task_error = _run_upgrade_tasks(args) except Exception as exc: - _record_handoff_log(f"handover_rollback_failed error={exc}") + task_error = f"upgrade tasks crashed: {exc}" + if task_error is not None: + _record_handoff_log(f"install_failed error={task_error}") + _write_upgrade_result( + args=args, + phase="failed", + failed_stage="install", + error=task_error, + backup_path=backup_path, + stderr=task_error, + ) + return 1 + + _report_pending_pro_bundle_install_receipt(args) + started, stdout, stderr = _start_service_after_upgrade(args) + if not started: + error = "service restart failed" + _write_upgrade_result( + args=args, + phase="failed", + failed_stage="start", + error=error, + backup_path=backup_path, + stdout=stdout, + stderr=stderr, + ) + return 1 + + _write_upgrade_result(args=args, phase="done", backup_path=backup_path) + _cleanup_dir(args.cleanup_dir) + return 0 def _cleanup_dir(path_value: str | None) -> None: @@ -202,6 +428,86 @@ def _cleanup_dir(path_value: str | None) -> None: shutil.rmtree(Path(path_value), ignore_errors=True) +def _legacy_upgrade_page_pids(args: argparse.Namespace, page_dir: Path, pid_path: Path) -> list[int]: + """Find trusted temporary upgrade-page processes from older handoffs.""" + candidates: set[int] = set() + try: + candidates.update(service_manager.port_owner_pids(args.frontend_port)) + except Exception: + pass + try: + candidates.add(int(pid_path.read_text(encoding="utf-8").strip())) + except (OSError, ValueError): + pass + + page_dir_text = str(page_dir).lower() + matches: list[int] = [] + for pid in sorted(candidates): + try: + command_line = service_manager._process_command_line(pid).lower() + except Exception: + continue + if "http.server" in command_line and "upgrade-page" in command_line and page_dir_text in command_line: + matches.append(pid) + return matches + + +def _cleanup_legacy_upgrade_handover(args: argparse.Namespace) -> bool: + """Stop and remove upgrade-page artifacts left by old handoff protocols.""" + run_dir = updater_module._flocks_root() / "run" + state_path = run_dir / "upgrade-state.json" + pid_path = run_dir / "upgrade_server.pid" + page_dir = run_dir / "upgrade-page" + if not state_path.exists() and not pid_path.exists() and not page_dir.exists(): + return True + page_pids = _legacy_upgrade_page_pids(args, page_dir, pid_path) + + try: + for pid in page_pids: + service_manager._terminate_orphan_pid(pid, "升级临时页", _NullConsole()) + except Exception as exc: + _record_handoff_log(f"legacy_handover_stop_failed error={exc}") + return False + + remaining = _legacy_upgrade_page_pids(args, page_dir, pid_path) + if remaining: + _record_handoff_log(f"legacy_handover_still_running pids={remaining}") + return False + + state_path.unlink(missing_ok=True) + pid_path.unlink(missing_ok=True) + shutil.rmtree(page_dir, ignore_errors=True) + if page_pids: + _record_handoff_log(f"legacy_handover_cleaned pids={page_pids}") + return True + + +def _legacy_supervisor_pid(args: argparse.Namespace) -> int | None: + """Capture the daemon PID associated with an older handoff request.""" + try: + candidates = service_manager.trusted_daemon_process_pids(root=Path(args.install_root)) + except Exception as exc: + _record_handoff_log(f"legacy_daemon_scan_failed error={exc}") + return None + + port_tokens = (f"--server-port {args.backend_port}", f"--server-port={args.backend_port}") + matches: list[int] = [] + for pid in candidates: + try: + command_line = service_manager._process_command_line(pid).lower() + except Exception: + continue + if any(token in command_line for token in port_tokens): + matches.append(pid) + if len(matches) == 1: + return matches[0] + if not matches and len(candidates) == 1: + return candidates[0] + if candidates: + _record_handoff_log(f"legacy_daemon_scan_ambiguous pids={candidates}") + return None + + def _cli_subcommand(argv: Sequence[str]) -> str | None: """Return the flocks.cli.main subcommand embedded in a Python argv.""" for index, value in enumerate(argv[:-2]): @@ -210,6 +516,40 @@ def _cli_subcommand(argv: Sequence[str]) -> str | None: return None +def _restore_legacy_handoff_endpoints(args: argparse.Namespace) -> None: + """Restore endpoints lost by pre-v2026.7.8 split-service handoffs.""" + if not args.backend_pid_file or _cli_subcommand(args.restart_argv) != "serve": + return + + state_path = updater_module._flocks_root() / "run" / "upgrade-state.json" + try: + payload = json.loads(state_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return + except (OSError, json.JSONDecodeError) as exc: + _record_handoff_log(f"legacy_service_config_invalid error={exc}") + return + + if not isinstance(payload, dict): + _record_handoff_log("legacy_service_config_invalid error=state is not an object") + return + + hosts = (payload.get("backend_host"), payload.get("frontend_host")) + ports = (payload.get("backend_port"), payload.get("frontend_port")) + if not all(isinstance(host, str) and host.strip() for host in hosts) or not all( + isinstance(port, int) and not isinstance(port, bool) and 0 < port <= 65535 for port in ports + ): + _record_handoff_log("legacy_service_config_invalid error=invalid host or port") + return + + args.backend_host, args.frontend_host = hosts + args.backend_port, args.frontend_port = ports + _record_handoff_log( + "legacy_service_config_restored " + f"backend={args.backend_host}:{args.backend_port} frontend={args.frontend_host}:{args.frontend_port}" + ) + + def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Sequence[str]) -> list[str]: if _cli_subcommand(restart_argv) != "serve": return list(restart_argv) @@ -236,6 +576,10 @@ def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Se def run(argv: Sequence[str] | None = None) -> int: args = _parse_args(argv) + if args.mode == "upgrade": + return _run_simple_upgrade(args) + + _restore_legacy_handoff_endpoints(args) restart_argv = _restart_argv_for_current_runtime(args, args.restart_argv) if not restart_argv: _record_handoff_log("missing_restart_argv") @@ -246,14 +590,36 @@ def run(argv: Sequence[str] | None = None) -> int: f"parent_pid={args.parent_pid} backend={args.backend_host}:{args.backend_port} " f"frontend={args.frontend_host}:{args.frontend_port}" ) + legacy_daemon_pid = _legacy_supervisor_pid(args) if args.prepare_handover else None - if not _wait_for_parent_exit(args.parent_pid): + if args.parent_pid is not None and not _wait_for_parent_exit(args.parent_pid): _record_handoff_log(f"parent_exit_timeout parent_pid={args.parent_pid}") _cleanup_dir(args.cleanup_dir) return 1 + supervisor_stopped = False if args.prepare_handover: - if not _prepare_upgrade_handover(args): + supervisor_stopped = _stop_supervisor_before_restart( + daemon_pid=legacy_daemon_pid, + backend_port=args.backend_port, + service_ports=(args.frontend_port,), + force_daemon_stop=True, + ) + if not supervisor_stopped: + _record_handoff_log("legacy_handover_stop_timeout") + _cleanup_dir(args.cleanup_dir) + return 1 + elif args.pro_wheel_path or args.pro_bundle_manifest_path: + supervisor_stopped = _stop_supervisor_before_restart( + backend_port=args.backend_port, + service_ports=(args.frontend_port,), + ) + if not supervisor_stopped: + _record_handoff_log("pro_handover_stop_timeout") + _cleanup_dir(args.cleanup_dir) + return 1 + if not _ensure_backend_port_free(args.backend_port): + _record_handoff_log(f"backend_port_unavailable port={args.backend_port}") _cleanup_dir(args.cleanup_dir) return 1 elif not _ensure_backend_port_free(args.backend_port): @@ -266,15 +632,19 @@ def run(argv: Sequence[str] | None = None) -> int: except Exception as exc: task_error = f"upgrade tasks crashed: {exc}" if task_error is not None: - _rollback_failed_upgrade(args, task_error) + _record_handoff_log(f"upgrade_tasks_failed error={task_error}") _cleanup_dir(args.cleanup_dir) return 1 _report_pending_pro_bundle_install_receipt(args) - if not _stop_supervisor_before_restart(): + uses_legacy_protocol = bool(args.backup_path or args.prepare_handover or args.backend_pid_file) + if uses_legacy_protocol and not _cleanup_legacy_upgrade_handover(args): + _record_handoff_log("legacy_handover_cleanup_failed") + _cleanup_dir(args.cleanup_dir) + return 1 + + if not supervisor_stopped and not _stop_supervisor_before_restart(): _record_handoff_log("supervisor_stop_timeout") - if args.prepare_handover: - _rollback_upgrade_handover() _cleanup_dir(args.cleanup_dir) return 1 @@ -286,8 +656,6 @@ def run(argv: Sequence[str] | None = None) -> int: ) except OSError as exc: _record_handoff_log(f"restart_spawn_failed error={exc}") - if args.prepare_handover: - _rollback_upgrade_handover() _cleanup_dir(args.cleanup_dir) return 1 diff --git a/flocks/updater/updater.py b/flocks/updater/updater.py index 3ebdf7791..bfc418415 100644 --- a/flocks/updater/updater.py +++ b/flocks/updater/updater.py @@ -1,10 +1,8 @@ -""" -Core updater logic +"""Core updater logic. -Checks for updates via GitHub / Gitee / GitLab Releases API, downloads -the source archive (zip / tar.gz), backs up the current installation, -extracts the new source over it, re-syncs dependencies, and restarts the -process in-place. +The active process downloads and validates source archives. A detached +handoff stops the old service, backs up and replaces the source tree, repairs +the installation, and restores the captured service state. No git binary is required at runtime — all code fetching is done via HTTP. @@ -13,13 +11,11 @@ """ import asyncio -import contextlib import importlib.util import json import os import re import shutil -import signal import subprocess import sys import tarfile @@ -40,13 +36,6 @@ _DEFAULT_REPO = "AgentFlocks/Flocks" _BACKUP_DIR = Path.home() / ".flocks" / "version" -_UPGRADE_PAGE_MARKER = "flocks-upgrade-in-progress" -_UPGRADE_PHASE_HANDOVER_PREPARING = "handover_preparing" -_UPGRADE_PHASE_TEMP_PAGE_ACTIVE = "temporary_page_active" -_UPGRADE_PHASE_CUTOVER_APPLIED = "cutover_applied" -_UPGRADE_PHASE_ROLLBACK_IN_PROGRESS = "rollback_in_progress" -_UPGRADE_PHASE_ROLLBACK_FAILED = "rollback_failed" -_STATE_FIELD_UNSET = object() _UPDATE_REGION_CN = "cn" _CN_NPM_REGISTRY = "https://registry.npmmirror.com/" _CN_UV_DEFAULT_INDEX = "https://mirrors.aliyun.com/pypi/simple" @@ -54,9 +43,8 @@ _CURL_USER_AGENT = "curl/8.7.1" _FRONTEND_DEPENDENCY_INSTALL_TIMEOUT_SECONDS = 300 _FRONTEND_BUILD_TIMEOUT_SECONDS = 300 -_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 180 +_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 300 _WINDOWS_DEPENDENCY_SYNC_TIMEOUT_SECONDS = 300 -_CANCELLATION_RETRY_DELAY_SECONDS = 0.1 _PRESERVE_NAMES: set[str] = { ".venv", @@ -67,6 +55,17 @@ "__pycache__", } +_ROOT_RUNTIME_NAMES: set[str] = { + ".secret.json", + "config.json", + "data", + "flocks.json", + "flocks.jsonc", + "mcp_list.json", + "run", + "storage", +} + log = Log.create(service="updater") @@ -109,6 +108,15 @@ class UpdateMirrorProfile: pip_index_url: str | None = None +@dataclass(frozen=True) +class ServiceSnapshot: + """Service state captured once before the detached upgrade starts.""" + + config: Any + daemon_pid: int | None + was_running: bool + + @dataclass(frozen=True) class _FrontendNpmCandidate: """A single npm launcher candidate for frontend rebuilds.""" @@ -118,12 +126,6 @@ class _FrontendNpmCandidate: source: str -@dataclass(frozen=True) -class _ProComponentSnapshot: - installed: bool - version: str | None = None - - # ------------------------------------------------------------------ # # Install root # ------------------------------------------------------------------ # @@ -517,18 +519,6 @@ async def build_updated_frontend( raise RuntimeError(frontend_error) -async def _await_ignoring_cancellation(awaitable): - """Finish a post-handover critical step even if the SSE client disconnects.""" - task = asyncio.create_task(awaitable) - while True: - try: - return await asyncio.shield(task) - except asyncio.CancelledError: - log.warning("updater.restart.critical_step_cancelled_ignored") - with contextlib.suppress(asyncio.CancelledError): - await asyncio.sleep(_CANCELLATION_RETRY_DELAY_SECONDS) - - def _dependency_sync_timeout_seconds() -> int: """Return the timeout budget for ``uv sync`` during self-update.""" if sys.platform == "win32": @@ -538,7 +528,7 @@ def _dependency_sync_timeout_seconds() -> int: def _build_dependency_sync_command(uv_path: str, *, uv_default_index: str | None = None) -> list[str]: """Build the ``uv sync`` command used by the self-updater.""" - cmd = [uv_path, "sync", "--frozen", "--no-python-downloads"] + cmd = [uv_path, "sync", "--no-python-downloads"] if uv_default_index: cmd.extend(["--default-index", uv_default_index]) return cmd @@ -569,10 +559,31 @@ async def _run_uv_sync(cmd: list[str]) -> tuple[int, str, str]: def _timeout_message() -> str: return f"Dependency sync timed out after {effective_timeout}s while running uv sync." + def _log_timeout(exc: subprocess.TimeoutExpired, *, retry_without_default_index: bool) -> None: + log.warning( + "updater.dependencies.sync_timeout", + { + "command": list(exc.cmd) if not isinstance(exc.cmd, str) else exc.cmd, + "timeout": effective_timeout, + "stdout": _clean_process_output(exc.stdout), + "stderr": _clean_process_output(exc.stderr), + "retry_without_default_index": retry_without_default_index, + }, + ) + try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: - return _timeout_message() + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, retry_without_default_index=bool(uv_default_index)) + if not uv_default_index: + return _timeout_message() + uv_cmd = _build_dependency_sync_command(uv_path) + uv_default_index = None + try: + code, _, err = await _run_uv_sync(uv_cmd) + except subprocess.TimeoutExpired as fallback_exc: + _log_timeout(fallback_exc, retry_without_default_index=False) + return _timeout_message() if ( code != 0 @@ -595,7 +606,8 @@ def _timeout_message() -> str: await asyncio.sleep(2) try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, retry_without_default_index=False) return _timeout_message() if code != 0 and uv_default_index: @@ -610,7 +622,8 @@ def _timeout_message() -> str: uv_cmd = _build_dependency_sync_command(uv_path) try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, retry_without_default_index=False) return _timeout_message() if code != 0: @@ -618,7 +631,8 @@ def _timeout_message() -> str: await asyncio.sleep(3) try: code, _, err = await _run_uv_sync(uv_cmd) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired as exc: + _log_timeout(exc, retry_without_default_index=False) return _timeout_message() if code != 0: @@ -684,197 +698,17 @@ def _upgrade_run_dir() -> Path: return _flocks_root() / "run" -def _upgrade_log_dir() -> Path: - return _flocks_root() / "logs" - - -def _upgrade_state_path() -> Path: - return _upgrade_run_dir() / "upgrade-state.json" +def _upgrade_result_path() -> Path: + return _upgrade_run_dir() / "upgrade-result.json" -def _upgrade_server_pid_path() -> Path: - return _upgrade_run_dir() / "upgrade_server.pid" - - -def _upgrade_page_dir() -> Path: - return _upgrade_run_dir() / "upgrade-page" - - -def _upgrade_page_log_path() -> Path: - return _upgrade_log_dir() / "upgrade-page.log" - - -def _read_upgrade_state() -> dict[str, Any] | None: - path = _upgrade_state_path() - if not path.exists(): - return None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return None - return payload if isinstance(payload, dict) else None - - -def _write_upgrade_state(payload: dict[str, Any]) -> None: - path = _upgrade_state_path() +def _write_upgrade_result_state(payload: dict[str, Any]) -> None: + """Persist diagnostics from the detached upgrade helper.""" + path = _upgrade_result_path() path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, ensure_ascii=True, sort_keys=True), encoding="utf-8") - - -def _clear_upgrade_state() -> None: - _upgrade_state_path().unlink(missing_ok=True) - - -def _persist_upgrade_state( - payload: dict[str, Any], - *, - phase: str | None = None, - last_error: str | None | object = _STATE_FIELD_UNSET, -) -> dict[str, Any]: - if phase is not None: - payload["phase"] = phase - if last_error is not _STATE_FIELD_UNSET: - if last_error: - payload["last_error"] = last_error - else: - payload.pop("last_error", None) - _write_upgrade_state(payload) - return payload - - -def _upgrade_page_html(version: str) -> str: - return f""" - - - - - Flocks 升级中 - - - -
-
Flocks 正在升级
-

系统升级中,请稍候

-

升级期间服务会短暂重启。当前页面会在新版本恢复后自动刷新。

-
v{version}
-
- - 正在切换到新版本并恢复服务... -
-
如果页面长时间没有恢复,请稍后手动刷新一次。
-
- - - -""" - - -# ------------------------------------------------------------------ # -# Config helper -# ------------------------------------------------------------------ # + result = dict(payload) + result["updated_at"] = datetime.now(timezone.utc).isoformat() + path.write_text(json.dumps(result, ensure_ascii=True, sort_keys=True), encoding="utf-8") async def _get_updater_config(): @@ -1622,7 +1456,7 @@ def _backup_current_version( retain_count: int = 1, ) -> Path | None: """ - Compress the current install directory into ~/.flocks/version/ . + Compress the current source tree into ~/.flocks/version/ . Returns the backup path on success, None on failure. Preserved runtime/user directories are excluded. """ @@ -1633,7 +1467,9 @@ def _backup_current_version( def _filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None: parts = info.name.split("/") - for index, part in enumerate(parts): + if len(parts) >= 2 and parts[0] == "flocks" and parts[1] in _ROOT_RUNTIME_NAMES: + return None + for part in parts: if part in _PRESERVE_NAMES: return None if part == "dist": @@ -1737,62 +1573,34 @@ def _venv_python_path(install_root: Path) -> Path: return install_root / ".venv" / "bin" / "python" -async def _snapshot_pro_component(install_root: Path) -> _ProComponentSnapshot: - python_path = _venv_python_path(install_root) - if not python_path.exists(): - return _ProComponentSnapshot(installed=False) - code, out, _ = await _run_async( - [ - str(python_path), - "-c", - "import importlib.metadata as m\n" - "import sys\n" - "try:\n" - " print(m.version('flockspro'))\n" - "except m.PackageNotFoundError:\n" - " sys.exit(2)\n", - ], - cwd=install_root, - timeout=30, - ) - if code == 0 and out.strip(): - return _ProComponentSnapshot(installed=True, version=out.strip()) - return _ProComponentSnapshot(installed=False) - - -async def _restore_pro_component_snapshot( - snapshot: _ProComponentSnapshot, - *, - uv_path: str, +async def install_or_repair_source( install_root: Path, - env: dict[str, str] | None, -) -> str | None: - python_path = _venv_python_path(install_root) - if not python_path.exists(): - return f"Python environment may need manual repair: missing {python_path}" - if snapshot.installed and snapshot.version: - cmd = [uv_path, "pip", "install", "--python", str(python_path), "--no-deps", f"flockspro=={snapshot.version}"] - else: - cmd = [uv_path, "pip", "uninstall", "--python", str(python_path), "-y", "flockspro"] - code, _, err = await _run_async(cmd, cwd=install_root, timeout=180, env=env) - if code != 0: - return f"Python environment may need manual repair: {err}" - return None - - -async def run_handoff_upgrade_tasks( *, - install_root: Path, - uv_path: str, version: str, + uv_path: str, uv_default_index: str | None = None, npm_registry: str | None = None, + sync_timeout: int = 300, pro_wheel_path: Path | None = None, pro_bundle_manifest_path: Path | None = None, bundle_sha256: str | None = None, - sync_timeout: int | None = None, -) -> str | None: - """Run upgrade work that must happen after the old service exits.""" +) -> None: + """Install or repair the active source tree after managed services stop.""" + install_webui_dir = install_root / "webui" + if install_webui_dir.is_dir() and (install_webui_dir / "package.json").exists(): + from flocks.cli import service_manager + + install_script = "scripts/install.ps1" if sys.platform == "win32" else "scripts/install.sh" + if service_manager.resolve_npm_executable() is None: + raise RuntimeError( + f"npm was not found. Run {install_script} to install Node.js and npm." + ) + if not service_manager.node_version_satisfies_requirement(): + raise RuntimeError( + f"Node.js {service_manager.MIN_NODE_MAJOR}+ is required. " + f"Run {install_script} to install a compatible runtime." + ) + sync_env = _build_uv_sync_env() sync_error = await _sync_project_dependencies( uv_path=uv_path, @@ -1802,7 +1610,7 @@ async def run_handoff_upgrade_tasks( env=sync_env, ) if sync_error is not None: - return sync_error + raise RuntimeError(sync_error) if pro_wheel_path is not None: python_path = _venv_python_path(install_root) @@ -1814,34 +1622,28 @@ async def run_handoff_upgrade_tasks( env=sync_env, ) if code != 0: - return f"Flocks Pro component install failed: {err}" + raise RuntimeError(f"Flocks Pro component install failed: {err}") - validation_error = await _validate_restart_runtime(install_root) - if validation_error: - return validation_error - - install_webui_dir = install_root / "webui" if install_webui_dir.is_dir() and (install_webui_dir / "package.json").exists(): frontend_error = await _build_frontend_workspace( install_webui_dir, npm_registry=npm_registry, ) if frontend_error is not None: - return frontend_error + raise RuntimeError(frontend_error) + + validation_error = await _validate_restart_runtime(install_root) + if validation_error: + raise RuntimeError(validation_error) + + _refresh_global_cli_entry(install_root) - _write_version_marker(version.lstrip("v")) if pro_bundle_manifest_path is not None and pro_bundle_manifest_path.is_file(): _write_pro_bundle_install_marker( _load_json_file(pro_bundle_manifest_path), bundle_sha256=bundle_sha256, ) - - try: - _refresh_global_cli_entry(install_root) - except Exception as exc: - log.warning("updater.refresh_cli.failed", {"error": str(exc)}) - - return None + _write_version_marker(version.lstrip("v")) def _merge_console_manifest_release_identity( @@ -1971,11 +1773,6 @@ async def _uninstall_pro_component( return None -class _NullConsole: - def print(self, *args, **kwargs) -> None: - return None - - def _current_service_config(): from flocks.cli import service_manager from flocks.cli.service_config import service_config_from_status_payload @@ -1992,6 +1789,68 @@ def _current_service_config(): ) +def _capture_service_snapshot() -> ServiceSnapshot: + """Capture restart configuration and liveness before the service is stopped.""" + from flocks.cli import service_manager + from flocks.cli.service_config import ServiceConfig, service_config_from_status_payload + from flocks.cli.service_control import read_supervisor_status + + paths = service_manager.runtime_paths() + try: + status = read_supervisor_status(paths=paths, timeout=1.0) + except Exception as exc: + backend_record = service_manager.read_runtime_record(paths.backend_pid) + frontend_record = service_manager.read_runtime_record(paths.frontend_pid) + backend_running = service_manager.runtime_record_is_running(backend_record) + frontend_running = service_manager.runtime_record_is_running(frontend_record) + if backend_running or frontend_running: + backend_host = backend_record.host if backend_record and backend_record.host else "127.0.0.1" + backend_port = backend_record.port if backend_record and backend_record.port else 5173 + frontend_host = frontend_record.host if frontend_record and frontend_record.host else backend_host + frontend_port = frontend_record.port if frontend_record and frontend_record.port else backend_port + config = ServiceConfig( + backend_host=backend_host, + backend_port=backend_port, + frontend_host=frontend_host, + frontend_port=frontend_port, + no_browser=True, + skip_frontend_build=True, + ) + return ServiceSnapshot(config=config, daemon_pid=None, was_running=True) + + try: + daemon_pids = service_manager.trusted_daemon_process_pids(root=_get_repo_root()) + except Exception: + daemon_pids = [] + if daemon_pids: + raise RuntimeError( + "Supervisor is running but its control API is unavailable; " + "cannot safely capture the service configuration." + ) from exc + return ServiceSnapshot( + config=ServiceConfig(no_browser=True, skip_frontend_build=True), + daemon_pid=None, + was_running=False, + ) + + config = service_config_from_status_payload( + status.raw, + ) + backend_state = status.backend.state.lower() + was_running = bool(status.backend.pid) or backend_state in { + "healthy", + "starting", + "restarting", + "paused", + "static", + } + return ServiceSnapshot( + config=config, + daemon_pid=status.daemon.pid, + was_running=was_running, + ) + + def _spawn_detached_process( command: list[str], *, @@ -2027,491 +1886,19 @@ def _spawn_detached_process( handle.close() -def _write_upgrade_page(version: str) -> Path: - page_dir = _upgrade_page_dir() - page_dir.mkdir(parents=True, exist_ok=True) - (page_dir / "index.html").write_text(_upgrade_page_html(version), encoding="utf-8") - return page_dir - - -def _upgrade_page_probe_urls(frontend_host: str, frontend_port: int) -> list[str]: - from flocks.cli import service_manager - - if frontend_host == "::": - return [ - f"http://[::1]:{frontend_port}", - f"http://127.0.0.1:{frontend_port}", - ] - return [f"http://{service_manager.access_host(frontend_host)}:{frontend_port}"] - - -def _wait_for_upgrade_page(config) -> None: - page_urls = _upgrade_page_probe_urls(config.frontend_host, config.frontend_port) - with httpx.Client(timeout=1.5) as client: - for _ in range(40): - for page_url in page_urls: - try: - response = client.get(page_url) - if response.status_code < 500: - return - except Exception: - pass - time.sleep(0.25) - raise RuntimeError("Upgrade page server failed to start in time") - - -def _start_upgrade_page_server(config, version: str) -> dict[str, Any]: - from flocks.cli import service_manager - - page_dir = _write_upgrade_page(version) - page_dir_resolved = page_dir.resolve() - process = _spawn_detached_process( - service_manager.resolve_python_subprocess_command(_get_repo_root()) - + [ - "-m", - "http.server", - str(config.frontend_port), - "--bind", - config.frontend_host, - "--directory", - str(page_dir_resolved), - ], - cwd=page_dir_resolved, - log_path=_upgrade_page_log_path(), - ) - _upgrade_server_pid_path().write_text(str(process.pid), encoding="utf-8") - _wait_for_upgrade_page(config) - return { - "page_dir": str(page_dir_resolved), - "page_log": str(_upgrade_page_log_path().resolve()), - "upgrade_server_pid": process.pid, - } - - -def _stop_upgrade_page_server(*, frontend_port: int | None = None) -> None: - pid_path = _upgrade_server_pid_path() - if pid_path.exists(): - try: - pid = int(pid_path.read_text(encoding="utf-8").strip()) - except (OSError, ValueError): - pid = None - pid_path.unlink(missing_ok=True) - - if pid is not None: - try: - if sys.platform == "win32": - subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False, capture_output=True) - else: - os.kill(pid, signal.SIGTERM) - except OSError: - pass - - for _ in range(40): - try: - os.kill(pid, 0) - except OSError: - break - time.sleep(0.1) - else: - if sys.platform != "win32": - try: - os.kill(pid, signal.SIGKILL) - except OSError: - pass - time.sleep(0.1) - - pid_path.unlink(missing_ok=True) - - if frontend_port is None: - return - - from flocks.cli import service_manager - - remaining = [ - pid - for pid in service_manager.port_owner_pids(frontend_port) - if _looks_like_upgrade_page_process(pid) - ] - if remaining: - log.info( - "updater.upgrade_page.port_fallback_kill", - { - "port": frontend_port, - "pids": remaining, - }, - ) - for rpid in remaining: - try: - if sys.platform == "win32": - subprocess.run(["taskkill", "/PID", str(rpid), "/T", "/F"], check=False, capture_output=True) - else: - os.kill(rpid, signal.SIGKILL) - except OSError: - pass - - if sys.platform == "win32": - wait_attempts = 40 - wait_interval = 0.25 - for _ in range(wait_attempts): - if not any(_looks_like_upgrade_page_process(pid) for pid in service_manager.port_owner_pids(frontend_port)): - return - time.sleep(wait_interval) - return - - if remaining: - time.sleep(0.3) - - -def _looks_like_upgrade_page_process(pid: int) -> bool: - """Return True only for the temporary upgrade-page http.server process.""" - try: - from flocks.cli import service_manager - - command_line = service_manager._process_command_line(pid).lower() - except Exception: - return False - if not command_line: - return False - page_dir = str(_upgrade_page_dir()).lower() - return "http.server" in command_line and "upgrade-page" in command_line and page_dir in command_line - - -def _prepare_upgrade_handover(version: str) -> dict[str, Any]: - from flocks.cli import service_manager - from flocks.cli.service_control import request_prepare_upgrade - - config = _current_service_config() - payload: dict[str, Any] = { - "version": version, - "backend_host": config.backend_host, - "backend_port": config.backend_port, - "frontend_host": config.frontend_host, - "frontend_port": config.frontend_port, - "skip_frontend_build": True, - "phase": _UPGRADE_PHASE_HANDOVER_PREPARING, - } - _persist_upgrade_state(payload, last_error=None) - - console = _NullConsole() - paths = service_manager.runtime_paths() - request_prepare_upgrade(paths=paths, timeout=30.0) - - try: - payload.update(_start_upgrade_page_server(config, version)) - _persist_upgrade_state( - payload, - phase=_UPGRADE_PHASE_TEMP_PAGE_ACTIVE, - last_error=None, - ) - except Exception: - _stop_upgrade_page_server(frontend_port=config.frontend_port) - _clear_upgrade_state() - try: - _start_frontend_with_fallback(config, console, allow_build_fallback=False) - except Exception as restart_error: - log.error("updater.frontend.restore_failed", {"error": str(restart_error)}) - raise - - return payload - - def _spawn_restart_handoff(command: list[str], *, cwd: Path) -> subprocess.Popen: - creationflags = 0 - kwargs: dict[str, object] = {} - if sys.platform == "win32": - creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0) - startupinfo_cls = getattr(subprocess, "STARTUPINFO", None) - if startupinfo_cls is not None: - startupinfo = startupinfo_cls() - startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 0) - startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", 0) - kwargs["startupinfo"] = startupinfo - else: - kwargs["start_new_session"] = True - return subprocess.Popen(command, cwd=cwd, close_fds=True, creationflags=creationflags, **kwargs) - - -def _service_config_from_payload( - payload: dict[str, Any], - *, - skip_frontend_build: bool | None = None, -): - from flocks.cli.service_config import ServiceConfig, service_config_from_payload - - resolved_skip_frontend_build = ( - bool(payload.get("skip_frontend_build", True)) if skip_frontend_build is None else skip_frontend_build - ) - migrated_payload = dict(payload) - backend_port = migrated_payload.get("backend_port") - frontend_port = migrated_payload.get("frontend_port") - if isinstance(backend_port, int) and isinstance(frontend_port, int) and backend_port != frontend_port: - migrated_payload["legacy_backend_host"] = migrated_payload.get("backend_host") - migrated_payload["legacy_backend_port"] = backend_port - migrated_payload["backend_host"] = migrated_payload.get("frontend_host") or migrated_payload.get("backend_host") - migrated_payload["backend_port"] = frontend_port - migrated_payload["server_port_migration_hint"] = True - return service_config_from_payload( - migrated_payload, - default=ServiceConfig(), - no_browser=True, - skip_frontend_build=resolved_skip_frontend_build, + return _spawn_detached_process( + command, + cwd=cwd, + log_path=_flocks_root() / "logs" / "backend.log", ) def _handoff_service_config(): - payload = _read_upgrade_state() - if payload is not None: - return _service_config_from_payload(payload, skip_frontend_build=True) + """Read the current config for restart-only handoffs.""" return _current_service_config() -def _read_upgrade_server_pid() -> tuple[int | None, bool]: - pid_path = _upgrade_server_pid_path() - if not pid_path.exists(): - return None, False - try: - return int(pid_path.read_text(encoding="utf-8").strip()), True - except (OSError, ValueError): - pid_path.unlink(missing_ok=True) - return None, True - - -def _payload_frontend_port(payload: dict[str, Any] | None) -> int | None: - if not payload: - return None - value = payload.get("frontend_port") - if isinstance(value, bool): - return None - if isinstance(value, int): - return value if value > 0 else None - if isinstance(value, str) and value.isdigit(): - parsed = int(value) - return parsed if parsed > 0 else None - return None - - -def read_upgrade_runtime_state(frontend_port: int | None = None) -> dict[str, Any]: - payload = _read_upgrade_state() - upgrade_pid, pid_file_present = _read_upgrade_server_pid() - resolved_port = _payload_frontend_port(payload) or frontend_port - frontend_host = str(payload.get("frontend_host")) if payload and payload.get("frontend_host") else None - - listener_pids: list[int] = [] - if resolved_port is not None: - try: - from flocks.cli import service_manager - - listener_pids = service_manager.port_owner_pids(resolved_port) - except Exception: - listener_pids = [] - - listener_matches_pid = upgrade_pid is not None and upgrade_pid in listener_pids - return { - "payload": payload, - "payload_present": payload is not None, - "pid_file_present": pid_file_present, - "upgrade_pid": upgrade_pid, - "frontend_host": frontend_host, - "frontend_port": resolved_port, - "listener_pids": listener_pids, - "listener_matches_pid": listener_matches_pid, - "page_active": listener_matches_pid, - "has_artifacts": payload is not None or pid_file_present, - } - - -def _webui_runtime_ready(state: str) -> bool: - return state in {"healthy", "static"} - - -def _start_frontend_with_fallback(config, console, *, allow_build_fallback: bool) -> None: - from flocks.cli.service_config import with_frontend_build - from flocks.cli.service_control import request_restart_webui, request_resume_upgrade - - try: - status = request_resume_upgrade( - config, - paths=None, - timeout=180.0, - ) - if not _webui_runtime_ready(status.webui.state): - raise RuntimeError(status.webui.last_error or "WebUI restart did not become healthy") - return - except Exception: - if not allow_build_fallback or not config.skip_frontend_build: - raise - - rebuilt_config = with_frontend_build(config, skip_frontend_build=False) - result = request_restart_webui(rebuilt_config, force_frontend_build=True, paths=None, timeout=180.0) - if not _webui_runtime_ready(result.webui.state): - raise RuntimeError(result.webui.last_error or "WebUI restart did not become healthy") - - -def cleanup_orphan_upgrade_state(*, frontend_port: int | None = None) -> bool: - state = read_upgrade_runtime_state(frontend_port=frontend_port) - if not state["has_artifacts"]: - return False - - resolved_port = state["frontend_port"] - if resolved_port is None: - _stop_upgrade_page_server() - else: - _stop_upgrade_page_server(frontend_port=resolved_port) - - _clear_upgrade_state() - _upgrade_server_pid_path().unlink(missing_ok=True) - shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) - return True - - -def resolve_upgrade_runtime_state( - *, - attempt_recover: bool = True, - frontend_port: int | None = None, -) -> dict[str, Any]: - state = read_upgrade_runtime_state(frontend_port=frontend_port) - if not state["has_artifacts"]: - return { - **state, - "action": "noop", - "error": None, - } - - resolved_port = state["frontend_port"] - if attempt_recover and state["payload_present"]: - try: - recover_upgrade_state() - return { - **state, - "action": "recovered", - "error": None, - } - except Exception as exc: - cleanup_orphan_upgrade_state(frontend_port=resolved_port) - return { - **state, - "action": "cleanup_after_failed_recover", - "error": str(exc), - } - - cleanup_orphan_upgrade_state(frontend_port=resolved_port) - return { - **state, - "action": "cleaned", - "error": None, - } - - -def _restore_backup_if_possible( - backup_path: Path, - install_root: Path, - previous_version: str, -) -> None: - """Restore install tree from backup without touching upgrade-page state.""" - try: - _restore_backup_archive(backup_path, install_root) - _write_version_marker(previous_version) - log.info("updater.backup.restored", {"backup": str(backup_path)}) - except Exception as exc: - log.error( - "updater.backup.restore_failed", - { - "backup": str(backup_path), - "error": str(exc), - }, - ) - - -def _restore_backup_archive(backup_path: Path, install_root: Path) -> None: - restore_dir = Path(tempfile.mkdtemp(prefix="flocks-rollback-")) - try: - content_root = _extract_archive(backup_path, restore_dir) - _replace_install_dir(content_root, install_root) - finally: - shutil.rmtree(restore_dir, ignore_errors=True) - - -def _rollback_failed_update( - backup_path: Path | None, - install_root: Path, - previous_version: str, -) -> None: - payload = _read_upgrade_state() - if not payload: - return - - _persist_upgrade_state( - payload, - phase=_UPGRADE_PHASE_ROLLBACK_IN_PROGRESS, - last_error=None, - ) - restored_backup = False - restore_error: str | None = None - if backup_path is not None: - try: - _restore_backup_archive(backup_path, install_root) - _write_version_marker(previous_version.lstrip("v")) - restored_backup = True - except Exception as exc: - restore_error = f"Failed to restore backup: {exc}" - log.error("updater.backup.restore_failed", {"error": str(exc), "backup": str(backup_path)}) - - console = _NullConsole() - config = _service_config_from_payload(payload, skip_frontend_build=True) - _stop_upgrade_page_server(frontend_port=config.frontend_port) - try: - _start_frontend_with_fallback( - config, - console, - allow_build_fallback=restored_backup, - ) - except Exception as exc: - log.error( - "updater.frontend.rollback_failed", - {"error": str(exc), "restored_backup": restored_backup, "restore_error": restore_error}, - ) - finally: - _clear_upgrade_state() - shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) - - -def recover_upgrade_state() -> None: - payload = _read_upgrade_state() - if not payload: - return - - console = _NullConsole() - config = _service_config_from_payload(payload) - - _stop_upgrade_page_server(frontend_port=config.frontend_port) - try: - _start_frontend_with_fallback(config, console, allow_build_fallback=True) - except Exception as exc: - log.error("updater.frontend.resume_failed", {"error": str(exc)}) - raise - finally: - _clear_upgrade_state() - shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) - - -def rollback_upgrade_handover() -> None: - payload = _read_upgrade_state() - if not payload: - return - - console = _NullConsole() - config = _service_config_from_payload(payload, skip_frontend_build=True) - - _stop_upgrade_page_server(frontend_port=config.frontend_port) - try: - _start_frontend_with_fallback(config, console, allow_build_fallback=False) - except Exception as exc: - log.error("updater.frontend.rollback_failed", {"error": str(exc)}) - finally: - _clear_upgrade_state() - shutil.rmtree(_upgrade_page_dir(), ignore_errors=True) - - def cleanup_replaced_files(root: Path | None = None) -> None: install_root = root or _get_repo_root() leftovers = sorted( @@ -2607,23 +1994,26 @@ def _safe_remove(target: Path) -> None: def _replace_install_dir( source_dir: Path, install_root: Path, + *, + _is_root: bool = True, ) -> None: """ - Overwrite *install_root* with the contents of *source_dir*, while - preserving user/runtime directories listed in ``_PRESERVE_NAMES`` - at **any** directory depth (not only the top level). + Overwrite *install_root* with the contents of *source_dir*. Build caches + are preserved at every depth; root-level configuration and runtime data + are preserved only at the installation root. """ install_root.mkdir(parents=True, exist_ok=True) source_names = {item.name for item in source_dir.iterdir()} + preserve_names = _PRESERVE_NAMES | (_ROOT_RUNTIME_NAMES if _is_root else set()) for item in source_dir.iterdir(): - if item.name in _PRESERVE_NAMES: + if item.name in preserve_names: continue target = install_root / item.name if item.is_dir() and not item.is_symlink(): if target.exists() or target.is_symlink(): if target.is_dir() and not target.is_symlink(): - _replace_install_dir(item, target) + _replace_install_dir(item, target, _is_root=False) continue _safe_remove(target) shutil.copytree(item, target, symlinks=True) @@ -2633,7 +2023,7 @@ def _replace_install_dir( shutil.copy2(item, target) for child in install_root.iterdir(): - if child.name not in source_names and child.name not in _PRESERVE_NAMES: + if child.name not in source_names and child.name not in preserve_names: _safe_remove(child) @@ -3108,11 +2498,7 @@ async def perform_pro_bundle_downgrade( current_version=current_version, ) log.info("updater.downgrade.restart_handoff_spawn", {"argv": handoff_argv}) - subprocess.Popen( - handoff_argv, - cwd=install_root, - close_fds=True, - ) + _spawn_restart_handoff(handoff_argv, cwd=install_root) os._exit(0) except Exception as exc: log.error("updater.downgrade.restart_failed", {"error": str(exc)}) @@ -3137,6 +2523,7 @@ async def perform_update( locale: str | None = None, region: str | None = None, force_console_manifest: bool = False, + wait_for_handoff: bool = False, ) -> AsyncGenerator[UpdateProgress, None]: """ Async generator that executes the upgrade steps and yields progress events. @@ -3268,27 +2655,8 @@ async def _queue_download_progress(progress: UpdateProgress) -> None: return # ------------------------------------------------------------------ # - # Step 2 – backup current version + # Step 2 – validate and extract the staged source # ------------------------------------------------------------------ # - yield UpdateProgress(stage="backing_up", message="Backing up current version...") - - backup_path = await asyncio.to_thread( - _backup_current_version, - install_root, - current_version, - ucfg.backup_retain_count, - ) - if backup_path: - yield UpdateProgress( - stage="backing_up", - message=f"Backup complete: {backup_path.name}", - ) - else: - yield UpdateProgress( - stage="backing_up", - message="Backup skipped (non-fatal, continuing upgrade)", - ) - extract_dir = tmp_dir / "extracted" extract_dir.mkdir() try: @@ -3309,8 +2677,6 @@ async def _queue_download_progress(progress: UpdateProgress) -> None: except Exception as exc: shutil.rmtree(tmp_dir, ignore_errors=True) msg = f"Failed to extract files: {exc}" - if backup_path: - msg += f"\nRestore from backup: {backup_path}" _record_update_journal(f"ERROR {msg}") yield UpdateProgress(stage="error", message=msg, success=False) return @@ -3328,66 +2694,9 @@ async def _queue_download_progress(progress: UpdateProgress) -> None: else: effective_update_version = latest_tag - # ------------------------------------------------------------------ # - # Step 3 – determine whether frontend handover is needed - # ------------------------------------------------------------------ # - staged_webui_dir = content_root / "webui" - needs_handover = ( - not skip_core_replace - and staged_webui_dir.is_dir() - and (staged_webui_dir / "package.json").exists() - ) - - # ------------------------------------------------------------------ # - # Step 4 – replace install tree - # (Frontend proxy is still alive so the SSE stream keeps flowing.) - # ------------------------------------------------------------------ # - yield UpdateProgress( - stage="applying", - message=( - f"Keeping local Flocks {_version_label(current_version)} and installing the Pro component..." - if skip_core_replace - else f"Applying v{latest_tag}..." - ), - ) - - async def _restore_after_apply_failure() -> None: - if backup_path is None: - return - await asyncio.to_thread( - _restore_backup_if_possible, - backup_path, - install_root, - current_version, - ) - - try: - if not skip_core_replace: - await asyncio.to_thread( - _replace_install_dir, - content_root, - install_root, - ) - except Exception as exc: - final_replace_error: Exception | None = exc - if final_replace_error is not None: - shutil.rmtree(tmp_dir, ignore_errors=True) - await _restore_after_apply_failure() - msg = f"Failed to replace files: {final_replace_error}" - if backup_path: - msg += f"\nRestore from backup: {backup_path}" - yield UpdateProgress(stage="error", message=msg, success=False) - return - - # ------------------------------------------------------------------ # - # Step 5 – prepare dependency sync - # ------------------------------------------------------------------ # - yield UpdateProgress(stage="syncing", message="Preparing dependency sync...") - uv_path = _find_executable("uv") if not uv_path: shutil.rmtree(tmp_dir, ignore_errors=True) - await _restore_after_apply_failure() hint = ( "Dependency sync failed: uv is required but was not found. " "Please install uv (https://docs.astral.sh/uv/) and ensure it " @@ -3409,30 +2718,32 @@ async def _restore_after_apply_failure() -> None: ) # ------------------------------------------------------------------ # - # Step 6 – run post-apply tasks or restart via handoff - # - # With restart=True, dependency sync, Pro install, frontend build, marker - # writes, and CLI refresh happen in restart_handoff after the old backend - # exits. This avoids mutating the active Python environment while the old - # service is still running. + # Step 3 – pure Pro installs may stay in process; source changes are backed + # up here and then always use the detached handoff. # ------------------------------------------------------------------ # - if not restart: - task_error = await run_handoff_upgrade_tasks( - install_root=install_root, - uv_path=uv_path, - version=effective_update_version, - uv_default_index=profile.uv_default_index, - npm_registry=profile.npm_registry, - pro_wheel_path=pro_wheel_path, - pro_bundle_manifest_path=pro_bundle_manifest_path, - bundle_sha256=bundle_sha256, - sync_timeout=sync_timeout, + if skip_core_replace: + yield UpdateProgress( + stage="applying", + message=f"Keeping local Flocks {_version_label(current_version)} and installing the Pro component...", ) - if task_error is not None: + + if skip_core_replace and not restart: + try: + await install_or_repair_source( + install_root=install_root, + uv_path=uv_path, + version=effective_update_version, + uv_default_index=profile.uv_default_index, + npm_registry=profile.npm_registry, + pro_wheel_path=pro_wheel_path, + pro_bundle_manifest_path=pro_bundle_manifest_path, + bundle_sha256=bundle_sha256, + sync_timeout=sync_timeout, + ) + except RuntimeError as exc: shutil.rmtree(tmp_dir, ignore_errors=True) - await _restore_after_apply_failure() - _record_update_journal(f"ERROR {task_error}") - yield UpdateProgress(stage="error", message=task_error, success=False) + _record_update_journal(f"ERROR {exc}") + yield UpdateProgress(stage="error", message=str(exc), success=False) return shutil.rmtree(tmp_dir, ignore_errors=True) @@ -3444,36 +2755,40 @@ async def _restore_after_apply_failure() -> None: ) return - yield UpdateProgress(stage="restarting", message="Restarting service...") - - log.info( - "updater.restart", - { - "tag": latest_tag, - "effective_version": effective_update_version, - "sources": profile.sources, - "repo": ucfg.repo, - "region": profile.region, - }, - ) - await asyncio.sleep(0.8) + if not restart: + shutil.rmtree(tmp_dir, ignore_errors=True) + message = "Source upgrades require the detached handoff; restart=False is only valid for Pro-only changes." + _record_update_journal(f"ERROR {message}") + yield UpdateProgress(stage="error", message=message, success=False) + return - if "--reload" in sys.argv: - log.info("updater.restart.reload_exit3") - sys.exit(3) + backup_path: Path | None = None + if not skip_core_replace: + yield UpdateProgress(stage="backing_up", message="Backing up current version...") + try: + backup_path = await asyncio.to_thread( + _backup_current_version, + install_root, + current_version, + ucfg.backup_retain_count, + ) + except Exception as exc: + log.error("updater.backup.failed", {"error": str(exc)}) + if backup_path is None: + shutil.rmtree(tmp_dir, ignore_errors=True) + message = "Failed to back up the current source; the update was not applied." + _record_update_journal(f"ERROR {message}") + yield UpdateProgress(stage="error", message=message, success=False) + return - try: - restart_argv = _build_restart_argv(install_root) - except Exception as exc: - log.error("updater.restart.build_argv_failed", {"error": str(exc)}) yield UpdateProgress( - stage="error", - message=f"Failed to build restart command: {exc}", - success=False, + stage="applying", + message=f"Staging v{latest_tag} for handoff...", ) - return try: + restart_argv = _build_restart_argv(install_root) + service_snapshot = _capture_service_snapshot() if not skip_core_replace else None handoff_argv = _build_restart_handoff_argv( restart_argv, install_root, @@ -3481,6 +2796,8 @@ async def _restore_after_apply_failure() -> None: sync_timeout=sync_timeout, version=effective_update_version, current_version=current_version, + content_root=content_root if not skip_core_replace else None, + service_snapshot=service_snapshot, backup_path=backup_path, uv_default_index=profile.uv_default_index, npm_registry=profile.npm_registry, @@ -3488,8 +2805,37 @@ async def _restore_after_apply_failure() -> None: pro_bundle_manifest_path=pro_bundle_manifest_path, bundle_sha256=bundle_sha256, cleanup_dir=tmp_dir, - prepare_handover=needs_handover, + wait_for_parent=not wait_for_handoff, + ) + except Exception as exc: + log.error("updater.restart.build_argv_failed", {"error": str(exc)}) + shutil.rmtree(tmp_dir, ignore_errors=True) + yield UpdateProgress( + stage="error", + message=f"Failed to prepare restart handoff: {exc}", + success=False, ) + return + + yield UpdateProgress(stage="restarting", message="Restarting service...") + + log.info( + "updater.restart", + { + "tag": latest_tag, + "effective_version": effective_update_version, + "sources": profile.sources, + "repo": ucfg.repo, + "region": profile.region, + }, + ) + await asyncio.sleep(0.8) + + if "--reload" in sys.argv: + log.info("updater.restart.reload_exit3") + sys.exit(3) + + try: log.info( "updater.restart.handoff_spawn", { @@ -3497,7 +2843,22 @@ async def _restore_after_apply_failure() -> None: "restart_argv": restart_argv, }, ) - _spawn_restart_handoff(handoff_argv, cwd=install_root) + handoff_process = _spawn_restart_handoff(handoff_argv, cwd=install_root) + if wait_for_handoff: + returncode = await asyncio.to_thread(handoff_process.wait) + if returncode != 0: + yield UpdateProgress( + stage="error", + message=f"Upgrade handoff failed with exit code {returncode}. See backend.log for details.", + success=False, + ) + return + yield UpdateProgress( + stage="done", + message=f"Upgraded to {_version_label(effective_update_version)}", + success=True, + ) + return os._exit(0) except Exception as exc: log.error("updater.restart.handoff_spawn_failed", {"error": str(exc)}) @@ -3615,6 +2976,8 @@ def _build_restart_handoff_argv( sync_timeout: int, version: str, current_version: str, + content_root: Path | None = None, + service_snapshot: ServiceSnapshot | None = None, backup_path: Path | None = None, uv_default_index: str | None = None, npm_registry: str | None = None, @@ -3622,56 +2985,91 @@ def _build_restart_handoff_argv( pro_bundle_manifest_path: Path | None = None, bundle_sha256: str | None = None, cleanup_dir: Path | None = None, - prepare_handover: bool = False, + wait_for_parent: bool = True, ) -> list[str]: """Wrap the real restart command in a helper that finishes upgrade work.""" if not restart_argv: raise ValueError("restart command is empty") - config = _handoff_service_config() - managed_restart_argv = [ - restart_argv[0], - "-m", - "flocks.cli.main", - "start", - "--no-browser", - "--skip-webui-build", - "--host", - str(config.backend_host), - "--port", - str(config.backend_port), - ] - if config.legacy_backend_host is not None: - managed_restart_argv.extend(["--server-host", str(config.legacy_backend_host)]) - if config.legacy_backend_port is not None: - managed_restart_argv.extend(["--server-port", str(config.legacy_backend_port)]) + source_upgrade = content_root is not None + if source_upgrade: + if service_snapshot is None: + raise ValueError("source upgrade requires a captured service snapshot") + if backup_path is None: + raise ValueError("source upgrade requires a backup path") + config = service_snapshot.config + else: + config = _handoff_service_config() + + from flocks.cli.service_config import service_config_payload + + if source_upgrade: + # The upgrade helper reconstructs ``flocks start`` from the serialized + # snapshot after the old source has been replaced. + managed_restart_argv = [restart_argv[0]] + else: + managed_restart_argv = [ + restart_argv[0], + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + str(config.frontend_host), + "--port", + str(config.frontend_port), + ] + if config.legacy_backend_host is not None: + managed_restart_argv.extend(["--server-host", str(config.legacy_backend_host)]) + if config.legacy_backend_port is not None: + managed_restart_argv.extend(["--server-port", str(config.legacy_backend_port)]) argv = [ restart_argv[0], "-m", "flocks.updater.restart_handoff", - "--parent-pid", - str(os.getpid()), - "--backend-host", - str(config.backend_host), - "--backend-port", - str(config.backend_port), - "--frontend-host", - str(config.frontend_host), - "--frontend-port", - str(config.frontend_port), - "--install-root", - str(install_root), - "--uv-path", - uv_path, - "--sync-timeout", - str(sync_timeout), - "--version", - version, - "--current-version", - current_version, ] - if backup_path is not None: - argv.extend(["--backup-path", str(backup_path)]) + if wait_for_parent: + argv.extend(["--parent-pid", str(os.getpid())]) + argv.extend( + [ + "--backend-host", + str(config.backend_host), + "--backend-port", + str(config.backend_port), + "--frontend-host", + str(config.frontend_host), + "--frontend-port", + str(config.frontend_port), + "--install-root", + str(install_root), + "--uv-path", + uv_path, + "--sync-timeout", + str(sync_timeout), + "--version", + version, + "--current-version", + current_version, + ] + ) + if source_upgrade: + argv.extend( + [ + "--mode", + "upgrade", + "--content-root", + str(content_root), + "--backup-path", + str(backup_path), + "--service-config-json", + json.dumps(service_config_payload(config), ensure_ascii=True, sort_keys=True), + ] + ) + if service_snapshot.was_running: + argv.append("--was-running") + if service_snapshot.daemon_pid is not None: + argv.extend(["--daemon-pid", str(service_snapshot.daemon_pid)]) if uv_default_index: argv.extend(["--uv-default-index", uv_default_index]) if npm_registry: @@ -3684,8 +3082,6 @@ def _build_restart_handoff_argv( argv.extend(["--bundle-sha256", bundle_sha256]) if cleanup_dir is not None: argv.extend(["--cleanup-dir", str(cleanup_dir)]) - if prepare_handover: - argv.append("--prepare-handover") argv.extend(["--", *managed_restart_argv]) return argv diff --git a/tests/cli/test_doctor_command.py b/tests/cli/test_doctor_command.py index dea132de8..b9f21513a 100644 --- a/tests/cli/test_doctor_command.py +++ b/tests/cli/test_doctor_command.py @@ -12,17 +12,16 @@ async def _noop_log_init(**_: object) -> None: return None -def test_doctor_runs_source_installer_from_source_root(monkeypatch, tmp_path) -> None: +def test_doctor_runs_core_installer_from_source_root(monkeypatch, tmp_path) -> None: monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) - calls: list[tuple[list[str], object, bool]] = [] - - def fake_run(command, *, cwd, check, env): - _ = env - calls.append((command, cwd, check)) - - monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + calls: list[object] = [] + monkeypatch.setattr( + doctor_cmd, + "_run_core_install", + lambda source_root, **_kwargs: calls.append(source_root), + ) monkeypatch.setattr( "flocks.cli.service_manager.build_status_lines", lambda: [ @@ -35,16 +34,30 @@ def fake_run(command, *, cwd, check, env): assert result.exit_code == 0, result.stdout assert "Flocks source directory:" in result.stdout - assert "scripts/install.sh" in result.stdout + assert "Repairing Flocks core installation" in result.stdout assert "安装正常" in result.stdout assert "运行状态正常" in result.stdout assert len(calls) == 1 - command, cwd, check = calls[0] - assert command[0] == "bash" - assert command[1].endswith("scripts/install.sh") - assert cwd == doctor_cmd._find_source_root() - assert check is True + assert calls == [doctor_cmd._find_source_root()] + + +def test_doctor_uses_shared_core_install_entry(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) + monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) + calls: list[object] = [] + + monkeypatch.setattr( + doctor_cmd, + "_run_core_install", + lambda source_root, **_kwargs: calls.append(source_root), + ) + monkeypatch.setattr(doctor_cmd, "_print_service_diagnosis", lambda: None) + + result = runner.invoke(cli_main.app, ["doctor"]) + + assert result.exit_code == 0, result.stdout + assert calls == [doctor_cmd._find_source_root()] def test_doctor_uses_cn_environment_for_zh_install_profile(monkeypatch, tmp_path) -> None: @@ -54,14 +67,11 @@ def test_doctor_uses_cn_environment_for_zh_install_profile(monkeypatch, tmp_path monkeypatch.setattr(cli_main.Log, "init", _noop_log_init) captured: dict[str, object] = {} - - def fake_run(command, *, cwd, check, env): - captured["command"] = command - captured["cwd"] = cwd - captured["check"] = check - captured["env"] = env - - monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + monkeypatch.setattr( + doctor_cmd, + "_run_core_install", + lambda source_root, *, env: captured.update(source_root=source_root, env=env), + ) monkeypatch.setattr("flocks.cli.service_manager.build_status_lines", lambda: ["[flocks] 后端未运行", "[flocks] WebUI 未运行"]) result = runner.invoke(cli_main.app, ["doctor"]) @@ -99,7 +109,7 @@ def fail_run(*_args, **_kwargs): result = runner.invoke(cli_main.app, ["doctor"]) assert result.exit_code == 0, result.stdout - assert "scripts/install.ps1" in result.stdout + assert "Repairing Flocks core installation" in result.stdout assert "installer will continue in this console" in result.stdout assert captured["cwd"] == doctor_cmd._find_source_root() assert captured["close_fds"] is True @@ -121,16 +131,14 @@ def test_doctor_windows_handoff_child_runs_installer_synchronously(monkeypatch, captured: dict[str, object] = {} - def fake_run(command, *, cwd, check, env): - captured["command"] = command - captured["cwd"] = cwd - captured["check"] = check - captured["env"] = env - def fail_popen(*_args, **_kwargs): raise AssertionError("handoff child should run the installer directly") - monkeypatch.setattr(doctor_cmd.subprocess, "run", fake_run) + monkeypatch.setattr( + doctor_cmd, + "_run_core_install", + lambda source_root, *, env: captured.update(source_root=source_root, env=env), + ) monkeypatch.setattr(doctor_cmd.subprocess, "Popen", fail_popen) monkeypatch.setattr( "flocks.cli.service_manager.build_status_lines", @@ -143,11 +151,7 @@ def fail_popen(*_args, **_kwargs): result = runner.invoke(cli_main.app, ["doctor"]) assert result.exit_code == 0, result.stdout - command = captured["command"] - assert isinstance(command, list) - assert command[-1].endswith("scripts/install.ps1") - assert captured["cwd"] == doctor_cmd._find_source_root() - assert captured["check"] is True + assert captured["source_root"] == doctor_cmd._find_source_root() assert "安装正常" in result.stdout assert "运行状态正常" in result.stdout @@ -175,18 +179,3 @@ def test_service_status_is_healthy_accepts_legacy_backend_and_webui() -> None: ] ) assert not doctor_cmd._service_status_is_healthy(["[flocks] 后端运行中: PID=111", "[flocks] WebUI 未运行"]) - - -def test_doctor_builds_windows_install_command(monkeypatch) -> None: - monkeypatch.setattr(doctor_cmd.shutil, "which", lambda name: None) - - command = doctor_cmd._build_source_install_command(doctor_cmd._find_source_root() / "scripts" / "install.ps1") - - assert command == [ - "powershell", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(doctor_cmd._find_source_root() / "scripts" / "install.ps1"), - ] diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index d75c1cac0..7bb3b3893 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -15,7 +15,6 @@ from tests.helpers.service_supervisor import ( SleeperProcessAdapter, make_short_runtime_root, - wait_for_process_exit, ) @@ -30,7 +29,6 @@ def print(self, *args, **kwargs) -> None: @pytest.fixture(autouse=True) def _skip_backend_webui_dist_check(monkeypatch) -> None: monkeypatch.setattr(service_manager, "_ensure_webui_dist", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", lambda *_args, **_kwargs: {"action": "noop", "error": None}) def _make_runtime_paths(tmp_path: Path) -> service_manager.RuntimePaths: @@ -973,27 +971,22 @@ def test_start_all_starts_supervisor_when_control_api_is_down(monkeypatch) -> No assert call_order == ["ensure_runtime_dirs", "_start_all_without_stop"] -def test_start_all_resolves_upgrade_runtime_before_supervisor_status(monkeypatch) -> None: +def test_start_all_checks_supervisor_before_starting(monkeypatch) -> None: events: list[str] = [] console = DummyConsole() paths = _make_runtime_paths(Path("/tmp/flocks-test")) - def resolve_upgrade_runtime(_console, *, frontend_port: int, attempt_recover: bool) -> dict[str, object]: - events.append(f"upgrade:{frontend_port}:{attempt_recover}") - return {"action": "cleaned", "error": None} - def supervisor_running(_paths) -> bool: events.append("supervisor") return False monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - monkeypatch.setattr(service_manager, "_resolve_upgrade_runtime", resolve_upgrade_runtime) monkeypatch.setattr(service_manager, "supervisor_is_running", supervisor_running) monkeypatch.setattr(service_manager, "_start_all_without_stop", lambda _config, _console: events.append("start")) service_manager.start_all(service_manager.ServiceConfig(frontend_port=5173), console) - assert events == ["upgrade:5173:False", "supervisor", "start"] + assert events == ["supervisor", "start"] def test_start_all_does_not_duplicate_running_supervisor(monkeypatch) -> None: @@ -1613,47 +1606,6 @@ def test_supervisor_rejects_static_webui_stop_control_api(monkeypatch, tmp_path: assert exc_info.value.response.status_code == 409 -@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") -def test_supervisor_upgrade_prepare_control_api_pauses_real_child_restart(monkeypatch, tmp_path: Path) -> None: - del tmp_path - short_root = make_short_runtime_root("flocks-supervisor-") - paths = _make_runtime_paths(short_root) - paths.run_dir.mkdir(parents=True) - paths.log_dir.mkdir(parents=True) - monkeypatch.setattr(service_manager, "ensure_runtime_dirs", lambda: paths) - backend_adapter = SleeperProcessAdapter() - daemon = service_supervisor.SupervisorDaemon( - service_manager.ServiceConfig(backend_port=9995, frontend_port=9996), - backend_adapter=backend_adapter, - ) - daemon._start_control_server() - - try: - daemon.restart_all(reason="test startup") - backend_process = daemon.backend.process - assert backend_process is not None - assert daemon.webui.process is None - assert daemon.webui.state == "static" - - status = service_control.request_prepare_upgrade(paths=paths) - - wait_for_process_exit(backend_process) - assert status.backend.paused is True - assert status.webui.paused is True - assert daemon.backend.process is None - assert backend_process.pid in backend_adapter.stopped - - daemon.tick() - - assert len(backend_adapter.started) == 1 - assert daemon.backend.process is None - assert daemon.status_payload()["backend"]["paused"] is True - finally: - daemon.shutdown_children() - daemon._stop_control_server() - shutil.rmtree(short_root, ignore_errors=True) - - def test_build_webui_dist_tolerates_windows_node_assertion_after_build(monkeypatch, tmp_path: Path) -> None: webui_dir = tmp_path / "webui" webui_dist = webui_dir / "dist" diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py index 76b5e971e..5dd268b15 100644 --- a/tests/cli/test_update_command.py +++ b/tests/cli/test_update_command.py @@ -7,7 +7,6 @@ import flocks.cli.commands.update as update_cmd import flocks.cli.main as cli_main -import flocks.cli.service_manager as service_manager import flocks.updater as updater_pkg from flocks.updater.models import UpdateProgress, VersionInfo @@ -18,10 +17,10 @@ async def _noop_log_init(**_: object) -> None: return None -def test_updater_package_exports_build_updated_frontend() -> None: +def test_updater_package_exports_shared_installer() -> None: from flocks.updater import updater as updater_module - assert updater_pkg.build_updated_frontend is updater_module.build_updated_frontend + assert updater_pkg.install_or_repair_source is updater_module.install_or_repair_source def test_update_cli_accepts_force_option(monkeypatch, tmp_path) -> None: @@ -92,8 +91,6 @@ def test_update_prompts_for_cn_mirror_before_upgrade_confirmation(monkeypatch, t check_regions: list[str | None] = [] confirm_prompts: list[str] = [] captured: dict[str, object] = {} - stop_calls: list[str] = [] - build_calls: list[str | None] = [] answers = iter([True, True]) async def fake_check_update(*, locale: str | None = None, region: str | None = None) -> VersionInfo: @@ -123,6 +120,7 @@ async def fake_perform_update( restart: bool = True, locale: str | None = None, region: str | None = None, + wait_for_handoff: bool = False, ): captured["latest_tag"] = latest_tag captured["zipball_url"] = zipball_url @@ -131,6 +129,7 @@ async def fake_perform_update( captured["bundle_format"] = bundle_format captured["perform_region"] = region captured["restart"] = restart + captured["wait_for_handoff"] = wait_for_handoff async for step in _fake_progress(): yield step @@ -138,18 +137,10 @@ def fake_confirm(prompt: str, default: bool = False) -> bool: confirm_prompts.append(prompt) return next(answers) - def fake_stop_all(console) -> None: - stop_calls.append("stop") - - async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None: - build_calls.append(region) - monkeypatch.setattr(updater_pkg, "check_update", fake_check_update) monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update) - monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend) monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source") monkeypatch.setattr(update_cmd.typer, "confirm", fake_confirm) - monkeypatch.setattr(service_manager, "stop_all", fake_stop_all) import asyncio @@ -157,8 +148,6 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str assert check_regions == ["cn"] assert confirm_prompts == ["\n是否使用中国镜像进行升级?", "\n是否立即升级?"] - assert stop_calls == ["stop"] - assert build_calls == ["cn"] assert captured == { "latest_tag": "2026.4.2", "zipball_url": "https://gitee.example.com/flocks.zip", @@ -166,13 +155,17 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str "bundle_sha256": None, "bundle_format": None, "perform_region": "cn", - "restart": False, + "restart": True, + "wait_for_handoff": True, } assert "已切换为中国镜像源" not in output.getvalue() async def _fake_progress(): yield UpdateProgress(stage="fetching", message="fetching") + yield UpdateProgress(stage="backing_up", message="backing up") + yield UpdateProgress(stage="applying", message="applying") + yield UpdateProgress(stage="restarting", message="restarting") yield UpdateProgress(stage="done", message="done", success=True) @@ -197,8 +190,6 @@ async def fake_check_update(*, locale: str | None = None, region: str | None = N ) captured: dict[str, object] = {} - stop_calls: list[str] = [] - build_calls: list[str | None] = [] async def fake_perform_update( latest_tag: str, @@ -210,6 +201,7 @@ async def fake_perform_update( restart: bool = True, locale: str | None = None, region: str | None = None, + wait_for_handoff: bool = False, ): captured["latest_tag"] = latest_tag captured["zipball_url"] = zipball_url @@ -218,20 +210,13 @@ async def fake_perform_update( captured["bundle_format"] = bundle_format captured["perform_region"] = region captured["restart"] = restart + captured["wait_for_handoff"] = wait_for_handoff async for step in _fake_progress(): yield step - def fake_stop_all(console) -> None: - stop_calls.append("stop") - - async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None: - build_calls.append(region) - monkeypatch.setattr(updater_pkg, "check_update", fake_check_update) monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update) - monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend) monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source") - monkeypatch.setattr(service_manager, "stop_all", fake_stop_all) import asyncio @@ -245,15 +230,16 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str "bundle_format": None, "check_region": "cn", "perform_region": "cn", - "restart": False, + "restart": True, + "wait_for_handoff": True, } - assert stop_calls == ["stop"] - assert build_calls == ["cn"] assert "强制重新安装 v2026.4.2" in output.getvalue() + assert "[3/3] 应用新版本... ✓" in output.getvalue() + assert "重启服务" not in output.getvalue() assert "升级完成" in output.getvalue() -def test_update_executes_flocks_stop_before_upgrade(monkeypatch, tmp_path) -> None: +def test_update_delegates_stop_install_build_and_restart_to_handoff(monkeypatch, tmp_path) -> None: output = StringIO() monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) @@ -288,8 +274,9 @@ async def fake_perform_update( restart: bool = True, locale: str | None = None, region: str | None = None, + wait_for_handoff: bool = False, ): - events.append("perform_update") + events.append(f"perform_update:{wait_for_handoff}") async for step in _fake_progress(): yield step @@ -297,29 +284,21 @@ def fake_confirm(prompt: str, default: bool = False) -> bool: confirm_prompts.append(prompt) return next(answers) - def fake_stop_all(console) -> None: - events.append("stop") - - async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None: - events.append("build") - monkeypatch.setattr(updater_pkg, "check_update", fake_check_update) monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update) - monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend) monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source") monkeypatch.setattr(update_cmd.typer, "confirm", fake_confirm) - monkeypatch.setattr(service_manager, "stop_all", fake_stop_all) import asyncio asyncio.run(update_cmd._update(check=False, yes=False, force=False, region=None)) assert confirm_prompts == ["\n是否使用中国镜像进行升级?", "\n是否立即升级?"] - assert events == ["stop", "perform_update", "build"] - assert "已执行 flocks stop" in output.getvalue() + assert events == ["perform_update:True"] + assert "已执行 flocks stop" not in output.getvalue() -def test_update_reports_frontend_build_failure_after_common_upgrade(monkeypatch, tmp_path) -> None: +def test_update_reports_handoff_preparation_failure(monkeypatch, tmp_path) -> None: output = StringIO() monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path)) monkeypatch.delenv("FLOCKS_INSTALL_LANGUAGE", raising=False) @@ -350,21 +329,13 @@ async def fake_perform_update( restart: bool = True, locale: str | None = None, region: str | None = None, + wait_for_handoff: bool = False, ): - async for step in _fake_progress(): - yield step - - def fake_stop_all(console) -> None: - return None - - async def fake_build_updated_frontend(*, locale: str | None = None, region: str | None = None) -> None: - raise RuntimeError("npm run build failed") + yield UpdateProgress(stage="error", message="handoff preparation failed", success=False) monkeypatch.setattr(updater_pkg, "check_update", fake_check_update) monkeypatch.setattr(updater_pkg, "perform_update", fake_perform_update) - monkeypatch.setattr(updater_pkg, "build_updated_frontend", fake_build_updated_frontend) monkeypatch.setattr(updater_pkg, "detect_deploy_mode", lambda: "source") - monkeypatch.setattr(service_manager, "stop_all", fake_stop_all) import asyncio @@ -372,4 +343,4 @@ async def fake_build_updated_frontend(*, locale: str | None = None, region: str asyncio.run(update_cmd._update(check=False, yes=True, force=False, region=None)) assert excinfo.value.exit_code == 1 - assert "前端构建失败" in output.getvalue() + assert "handoff preparation failed" in output.getvalue() diff --git a/tests/server/routes/test_console_upgrade_routes.py b/tests/server/routes/test_console_upgrade_routes.py index d4dc0b55e..b7c0c94df 100644 --- a/tests/server/routes/test_console_upgrade_routes.py +++ b/tests/server/routes/test_console_upgrade_routes.py @@ -1522,7 +1522,7 @@ async def test_auto_activate_reinstalls_when_existing_pro_marker_is_not_target_b async def _fake_perform_pro_bundle_install(*args, **kwargs): assert args == () - assert kwargs["restart"] is False + assert kwargs["restart"] is True marker_state["payload"] = { "release_id": "rel_20260605", "bundle_release_id": "rel_20260605", @@ -1627,7 +1627,7 @@ async def test_auto_activate_installs_pro_bundle_when_core_version_is_latest( async def _fake_perform_pro_bundle_install(*args, **kwargs): nonlocal installed assert args == () - assert kwargs["restart"] is False + assert kwargs["restart"] is True yield UpdateProgress(stage="syncing", message="Installing Flocks Pro component...", success=None) installed = True yield UpdateProgress(stage="done", message="Flocks Pro component installed from v2026.5.9", success=True) diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index de6677698..14e8741f4 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -19,7 +19,7 @@ def warn(self, *_args, **_kwargs) -> None: @pytest.mark.asyncio -async def test_lifespan_cleans_leftovers_before_recovering_upgrade_state( +async def test_lifespan_cleans_replaced_files_without_upgrade_recovery( monkeypatch: pytest.MonkeyPatch, ) -> None: events: list[str] = [] @@ -27,9 +27,6 @@ async def test_lifespan_cleans_leftovers_before_recovering_upgrade_state( def cleanup_replaced_files() -> None: events.append("cleanup_replaced_files") - def recover_upgrade_state() -> None: - events.append("recover_upgrade_state") - async def fake_storage_init() -> None: return None @@ -56,7 +53,6 @@ async def fake_async_noop(*_args, **_kwargs) -> None: "flocks.updater.updater", types.SimpleNamespace( cleanup_replaced_files=cleanup_replaced_files, - recover_upgrade_state=recover_upgrade_state, ), ) monkeypatch.setitem( @@ -149,4 +145,4 @@ async def fake_async_noop(*_args, **_kwargs) -> None: async with app_module.lifespan(SimpleNamespace()): pass - assert events == ["cleanup_replaced_files", "recover_upgrade_state"] + assert events == ["cleanup_replaced_files"] diff --git a/tests/updater/test_restart_handoff.py b/tests/updater/test_restart_handoff.py index e9df3cbb7..b044d3baa 100644 --- a/tests/updater/test_restart_handoff.py +++ b/tests/updater/test_restart_handoff.py @@ -1,4 +1,6 @@ +import json import shutil +import subprocess import sys from pathlib import Path from types import SimpleNamespace @@ -6,11 +8,17 @@ import pytest from flocks.cli import service_manager +from flocks.cli.service_config import service_config_payload from flocks.updater import restart_handoff -from tests.helpers.service_supervisor import make_short_runtime_root, start_supervisor, stop_supervisor, wait_for_supervisor +from tests.helpers.service_supervisor import ( + make_short_runtime_root, + start_supervisor, + stop_supervisor, + wait_for_supervisor, +) -def _handoff_args(tmp_path: Path, restart_argv: list[str], *, prepare_handover: bool = False) -> list[str]: +def _handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]: args = [ "--parent-pid", "1234", @@ -33,11 +41,423 @@ def _handoff_args(tmp_path: Path, restart_argv: list[str], *, prepare_handover: "--current-version", "2026.3.31", ] - if prepare_handover: - args.append("--prepare-handover") return [*args, "--", *restart_argv] +def _simple_upgrade_handoff_args(tmp_path: Path) -> list[str]: + content_root = tmp_path / "staged" + content_root.mkdir() + backup_path = tmp_path / "backup.tar.gz" + backup_path.write_text("backup", encoding="utf-8") + config = service_manager.ServiceConfig( + backend_host="10.0.0.8", + backend_port=5273, + frontend_host="10.0.0.8", + frontend_port=5273, + legacy_backend_host="0.0.0.0", + legacy_backend_port=9000, + no_browser=True, + skip_frontend_build=True, + ) + return [ + "--mode", + "upgrade", + "--parent-pid", + "1234", + "--backend-host", + config.backend_host, + "--backend-port", + str(config.backend_port), + "--frontend-host", + config.frontend_host, + "--frontend-port", + str(config.frontend_port), + "--install-root", + str(tmp_path / "install"), + "--content-root", + str(content_root), + "--backup-path", + str(backup_path), + "--was-running", + "--daemon-pid", + "2468", + "--service-config-json", + json.dumps(service_config_payload(config)), + "--uv-path", + "uv", + "--sync-timeout", + "300", + "--version", + "2026.4.1", + "--current-version", + "2026.3.31", + "--", + "/install/.venv/bin/python", + ] + + +def _v2026_7_1_handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]: + """Build the handoff protocol emitted by the v2026.7.1 updater.""" + args = _handoff_args(tmp_path, restart_argv) + args[args.index("--install-root") : args.index("--install-root")] = [ + "--backend-pid-file", + str(tmp_path / "backend.pid"), + ] + separator_index = args.index("--") + args[separator_index:separator_index] = [ + "--backup-path", + str(tmp_path / "backup.tar.gz"), + ] + return args + + +def _v2026_7_15_handoff_args(tmp_path: Path, restart_argv: list[str]) -> list[str]: + """Build the handoff protocol emitted by the v2026.7.15 updater.""" + args = _handoff_args(tmp_path, restart_argv) + args[args.index("--backend-port") + 1] = "5173" + separator_index = args.index("--") + args[separator_index:separator_index] = [ + "--backup-path", + str(tmp_path / "backup.tar.gz"), + "--prepare-handover", + ] + return args + + +def test_current_version_upgrade_handoff_stops_replaces_installs_and_restarts( + monkeypatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + cleanup_dir = tmp_path / "cleanup" + cleanup_dir.mkdir() + args = _simple_upgrade_handoff_args(tmp_path) + args[args.index("--") : args.index("--")] = ["--cleanup-dir", str(cleanup_dir)] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None) + monkeypatch.setattr( + restart_handoff, + "_wait_for_parent_exit", + lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_stop_services_before_upgrade", + lambda args: events.append(f"stop:{args.daemon_pid}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_apply_new_source", + lambda args: events.append("replace"), + ) + monkeypatch.setattr( + restart_handoff, + "_run_upgrade_tasks", + lambda args: events.append("install") or None, + ) + monkeypatch.setattr( + restart_handoff, + "_start_service_after_upgrade", + lambda args: events.append("start") or (True, "", ""), + ) + monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **_kwargs: None) + + assert restart_handoff.run(args) == 0 + assert events == [ + "wait-parent:1234", + "stop:2468", + "replace", + "install", + "start", + ] + assert not cleanup_dir.exists() + + +def test_current_version_upgrade_handoff_can_run_without_waiting_for_parent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + args = _simple_upgrade_handoff_args(tmp_path) + parent_index = args.index("--parent-pid") + del args[parent_index : parent_index + 2] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None) + monkeypatch.setattr( + restart_handoff, + "_wait_for_parent_exit", + lambda _parent_pid: pytest.fail("parent wait must be skipped"), + ) + monkeypatch.setattr( + restart_handoff, + "_stop_services_before_upgrade", + lambda _args: events.append("stop") or True, + ) + monkeypatch.setattr(restart_handoff, "_apply_new_source", lambda _args: events.append("replace")) + monkeypatch.setattr( + restart_handoff, + "_run_upgrade_tasks", + lambda _args: events.append("install") or None, + ) + monkeypatch.setattr( + restart_handoff, + "_start_service_after_upgrade", + lambda _args: events.append("start") or (True, "", ""), + ) + monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **_kwargs: None) + + assert restart_handoff.run(args) == 0 + assert events == ["stop", "replace", "install", "start"] + + +@pytest.mark.parametrize( + ("host", "port"), + [ + ("127.0.0.1", 5173), + ("0.0.0.0", 5273), + ("10.20.30.40", 9527), + ("::", 6173), + ("2001:db8::20", 7173), + ], +) +def test_upgrade_start_argv_uses_captured_host_port_without_control_api(host: str, port: int) -> None: + config = service_manager.ServiceConfig( + backend_host=host, + backend_port=port, + frontend_host=host, + frontend_port=port, + legacy_backend_host="0.0.0.0", + legacy_backend_port=9000, + no_browser=True, + skip_frontend_build=True, + ) + args = SimpleNamespace( + restart_argv=["/install/.venv/bin/python"], + service_config_json=json.dumps(service_config_payload(config)), + ) + + assert restart_handoff._build_captured_start_argv(args) == [ + "/install/.venv/bin/python", + "-m", + "flocks.cli.main", + "start", + "--host", + host, + "--port", + str(port), + "--no-browser", + "--skip-webui-build", + "--server-host", + "0.0.0.0", + "--server-port", + "9000", + ] + + +@pytest.mark.parametrize( + ("public_host", "public_port"), + [ + ("0.0.0.0", 5173), + ("10.20.30.40", 9527), + ("::", 6173), + ("2001:db8::20", 7173), + ], +) +def test_upgrade_start_argv_preserves_distinct_legacy_public_endpoint( + public_host: str, + public_port: int, +) -> None: + config = service_manager.ServiceConfig( + backend_host="127.0.0.1", + backend_port=8000, + frontend_host=public_host, + frontend_port=public_port, + legacy_backend_host="127.0.0.1", + legacy_backend_port=8000, + no_browser=True, + skip_frontend_build=True, + ) + args = SimpleNamespace( + restart_argv=["/install/.venv/bin/python"], + service_config_json=json.dumps(service_config_payload(config)), + ) + + assert restart_handoff._build_captured_start_argv(args) == [ + "/install/.venv/bin/python", + "-m", + "flocks.cli.main", + "start", + "--host", + public_host, + "--port", + str(public_port), + "--no-browser", + "--skip-webui-build", + "--server-host", + "127.0.0.1", + "--server-port", + "8000", + ] + + +def test_upgrade_wait_ports_exclude_legacy_cleanup_port(tmp_path: Path) -> None: + args = restart_handoff._parse_args(_simple_upgrade_handoff_args(tmp_path)) + + assert restart_handoff._service_ports(args) == (5273,) + + +def test_upgrade_install_failure_keeps_backup_and_temp_without_restart_or_rollback( + monkeypatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + results: list[dict[str, object]] = [] + cleanup_dir = tmp_path / "cleanup" + cleanup_dir.mkdir() + backup_path = tmp_path / "backup.tar.gz" + backup_path.write_text("backup", encoding="utf-8") + args = _simple_upgrade_handoff_args(tmp_path) + args[args.index("--") : args.index("--")] = ["--cleanup-dir", str(cleanup_dir)] + + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda _pid: True) + monkeypatch.setattr(restart_handoff, "_stop_services_before_upgrade", lambda _args: True) + monkeypatch.setattr( + restart_handoff, + "_apply_new_source", + lambda _args: events.append("replace"), + ) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda _args: "npm build failed") + monkeypatch.setattr( + restart_handoff, + "_start_service_after_upgrade", + lambda _args: events.append("start") or (True, "", ""), + ) + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(message)) + monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **kwargs: results.append(kwargs)) + + assert restart_handoff.run(args) == 1 + assert events[0] == "replace" + assert "start" not in events + assert not any("rollback" in event for event in events) + assert cleanup_dir.exists() + assert backup_path.exists() + assert results[-1]["failed_stage"] == "install" + assert results[-1]["backup_path"] == backup_path + + +def test_upgrade_start_failure_records_process_output_and_keeps_temp( + monkeypatch, + tmp_path: Path, +) -> None: + results: list[dict[str, object]] = [] + cleanup_dir = tmp_path / "cleanup" + cleanup_dir.mkdir() + backup_path = tmp_path / "backup.tar.gz" + backup_path.write_text("backup", encoding="utf-8") + args = _simple_upgrade_handoff_args(tmp_path) + args[args.index("--") : args.index("--")] = ["--cleanup-dir", str(cleanup_dir)] + + monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda _pid: True) + monkeypatch.setattr(restart_handoff, "_stop_services_before_upgrade", lambda _args: True) + monkeypatch.setattr(restart_handoff, "_apply_new_source", lambda _args: None) + monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda _args: None) + monkeypatch.setattr(restart_handoff, "_report_pending_pro_bundle_install_receipt", lambda _args: None) + monkeypatch.setattr( + restart_handoff, + "_start_service_after_upgrade", + lambda _args: (False, "start stdout", "start stderr"), + ) + monkeypatch.setattr(restart_handoff, "_write_upgrade_result", lambda **kwargs: results.append(kwargs)) + + assert restart_handoff.run(args) == 1 + assert cleanup_dir.exists() + assert results[-1]["failed_stage"] == "start" + assert results[-1]["stdout"] == "start stdout" + assert results[-1]["stderr"] == "start stderr" + + +def test_upgrade_writes_running_after_parent_exits(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + args = _simple_upgrade_handoff_args(tmp_path) + + monkeypatch.setattr( + restart_handoff, + "_wait_for_parent_exit", + lambda _pid: events.append("parent-exited") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_write_upgrade_result", + lambda **kwargs: events.append(f"write:{kwargs['phase']}"), + ) + monkeypatch.setattr( + restart_handoff, + "_stop_services_before_upgrade", + lambda _args: events.append("stop") or False, + ) + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None) + + assert restart_handoff.run(args) == 1 + assert events[:3] == ["parent-exited", "write:running", "stop"] + + +def test_upgrade_result_write_failure_is_non_fatal(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + args = restart_handoff._parse_args(_simple_upgrade_handoff_args(tmp_path)) + + monkeypatch.setattr( + restart_handoff.updater_module, + "_write_upgrade_result_state", + lambda _payload: (_ for _ in ()).throw(OSError("disk unavailable")), + ) + monkeypatch.setattr( + restart_handoff, + "_record_handoff_log", + lambda message: events.append(message), + ) + + restart_handoff._write_upgrade_result(args=args, phase="running") + + assert events == ["upgrade_result_write_failed phase=running error=disk unavailable"] + + +def test_upgrade_that_was_stopped_does_not_start_service(monkeypatch, tmp_path: Path) -> None: + args = _simple_upgrade_handoff_args(tmp_path) + args.remove("--was-running") + parsed = restart_handoff._parse_args(args) + monkeypatch.setattr( + restart_handoff.subprocess, + "run", + lambda *_args, **_kwargs: pytest.fail("stopped service must remain stopped"), + ) + + assert restart_handoff._start_service_after_upgrade(parsed) == (True, "", "") + + +def test_upgrade_start_decodes_invalid_windows_output_without_crashing(monkeypatch, tmp_path: Path) -> None: + parsed = restart_handoff._parse_args(_simple_upgrade_handoff_args(tmp_path)) + logs: list[str] = [] + + def fake_run(command, **kwargs): + assert kwargs["text"] is False + return subprocess.CompletedProcess( + command, + 1, + stdout=b"start stdout \x80", + stderr=b"start stderr \x81", + ) + + monkeypatch.setattr(restart_handoff.subprocess, "run", fake_run) + monkeypatch.setattr(restart_handoff, "_record_handoff_log", logs.append) + + assert restart_handoff._start_service_after_upgrade(parsed) == ( + False, + "start stdout �", + "start stderr �", + ) + assert logs == ["restart_failed returncode=1 stdout=start stdout � stderr=start stderr �"] + + def test_run_waits_for_parent_and_backend_port_before_spawning( monkeypatch, tmp_path: Path, @@ -75,8 +495,9 @@ def test_run_waits_for_parent_and_backend_port_before_spawning( monkeypatch.setattr( restart_handoff.subprocess, "Popen", - lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") - or SimpleNamespace(pid=4321), + lambda argv, cwd=None, close_fds=False: ( + events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321) + ), ) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) monkeypatch.setattr( @@ -123,8 +544,9 @@ def test_run_keeps_current_start_restart_argv(monkeypatch, tmp_path: Path) -> No monkeypatch.setattr( restart_handoff.subprocess, "Popen", - lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") - or SimpleNamespace(pid=4321), + lambda argv, cwd=None, close_fds=False: ( + events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321) + ), ) code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) @@ -137,7 +559,7 @@ def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Pat events: list[str] = [] restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] args = _handoff_args(tmp_path, restart_argv) - args[args.index("--install-root"):args.index("--install-root")] = [ + args[args.index("--install-root") : args.index("--install-root")] = [ "--backend-pid-file", str(tmp_path / "backend.pid"), ] @@ -146,12 +568,14 @@ def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Pat monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) + monkeypatch.setattr(restart_handoff, "_cleanup_legacy_upgrade_handover", lambda args: True) monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: True) monkeypatch.setattr( restart_handoff.subprocess, "Popen", - lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") - or SimpleNamespace(pid=4321), + lambda argv, cwd=None, close_fds=False: ( + events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321) + ), ) code = restart_handoff.run(args) @@ -160,14 +584,122 @@ def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Pat assert f"spawn:{restart_argv}:{tmp_path}:True" in events -def test_run_prepares_handover_after_parent_exit_without_waiting_for_page_port( +def test_v2026_7_1_upgrade_handoff_runs_tasks_and_restarts(monkeypatch, tmp_path: Path) -> None: + events: list[str] = [] + flocks_root = tmp_path / "flocks-root" + upgrade_state = flocks_root / "run" / "upgrade-state.json" + upgrade_state.parent.mkdir(parents=True) + upgrade_state.write_text( + json.dumps( + { + "backend_host": "127.0.0.1", + "backend_port": 8000, + "frontend_host": "0.0.0.0", + "frontend_port": 8888, + } + ), + encoding="utf-8", + ) + restart_argv = [ + "python.exe", + "-m", + "flocks.cli.main", + "serve", + "--host", + "127.0.0.1", + "--port", + "8000", + ] + expected_restart_argv = [ + "python.exe", + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + "0.0.0.0", + "--port", + "8888", + "--server-host", + "127.0.0.1", + "--server-port", + "8000", + ] + args = _v2026_7_1_handoff_args(tmp_path, restart_argv) + + monkeypatch.setattr(restart_handoff.updater_module, "_flocks_root", lambda: flocks_root) + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None) + monkeypatch.setattr( + restart_handoff, + "_wait_for_parent_exit", + lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_ensure_backend_port_free", + lambda backend_port: events.append(f"free-port:{backend_port}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_run_upgrade_tasks", + lambda _args: events.append("install") or None, + ) + monkeypatch.setattr( + restart_handoff, + "_cleanup_legacy_upgrade_handover", + lambda _args: events.append("cleanup-handover") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_stop_supervisor_before_restart", + lambda: events.append("stop-supervisor") or True, + ) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda argv, cwd=None, close_fds=False: ( + events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321) + ), + ) + + assert restart_handoff.run(args) == 0 + assert events == [ + "wait-parent:1234", + "free-port:8000", + "install", + "cleanup-handover", + "stop-supervisor", + f"spawn:{expected_restart_argv}:{tmp_path}:True", + ] + + +def test_v2026_7_15_upgrade_handoff_stops_before_tasks_and_restarts( monkeypatch, tmp_path: Path, ) -> None: events: list[str] = [] - restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + restart_argv = [ + "python.exe", + "-m", + "flocks.cli.main", + "start", + "--no-browser", + "--skip-webui-build", + "--host", + "0.0.0.0", + "--port", + "5173", + "--server-host", + "127.0.0.1", + "--server-port", + "8000", + ] + args = _v2026_7_15_handoff_args(tmp_path, restart_argv) + args[args.index("--backend-host") + 1] = "0.0.0.0" + args[args.index("--frontend-host") + 1] = "0.0.0.0" - monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None) monkeypatch.setattr( restart_handoff, "_wait_for_parent_exit", @@ -175,8 +707,91 @@ def test_run_prepares_handover_after_parent_exit_without_waiting_for_page_port( ) monkeypatch.setattr( restart_handoff, - "_prepare_upgrade_handover", - lambda args: events.append(f"prepare:{args.version}") or True, + "_ensure_backend_port_free", + lambda _backend_port: pytest.fail("legacy handover must stop the supervisor first"), + ) + monkeypatch.setattr( + restart_handoff, + "_stop_supervisor_before_restart", + lambda **kwargs: events.append(f"stop-supervisor:{kwargs}") or True, + ) + monkeypatch.setattr(restart_handoff, "_legacy_supervisor_pid", lambda _args: 2468) + monkeypatch.setattr( + restart_handoff, + "_run_upgrade_tasks", + lambda _args: events.append("install") or None, + ) + monkeypatch.setattr( + restart_handoff, + "_cleanup_legacy_upgrade_handover", + lambda _args: events.append("cleanup-handover") or True, + ) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda argv, cwd=None, close_fds=False: ( + events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321) + ), + ) + + assert restart_handoff.run(args) == 0 + assert events == [ + "wait-parent:1234", + ( + "stop-supervisor:{'daemon_pid': 2468, 'backend_port': 5173, " + "'service_ports': (5173,), 'force_daemon_stop': True}" + ), + "install", + "cleanup-handover", + f"spawn:{restart_argv}:{tmp_path}:True", + ] + + +def test_cleanup_legacy_upgrade_handover_stops_trusted_page(monkeypatch, tmp_path: Path) -> None: + run_dir = tmp_path / "run" + page_dir = run_dir / "upgrade-page" + page_dir.mkdir(parents=True) + state_path = run_dir / "upgrade-state.json" + state_path.write_text("{}", encoding="utf-8") + pid_path = run_dir / "upgrade_server.pid" + pid_path.write_text("2468", encoding="utf-8") + alive = {2468} + + monkeypatch.setattr(restart_handoff.updater_module, "_flocks_root", lambda: tmp_path) + monkeypatch.setattr( + restart_handoff.service_manager, + "port_owner_pids", + lambda _port: sorted(alive), + ) + monkeypatch.setattr( + restart_handoff.service_manager, + "_process_command_line", + lambda pid: f"python -m http.server --directory {page_dir}" if pid in alive else "", + ) + monkeypatch.setattr( + restart_handoff.service_manager, + "_terminate_orphan_pid", + lambda pid, _label, _console: alive.discard(pid), + ) + + assert restart_handoff._cleanup_legacy_upgrade_handover(SimpleNamespace(frontend_port=5173)) + assert not state_path.exists() + assert not pid_path.exists() + assert not page_dir.exists() + + +def test_restart_only_waits_for_port_after_parent_exit( + monkeypatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) + monkeypatch.setattr( + restart_handoff, + "_wait_for_parent_exit", + lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True, ) monkeypatch.setattr( restart_handoff, @@ -192,16 +807,17 @@ def test_run_prepares_handover_after_parent_exit_without_waiting_for_page_port( monkeypatch.setattr( restart_handoff.subprocess, "Popen", - lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") - or SimpleNamespace(pid=4321), + lambda argv, cwd=None, close_fds=False: ( + events.append(f"spawn:{list(argv)}:{cwd}:{close_fds}") or SimpleNamespace(pid=4321) + ), ) - code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True)) + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) assert code == 0 assert events[1:] == [ "wait-parent:1234", - "prepare:2026.4.1", + "free-port:8000", "tasks", "stop-supervisor", f"spawn:{restart_argv}:{tmp_path}:True", @@ -223,7 +839,8 @@ def test_run_reports_pending_install_receipt_after_pro_bundle_tasks( monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) - monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port, backend_pid_file: True) + monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda **_kwargs: True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: events.append("tasks") or None) monkeypatch.setattr( restart_handoff, @@ -235,12 +852,66 @@ def test_run_reports_pending_install_receipt_after_pro_bundle_tasks( "Popen", lambda argv, cwd=None, close_fds=False: events.append(f"spawn:{list(argv)}") or SimpleNamespace(pid=4321), ) - monkeypatch.setattr(restart_handoff, "_record_backend_runtime_if_direct_serve", lambda *_args, **_kwargs: None) - code = restart_handoff.run(args) assert code == 0 - assert events[1:4] == ["tasks", "receipt", f"spawn:{restart_argv}"] + assert events.index("tasks") < events.index("receipt") + assert any(event.startswith("spawn:") for event in events) + + +def test_pro_bundle_handoff_stops_supervisor_before_port_check_and_install( + monkeypatch, + tmp_path: Path, +) -> None: + events: list[str] = [] + restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] + pro_wheel = tmp_path / "flockspro.whl" + manifest = tmp_path / "manifest.json" + args = _handoff_args(tmp_path, restart_argv) + separator_index = args.index("--") + args[separator_index:separator_index] = [ + "--pro-wheel-path", + str(pro_wheel), + "--pro-bundle-manifest-path", + str(manifest), + ] + + monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None) + monkeypatch.setattr( + restart_handoff, + "_wait_for_parent_exit", + lambda parent_pid: events.append(f"wait-parent:{parent_pid}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_stop_supervisor_before_restart", + lambda **kwargs: events.append(f"stop-supervisor:{kwargs}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_ensure_backend_port_free", + lambda backend_port: events.append(f"free-port:{backend_port}") or True, + ) + monkeypatch.setattr( + restart_handoff, + "_run_upgrade_tasks", + lambda _args: events.append("install") or None, + ) + monkeypatch.setattr(restart_handoff, "_report_pending_pro_bundle_install_receipt", lambda _args: None) + monkeypatch.setattr( + restart_handoff.subprocess, + "Popen", + lambda _argv, **_kwargs: events.append("spawn") or SimpleNamespace(pid=4321), + ) + + assert restart_handoff.run(args) == 0 + assert events == [ + "wait-parent:1234", + "stop-supervisor:{'backend_port': 8000, 'service_ports': (5173,)}", + "free-port:8000", + "install", + "spawn", + ] def test_run_does_not_spawn_when_parent_exit_times_out(monkeypatch, tmp_path: Path) -> None: @@ -258,7 +929,10 @@ def test_run_does_not_spawn_when_parent_exit_times_out(monkeypatch, tmp_path: Pa code = restart_handoff.run(_handoff_args(tmp_path, ["python.exe", "-m", "flocks.cli.main", "start"])) assert code == 1 - assert events == ["log:started parent_pid=1234 backend=127.0.0.1:8000 frontend=127.0.0.1:5173", "log:parent_exit_timeout parent_pid=1234"] + assert events == [ + "log:started parent_pid=1234 backend=127.0.0.1:8000 frontend=127.0.0.1:5173", + "log:parent_exit_timeout parent_pid=1234", + ] def test_run_does_not_spawn_when_upgrade_tasks_fail(monkeypatch, tmp_path: Path) -> None: @@ -269,7 +943,6 @@ def test_run_does_not_spawn_when_upgrade_tasks_fail(monkeypatch, tmp_path: Path) monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: "sync failed") - monkeypatch.setattr(restart_handoff, "_rollback_failed_upgrade", lambda args, error: events.append(f"rollback:{error}")) monkeypatch.setattr( restart_handoff.subprocess, "Popen", @@ -279,11 +952,11 @@ def test_run_does_not_spawn_when_upgrade_tasks_fail(monkeypatch, tmp_path: Path) code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) assert code == 1 - assert "rollback:sync failed" in events + assert "log:upgrade_tasks_failed error=sync failed" in events assert "spawn" not in events -def test_run_rolls_back_and_cleans_up_when_upgrade_tasks_crash(monkeypatch, tmp_path: Path) -> None: +def test_run_logs_and_cleans_up_when_upgrade_tasks_crash(monkeypatch, tmp_path: Path) -> None: events: list[str] = [] cleanup_dir = tmp_path / "cleanup" cleanup_dir.mkdir() @@ -300,7 +973,6 @@ def crash(_args): monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda backend_port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", crash) - monkeypatch.setattr(restart_handoff, "_rollback_failed_upgrade", lambda args, error: events.append(f"rollback:{error}")) monkeypatch.setattr( restart_handoff.subprocess, "Popen", @@ -310,7 +982,7 @@ def crash(_args): code = restart_handoff.run(args) assert code == 1 - assert "rollback:upgrade tasks crashed: boom" in events + assert "log:upgrade_tasks_failed error=upgrade tasks crashed: boom" in events assert not cleanup_dir.exists() assert "spawn" not in events @@ -337,50 +1009,78 @@ def test_run_does_not_spawn_when_supervisor_stop_fails(monkeypatch, tmp_path: Pa assert "spawn" not in events -def test_run_rolls_back_prepared_handover_when_supervisor_stop_fails(monkeypatch, tmp_path: Path) -> None: +def test_restart_only_does_not_rollback_when_supervisor_stop_fails(monkeypatch, tmp_path: Path) -> None: events: list[str] = [] restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) - monkeypatch.setattr(restart_handoff, "_prepare_upgrade_handover", lambda args: events.append("prepare") or True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda _port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: False) - monkeypatch.setattr(restart_handoff, "_rollback_upgrade_handover", lambda: events.append("rollback-handover")) monkeypatch.setattr( restart_handoff.subprocess, "Popen", lambda *_args, **_kwargs: events.append("spawn"), ) - code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True)) + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) assert code == 1 - assert "rollback-handover" in events + assert not any("rollback" in event for event in events) assert "spawn" not in events -def test_run_rolls_back_prepared_handover_when_restart_spawn_fails(monkeypatch, tmp_path: Path) -> None: +def test_restart_only_does_not_rollback_when_restart_spawn_fails(monkeypatch, tmp_path: Path) -> None: events: list[str] = [] restart_argv = ["python.exe", "-m", "flocks.cli.main", "start"] monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda message: events.append(f"log:{message}")) monkeypatch.setattr(restart_handoff, "_wait_for_parent_exit", lambda parent_pid: True) - monkeypatch.setattr(restart_handoff, "_prepare_upgrade_handover", lambda args: events.append("prepare") or True) + monkeypatch.setattr(restart_handoff, "_ensure_backend_port_free", lambda _port: True) monkeypatch.setattr(restart_handoff, "_run_upgrade_tasks", lambda args: None) monkeypatch.setattr(restart_handoff, "_stop_supervisor_before_restart", lambda: True) - monkeypatch.setattr(restart_handoff, "_rollback_upgrade_handover", lambda: events.append("rollback-handover")) monkeypatch.setattr( restart_handoff.subprocess, "Popen", lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("spawn failed")), ) - code = restart_handoff.run(_handoff_args(tmp_path, restart_argv, prepare_handover=True)) + code = restart_handoff.run(_handoff_args(tmp_path, restart_argv)) assert code == 1 assert "log:restart_spawn_failed error=spawn failed" in events - assert "rollback-handover" in events + assert not any("rollback" in event for event in events) + + +def test_stop_supervisor_requests_stop_when_health_probe_is_temporarily_unavailable(monkeypatch) -> None: + from flocks.cli import service_control + + events: list[str] = [] + daemon_running = True + + monkeypatch.setattr(service_control, "supervisor_is_running", lambda _paths: False) + monkeypatch.setattr( + restart_handoff.service_manager, + "pid_is_running", + lambda _pid: daemon_running, + ) + monkeypatch.setattr(restart_handoff, "_backend_port_in_use", lambda _port: False) + + def request_stop(*, paths, timeout): + del paths, timeout + nonlocal daemon_running + events.append("request-stop") + daemon_running = False + + monkeypatch.setattr(service_control, "request_stop", request_stop) + + assert restart_handoff._stop_supervisor_before_restart( + daemon_pid=2468, + timeout_seconds=0.01, + poll_interval_seconds=0.001, + ) + assert events == ["request-stop"] @pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") diff --git a/tests/updater/test_restart_handoff_process.py b/tests/updater/test_restart_handoff_process.py new file mode 100644 index 000000000..69c302a43 --- /dev/null +++ b/tests/updater/test_restart_handoff_process.py @@ -0,0 +1,281 @@ +import json +import os +import shutil +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +from flocks.cli import service_manager +from flocks.cli.service_config import service_config_payload + + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="uses Unix executable shims and symlinks") + +REPO_ROOT = Path(__file__).resolve().parents[2] +LOCALE_HANDOFF_ARGS = [ + pytest.param([], id="english-upgrade"), + pytest.param( + [ + "--uv-default-index", + "https://mirrors.aliyun.com/pypi/simple", + "--npm-registry", + "https://registry.npmmirror.com/", + ], + id="chinese-upgrade", + ), +] + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _prepare_isolated_runtime(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]: + install_root = tmp_path / "install" + install_root.mkdir() + (install_root / "pyproject.toml").write_text( + '[project]\nname = "handoff-process-test"\nversion = "0.0.0"\nrequires-python = ">=3.12"\ndependencies = []\n', + encoding="utf-8", + ) + runtime_python = install_root / ".venv" / "bin" / "python" + runtime_python.parent.mkdir(parents=True) + runtime_python.symlink_to(sys.executable) + + uv_path = shutil.which("uv") + if uv_path is None: + pytest.skip("uv is required for the real handoff process test") + + isolated_home = tmp_path / "home" + isolated_home.mkdir() + env = os.environ.copy() + env.update( + { + "FLOCKS_ROOT": str(tmp_path / "flocks-root"), + "HOME": str(isolated_home), + "UV_CACHE_DIR": str(tmp_path / "uv-cache"), + "UV_PYTHON_DOWNLOADS": "never", + } + ) + env.pop("PYTHONPATH", None) + return install_root, Path(uv_path), env + + +def _parent_process() -> subprocess.Popen[str]: + return subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(0.2)"], + text=True, + ) + + +def _base_handoff_command( + *, + parent_pid: int, + install_root: Path, + uv_path: Path, + backend_port: int, + frontend_port: int, + current_version: str, +) -> list[str]: + return [ + sys.executable, + "-m", + "flocks.updater.restart_handoff", + "--parent-pid", + str(parent_pid), + "--backend-host", + "127.0.0.1", + "--backend-port", + str(backend_port), + "--frontend-host", + "127.0.0.1", + "--frontend-port", + str(frontend_port), + "--install-root", + str(install_root), + "--uv-path", + str(uv_path), + "--sync-timeout", + "10", + "--version", + "2026.7.15", + "--current-version", + current_version, + ] + + +def _restart_marker_command(install_root: Path, marker: Path, value: str) -> list[str]: + runtime_python = install_root / ".venv" / "bin" / "python" + script = f"from pathlib import Path; Path({str(marker)!r}).write_text({value!r}, encoding='utf-8')" + return [str(runtime_python), "-c", script] + + +def _run_handoff(command: list[str], *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + + +def _wait_for_marker(marker: Path, expected: str) -> None: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if marker.is_file() and marker.read_text(encoding="utf-8") == expected: + return + time.sleep(0.05) + raise AssertionError(f"restart marker was not written: {marker}") + + +@pytest.mark.parametrize("locale_args", LOCALE_HANDOFF_ARGS) +def test_v2026_7_1_real_handoff_command_restarts_with_current_code( + tmp_path: Path, + locale_args: list[str], +) -> None: + install_root, fake_uv, env = _prepare_isolated_runtime(tmp_path) + marker = tmp_path / "v2026.7.1-restarted" + backup_path = tmp_path / "backup.tar.gz" + backup_path.write_text("backup", encoding="utf-8") + parent = _parent_process() + command = _base_handoff_command( + parent_pid=parent.pid, + install_root=install_root, + uv_path=fake_uv, + backend_port=_free_port(), + frontend_port=_free_port(), + current_version="2026.7.1", + ) + install_index = command.index("--install-root") + command[install_index:install_index] = [ + "--backend-pid-file", + str(tmp_path / "backend.pid"), + ] + command.extend( + [ + *locale_args, + "--backup-path", + str(backup_path), + "--", + *_restart_marker_command(install_root, marker, "v2026.7.1"), + ] + ) + + completed = _run_handoff(command, env=env) + parent.wait(timeout=5) + + assert completed.returncode == 0, completed.stderr + _wait_for_marker(marker, "v2026.7.1") + + +@pytest.mark.parametrize("locale_args", LOCALE_HANDOFF_ARGS) +def test_v2026_7_15_real_handoff_command_restarts_with_current_code( + tmp_path: Path, + locale_args: list[str], +) -> None: + install_root, fake_uv, env = _prepare_isolated_runtime(tmp_path) + marker = tmp_path / "v2026.7.15-restarted" + backup_path = tmp_path / "backup.tar.gz" + backup_path.write_text("backup", encoding="utf-8") + public_port = _free_port() + parent = _parent_process() + command = _base_handoff_command( + parent_pid=parent.pid, + install_root=install_root, + uv_path=fake_uv, + backend_port=public_port, + frontend_port=public_port, + current_version="2026.7.15", + ) + command.extend( + [ + *locale_args, + "--backup-path", + str(backup_path), + "--prepare-handover", + "--", + *_restart_marker_command(install_root, marker, "v2026.7.15"), + ] + ) + + completed = _run_handoff(command, env=env) + parent.wait(timeout=5) + + assert completed.returncode == 0, completed.stderr + _wait_for_marker(marker, "v2026.7.15") + + +@pytest.mark.parametrize("locale_args", LOCALE_HANDOFF_ARGS) +def test_current_real_upgrade_handoff_command_restarts_with_replaced_source( + tmp_path: Path, + locale_args: list[str], +) -> None: + install_root, fake_uv, env = _prepare_isolated_runtime(tmp_path) + marker = tmp_path / "current-restarted" + env["FLOCKS_RESTART_MARKER"] = str(marker) + + content_root = tmp_path / "staged" + fake_cli = content_root / "flocks" / "cli" + fake_cli.mkdir(parents=True) + (content_root / "flocks" / "__init__.py").write_text("", encoding="utf-8") + shutil.copy2(install_root / "pyproject.toml", content_root / "pyproject.toml") + (fake_cli / "__init__.py").write_text("", encoding="utf-8") + (fake_cli / "main.py").write_text( + "import os\n" + "from pathlib import Path\n" + "Path(os.environ['FLOCKS_RESTART_MARKER']).write_text('current', encoding='utf-8')\n", + encoding="utf-8", + ) + backup_path = tmp_path / "backup.tar.gz" + backup_path.write_text("backup", encoding="utf-8") + + backend_port = _free_port() + frontend_port = _free_port() + config = service_manager.ServiceConfig( + backend_host="127.0.0.1", + backend_port=backend_port, + frontend_host="127.0.0.1", + frontend_port=frontend_port, + no_browser=True, + skip_frontend_build=True, + ) + parent = _parent_process() + command = _base_handoff_command( + parent_pid=parent.pid, + install_root=install_root, + uv_path=fake_uv, + backend_port=backend_port, + frontend_port=frontend_port, + current_version="2026.7.15", + ) + command.extend( + [ + *locale_args, + "--mode", + "upgrade", + "--content-root", + str(content_root), + "--backup-path", + str(backup_path), + "--was-running", + "--service-config-json", + json.dumps(service_config_payload(config)), + "--", + str(install_root / ".venv" / "bin" / "python"), + ] + ) + + completed = _run_handoff(command, env=env) + parent.wait(timeout=5) + + assert completed.returncode == 0, completed.stderr + assert marker.read_text(encoding="utf-8") == "current" + assert (install_root / "flocks" / "cli" / "main.py").is_file() diff --git a/tests/updater/test_updater.py b/tests/updater/test_updater.py index cbeb4b043..58dec2b10 100644 --- a/tests/updater/test_updater.py +++ b/tests/updater/test_updater.py @@ -11,6 +11,7 @@ import pytest from flocks.cli import service_control, service_manager +from flocks.cli.service_config import service_config_payload from flocks.updater import updater from tests.helpers.service_supervisor import ( make_short_runtime_root, @@ -73,6 +74,87 @@ def test_current_service_config_requires_supervisor_control_api(monkeypatch: pyt updater._current_service_config() +def test_capture_service_snapshot_preserves_complete_supervisor_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = service_manager.ServiceConfig( + backend_host="2001:db8::20", + backend_port=9527, + frontend_host="2001:db8::20", + frontend_port=9527, + legacy_backend_host="0.0.0.0", + legacy_backend_port=9000, + server_port_migration_hint=True, + no_browser=False, + skip_frontend_build=False, + ) + payload = { + "daemon": {"pid": 2468, "state": "running"}, + "backend": { + "pid": 3579, + "host": config.backend_host, + "port": config.backend_port, + "state": "healthy", + "health": "healthy", + }, + "webui": { + "host": config.frontend_host, + "port": config.frontend_port, + "state": "static", + }, + "config": service_config_payload(config), + } + status = service_control.parse_supervisor_status(payload) + monkeypatch.setattr(service_control, "read_supervisor_status", lambda **_kwargs: status) + + snapshot = updater._capture_service_snapshot() + + assert snapshot.config == config + assert snapshot.daemon_pid == 2468 + assert snapshot.was_running is True + + +def test_spawn_restart_handoff_redirects_output_to_backend_log( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capfd: pytest.CaptureFixture[str], +) -> None: + flocks_root = tmp_path / "flocks-root" + command = [ + sys.executable, + "-c", + "import sys; print('handoff stdout'); print('handoff stderr', file=sys.stderr)", + ] + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + + process = updater._spawn_restart_handoff(command, cwd=tmp_path) + + assert process.wait(timeout=10) == 0 + captured = capfd.readouterr() + assert captured.out == "" + assert captured.err == "" + backend_output = (flocks_root / "logs" / "backend.log").read_text(encoding="utf-8") + assert "handoff stdout" in backend_output + assert "handoff stderr" in backend_output + + +def test_capture_service_snapshot_allows_stopped_service_without_control_api( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + service_control, + "read_supervisor_status", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("control down")), + ) + monkeypatch.setattr(service_manager, "read_runtime_record", lambda _path: None) + monkeypatch.setattr(service_manager, "trusted_daemon_process_pids", lambda **_kwargs: []) + + snapshot = updater._capture_service_snapshot() + + assert snapshot.daemon_pid is None + assert snapshot.was_running is False + + def test_run_handles_none_process_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def fake_run(*args, **kwargs): return subprocess.CompletedProcess(args=args[0], returncode=0, stdout=None, stderr=None) @@ -107,41 +189,6 @@ def fake_run(*args, **kwargs): assert stderr == "failed�output" -@pytest.mark.asyncio -async def test_await_ignoring_cancellation_backs_off_on_repeated_cancellation( - monkeypatch: pytest.MonkeyPatch, -) -> None: - shield_calls = 0 - sleep_delays: list[float] = [] - - async def fake_shield(task): - nonlocal shield_calls - shield_calls += 1 - if shield_calls <= 2: - raise updater.asyncio.CancelledError - return await task - - async def fake_sleep(delay: float) -> None: - sleep_delays.append(delay) - if len(sleep_delays) == 1: - raise updater.asyncio.CancelledError - - async def critical_step() -> str: - return "done" - - monkeypatch.setattr(updater.asyncio, "shield", fake_shield) - monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) - - result = await updater._await_ignoring_cancellation(critical_step()) - - assert result == "done" - assert shield_calls == 3 - assert sleep_delays == [ - updater._CANCELLATION_RETRY_DELAY_SECONDS, - updater._CANCELLATION_RETRY_DELAY_SECONDS, - ] - - def test_get_current_version_prefers_higher_marker_version( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -516,7 +563,6 @@ def test_build_dependency_sync_command_installs_project_on_windows( assert updater._build_dependency_sync_command("uv", uv_default_index="https://mirror.example/simple") == [ "uv", "sync", - "--frozen", "--no-python-downloads", "--default-index", "https://mirror.example/simple", @@ -528,7 +574,7 @@ def test_build_dependency_sync_command_keeps_project_install_on_non_windows( ) -> None: monkeypatch.setattr(updater.sys, "platform", "linux") - assert updater._build_dependency_sync_command("uv") == ["uv", "sync", "--frozen", "--no-python-downloads"] + assert updater._build_dependency_sync_command("uv") == ["uv", "sync", "--no-python-downloads"] def test_wheel_build_config_does_not_force_include_runtime_or_build_outputs() -> None: @@ -563,21 +609,6 @@ def test_build_frontend_subprocess_env_prepends_bundled_node_on_windows( assert result["PATH"].split(os.pathsep)[0] == str(node_home) -def test_upgrade_page_html_contains_marker_and_version() -> None: - html = updater._upgrade_page_html("2026.3.31.1") - - assert "flocks-upgrade-in-progress" in html - assert "v2026.3.31.1" in html - assert "window.location.reload()" in html - - -def test_upgrade_page_probe_urls_support_ipv6_loopback_fallback() -> None: - assert updater._upgrade_page_probe_urls("::", 5173) == [ - "http://[::1]:5173", - "http://127.0.0.1:5173", - ] - - def test_resolve_update_mirror_profile_uses_cn_defaults_for_zh_locale() -> None: profile = updater._resolve_update_mirror_profile( ["github", "gitee", "gitlab"], @@ -823,10 +854,9 @@ def test_build_restart_handoff_argv_rewrites_serve_to_managed_start( sync_timeout=300, version="2026.4.1", current_version="2026.3.31", - prepare_handover=True, ) - assert "--prepare-handover" in argv[: argv.index("--")] + assert "--mode" not in argv assert argv[argv.index("--") + 1 :] == [ "python", "-m", @@ -845,6 +875,29 @@ def test_build_restart_handoff_argv_rewrites_serve_to_managed_start( ] +def test_build_restart_handoff_argv_can_skip_parent_wait( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + updater, + "_handoff_service_config", + lambda: service_manager.ServiceConfig(), + ) + + argv = updater._build_restart_handoff_argv( + ["python"], + tmp_path, + uv_path="uv", + sync_timeout=300, + version="2026.4.1", + current_version="2026.3.31", + wait_for_parent=False, + ) + + assert "--parent-pid" not in argv + + def test_refresh_global_cli_entry_creates_symlink_on_unix( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -992,6 +1045,70 @@ async def fail_run_async(*_args, **_kwargs): assert await updater._validate_restart_runtime(tmp_path) is None +@pytest.mark.asyncio +async def test_shared_installer_runs_core_steps_in_order( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + webui_dir = tmp_path / "webui" + webui_dir.mkdir() + (webui_dir / "package.json").write_text("{}", encoding="utf-8") + events: list[str] = [] + + async def fake_sync(**_kwargs) -> None: + events.append("sync") + return None + + async def fake_build(*_args, **_kwargs) -> None: + events.append("build") + return None + + async def fake_validate(_root: Path) -> None: + events.append("validate") + return None + + monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: "/usr/bin/npm") + monkeypatch.setattr(service_manager, "node_version_satisfies_requirement", lambda: True) + monkeypatch.setattr(updater, "_sync_project_dependencies", fake_sync) + monkeypatch.setattr(updater, "_build_frontend_workspace", fake_build) + monkeypatch.setattr(updater, "_validate_restart_runtime", fake_validate) + monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: events.append("refresh-cli")) + monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}")) + + await updater.install_or_repair_source( + tmp_path, + version="v2026.7.2", + uv_path="/usr/bin/uv", + ) + + assert events == ["sync", "build", "validate", "refresh-cli", "marker:2026.7.2"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("platform", "script"), + [("darwin", "scripts/install.sh"), ("win32", "scripts/install.ps1")], +) +async def test_shared_installer_reports_missing_npm_with_platform_installer_hint( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + platform: str, + script: str, +) -> None: + webui_dir = tmp_path / "webui" + webui_dir.mkdir() + (webui_dir / "package.json").write_text("{}", encoding="utf-8") + monkeypatch.setattr(updater.sys, "platform", platform) + monkeypatch.setattr(service_manager, "resolve_npm_executable", lambda: None) + + with pytest.raises(RuntimeError, match=script): + await updater.install_or_repair_source( + tmp_path, + version="2026.7.2", + uv_path="uv", + ) + + def test_rmtree_onerror_retries_before_logging_skip(monkeypatch: pytest.MonkeyPatch) -> None: attempts: list[str] = [] warnings: list[tuple[str, dict[str, str]]] = [] @@ -1063,495 +1180,6 @@ def test_safe_remove_renames_locked_directory_on_windows( assert (leftovers[0] / "dist" / "index.html").exists() -@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") -def test_prepare_upgrade_handover_writes_state_and_stops_frontend_with_real_control_api( - monkeypatch: pytest.MonkeyPatch, -) -> None: - short_root = make_short_runtime_root("flocks-updater-") - monkeypatch.setenv("FLOCKS_ROOT", str(short_root)) - paths = service_manager.runtime_paths() - config = service_manager.ServiceConfig( - backend_host="127.0.0.1", - backend_port=9995, - frontend_host="127.0.0.1", - frontend_port=9996, - ) - daemon, thread = start_supervisor(config) - wait_for_supervisor(paths, running=True) - - monkeypatch.setattr( - updater, - "_start_upgrade_page_server", - lambda _config, _version: { - "upgrade_server_pid": 321, - "page_dir": str(short_root / "page"), - "page_log": str(short_root / "logs" / "upgrade.log"), - }, - ) - - try: - payload = updater._prepare_upgrade_handover("2026.3.31.1") - - status = service_control.read_supervisor_status(paths) - assert status.backend.paused is True - assert status.webui.paused is True - assert payload["upgrade_server_pid"] == 321 - assert payload["backend_port"] == 9995 - assert payload["frontend_port"] == 9996 - assert updater._read_upgrade_state()["version"] == "2026.3.31.1" - finally: - stop_supervisor(daemon, thread) - shutil.rmtree(short_root, ignore_errors=True) - - -@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") -def test_prepare_upgrade_handover_restores_frontend_when_upgrade_page_fails_with_real_control_api( - monkeypatch: pytest.MonkeyPatch, -) -> None: - short_root = make_short_runtime_root("flocks-updater-") - monkeypatch.setenv("FLOCKS_ROOT", str(short_root)) - paths = service_manager.runtime_paths() - config = service_manager.ServiceConfig( - backend_host="127.0.0.1", - backend_port=9995, - frontend_host="127.0.0.1", - frontend_port=9996, - ) - daemon, thread = start_supervisor(config) - wait_for_supervisor(paths, running=True) - calls: list[str] = [] - - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **_kw: calls.append("stop_page")) - monkeypatch.setattr( - updater, - "_start_upgrade_page_server", - lambda _config, _version: (_ for _ in ()).throw(RuntimeError("page failed")), - ) - - try: - with pytest.raises(RuntimeError, match="page failed"): - updater._prepare_upgrade_handover("2026.3.31.1") - - status = service_control.read_supervisor_status(paths) - assert calls == ["stop_page"] - assert status.backend.paused is False - assert status.backend.pid is not None - assert status.webui.paused is False - assert status.webui.pid is None - assert status.webui.state == "static" - assert updater._read_upgrade_state() is None - finally: - stop_supervisor(daemon, thread) - shutil.rmtree(short_root, ignore_errors=True) - - -@pytest.mark.skipif(sys.platform == "win32", reason="uses the Unix domain socket control API") -def test_rollback_failed_update_resumes_backend_when_handoff_tasks_fail( - monkeypatch: pytest.MonkeyPatch, -) -> None: - short_root = make_short_runtime_root("flocks-updater-") - monkeypatch.setenv("FLOCKS_ROOT", str(short_root)) - paths = service_manager.runtime_paths() - config = service_manager.ServiceConfig( - backend_host="127.0.0.1", - backend_port=9995, - frontend_host="127.0.0.1", - frontend_port=9996, - ) - daemon, thread = start_supervisor(config) - wait_for_supervisor(paths, running=True) - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **_kw: None) - - try: - updater._write_upgrade_state( - { - "version": "2026.4.1", - "backend_host": "127.0.0.1", - "backend_port": 9995, - "frontend_host": "127.0.0.1", - "frontend_port": 9996, - "skip_frontend_build": True, - } - ) - old_backend = daemon.backend.process - assert old_backend is not None - service_control.request_prepare_upgrade(paths=paths) - wait_for_process_exit(old_backend) - - updater._rollback_failed_update(None, short_root / "install", "2026.3.31") - - status = service_control.read_supervisor_status(paths) - assert status.backend.paused is False - assert status.webui.paused is False - assert status.backend.pid is not None - assert status.backend.pid != old_backend.pid - assert status.webui.pid is None - assert status.webui.state == "static" - assert updater._read_upgrade_state() is None - finally: - stop_supervisor(daemon, thread) - shutil.rmtree(short_root, ignore_errors=True) - - -def test_recover_upgrade_state_restarts_frontend_and_clears_marker( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - started: list[tuple[int, bool | None]] = [] - stopped: list[str] = [] - - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: stopped.append("stop")) - monkeypatch.setattr( - service_control, - "request_resume_upgrade", - lambda config, **_kwargs: started.append((config.frontend_port, config.skip_frontend_build)) - or _webui_control_status(), - ) - updater._write_upgrade_state( - { - "version": "2026.3.31.1", - "backend_host": "127.0.0.1", - "backend_port": 8000, - "frontend_host": "127.0.0.1", - "frontend_port": 5173, - "skip_frontend_build": True, - } - ) - - updater.recover_upgrade_state() - - assert stopped == ["stop"] - assert started == [(5173, True)] - assert updater._read_upgrade_state() is None - - -def test_recover_upgrade_state_retries_frontend_with_build_when_dist_is_missing( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - starts: list[tuple[str, bool | None, bool | None]] = [] - - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: None) - - results = iter([ - _webui_control_payload("degraded", "missing dist"), - _webui_control_payload(), - ]) - - def fake_resume_upgrade(config, **_kwargs): - starts.append(("resume", config.skip_frontend_build, None)) - return service_control.parse_supervisor_status(next(results)) - - def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs): - starts.append(("restart_webui", config.skip_frontend_build, force_frontend_build or None)) - return service_control.parse_supervisor_status(next(results)) - - monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) - monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) - updater._write_upgrade_state( - { - "version": "2026.3.31.1", - "backend_host": "127.0.0.1", - "backend_port": 8000, - "frontend_host": "127.0.0.1", - "frontend_port": 5173, - "skip_frontend_build": True, - } - ) - - updater.recover_upgrade_state() - - assert starts == [("resume", True, None), ("restart_webui", False, True)] - assert updater._read_upgrade_state() is None - - -def test_recover_upgrade_state_restart_failure_clears_state_without_restarting_page( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - starts: list[tuple[str, bool | None, bool | None]] = [] - - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: None) - - def fake_resume_upgrade(config, **_kwargs): - starts.append(("resume", config.skip_frontend_build, None)) - return _webui_control_status("degraded", "still broken") - - def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs): - starts.append(("restart_webui", config.skip_frontend_build, force_frontend_build or None)) - return _webui_control_status("degraded", "still broken") - - monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) - monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) - updater._write_upgrade_state( - { - "version": "2026.3.31.1", - "backend_host": "127.0.0.1", - "backend_port": 8000, - "frontend_host": "127.0.0.1", - "frontend_port": 5173, - "skip_frontend_build": True, - } - ) - - with pytest.raises(RuntimeError, match="still broken"): - updater.recover_upgrade_state() - - assert starts == [("resume", True, None), ("restart_webui", False, True)] - assert updater._read_upgrade_state() is None - - -def test_start_upgrade_page_server_binds_configured_frontend_host( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - flocks_root = tmp_path / ".flocks" - (flocks_root / "run").mkdir(parents=True) - monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) - - page_dir = tmp_path / "page" - page_dir.mkdir() - captured: dict[str, object] = {} - - monkeypatch.setattr(updater, "_write_upgrade_page", lambda _version: page_dir) - monkeypatch.setattr(updater, "_wait_for_upgrade_page", lambda config: captured.setdefault("wait_host", config.frontend_host)) - monkeypatch.setattr( - service_manager, - "resolve_python_subprocess_command", - lambda _root=None: ["/env/bin/python"], - ) - monkeypatch.setattr( - updater, - "_spawn_detached_process", - lambda command, *, cwd, log_path: captured.update({ - "command": command, - "cwd": cwd, - "log_path": log_path, - }) or SimpleNamespace(pid=4321), - ) - - config = service_manager.ServiceConfig(frontend_host="0.0.0.0", frontend_port=5173) - payload = updater._start_upgrade_page_server(config, "2026.4.1") - - assert payload["upgrade_server_pid"] == 4321 - assert captured["command"] == [ - "/env/bin/python", - "-m", - "http.server", - "5173", - "--bind", - "0.0.0.0", - "--directory", - str(page_dir.resolve()), - ] - assert captured["wait_host"] == "0.0.0.0" - - -def test_stop_upgrade_page_server_does_not_kill_unified_flocks_service( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - flocks_root = tmp_path / ".flocks" - monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) - killed: list[int] = [] - - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: [111]) - monkeypatch.setattr( - service_manager, - "_process_command_line", - lambda _pid: "/env/bin/python -m flocks.cli.main serve --host 127.0.0.1 --port 5173", - ) - monkeypatch.setattr(updater.os, "kill", lambda pid, _sig: killed.append(pid)) - - updater._stop_upgrade_page_server(frontend_port=5173) - - assert killed == [] - - -def test_stop_upgrade_page_server_kills_only_upgrade_page_process( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - flocks_root = tmp_path / ".flocks" - page_dir = flocks_root / "run" / "upgrade-page" - monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) - killed: list[int] = [] - - def fake_command_line(pid: int) -> str: - if pid == 222: - return f"/env/bin/python -m http.server 5173 --directory {page_dir}" - return "/env/bin/python -m flocks.cli.main serve --host 127.0.0.1 --port 5173" - - monkeypatch.setattr(service_manager, "port_owner_pids", lambda _port: [111, 222]) - monkeypatch.setattr(service_manager, "_process_command_line", fake_command_line) - monkeypatch.setattr(updater.os, "kill", lambda pid, _sig: killed.append(pid)) - monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None) - - updater._stop_upgrade_page_server(frontend_port=5173) - - assert killed == [222] - - -def test_wait_for_upgrade_page_uses_access_host_for_local_probe( - monkeypatch: pytest.MonkeyPatch, -) -> None: - requested_urls: list[str] = [] - - class _FakeClient: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def get(self, url): - requested_urls.append(url) - return SimpleNamespace(status_code=200) - - monkeypatch.setattr(updater.httpx, "Client", lambda timeout: _FakeClient()) - monkeypatch.setattr(service_manager, "access_host", lambda host: "127.0.0.1" if host == "0.0.0.0" else host) - monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None) - - updater._wait_for_upgrade_page(service_manager.ServiceConfig(frontend_host="0.0.0.0", frontend_port=5173)) - - assert requested_urls == ["http://127.0.0.1:5173"] - - -def test_wait_for_upgrade_page_falls_back_from_ipv6_to_ipv4_probe( - monkeypatch: pytest.MonkeyPatch, -) -> None: - requested_urls: list[str] = [] - - class _FakeClient: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def get(self, url): - requested_urls.append(url) - if url == "http://[::1]:5173": - raise OSError("ipv6 unavailable") - return SimpleNamespace(status_code=200) - - monkeypatch.setattr(updater.httpx, "Client", lambda timeout: _FakeClient()) - monkeypatch.setattr(updater.time, "sleep", lambda _seconds: None) - - updater._wait_for_upgrade_page(service_manager.ServiceConfig(frontend_host="::", frontend_port=5173)) - - assert requested_urls == [ - "http://[::1]:5173", - "http://127.0.0.1:5173", - ] - - -def test_rollback_failed_update_restores_backup_and_rebuilds_frontend_if_needed( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - events: list[str] = [] - - monkeypatch.setattr(updater, "_restore_backup_archive", lambda backup, root: events.append(f"restore:{backup.name}:{root.name}")) - monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}")) - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: events.append("stop_page")) - monkeypatch.setattr(updater.shutil, "rmtree", lambda path, ignore_errors=True: events.append(f"rmtree:{Path(path).name}")) - - results = iter([ - _webui_control_payload("degraded", "missing dist"), - _webui_control_payload(), - ]) - - def fake_resume_upgrade(config, **_kwargs) -> service_control.SupervisorStatus: - events.append(f"resume:{config.skip_frontend_build}") - return service_control.parse_supervisor_status(next(results)) - - def fake_restart_webui(config, *, force_frontend_build=False, **_kwargs) -> service_control.SupervisorStatus: - events.append(f"restart_webui:{config.skip_frontend_build}:{force_frontend_build or None}") - return service_control.parse_supervisor_status(next(results)) - - monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) - monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) - updater._write_upgrade_state( - { - "version": "2026.4.1", - "backend_host": "127.0.0.1", - "backend_port": 8000, - "frontend_host": "127.0.0.1", - "frontend_port": 5173, - "skip_frontend_build": True, - } - ) - - backup_path = tmp_path / "backup.tar.gz" - backup_path.write_text("backup", encoding="utf-8") - updater._rollback_failed_update(backup_path, tmp_path / "install", "2026.3.31") - - assert events == [ - "restore:backup.tar.gz:install", - "marker:2026.3.31", - "stop_page", - "resume:True", - "restart_webui:False:True", - "rmtree:upgrade-page", - ] - assert updater._read_upgrade_state() is None - - -def test_rollback_failed_update_clears_state_when_restore_and_frontend_both_fail( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path / ".flocks")) - events: list[str] = [] - - monkeypatch.setattr( - updater, - "_restore_backup_archive", - lambda *_args: (_ for _ in ()).throw(RuntimeError("backup broken")), - ) - monkeypatch.setattr(updater, "_write_version_marker", lambda version: events.append(f"marker:{version}")) - monkeypatch.setattr(updater, "_stop_upgrade_page_server", lambda **kw: events.append("stop_page")) - monkeypatch.setattr(updater.shutil, "rmtree", lambda path, ignore_errors=True: events.append(f"rmtree:{Path(path).name}")) - - def fake_resume_upgrade(config, **_kwargs) -> service_control.SupervisorStatus: - events.append(f"resume:{config.skip_frontend_build}") - return _webui_control_status("degraded", "frontend still broken") - - def fake_restart_webui(config, **_kwargs) -> service_control.SupervisorStatus: - events.append(f"restart_webui:{config.skip_frontend_build}") - return _webui_control_status("degraded", "frontend still broken") - - monkeypatch.setattr(service_control, "request_resume_upgrade", fake_resume_upgrade) - monkeypatch.setattr(service_control, "request_restart_webui", fake_restart_webui) - updater._write_upgrade_state( - { - "version": "2026.4.1", - "backend_host": "127.0.0.1", - "backend_port": 8000, - "frontend_host": "127.0.0.1", - "frontend_port": 5173, - "skip_frontend_build": True, - "phase": "cutover_applied", - } - ) - - backup_path = tmp_path / "backup.tar.gz" - backup_path.write_text("backup", encoding="utf-8") - updater._rollback_failed_update(backup_path, tmp_path / "install", "2026.3.31") - - assert events == [ - "stop_page", - "resume:True", - "rmtree:upgrade-page", - ] - assert updater._read_upgrade_state() is None - - def test_backup_current_version_excludes_all_dist_directories( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -1566,6 +1194,12 @@ def test_backup_current_version_excludes_all_dist_directories( (webui_dist / "index.html").write_text("ok", encoding="utf-8") (git_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (install_root / "flocks.json").write_text('{"keep": true}', encoding="utf-8") + runtime_data = install_root / "data" + runtime_data.mkdir() + (runtime_data / "flocks.db").write_text("runtime", encoding="utf-8") + nested_source_config = install_root / "webui" / "src" / "locales" / "en-US" / "config.json" + nested_source_config.parent.mkdir(parents=True) + nested_source_config.write_text('{"source": true}', encoding="utf-8") (other_dist / "ignored.txt").write_text("nope", encoding="utf-8") backup_dir = tmp_path / "backups" @@ -1577,7 +1211,9 @@ def test_backup_current_version_excludes_all_dist_directories( names = tar.getnames() assert "flocks/webui/dist/index.html" not in names - assert "flocks/flocks.json" in names + assert "flocks/flocks.json" not in names + assert "flocks/data/flocks.db" not in names + assert "flocks/webui/src/locales/en-US/config.json" in names assert "flocks/.git/HEAD" not in names assert "flocks/dist/ignored.txt" not in names @@ -1689,6 +1325,8 @@ def test_replace_install_dir_copies_dot_flocks_plugins_from_source( (source_dir / "flocks.json").write_text('{"version": "new"}', encoding="utf-8") (install_root / "flocks.json").write_text('{"keep": true}', encoding="utf-8") + (install_root / "run").mkdir() + (install_root / "run" / "service.pid").write_text("2468", encoding="utf-8") (install_root / ".git").mkdir() (install_root / ".git" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") @@ -1697,14 +1335,37 @@ def test_replace_install_dir_copies_dot_flocks_plugins_from_source( assert (inst_plugins / "fofa" / "_provider.yaml").read_text(encoding="utf-8") == "version: new" assert (inst_plugins / "new_release_plugin" / "tool.yaml").read_text(encoding="utf-8") == "name: new" assert not (inst_plugins / "obsolete_plugin").exists() - assert (install_root / "flocks.json").read_text(encoding="utf-8") == '{"version": "new"}' + assert (install_root / "flocks.json").read_text(encoding="utf-8") == '{"keep": true}' + assert (install_root / "run" / "service.pid").read_text(encoding="utf-8") == "2468" assert (install_root / ".git" / "HEAD").read_text(encoding="utf-8") == "ref: refs/heads/main" @pytest.mark.asyncio -async def test_perform_update_schedules_handoff_with_deferred_handover( +@pytest.mark.parametrize( + ("locale", "expected_sources", "expected_mirror_args", "wait_for_handoff"), + [ + pytest.param("en-US", ["github", "gitee"], [], False, id="english-detached-upgrade"), + pytest.param( + "zh-CN", + ["gitee", "github"], + [ + "--uv-default-index", + "https://mirrors.aliyun.com/pypi/simple", + "--npm-registry", + "https://registry.npmmirror.com/", + ], + True, + id="chinese-waited-upgrade", + ), + ], +) +async def test_perform_update_only_stages_source_and_schedules_upgrade_handoff( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + locale: str, + expected_sources: list[str], + expected_mirror_args: list[str], + wait_for_handoff: bool, ) -> None: archive_path = tmp_path / "flocks.zip" archive_path.write_text("archive", encoding="utf-8") @@ -1720,11 +1381,13 @@ async def test_perform_update_schedules_handoff_with_deferred_handover( events: list[str] = [] popen_calls: list[list[str]] = [] + download_sources: list[str] = [] + progress_stages: list[str] = [] async def fake_get_updater_config(): return SimpleNamespace( archive_format="zip", - sources=["github"], + sources=["github", "gitee"], repo="AgentFlocks/Flocks", token=None, gitee_token=None, @@ -1733,7 +1396,8 @@ async def fake_get_updater_config(): gitee_repo=None, ) - async def fake_download_with_fallback(**_kwargs): + async def fake_download_with_fallback(**kwargs): + download_sources.extend(kwargs["sources"]) return archive_path async def fake_sleep(_seconds) -> None: @@ -1747,15 +1411,30 @@ async def fake_sleep(_seconds) -> None: monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root) monkeypatch.setattr(updater, "get_current_version", lambda: "2026.3.31") monkeypatch.setattr(updater, "_download_with_fallback", fake_download_with_fallback) - monkeypatch.setattr(updater, "_backup_current_version", lambda *_args, **_kwargs: tmp_path / "backup.tar.gz") + monkeypatch.setattr( + updater, + "_backup_current_version", + lambda *_args, **_kwargs: events.append("backup") or tmp_path / "backup.tar.gz", + ) monkeypatch.setattr(updater, "_extract_archive", lambda *_args, **_kwargs: staged_root) monkeypatch.setattr( updater, "_find_executable", lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv", ) - monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover") or {}) - monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) + config = service_manager.ServiceConfig( + backend_host="10.20.30.40", + backend_port=9527, + frontend_host="10.20.30.40", + frontend_port=9527, + legacy_backend_host="0.0.0.0", + legacy_backend_port=9000, + ) + monkeypatch.setattr( + updater, + "_capture_service_snapshot", + lambda: updater.ServiceSnapshot(config=config, daemon_pid=2468, was_running=True), + ) monkeypatch.setattr( updater, "_replace_install_dir", @@ -1764,43 +1443,111 @@ async def fake_sleep(_seconds) -> None: ) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: ["/usr/bin/python3", "-m", "flocks.cli.main", "start"]) monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) - monkeypatch.setattr(updater, "_rollback_failed_update", lambda *_args: events.append("rollback")) - monkeypatch.setattr(updater, "rollback_upgrade_handover", lambda *_args: events.append("rollback_handover")) monkeypatch.setattr( updater, "_spawn_restart_handoff", - lambda argv, **_kwargs: popen_calls.append(list(argv)) or SimpleNamespace(pid=4321), + lambda argv, **_kwargs: popen_calls.append(list(argv)) + or SimpleNamespace(pid=4321, wait=lambda: events.append("wait") or 0), ) monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code))) - with pytest.raises(SystemExit, match="0"): - async for _step in updater.perform_update("2026.4.1"): - pass - - assert events[:2] == ["replace", "sleep"] - assert "handover" not in events + if wait_for_handoff: + async for step in updater.perform_update( + "2026.4.1", + locale=locale, + wait_for_handoff=True, + ): + progress_stages.append(step.stage) + else: + with pytest.raises(SystemExit, match="0"): + async for step in updater.perform_update("2026.4.1", locale=locale): + progress_stages.append(step.stage) + + expected_events = ["backup", "sleep"] + expected_stages = ["fetching", "backing_up", "applying", "restarting"] + if wait_for_handoff: + expected_events.append("wait") + expected_stages.append("done") + + assert events == expected_events + assert progress_stages == expected_stages + assert download_sources == expected_sources assert len(popen_calls) == 1 handoff_argv = popen_calls[0] assert handoff_argv[:3] == ["/usr/bin/python3", "-m", "flocks.updater.restart_handoff"] assert "--uv-path" in handoff_argv assert "--version" in handoff_argv - assert "--prepare-handover" in handoff_argv[: handoff_argv.index("--")] - assert handoff_argv[handoff_argv.index("--") + 1 :] == [ - "/usr/bin/python3", - "-m", - "flocks.cli.main", - "start", - "--no-browser", - "--skip-webui-build", - "--host", - "127.0.0.1", - "--port", - "5173", - "--server-host", - "127.0.0.1", - "--server-port", - "8000", - ] + assert "--mode" in handoff_argv + assert handoff_argv[handoff_argv.index("--mode") + 1] == "upgrade" + assert handoff_argv[handoff_argv.index("--content-root") + 1] == str(staged_root) + assert handoff_argv[handoff_argv.index("--backup-path") + 1] == str(tmp_path / "backup.tar.gz") + assert handoff_argv[handoff_argv.index("--daemon-pid") + 1] == "2468" + assert "--was-running" in handoff_argv + assert "--prepare-handover" not in handoff_argv + assert ("--parent-pid" in handoff_argv) is not wait_for_handoff + if expected_mirror_args: + mirror_index = handoff_argv.index("--uv-default-index") + assert handoff_argv[mirror_index : mirror_index + len(expected_mirror_args)] == expected_mirror_args + else: + assert "--uv-default-index" not in handoff_argv + assert "--npm-registry" not in handoff_argv + assert handoff_argv[handoff_argv.index("--") + 1 :] == ["/usr/bin/python3"] + + +@pytest.mark.asyncio +async def test_perform_update_aborts_before_handoff_when_backup_raises( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + archive_path = tmp_path / "flocks.zip" + archive_path.write_text("archive", encoding="utf-8") + update_dir = tmp_path / "update" + update_dir.mkdir() + staged_root = update_dir / "staged" + staged_root.mkdir() + install_root = tmp_path / "install-root" + install_root.mkdir() + handoff_spawned = False + + async def fake_get_updater_config(): + return SimpleNamespace( + archive_format="zip", + sources=["github"], + repo="AgentFlocks/Flocks", + token=None, + gitee_token=None, + backup_retain_count=3, + base_url=None, + gitee_repo=None, + ) + + async def fake_download_with_fallback(**_kwargs): + return archive_path + + def fail_backup(*_args, **_kwargs): + raise OSError("backup directory is read-only") + + def record_handoff(*_args, **_kwargs): + nonlocal handoff_spawned + handoff_spawned = True + + monkeypatch.setattr(updater, "_get_updater_config", fake_get_updater_config) + monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root) + monkeypatch.setattr(updater, "get_current_version", lambda: "2026.3.31") + monkeypatch.setattr(updater.tempfile, "mkdtemp", lambda **_kwargs: str(update_dir)) + monkeypatch.setattr(updater, "_download_with_fallback", fake_download_with_fallback) + monkeypatch.setattr(updater, "_extract_archive", lambda *_args, **_kwargs: staged_root) + monkeypatch.setattr(updater, "_find_executable", lambda _name: "/usr/bin/uv") + monkeypatch.setattr(updater, "_backup_current_version", fail_backup) + monkeypatch.setattr(updater, "_spawn_restart_handoff", record_handoff) + monkeypatch.setattr(updater, "_record_update_journal", lambda _message: None) + + progresses = [step async for step in updater.perform_update("2026.4.1")] + + assert progresses[-1].stage == "error" + assert progresses[-1].message == "Failed to back up the current source; the update was not applied." + assert handoff_spawned is False + assert not update_dir.exists() @pytest.mark.asyncio @@ -1873,10 +1620,13 @@ async def fake_sleep(_seconds) -> None: monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) monkeypatch.setattr( updater, - "_prepare_upgrade_handover", - lambda _version: (_ for _ in ()).throw(RuntimeError("handover boom")), + "_capture_service_snapshot", + lambda: updater.ServiceSnapshot( + config=service_manager.ServiceConfig(), + daemon_pid=None, + was_running=False, + ), ) - monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) monkeypatch.setattr( updater, "_spawn_restart_handoff", @@ -1888,13 +1638,14 @@ async def fake_sleep(_seconds) -> None: async for _step in updater.perform_update("2026.4.1"): pass - assert events == ["replace"] + assert events == [] assert len(popen_calls) == 1 - assert "--prepare-handover" in popen_calls[0][: popen_calls[0].index("--")] + assert "--mode" in popen_calls[0] + assert "--prepare-handover" not in popen_calls[0] @pytest.mark.asyncio -async def test_perform_update_uses_cn_mirror_profile_for_sources_and_dependency_commands( +async def test_perform_update_rejects_source_update_without_handoff( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -1958,25 +1709,14 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): ) ] - assert progresses[-1].stage == "done" + assert progresses[-1].stage == "error" + assert "detached handoff" in progresses[-1].message assert captured["sources"] == ["gitee", "github"] - assert run_calls == [ - ( - [ - "/usr/bin/uv", - "sync", - "--frozen", - "--no-python-downloads", - "--default-index", - "https://mirrors.aliyun.com/pypi/simple", - ], - None, - ), - ] + assert run_calls == [] @pytest.mark.asyncio -async def test_perform_update_retries_cn_uv_sync_with_default_source( +async def test_dependency_sync_retries_cn_mirror_with_default_source( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2005,7 +1745,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): if cmd == [ "/usr/bin/uv", "sync", - "--frozen", "--no-python-downloads", "--default-index", "https://mirrors.aliyun.com/pypi/simple", @@ -2032,24 +1771,74 @@ async def fake_sleep(_seconds) -> None: monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")] + error = await updater._sync_project_dependencies( + uv_path="/usr/bin/uv", + install_root=install_root, + uv_default_index="https://mirrors.aliyun.com/pypi/simple", + env=None, + ) - assert progresses[-1].stage == "done" + assert error is None assert run_calls == [ [ "/usr/bin/uv", "sync", - "--frozen", "--no-python-downloads", "--default-index", "https://mirrors.aliyun.com/pypi/simple", ], - ["/usr/bin/uv", "sync", "--frozen", "--no-python-downloads"], + ["/usr/bin/uv", "sync", "--no-python-downloads"], ] @pytest.mark.asyncio -async def test_perform_update_prefers_bundled_npm_for_windows_frontend_rebuild( +async def test_dependency_sync_retries_default_source_after_timeout_and_logs_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + calls: list[list[str]] = [] + warnings: list[tuple[str, dict[str, object]]] = [] + + async def fake_run_async(cmd, **_kwargs): + calls.append(list(cmd)) + if "--default-index" in cmd: + raise subprocess.TimeoutExpired( + cmd=cmd, + timeout=300, + output=b"mirror stdout", + stderr=b"mirror stderr", + ) + return 0, "", "" + + monkeypatch.setattr(updater, "_run_async", fake_run_async) + monkeypatch.setattr(updater.log, "warning", lambda event, payload: warnings.append((event, payload))) + + error = await updater._sync_project_dependencies( + uv_path="uv", + install_root=tmp_path, + uv_default_index="https://mirrors.aliyun.com/pypi/simple", + sync_timeout=300, + ) + + assert error is None + assert calls == [ + [ + "uv", + "sync", + "--no-python-downloads", + "--default-index", + "https://mirrors.aliyun.com/pypi/simple", + ], + ["uv", "sync", "--no-python-downloads"], + ] + timeout_payload = next(payload for event, payload in warnings if event == "updater.dependencies.sync_timeout") + assert timeout_payload["stdout"] == "mirror stdout" + assert timeout_payload["stderr"] == "mirror stderr" + assert timeout_payload["retry_without_default_index"] is True + + +@pytest.mark.asyncio +async def test_frontend_build_prefers_bundled_npm_on_windows( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2112,9 +1901,13 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) monkeypatch.setenv("PATH", "/usr/bin:/bin") - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")] + shutil.copytree(staged_webui, install_root / "webui") + frontend_error = await updater._build_frontend_workspace( + install_root / "webui", + npm_registry="https://registry.npmmirror.com/", + ) - assert progresses[-1].stage == "done" + assert frontend_error is None frontend_calls = [ call for call in run_calls if call[0][0] == str(bundled_npm) ] @@ -2133,7 +1926,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): @pytest.mark.asyncio -async def test_perform_update_retries_windows_frontend_with_system_npm_after_bundled_build_failure( +async def test_frontend_build_retries_system_npm_after_bundled_build_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2224,9 +2017,13 @@ def fake_find(name: str) -> str | None: monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) monkeypatch.setenv("PATH", "/usr/bin:/bin") - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")] + shutil.copytree(staged_webui, install_webui) + frontend_error = await updater._build_frontend_workspace( + install_webui, + npm_registry="https://registry.npmmirror.com/", + ) - assert progresses[-1].stage == "done" + assert frontend_error is None frontend_calls = [ call for call in run_calls if call[0][0] in {str(bundled_npm), system_npm} ] @@ -2364,7 +2161,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): @pytest.mark.asyncio -async def test_perform_update_retries_windows_frontend_with_full_timeout_after_bundled_install_and_ci_timeout( +async def test_frontend_build_retries_system_npm_after_bundled_timeouts( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2454,9 +2251,13 @@ def fake_find(name: str) -> str | None: monkeypatch.delenv("FLOCKS_INSTALL_ROOT", raising=False) monkeypatch.setenv("PATH", "/usr/bin:/bin") - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False, locale="zh-CN")] + shutil.copytree(staged_webui, install_webui) + frontend_error = await updater._build_frontend_workspace( + install_webui, + npm_registry="https://registry.npmmirror.com/", + ) - assert progresses[-1].stage == "done" + assert frontend_error is None frontend_calls = [ call for call in run_calls if call[0][0] in {str(bundled_npm), system_npm} ] @@ -2520,7 +2321,7 @@ async def fake_download_with_fallback(**_kwargs): @pytest.mark.asyncio -async def test_perform_update_retries_uv_sync_on_first_failure( +async def test_dependency_sync_retries_first_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2574,16 +2375,18 @@ async def fake_sleep(_s): monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) - progresses = [ - step async for step in updater.perform_update("2026.4.1", restart=False) - ] + error = await updater._sync_project_dependencies( + uv_path="/usr/bin/uv", + install_root=install_root, + env=None, + ) - assert progresses[-1].stage == "done" + assert error is None assert call_count == 2 @pytest.mark.asyncio -async def test_perform_update_syncs_windows_venv_in_place( +async def test_dependency_sync_updates_windows_venv_in_place( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2633,16 +2436,20 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] + error = await updater._sync_project_dependencies( + uv_path=r"C:\tools\uv.exe", + install_root=install_root, + env=None, + ) - assert progresses[-1].stage == "done" - assert sync_calls == [([r"C:\tools\uv.exe", "sync", "--frozen", "--no-python-downloads"], install_root)] + assert error is None + assert sync_calls == [([r"C:\tools\uv.exe", "sync", "--no-python-downloads"], install_root)] assert (install_root / ".venv" / "Scripts" / "python.exe").read_text(encoding="utf-8") == "old" assert not (install_root / ".venv.flocks_backup").exists() @pytest.mark.asyncio -async def test_perform_update_rolls_back_when_windows_uv_sync_times_out( +async def test_dependency_sync_reports_windows_timeout_without_rollback( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2699,31 +2506,32 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): ) monkeypatch.setattr(updater, "_build_uv_sync_env", lambda: None) monkeypatch.setattr(updater, "_replace_install_dir", lambda *_a, **_kw: None) - monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_a: events.append("restore")) - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] + error = await updater._sync_project_dependencies( + uv_path=r"C:\tools\uv.exe", + install_root=install_root, + env=None, + ) - assert progresses[-1].stage == "error" expected_timeout = updater._dependency_sync_timeout_seconds() - assert progresses[-1].message == ( + assert error == ( f"Dependency sync timed out after {expected_timeout}s while running uv sync." ) - assert events == ["restore"] + assert events == [] assert (install_root / ".venv" / "Scripts" / "python.exe").read_text(encoding="utf-8") == "new" assert not (install_root / ".venv.flocks_failed").exists() assert not (install_root / ".venv.flocks_backup").exists() @pytest.mark.asyncio -async def test_perform_update_fails_after_uv_sync_retry_exhausted( +async def test_dependency_sync_fails_after_retry_exhausted( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: - """When uv sync fails twice, the updater should give up and rollback.""" + """When uv sync fails twice, the updater should return the final error.""" archive_path = tmp_path / "flocks.tar.gz" archive_path.write_text("archive", encoding="utf-8") staged_root = tmp_path / "staged" - rolled_back = False async def fake_get_updater_config(): return SimpleNamespace( @@ -2745,10 +2553,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): async def fake_download(**_kw): return archive_path - def fake_restore(*_args): - nonlocal rolled_back - rolled_back = True - monkeypatch.setattr(updater, "_get_updater_config", fake_get_updater_config) monkeypatch.setattr(updater, "_get_repo_root", lambda: tmp_path / "install-root") monkeypatch.setattr(updater, "get_current_version", lambda: "2026.3.31") @@ -2758,25 +2562,25 @@ def fake_restore(*_args): monkeypatch.setattr(updater, "_run_async", fake_run_async) monkeypatch.setattr(updater, "_find_executable", lambda _name: "/usr/bin/uv") monkeypatch.setattr(updater, "_build_uv_sync_env", lambda: None) + async def fake_sleep(_s): pass monkeypatch.setattr(updater, "_replace_install_dir", lambda *_a, **_kw: None) - monkeypatch.setattr(updater, "_restore_backup_if_possible", fake_restore) monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) - progresses = [ - step async for step in updater.perform_update("2026.4.1", restart=False) - ] + error = await updater._sync_project_dependencies( + uv_path="/usr/bin/uv", + install_root=tmp_path / "install-root", + env=None, + ) - error_events = [p for p in progresses if p.stage == "error"] - assert len(error_events) == 1 - assert "resolution failed" in error_events[0].message - assert rolled_back + assert error is not None + assert "resolution failed" in error @pytest.mark.asyncio -async def test_perform_update_repairs_broken_uv_managed_python_cache_before_retrying_sync( +async def test_dependency_sync_repairs_broken_uv_managed_python_cache_before_retry( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2841,18 +2645,20 @@ async def fake_sleep(seconds): monkeypatch.setattr(updater, "_repair_windows_uv_managed_python_install", lambda text: repaired_messages.append(text) or Path(r"C:\Users\worker\AppData\Roaming\uv\python\cpython-3.12-windows-x86_64-none")) monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) - progresses = [ - step async for step in updater.perform_update("2026.4.1", restart=False) - ] + error = await updater._sync_project_dependencies( + uv_path=r"C:\Users\worker\AppData\Local\Programs\Flocks\tools\uv\uv.exe", + install_root=install_root, + env=None, + ) - assert progresses[-1].stage == "done" + assert error is None assert call_count == 2 assert sleep_calls == [2] assert len(repaired_messages) == 1 @pytest.mark.asyncio -async def test_perform_update_rolls_back_when_replace_fails_on_windows_locked_file( +async def test_perform_update_without_handoff_never_attempts_source_replace( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2903,7 +2709,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): "_find_executable", lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv", ) - monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover") or {}) monkeypatch.setattr( updater, "_replace_install_dir", @@ -2913,18 +2718,16 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): ) ), ) - monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) - - progresses = [step async for step in updater.perform_update("2026.4.1")] + progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] assert progresses[-1].stage == "error" - assert "WinError 5" in progresses[-1].message - assert events == ["restore"] + assert "detached handoff" in progresses[-1].message + assert events == [] assert "handover" not in events @pytest.mark.asyncio -async def test_perform_update_reports_windows_file_lock_without_stopping_current_backend( +async def test_perform_update_without_handoff_does_not_touch_locked_windows_files( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -2983,11 +2786,16 @@ def fake_replace_install_dir(*_args, **_kwargs): "_find_executable", lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv", ) - monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) - monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) + monkeypatch.setattr( + updater, + "_capture_service_snapshot", + lambda: updater.ServiceSnapshot( + config=service_manager.ServiceConfig(), + daemon_pid=None, + was_running=False, + ), + ) monkeypatch.setattr(updater, "_replace_install_dir", fake_replace_install_dir) - monkeypatch.setattr(updater, "_rollback_failed_update", lambda *_args: events.append("rollback")) - monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main", "start"]) monkeypatch.setattr( updater, @@ -2996,17 +2804,18 @@ def fake_replace_install_dir(*_args, **_kwargs): ) monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code))) - progresses = [step async for step in updater.perform_update("2026.4.1")] + progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] assert progresses[-1].stage == "error" - assert "WinError 32" in progresses[-1].message - assert events == ["replace-1", "restore"] + assert "detached handoff" in progresses[-1].message + assert replace_attempts["count"] == 0 + assert events == [] assert "handover" not in events assert "popen" not in events @pytest.mark.asyncio -async def test_perform_update_reports_frontend_dependency_install_timeout( +async def test_frontend_workspace_reports_dependency_install_timeout( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -3064,23 +2873,19 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): or shutil.copytree(staged_webui, install_webui, dirs_exist_ok=True), ) monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) - monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] + shutil.copytree(staged_webui, install_webui) + frontend_error = await updater._build_frontend_workspace(install_webui) - assert progresses[-1].stage == "error" - assert progresses[-1].message == "Frontend dependency install timed out after 300s while running npm ci." + assert frontend_error == "Frontend dependency install timed out after 300s while running npm ci." assert events == [ - "replace", - "/usr/bin/uv sync --frozen --no-python-downloads", "/usr/bin/npm install", "/usr/bin/npm ci", - "restore", ] @pytest.mark.asyncio -async def test_perform_update_rolls_back_handover_when_current_frontend_build_fails( +async def test_frontend_workspace_reports_build_failure_without_rollback( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -3144,13 +2949,12 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): lambda *_args, **_kwargs: events.append("replace") or shutil.copytree(staged_webui, install_webui, dirs_exist_ok=True), ) - monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] + shutil.copytree(staged_webui, install_webui) + frontend_error = await updater._build_frontend_workspace(install_webui) - assert progresses[-1].stage == "error" - assert progresses[-1].message == "Frontend build failed: boom" - assert events == ["replace", "uv-sync", "npm-install", "npm-build", "restore"] + assert frontend_error == "Frontend build failed: boom" + assert events == ["npm-install", "npm-build"] @pytest.mark.asyncio @@ -3204,7 +3008,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): "_find_executable", lambda name: "/usr/bin/npm" if name in {"npm", "npm.cmd"} else "/usr/bin/uv", ) - monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None) monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) @@ -3215,7 +3018,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): await gen.aclose() assert "handover" not in events - assert updater._read_upgrade_state() is None + assert not updater._upgrade_result_path().exists() @pytest.mark.asyncio @@ -3271,7 +3074,6 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main", "start"]) - monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) monkeypatch.setattr( updater, @@ -3291,29 +3093,15 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): assert handoff_argv[:3] == [r"C:\tool\python.exe", "-m", "flocks.updater.restart_handoff"] assert "--parent-pid" in handoff_argv assert "--backend-port" in handoff_argv - assert "--prepare-handover" in handoff_argv[: handoff_argv.index("--")] - assert handoff_argv[handoff_argv.index("--") + 1 :] == [ - r"C:\tool\python.exe", - "-m", - "flocks.cli.main", - "start", - "--no-browser", - "--skip-webui-build", - "--host", - "127.0.0.1", - "--port", - "5173", - "--server-host", - "127.0.0.1", - "--server-port", - "8000", - ] + assert handoff_argv[handoff_argv.index("--mode") + 1] == "upgrade" + assert "--prepare-handover" not in handoff_argv + assert handoff_argv[handoff_argv.index("--") + 1 :] == [r"C:\tool\python.exe"] assert events == [] assert "execv" not in events @pytest.mark.asyncio -async def test_perform_update_stops_when_windows_restart_runtime_validation_fails( +async def test_validate_restart_runtime_reports_missing_windows_python( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -3364,15 +3152,13 @@ async def fake_validate_restart_runtime(_install_root: Path) -> str | None: ) monkeypatch.setattr(updater, "_replace_install_dir", lambda *_args, **_kwargs: None) monkeypatch.setattr(updater, "_write_version_marker", lambda _v: events.append("marker")) - monkeypatch.setattr(updater, "_restore_backup_if_possible", lambda *_args: events.append("restore")) monkeypatch.setattr(updater, "_validate_restart_runtime", fake_validate_restart_runtime) monkeypatch.setattr(updater.subprocess, "Popen", lambda *_args, **_kwargs: events.append("popen")) - progresses = [step async for step in updater.perform_update("2026.4.1", restart=False)] + validation_error = await updater._validate_restart_runtime(tmp_path / "install-root") - assert progresses[-1].stage == "error" - assert progresses[-1].message == "Restart runtime is missing: .venv/Scripts/python.exe" - assert events == ["restore"] + assert validation_error == "Restart runtime is missing: .venv/Scripts/python.exe" + assert events == [] @pytest.mark.asyncio @@ -3426,12 +3212,12 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): FileNotFoundError("python.exe not found"), ), ) - monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) progresses = [step async for step in updater.perform_update("2026.4.1")] assert progresses[-1].stage == "error" - assert "Failed to build restart command" in progresses[-1].message + assert "restarting" not in [step.stage for step in progresses] + assert "Failed to prepare restart handoff" in progresses[-1].message assert "handover" not in events @@ -3488,9 +3274,7 @@ async def fake_run_async(cmd, cwd=None, timeout=None, env=None): monkeypatch.setattr(updater, "_write_version_marker", lambda _v: None) monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _root: None) monkeypatch.setattr(updater, "_build_restart_argv", lambda install_root=None: [r"C:\tool\python.exe", "-m", "flocks.cli.main"]) - monkeypatch.setattr(updater, "_prepare_upgrade_handover", lambda _version: events.append("handover")) monkeypatch.setattr(updater, "_handoff_service_config", lambda: service_manager.ServiceConfig()) - monkeypatch.setattr(updater, "rollback_upgrade_handover", lambda: events.append("rollback_handover")) monkeypatch.setattr( updater, "_spawn_restart_handoff", diff --git a/tests/updater/test_updater_console_manifest_bundle.py b/tests/updater/test_updater_console_manifest_bundle.py index 118c75f55..c43ed1c58 100644 --- a/tests/updater/test_updater_console_manifest_bundle.py +++ b/tests/updater/test_updater_console_manifest_bundle.py @@ -1,7 +1,8 @@ from __future__ import annotations -import zipfile import json +import sys +import zipfile from datetime import UTC, datetime, timedelta from types import SimpleNamespace @@ -527,6 +528,7 @@ async def _after_uninstall(): monkeypatch.setattr(updater, "_is_pro_component_installed", lambda: True) monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None) monkeypatch.setattr(updater, "_uninstall_pro_component", _fake_uninstall_pro_component) + monkeypatch.setattr(updater, "_write_version_marker", lambda _version: None) monkeypatch.setattr(updater, "_refresh_global_cli_entry", lambda _install_root: None) progresses = [ @@ -545,6 +547,68 @@ async def _after_uninstall(): assert len(archived_marker) == 1 +@pytest.mark.asyncio +async def test_perform_pro_bundle_downgrade_uses_restart_handoff_spawn( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from flocks.updater import deploy as deploy_mod + + flocks_root = tmp_path / "flocks-root" + run_dir = flocks_root / "run" + run_dir.mkdir(parents=True) + (run_dir / "pro-bundle-installed.json").write_text( + json.dumps( + { + "bundle_version": "v2026.6.24", + "core_version": "v2026.6.21", + "flockspro_component_version": "v2026.6.24-pro", + "installed_at": "2026-06-24T08:00:00+00:00", + } + ), + encoding="utf-8", + ) + install_root = tmp_path / "install-root" + install_root.mkdir() + handoff_argv = [sys.executable, "-m", "flocks.updater.restart_handoff"] + spawn_calls: list[tuple[list[str], Path]] = [] + + async def fake_uninstall_pro_component(*, uv_path, install_root, env): + return None + + async def fake_sleep(_seconds: float) -> None: + return None + + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + monkeypatch.setattr(deploy_mod, "detect_deploy_mode", lambda: "source") + monkeypatch.setattr(updater, "_get_repo_root", lambda: install_root) + monkeypatch.setattr(updater, "get_current_version", lambda: "2026.6.24") + monkeypatch.setattr(updater, "_is_pro_component_installed", lambda: True) + monkeypatch.setattr(updater, "_find_executable", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr(updater, "_uninstall_pro_component", fake_uninstall_pro_component) + monkeypatch.setattr(updater, "_write_version_marker", lambda _version: None) + monkeypatch.setattr(updater, "_build_restart_argv", lambda _install_root: [sys.executable]) + monkeypatch.setattr(updater, "_build_restart_handoff_argv", lambda *_args, **_kwargs: handoff_argv) + monkeypatch.setattr(updater.asyncio, "sleep", fake_sleep) + monkeypatch.setattr( + updater, + "_spawn_restart_handoff", + lambda argv, *, cwd: spawn_calls.append((argv, cwd)) or SimpleNamespace(pid=4321), + ) + monkeypatch.setattr( + updater.subprocess, + "Popen", + lambda *_args, **_kwargs: pytest.fail("downgrade must use the restart handoff spawn helper"), + ) + monkeypatch.setattr(updater.os, "_exit", lambda code: (_ for _ in ()).throw(SystemExit(code))) + + with pytest.raises(SystemExit, match="0"): + async for _step in updater.perform_pro_bundle_downgrade(): + pass + + assert spawn_calls == [(handoff_argv, install_root)] + + @pytest.mark.asyncio async def test_load_console_session_token_falls_back_to_shared_session( monkeypatch: pytest.MonkeyPatch, @@ -811,7 +875,7 @@ def stream(self, method, url, headers=None): @pytest.mark.asyncio -async def test_perform_pro_bundle_install_replaces_core_and_installs_wheel( +async def test_core_pro_bundle_requires_source_upgrade_handoff( monkeypatch: pytest.MonkeyPatch, tmp_path, ) -> None: @@ -884,20 +948,12 @@ async def _fake_run_async(cmd, **_kwargs): monkeypatch.setattr(updater, "_run_async", _fake_run_async) progresses = [step async for step in updater.perform_pro_bundle_install(restart=False)] - assert progresses[-1].stage == "done" - assert (install_root / "new_core.py").is_file() - assert not (install_root / "old_core.py").exists() - assert any(cmd[:2] == ["/usr/bin/uv", "sync"] for cmd in captured) - pip_installs = [cmd for cmd in captured if cmd[:3] == ["/usr/bin/uv", "pip", "install"]] - assert pip_installs - assert "--no-deps" in pip_installs[-1] - assert str(wheel.name) in pip_installs[-1][-1] - marker = tmp_path / "flocks-root" / "run" / "pro-bundle-installed.json" - assert marker.is_file() - marker_payload = __import__("json").loads(marker.read_text(encoding="utf-8")) - assert version_writes == ["2026.5.10"] - assert marker_payload["bundle_version"] == "v2026.5.11" - assert marker_payload["core_version"] == "v2026.5.10" + assert progresses[-1].stage == "error" + assert "detached handoff" in progresses[-1].message + assert not (install_root / "new_core.py").exists() + assert (install_root / "old_core.py").exists() + assert captured == [] + assert version_writes == [] @pytest.mark.asyncio @@ -977,6 +1033,8 @@ async def _fake_run_async(cmd, **_kwargs): progresses = [step async for step in updater.perform_pro_bundle_install(restart=False)] assert progresses[-1].stage == "done" + assert "backing_up" not in [step.stage for step in progresses] + assert "syncing" not in [step.stage for step in progresses] assert any("Keeping local Flocks v2026.6.18" in step.message for step in progresses) assert (install_root / "current_core.py").is_file() assert not (install_root / "older_core.py").exists() diff --git a/webui/src/locales/en-US/update.json b/webui/src/locales/en-US/update.json index e775bc060..a8a6d9111 100644 --- a/webui/src/locales/en-US/update.json +++ b/webui/src/locales/en-US/update.json @@ -37,7 +37,6 @@ "fetchingPro": "Downloading Pro bundle", "backing_up": "Backing up current version", "applying": "Applying new version", - "syncing": "Syncing backend dependencies", "restarting": "Restarting service", "done": "Done", "error": "Error" diff --git a/webui/src/locales/zh-CN/update.json b/webui/src/locales/zh-CN/update.json index 5d8e90af9..68b0ecaec 100644 --- a/webui/src/locales/zh-CN/update.json +++ b/webui/src/locales/zh-CN/update.json @@ -37,7 +37,6 @@ "fetchingPro": "下载 Pro bundle", "backing_up": "备份当前版本", "applying": "应用新版本", - "syncing": "同步后端依赖", "restarting": "重启服务", "done": "完成", "error": "出错"