review(a59): обновление, которое видно, принято с исправлениями

Аудит A59. Механизм показа и загрузки собран верно, но три вещи говорили
владельцу неправду.

Запись о применённом обновлении делалась ДО запуска установщика. Установщик
падал, запись оставалась, и при следующем старте хаб писал в журнал «успешно
обновлён», а интерфейс показывал тост об успехе — про версию, которая не
установилась. Теперь «было» снимается до установки (иначе после подмены файлов
прежняя сборка совпадёт с новой), а сама запись делается только на путях успеха.

Причина отказа .sh-установщика была пустой: «Установка не удалась: » без единого
признака. Установщик может завершиться, не сказав ни слова, поэтому в сообщение
добавлен код возврата.

Отмена не смотрела, что происходит. Действие cancel_update открыто в HTTP-API, и
вызов на этапе установки чистил staging вместе с исполняемым в этот момент
файлом, отвечая «отменено» поверх продолжающейся установки. Отмена теперь
принимается только на проверке и загрузке, отказ называет причину, интерфейс
возобновляет опрос вместо замершего окна.

Полоса хода при неизвестном размере заполнялась целиком: текст рядом честно
писал «Н/Д: сервер не сообщил размер», а полная полоса читалась как «готово».
Заменена бегущим отрезком на всех этапах с неизвестной долей.

Обработчик хода при неизвестном размере получал выдуманный ноль на каждом чанке —
теперь старая форма обработчика в этом случае просто не вызывается.

Возвращены комментарии ревьюера, снятые в ходе задания: про running_commit как
единственный признак живого кода, про версию только из API, про «отсутствие
суммы — не разрешение», про ожидание установщика и порядок перезапуска.

Тесты: 723 passed, 2 skipped (было 718/2). Добавлены проверки провала установки,
отказа в отмене и честной полосы. ruff чисто, релизный гейт пройден.

НЕ СДЕЛАНО и требует живого прогона: на Linux install-linux.sh снимает сам хаб,
пока тот ждёт установщик, поэтому schedule_restart не выполняется. Отката для
путей .sh и .exe по-прежнему нет.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EDLuenXmGjWaS2rs8E72En
This commit is contained in:
ochenstarik-ui 2026-09-02 19:15:54 +07:00
parent cc18e26d1c
commit 285ae7cc07
5 changed files with 354 additions and 35 deletions

View file

@ -1370,9 +1370,15 @@ class ActionExecutor:
return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress} return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress}
elif action == 'cancel_update': elif action == 'cancel_update':
# Отмена принимается не всегда: после начала установки отменять уже
# нечего. Отвечаем тем, что произошло на самом деле, иначе владелец
# видит «отменено» поверх продолжающейся установки.
mgr = UpdateManager() mgr = UpdateManager()
progress = mgr.cancel_download() progress = mgr.cancel_download()
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress} if progress.get('cancel_accepted'):
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress}
reason = progress.get('cancel_refused_reason') or 'Отмена сейчас невозможна'
return {'ok': False, 'message': reason, 'data': progress}
elif action == 'run_preflight': elif action == 'run_preflight':
from antigravity_provider.router.preflight_service import PreflightCheckService from antigravity_provider.router.preflight_service import PreflightCheckService

View file

@ -264,7 +264,6 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
"config_dir": str(paths.get_config_dir()), "config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()), "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() snap_dict["last_applied_update"] = get_last_applied_update()
return JSONResponse(content=jsonable_encoder(snap_dict)) return JSONResponse(content=jsonable_encoder(snap_dict))
@ -680,7 +679,6 @@ def _background_refresh_loop() -> None:
# Check if update was just applied and log event to EventLogService # Check if update was just applied and log event to EventLogService
try: try:
from antigravity_provider.updater.update_manager import get_last_applied_update, acknowledge_last_applied_update
from antigravity_provider.router.unified_health import EventLogService from antigravity_provider.router.unified_health import EventLogService
applied = get_last_applied_update() applied = get_last_applied_update()
if applied and not applied.get("acknowledged"): if applied and not applied.get("acknowledged"):

View file

