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