merge(a59): обновление видно на экране и не врёт об успехе

Окно при запуске, полоса хода с честным Н/Д без размера, отмена с удалением
недокачанного файла, обязательная SHA-256 — принято как есть.

Правки ревьюера: запись о применённом обновлении делалась до запуска установщика
и переживала его падение, поэтому хаб рапортовал об успехе версии, которая не
установилась. Отмена не смотрела на этап и сносила staging вместе с исполняемым
установщиком. Полоса при неизвестном размере заполнялась целиком, хотя текст
рядом честно писал Н/Д. Возвращены шесть блоков комментариев, снятых в ходе
задания.

Не сделано и вынесено в A60: установщик снимает процесс, который его запустил и
ждёт, поэтому перезапуск не наступает; отката для путей .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:25:07 +07:00
commit 2f353778c2
5 changed files with 1318 additions and 78 deletions

View file

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

View file

@ -23,7 +23,13 @@ from antigravity_provider.router.state_store import HubStateStore
from antigravity_provider.router.action_handler import ActionExecutor from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.router_config import load_router_config 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") logger = logging.getLogger("hermes.router.web")
@ -258,6 +264,7 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
"config_dir": str(paths.get_config_dir()), "config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()), "log_file": str(paths.get_log_file()),
} }
snap_dict["last_applied_update"] = get_last_applied_update()
try: try:
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False) snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
@ -527,6 +534,7 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
"installed_at": get_installed_build_time(), "installed_at": get_installed_build_time(),
"version": __version__, "version": __version__,
"last_update_check": last_check.to_dict() if last_check else None, "last_update_check": last_check.to_dict() if last_check else None,
"last_applied_update": get_last_applied_update(),
"network_security": { "network_security": {
"is_external_bind": is_external, "is_external_bind": is_external,
"is_tls": False, "is_tls": False,
@ -699,6 +707,25 @@ def _background_refresh_loop() -> None:
except Exception as exc: except Exception as exc:
logger.debug("Initial background update check skipped: %s", exc) logger.debug("Initial background update check skipped: %s", exc)
# Check if update was just applied and log event to EventLogService
try:
from antigravity_provider.router.unified_health import EventLogService
applied = get_last_applied_update()
if applied and not applied.get("acknowledged"):
prev_v = applied.get("prev_version", "unknown")
prev_c = (applied.get("prev_commit") or "unknown")[:7]
new_v = applied.get("new_version", "unknown")
new_c = (applied.get("new_commit") or "unknown")[:7]
EventLogService.get().log(
"system",
f"Hermes Hub успешно обновлён с {prev_v} ({prev_c}) до {new_v} ({new_c})",
level="info",
)
acknowledge_last_applied_update()
logger.info("Recorded post-update event in EventLogService: %s -> %s", prev_c, new_c)
except Exception as exc:
logger.debug("Check last_applied_update on startup skipped: %s", exc)
while not _background_stop.is_set(): while not _background_stop.is_set():
try: try:
AccountProbeService.get().tick() AccountProbeService.get().tick()

View file

@ -195,7 +195,7 @@ function initEventListeners() {
const btnApplyUpdate = document.getElementById('btn-apply-update'); const btnApplyUpdate = document.getElementById('btn-apply-update');
if (btnApplyUpdate) { if (btnApplyUpdate) {
btnApplyUpdate.addEventListener('click', () => applyUpdate()); btnApplyUpdate.addEventListener('click', () => openUpdateModal('details'));
} }
// Preflight check listener // Preflight check listener
@ -585,6 +585,8 @@ async function saveAuthTokenFromPrompt() {
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот. // сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
const SILENT_ACTIONS = new Set([ const SILENT_ACTIONS = new Set([
'get_compression_status', 'get_compression_status',
'get_update_progress',
'cancel_update',
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth', 'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
'poll_redirect_auth', 'poll_device_auth', 'poll_redirect_auth', 'poll_device_auth',
]); ]);
@ -2510,6 +2512,7 @@ function closeModal() {
stopDeviceAuthPolling(); stopDeviceAuthPolling();
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно. // Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
stopRedirectAuthPolling(); stopRedirectAuthPolling();
stopUpdateProgressPolling();
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden'); if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
} }
@ -2575,7 +2578,33 @@ function applyTheme(theme) {
} }
} }
// ── UPDATE MANAGEMENT (P0-1 / In-App Updates) ── // ── UPDATE MANAGEMENT (A59 / In-App Updates) ──
let updateProgressInterval = null;
function stopUpdateProgressPolling() {
if (updateProgressInterval) {
clearInterval(updateProgressInterval);
updateProgressInterval = null;
}
}
function checkPostUpdateNotification() {
const applied = (currentSnapshot && currentSnapshot.last_applied_update) || (currentSettings && currentSettings.last_applied_update);
if (!applied || !applied.new_commit) return;
const key = 'hermes_notified_update_' + (applied.new_commit || applied.new_version);
if (!localStorage.getItem(key)) {
const prevC = applied.prev_commit ? applied.prev_commit.slice(0, 7) : '—';
const newC = applied.new_commit ? applied.new_commit.slice(0, 7) : '—';
const newV = applied.new_version || '0.1.3';
showToast(
`Hermes Hub успешно обновлён до версии ${newV} (сборка ${newC}). Предыдущая сборка: ${prevC}`,
'success',
10000
);
localStorage.setItem(key, '1');
}
}
async function checkUpdates(silent = false) { async function checkUpdates(silent = false) {
if (!silent) { if (!silent) {
showToast('Проверка обновлений...', 'info'); showToast('Проверка обновлений...', 'info');
@ -2585,11 +2614,20 @@ async function checkUpdates(silent = false) {
if (res && res.ok && res.data) { if (res && res.ok && res.data) {
latestUpdateInfo = res.data; latestUpdateInfo = res.data;
renderUpdateUI(); 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'); const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new');
showToast(`Доступно обновление (сборка ${c})`, 'info'); showToast(`Доступно обновление (сборка ${c})`, 'info');
openUpdateModal('details');
}
} else { } else {
if (!silent) {
showToast(res.data.message || 'Установлена последняя сборка', 'success'); showToast(res.data.message || 'Установлена последняя сборка', 'success');
} }
} }
@ -2719,9 +2757,11 @@ function renderUpdateUI() {
detailsBlock.classList.add('hidden'); detailsBlock.classList.add('hidden');
} }
} }
checkPostUpdateNotification();
} }
function openUpdateModal() { function openUpdateModal(mode = 'details') {
if (!latestUpdateInfo) { if (!latestUpdateInfo) {
checkUpdates(false); checkUpdates(false);
return; return;
@ -2731,62 +2771,240 @@ function openUpdateModal() {
? latestUpdateInfo.installed_commit.slice(0, 7) ? latestUpdateInfo.installed_commit.slice(0, 7)
: 'неизвестно'; : 'неизвестно';
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—'); 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'; // Блок «Что нового»: брать из 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.modalTitle) elements.modalTitle.textContent = 'Доступно обновление Hermes Hub';
if (mode === 'details') {
if (elements.modalBody) { if (elements.modalBody) {
elements.modalBody.innerHTML = ` elements.modalBody.innerHTML = `
<div class="update-modal-body"> <div class="update-modal-body">
<div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);"> <div style="display:flex; justify-content:space-between; margin-bottom:12px; padding:10px; background:var(--surface-muted); border-radius:var(--radius-sm);">
<div> <div>
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div> <div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(instC)}</div> <div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(curVer)} (${escapeHtml(instC)})</div>
</div> </div>
<div style="text-align:right;"> <div style="text-align:right;">
<div style="font-size:11px; color:var(--text-muted);">Новая сборка:</div> <div style="font-size:11px; color:var(--text-muted);">Новая версия:</div>
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(latC)}</div> <div style="font-weight:600; font-family:var(--font-mono); font-size:13px; color:var(--status-warning);">${escapeHtml(newVer)} (${escapeHtml(latC)})</div>
</div> </div>
</div> </div>
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);"> <div style="display:flex; justify-content:space-between; margin-bottom:10px; font-size:12px; color:var(--text-muted);">
Тег: <strong>${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')}</strong> <div>Размер загрузки: <strong style="color:var(--text-primary);">${escapeHtml(sizeText)}</strong></div>
${latestUpdateInfo.published_at ? ` &bull; Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''} ${latestUpdateInfo.published_at ? `<div>Дата: <strong>${escapeHtml(latestUpdateInfo.published_at.slice(0, 10))}</strong></div>` : ''}
</div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Список изменений (Release Notes):</div>
<div style="max-height:200px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono);">
${escapeHtml(latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.')}
</div> </div>
<div style="font-weight:600; font-size:12px; margin-bottom:4px;">Что нового:</div>
<div style="max-height:180px; overflow-y:auto; font-size:12px; line-height:1.4; white-space:pre-wrap; background:var(--surface-muted); padding:10px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); font-family:var(--font-mono); margin-bottom:12px;">${escapeHtml(notesText)}</div>
</div> </div>
`; `;
} }
if (elements.modalFooter) { if (elements.modalFooter) {
elements.modalFooter.innerHTML = ` elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button> <button class="btn btn-secondary" id="btn-modal-dismiss-update" onclick="dismissUpdateModal('${escapeHtml(versionKey)}')">Напомнить позже</button>
<button class="btn btn-primary" id="btn-modal-install-update" onclick="handleInstallUpdateFromModal()">Установить обновление</button> <button class="btn btn-primary" id="btn-modal-start-update" onclick="startUpdateProcess()">Обновить сейчас</button>
`; `;
} }
} 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(); showModal();
} }
async function handleInstallUpdateFromModal() { function dismissUpdateModal(versionKey) {
const btn = document.getElementById('btn-modal-install-update'); if (versionKey) {
if (btn) { localStorage.setItem('hermes_dismissed_update_' + versionKey, '1');
btn.disabled = true;
btn.textContent = 'Установка...';
} }
await applyUpdate();
closeModal(); closeModal();
} }
async function applyUpdate() { async function startUpdateProcess() {
showToast('Загрузка и запуск обновления...', 'info'); openUpdateModal('progress');
executeAction('apply_update', {});
stopUpdateProgressPolling();
updateProgressInterval = setInterval(pollUpdateProgress, 500);
}
async function pollUpdateProgress() {
try { try {
const res = await executeAction('apply_update', {}); const res = await executeAction('get_update_progress', {});
if (res && res.ok) { if (res && res.ok && res.data) {
showToast(res.message || 'Обновление запущено успешно!', 'success'); const p = res.data;
} else { renderUpdateProgressView(p);
showToast((res && res.message) || 'Ошибка установки обновления', 'error'); if (p.status === 'completed' || p.status === 'failed' || p.status === 'cancelled' || p.status === 'restarting') {
stopUpdateProgressPolling();
}
} }
} catch (err) { } catch (err) {
showToast(`Ошибка установки: ${err.message}`, 'error'); console.debug('Failed polling update progress:', err);
}
}
// Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу
// заменяем бегущим отрезком, а не заполняем целиком.
const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']);
function renderUpdateProgressView(p) {
if (!elements.modalBody) return;
const status = p.status || 'downloading';
const filename = p.filename || 'Пакет обновления';
const downloaded = p.downloaded_bytes || 0;
const total = p.total_bytes;
const percent = p.progress_percent;
const msg = p.message || '';
const error = p.error;
let progressDetail = '';
let barWidth = '0%';
let isIndeterminate = false;
const dlMb = (downloaded / 1048576).toFixed(1);
if (total && total > 0) {
const totMb = (total / 1048576).toFixed(1);
const pctVal = percent !== null && percent !== undefined ? percent.toFixed(1) : ((downloaded / total) * 100).toFixed(1);
progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`;
barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`;
} else {
// Размер неизвестен — доля не вычисляется. Полоса при этом не должна
// изображать процент: полная полоса читается как «готово». Активные этапы
// показываем бегущим отрезком, завершённые — сплошной полосой.
progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`;
isIndeterminate = true;
barWidth = '100%';
}
let statusBadge = `<span class="badge badge-status warning">Загрузка</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 === 'restarting') 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 === '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 = `
<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="font-weight:600; font-size:13px; color:var(--text-primary); word-break:break-all;">
${escapeHtml(filename)}
</div>
<div>${statusBadge}</div>
</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;">
${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 style="font-size:12px; color:var(--text-muted); font-family:var(--font-mono); margin-bottom:12px; display:flex; justify-content:space-between;">
<span>${escapeHtml(progressDetail)}</span>
</div>
${error ? `
<div style="background:rgba(239, 68, 68, 0.1); border:1px solid var(--status-danger); border-radius:var(--radius-sm); padding:10px; font-size:12px; color:var(--status-danger); margin-bottom:12px; white-space:pre-wrap;">
${escapeHtml(error)}
</div>
` : ''}
${status === 'restarting' || status === 'completed' ? `
<div style="background:rgba(34, 197, 94, 0.1); border:1px solid var(--status-healthy); border-radius:var(--radius-sm); padding:10px; font-size:12px; color:var(--status-healthy); margin-bottom:12px;">
${escapeHtml(msg || 'Обновление успешно установлено!')}
</div>
` : ''}
</div>
`;
if (elements.modalFooter) {
if (status === 'downloading') {
elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" id="btn-cancel-update" onclick="cancelUpdateProcess()">Отменить загрузку</button>
`;
} else if (status === 'failed' || status === 'cancelled') {
elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
<button class="btn btn-primary" onclick="startUpdateProcess()">Повторить</button>
`;
} else if (status === 'restarting' || status === 'installing' || status === 'verifying') {
elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" disabled>Установка в процессе...</button>
`;
} else {
elements.modalFooter.innerHTML = `
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
`;
}
}
}
async function cancelUpdateProcess() {
stopUpdateProgressPolling();
const cancelRes = await executeAction('cancel_update', {});
// Отмену могли не принять: после начала установки отменять уже нечего.
// Тогда возвращаем опрос обратно, иначе окно замрёт на последнем кадре и
// владелец решит, что установка встала.
const accepted = cancelRes && cancelRes.data && cancelRes.data.cancel_accepted;
if (!accepted) {
showToast((cancelRes && cancelRes.message) || 'Отменять уже нечего: установка идёт', 'warning');
updateProgressInterval = setInterval(pollUpdateProgress, 500);
}
const res = await executeAction('get_update_progress', {});
if (res && res.ok && res.data) {
renderUpdateProgressView(res.data);
} }
} }