@ -2506,6 +2506,14 @@ function renderUpdateUI() {
const badgeText = document.getElementById('header-update-text'); const badgeText = document.getElementById('header-update-text');
const commitTag = document.getElementById('commit-tag'); const commitTag = document.getElementById('commit-tag');
// Первым источником — снапшот работающего сервера: он приходит всегда, а
// панель обновлений заполняется только при её открытии. На Linux строка
// сборки поэтому оставалась пустой, и понять, дошло ли обновление, было
// нельзя.
//
// Берём running_commit — коммит, снятый при СТАРТЕ процесса. Поле commit
// читается с диска при каждом запросе, и переживший обновление процесс
// рапортует им свежий номер при старом поведении.
const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || ''; const runningCommit = (currentSnapshot && currentSnapshot.running_commit) || '';
const installedCommit = runningCommit const installedCommit = runningCommit
|| (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown' || (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown'
@ -2543,6 +2551,9 @@ function renderUpdateUI() {
const releaseMeta = document.getElementById('update-release-meta'); const releaseMeta = document.getElementById('update-release-meta');
const releaseNotes = document.getElementById('update-release-notes'); const releaseNotes = document.getElementById('update-release-notes');
// Версия берётся ТОЛЬКО из API. Раньше номер был зашит в разметке и в
// запасном значении: подъём версии в коде до интерфейса не доходил, и
// владелец видел старый номер при новой сборке.
const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || ''; const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '';
const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно';
if (updateInfoDesc) { if (updateInfoDesc) {
@ -2707,6 +2718,10 @@ async function pollUpdateProgress() {
} }
} }
// Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу
// заменяем бегущим отрезком, а не заполняем целиком.
const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']);
function renderUpdateProgressView(p) { function renderUpdateProgressView(p) {
if (!elements.modalBody) return; if (!elements.modalBody) return;
@ -2729,20 +2744,46 @@ function renderUpdateProgressView(p) {
progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`; progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`;
barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`; barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`;
} else { } else {
// Honest: no content-length // Размер неизвестен — доля не вычисляется. Полоса при этом не должна
// изображать процент: полная полоса читается как «готово». Активные этапы
// показываем бегущим отрезком, завершённые — сплошной полосой.
progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`; progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`;
isIndeterminate = true; isIndeterminate = true;
barWidth = downloaded > 0 ? '100%' : '20%'; barWidth = '100%';
} }
let statusBadge = `<span class="badge badge-status warning">Загрузка</span>`; let statusBadge = `<span class="badge badge-status warning">Загрузка</span>`;
if (status === 'verifying') statusBadge = `<span class="badge badge-status warning">Проверка SHA-256</span>`; if (status === 'checking') statusBadge = `<span class="badge badge-status warning">Проверка обновлений</span>`;
else if (status === 'verifying') statusBadge = `<span class="badge badge-status warning">Проверка SHA-256</span>`;
else if (status === 'installing') statusBadge = `<span class="badge badge-status warning">Установка</span>`; else if (status === 'installing') statusBadge = `<span class="badge badge-status warning">Установка</span>`;
else if (status === 'restarting') statusBadge = `<span class="badge badge-status healthy">Перезапуск</span>`; else if (status === 'restarting') statusBadge = `<span class="badge badge-status healthy">Перезапуск</span>`;
else if (status === 'completed') statusBadge = `<span class="badge badge-status healthy">Завершено</span>`; else if (status === 'completed') statusBadge = `<span class="badge badge-status healthy">Завершено</span>`;
else if (status === 'failed') statusBadge = `<span class="badge badge-status danger">Ошибка</span>`; else if (status === 'failed') statusBadge = `<span class="badge badge-status danger">Ошибка</span>`;
else if (status === 'cancelled') statusBadge = `<span class="badge badge-status">Отменено</span>`; else if (status === 'cancelled') statusBadge = `<span class="badge badge-status">Отменено</span>`;
// 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 = ` elements.modalBody.innerHTML = `
<div class="update-progress-body" style="padding:4px 0;"> <div class="update-progress-body" style="padding:4px 0;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;"> <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
@ -2755,7 +2796,11 @@ function renderUpdateProgressView(p) {
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">${escapeHtml(msg)}</div> <div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">${escapeHtml(msg)}</div>
<div style="background:var(--surface-muted); border-radius:var(--radius-sm); overflow:hidden; height:12px; margin-bottom:8px; border:1px solid var(--border-subtle); position:relative;"> <div style="background:var(--surface-muted); border-radius:var(--radius-sm); overflow:hidden; height:12px; margin-bottom:8px; border:1px solid var(--border-subtle); position:relative;">
<div style="background:${status === 'failed' ? 'var(--status-danger)' : (status === 'cancelled' ? 'var(--text-muted)' : 'var(--accent)')}; height:100%; width:${barWidth}; transition:width 0.3s ease; ${isIndeterminate && status === 'downloading' ? 'opacity:0.8;' : ''}"></div> ${isIndeterminate && INDETERMINATE_ACTIVE_STATUSES.has(status) ? `
<div class="indeterminate-bar"></div>
` : `
<div style="background:${status === 'failed' ? 'var(--status-danger)' : (status === 'cancelled' ? 'var(--text-muted)' : 'var(--accent)')}; height:100%; width:${barWidth}; transition:width 0.3s ease;"></div>
`}
</div> </div>
<div style="font-size:12px; color:var(--text-muted); font-family:var(--font-mono); margin-bottom:12px; display:flex; justify-content:space-between;"> <div style="font-size:12px; color:var(--text-muted); font-family:var(--font-mono); margin-bottom:12px; display:flex; justify-content:space-between;">
@ -2800,7 +2845,17 @@ function renderUpdateProgressView(p) {
async function cancelUpdateProcess() { async function cancelUpdateProcess() {
stopUpdateProgressPolling(); stopUpdateProgressPolling();
await executeAction('cancel_update', {}); const cancelRes = await executeAction('cancel_update', {});
// Отмену могли не принять: после начала установки отменять уже нечего.
// Тогда возвращаем опрос обратно, иначе окно замрёт на последнем кадре и
// владелец решит, что установка встала.
const accepted = cancelRes && cancelRes.data && cancelRes.data.cancel_accepted;
if (!accepted) {
showToast((cancelRes && cancelRes.message) || 'Отменять уже нечего: установка идёт', 'warning');
updateProgressInterval = setInterval(pollUpdateProgress, 500);
}
const res = await executeAction('get_update_progress', {}); const res = await executeAction('get_update_progress', {});
if (res && res.ok && res.data) { if (res && res.ok && res.data) {
renderUpdateProgressView(res.data); renderUpdateProgressView(res.data);

View file

@ -13,6 +13,7 @@ Features:
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import inspect
import json import json
import logging import logging
import os import os
@ -433,28 +434,46 @@ def stop_running_hub(timeout_sec: float = 10.0) -> bool:
return True 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: def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
"""Safely invoke progress callback supporting both 1-arg float and 2-arg (downloaded, total) signatures.""" """Вызвать обработчик хода загрузки, поддержав обе его формы.
Старая форма принимает одну долю (0..1), новая (скачано, всего).
Доли при неизвестном общем размере не существует, и подставлять вместо неё
ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
Старую форму в этом случае просто не зовём молчание честнее выдуманного
числа, а сам ход всё равно виден через _set_progress.
"""
if not cb: if not cb:
return return
import inspect known_total = bool(total and total > 0)
try: try:
sig = inspect.signature(cb) sig = inspect.signature(cb)
if len(sig.parameters) == 1: single_arg = len(sig.parameters) == 1
if total and total > 0: except (TypeError, ValueError):
cb(downloaded / total) single_arg = False
else:
cb(0.0) if single_arg:
else: if known_total:
cb(downloaded, total) cb(downloaded / total)
except Exception: return
try:
cb(downloaded, total) try:
except TypeError: cb(downloaded, total)
if total and total > 0: except TypeError:
cb(downloaded / total) if known_total:
else: cb(downloaded / total)
cb(0.0)
class UpdateManager: class UpdateManager:
@ -507,7 +526,25 @@ class UpdateManager:
@classmethod @classmethod
def cancel_download(cls) -> Dict[str, Any]: def cancel_download(cls) -> Dict[str, Any]:
"""Cancel in-progress download, remove partially downloaded files, and set status to cancelled.""" """Отменить загрузку обновления и удалить недокачанные файлы.
Отмена допустима только пока идёт проверка или загрузка. Действие
`cancel_update` открыто в HTTP-API, и без этой проверки вызов во время
установки вычищал каталог staging вместе с файлом установщика, который
в этот момент исполняет bash: установка ломалась на середине, а ответ
«отменено» сообщал владельцу неправду о том, что происходит с машиной.
"""
with cls._lock:
current_status = cls._progress.status
if current_status not in ("checking", "downloading"):
refused = cls._progress.to_dict()
refused["cancel_accepted"] = False
refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get(
current_status,
f"Отмена невозможна на этапе «{current_status}»",
)
return refused
cls._cancel_event.set() cls._cancel_event.set()
with cls._lock: with cls._lock:
cls._progress = UpdateProgress( cls._progress = UpdateProgress(
@ -529,7 +566,9 @@ class UpdateManager:
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
except Exception as exc: except Exception as exc:
logger.debug("Clean staging dir on cancel failed: %s", exc) logger.debug("Clean staging dir on cancel failed: %s", exc)
return cls.get_progress_dict() accepted = cls.get_progress_dict()
accepted["cancel_accepted"] = True
return accepted
@classmethod @classmethod
def cancel_update(cls) -> Dict[str, Any]: def cancel_update(cls) -> Dict[str, Any]:
@ -996,6 +1035,10 @@ class UpdateManager:
if not expected_sha and check_result.manifest and check_result.manifest.sha256: if not expected_sha and check_result.manifest and check_result.manifest.sha256:
expected_sha = check_result.manifest.sha256.lower() expected_sha = check_result.manifest.sha256.lower()
# Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt
# expected_sha оставался пустым, проверка молча пропускалась и скачанный
# файл всё равно запускался. Здесь запускается загруженный из сети
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
if not expected_sha: if not expected_sha:
dest_file.unlink(missing_ok=True) dest_file.unlink(missing_ok=True)
err = ( err = (
@ -1028,12 +1071,6 @@ class UpdateManager:
prev_c = get_installed_commit() prev_c = get_installed_commit()
new_v = check_result.latest_version new_v = check_result.latest_version
new_c = check_result.latest_commit or "" 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 # 6. Apply update based on file type
self._set_progress( self._set_progress(
@ -1042,11 +1079,20 @@ class UpdateManager:
message=f"Установка пакета {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,
)
if chosen_asset_name.endswith(".zip"): if chosen_asset_name.endswith(".zip"):
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir) ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
if not ok: if not ok:
self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg) self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg)
return False, msg return False, msg
_record_success()
self._set_progress(status="completed", filename=chosen_asset_name, message=msg) self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
return True, "Обновление успешно установлено" return True, "Обновление успешно установлено"
@ -1067,6 +1113,7 @@ class UpdateManager:
err = f"Установщик завершился с кодом {rc}. Обновление не применено." err = f"Установщик завершился с кодом {rc}. Обновление не применено."
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err return False, err
_record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...") self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart() ok_r, msg_r = self.schedule_restart()
if not ok_r: if not ok_r:
@ -1082,15 +1129,25 @@ class UpdateManager:
elif chosen_asset_name.endswith(".sh"): elif chosen_asset_name.endswith(".sh"):
try: try:
os.chmod(dest_file, 0o755) os.chmod(dest_file, 0o755)
# Ждём завершения: без этого перезапуск начался бы прямо во
# время распаковки, а владелец получил бы обещание перезапуска
# при неизвестном исходе установки.
res_i = subprocess.run( res_i = subprocess.run(
["bash", str(dest_file)], ["bash", str(dest_file)],
capture_output=True, text=True, timeout=600, capture_output=True, text=True, timeout=600,
) )
if res_i.returncode != 0: if res_i.returncode != 0:
# Код возврата в сообщении обязателен: установщик может
# завершиться, не сказав ни слова, и владелец получал
# «Установка не удалась: » без единого признака причины.
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:] tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
err = "Установка не удалась: " + " / ".join(tail) if tail:
err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail)
else:
err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил"
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err) self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err return False, err
_record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...") self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart() ok_r, msg_r = self.schedule_restart()
if not ok_r: if not ok_r:
@ -1107,7 +1164,17 @@ class UpdateManager:
return True, "Файл обновления загружен и проверен" return True, "Файл обновления загружен и проверен"
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]: def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
"""Перезапустить веб-хаб после установки обновления.""" """Перезапустить веб-хаб после установки обновления.
Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не
поднимают, а сообщение обещало перезапуск. Владелец оставался со старым
процессом, продолжавшим отдавать старый код, и делал вывод, что
обновление не сработало.
Порядок именно такой: сначала отсоединённый помощник, потом выход
текущего процесса. Лаунчер считает хаб работающим, если порт отвечает,
поэтому поднимать новый, не освободив порт, бесполезно.
"""
home = paths.get_hermes_home() home = paths.get_hermes_home()
if sys.platform == "win32": if sys.platform == "win32":
launcher = home / "HermesHubWeb.exe" launcher = home / "HermesHubWeb.exe"

View file

@ -160,18 +160,65 @@ def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, mo
staging_file.parent.mkdir(parents=True, exist_ok=True) staging_file.parent.mkdir(parents=True, exist_ok=True)
staging_file.write_bytes(b"Partial download data 12345") 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 # Trigger cancel
cancel_res = UpdateManager.cancel_download() cancel_res = UpdateManager.cancel_download()
assert cancel_res["status"] == "cancelled" assert cancel_res["status"] == "cancelled"
assert cancel_res["cancel_accepted"] is True
assert "отменена" in (cancel_res["message"] or "").lower() assert "отменена" in (cancel_res["message"] or "").lower()
assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation" assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation"
# Also test ActionExecutor 'cancel_update' # Also test ActionExecutor 'cancel_update'
UpdateManager._set_progress(status="downloading", filename="partial_download.zip")
action_res = ActionExecutor.execute("cancel_update", {}) action_res = ActionExecutor.execute("cancel_update", {})
assert action_res["ok"] is True assert action_res["ok"] is True
assert action_res["data"]["status"] == "cancelled" 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 ── # ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ──
@pytest.mark.unit @pytest.mark.unit
def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch): def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch):
@ -313,3 +360,149 @@ def test_p0_5_silent_actions_and_no_interval_polling():
assert res_prog["ok"] is True assert res_prog["ok"] is True
assert "data" in res_prog assert "data" in res_prog
assert "status" in res_prog["data"] 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, (
"после отклонённой отмены опрос хода загрузки должен возобновляться"
)