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:
parent
cc18e26d1c
commit
285ae7cc07
5 changed files with 354 additions and 35 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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, (
|
||||||
|
"после отклонённой отмены опрос хода загрузки должен возобновляться"
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue