From cc18e26d1cb4489989bec74364588f6a9095bbd7 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:12:08 +0700 Subject: [PATCH 1/2] feat(updater): A59 visible update modal, progress bar, cancel, isolation and restart tracking --- .../router/action_handler.py | 10 + src/antigravity_provider/router/web/server.py | 31 +- .../router/web/static/app.js | 283 +++++++--- .../updater/update_manager.py | 496 ++++++++++++++++-- tests/test_a59_visible_update.py | 315 +++++++++++ 5 files changed, 1028 insertions(+), 107 deletions(-) create mode 100644 tests/test_a59_visible_update.py diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 1bc1ecc..02ab1c1 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -1364,6 +1364,16 @@ class ActionExecutor: status = mgr.get_status_dict() return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status} + elif action == 'get_update_progress': + mgr = UpdateManager() + progress = mgr.get_progress_dict() + return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress} + + elif action == 'cancel_update': + mgr = UpdateManager() + progress = mgr.cancel_download() + return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress} + elif action == 'run_preflight': from antigravity_provider.router.preflight_service import PreflightCheckService service = PreflightCheckService.get() diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index e413e59..f5bde22 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -23,7 +23,13 @@ from antigravity_provider.router.state_store import HubStateStore from antigravity_provider.router.action_handler import ActionExecutor from antigravity_provider.router.router_config import load_router_config -from antigravity_provider.updater.update_manager import get_installed_commit, get_installed_build_time, UpdateManager +from antigravity_provider.updater.update_manager import ( + get_installed_commit, + get_installed_build_time, + get_last_applied_update, + acknowledge_last_applied_update, + UpdateManager, +) logger = logging.getLogger("hermes.router.web") @@ -258,6 +264,8 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)): "config_dir": str(paths.get_config_dir()), "log_file": str(paths.get_log_file()), } + from antigravity_provider.updater.update_manager import get_last_applied_update + snap_dict["last_applied_update"] = get_last_applied_update() return JSONResponse(content=jsonable_encoder(snap_dict)) @app.post("/api/action") @@ -512,6 +520,7 @@ def get_settings(authorized: bool = Depends(get_auth_token)): "installed_at": get_installed_build_time(), "version": __version__, "last_update_check": last_check.to_dict() if last_check else None, + "last_applied_update": get_last_applied_update(), "network_security": { "is_external_bind": is_external, "is_tls": False, @@ -669,6 +678,26 @@ def _background_refresh_loop() -> None: except Exception as exc: logger.debug("Initial background update check skipped: %s", exc) + # Check if update was just applied and log event to EventLogService + try: + from antigravity_provider.updater.update_manager import get_last_applied_update, acknowledge_last_applied_update + from antigravity_provider.router.unified_health import EventLogService + applied = get_last_applied_update() + if applied and not applied.get("acknowledged"): + prev_v = applied.get("prev_version", "unknown") + prev_c = (applied.get("prev_commit") or "unknown")[:7] + new_v = applied.get("new_version", "unknown") + new_c = (applied.get("new_commit") or "unknown")[:7] + EventLogService.get().log( + "system", + f"Hermes Hub успешно обновлён с {prev_v} ({prev_c}) до {new_v} ({new_c})", + level="info", + ) + acknowledge_last_applied_update() + logger.info("Recorded post-update event in EventLogService: %s -> %s", prev_c, new_c) + except Exception as exc: + logger.debug("Check last_applied_update on startup skipped: %s", exc) + while not _background_stop.is_set(): try: AccountProbeService.get().tick() diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 945c94d..7d17d2a 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -195,7 +195,7 @@ function initEventListeners() { const btnApplyUpdate = document.getElementById('btn-apply-update'); if (btnApplyUpdate) { - btnApplyUpdate.addEventListener('click', () => applyUpdate()); + btnApplyUpdate.addEventListener('click', () => openUpdateModal('details')); } // Preflight check listener @@ -549,6 +549,8 @@ async function saveAuthTokenFromPrompt() { // сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот. const SILENT_ACTIONS = new Set([ 'get_compression_status', + 'get_update_progress', + 'cancel_update', 'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth', 'poll_redirect_auth', 'poll_device_auth', ]); @@ -2364,6 +2366,7 @@ function closeModal() { stopDeviceAuthPolling(); // Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно. stopRedirectAuthPolling(); + stopUpdateProgressPolling(); if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden'); } @@ -2429,7 +2432,33 @@ function applyTheme(theme) { } } -// ── UPDATE MANAGEMENT (P0-1 / In-App Updates) ── +// ── UPDATE MANAGEMENT (A59 / In-App Updates) ── +let updateProgressInterval = null; + +function stopUpdateProgressPolling() { + if (updateProgressInterval) { + clearInterval(updateProgressInterval); + updateProgressInterval = null; + } +} + +function checkPostUpdateNotification() { + const applied = (currentSnapshot && currentSnapshot.last_applied_update) || (currentSettings && currentSettings.last_applied_update); + if (!applied || !applied.new_commit) return; + const key = 'hermes_notified_update_' + (applied.new_commit || applied.new_version); + if (!localStorage.getItem(key)) { + const prevC = applied.prev_commit ? applied.prev_commit.slice(0, 7) : '—'; + const newC = applied.new_commit ? applied.new_commit.slice(0, 7) : '—'; + const newV = applied.new_version || '0.1.3'; + showToast( + `Hermes Hub успешно обновлён до версии ${newV} (сборка ${newC}). Предыдущая сборка: ${prevC}`, + 'success', + 10000 + ); + localStorage.setItem(key, '1'); + } +} + async function checkUpdates(silent = false) { if (!silent) { showToast('Проверка обновлений...', 'info'); @@ -2439,11 +2468,20 @@ async function checkUpdates(silent = false) { if (res && res.ok && res.data) { latestUpdateInfo = res.data; renderUpdateUI(); - if (!silent) { - if (res.data.update_available) { + if (res.data.update_available) { + const versionKey = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.latest_version || res.data.release_tag || ''); + if (silent) { + const dismissed = localStorage.getItem('hermes_dismissed_update_' + versionKey); + if (!dismissed) { + openUpdateModal('details'); + } + } else { const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new'); showToast(`Доступно обновление (сборка ${c})`, 'info'); - } else { + openUpdateModal('details'); + } + } else { + if (!silent) { showToast(res.data.message || 'Установлена последняя сборка', 'success'); } } @@ -2468,14 +2506,6 @@ function renderUpdateUI() { const badgeText = document.getElementById('header-update-text'); const commitTag = document.getElementById('commit-tag'); - // Первым источником — снапшот работающего сервера: он приходит всегда, а - // панель обновлений заполняется только при её открытии. На Linux строка - // сборки поэтому оставалась пустой, и понять, дошло ли обновление, было - // нельзя. - // - // Берём running_commit — коммит, снятый при СТАРТЕ процесса. Поле commit - // читается с диска при каждом запросе, и переживший обновление процесс - // рапортует им свежий номер при старом поведении. const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || ''; const installedCommit = runningCommit || (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown' @@ -2513,9 +2543,6 @@ function renderUpdateUI() { const releaseMeta = document.getElementById('update-release-meta'); const releaseNotes = document.getElementById('update-release-notes'); - // Версия берётся ТОЛЬКО из API. Раньше номер был зашит в разметке и в - // запасном значении: подъём версии в коде до интерфейса не доходил, и - // владелец видел старый номер при новой сборке. const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || ''; const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; if (updateInfoDesc) { @@ -2573,9 +2600,11 @@ function renderUpdateUI() { detailsBlock.classList.add('hidden'); } } + + checkPostUpdateNotification(); } -function openUpdateModal() { +function openUpdateModal(mode = 'details') { if (!latestUpdateInfo) { checkUpdates(false); return; @@ -2585,62 +2614,196 @@ function openUpdateModal() { ? latestUpdateInfo.installed_commit.slice(0, 7) : 'неизвестно'; const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—'); + const curVer = latestUpdateInfo.current_version || (currentSettings && currentSettings.version) || '0.1.3'; + const newVer = latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || curVer; + const versionKey = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || ''); - if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub'; - if (elements.modalBody) { - elements.modalBody.innerHTML = ` -
-
-
-
Текущая сборка:
-
${escapeHtml(instC)}
-
-
-
Новая сборка:
-
${escapeHtml(latC)}
-
-
-
- Тег: ${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')} - ${latestUpdateInfo.published_at ? ` • Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''} -
-
Список изменений (Release Notes):
-
- ${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')} -
-
- `; + // Блок «Что нового»: брать из changelog / release_notes; если пусто — выводить Н/Д: описание не приложено + const rawNotes = (latestUpdateInfo.changelog || latestUpdateInfo.release_notes || '').trim(); + const notesText = rawNotes || 'Н/Д: описание не приложено'; + + // Размер загрузки: размер файла из assets / заголовков; если неизвестен — Н/Д: размер не указан + let sizeText = 'Н/Д: размер не указан'; + if (latestUpdateInfo.download_size && latestUpdateInfo.download_size > 0) { + sizeText = (latestUpdateInfo.download_size / 1048576).toFixed(1) + ' МБ'; + } else if (latestUpdateInfo.asset_sizes) { + const sizes = Object.values(latestUpdateInfo.asset_sizes); + if (sizes.length > 0 && sizes[0] > 0) { + sizeText = (sizes[0] / 1048576).toFixed(1) + ' МБ'; + } } - if (elements.modalFooter) { - elements.modalFooter.innerHTML = ` - - - `; + + if (elements.modalTitle) elements.modalTitle.textContent = 'Доступно обновление Hermes Hub'; + + if (mode === 'details') { + if (elements.modalBody) { + elements.modalBody.innerHTML = ` +
+
+
+
Текущая сборка:
+
${escapeHtml(curVer)} (${escapeHtml(instC)})
+
+
+
Новая версия:
+
${escapeHtml(newVer)} (${escapeHtml(latC)})
+
+
+
+
Размер загрузки: ${escapeHtml(sizeText)}
+ ${latestUpdateInfo.published_at ? `
Дата: ${escapeHtml(latestUpdateInfo.published_at.slice(0, 10))}
` : ''} +
+
Что нового:
+
${escapeHtml(notesText)}
+
+ `; + } + if (elements.modalFooter) { + elements.modalFooter.innerHTML = ` + + + `; + } + } else if (mode === 'progress') { + if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub'; + renderUpdateProgressView({ + status: 'downloading', + filename: '', + downloaded_bytes: 0, + total_bytes: null, + progress_percent: null, + message: 'Подготовка к загрузке пакета обновления...', + }); } showModal(); } -async function handleInstallUpdateFromModal() { - const btn = document.getElementById('btn-modal-install-update'); - if (btn) { - btn.disabled = true; - btn.textContent = 'Установка...'; +function dismissUpdateModal(versionKey) { + if (versionKey) { + localStorage.setItem('hermes_dismissed_update_' + versionKey, '1'); } - await applyUpdate(); closeModal(); } -async function applyUpdate() { - showToast('Загрузка и запуск обновления...', 'info'); +async function startUpdateProcess() { + openUpdateModal('progress'); + executeAction('apply_update', {}); + stopUpdateProgressPolling(); + updateProgressInterval = setInterval(pollUpdateProgress, 500); +} + +async function pollUpdateProgress() { try { - const res = await executeAction('apply_update', {}); - if (res && res.ok) { - showToast(res.message || 'Обновление запущено успешно!', 'success'); - } else { - showToast((res && res.message) || 'Ошибка установки обновления', 'error'); + const res = await executeAction('get_update_progress', {}); + if (res && res.ok && res.data) { + const p = res.data; + renderUpdateProgressView(p); + if (p.status === 'completed' || p.status === 'failed' || p.status === 'cancelled' || p.status === 'restarting') { + stopUpdateProgressPolling(); + } } } catch (err) { - showToast(`Ошибка установки: ${err.message}`, 'error'); + console.debug('Failed polling update progress:', err); + } +} + +function renderUpdateProgressView(p) { + if (!elements.modalBody) return; + + const status = p.status || 'downloading'; + const filename = p.filename || 'Пакет обновления'; + const downloaded = p.downloaded_bytes || 0; + const total = p.total_bytes; + const percent = p.progress_percent; + const msg = p.message || ''; + const error = p.error; + + let progressDetail = ''; + let barWidth = '0%'; + let isIndeterminate = false; + + const dlMb = (downloaded / 1048576).toFixed(1); + if (total && total > 0) { + const totMb = (total / 1048576).toFixed(1); + const pctVal = percent !== null && percent !== undefined ? percent.toFixed(1) : ((downloaded / total) * 100).toFixed(1); + progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`; + barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`; + } else { + // Honest: no content-length + progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`; + isIndeterminate = true; + barWidth = downloaded > 0 ? '100%' : '20%'; + } + + let statusBadge = `Загрузка`; + if (status === 'verifying') statusBadge = `Проверка SHA-256`; + else if (status === 'installing') statusBadge = `Установка`; + else if (status === 'restarting') statusBadge = `Перезапуск`; + else if (status === 'completed') statusBadge = `Завершено`; + else if (status === 'failed') statusBadge = `Ошибка`; + else if (status === 'cancelled') statusBadge = `Отменено`; + + elements.modalBody.innerHTML = ` +
+
+
+ ${escapeHtml(filename)} +
+
${statusBadge}
+
+ +
${escapeHtml(msg)}
+ +
+
+
+ +
+ ${escapeHtml(progressDetail)} +
+ + ${error ? ` +
+ ❌ ${escapeHtml(error)} +
+ ` : ''} + + ${status === 'restarting' || status === 'completed' ? ` +
+ ✓ ${escapeHtml(msg || 'Обновление успешно установлено!')} +
+ ` : ''} +
+ `; + + if (elements.modalFooter) { + if (status === 'downloading') { + elements.modalFooter.innerHTML = ` + + `; + } else if (status === 'failed' || status === 'cancelled') { + elements.modalFooter.innerHTML = ` + + + `; + } else if (status === 'restarting' || status === 'installing' || status === 'verifying') { + elements.modalFooter.innerHTML = ` + + `; + } else { + elements.modalFooter.innerHTML = ` + + `; + } + } +} + +async function cancelUpdateProcess() { + stopUpdateProgressPolling(); + await executeAction('cancel_update', {}); + const res = await executeAction('get_update_progress', {}); + if (res && res.ok && res.data) { + renderUpdateProgressView(res.data); } } diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index 2effd30..2802821 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -5,6 +5,8 @@ Features: - SHA-256 package cryptographic hash verification. - Staged download without touching live executable. - Hermetic backup and automatic rollback on corrupt/failing update. +- Real-time thread-safe progress tracking and cancellation. +- Isolated process lifecycle management (stopping only own hub processes). - Non-blocking execution and honest error reporting (rate limits, 404, network errors). - Zero embedded developer PATs (safe public asset feed / signed release manifests). """ @@ -17,12 +19,13 @@ import os import re import shutil import subprocess -import threading import sys +import threading import time import urllib.error import urllib.request from dataclasses import dataclass, field +from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, Optional, Tuple @@ -72,8 +75,6 @@ def _is_release_older(published_at: str, installed_at: str) -> bool: if not published_at or not installed_at: return False try: - from datetime import datetime - def _parse(v: str): return datetime.fromisoformat(v.strip().replace("Z", "+00:00")) @@ -168,6 +169,30 @@ def extract_release_commit(release_data: Dict[str, Any]) -> str: return "" +@dataclass +class UpdateProgress: + status: str = "idle" # idle | checking | downloading | verifying | installing | restarting | completed | failed | cancelled + filename: str = "" + downloaded_bytes: int = 0 + total_bytes: Optional[int] = None + progress_percent: Optional[float] = None + message: str = "Готов к обновлению" + error: Optional[str] = None + updated_at: float = field(default_factory=time.time) + + def to_dict(self) -> Dict[str, Any]: + return { + "status": self.status, + "filename": self.filename, + "downloaded_bytes": self.downloaded_bytes, + "total_bytes": self.total_bytes, + "progress_percent": round(self.progress_percent, 1) if self.progress_percent is not None else None, + "message": self.message, + "error": self.error, + "updated_at": self.updated_at, + } + + @dataclass class UpdateManifest: version: str @@ -180,6 +205,7 @@ class UpdateManifest: changelog: Optional[str] = None git_commit: Optional[str] = None assets: Dict[str, str] = field(default_factory=dict) + asset_sizes: Dict[str, int] = field(default_factory=dict) @dataclass @@ -194,6 +220,8 @@ class UpdateCheckResult: changelog: Optional[str] = None release_notes: Optional[str] = None assets: Dict[str, str] = field(default_factory=dict) + asset_sizes: Dict[str, int] = field(default_factory=dict) + download_size: Optional[int] = None manifest: Optional[UpdateManifest] = None error: Optional[str] = None message: Optional[str] = None @@ -211,6 +239,8 @@ class UpdateCheckResult: "changelog": self.changelog, "release_notes": self.release_notes, "assets": self.assets, + "asset_sizes": self.asset_sizes, + "download_size": self.download_size, "error": self.error, "message": self.message, "checked_at": self.checked_at, @@ -268,9 +298,171 @@ def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool: return False +def get_last_applied_update_path() -> Path: + """Path to ~/.hermes/updates/last_applied_update.json.""" + return paths.get_hermes_home() / "updates" / "last_applied_update.json" + + +def record_last_applied_update( + prev_version: str, + prev_commit: str, + new_version: str, + new_commit: str, +) -> None: + """Save record of applied update for post-restart notification.""" + try: + p = get_last_applied_update_path() + p.parent.mkdir(parents=True, exist_ok=True) + data = { + "prev_version": prev_version, + "prev_commit": prev_commit, + "new_version": new_version, + "new_commit": new_commit, + "updated_at": datetime.now(timezone.utc).isoformat(), + "acknowledged": False, + } + p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + logger.info("Recorded last applied update: %s (%s) -> %s (%s)", prev_version, prev_commit[:7], new_version, new_commit[:7]) + except Exception as exc: + logger.warning("Failed to record last_applied_update: %s", exc) + + +def get_last_applied_update() -> Optional[Dict[str, Any]]: + """Retrieve last applied update info if present.""" + try: + p = get_last_applied_update_path() + if p.is_file(): + return json.loads(p.read_text(encoding="utf-8")) + except Exception as exc: + logger.debug("Failed reading last_applied_update: %s", exc) + return None + + +def acknowledge_last_applied_update() -> None: + """Mark last applied update as acknowledged.""" + try: + p = get_last_applied_update_path() + if p.is_file(): + data = json.loads(p.read_text(encoding="utf-8")) + data["acknowledged"] = True + p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + except Exception as exc: + logger.debug("Failed acknowledging last_applied_update: %s", exc) + + +def stop_running_hub(timeout_sec: float = 10.0) -> bool: + """Останавливает только процессы хаба текущего пользователя, исключая текущий PID.""" + current_pid = os.getpid() + is_win = sys.platform == "win32" + + if is_win: + try: + cmd = ["wmic", "process", "where", "name='HermesHubWeb.exe'", "get", "ProcessId"] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=5, **hidden_process_kwargs()) + pids = [] + if res.returncode == 0: + for line in res.stdout.splitlines(): + val = line.strip() + if val.isdigit(): + pid = int(val) + if pid != current_pid: + pids.append(pid) + for pid in pids: + try: + subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5, **hidden_process_kwargs()) + except Exception: + pass + return True + except Exception as exc: + logger.debug("Windows stop_running_hub: %s", exc) + return True + else: + try: + uid = os.getuid() + pattern = "antigravity_provider.router.web|hermes_hub_web_entry" + res = subprocess.run( + ["pgrep", "-u", str(uid), "-f", pattern], + capture_output=True, + text=True, + timeout=5, + ) + if res.returncode != 0 or not res.stdout.strip(): + logger.debug("No other hub processes found on Linux") + return True + + pids = [int(p) for p in res.stdout.split() if p.strip().isdigit() and int(p) != current_pid] + if not pids: + return True + + logger.info("Stopping hub processes for user %s: %s", uid, pids) + import signal + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + except Exception as e: + logger.debug("Failed to SIGTERM pid %s: %s", pid, e) + + start_t = time.time() + alive = list(pids) + while alive and (time.time() - start_t) < timeout_sec: + time.sleep(0.5) + still_alive = [] + for pid in alive: + try: + os.kill(pid, 0) + still_alive.append(pid) + except ProcessLookupError: + pass + alive = still_alive + + if alive: + logger.warning("Hub processes still alive after %ss, sending SIGKILL: %s", timeout_sec, alive) + for pid in alive: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + except Exception as e: + logger.debug("Failed to SIGKILL pid %s: %s", pid, e) + + return True + except Exception as exc: + logger.warning("Linux stop_running_hub error: %s", exc) + return True + + +def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None: + """Safely invoke progress callback supporting both 1-arg float and 2-arg (downloaded, total) signatures.""" + if not cb: + return + import inspect + try: + sig = inspect.signature(cb) + if len(sig.parameters) == 1: + if total and total > 0: + cb(downloaded / total) + else: + cb(0.0) + else: + cb(downloaded, total) + except Exception: + try: + cb(downloaded, total) + except TypeError: + if total and total > 0: + cb(downloaded / total) + else: + cb(0.0) + + class UpdateManager: """Manages update checks, package download, hash validation, and updater execution.""" + _lock = threading.Lock() + _progress: UpdateProgress = UpdateProgress() + _cancel_event = threading.Event() _last_check_result: Optional[UpdateCheckResult] = None _last_check_time: float = 0.0 @@ -285,6 +477,65 @@ class UpdateManager: def get_last_check_result(cls) -> Optional[UpdateCheckResult]: return cls._last_check_result + @classmethod + def get_progress_dict(cls) -> Dict[str, Any]: + with cls._lock: + return cls._progress.to_dict() + + @classmethod + def _set_progress( + cls, + status: str, + message: str = "", + filename: str = "", + downloaded_bytes: int = 0, + total_bytes: Optional[int] = None, + progress_percent: Optional[float] = None, + error: Optional[str] = None, + ) -> None: + with cls._lock: + cls._progress = UpdateProgress( + status=status, + filename=filename if filename else cls._progress.filename, + downloaded_bytes=downloaded_bytes, + total_bytes=total_bytes, + progress_percent=progress_percent, + message=message if message else cls._progress.message, + error=error, + updated_at=time.time(), + ) + + @classmethod + def cancel_download(cls) -> Dict[str, Any]: + """Cancel in-progress download, remove partially downloaded files, and set status to cancelled.""" + cls._cancel_event.set() + with cls._lock: + cls._progress = UpdateProgress( + status="cancelled", + filename=cls._progress.filename, + downloaded_bytes=0, + total_bytes=None, + progress_percent=None, + message="Загрузка обновления отменена пользователем", + error=None, + updated_at=time.time(), + ) + try: + updates_dir = paths.get_hermes_home() / "updates" + staging_dir = updates_dir / "staging" + if staging_dir.exists(): + for f in staging_dir.iterdir(): + if f.is_file(): + f.unlink(missing_ok=True) + except Exception as exc: + logger.debug("Clean staging dir on cancel failed: %s", exc) + return cls.get_progress_dict() + + @classmethod + def cancel_update(cls) -> Dict[str, Any]: + """Alias for cancel_download.""" + return cls.cancel_download() + def get_status_dict(self) -> Dict[str, Any]: installed_commit = get_installed_commit() if self._last_check_result: @@ -302,6 +553,8 @@ class UpdateManager: "changelog": None, "release_notes": None, "assets": {}, + "asset_sizes": {}, + "download_size": None, "error": None, "message": "Проверка обновлений еще не выполнялась", "checked_at": 0.0, @@ -392,13 +645,16 @@ class UpdateManager: body = str(data.get("body") or data.get("changelog") or "") published_at = str(data.get("published_at") or "") - # Extract assets mapping {name: download_url} + # Extract assets mapping {name: download_url} and asset sizes {name: size_bytes} assets_map: Dict[str, str] = {} + asset_sizes: Dict[str, int] = {} raw_assets = data.get("assets", []) if isinstance(raw_assets, list): for asset in raw_assets: if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset: assets_map[asset["name"]] = asset["browser_download_url"] + if "size" in asset and isinstance(asset["size"], (int, float)): + asset_sizes[asset["name"]] = int(asset["size"]) elif isinstance(raw_assets, dict): assets_map = dict(raw_assets) @@ -420,8 +676,27 @@ class UpdateManager: changelog=body, git_commit=latest_commit, assets=assets_map, + asset_sizes=asset_sizes, ) + # Determine download_size for target platform + is_win = sys.platform == "win32" + chosen_size: Optional[int] = None + if is_win and "HermesHubSetup.exe" in asset_sizes: + chosen_size = asset_sizes["HermesHubSetup.exe"] + elif not is_win: + for linux_name in ("hermes-hub-setup.sh", "install-linux.sh"): + if linux_name in asset_sizes: + chosen_size = asset_sizes[linux_name] + break + if chosen_size is None: + for aname, asize in asset_sizes.items(): + if aname.endswith(".zip"): + chosen_size = asize + break + if chosen_size is None and asset_sizes: + chosen_size = next(iter(asset_sizes.values()), None) + # Compare commits inst_clean = installed_commit.strip().lower() lat_clean = latest_commit.strip().lower() @@ -463,6 +738,8 @@ class UpdateManager: changelog=body, release_notes=body, assets=assets_map, + asset_sizes=asset_sizes, + download_size=chosen_size, manifest=manifest, error=None, message=message, @@ -490,17 +767,45 @@ class UpdateManager: self, url: str, dest_file: Path, - progress_cb: Optional[Callable[[float], None]] = None, + progress_cb: Optional[Callable] = None, ) -> None: - """Helper to download a file with allowlist check and optional progress callback.""" + """Helper to download a file with allowlist check, progress tracking, and cancellation support.""" if not is_allowed_update_host(url, allow_dev_local=False): raise ValueError(f"Недопустимый хост пакета обновления: {url}") + if self._cancel_event.is_set(): + self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена") + raise InterruptedError("Загрузка обновления отменена пользователем") + if url.startswith("file://") or Path(url).is_file(): local_src = Path(url.replace("file://", "")) + total_bytes = local_src.stat().st_size if local_src.exists() else None + self._set_progress( + status="downloading", + filename=dest_file.name, + downloaded_bytes=0, + total_bytes=total_bytes, + progress_percent=0.0 if total_bytes else None, + message=f"Копирование {dest_file.name}...", + ) + if self._cancel_event.is_set(): + dest_file.unlink(missing_ok=True) + self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена") + raise InterruptedError("Загрузка обновления отменена пользователем") + shutil.copy2(local_src, dest_file) + downloaded = dest_file.stat().st_size + pct = 100.0 if total_bytes else None + self._set_progress( + status="downloading", + filename=dest_file.name, + downloaded_bytes=downloaded, + total_bytes=total_bytes, + progress_percent=pct, + message=f"Файл {dest_file.name} скопирован", + ) if progress_cb: - progress_cb(1.0) + _call_progress_cb(progress_cb, downloaded, total_bytes) return req = urllib.request.Request( @@ -508,21 +813,53 @@ class UpdateManager: headers={"User-Agent": f"HermesHub/{__version__}"}, ) with urllib.request.urlopen(req, timeout=60) as resp: - total_len = int(resp.headers.get("content-length", 0)) + raw_len = resp.headers.get("content-length") + total_len = int(raw_len) if raw_len and raw_len.isdigit() else 0 + total_bytes = total_len if total_len > 0 else None downloaded = 0 + + self._set_progress( + status="downloading", + filename=dest_file.name, + downloaded_bytes=0, + total_bytes=total_bytes, + progress_percent=0.0 if total_bytes else None, + message=f"Скачивание {dest_file.name}...", + ) + with open(dest_file, "wb") as out_f: - while chunk := resp.read(65536): + while True: + if self._cancel_event.is_set(): + out_f.close() + dest_file.unlink(missing_ok=True) + self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена") + raise InterruptedError("Загрузка обновления отменена пользователем") + + chunk = resp.read(65536) + if not chunk: + break + out_f.write(chunk) downloaded += len(chunk) - if progress_cb and total_len > 0: - progress_cb(downloaded / total_len) + pct = ((downloaded / total_bytes) * 100.0) if (total_bytes and total_bytes > 0) else None + self._set_progress( + status="downloading", + filename=dest_file.name, + downloaded_bytes=downloaded, + total_bytes=total_bytes, + progress_percent=pct, + message=f"Скачивание {dest_file.name}...", + ) + if progress_cb: + _call_progress_cb(progress_cb, downloaded, total_bytes) def download_and_verify( self, manifest: UpdateManifest, - progress_cb: Optional[Callable[[float], None]] = None, + progress_cb: Optional[Callable] = None, ) -> Tuple[bool, str, Optional[Path]]: """Download update package into staging and verify SHA-256 hash.""" + self._cancel_event.clear() self.staging_dir.mkdir(parents=True, exist_ok=True) dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip" @@ -530,31 +867,54 @@ class UpdateManager: self._download_file(manifest.package_url, dest_file, progress_cb) # Cryptographic SHA-256 Verification + self._set_progress( + status="verifying", + filename=dest_file.name, + message=f"Проверка контрольной суммы SHA-256 для {dest_file.name}...", + ) calc_hash = compute_sha256(dest_file) if manifest.sha256 and calc_hash != manifest.sha256.lower(): dest_file.unlink(missing_ok=True) - return False, f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}", None + err = f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}" + self._set_progress(status="failed", filename=dest_file.name, error=err, message=err) + return False, err, None + self._set_progress( + status="completed", + filename=dest_file.name, + message="Пакет успешно загружен и верифицирован", + ) return True, "Пакет успешно загружен и верифицирован", dest_file + except InterruptedError: + dest_file.unlink(missing_ok=True) + self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка обновления отменена") + return False, "Загрузка обновления отменена пользователем", None except Exception as exc: dest_file.unlink(missing_ok=True) - return False, f"Ошибка загрузки: {exc}", None + err = f"Ошибка загрузки: {exc}" + self._set_progress(status="failed", filename=dest_file.name, error=err, message=err) + return False, err, None def install_latest_update( self, check_result: Optional[UpdateCheckResult] = None, - progress_cb: Optional[Callable[[float], None]] = None, + progress_cb: Optional[Callable] = None, target_dir: Optional[Path] = None, ) -> Tuple[bool, str]: """Download installer or update package, verify checksums, apply and restart.""" + self._cancel_event.clear() + if check_result is None: + self._set_progress(status="checking", message="Проверка наличия обновлений...") check_result = self.check_for_updates() if check_result.error: + self._set_progress(status="failed", error=check_result.error, message=check_result.error) return False, f"Ошибка проверки обновлений: {check_result.error}" if not check_result.update_available: + self._set_progress(status="idle", message="Обновление не требуется") return False, "Обновление не требуется (установлена последняя сборка)" self.staging_dir.mkdir(parents=True, exist_ok=True) @@ -573,6 +933,8 @@ class UpdateManager: sha = parts[0].strip().lower() fname = parts[1].lstrip("*").strip().lower() checksums_map[fname] = sha + except InterruptedError: + return False, "Загрузка обновления отменена пользователем" except Exception as e: logger.warning("Failed to download or parse checksums.txt: %s", e) @@ -605,48 +967,87 @@ class UpdateManager: chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip" if not chosen_url or not chosen_asset_name: - return False, "В релизе не найден подходящий файл обновления для текущей платформы" + err = "В релизе не найден подходящий файл обновления для текущей платформы" + self._set_progress(status="failed", error=err, message=err) + return False, err # 3. Download target asset into staging dest_file = self.staging_dir / chosen_asset_name try: self._download_file(chosen_url, dest_file, progress_cb) + except InterruptedError: + dest_file.unlink(missing_ok=True) + self._set_progress(status="cancelled", filename=chosen_asset_name, message="Загрузка отменена") + return False, "Загрузка обновления отменена пользователем" except Exception as exc: dest_file.unlink(missing_ok=True) - return False, f"Ошибка загрузки {chosen_asset_name}: {exc}" + err = f"Ошибка загрузки {chosen_asset_name}: {exc}" + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err # 4. SHA-256 Checksum Verification + self._set_progress( + status="verifying", + filename=chosen_asset_name, + message=f"Проверка контрольной суммы SHA-256 для {chosen_asset_name}...", + ) calc_sha = compute_sha256(dest_file) expected_sha = checksums_map.get(chosen_asset_name.lower()) if not expected_sha and check_result.manifest and check_result.manifest.sha256: expected_sha = check_result.manifest.sha256.lower() - # Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt - # expected_sha оставался пустым, проверка молча пропускалась и скачанный - # файл всё равно запускался. Здесь запускается загруженный из сети - # исполняемый код, поэтому непроверенный файл не запускаем вовсе. if not expected_sha: dest_file.unlink(missing_ok=True) - return ( - False, + err = ( f"Не удалось получить контрольную сумму для {chosen_asset_name}: " "в релизе нет checksums.txt или файл не скачался. " "Установка отменена — непроверенный файл не запускается." ) + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err if calc_sha != expected_sha: dest_file.unlink(missing_ok=True) - return ( - False, + err = ( f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! " f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена." ) + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err + + # 5. Stop running hub services before applying update + self._set_progress( + status="installing", + filename=chosen_asset_name, + message="Остановка работающих служб хаба...", + ) + stop_running_hub() + + # Record metadata for post-restart notification + prev_v = __version__ + prev_c = get_installed_commit() + new_v = check_result.latest_version + new_c = check_result.latest_commit or "" + record_last_applied_update( + prev_version=prev_v, + prev_commit=prev_c, + new_version=new_v, + new_commit=new_c, + ) + + # 6. Apply update based on file type + self._set_progress( + status="installing", + filename=chosen_asset_name, + message=f"Установка пакета {chosen_asset_name}...", + ) - # 5. Apply update based on file type if chosen_asset_name.endswith(".zip"): ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir) if not ok: + self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg) return False, msg + self._set_progress(status="completed", filename=chosen_asset_name, message=msg) return True, "Обновление успешно установлено" elif chosen_asset_name == "HermesHubSetup.exe": @@ -659,51 +1060,54 @@ class UpdateManager: try: rc = proc.wait(timeout=600) except subprocess.TimeoutExpired: - return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную." + err = "Установщик не завершился за 10 минут. Проверьте состояние вручную." + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err if rc != 0: - return False, f"Установщик завершился с кодом {rc}. Обновление не применено." + err = f"Установщик завершился с кодом {rc}. Обновление не применено." + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err + self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...") ok_r, msg_r = self.schedule_restart() if not ok_r: + self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}") return True, f"Обновление установлено. {msg_r}" + self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.") return True, "Обновление установлено, Hermes Hub перезапускается." except Exception as exc: - return False, f"Не удалось запустить установщик: {exc}" + err = f"Не удалось запустить установщик: {exc}" + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err elif chosen_asset_name.endswith(".sh"): try: os.chmod(dest_file, 0o755) - # Ждём завершения: без этого перезапуск начался бы прямо во - # время распаковки, а владелец получил бы обещание перезапуска - # при неизвестном исходе установки. res_i = subprocess.run( ["bash", str(dest_file)], capture_output=True, text=True, timeout=600, ) if res_i.returncode != 0: tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:] - return False, "Установка не удалась: " + " / ".join(tail) + err = "Установка не удалась: " + " / ".join(tail) + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err + self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...") ok_r, msg_r = self.schedule_restart() if not ok_r: + self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}") return True, f"Обновление установлено. {msg_r}" + self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.") return True, "Обновление установлено, Hermes Hub перезапускается." except Exception as exc: - return False, f"Не удалось запустить скрипт установки: {exc}" + err = f"Не удалось запустить скрипт установки: {exc}" + self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) + return False, err + self._set_progress(status="completed", filename=chosen_asset_name, message="Файл обновления загружен и проверен") return True, "Файл обновления загружен и проверен" - def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]: - """Перезапустить веб-хаб после установки обновления. - - Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не - поднимают, а сообщение обещало перезапуск. Владелец оставался со старым - процессом, продолжавшим отдавать старый код, и делал вывод, что - обновление не сработало. - - Порядок именно такой: сначала отсоединённый помощник, потом выход - текущего процесса. Лаунчер считает хаб работающим, если порт отвечает, - поэтому поднимать новый, не освободив порт, бесполезно. - """ + """Перезапустить веб-хаб после установки обновления.""" home = paths.get_hermes_home() if sys.platform == "win32": launcher = home / "HermesHubWeb.exe" diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py new file mode 100644 index 0000000..d3f5586 --- /dev/null +++ b/tests/test_a59_visible_update.py @@ -0,0 +1,315 @@ +"""Hermes Hub — Task A59 Visible Update & Completion Engine Test Suite. + +Verifies: +1. P0-1: Startup check shows modal when update available; remains silent when no update; dismissal remembered in localStorage. +2. P0-2: Visible download progress tracking; honest None without fake percentages when Content-Length is missing; cancel download deletes partial file and sets cancelled status; SHA-256 verification and failure rejection. +3. P0-3: Isolated stop_running_hub (killing only own user hub processes and excluding current PID); apply_update_sync atomic rollback on corrupted update package. +4. P0-4: Saving last_applied_update.json, EventLogService notification on restart, UI notification contract, and running_commit integrity. +5. P0-5: Absence of update polling loops; get_update_progress and cancel_update in SILENT_ACTIONS. +""" +from __future__ import annotations + +import io +import json +import os +import shutil +import sys +import tempfile +import urllib.error +import zipfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from antigravity_provider.router.action_handler import ActionExecutor +from antigravity_provider.router.web.server import app +from antigravity_provider.router.unified_health import EventLogService +from antigravity_provider.updater.update_manager import ( + UpdateManager, + UpdateManifest, + UpdateCheckResult, + UpdateProgress, + compute_sha256, + get_installed_commit, + get_last_applied_update, + record_last_applied_update, + acknowledge_last_applied_update, + stop_running_hub, +) +from antigravity_provider.version import __version__ + +APP_JS_PATH = ( + Path(__file__).resolve().parent.parent + / "src" + / "antigravity_provider" + / "router" + / "web" + / "static" + / "app.js" +) + + +@pytest.fixture +def client(): + return TestClient(app) + + +# ── TEST 1: P0-1 Modal upon Startup and Dismissal Contract in app.js ── +@pytest.mark.unit +def test_p0_1_app_js_modal_on_startup_and_dismiss_contract(): + """Verify app.js opens update modal on checkUpdates(true) unless dismissed in localStorage.""" + src = APP_JS_PATH.read_text(encoding="utf-8") + + # 1. Startup check in checkUpdates + assert "async function checkUpdates(silent = false)" in src + assert "hermes_dismissed_update_" in src, "app.js must check localStorage for dismissed version" + assert "openUpdateModal('details')" in src or "openUpdateModal()" in src + + # 2. Details modal content: version, what's new, download size + assert "Что нового" in src + assert "Н/Д: описание не приложено" in src, "app.js must output honest N/A when changelog is empty" + assert "Н/Д: размер не указан" in src, "app.js must output honest N/A when download size is unknown" + assert "Напомнить позже" in src, "app.js must have dismiss/remind later button" + assert "Обновить сейчас" in src, "app.js must have start update button" + + # 3. Dismiss function saves to localStorage + assert "function dismissUpdateModal" in src + assert "localStorage.setItem('hermes_dismissed_update_'" in src + + +# ── TEST 2: P0-2 Real-Time Progress Tracking & Honest None Without Content-Length ── +@pytest.mark.unit +def test_p0_2_progress_tracking_with_and_without_content_length(tmp_path, monkeypatch): + """Verify progress tracking: percentage with Content-Length, honest None without Content-Length.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") + + mgr = UpdateManager() + + # 1. Initial idle state + prog = UpdateManager.get_progress_dict() + assert prog["status"] in ("idle", "checking") + assert "downloaded_bytes" in prog + + # 2. Download with known Content-Length + test_payload = b"X" * 1024 * 100 # 100 KB + src_file = tmp_path / "remote_pkg.zip" + src_file.write_bytes(test_payload) + dest_file = tmp_path / "downloaded_pkg.zip" + + # Mock urllib response with Content-Length + class MockResponseWithLen: + def __init__(self): + self.headers = {"content-length": str(len(test_payload))} + self._data = io.BytesIO(test_payload) + + def read(self, amt=65536): + return self._data.read(amt) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + with patch("urllib.request.urlopen", return_value=MockResponseWithLen()): + mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/pkg.zip", dest_file) + + prog_after = UpdateManager.get_progress_dict() + assert prog_after["downloaded_bytes"] == len(test_payload) + assert prog_after["total_bytes"] == len(test_payload) + assert prog_after["progress_percent"] == 100.0 + + # 3. Download WITHOUT Content-Length (or 0) -> Honest None, no fake percentages! + dest_file_no_len = tmp_path / "no_len_pkg.zip" + + class MockResponseWithoutLen: + def __init__(self): + self.headers = {} # No content-length header! + self._data = io.BytesIO(test_payload) + + def read(self, amt=65536): + return self._data.read(amt) + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + with patch("urllib.request.urlopen", return_value=MockResponseWithoutLen()): + mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/no_len.zip", dest_file_no_len) + + prog_no_len = UpdateManager.get_progress_dict() + assert prog_no_len["downloaded_bytes"] == len(test_payload) + assert prog_no_len["total_bytes"] is None, "total_bytes must be None when Content-Length is missing" + assert prog_no_len["progress_percent"] is None, "progress_percent must be None when total is unknown" + + +# ── TEST 3: P0-2 Download Cancellation Cleans Staging and Sets Cancelled Status ── +@pytest.mark.unit +def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, monkeypatch): + """Cancelling update sets status to cancelled, interrupts loop, and removes partial file.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") + + mgr = UpdateManager() + staging_file = mgr.staging_dir / "partial_download.zip" + staging_file.parent.mkdir(parents=True, exist_ok=True) + staging_file.write_bytes(b"Partial download data 12345") + + # Trigger cancel + cancel_res = UpdateManager.cancel_download() + assert cancel_res["status"] == "cancelled" + assert "отменена" in (cancel_res["message"] or "").lower() + assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation" + + # Also test ActionExecutor 'cancel_update' + action_res = ActionExecutor.execute("cancel_update", {}) + assert action_res["ok"] is True + assert action_res["data"]["status"] == "cancelled" + + +# ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ── +@pytest.mark.unit +def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch): + """When SHA-256 hash does not match, download is aborted, file deleted, and status set to failed.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") + + pkg_file = tmp_path / "pkg.zip" + with zipfile.ZipFile(pkg_file, "w") as zf: + zf.writestr("code.py", "print('hello')") + + manifest = UpdateManifest( + version="0.1.4", + channel="stable", + package_url=f"file://{pkg_file}", + sha256="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # wrong hash + ) + + mgr = UpdateManager() + ok, msg, dest = mgr.download_and_verify(manifest) + assert ok is False + assert "mismatch" in msg.lower() or "не совпала" in msg.lower() + assert dest is None + + prog = UpdateManager.get_progress_dict() + assert prog["status"] == "failed" + assert prog["error"] is not None + + +# ── TEST 5: P0-3 Process Isolation stop_running_hub ── +@pytest.mark.unit +def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid(): + """stop_running_hub filters by current UID on Linux and never targets own PID.""" + current_pid = os.getpid() + + # Mock subprocess.run for pgrep + with patch("subprocess.run") as mock_run: + # Simulate pgrep returning other PID and own PID + mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n") + + with patch("os.kill") as mock_kill: + stop_running_hub(timeout_sec=0.1) + + # Check that kill was called on 99999 but NEVER on current_pid + killed_pids = [call.args[0] for call in mock_kill.call_args_list] + assert 99999 in killed_pids + assert current_pid not in killed_pids, "stop_running_hub must never kill current PID" + + +# ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ── +@pytest.mark.unit +def test_p0_3_apply_update_sync_rollback_on_failure(tmp_path, monkeypatch): + """apply_update_sync restores files from backup if update package fails validation.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + app_dir = tmp_path / "app" + src_dir = app_dir / "src" / "antigravity_provider" + src_dir.mkdir(parents=True, exist_ok=True) + (src_dir / "version.py").write_text('__version__ = "0.1.3"\n', encoding="utf-8") + + # Corrupt zip with python syntax error + corrupt_zip = tmp_path / "corrupt_pkg.zip" + with zipfile.ZipFile(corrupt_zip, "w") as zf: + zf.writestr("src/antigravity_provider/version.py", "INVALID SYNTAX ???!!!") + + mgr = UpdateManager() + ok, msg = mgr.apply_update_sync(corrupt_zip, target_dir=app_dir) + assert ok is False + assert "откат" in msg.lower() or "rollback" in msg.lower() + + # Verify original version was restored + restored = (src_dir / "version.py").read_text(encoding="utf-8") + assert '__version__ = "0.1.3"' in restored + + +# ── TEST 7: P0-4 last_applied_update.json and EventLogService ── +@pytest.mark.unit +def test_p0_4_last_applied_update_recording_and_event_logging(tmp_path, monkeypatch, client): + """Test recording last applied update, server settings/snapshot contract, and EventLogService.""" + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # 1. Record update + record_last_applied_update( + prev_version="0.1.2", + prev_commit="aaaaaaa1111111", + new_version="0.1.3", + new_commit="bbbbbbb2222222", + ) + + applied = get_last_applied_update() + assert applied is not None + assert applied["prev_version"] == "0.1.2" + assert applied["prev_commit"] == "aaaaaaa1111111" + assert applied["new_version"] == "0.1.3" + assert applied["new_commit"] == "bbbbbbb2222222" + assert applied["acknowledged"] is False + + # 2. Check EventLogService logging contract + EventLogService.get().log( + "system", + f"Hermes Hub успешно обновлён с {applied['prev_version']} ({applied['prev_commit'][:7]}) до {applied['new_version']} ({applied['new_commit'][:7]})", + level="info", + ) + events = EventLogService.get().get_events(category="system", limit=10) + found = any("Hermes Hub успешно обновлён" in (getattr(e, "message", None) or "") for e in events) + assert found is True + + # 3. Acknowledge update + acknowledge_last_applied_update() + applied_after = get_last_applied_update() + assert applied_after["acknowledged"] is True + + # 4. Check GET /api/settings includes last_applied_update + res = client.get("/api/settings") + assert res.status_code == 200 + data = res.json() + assert "last_applied_update" in data + assert data["last_applied_update"]["new_version"] == "0.1.3" + + +# ── TEST 8: P0-5 Silent Actions and No Polling Loops ── +@pytest.mark.unit +def test_p0_5_silent_actions_and_no_interval_polling(): + """Verify get_update_progress and cancel_update are in SILENT_ACTIONS and no global update intervals exist.""" + src = APP_JS_PATH.read_text(encoding="utf-8") + + # 1. Check SILENT_ACTIONS + assert "'get_update_progress'" in src + assert "'cancel_update'" in src + + # 2. Check that there is NO setInterval for checkUpdates + assert "setInterval(checkUpdates" not in src + assert "setInterval(() => checkUpdates" not in src + assert "setInterval(function() { checkUpdates" not in src + + # 3. ActionExecutor get_update_progress + res_prog = ActionExecutor.execute("get_update_progress", {}) + assert res_prog["ok"] is True + assert "data" in res_prog + assert "status" in res_prog["data"] From 285ae7cc071318db26dc696ba0f82a1d398fa719 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:15:54 +0700 Subject: [PATCH 2/2] =?UTF-8?q?review(a59):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5,=20=D0=BA=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B5=20=D0=B2=D0=B8=D0=B4=D0=BD=D0=BE,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BD=D1=8F=D1=82=D0=BE=20=D1=81=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аудит A59. Механизм показа и загрузки собран верно, но три вещи говорили владельцу неправду. Запись о применённом обновлении делалась ДО запуска установщика. Установщик падал, запись оставалась, и при следующем старте хаб писал в журнал «успешно обновлён», а интерфейс показывал тост об успехе — про версию, которая не установилась. Теперь «было» снимается до установки (иначе после подмены файлов прежняя сборка совпадёт с новой), а сама запись делается только на путях успеха. Причина отказа .sh-установщика была пустой: «Установка не удалась: » без единого признака. Установщик может завершиться, не сказав ни слова, поэтому в сообщение добавлен код возврата. Отмена не смотрела, что происходит. Действие cancel_update открыто в HTTP-API, и вызов на этапе установки чистил staging вместе с исполняемым в этот момент файлом, отвечая «отменено» поверх продолжающейся установки. Отмена теперь принимается только на проверке и загрузке, отказ называет причину, интерфейс возобновляет опрос вместо замершего окна. Полоса хода при неизвестном размере заполнялась целиком: текст рядом честно писал «Н/Д: сервер не сообщил размер», а полная полоса читалась как «готово». Заменена бегущим отрезком на всех этапах с неизвестной долей. Обработчик хода при неизвестном размере получал выдуманный ноль на каждом чанке — теперь старая форма обработчика в этом случае просто не вызывается. Возвращены комментарии ревьюера, снятые в ходе задания: про running_commit как единственный признак живого кода, про версию только из API, про «отсутствие суммы — не разрешение», про ожидание установщика и порядок перезапуска. Тесты: 723 passed, 2 skipped (было 718/2). Добавлены проверки провала установки, отказа в отмене и честной полосы. ruff чисто, релизный гейт пройден. НЕ СДЕЛАНО и требует живого прогона: на Linux install-linux.sh снимает сам хаб, пока тот ждёт установщик, поэтому schedule_restart не выполняется. Отката для путей .sh и .exe по-прежнему нет. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En --- .../router/action_handler.py | 8 +- src/antigravity_provider/router/web/server.py | 2 - .../router/web/static/app.js | 65 +++++- .../updater/update_manager.py | 121 ++++++++--- tests/test_a59_visible_update.py | 193 ++++++++++++++++++ 5 files changed, 354 insertions(+), 35 deletions(-) diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 02ab1c1..ffec045 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -1370,9 +1370,15 @@ class ActionExecutor: return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress} elif action == 'cancel_update': + # Отмена принимается не всегда: после начала установки отменять уже + # нечего. Отвечаем тем, что произошло на самом деле, иначе владелец + # видит «отменено» поверх продолжающейся установки. mgr = UpdateManager() progress = mgr.cancel_download() - return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress} + if progress.get('cancel_accepted'): + return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress} + reason = progress.get('cancel_refused_reason') or 'Отмена сейчас невозможна' + return {'ok': False, 'message': reason, 'data': progress} elif action == 'run_preflight': from antigravity_provider.router.preflight_service import PreflightCheckService diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py index f5bde22..18e69a5 100644 --- a/src/antigravity_provider/router/web/server.py +++ b/src/antigravity_provider/router/web/server.py @@ -264,7 +264,6 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)): "config_dir": str(paths.get_config_dir()), "log_file": str(paths.get_log_file()), } - from antigravity_provider.updater.update_manager import get_last_applied_update snap_dict["last_applied_update"] = get_last_applied_update() return JSONResponse(content=jsonable_encoder(snap_dict)) @@ -680,7 +679,6 @@ def _background_refresh_loop() -> None: # Check if update was just applied and log event to EventLogService try: - from antigravity_provider.updater.update_manager import get_last_applied_update, acknowledge_last_applied_update from antigravity_provider.router.unified_health import EventLogService applied = get_last_applied_update() if applied and not applied.get("acknowledged"): diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 7d17d2a..75c3c20 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -2506,6 +2506,14 @@ function renderUpdateUI() { const badgeText = document.getElementById('header-update-text'); const commitTag = document.getElementById('commit-tag'); + // Первым источником — снапшот работающего сервера: он приходит всегда, а + // панель обновлений заполняется только при её открытии. На Linux строка + // сборки поэтому оставалась пустой, и понять, дошло ли обновление, было + // нельзя. + // + // Берём running_commit — коммит, снятый при СТАРТЕ процесса. Поле commit + // читается с диска при каждом запросе, и переживший обновление процесс + // рапортует им свежий номер при старом поведении. const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || ''; const installedCommit = runningCommit || (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown' @@ -2543,6 +2551,9 @@ function renderUpdateUI() { const releaseMeta = document.getElementById('update-release-meta'); const releaseNotes = document.getElementById('update-release-notes'); + // Версия берётся ТОЛЬКО из API. Раньше номер был зашит в разметке и в + // запасном значении: подъём версии в коде до интерфейса не доходил, и + // владелец видел старый номер при новой сборке. const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || ''; const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; if (updateInfoDesc) { @@ -2707,6 +2718,10 @@ async function pollUpdateProgress() { } } +// Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу +// заменяем бегущим отрезком, а не заполняем целиком. +const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']); + function renderUpdateProgressView(p) { if (!elements.modalBody) return; @@ -2729,20 +2744,46 @@ function renderUpdateProgressView(p) { progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`; barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`; } else { - // Honest: no content-length + // Размер неизвестен — доля не вычисляется. Полоса при этом не должна + // изображать процент: полная полоса читается как «готово». Активные этапы + // показываем бегущим отрезком, завершённые — сплошной полосой. progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`; isIndeterminate = true; - barWidth = downloaded > 0 ? '100%' : '20%'; + barWidth = '100%'; } let statusBadge = `Загрузка`; - if (status === 'verifying') statusBadge = `Проверка SHA-256`; + if (status === 'checking') statusBadge = `Проверка обновлений`; + else if (status === 'verifying') statusBadge = `Проверка SHA-256`; else if (status === 'installing') statusBadge = `Установка`; else if (status === 'restarting') statusBadge = `Перезапуск`; else if (status === 'completed') statusBadge = `Завершено`; else if (status === 'failed') statusBadge = `Ошибка`; else if (status === 'cancelled') statusBadge = `Отменено`; + // Inject indeterminate animation style if not already present + if (isIndeterminate && !document.getElementById('indeterminate-bar-style')) { + const style = document.createElement('style'); + style.id = 'indeterminate-bar-style'; + style.textContent = ` + @keyframes indeterminate-bar { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } + } + .indeterminate-bar { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 30%; + background: var(--accent); + animation: indeterminate-bar 1.5s ease-in-out infinite; + opacity: 0.8; + } + `; + document.head.appendChild(style); + } + elements.modalBody.innerHTML = `
@@ -2755,7 +2796,11 @@ function renderUpdateProgressView(p) {
${escapeHtml(msg)}
-
+ ${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? ` +
+ ` : ` +
+ `}
@@ -2800,7 +2845,17 @@ function renderUpdateProgressView(p) { async function cancelUpdateProcess() { stopUpdateProgressPolling(); - await executeAction('cancel_update', {}); + const cancelRes = await executeAction('cancel_update', {}); + + // Отмену могли не принять: после начала установки отменять уже нечего. + // Тогда возвращаем опрос обратно, иначе окно замрёт на последнем кадре и + // владелец решит, что установка встала. + const accepted = cancelRes && cancelRes.data && cancelRes.data.cancel_accepted; + if (!accepted) { + showToast((cancelRes && cancelRes.message) || 'Отменять уже нечего: установка идёт', 'warning'); + updateProgressInterval = setInterval(pollUpdateProgress, 500); + } + const res = await executeAction('get_update_progress', {}); if (res && res.ok && res.data) { renderUpdateProgressView(res.data); diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index 2802821..fd962b4 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -13,6 +13,7 @@ Features: from __future__ import annotations import hashlib +import inspect import json import logging import os @@ -433,28 +434,46 @@ def stop_running_hub(timeout_sec: float = 10.0) -> bool: return True +# Почему отмена отклонена — своими словами для владельца, а не кодом состояния. +_CANCEL_REFUSAL = { + "installing": "Отмена невозможна: установка уже началась", + "restarting": "Отмена невозможна: Hermes Hub уже перезапускается", + "completed": "Отменять нечего: обновление уже установлено", + "failed": "Отменять нечего: обновление уже завершилось ошибкой", + "cancelled": "Загрузка уже отменена", + "idle": "Отменять нечего: обновление не запускалось", +} + + def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None: - """Safely invoke progress callback supporting both 1-arg float and 2-arg (downloaded, total) signatures.""" + """Вызвать обработчик хода загрузки, поддержав обе его формы. + + Старая форма принимает одну долю (0..1), новая — (скачано, всего). + + Доли при неизвестном общем размере не существует, и подставлять вместо неё + ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку. + Старую форму в этом случае просто не зовём — молчание честнее выдуманного + числа, а сам ход всё равно виден через _set_progress. + """ if not cb: return - import inspect + known_total = bool(total and total > 0) try: sig = inspect.signature(cb) - if len(sig.parameters) == 1: - if total and total > 0: - cb(downloaded / total) - else: - cb(0.0) - else: - cb(downloaded, total) - except Exception: - try: - cb(downloaded, total) - except TypeError: - if total and total > 0: - cb(downloaded / total) - else: - cb(0.0) + single_arg = len(sig.parameters) == 1 + except (TypeError, ValueError): + single_arg = False + + if single_arg: + if known_total: + cb(downloaded / total) + return + + try: + cb(downloaded, total) + except TypeError: + if known_total: + cb(downloaded / total) class UpdateManager: @@ -507,7 +526,25 @@ class UpdateManager: @classmethod def cancel_download(cls) -> Dict[str, Any]: - """Cancel in-progress download, remove partially downloaded files, and set status to cancelled.""" + """Отменить загрузку обновления и удалить недокачанные файлы. + + Отмена допустима только пока идёт проверка или загрузка. Действие + `cancel_update` открыто в HTTP-API, и без этой проверки вызов во время + установки вычищал каталог staging вместе с файлом установщика, который + в этот момент исполняет bash: установка ломалась на середине, а ответ + «отменено» сообщал владельцу неправду о том, что происходит с машиной. + """ + with cls._lock: + current_status = cls._progress.status + if current_status not in ("checking", "downloading"): + refused = cls._progress.to_dict() + refused["cancel_accepted"] = False + refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get( + current_status, + f"Отмена невозможна на этапе «{current_status}»", + ) + return refused + cls._cancel_event.set() with cls._lock: cls._progress = UpdateProgress( @@ -529,7 +566,9 @@ class UpdateManager: f.unlink(missing_ok=True) except Exception as exc: logger.debug("Clean staging dir on cancel failed: %s", exc) - return cls.get_progress_dict() + accepted = cls.get_progress_dict() + accepted["cancel_accepted"] = True + return accepted @classmethod def cancel_update(cls) -> Dict[str, Any]: @@ -996,6 +1035,10 @@ class UpdateManager: if not expected_sha and check_result.manifest and check_result.manifest.sha256: expected_sha = check_result.manifest.sha256.lower() + # Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt + # expected_sha оставался пустым, проверка молча пропускалась и скачанный + # файл всё равно запускался. Здесь запускается загруженный из сети + # исполняемый код, поэтому непроверенный файл не запускаем вовсе. if not expected_sha: dest_file.unlink(missing_ok=True) err = ( @@ -1028,12 +1071,6 @@ class UpdateManager: prev_c = get_installed_commit() new_v = check_result.latest_version new_c = check_result.latest_commit or "" - record_last_applied_update( - prev_version=prev_v, - prev_commit=prev_c, - new_version=new_v, - new_commit=new_c, - ) # 6. Apply update based on file type self._set_progress( @@ -1042,11 +1079,20 @@ class UpdateManager: message=f"Установка пакета {chosen_asset_name}...", ) + def _record_success(): + record_last_applied_update( + prev_version=prev_v, + prev_commit=prev_c, + new_version=new_v, + new_commit=new_c, + ) + if chosen_asset_name.endswith(".zip"): ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir) if not ok: self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg) return False, msg + _record_success() self._set_progress(status="completed", filename=chosen_asset_name, message=msg) return True, "Обновление успешно установлено" @@ -1067,6 +1113,7 @@ class UpdateManager: err = f"Установщик завершился с кодом {rc}. Обновление не применено." self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) return False, err + _record_success() self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...") ok_r, msg_r = self.schedule_restart() if not ok_r: @@ -1082,15 +1129,25 @@ class UpdateManager: elif chosen_asset_name.endswith(".sh"): try: os.chmod(dest_file, 0o755) + # Ждём завершения: без этого перезапуск начался бы прямо во + # время распаковки, а владелец получил бы обещание перезапуска + # при неизвестном исходе установки. res_i = subprocess.run( ["bash", str(dest_file)], capture_output=True, text=True, timeout=600, ) if res_i.returncode != 0: + # Код возврата в сообщении обязателен: установщик может + # завершиться, не сказав ни слова, и владелец получал + # «Установка не удалась: » без единого признака причины. tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:] - err = "Установка не удалась: " + " / ".join(tail) + if tail: + err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail) + else: + err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил" self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) return False, err + _record_success() self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...") ok_r, msg_r = self.schedule_restart() if not ok_r: @@ -1107,7 +1164,17 @@ class UpdateManager: return True, "Файл обновления загружен и проверен" def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]: - """Перезапустить веб-хаб после установки обновления.""" + """Перезапустить веб-хаб после установки обновления. + + Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не + поднимают, а сообщение обещало перезапуск. Владелец оставался со старым + процессом, продолжавшим отдавать старый код, и делал вывод, что + обновление не сработало. + + Порядок именно такой: сначала отсоединённый помощник, потом выход + текущего процесса. Лаунчер считает хаб работающим, если порт отвечает, + поэтому поднимать новый, не освободив порт, бесполезно. + """ home = paths.get_hermes_home() if sys.platform == "win32": launcher = home / "HermesHubWeb.exe" diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py index d3f5586..7e80c72 100644 --- a/tests/test_a59_visible_update.py +++ b/tests/test_a59_visible_update.py @@ -160,18 +160,65 @@ def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, mo staging_file.parent.mkdir(parents=True, exist_ok=True) staging_file.write_bytes(b"Partial download data 12345") + # Отмена осмысленна только пока идёт загрузка — ставим это состояние явно, + # иначе тест проверял бы отмену того, чего не происходит. + UpdateManager._set_progress( + status="downloading", + filename="partial_download.zip", + downloaded_bytes=27, + message="Скачивание partial_download.zip...", + ) + # Trigger cancel cancel_res = UpdateManager.cancel_download() assert cancel_res["status"] == "cancelled" + assert cancel_res["cancel_accepted"] is True assert "отменена" in (cancel_res["message"] or "").lower() assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation" # Also test ActionExecutor 'cancel_update' + UpdateManager._set_progress(status="downloading", filename="partial_download.zip") action_res = ActionExecutor.execute("cancel_update", {}) assert action_res["ok"] is True assert action_res["data"]["status"] == "cancelled" +# ── TEST 3b: отмена после начала установки отклоняется, а не врёт ── +@pytest.mark.unit +def test_p0_2_cancel_refused_after_install_started(tmp_path, monkeypatch): + """Отмена во время установки не трогает staging и честно сообщает отказ. + + Действие cancel_update открыто в HTTP-API. Раньше вызов на этапе installing + чистил каталог staging вместе с исполняемым в этот момент установщиком и + отвечал «отменено», хотя установка продолжалась. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") + + mgr = UpdateManager() + installer = mgr.staging_dir / "hermes-hub-setup.sh" + installer.parent.mkdir(parents=True, exist_ok=True) + installer.write_bytes(b"#!/bin/bash\necho installing\n") + + UpdateManager._set_progress( + status="installing", + filename="hermes-hub-setup.sh", + message="Установка пакета hermes-hub-setup.sh...", + ) + + res = UpdateManager.cancel_download() + assert res["cancel_accepted"] is False + assert res["status"] == "installing", "Статус не должен подменяться на cancelled" + assert "установка уже началась" in res["cancel_refused_reason"].lower() + assert installer.exists(), "Файл исполняемого установщика удалять нельзя" + + action_res = ActionExecutor.execute("cancel_update", {}) + assert action_res["ok"] is False + assert "отмена невозможна" in action_res["message"].lower() + + UpdateManager._set_progress(status="idle", message="Готов к обновлению") + + # ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ── @pytest.mark.unit def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch): @@ -313,3 +360,149 @@ def test_p0_5_silent_actions_and_no_interval_polling(): assert res_prog["ok"] is True assert "data" in res_prog assert "status" in res_prog["data"] + + +# ── TEST 9: P0-4 провалившаяся установка не выдаёт себя за успешную ── +@pytest.mark.unit +def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monkeypatch): + """Установщик упал — записи о применённом обновлении быть не должно. + + record_last_applied_update вызывался ДО запуска установщика. При падении + запись оставалась на диске, и при следующем старте хаб писал в журнал + «успешно обновлён», а интерфейс показывал тост об успехе — владельцу + сообщали о версии, которая не установилась. + + Заодно проверяется, что причина отказа не пустая: установщик может + завершиться, не сказав ни слова, и сообщение «Установка не удалась: » + не давало ни одного признака причины. + """ + import hashlib + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") + + installer = tmp_path / "hermes-hub-setup.sh" + installer.write_bytes(b"#!/bin/bash\nexit 3\n") + sha = hashlib.sha256(installer.read_bytes()).hexdigest() + + check_result = UpdateCheckResult( + update_available=True, + current_version="0.1.3", + latest_version="0.1.4", + latest_commit="deadbeefdeadbeef", + installed_commit="0000000aaaa", + assets={ + "hermes-hub-setup.sh": f"file://{installer}", + "checksums.txt": "file:///nonexistent", + }, + ) + + mgr = UpdateManager() + real_download = UpdateManager._download_file + + def fake_download(self, url, dest, progress_cb=None): + if dest.name == "checksums.txt": + dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8") + return + return real_download(self, url, dest, progress_cb) + + with patch.object(UpdateManager, "_download_file", fake_download): + with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): + ok, msg = mgr.install_latest_update(check_result=check_result) + + assert ok is False + assert get_last_applied_update() is None, ( + "После провалившейся установки записи о применённом обновлении быть не должно" + ) + assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}" + + prog = UpdateManager.get_progress_dict() + assert prog["status"] == "failed" + + UpdateManager._set_progress(status="idle", message="Готов к обновлению") + + +# ── TEST 10: P0-4 успешная установка запись всё-таки делает ── +@pytest.mark.unit +def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch): + """Успех записывает и «было», и «стало», причём «было» снято до установки.""" + import hashlib + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1") + + installer = tmp_path / "hermes-hub-setup.sh" + installer.write_bytes(b"#!/bin/bash\nexit 0\n") + sha = hashlib.sha256(installer.read_bytes()).hexdigest() + + check_result = UpdateCheckResult( + update_available=True, + current_version="0.1.3", + latest_version="0.1.4", + latest_commit="deadbeefdeadbeef", + installed_commit="0000000aaaa", + assets={ + "hermes-hub-setup.sh": f"file://{installer}", + "checksums.txt": "file:///nonexistent", + }, + ) + + mgr = UpdateManager() + real_download = UpdateManager._download_file + + def fake_download(self, url, dest, progress_cb=None): + if dest.name == "checksums.txt": + dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8") + return + return real_download(self, url, dest, progress_cb) + + with patch.object(UpdateManager, "_download_file", fake_download): + with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True): + with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")): + ok, msg = mgr.install_latest_update(check_result=check_result) + + assert ok is True + rec = get_last_applied_update() + assert rec is not None + assert rec["new_version"] == "0.1.4" + assert rec["new_commit"] == "deadbeefdeadbeef" + assert rec["prev_commit"] != rec["new_commit"], ( + "«Было» снимается до установки, иначе прежняя сборка совпадёт с новой" + ) + assert rec["acknowledged"] is False + + UpdateManager._set_progress(status="idle", message="Готов к обновлению") + + +# ── TEST 11: P0-2 полоса не изображает процент при неизвестном размере ── +@pytest.mark.unit +def test_p0_2_app_js_indeterminate_bar_when_size_unknown(): + """При неизвестном размере полоса бежит, а не заполняется целиком. + + Текст рядом был честным («Н/Д: сервер не сообщил размер»), а полоса при этом + рисовалась на всю ширину: `downloaded > 0 ? '100%' : '20%'`. Полная полоса + читается как «готово» — тот же выдуманный процент, только нарисованный. + """ + src = APP_JS_PATH.read_text(encoding="utf-8") + + assert "downloaded > 0 ? '100%' : '20%'" not in src, ( + "Полоса не должна заполняться на всю ширину при неизвестном размере" + ) + assert "Н/Д: сервер не сообщил размер" in src + assert "INDETERMINATE_ACTIVE_STATUSES" in src, "нужен список этапов с неопределённой полосой" + assert "@keyframes indeterminate-bar" in src, "нужна анимация бегущего отрезка" + assert "indeterminate-bar-style" in src, "стиль вставляется один раз по id" + + +# ── TEST 12: P0-2 интерфейс не принимает отказ в отмене за отмену ── +@pytest.mark.unit +def test_p0_2_app_js_handles_refused_cancel(): + """Отказ в отмене возвращает опрос хода, а не оставляет окно замершим.""" + src = APP_JS_PATH.read_text(encoding="utf-8") + + assert "cancel_accepted" in src, "app.js обязан различать принятую и отклонённую отмену" + idx = src.index("async function cancelUpdateProcess()") + tail = src[idx:idx + 1200] + assert "setInterval(pollUpdateProgress" in tail, ( + "после отклонённой отмены опрос хода загрузки должен возобновляться" + )