View file

@ -5,24 +5,28 @@ Features:
- SHA-256 package cryptographic hash verification. - SHA-256 package cryptographic hash verification.
- Staged download without touching live executable. - Staged download without touching live executable.
- Hermetic backup and automatic rollback on corrupt/failing update. - 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). - Non-blocking execution and honest error reporting (rate limits, 404, network errors).
- Zero embedded developer PATs (safe public asset feed / signed release manifests). - Zero embedded developer PATs (safe public asset feed / signed release manifests).
""" """
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
import re import re
import shutil import shutil
import subprocess import subprocess
import threading
import sys import sys
import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Optional, Tuple from typing import Any, Callable, Dict, Optional, Tuple
@ -72,8 +76,6 @@ def _is_release_older(published_at: str, installed_at: str) -> bool:
if not published_at or not installed_at: if not published_at or not installed_at:
return False return False
try: try:
from datetime import datetime
def _parse(v: str): def _parse(v: str):
return datetime.fromisoformat(v.strip().replace("Z", "+00:00")) return datetime.fromisoformat(v.strip().replace("Z", "+00:00"))
@ -168,6 +170,30 @@ def extract_release_commit(release_data: Dict[str, Any]) -> str:
return "" 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 @dataclass
class UpdateManifest: class UpdateManifest:
version: str version: str
@ -180,6 +206,7 @@ class UpdateManifest:
changelog: Optional[str] = None changelog: Optional[str] = None
git_commit: Optional[str] = None git_commit: Optional[str] = None
assets: Dict[str, str] = field(default_factory=dict) assets: Dict[str, str] = field(default_factory=dict)
asset_sizes: Dict[str, int] = field(default_factory=dict)
@dataclass @dataclass
@ -194,6 +221,8 @@ class UpdateCheckResult:
changelog: Optional[str] = None changelog: Optional[str] = None
release_notes: Optional[str] = None release_notes: Optional[str] = None
assets: Dict[str, str] = field(default_factory=dict) 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 manifest: Optional[UpdateManifest] = None
error: Optional[str] = None error: Optional[str] = None
message: Optional[str] = None message: Optional[str] = None
@ -211,6 +240,8 @@ class UpdateCheckResult:
"changelog": self.changelog, "changelog": self.changelog,
"release_notes": self.release_notes, "release_notes": self.release_notes,
"assets": self.assets, "assets": self.assets,
"asset_sizes": self.asset_sizes,
"download_size": self.download_size,
"error": self.error, "error": self.error,
"message": self.message, "message": self.message,
"checked_at": self.checked_at, "checked_at": self.checked_at,
@ -268,9 +299,189 @@ def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool:
return False return False
def get_last_applied_update_path() -> Path:
"""Path to ~/.hermes/updates/last_applied_update.json."""
return paths.get_hermes_home() / "updates" / "last_applied_update.json"
def record_last_applied_update(
prev_version: str,
prev_commit: str,
new_version: str,
new_commit: str,
) -> None:
"""Save record of applied update for post-restart notification."""
try:
p = get_last_applied_update_path()
p.parent.mkdir(parents=True, exist_ok=True)
data = {
"prev_version": prev_version,
"prev_commit": prev_commit,
"new_version": new_version,
"new_commit": new_commit,
"updated_at": datetime.now(timezone.utc).isoformat(),
"acknowledged": False,
}
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
logger.info("Recorded last applied update: %s (%s) -> %s (%s)", prev_version, prev_commit[:7], new_version, new_commit[:7])
except Exception as exc:
logger.warning("Failed to record last_applied_update: %s", exc)
def get_last_applied_update() -> Optional[Dict[str, Any]]:
"""Retrieve last applied update info if present."""
try:
p = get_last_applied_update_path()
if p.is_file():
return json.loads(p.read_text(encoding="utf-8"))
except Exception as exc:
logger.debug("Failed reading last_applied_update: %s", exc)
return None
def acknowledge_last_applied_update() -> None:
"""Mark last applied update as acknowledged."""
try:
p = get_last_applied_update_path()
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8"))
data["acknowledged"] = True
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
except Exception as exc:
logger.debug("Failed acknowledging last_applied_update: %s", exc)
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
current_pid = os.getpid()
is_win = sys.platform == "win32"
if is_win:
try:
cmd = ["wmic", "process", "where", "name='HermesHubWeb.exe'", "get", "ProcessId"]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=5, **hidden_process_kwargs())
pids = []
if res.returncode == 0:
for line in res.stdout.splitlines():
val = line.strip()
if val.isdigit():
pid = int(val)
if pid != current_pid:
pids.append(pid)
for pid in pids:
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5, **hidden_process_kwargs())
except Exception:
pass
return True
except Exception as exc:
logger.debug("Windows stop_running_hub: %s", exc)
return True
else:
try:
uid = os.getuid()
pattern = "antigravity_provider.router.web|hermes_hub_web_entry"
res = subprocess.run(
["pgrep", "-u", str(uid), "-f", pattern],
capture_output=True,
text=True,
timeout=5,
)
if res.returncode != 0 or not res.stdout.strip():
logger.debug("No other hub processes found on Linux")
return True
pids = [int(p) for p in res.stdout.split() if p.strip().isdigit() and int(p) != current_pid]
if not pids:
return True
logger.info("Stopping hub processes for user %s: %s", uid, pids)
import signal
for pid in pids:
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
except Exception as e:
logger.debug("Failed to SIGTERM pid %s: %s", pid, e)
start_t = time.time()
alive = list(pids)
while alive and (time.time() - start_t) < timeout_sec:
time.sleep(0.5)
still_alive = []
for pid in alive:
try:
os.kill(pid, 0)
still_alive.append(pid)
except ProcessLookupError:
pass
alive = still_alive
if alive:
logger.warning("Hub processes still alive after %ss, sending SIGKILL: %s", timeout_sec, alive)
for pid in alive:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
except Exception as e:
logger.debug("Failed to SIGKILL pid %s: %s", pid, e)
return True
except Exception as exc:
logger.warning("Linux stop_running_hub error: %s", exc)
return True
# Почему отмена отклонена — своими словами для владельца, а не кодом состояния.
_CANCEL_REFUSAL = {
"installing": "Отмена невозможна: установка уже началась",
"restarting": "Отмена невозможна: Hermes Hub уже перезапускается",
"completed": "Отменять нечего: обновление уже установлено",
"failed": "Отменять нечего: обновление уже завершилось ошибкой",
"cancelled": "Загрузка уже отменена",
"idle": "Отменять нечего: обновление не запускалось",
}
def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
"""Вызвать обработчик хода загрузки, поддержав обе его формы.
Старая форма принимает одну долю (0..1), новая (скачано, всего).
Доли при неизвестном общем размере не существует, и подставлять вместо неё
ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
Старую форму в этом случае просто не зовём молчание честнее выдуманного
числа, а сам ход всё равно виден через _set_progress.
"""
if not cb:
return
known_total = bool(total and total > 0)
try:
sig = inspect.signature(cb)
single_arg = len(sig.parameters) == 1
except (TypeError, ValueError):
single_arg = False
if single_arg:
if known_total:
cb(downloaded / total)
return
try:
cb(downloaded, total)
except TypeError:
if known_total:
cb(downloaded / total)
class UpdateManager: class UpdateManager:
"""Manages update checks, package download, hash validation, and updater execution.""" """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_result: Optional[UpdateCheckResult] = None
_last_check_time: float = 0.0 _last_check_time: float = 0.0
@ -285,6 +496,85 @@ class UpdateManager:
def get_last_check_result(cls) -> Optional[UpdateCheckResult]: def get_last_check_result(cls) -> Optional[UpdateCheckResult]:
return cls._last_check_result return cls._last_check_result
@classmethod
def get_progress_dict(cls) -> Dict[str, Any]:
with cls._lock:
return cls._progress.to_dict()
@classmethod
def _set_progress(
cls,
status: str,
message: str = "",
filename: str = "",
downloaded_bytes: int = 0,
total_bytes: Optional[int] = None,
progress_percent: Optional[float] = None,
error: Optional[str] = None,
) -> None:
with cls._lock:
cls._progress = UpdateProgress(
status=status,
filename=filename if filename else cls._progress.filename,
downloaded_bytes=downloaded_bytes,
total_bytes=total_bytes,
progress_percent=progress_percent,
message=message if message else cls._progress.message,
error=error,
updated_at=time.time(),
)
@classmethod
def cancel_download(cls) -> Dict[str, Any]:
"""Отменить загрузку обновления и удалить недокачанные файлы.
Отмена допустима только пока идёт проверка или загрузка. Действие
`cancel_update` открыто в HTTP-API, и без этой проверки вызов во время
установки вычищал каталог staging вместе с файлом установщика, который
в этот момент исполняет bash: установка ломалась на середине, а ответ
«отменено» сообщал владельцу неправду о том, что происходит с машиной.
"""
with cls._lock:
current_status = cls._progress.status
if current_status not in ("checking", "downloading"):
refused = cls._progress.to_dict()
refused["cancel_accepted"] = False
refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get(
current_status,
f"Отмена невозможна на этапе «{current_status}»",
)
return refused
cls._cancel_event.set()
with cls._lock:
cls._progress = UpdateProgress(
status="cancelled",
filename=cls._progress.filename,
downloaded_bytes=0,
total_bytes=None,
progress_percent=None,
message="Загрузка обновления отменена пользователем",
error=None,
updated_at=time.time(),
)
try:
updates_dir = paths.get_hermes_home() / "updates"
staging_dir = updates_dir / "staging"
if staging_dir.exists():
for f in staging_dir.iterdir():
if f.is_file():
f.unlink(missing_ok=True)
except Exception as exc:
logger.debug("Clean staging dir on cancel failed: %s", exc)
accepted = cls.get_progress_dict()
accepted["cancel_accepted"] = True
return accepted
@classmethod
def cancel_update(cls) -> Dict[str, Any]:
"""Alias for cancel_download."""
return cls.cancel_download()
def get_status_dict(self) -> Dict[str, Any]: def get_status_dict(self) -> Dict[str, Any]:
installed_commit = get_installed_commit() installed_commit = get_installed_commit()
if self._last_check_result: if self._last_check_result:
@ -302,6 +592,8 @@ class UpdateManager:
"changelog": None, "changelog": None,
"release_notes": None, "release_notes": None,
"assets": {}, "assets": {},
"asset_sizes": {},
"download_size": None,
"error": None, "error": None,
"message": "Проверка обновлений еще не выполнялась", "message": "Проверка обновлений еще не выполнялась",
"checked_at": 0.0, "checked_at": 0.0,
@ -392,13 +684,16 @@ class UpdateManager:
body = str(data.get("body") or data.get("changelog") or "") body = str(data.get("body") or data.get("changelog") or "")
published_at = str(data.get("published_at") 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] = {} assets_map: Dict[str, str] = {}
asset_sizes: Dict[str, int] = {}
raw_assets = data.get("assets", []) raw_assets = data.get("assets", [])
if isinstance(raw_assets, list): if isinstance(raw_assets, list):
for asset in raw_assets: for asset in raw_assets:
if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset: if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset:
assets_map[asset["name"]] = asset["browser_download_url"] 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): elif isinstance(raw_assets, dict):
assets_map = dict(raw_assets) assets_map = dict(raw_assets)
@ -420,8 +715,27 @@ class UpdateManager:
changelog=body, changelog=body,
git_commit=latest_commit, git_commit=latest_commit,
assets=assets_map, 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 # Compare commits
inst_clean = installed_commit.strip().lower() inst_clean = installed_commit.strip().lower()
lat_clean = latest_commit.strip().lower() lat_clean = latest_commit.strip().lower()
@ -463,6 +777,8 @@ class UpdateManager:
changelog=body, changelog=body,
release_notes=body, release_notes=body,
assets=assets_map, assets=assets_map,
asset_sizes=asset_sizes,
download_size=chosen_size,
manifest=manifest, manifest=manifest,
error=None, error=None,
message=message, message=message,
@ -490,17 +806,45 @@ class UpdateManager:
self, self,
url: str, url: str,
dest_file: Path, dest_file: Path,
progress_cb: Optional[Callable[[float], None]] = None, progress_cb: Optional[Callable] = None,
) -> 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): if not is_allowed_update_host(url, allow_dev_local=False):
raise ValueError(f"Недопустимый хост пакета обновления: {url}") 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(): if url.startswith("file://") or Path(url).is_file():
local_src = Path(url.replace("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) 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: if progress_cb:
progress_cb(1.0) _call_progress_cb(progress_cb, downloaded, total_bytes)
return return
req = urllib.request.Request( req = urllib.request.Request(
@ -508,21 +852,53 @@ class UpdateManager:
headers={"User-Agent": f"HermesHub/{__version__}"}, headers={"User-Agent": f"HermesHub/{__version__}"},
) )
with urllib.request.urlopen(req, timeout=60) as resp: 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 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: 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) out_f.write(chunk)
downloaded += len(chunk) downloaded += len(chunk)
if progress_cb and total_len > 0: pct = ((downloaded / total_bytes) * 100.0) if (total_bytes and total_bytes > 0) else None
progress_cb(downloaded / total_len) 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( def download_and_verify(
self, self,
manifest: UpdateManifest, manifest: UpdateManifest,
progress_cb: Optional[Callable[[float], None]] = None, progress_cb: Optional[Callable] = None,
) -> Tuple[bool, str, Optional[Path]]: ) -> Tuple[bool, str, Optional[Path]]:
"""Download update package into staging and verify SHA-256 hash.""" """Download update package into staging and verify SHA-256 hash."""
self._cancel_event.clear()
self.staging_dir.mkdir(parents=True, exist_ok=True) self.staging_dir.mkdir(parents=True, exist_ok=True)
dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip" dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip"
@ -530,31 +906,54 @@ class UpdateManager:
self._download_file(manifest.package_url, dest_file, progress_cb) self._download_file(manifest.package_url, dest_file, progress_cb)
# Cryptographic SHA-256 Verification # 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) calc_hash = compute_sha256(dest_file)
if manifest.sha256 and calc_hash != manifest.sha256.lower(): if manifest.sha256 and calc_hash != manifest.sha256.lower():
dest_file.unlink(missing_ok=True) 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 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: except Exception as exc:
dest_file.unlink(missing_ok=True) 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( def install_latest_update(
self, self,
check_result: Optional[UpdateCheckResult] = None, check_result: Optional[UpdateCheckResult] = None,
progress_cb: Optional[Callable[[float], None]] = None, progress_cb: Optional[Callable] = None,
target_dir: Optional[Path] = None, target_dir: Optional[Path] = None,
) -> Tuple[bool, str]: ) -> Tuple[bool, str]:
"""Download installer or update package, verify checksums, apply and restart.""" """Download installer or update package, verify checksums, apply and restart."""
self._cancel_event.clear()
if check_result is None: if check_result is None:
self._set_progress(status="checking", message="Проверка наличия обновлений...")
check_result = self.check_for_updates() check_result = self.check_for_updates()
if check_result.error: if check_result.error:
self._set_progress(status="failed", error=check_result.error, message=check_result.error)
return False, f"Ошибка проверки обновлений: {check_result.error}" return False, f"Ошибка проверки обновлений: {check_result.error}"
if not check_result.update_available: if not check_result.update_available:
self._set_progress(status="idle", message="Обновление не требуется")
return False, "Обновление не требуется (установлена последняя сборка)" return False, "Обновление не требуется (установлена последняя сборка)"
self.staging_dir.mkdir(parents=True, exist_ok=True) self.staging_dir.mkdir(parents=True, exist_ok=True)
@ -573,6 +972,8 @@ class UpdateManager:
sha = parts[0].strip().lower() sha = parts[0].strip().lower()
fname = parts[1].lstrip("*").strip().lower() fname = parts[1].lstrip("*").strip().lower()
checksums_map[fname] = sha checksums_map[fname] = sha
except InterruptedError:
return False, "Загрузка обновления отменена пользователем"
except Exception as e: except Exception as e:
logger.warning("Failed to download or parse checksums.txt: %s", e) logger.warning("Failed to download or parse checksums.txt: %s", e)
@ -605,17 +1006,30 @@ class UpdateManager:
chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip" 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: 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 # 3. Download target asset into staging
dest_file = self.staging_dir / chosen_asset_name dest_file = self.staging_dir / chosen_asset_name
try: try:
self._download_file(chosen_url, dest_file, progress_cb) 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: except Exception as exc:
dest_file.unlink(missing_ok=True) 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 # 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) calc_sha = compute_sha256(dest_file)
expected_sha = checksums_map.get(chosen_asset_name.lower()) expected_sha = checksums_map.get(chosen_asset_name.lower())
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:
@ -627,26 +1041,59 @@ class UpdateManager:
# исполняемый код, поэтому непроверенный файл не запускаем вовсе. # исполняемый код, поэтому непроверенный файл не запускаем вовсе.
if not expected_sha: if not expected_sha:
dest_file.unlink(missing_ok=True) dest_file.unlink(missing_ok=True)
return ( err = (
False,
f"Не удалось получить контрольную сумму для {chosen_asset_name}: " f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
"в релизе нет checksums.txt или файл не скачался. " "в релизе нет checksums.txt или файл не скачался. "
"Установка отменена — непроверенный файл не запускается." "Установка отменена — непроверенный файл не запускается."
) )
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
if calc_sha != expected_sha: if calc_sha != expected_sha:
dest_file.unlink(missing_ok=True) dest_file.unlink(missing_ok=True)
return ( err = (
False,
f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! " f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! "
f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена." f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена."
) )
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
# 5. Stop running hub services before applying update
self._set_progress(
status="installing",
filename=chosen_asset_name,
message="Остановка работающих служб хаба...",
)
stop_running_hub()
# Record metadata for post-restart notification
prev_v = __version__
prev_c = get_installed_commit()
new_v = check_result.latest_version
new_c = check_result.latest_commit or ""
# 6. Apply update based on file type
self._set_progress(
status="installing",
filename=chosen_asset_name,
message=f"Установка пакета {chosen_asset_name}...",
)
def _record_success():
record_last_applied_update(
prev_version=prev_v,
prev_commit=prev_c,
new_version=new_v,
new_commit=new_c,
)
# 5. Apply update based on file type
if chosen_asset_name.endswith(".zip"): 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)
return False, msg return False, msg
_record_success()
self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
return True, "Обновление успешно установлено" return True, "Обновление успешно установлено"
elif chosen_asset_name == "HermesHubSetup.exe": elif chosen_asset_name == "HermesHubSetup.exe":
@ -659,15 +1106,25 @@ class UpdateManager:
try: try:
rc = proc.wait(timeout=600) rc = proc.wait(timeout=600)
except subprocess.TimeoutExpired: 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: if rc != 0:
return False, f"Установщик завершился с кодом {rc}. Обновление не применено." err = f"Установщик завершился с кодом {rc}. Обновление не применено."
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
_record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart() ok_r, msg_r = self.schedule_restart()
if not ok_r: if not ok_r:
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
return True, f"Обновление установлено. {msg_r}" return True, f"Обновление установлено. {msg_r}"
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
return True, "Обновление установлено, Hermes Hub перезапускается." return True, "Обновление установлено, Hermes Hub перезапускается."
except Exception as exc: 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"): elif chosen_asset_name.endswith(".sh"):
try: try:
@ -680,18 +1137,32 @@ class UpdateManager:
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:]
return False, "Установка не удалась: " + " / ".join(tail) if tail:
err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail)
else:
err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил"
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
return False, err
_record_success()
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
ok_r, msg_r = self.schedule_restart() ok_r, msg_r = self.schedule_restart()
if not ok_r: if not ok_r:
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
return True, f"Обновление установлено. {msg_r}" return True, f"Обновление установлено. {msg_r}"
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
return True, "Обновление установлено, Hermes Hub перезапускается." return True, "Обновление установлено, Hermes Hub перезапускается."
except Exception as exc: 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, "Файл обновления загружен и проверен" 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]:
"""Перезапустить веб-хаб после установки обновления. """Перезапустить веб-хаб после установки обновления.

View file

@ -0,0 +1,508 @@
"""Hermes Hub — Task A59 Visible Update & Completion Engine Test Suite.
Verifies:
1. P0-1: Startup check shows modal when update available; remains silent when no update; dismissal remembered in localStorage.
2. P0-2: Visible download progress tracking; honest None without fake percentages when Content-Length is missing; cancel download deletes partial file and sets cancelled status; SHA-256 verification and failure rejection.
3. P0-3: Isolated stop_running_hub (killing only own user hub processes and excluding current PID); apply_update_sync atomic rollback on corrupted update package.
4. P0-4: Saving last_applied_update.json, EventLogService notification on restart, UI notification contract, and running_commit integrity.
5. P0-5: Absence of update polling loops; get_update_progress and cancel_update in SILENT_ACTIONS.
"""
from __future__ import annotations
import io
import json
import os
import shutil
import sys
import tempfile
import urllib.error
import zipfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.web.server import app
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider.updater.update_manager import (
UpdateManager,
UpdateManifest,
UpdateCheckResult,
UpdateProgress,
compute_sha256,
get_installed_commit,
get_last_applied_update,
record_last_applied_update,
acknowledge_last_applied_update,
stop_running_hub,
)
from antigravity_provider.version import __version__
APP_JS_PATH = (
Path(__file__).resolve().parent.parent
/ "src"
/ "antigravity_provider"
/ "router"
/ "web"
/ "static"
/ "app.js"
)
@pytest.fixture
def client():
return TestClient(app)
# ── TEST 1: P0-1 Modal upon Startup and Dismissal Contract in app.js ──
@pytest.mark.unit
def test_p0_1_app_js_modal_on_startup_and_dismiss_contract():
"""Verify app.js opens update modal on checkUpdates(true) unless dismissed in localStorage."""
src = APP_JS_PATH.read_text(encoding="utf-8")
# 1. Startup check in checkUpdates
assert "async function checkUpdates(silent = false)" in src
assert "hermes_dismissed_update_" in src, "app.js must check localStorage for dismissed version"
assert "openUpdateModal('details')" in src or "openUpdateModal()" in src
# 2. Details modal content: version, what's new, download size
assert "Что нового" in src
assert "Н/Д: описание не приложено" in src, "app.js must output honest N/A when changelog is empty"
assert "Н/Д: размер не указан" in src, "app.js must output honest N/A when download size is unknown"
assert "Напомнить позже" in src, "app.js must have dismiss/remind later button"
assert "Обновить сейчас" in src, "app.js must have start update button"
# 3. Dismiss function saves to localStorage
assert "function dismissUpdateModal" in src
assert "localStorage.setItem('hermes_dismissed_update_'" in src
# ── TEST 2: P0-2 Real-Time Progress Tracking & Honest None Without Content-Length ──
@pytest.mark.unit
def test_p0_2_progress_tracking_with_and_without_content_length(tmp_path, monkeypatch):
"""Verify progress tracking: percentage with Content-Length, honest None without Content-Length."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
mgr = UpdateManager()
# 1. Initial idle state
prog = UpdateManager.get_progress_dict()
assert prog["status"] in ("idle", "checking")
assert "downloaded_bytes" in prog
# 2. Download with known Content-Length
test_payload = b"X" * 1024 * 100 # 100 KB
src_file = tmp_path / "remote_pkg.zip"
src_file.write_bytes(test_payload)
dest_file = tmp_path / "downloaded_pkg.zip"
# Mock urllib response with Content-Length
class MockResponseWithLen:
def __init__(self):
self.headers = {"content-length": str(len(test_payload))}
self._data = io.BytesIO(test_payload)
def read(self, amt=65536):
return self._data.read(amt)
def __enter__(self):
return self
def __exit__(self, *args):
pass
with patch("urllib.request.urlopen", return_value=MockResponseWithLen()):
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/pkg.zip", dest_file)
prog_after = UpdateManager.get_progress_dict()
assert prog_after["downloaded_bytes"] == len(test_payload)
assert prog_after["total_bytes"] == len(test_payload)
assert prog_after["progress_percent"] == 100.0
# 3. Download WITHOUT Content-Length (or 0) -> Honest None, no fake percentages!
dest_file_no_len = tmp_path / "no_len_pkg.zip"
class MockResponseWithoutLen:
def __init__(self):
self.headers = {} # No content-length header!
self._data = io.BytesIO(test_payload)
def read(self, amt=65536):
return self._data.read(amt)
def __enter__(self):
return self
def __exit__(self, *args):
pass
with patch("urllib.request.urlopen", return_value=MockResponseWithoutLen()):
mgr._download_file("https://github.com/ochenstarik-ui/hermes-hub/releases/download/v0.1.3/no_len.zip", dest_file_no_len)
prog_no_len = UpdateManager.get_progress_dict()
assert prog_no_len["downloaded_bytes"] == len(test_payload)
assert prog_no_len["total_bytes"] is None, "total_bytes must be None when Content-Length is missing"
assert prog_no_len["progress_percent"] is None, "progress_percent must be None when total is unknown"
# ── TEST 3: P0-2 Download Cancellation Cleans Staging and Sets Cancelled Status ──
@pytest.mark.unit
def test_p0_2_cancel_download_cleans_file_and_sets_cancelled_status(tmp_path, monkeypatch):
"""Cancelling update sets status to cancelled, interrupts loop, and removes partial file."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
mgr = UpdateManager()
staging_file = mgr.staging_dir / "partial_download.zip"
staging_file.parent.mkdir(parents=True, exist_ok=True)
staging_file.write_bytes(b"Partial download data 12345")
# Отмена осмысленна только пока идёт загрузка — ставим это состояние явно,
# иначе тест проверял бы отмену того, чего не происходит.
UpdateManager._set_progress(
status="downloading",
filename="partial_download.zip",
downloaded_bytes=27,
message="Скачивание partial_download.zip...",
)
# Trigger cancel
cancel_res = UpdateManager.cancel_download()
assert cancel_res["status"] == "cancelled"
assert cancel_res["cancel_accepted"] is True
assert "отменена" in (cancel_res["message"] or "").lower()
assert not staging_file.exists(), "Partially downloaded file in staging must be removed upon cancellation"
# Also test ActionExecutor 'cancel_update'
UpdateManager._set_progress(status="downloading", filename="partial_download.zip")
action_res = ActionExecutor.execute("cancel_update", {})
assert action_res["ok"] is True
assert action_res["data"]["status"] == "cancelled"
# ── TEST 3b: отмена после начала установки отклоняется, а не врёт ──
@pytest.mark.unit
def test_p0_2_cancel_refused_after_install_started(tmp_path, monkeypatch):
"""Отмена во время установки не трогает staging и честно сообщает отказ.
Действие cancel_update открыто в HTTP-API. Раньше вызов на этапе installing
чистил каталог staging вместе с исполняемым в этот момент установщиком и
отвечал «отменено», хотя установка продолжалась.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
mgr = UpdateManager()
installer = mgr.staging_dir / "hermes-hub-setup.sh"
installer.parent.mkdir(parents=True, exist_ok=True)
installer.write_bytes(b"#!/bin/bash\necho installing\n")
UpdateManager._set_progress(
status="installing",
filename="hermes-hub-setup.sh",
message="Установка пакета hermes-hub-setup.sh...",
)
res = UpdateManager.cancel_download()
assert res["cancel_accepted"] is False
assert res["status"] == "installing", "Статус не должен подменяться на cancelled"
assert "установка уже началась" in res["cancel_refused_reason"].lower()
assert installer.exists(), "Файл исполняемого установщика удалять нельзя"
action_res = ActionExecutor.execute("cancel_update", {})
assert action_res["ok"] is False
assert "отмена невозможна" in action_res["message"].lower()
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
# ── TEST 4: P0-2 SHA-256 Mismatch Rejection and Failure Status ──
@pytest.mark.unit
def test_p0_2_sha256_mismatch_aborts_and_sets_failed_status(tmp_path, monkeypatch):
"""When SHA-256 hash does not match, download is aborted, file deleted, and status set to failed."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
pkg_file = tmp_path / "pkg.zip"
with zipfile.ZipFile(pkg_file, "w") as zf:
zf.writestr("code.py", "print('hello')")
manifest = UpdateManifest(
version="0.1.4",
channel="stable",
package_url=f"file://{pkg_file}",
sha256="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", # wrong hash
)
mgr = UpdateManager()
ok, msg, dest = mgr.download_and_verify(manifest)
assert ok is False
assert "mismatch" in msg.lower() or "не совпала" in msg.lower()
assert dest is None
prog = UpdateManager.get_progress_dict()
assert prog["status"] == "failed"
assert prog["error"] is not None
# ── TEST 5: P0-3 Process Isolation stop_running_hub ──
@pytest.mark.unit
def test_p0_3_stop_running_hub_isolates_user_and_excludes_current_pid():
"""stop_running_hub filters by current UID on Linux and never targets own PID."""
current_pid = os.getpid()
# Mock subprocess.run for pgrep
with patch("subprocess.run") as mock_run:
# Simulate pgrep returning other PID and own PID
mock_run.return_value = MagicMock(returncode=0, stdout=f"99999 {current_pid}\n")
with patch("os.kill") as mock_kill:
stop_running_hub(timeout_sec=0.1)
# Check that kill was called on 99999 but NEVER on current_pid
killed_pids = [call.args[0] for call in mock_kill.call_args_list]
assert 99999 in killed_pids
assert current_pid not in killed_pids, "stop_running_hub must never kill current PID"
# ── TEST 6: P0-3 apply_update_sync Rollback on Corruption ──
@pytest.mark.unit
def test_p0_3_apply_update_sync_rollback_on_failure(tmp_path, monkeypatch):
"""apply_update_sync restores files from backup if update package fails validation."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
app_dir = tmp_path / "app"
src_dir = app_dir / "src" / "antigravity_provider"
src_dir.mkdir(parents=True, exist_ok=True)
(src_dir / "version.py").write_text('__version__ = "0.1.3"\n', encoding="utf-8")
# Corrupt zip with python syntax error
corrupt_zip = tmp_path / "corrupt_pkg.zip"
with zipfile.ZipFile(corrupt_zip, "w") as zf:
zf.writestr("src/antigravity_provider/version.py", "INVALID SYNTAX ???!!!")
mgr = UpdateManager()
ok, msg = mgr.apply_update_sync(corrupt_zip, target_dir=app_dir)
assert ok is False
assert "откат" in msg.lower() or "rollback" in msg.lower()
# Verify original version was restored
restored = (src_dir / "version.py").read_text(encoding="utf-8")
assert '__version__ = "0.1.3"' in restored
# ── TEST 7: P0-4 last_applied_update.json and EventLogService ──
@pytest.mark.unit
def test_p0_4_last_applied_update_recording_and_event_logging(tmp_path, monkeypatch, client):
"""Test recording last applied update, server settings/snapshot contract, and EventLogService."""
hermes_home = tmp_path / "hermes"
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# 1. Record update
record_last_applied_update(
prev_version="0.1.2",
prev_commit="aaaaaaa1111111",
new_version="0.1.3",
new_commit="bbbbbbb2222222",
)
applied = get_last_applied_update()
assert applied is not None
assert applied["prev_version"] == "0.1.2"
assert applied["prev_commit"] == "aaaaaaa1111111"
assert applied["new_version"] == "0.1.3"
assert applied["new_commit"] == "bbbbbbb2222222"
assert applied["acknowledged"] is False
# 2. Check EventLogService logging contract
EventLogService.get().log(
"system",
f"Hermes Hub успешно обновлён с {applied['prev_version']} ({applied['prev_commit'][:7]}) до {applied['new_version']} ({applied['new_commit'][:7]})",
level="info",
)
events = EventLogService.get().get_events(category="system", limit=10)
found = any("Hermes Hub успешно обновлён" in (getattr(e, "message", None) or "") for e in events)
assert found is True
# 3. Acknowledge update
acknowledge_last_applied_update()
applied_after = get_last_applied_update()
assert applied_after["acknowledged"] is True
# 4. Check GET /api/settings includes last_applied_update
res = client.get("/api/settings")
assert res.status_code == 200
data = res.json()
assert "last_applied_update" in data
assert data["last_applied_update"]["new_version"] == "0.1.3"
# ── TEST 8: P0-5 Silent Actions and No Polling Loops ──
@pytest.mark.unit
def test_p0_5_silent_actions_and_no_interval_polling():
"""Verify get_update_progress and cancel_update are in SILENT_ACTIONS and no global update intervals exist."""
src = APP_JS_PATH.read_text(encoding="utf-8")
# 1. Check SILENT_ACTIONS
assert "'get_update_progress'" in src
assert "'cancel_update'" in src
# 2. Check that there is NO setInterval for checkUpdates
assert "setInterval(checkUpdates" not in src
assert "setInterval(() => checkUpdates" not in src
assert "setInterval(function() { checkUpdates" not in src
# 3. ActionExecutor get_update_progress
res_prog = ActionExecutor.execute("get_update_progress", {})
assert res_prog["ok"] is True
assert "data" in res_prog
assert "status" in res_prog["data"]
# ── TEST 9: P0-4 провалившаяся установка не выдаёт себя за успешную ──
@pytest.mark.unit
def test_p0_4_failed_install_records_nothing_and_names_exit_code(tmp_path, monkeypatch):
"""Установщик упал — записи о применённом обновлении быть не должно.
record_last_applied_update вызывался ДО запуска установщика. При падении
запись оставалась на диске, и при следующем старте хаб писал в журнал
«успешно обновлён», а интерфейс показывал тост об успехе владельцу
сообщали о версии, которая не установилась.
Заодно проверяется, что причина отказа не пустая: установщик может
завершиться, не сказав ни слова, и сообщение «Установка не удалась: »
не давало ни одного признака причины.
"""
import hashlib
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
installer = tmp_path / "hermes-hub-setup.sh"
installer.write_bytes(b"#!/bin/bash\nexit 3\n")
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult(
update_available=True,
current_version="0.1.3",
latest_version="0.1.4",
latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa",
assets={
"hermes-hub-setup.sh": f"file://{installer}",
"checksums.txt": "file:///nonexistent",
},
)
mgr = UpdateManager()
real_download = UpdateManager._download_file
def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt":
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
return
return real_download(self, url, dest, progress_cb)
with patch.object(UpdateManager, "_download_file", fake_download):
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
ok, msg = mgr.install_latest_update(check_result=check_result)
assert ok is False
assert get_last_applied_update() is None, (
"После провалившейся установки записи о применённом обновлении быть не должно"
)
assert "код 3" in msg, f"Причина отказа должна называть код возврата, получено: {msg!r}"
prog = UpdateManager.get_progress_dict()
assert prog["status"] == "failed"
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
# ── TEST 10: P0-4 успешная установка запись всё-таки делает ──
@pytest.mark.unit
def test_p0_4_successful_install_records_previous_and_new_build(tmp_path, monkeypatch):
"""Успех записывает и «было», и «стало», причём «было» снято до установки."""
import hashlib
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HERMES_HUB_DEV_MODE", "1")
installer = tmp_path / "hermes-hub-setup.sh"
installer.write_bytes(b"#!/bin/bash\nexit 0\n")
sha = hashlib.sha256(installer.read_bytes()).hexdigest()
check_result = UpdateCheckResult(
update_available=True,
current_version="0.1.3",
latest_version="0.1.4",
latest_commit="deadbeefdeadbeef",
installed_commit="0000000aaaa",
assets={
"hermes-hub-setup.sh": f"file://{installer}",
"checksums.txt": "file:///nonexistent",
},
)
mgr = UpdateManager()
real_download = UpdateManager._download_file
def fake_download(self, url, dest, progress_cb=None):
if dest.name == "checksums.txt":
dest.write_text(f"{sha} hermes-hub-setup.sh\n", encoding="utf-8")
return
return real_download(self, url, dest, progress_cb)
with patch.object(UpdateManager, "_download_file", fake_download):
with patch("antigravity_provider.updater.update_manager.stop_running_hub", return_value=True):
with patch.object(UpdateManager, "schedule_restart", return_value=(True, "перезапуск запущен")):
ok, msg = mgr.install_latest_update(check_result=check_result)
assert ok is True
rec = get_last_applied_update()
assert rec is not None
assert rec["new_version"] == "0.1.4"
assert rec["new_commit"] == "deadbeefdeadbeef"
assert rec["prev_commit"] != rec["new_commit"], (
"«Было» снимается до установки, иначе прежняя сборка совпадёт с новой"
)
assert rec["acknowledged"] is False
UpdateManager._set_progress(status="idle", message="Готов к обновлению")
# ── TEST 11: P0-2 полоса не изображает процент при неизвестном размере ──
@pytest.mark.unit
def test_p0_2_app_js_indeterminate_bar_when_size_unknown():
"""При неизвестном размере полоса бежит, а не заполняется целиком.
Текст рядом был честным («Н/Д: сервер не сообщил размер»), а полоса при этом
рисовалась на всю ширину: `downloaded > 0 ? '100%' : '20%'`. Полная полоса
читается как «готово» тот же выдуманный процент, только нарисованный.
"""
src = APP_JS_PATH.read_text(encoding="utf-8")
assert "downloaded > 0 ? '100%' : '20%'" not in src, (
"Полоса не должна заполняться на всю ширину при неизвестном размере"
)
assert "Н/Д: сервер не сообщил размер" in src
assert "INDETERMINATE_ACTIVE_STATUSES" in src, "нужен список этапов с неопределённой полосой"
assert "@keyframes indeterminate-bar" in src, "нужна анимация бегущего отрезка"
assert "indeterminate-bar-style" in src, "стиль вставляется один раз по id"
# ── TEST 12: P0-2 интерфейс не принимает отказ в отмене за отмену ──
@pytest.mark.unit
def test_p0_2_app_js_handles_refused_cancel():
"""Отказ в отмене возвращает опрос хода, а не оставляет окно замершим."""
src = APP_JS_PATH.read_text(encoding="utf-8")
assert "cancel_accepted" in src, "app.js обязан различать принятую и отклонённую отмену"
idx = src.index("async function cancelUpdateProcess()")
tail = src[idx:idx + 1200]
assert "setInterval(pollUpdateProgress" in tail, (
"после отклонённой отмены опрос хода загрузки должен возобновляться"
)