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:
commit
2f353778c2
5 changed files with 1318 additions and 78 deletions
|
|
@ -1364,6 +1364,22 @@ class ActionExecutor:
|
|||
status = mgr.get_status_dict()
|
||||
return {'ok': True, 'message': status.get('message') or 'Статус получен', 'data': status}
|
||||
|
||||
elif action == 'get_update_progress':
|
||||
mgr = UpdateManager()
|
||||
progress = mgr.get_progress_dict()
|
||||
return {'ok': True, 'message': progress.get('message') or 'Ход обновления', 'data': progress}
|
||||
|
||||
elif action == 'cancel_update':
|
||||
# Отмена принимается не всегда: после начала установки отменять уже
|
||||
# нечего. Отвечаем тем, что произошло на самом деле, иначе владелец
|
||||
# видит «отменено» поверх продолжающейся установки.
|
||||
mgr = UpdateManager()
|
||||
progress = mgr.cancel_download()
|
||||
if progress.get('cancel_accepted'):
|
||||
return {'ok': True, 'message': 'Загрузка обновления отменена', 'data': progress}
|
||||
reason = progress.get('cancel_refused_reason') or 'Отмена сейчас невозможна'
|
||||
return {'ok': False, 'message': reason, 'data': progress}
|
||||
|
||||
elif action == 'run_preflight':
|
||||
from antigravity_provider.router.preflight_service import PreflightCheckService
|
||||
service = PreflightCheckService.get()
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ from antigravity_provider.router.state_store import HubStateStore
|
|||
from antigravity_provider.router.action_handler import ActionExecutor
|
||||
from antigravity_provider.router.router_config import load_router_config
|
||||
|
||||
from antigravity_provider.updater.update_manager import get_installed_commit, get_installed_build_time, UpdateManager
|
||||
from antigravity_provider.updater.update_manager import (
|
||||
get_installed_commit,
|
||||
get_installed_build_time,
|
||||
get_last_applied_update,
|
||||
acknowledge_last_applied_update,
|
||||
UpdateManager,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("hermes.router.web")
|
||||
|
||||
|
|
@ -258,6 +264,7 @@ def get_snapshot(authorized: bool = Depends(get_auth_token)):
|
|||
"config_dir": str(paths.get_config_dir()),
|
||||
"log_file": str(paths.get_log_file()),
|
||||
}
|
||||
snap_dict["last_applied_update"] = get_last_applied_update()
|
||||
try:
|
||||
from antigravity_provider.router.agy_eligibility_service import AgyEligibilityService
|
||||
snap_dict["agy_eligibility"] = AgyEligibilityService.get().check_eligibility_state(force=False)
|
||||
|
|
@ -527,6 +534,7 @@ def get_settings(authorized: bool = Depends(get_auth_token)):
|
|||
"installed_at": get_installed_build_time(),
|
||||
"version": __version__,
|
||||
"last_update_check": last_check.to_dict() if last_check else None,
|
||||
"last_applied_update": get_last_applied_update(),
|
||||
"network_security": {
|
||||
"is_external_bind": is_external,
|
||||
"is_tls": False,
|
||||
|
|
@ -699,6 +707,25 @@ def _background_refresh_loop() -> None:
|
|||
except Exception as exc:
|
||||
logger.debug("Initial background update check skipped: %s", exc)
|
||||
|
||||
# Check if update was just applied and log event to EventLogService
|
||||
try:
|
||||
from antigravity_provider.router.unified_health import EventLogService
|
||||
applied = get_last_applied_update()
|
||||
if applied and not applied.get("acknowledged"):
|
||||
prev_v = applied.get("prev_version", "unknown")
|
||||
prev_c = (applied.get("prev_commit") or "unknown")[:7]
|
||||
new_v = applied.get("new_version", "unknown")
|
||||
new_c = (applied.get("new_commit") or "unknown")[:7]
|
||||
EventLogService.get().log(
|
||||
"system",
|
||||
f"Hermes Hub успешно обновлён с {prev_v} ({prev_c}) до {new_v} ({new_c})",
|
||||
level="info",
|
||||
)
|
||||
acknowledge_last_applied_update()
|
||||
logger.info("Recorded post-update event in EventLogService: %s -> %s", prev_c, new_c)
|
||||
except Exception as exc:
|
||||
logger.debug("Check last_applied_update on startup skipped: %s", exc)
|
||||
|
||||
while not _background_stop.is_set():
|
||||
try:
|
||||
AccountProbeService.get().tick()
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ function initEventListeners() {
|
|||
|
||||
const btnApplyUpdate = document.getElementById('btn-apply-update');
|
||||
if (btnApplyUpdate) {
|
||||
btnApplyUpdate.addEventListener('click', () => applyUpdate());
|
||||
btnApplyUpdate.addEventListener('click', () => openUpdateModal('details'));
|
||||
}
|
||||
|
||||
// Preflight check listener
|
||||
|
|
@ -585,6 +585,8 @@ async function saveAuthTokenFromPrompt() {
|
|||
// сам себя. Поэтому опросы не только молчат, но и не дёргают снапшот.
|
||||
const SILENT_ACTIONS = new Set([
|
||||
'get_compression_status',
|
||||
'get_update_progress',
|
||||
'cancel_update',
|
||||
'poll_native_auth', 'poll_native_agy_login', 'poll_terminal_auth',
|
||||
'poll_redirect_auth', 'poll_device_auth',
|
||||
]);
|
||||
|
|
@ -2510,6 +2512,7 @@ function closeModal() {
|
|||
stopDeviceAuthPolling();
|
||||
// Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно.
|
||||
stopRedirectAuthPolling();
|
||||
stopUpdateProgressPolling();
|
||||
if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden');
|
||||
}
|
||||
|
||||
|
|
@ -2575,7 +2578,33 @@ function applyTheme(theme) {
|
|||
}
|
||||
}
|
||||
|
||||
// ── UPDATE MANAGEMENT (P0-1 / In-App Updates) ──
|
||||
// ── UPDATE MANAGEMENT (A59 / In-App Updates) ──
|
||||
let updateProgressInterval = null;
|
||||
|
||||
function stopUpdateProgressPolling() {
|
||||
if (updateProgressInterval) {
|
||||
clearInterval(updateProgressInterval);
|
||||
updateProgressInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkPostUpdateNotification() {
|
||||
const applied = (currentSnapshot && currentSnapshot.last_applied_update) || (currentSettings && currentSettings.last_applied_update);
|
||||
if (!applied || !applied.new_commit) return;
|
||||
const key = 'hermes_notified_update_' + (applied.new_commit || applied.new_version);
|
||||
if (!localStorage.getItem(key)) {
|
||||
const prevC = applied.prev_commit ? applied.prev_commit.slice(0, 7) : '—';
|
||||
const newC = applied.new_commit ? applied.new_commit.slice(0, 7) : '—';
|
||||
const newV = applied.new_version || '0.1.3';
|
||||
showToast(
|
||||
`Hermes Hub успешно обновлён до версии ${newV} (сборка ${newC}). Предыдущая сборка: ${prevC}`,
|
||||
'success',
|
||||
10000
|
||||
);
|
||||
localStorage.setItem(key, '1');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUpdates(silent = false) {
|
||||
if (!silent) {
|
||||
showToast('Проверка обновлений...', 'info');
|
||||
|
|
@ -2585,11 +2614,20 @@ async function checkUpdates(silent = false) {
|
|||
if (res && res.ok && res.data) {
|
||||
latestUpdateInfo = res.data;
|
||||
renderUpdateUI();
|
||||
if (!silent) {
|
||||
if (res.data.update_available) {
|
||||
if (res.data.update_available) {
|
||||
const versionKey = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.latest_version || res.data.release_tag || '');
|
||||
if (silent) {
|
||||
const dismissed = localStorage.getItem('hermes_dismissed_update_' + versionKey);
|
||||
if (!dismissed) {
|
||||
openUpdateModal('details');
|
||||
}
|
||||
} else {
|
||||
const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new');
|
||||
showToast(`Доступно обновление (сборка ${c})`, 'info');
|
||||
} else {
|
||||
openUpdateModal('details');
|
||||
}
|
||||
} else {
|
||||
if (!silent) {
|
||||
showToast(res.data.message || 'Установлена последняя сборка', 'success');
|
||||
}
|
||||
}
|
||||
|
|
@ -2719,9 +2757,11 @@ function renderUpdateUI() {
|
|||
detailsBlock.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
checkPostUpdateNotification();
|
||||
}
|
||||
|
||||
function openUpdateModal() {
|
||||
function openUpdateModal(mode = 'details') {
|
||||
if (!latestUpdateInfo) {
|
||||
checkUpdates(false);
|
||||
return;
|
||||
|
|
@ -2731,62 +2771,240 @@ function openUpdateModal() {
|
|||
? latestUpdateInfo.installed_commit.slice(0, 7)
|
||||
: 'неизвестно';
|
||||
const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—');
|
||||
const curVer = latestUpdateInfo.current_version || (currentSettings && currentSettings.version) || '0.1.3';
|
||||
const newVer = latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || curVer;
|
||||
const versionKey = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.latest_version || latestUpdateInfo.release_tag || '');
|
||||
|
||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub';
|
||||
if (elements.modalBody) {
|
||||
elements.modalBody.innerHTML = `
|
||||
<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>
|
||||
<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>
|
||||
<div style="text-align:right;">
|
||||
<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>
|
||||
</div>
|
||||
<div style="margin-bottom:8px; font-size:12px; color:var(--text-muted);">
|
||||
Тег: <strong>${escapeHtml(latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || '—')}</strong>
|
||||
${latestUpdateInfo.published_at ? ` • Дата: ${escapeHtml(latestUpdateInfo.published_at)}` : ''}
|
||||
</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>
|
||||
`;
|
||||
// Блок «Что нового»: брать из changelog / release_notes; если пусто — выводить Н/Д: описание не приложено
|
||||
const rawNotes = (latestUpdateInfo.changelog || latestUpdateInfo.release_notes || '').trim();
|
||||
const notesText = rawNotes || 'Н/Д: описание не приложено';
|
||||
|
||||
// Размер загрузки: размер файла из assets / заголовков; если неизвестен — Н/Д: размер не указан
|
||||
let sizeText = 'Н/Д: размер не указан';
|
||||
if (latestUpdateInfo.download_size && latestUpdateInfo.download_size > 0) {
|
||||
sizeText = (latestUpdateInfo.download_size / 1048576).toFixed(1) + ' МБ';
|
||||
} else if (latestUpdateInfo.asset_sizes) {
|
||||
const sizes = Object.values(latestUpdateInfo.asset_sizes);
|
||||
if (sizes.length > 0 && sizes[0] > 0) {
|
||||
sizeText = (sizes[0] / 1048576).toFixed(1) + ' МБ';
|
||||
}
|
||||
}
|
||||
if (elements.modalFooter) {
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>
|
||||
<button class="btn btn-primary" id="btn-modal-install-update" onclick="handleInstallUpdateFromModal()">Установить обновление</button>
|
||||
`;
|
||||
|
||||
if (elements.modalTitle) elements.modalTitle.textContent = 'Доступно обновление Hermes Hub';
|
||||
|
||||
if (mode === 'details') {
|
||||
if (elements.modalBody) {
|
||||
elements.modalBody.innerHTML = `
|
||||
<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>
|
||||
<div style="font-size:11px; color:var(--text-muted);">Текущая сборка:</div>
|
||||
<div style="font-weight:600; font-family:var(--font-mono); font-size:13px;">${escapeHtml(curVer)} (${escapeHtml(instC)})</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<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(newVer)} (${escapeHtml(latC)})</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:10px; font-size:12px; color:var(--text-muted);">
|
||||
<div>Размер загрузки: <strong style="color:var(--text-primary);">${escapeHtml(sizeText)}</strong></div>
|
||||
${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;">Что нового:</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>
|
||||
`;
|
||||
}
|
||||
if (elements.modalFooter) {
|
||||
elements.modalFooter.innerHTML = `
|
||||
<button class="btn btn-secondary" id="btn-modal-dismiss-update" onclick="dismissUpdateModal('${escapeHtml(versionKey)}')">Напомнить позже</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();
|
||||
}
|
||||
|
||||
async function handleInstallUpdateFromModal() {
|
||||
const btn = document.getElementById('btn-modal-install-update');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Установка...';
|
||||
function dismissUpdateModal(versionKey) {
|
||||
if (versionKey) {
|
||||
localStorage.setItem('hermes_dismissed_update_' + versionKey, '1');
|
||||
}
|
||||
await applyUpdate();
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function applyUpdate() {
|
||||
showToast('Загрузка и запуск обновления...', 'info');
|
||||
async function startUpdateProcess() {
|
||||
openUpdateModal('progress');
|
||||
executeAction('apply_update', {});
|
||||
stopUpdateProgressPolling();
|
||||
updateProgressInterval = setInterval(pollUpdateProgress, 500);
|
||||
}
|
||||
|
||||
async function pollUpdateProgress() {
|
||||
try {
|
||||
const res = await executeAction('apply_update', {});
|
||||
if (res && res.ok) {
|
||||
showToast(res.message || 'Обновление запущено успешно!', 'success');
|
||||
} else {
|
||||
showToast((res && res.message) || 'Ошибка установки обновления', 'error');
|
||||
const res = await executeAction('get_update_progress', {});
|
||||
if (res && res.ok && res.data) {
|
||||
const p = res.data;
|
||||
renderUpdateProgressView(p);
|
||||
if (p.status === 'completed' || p.status === 'failed' || p.status === 'cancelled' || p.status === 'restarting') {
|
||||
stopUpdateProgressPolling();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Ошибка установки: ${err.message}`, 'error');
|
||||
console.debug('Failed polling update progress:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Этапы, на которых процесс идёт и доля выполнения неизвестна: полосу
|
||||
// заменяем бегущим отрезком, а не заполняем целиком.
|
||||
const INDETERMINATE_ACTIVE_STATUSES = new Set(['checking', 'downloading', 'verifying', 'installing', 'restarting']);
|
||||
|
||||
function renderUpdateProgressView(p) {
|
||||
if (!elements.modalBody) return;
|
||||
|
||||
const status = p.status || 'downloading';
|
||||
const filename = p.filename || 'Пакет обновления';
|
||||
const downloaded = p.downloaded_bytes || 0;
|
||||
const total = p.total_bytes;
|
||||
const percent = p.progress_percent;
|
||||
const msg = p.message || '';
|
||||
const error = p.error;
|
||||
|
||||
let progressDetail = '';
|
||||
let barWidth = '0%';
|
||||
let isIndeterminate = false;
|
||||
|
||||
const dlMb = (downloaded / 1048576).toFixed(1);
|
||||
if (total && total > 0) {
|
||||
const totMb = (total / 1048576).toFixed(1);
|
||||
const pctVal = percent !== null && percent !== undefined ? percent.toFixed(1) : ((downloaded / total) * 100).toFixed(1);
|
||||
progressDetail = `${dlMb} МБ из ${totMb} МБ (${pctVal}%)`;
|
||||
barWidth = `${Math.min(100, Math.max(0, percent || (downloaded / total * 100)))}%`;
|
||||
} else {
|
||||
// Размер неизвестен — доля не вычисляется. Полоса при этом не должна
|
||||
// изображать процент: полная полоса читается как «готово». Активные этапы
|
||||
// показываем бегущим отрезком, завершённые — сплошной полосой.
|
||||
progressDetail = `${dlMb} МБ скачано (Н/Д: сервер не сообщил размер)`;
|
||||
isIndeterminate = true;
|
||||
barWidth = '100%';
|
||||
}
|
||||
|
||||
let statusBadge = `<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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,24 +5,28 @@ Features:
|
|||
- SHA-256 package cryptographic hash verification.
|
||||
- Staged download without touching live executable.
|
||||
- Hermetic backup and automatic rollback on corrupt/failing update.
|
||||
- Real-time thread-safe progress tracking and cancellation.
|
||||
- Isolated process lifecycle management (stopping only own hub processes).
|
||||
- Non-blocking execution and honest error reporting (rate limits, 404, network errors).
|
||||
- Zero embedded developer PATs (safe public asset feed / signed release manifests).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
|
|
@ -72,8 +76,6 @@ def _is_release_older(published_at: str, installed_at: str) -> bool:
|
|||
if not published_at or not installed_at:
|
||||
return False
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
def _parse(v: str):
|
||||
return datetime.fromisoformat(v.strip().replace("Z", "+00:00"))
|
||||
|
||||
|
|
@ -168,6 +170,30 @@ def extract_release_commit(release_data: Dict[str, Any]) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateProgress:
|
||||
status: str = "idle" # idle | checking | downloading | verifying | installing | restarting | completed | failed | cancelled
|
||||
filename: str = ""
|
||||
downloaded_bytes: int = 0
|
||||
total_bytes: Optional[int] = None
|
||||
progress_percent: Optional[float] = None
|
||||
message: str = "Готов к обновлению"
|
||||
error: Optional[str] = None
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": self.status,
|
||||
"filename": self.filename,
|
||||
"downloaded_bytes": self.downloaded_bytes,
|
||||
"total_bytes": self.total_bytes,
|
||||
"progress_percent": round(self.progress_percent, 1) if self.progress_percent is not None else None,
|
||||
"message": self.message,
|
||||
"error": self.error,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateManifest:
|
||||
version: str
|
||||
|
|
@ -180,6 +206,7 @@ class UpdateManifest:
|
|||
changelog: Optional[str] = None
|
||||
git_commit: Optional[str] = None
|
||||
assets: Dict[str, str] = field(default_factory=dict)
|
||||
asset_sizes: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -194,6 +221,8 @@ class UpdateCheckResult:
|
|||
changelog: Optional[str] = None
|
||||
release_notes: Optional[str] = None
|
||||
assets: Dict[str, str] = field(default_factory=dict)
|
||||
asset_sizes: Dict[str, int] = field(default_factory=dict)
|
||||
download_size: Optional[int] = None
|
||||
manifest: Optional[UpdateManifest] = None
|
||||
error: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
|
|
@ -211,6 +240,8 @@ class UpdateCheckResult:
|
|||
"changelog": self.changelog,
|
||||
"release_notes": self.release_notes,
|
||||
"assets": self.assets,
|
||||
"asset_sizes": self.asset_sizes,
|
||||
"download_size": self.download_size,
|
||||
"error": self.error,
|
||||
"message": self.message,
|
||||
"checked_at": self.checked_at,
|
||||
|
|
@ -268,9 +299,189 @@ def is_allowed_update_host(url: str, allow_dev_local: bool = False) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def get_last_applied_update_path() -> Path:
|
||||
"""Path to ~/.hermes/updates/last_applied_update.json."""
|
||||
return paths.get_hermes_home() / "updates" / "last_applied_update.json"
|
||||
|
||||
|
||||
def record_last_applied_update(
|
||||
prev_version: str,
|
||||
prev_commit: str,
|
||||
new_version: str,
|
||||
new_commit: str,
|
||||
) -> None:
|
||||
"""Save record of applied update for post-restart notification."""
|
||||
try:
|
||||
p = get_last_applied_update_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"prev_version": prev_version,
|
||||
"prev_commit": prev_commit,
|
||||
"new_version": new_version,
|
||||
"new_commit": new_commit,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"acknowledged": False,
|
||||
}
|
||||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
logger.info("Recorded last applied update: %s (%s) -> %s (%s)", prev_version, prev_commit[:7], new_version, new_commit[:7])
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to record last_applied_update: %s", exc)
|
||||
|
||||
|
||||
def get_last_applied_update() -> Optional[Dict[str, Any]]:
|
||||
"""Retrieve last applied update info if present."""
|
||||
try:
|
||||
p = get_last_applied_update_path()
|
||||
if p.is_file():
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
logger.debug("Failed reading last_applied_update: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def acknowledge_last_applied_update() -> None:
|
||||
"""Mark last applied update as acknowledged."""
|
||||
try:
|
||||
p = get_last_applied_update_path()
|
||||
if p.is_file():
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
data["acknowledged"] = True
|
||||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed acknowledging last_applied_update: %s", exc)
|
||||
|
||||
|
||||
def stop_running_hub(timeout_sec: float = 10.0) -> bool:
|
||||
"""Останавливает только процессы хаба текущего пользователя, исключая текущий PID."""
|
||||
current_pid = os.getpid()
|
||||
is_win = sys.platform == "win32"
|
||||
|
||||
if is_win:
|
||||
try:
|
||||
cmd = ["wmic", "process", "where", "name='HermesHubWeb.exe'", "get", "ProcessId"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=5, **hidden_process_kwargs())
|
||||
pids = []
|
||||
if res.returncode == 0:
|
||||
for line in res.stdout.splitlines():
|
||||
val = line.strip()
|
||||
if val.isdigit():
|
||||
pid = int(val)
|
||||
if pid != current_pid:
|
||||
pids.append(pid)
|
||||
for pid in pids:
|
||||
try:
|
||||
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5, **hidden_process_kwargs())
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("Windows stop_running_hub: %s", exc)
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
uid = os.getuid()
|
||||
pattern = "antigravity_provider.router.web|hermes_hub_web_entry"
|
||||
res = subprocess.run(
|
||||
["pgrep", "-u", str(uid), "-f", pattern],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode != 0 or not res.stdout.strip():
|
||||
logger.debug("No other hub processes found on Linux")
|
||||
return True
|
||||
|
||||
pids = [int(p) for p in res.stdout.split() if p.strip().isdigit() and int(p) != current_pid]
|
||||
if not pids:
|
||||
return True
|
||||
|
||||
logger.info("Stopping hub processes for user %s: %s", uid, pids)
|
||||
import signal
|
||||
for pid in pids:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Failed to SIGTERM pid %s: %s", pid, e)
|
||||
|
||||
start_t = time.time()
|
||||
alive = list(pids)
|
||||
while alive and (time.time() - start_t) < timeout_sec:
|
||||
time.sleep(0.5)
|
||||
still_alive = []
|
||||
for pid in alive:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
still_alive.append(pid)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
alive = still_alive
|
||||
|
||||
if alive:
|
||||
logger.warning("Hub processes still alive after %ss, sending SIGKILL: %s", timeout_sec, alive)
|
||||
for pid in alive:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("Failed to SIGKILL pid %s: %s", pid, e)
|
||||
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Linux stop_running_hub error: %s", exc)
|
||||
return True
|
||||
|
||||
|
||||
# Почему отмена отклонена — своими словами для владельца, а не кодом состояния.
|
||||
_CANCEL_REFUSAL = {
|
||||
"installing": "Отмена невозможна: установка уже началась",
|
||||
"restarting": "Отмена невозможна: Hermes Hub уже перезапускается",
|
||||
"completed": "Отменять нечего: обновление уже установлено",
|
||||
"failed": "Отменять нечего: обновление уже завершилось ошибкой",
|
||||
"cancelled": "Загрузка уже отменена",
|
||||
"idle": "Отменять нечего: обновление не запускалось",
|
||||
}
|
||||
|
||||
|
||||
def _call_progress_cb(cb: Optional[Callable], downloaded: int, total: Optional[int]) -> None:
|
||||
"""Вызвать обработчик хода загрузки, поддержав обе его формы.
|
||||
|
||||
Старая форма принимает одну долю (0..1), новая — (скачано, всего).
|
||||
|
||||
Доли при неизвестном общем размере не существует, и подставлять вместо неё
|
||||
ноль нельзя: обработчик получал бы «0%» на каждом чанке всю загрузку.
|
||||
Старую форму в этом случае просто не зовём — молчание честнее выдуманного
|
||||
числа, а сам ход всё равно виден через _set_progress.
|
||||
"""
|
||||
if not cb:
|
||||
return
|
||||
known_total = bool(total and total > 0)
|
||||
try:
|
||||
sig = inspect.signature(cb)
|
||||
single_arg = len(sig.parameters) == 1
|
||||
except (TypeError, ValueError):
|
||||
single_arg = False
|
||||
|
||||
if single_arg:
|
||||
if known_total:
|
||||
cb(downloaded / total)
|
||||
return
|
||||
|
||||
try:
|
||||
cb(downloaded, total)
|
||||
except TypeError:
|
||||
if known_total:
|
||||
cb(downloaded / total)
|
||||
|
||||
|
||||
class UpdateManager:
|
||||
"""Manages update checks, package download, hash validation, and updater execution."""
|
||||
|
||||
_lock = threading.Lock()
|
||||
_progress: UpdateProgress = UpdateProgress()
|
||||
_cancel_event = threading.Event()
|
||||
_last_check_result: Optional[UpdateCheckResult] = None
|
||||
_last_check_time: float = 0.0
|
||||
|
||||
|
|
@ -285,6 +496,85 @@ class UpdateManager:
|
|||
def get_last_check_result(cls) -> Optional[UpdateCheckResult]:
|
||||
return cls._last_check_result
|
||||
|
||||
@classmethod
|
||||
def get_progress_dict(cls) -> Dict[str, Any]:
|
||||
with cls._lock:
|
||||
return cls._progress.to_dict()
|
||||
|
||||
@classmethod
|
||||
def _set_progress(
|
||||
cls,
|
||||
status: str,
|
||||
message: str = "",
|
||||
filename: str = "",
|
||||
downloaded_bytes: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
progress_percent: Optional[float] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
with cls._lock:
|
||||
cls._progress = UpdateProgress(
|
||||
status=status,
|
||||
filename=filename if filename else cls._progress.filename,
|
||||
downloaded_bytes=downloaded_bytes,
|
||||
total_bytes=total_bytes,
|
||||
progress_percent=progress_percent,
|
||||
message=message if message else cls._progress.message,
|
||||
error=error,
|
||||
updated_at=time.time(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def cancel_download(cls) -> Dict[str, Any]:
|
||||
"""Отменить загрузку обновления и удалить недокачанные файлы.
|
||||
|
||||
Отмена допустима только пока идёт проверка или загрузка. Действие
|
||||
`cancel_update` открыто в HTTP-API, и без этой проверки вызов во время
|
||||
установки вычищал каталог staging вместе с файлом установщика, который
|
||||
в этот момент исполняет bash: установка ломалась на середине, а ответ
|
||||
«отменено» сообщал владельцу неправду о том, что происходит с машиной.
|
||||
"""
|
||||
with cls._lock:
|
||||
current_status = cls._progress.status
|
||||
if current_status not in ("checking", "downloading"):
|
||||
refused = cls._progress.to_dict()
|
||||
refused["cancel_accepted"] = False
|
||||
refused["cancel_refused_reason"] = _CANCEL_REFUSAL.get(
|
||||
current_status,
|
||||
f"Отмена невозможна на этапе «{current_status}»",
|
||||
)
|
||||
return refused
|
||||
|
||||
cls._cancel_event.set()
|
||||
with cls._lock:
|
||||
cls._progress = UpdateProgress(
|
||||
status="cancelled",
|
||||
filename=cls._progress.filename,
|
||||
downloaded_bytes=0,
|
||||
total_bytes=None,
|
||||
progress_percent=None,
|
||||
message="Загрузка обновления отменена пользователем",
|
||||
error=None,
|
||||
updated_at=time.time(),
|
||||
)
|
||||
try:
|
||||
updates_dir = paths.get_hermes_home() / "updates"
|
||||
staging_dir = updates_dir / "staging"
|
||||
if staging_dir.exists():
|
||||
for f in staging_dir.iterdir():
|
||||
if f.is_file():
|
||||
f.unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
logger.debug("Clean staging dir on cancel failed: %s", exc)
|
||||
accepted = cls.get_progress_dict()
|
||||
accepted["cancel_accepted"] = True
|
||||
return accepted
|
||||
|
||||
@classmethod
|
||||
def cancel_update(cls) -> Dict[str, Any]:
|
||||
"""Alias for cancel_download."""
|
||||
return cls.cancel_download()
|
||||
|
||||
def get_status_dict(self) -> Dict[str, Any]:
|
||||
installed_commit = get_installed_commit()
|
||||
if self._last_check_result:
|
||||
|
|
@ -302,6 +592,8 @@ class UpdateManager:
|
|||
"changelog": None,
|
||||
"release_notes": None,
|
||||
"assets": {},
|
||||
"asset_sizes": {},
|
||||
"download_size": None,
|
||||
"error": None,
|
||||
"message": "Проверка обновлений еще не выполнялась",
|
||||
"checked_at": 0.0,
|
||||
|
|
@ -392,13 +684,16 @@ class UpdateManager:
|
|||
body = str(data.get("body") or data.get("changelog") or "")
|
||||
published_at = str(data.get("published_at") or "")
|
||||
|
||||
# Extract assets mapping {name: download_url}
|
||||
# Extract assets mapping {name: download_url} and asset sizes {name: size_bytes}
|
||||
assets_map: Dict[str, str] = {}
|
||||
asset_sizes: Dict[str, int] = {}
|
||||
raw_assets = data.get("assets", [])
|
||||
if isinstance(raw_assets, list):
|
||||
for asset in raw_assets:
|
||||
if isinstance(asset, dict) and "name" in asset and "browser_download_url" in asset:
|
||||
assets_map[asset["name"]] = asset["browser_download_url"]
|
||||
if "size" in asset and isinstance(asset["size"], (int, float)):
|
||||
asset_sizes[asset["name"]] = int(asset["size"])
|
||||
elif isinstance(raw_assets, dict):
|
||||
assets_map = dict(raw_assets)
|
||||
|
||||
|
|
@ -420,8 +715,27 @@ class UpdateManager:
|
|||
changelog=body,
|
||||
git_commit=latest_commit,
|
||||
assets=assets_map,
|
||||
asset_sizes=asset_sizes,
|
||||
)
|
||||
|
||||
# Determine download_size for target platform
|
||||
is_win = sys.platform == "win32"
|
||||
chosen_size: Optional[int] = None
|
||||
if is_win and "HermesHubSetup.exe" in asset_sizes:
|
||||
chosen_size = asset_sizes["HermesHubSetup.exe"]
|
||||
elif not is_win:
|
||||
for linux_name in ("hermes-hub-setup.sh", "install-linux.sh"):
|
||||
if linux_name in asset_sizes:
|
||||
chosen_size = asset_sizes[linux_name]
|
||||
break
|
||||
if chosen_size is None:
|
||||
for aname, asize in asset_sizes.items():
|
||||
if aname.endswith(".zip"):
|
||||
chosen_size = asize
|
||||
break
|
||||
if chosen_size is None and asset_sizes:
|
||||
chosen_size = next(iter(asset_sizes.values()), None)
|
||||
|
||||
# Compare commits
|
||||
inst_clean = installed_commit.strip().lower()
|
||||
lat_clean = latest_commit.strip().lower()
|
||||
|
|
@ -463,6 +777,8 @@ class UpdateManager:
|
|||
changelog=body,
|
||||
release_notes=body,
|
||||
assets=assets_map,
|
||||
asset_sizes=asset_sizes,
|
||||
download_size=chosen_size,
|
||||
manifest=manifest,
|
||||
error=None,
|
||||
message=message,
|
||||
|
|
@ -490,17 +806,45 @@ class UpdateManager:
|
|||
self,
|
||||
url: str,
|
||||
dest_file: Path,
|
||||
progress_cb: Optional[Callable[[float], None]] = None,
|
||||
progress_cb: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Helper to download a file with allowlist check and optional progress callback."""
|
||||
"""Helper to download a file with allowlist check, progress tracking, and cancellation support."""
|
||||
if not is_allowed_update_host(url, allow_dev_local=False):
|
||||
raise ValueError(f"Недопустимый хост пакета обновления: {url}")
|
||||
|
||||
if self._cancel_event.is_set():
|
||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
||||
raise InterruptedError("Загрузка обновления отменена пользователем")
|
||||
|
||||
if url.startswith("file://") or Path(url).is_file():
|
||||
local_src = Path(url.replace("file://", ""))
|
||||
total_bytes = local_src.stat().st_size if local_src.exists() else None
|
||||
self._set_progress(
|
||||
status="downloading",
|
||||
filename=dest_file.name,
|
||||
downloaded_bytes=0,
|
||||
total_bytes=total_bytes,
|
||||
progress_percent=0.0 if total_bytes else None,
|
||||
message=f"Копирование {dest_file.name}...",
|
||||
)
|
||||
if self._cancel_event.is_set():
|
||||
dest_file.unlink(missing_ok=True)
|
||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
||||
raise InterruptedError("Загрузка обновления отменена пользователем")
|
||||
|
||||
shutil.copy2(local_src, dest_file)
|
||||
downloaded = dest_file.stat().st_size
|
||||
pct = 100.0 if total_bytes else None
|
||||
self._set_progress(
|
||||
status="downloading",
|
||||
filename=dest_file.name,
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=total_bytes,
|
||||
progress_percent=pct,
|
||||
message=f"Файл {dest_file.name} скопирован",
|
||||
)
|
||||
if progress_cb:
|
||||
progress_cb(1.0)
|
||||
_call_progress_cb(progress_cb, downloaded, total_bytes)
|
||||
return
|
||||
|
||||
req = urllib.request.Request(
|
||||
|
|
@ -508,21 +852,53 @@ class UpdateManager:
|
|||
headers={"User-Agent": f"HermesHub/{__version__}"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
total_len = int(resp.headers.get("content-length", 0))
|
||||
raw_len = resp.headers.get("content-length")
|
||||
total_len = int(raw_len) if raw_len and raw_len.isdigit() else 0
|
||||
total_bytes = total_len if total_len > 0 else None
|
||||
downloaded = 0
|
||||
|
||||
self._set_progress(
|
||||
status="downloading",
|
||||
filename=dest_file.name,
|
||||
downloaded_bytes=0,
|
||||
total_bytes=total_bytes,
|
||||
progress_percent=0.0 if total_bytes else None,
|
||||
message=f"Скачивание {dest_file.name}...",
|
||||
)
|
||||
|
||||
with open(dest_file, "wb") as out_f:
|
||||
while chunk := resp.read(65536):
|
||||
while True:
|
||||
if self._cancel_event.is_set():
|
||||
out_f.close()
|
||||
dest_file.unlink(missing_ok=True)
|
||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка отменена")
|
||||
raise InterruptedError("Загрузка обновления отменена пользователем")
|
||||
|
||||
chunk = resp.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
out_f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_cb and total_len > 0:
|
||||
progress_cb(downloaded / total_len)
|
||||
pct = ((downloaded / total_bytes) * 100.0) if (total_bytes and total_bytes > 0) else None
|
||||
self._set_progress(
|
||||
status="downloading",
|
||||
filename=dest_file.name,
|
||||
downloaded_bytes=downloaded,
|
||||
total_bytes=total_bytes,
|
||||
progress_percent=pct,
|
||||
message=f"Скачивание {dest_file.name}...",
|
||||
)
|
||||
if progress_cb:
|
||||
_call_progress_cb(progress_cb, downloaded, total_bytes)
|
||||
|
||||
def download_and_verify(
|
||||
self,
|
||||
manifest: UpdateManifest,
|
||||
progress_cb: Optional[Callable[[float], None]] = None,
|
||||
progress_cb: Optional[Callable] = None,
|
||||
) -> Tuple[bool, str, Optional[Path]]:
|
||||
"""Download update package into staging and verify SHA-256 hash."""
|
||||
self._cancel_event.clear()
|
||||
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_file = self.staging_dir / f"hermes-hub-{manifest.version}.zip"
|
||||
|
||||
|
|
@ -530,31 +906,54 @@ class UpdateManager:
|
|||
self._download_file(manifest.package_url, dest_file, progress_cb)
|
||||
|
||||
# Cryptographic SHA-256 Verification
|
||||
self._set_progress(
|
||||
status="verifying",
|
||||
filename=dest_file.name,
|
||||
message=f"Проверка контрольной суммы SHA-256 для {dest_file.name}...",
|
||||
)
|
||||
calc_hash = compute_sha256(dest_file)
|
||||
if manifest.sha256 and calc_hash != manifest.sha256.lower():
|
||||
dest_file.unlink(missing_ok=True)
|
||||
return False, f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}", None
|
||||
err = f"SHA-256 hash mismatch! Expected {manifest.sha256}, got {calc_hash}"
|
||||
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
|
||||
return False, err, None
|
||||
|
||||
self._set_progress(
|
||||
status="completed",
|
||||
filename=dest_file.name,
|
||||
message="Пакет успешно загружен и верифицирован",
|
||||
)
|
||||
return True, "Пакет успешно загружен и верифицирован", dest_file
|
||||
|
||||
except InterruptedError:
|
||||
dest_file.unlink(missing_ok=True)
|
||||
self._set_progress(status="cancelled", filename=dest_file.name, message="Загрузка обновления отменена")
|
||||
return False, "Загрузка обновления отменена пользователем", None
|
||||
except Exception as exc:
|
||||
dest_file.unlink(missing_ok=True)
|
||||
return False, f"Ошибка загрузки: {exc}", None
|
||||
err = f"Ошибка загрузки: {exc}"
|
||||
self._set_progress(status="failed", filename=dest_file.name, error=err, message=err)
|
||||
return False, err, None
|
||||
|
||||
def install_latest_update(
|
||||
self,
|
||||
check_result: Optional[UpdateCheckResult] = None,
|
||||
progress_cb: Optional[Callable[[float], None]] = None,
|
||||
progress_cb: Optional[Callable] = None,
|
||||
target_dir: Optional[Path] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Download installer or update package, verify checksums, apply and restart."""
|
||||
self._cancel_event.clear()
|
||||
|
||||
if check_result is None:
|
||||
self._set_progress(status="checking", message="Проверка наличия обновлений...")
|
||||
check_result = self.check_for_updates()
|
||||
|
||||
if check_result.error:
|
||||
self._set_progress(status="failed", error=check_result.error, message=check_result.error)
|
||||
return False, f"Ошибка проверки обновлений: {check_result.error}"
|
||||
|
||||
if not check_result.update_available:
|
||||
self._set_progress(status="idle", message="Обновление не требуется")
|
||||
return False, "Обновление не требуется (установлена последняя сборка)"
|
||||
|
||||
self.staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -573,6 +972,8 @@ class UpdateManager:
|
|||
sha = parts[0].strip().lower()
|
||||
fname = parts[1].lstrip("*").strip().lower()
|
||||
checksums_map[fname] = sha
|
||||
except InterruptedError:
|
||||
return False, "Загрузка обновления отменена пользователем"
|
||||
except Exception as e:
|
||||
logger.warning("Failed to download or parse checksums.txt: %s", e)
|
||||
|
||||
|
|
@ -605,17 +1006,30 @@ class UpdateManager:
|
|||
chosen_asset_name = Path(chosen_url).name or f"hermes-hub-{check_result.latest_version}.zip"
|
||||
|
||||
if not chosen_url or not chosen_asset_name:
|
||||
return False, "В релизе не найден подходящий файл обновления для текущей платформы"
|
||||
err = "В релизе не найден подходящий файл обновления для текущей платформы"
|
||||
self._set_progress(status="failed", error=err, message=err)
|
||||
return False, err
|
||||
|
||||
# 3. Download target asset into staging
|
||||
dest_file = self.staging_dir / chosen_asset_name
|
||||
try:
|
||||
self._download_file(chosen_url, dest_file, progress_cb)
|
||||
except InterruptedError:
|
||||
dest_file.unlink(missing_ok=True)
|
||||
self._set_progress(status="cancelled", filename=chosen_asset_name, message="Загрузка отменена")
|
||||
return False, "Загрузка обновления отменена пользователем"
|
||||
except Exception as exc:
|
||||
dest_file.unlink(missing_ok=True)
|
||||
return False, f"Ошибка загрузки {chosen_asset_name}: {exc}"
|
||||
err = f"Ошибка загрузки {chosen_asset_name}: {exc}"
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
|
||||
# 4. SHA-256 Checksum Verification
|
||||
self._set_progress(
|
||||
status="verifying",
|
||||
filename=chosen_asset_name,
|
||||
message=f"Проверка контрольной суммы SHA-256 для {chosen_asset_name}...",
|
||||
)
|
||||
calc_sha = compute_sha256(dest_file)
|
||||
expected_sha = checksums_map.get(chosen_asset_name.lower())
|
||||
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
||||
|
|
@ -627,26 +1041,59 @@ class UpdateManager:
|
|||
# исполняемый код, поэтому непроверенный файл не запускаем вовсе.
|
||||
if not expected_sha:
|
||||
dest_file.unlink(missing_ok=True)
|
||||
return (
|
||||
False,
|
||||
err = (
|
||||
f"Не удалось получить контрольную сумму для {chosen_asset_name}: "
|
||||
"в релизе нет checksums.txt или файл не скачался. "
|
||||
"Установка отменена — непроверенный файл не запускается."
|
||||
)
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
|
||||
if calc_sha != expected_sha:
|
||||
dest_file.unlink(missing_ok=True)
|
||||
return (
|
||||
False,
|
||||
err = (
|
||||
f"Контрольная сумма SHA-256 не совпала для {chosen_asset_name}! "
|
||||
f"Ожидалось {expected_sha}, получено {calc_sha}. Установка отменена."
|
||||
)
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
|
||||
# 5. Stop running hub services before applying update
|
||||
self._set_progress(
|
||||
status="installing",
|
||||
filename=chosen_asset_name,
|
||||
message="Остановка работающих служб хаба...",
|
||||
)
|
||||
stop_running_hub()
|
||||
|
||||
# Record metadata for post-restart notification
|
||||
prev_v = __version__
|
||||
prev_c = get_installed_commit()
|
||||
new_v = check_result.latest_version
|
||||
new_c = check_result.latest_commit or ""
|
||||
|
||||
# 6. Apply update based on file type
|
||||
self._set_progress(
|
||||
status="installing",
|
||||
filename=chosen_asset_name,
|
||||
message=f"Установка пакета {chosen_asset_name}...",
|
||||
)
|
||||
|
||||
def _record_success():
|
||||
record_last_applied_update(
|
||||
prev_version=prev_v,
|
||||
prev_commit=prev_c,
|
||||
new_version=new_v,
|
||||
new_commit=new_c,
|
||||
)
|
||||
|
||||
# 5. Apply update based on file type
|
||||
if chosen_asset_name.endswith(".zip"):
|
||||
ok, msg = self.apply_update_sync(dest_file, target_dir=target_dir)
|
||||
if not ok:
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=msg, message=msg)
|
||||
return False, msg
|
||||
_record_success()
|
||||
self._set_progress(status="completed", filename=chosen_asset_name, message=msg)
|
||||
return True, "Обновление успешно установлено"
|
||||
|
||||
elif chosen_asset_name == "HermesHubSetup.exe":
|
||||
|
|
@ -659,15 +1106,25 @@ class UpdateManager:
|
|||
try:
|
||||
rc = proc.wait(timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
||||
err = "Установщик не завершился за 10 минут. Проверьте состояние вручную."
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
if rc != 0:
|
||||
return False, f"Установщик завершился с кодом {rc}. Обновление не применено."
|
||||
err = f"Установщик завершился с кодом {rc}. Обновление не применено."
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
_record_success()
|
||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
||||
ok_r, msg_r = self.schedule_restart()
|
||||
if not ok_r:
|
||||
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
|
||||
return True, f"Обновление установлено. {msg_r}"
|
||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
|
||||
return True, "Обновление установлено, Hermes Hub перезапускается."
|
||||
except Exception as exc:
|
||||
return False, f"Не удалось запустить установщик: {exc}"
|
||||
err = f"Не удалось запустить установщик: {exc}"
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
|
||||
elif chosen_asset_name.endswith(".sh"):
|
||||
try:
|
||||
|
|
@ -680,18 +1137,32 @@ class UpdateManager:
|
|||
capture_output=True, text=True, timeout=600,
|
||||
)
|
||||
if res_i.returncode != 0:
|
||||
# Код возврата в сообщении обязателен: установщик может
|
||||
# завершиться, не сказав ни слова, и владелец получал
|
||||
# «Установка не удалась: » без единого признака причины.
|
||||
tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:]
|
||||
return False, "Установка не удалась: " + " / ".join(tail)
|
||||
if tail:
|
||||
err = f"Установка не удалась (код {res_i.returncode}): " + " / ".join(tail)
|
||||
else:
|
||||
err = f"Установка не удалась (код {res_i.returncode}): установщик ничего не сообщил"
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
_record_success()
|
||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Hermes Hub перезапускается...")
|
||||
ok_r, msg_r = self.schedule_restart()
|
||||
if not ok_r:
|
||||
self._set_progress(status="completed", filename=chosen_asset_name, message=f"Обновление установлено. {msg_r}")
|
||||
return True, f"Обновление установлено. {msg_r}"
|
||||
self._set_progress(status="restarting", filename=chosen_asset_name, message="Обновление установлено, Hermes Hub перезапускается.")
|
||||
return True, "Обновление установлено, Hermes Hub перезапускается."
|
||||
except Exception as exc:
|
||||
return False, f"Не удалось запустить скрипт установки: {exc}"
|
||||
err = f"Не удалось запустить скрипт установки: {exc}"
|
||||
self._set_progress(status="failed", filename=chosen_asset_name, error=err, message=err)
|
||||
return False, err
|
||||
|
||||
self._set_progress(status="completed", filename=chosen_asset_name, message="Файл обновления загружен и проверен")
|
||||
return True, "Файл обновления загружен и проверен"
|
||||
|
||||
|
||||
def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]:
|
||||
"""Перезапустить веб-хаб после установки обновления.
|
||||
|
||||
|
|
|
|||
508
tests/test_a59_visible_update.py
Normal file
508
tests/test_a59_visible_update.py
Normal 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, (
|
||||
"после отклонённой отмены опрос хода загрузки должен возобновляться"
|
||||
)
|
||||
Loading…
Reference in a new issue