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] 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"]