diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py
index a3bc2b9..5fe861d 100644
--- a/src/antigravity_provider/router/action_handler.py
+++ b/src/antigravity_provider/router/action_handler.py
@@ -1364,6 +1364,22 @@ 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()
+ 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
service = PreflightCheckService.get()
diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py
index 00cbc86..dda5fdc 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,7 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
"config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()),
}
+ snap_dict["last_applied_update"] = get_last_applied_update()
try:
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
@@ -527,6 +534,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,
@@ -699,6 +707,25 @@ 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.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 780a210..3c61f33 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
@@ -585,6 +585,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',
]);
@@ -2510,6 +2512,7 @@ function closeModal() {
stopDeviceAuthPolling();
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
stopRedirectAuthPolling();
+ stopUpdateProgressPolling();
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
}
@@ -2575,7 +2578,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');
@@ -2585,11 +2614,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');
}
}
@@ -2719,9 +2757,11 @@ function renderUpdateUI() {
detailsBlock.classList.add('hidden');
}
}
+
+ checkPostUpdateNotification();
}
-function openUpdateModal() {
+function openUpdateModal(mode = 'details') {
if (!latestUpdateInfo) {
checkUpdates(false);
return;
@@ -2731,62 +2771,240 @@ 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);
+ }
+}
+
+// Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу
+// заменяем бегущим отрезком, а не заполняем целиком.
+const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']);
+
+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 {
+ // Размер неизвестен — доля не вычисляется. Полоса при этом не должна
+ // изображать процент: полная полоса читается как «готово». Активные этапы
+ // показываем бегущим отрезком, завершённые — сплошной полосой.
+ progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`;
+ isIndeterminate = true;
+ barWidth = '100%';
+ }
+
+ let statusBadge = `Загрузка`;
+ 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 = `
+
+
+
+ ${escapeHtml(filename)}
+
+
${statusBadge}
+
+
+
${escapeHtml(msg)}
+
+
+ ${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? `
+
+ ` : `
+
+ `}
+
+
+
+ ${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();
+ 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 2effd30..fd962b4 100644
--- a/src/antigravity_provider/updater/update_manager.py
+++ b/src/antigravity_provider/updater/update_manager.py
@@ -5,24 +5,28 @@ 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).
"""
from __future__ import annotations
import hashlib
+import inspect
import json
import logging
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 +76,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 +170,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 +206,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 +221,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 +240,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 +299,189 @@ 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
+
+
+# Почему отмена отклонена — своими словами для владельца, а не кодом состояния.
+_CANCEL_REFUSAL = {
+ "installing": "Отмена невозможна: установка уже началась",
+ "restarting": "Отмена невозможна: Hermes Hub уже перезапускается",
+ "completed": "Отменять нечего: обновление уже установлено",
+ "failed": "Отменять нечего: обновление уже завершилось ошибкой",
+ "cancelled": "Загрузка уже отменена",
+ "idle": "Отменять нечего: обновление не запускалось",
+}
+
+
+def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
+ """Вызвать обработчик хода загрузки, поддержав обе его формы.
+
+ Старая форма принимает одну долю (0..1), новая — (скачано, всего).
+
+ Доли при неизвестном общем размере не существует, и подставлять вместо неё
+ ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
+ Старую форму в этом случае просто не зовём — молчание честнее выдуманного
+ числа, а сам ход всё равно виден через _set_progress.
+ """
+ if not cb:
+ return
+ known_total = bool(total and total > 0)
+ try:
+ sig = inspect.signature(cb)
+ 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:
"""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 +496,85 @@ 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_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(
+ 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)
+ accepted = cls.get_progress_dict()
+ accepted["cancel_accepted"] = True
+ return accepted
+
+ @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 +592,8 @@ class UpdateManager:
"changelog": None,
"release_notes": None,
"assets": {},
+ "asset_sizes": {},
+ "download_size": None,
"error": None,
"message": "Проверка обновлений еще не выполнялась",
"checked_at": 0.0,
@@ -392,13 +684,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 +715,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 +777,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 +806,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 +852,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 +906,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 +972,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,17 +1006,30 @@ 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:
@@ -627,26 +1041,59 @@ class UpdateManager:
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
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 ""
+
+ # 6. Apply update based on file type
+ self._set_progress(
+ status="installing",
+ filename=chosen_asset_name,
+ 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,
+ )
- # 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
+ _record_success()
+ self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
return True, "Обновление успешно установлено"
elif chosen_asset_name == "HermesHubSetup.exe":
@@ -659,15 +1106,25 @@ 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
+ _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:
+ 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:
@@ -680,18 +1137,32 @@ class UpdateManager:
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)
+ 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:
+ 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]:
"""Перезапустить веб-хаб после установки обновления.
diff --git a/tests/test_a59_visible_update.py b/tests/test_a59_visible_update.py
new file mode 100644
index 0000000..7e80c72
--- /dev/null
+++ b/tests/test_a59_visible_update.py
@@ -0,0 +1,508 @@
+"""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")
+
+ # Отмена осмысленна только пока идёт загрузка — ставим это состояние явно,
+ # иначе тест проверял бы отмену того, чего не происходит.
+ 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):
+ """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"]
+
+
+# ── 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, (
+ "после отклонённой отмены опрос хода загрузки должен возобновляться"
+ )