ouroboros/web/modules/updates.js
Ouroboros c5a705bfd8 release v6.90.3: every native browser dialog is replaced by the in-app dialog
All 21 window.prompt/confirm/alert sites across web/modules migrate to the
in-app openConfirmDialog (owner decision: the full class, not just the broken
sites), with a new single-button alert mode in confirm_dialog.js. This fixes
the two DEAD window.prompt sites on the macOS desktop shell — the cocoa shell
has no prompt delegate, so marketplace version updates and evolution campaign
objectives silently returned null there — while preserving each site's
contract: empty update version = latest, evolution cancel = do NOT start a
campaign, and the /panic critical control's complete confirm-and-send flow is
the injectable, node-tested chat.js::confirmAndSendPanic. Superseding an
INPUT dialog resolves the documented {confirmed:false, value:''} instead of a
bare false. A static quick-CI gate bans the native dialog trio from
web/modules so the class stays closed, and the canonical rule lives in
DEVELOPMENT.md with the confirm_dialog.js authority mapped in ARCHITECTURE.

Harness Accounts (#125) fetches immediately on page show, re-checks on
tab/page activation, and login polling backs off 6→30s and honestly gives up
into the existing unconfirmed verdict after 10 consecutive failures. The
login lifecycle is fully serialized (plan decision C7): ONE transition lock
covers start and dismiss, a new login starts only after the previous job is
terminal or provably cancelled (gone only on ok/404/410 — a 5xx or network
death refuses the restart instead of orphaning a live job server-side), an
unproven cancel keeps the card and the job id with a transient note that the
next successful poll read clears, the Retry button leaves the poll armed as
the recovery path, and both cancel continuations re-check the terminal
snapshot after their await so a settled job is never frozen behind a stale
cancel error — with the lost-contact verdict explicitly NOT counting as
proof of settlement (loginSettleProven). Six real-Chromium behavioral tests
pin the races, mutation-checked. Deleting every reviewer-slot row and saving
(#126) now surfaces the backend's 400 instead of pretending success. The
desktop pywebview-bridge branches in settings.js stay untouched by design.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 17:43:11 +03:00

332 lines
17 KiB
JavaScript

import { escapeHtmlAttr as escapeHtml } from './utils.js';
import { openConfirmDialog } from './confirm_dialog.js';
import { showToast } from './toast.js';
import { apiClient, apiFetch, updateStrategyForPlan } from './api_client.js';
export function initUpdates({ mount, state }) {
const page = document.createElement('div');
page.id = 'page-updates';
page.className = 'settings-embedded-content settings-updates-panel';
// Keep the update action beside the status it refreshes.
page.innerHTML = `
<div class="updates-scroll">
<section class="updates-card" id="updates-status-card">
<div class="updates-card-head">
<div class="updates-card-head-main">
<div class="section-title">Official Updates</div>
<div class="updates-summary" id="updates-summary">Loading update status...</div>
</div>
<div class="updates-head-actions">
<span class="status-badge offline" id="updates-badge">Idle</span>
<button class="btn btn-default btn-sm" id="btn-update-check">Check for updates</button>
</div>
</div>
<div class="updates-meta" id="updates-meta"></div>
<div class="updates-actions">
<button class="btn btn-primary" id="btn-update-apply" disabled>Update Now</button>
</div>
<details class="updates-recovery">
<summary>Recovery</summary>
<p>Replace the active checkout with the exact official version from the selected channel. A rescue copy is saved first, but this is intentionally more destructive than Update Now.</p>
<button class="btn btn-danger btn-sm" id="btn-update-replace">Replace with Official Version (Recovery)</button>
</details>
</section>
<section class="updates-card">
<div class="evo-versions-header">
<div id="updates-current" class="evo-versions-branch"></div>
<button class="btn btn-primary" id="updates-promote">Promote to QA</button>
</div>
<div class="evo-versions-cols">
<div class="evo-versions-col">
<h3 class="section-title">Local Recovery: Recent Commits</h3>
<div id="updates-commits" class="log-scroll evo-versions-list"></div>
</div>
<div class="evo-versions-col">
<h3 class="section-title">Official Releases</h3>
<div id="updates-official-tags" class="log-scroll evo-versions-list"></div>
</div>
<div class="evo-versions-col">
<h3 class="section-title">Local Recovery: Local Tags</h3>
<div id="updates-tags" class="log-scroll evo-versions-list"></div>
</div>
</div>
</section>
</div>
`;
mount.appendChild(page);
const checkBtn = page.querySelector('#btn-update-check');
const applyBtn = page.querySelector('#btn-update-apply');
const replaceBtn = page.querySelector('#btn-update-replace');
const badge = page.querySelector('#updates-badge');
const summary = page.querySelector('#updates-summary');
const meta = page.querySelector('#updates-meta');
const current = page.querySelector('#updates-current');
const commitsDiv = page.querySelector('#updates-commits');
const officialTagsDiv = page.querySelector('#updates-official-tags');
const tagsDiv = page.querySelector('#updates-tags');
let latestStatus = null;
function setBadge(kind, text) {
badge.className = `status-badge ${kind}`;
badge.textContent = text;
}
function divergenceText(data) {
const parts = [];
if (data.behind) parts.push(`${data.behind} incoming`);
if (data.ahead) parts.push(`${data.ahead} local`);
if (data.dirty_count) parts.push(`${data.dirty_count} dirty`);
return parts.join(' / ') || 'clean';
}
function renderStatus(data) {
latestStatus = data;
const unmanaged = data.managed === false
|| (Array.isArray(data.warnings) && data.warnings.includes('managed_updates_unavailable'));
if (unmanaged) {
summary.textContent = 'Managed updates are unavailable for this checkout.';
meta.innerHTML = `
<span class="evo-runtime-chip"><strong>Mode:</strong> source checkout</span>
<span class="evo-runtime-chip"><strong>Action:</strong> use git or install a launcher-managed build</span>
`;
applyBtn.disabled = true;
replaceBtn.disabled = true;
applyBtn.dataset.safe = '0';
applyBtn.textContent = 'Unavailable';
setBadge('offline', 'Unavailable');
return;
}
if (!data.from_cache && Array.isArray(data.warnings) && data.warnings.includes('official_status_requires_check')) {
summary.textContent = 'Click Check for updates to refresh official update status.';
meta.innerHTML = '<span class="evo-runtime-chip"><strong>Official repo:</strong> razzant/ouroboros</span>';
applyBtn.disabled = true;
replaceBtn.disabled = false;
applyBtn.dataset.safe = '0';
applyBtn.textContent = 'Check Required';
setBadge('offline', 'Not checked');
return;
}
if (data.check_ok === false) {
summary.textContent = 'Could not check the official update channel. Try again when the network is available.';
meta.innerHTML = '<span class="evo-runtime-chip"><strong>Official repo:</strong> razzant/ouroboros</span>';
applyBtn.disabled = true;
replaceBtn.disabled = false;
applyBtn.dataset.safe = '0';
applyBtn.textContent = 'Check Failed';
setBadge('error', 'Check failed');
return;
}
const currentVersion = data.current_version || 'unknown';
const latestVersion = data.latest_version || 'unknown';
const currentSha = data.current_short_sha || '?';
const latestSha = data.latest_short_sha || '?';
const latestMsg = data.latest_message || 'No remote message.';
const canUpdate = Boolean(data.available);
const safe = Boolean(data.safe_to_apply);
summary.textContent = canUpdate
? `Update available: ${currentVersion} (${currentSha}) -> ${latestVersion} (${latestSha})`
: `Ouroboros is up to date at ${currentVersion} (${currentSha}).`;
meta.innerHTML = [
`<span class="evo-runtime-chip"><strong>Official repo:</strong> razzant/ouroboros</span>`,
`<span class="evo-runtime-chip"><strong>Remote ref:</strong> ${escapeHtml(data.remote || 'managed')}/${escapeHtml(data.remote_branch || '')}</span>`,
`<span class="evo-runtime-chip"><strong>Channel:</strong> ${escapeHtml(data.update_channel || 'stable')}</span>`,
`<span class="evo-runtime-chip"><strong>Divergence:</strong> ${escapeHtml(divergenceText(data))}</span>`,
`<span class="evo-runtime-chip"><strong>Latest:</strong> ${escapeHtml(latestMsg)}</span>`,
].join('');
applyBtn.disabled = !canUpdate;
replaceBtn.disabled = false;
applyBtn.dataset.safe = safe ? '1' : '0';
applyBtn.textContent = !canUpdate ? 'No Update Available' : 'Update Now';
setBadge(canUpdate ? (safe ? 'online' : 'starting') : 'offline', canUpdate ? 'Available' : 'Current');
}
async function loadStatus({ fetchRemote = false } = {}) {
checkBtn.disabled = true;
setBadge('starting', fetchRemote ? 'Checking...' : 'Loading...');
try {
const data = await (fetchRemote ? apiClient.updateCheck() : apiClient.updateStatus());
renderStatus(data);
renderOfficialTags(data.official_tags || []);
} catch (err) {
summary.textContent = `Failed to load update status: ${err.message || err}`;
meta.innerHTML = '';
applyBtn.disabled = true;
replaceBtn.disabled = true;
setBadge('error', 'Error');
} finally {
checkBtn.disabled = false;
}
}
function renderVersionRow(item, labelText, targetId) {
const row = document.createElement('div');
row.className = 'log-entry evo-versions-row';
const date = (item.date || '').slice(0, 16).replace('T', ' ');
const msg = escapeHtml((item.message || '').slice(0, 72));
row.innerHTML = `
<span class="log-type tools evo-versions-row-label">${escapeHtml(labelText)}</span>
<span class="log-ts">${escapeHtml(date)}</span>
<span class="log-msg evo-versions-row-msg">${msg}</span>
<button class="btn btn-danger btn-xs" data-target="${escapeHtml(targetId)}">Restore</button>
`;
row.querySelector('button').addEventListener('click', () => rollback(targetId));
return row;
}
function renderOfficialTags(tags) {
officialTagsDiv.innerHTML = '';
(tags || []).forEach((tag) => {
const row = document.createElement('div');
row.className = 'log-entry evo-versions-row';
row.innerHTML = `
<span class="log-type tools evo-versions-row-label">${escapeHtml(tag.tag || '')}</span>
<span class="log-msg evo-versions-row-msg">${escapeHtml((tag.sha || '').slice(0, 12))}</span>
`;
officialTagsDiv.appendChild(row);
});
if (!tags?.length) officialTagsDiv.innerHTML = '<div class="evo-empty">Check for updates to load official releases.</div>';
}
async function loadVersions() {
try {
const resp = await apiFetch('/api/git/log', { cache: 'no-store' });
if (!resp.ok) throw new Error('Git log API error ' + resp.status);
const data = await resp.json();
current.textContent = `Branch: ${data.branch || '?'} @ ${data.sha || '?'}`;
commitsDiv.innerHTML = '';
(data.commits || []).forEach((commit) => {
commitsDiv.appendChild(renderVersionRow(commit, commit.short_sha || commit.sha?.slice(0, 8), commit.sha));
});
if (!data.commits?.length) commitsDiv.innerHTML = '<div class="evo-empty">No commits found</div>';
tagsDiv.innerHTML = '';
(data.tags || []).forEach((tag) => {
tagsDiv.appendChild(renderVersionRow(tag, tag.tag, tag.tag));
});
if (!data.tags?.length) tagsDiv.innerHTML = '<div class="evo-empty">No tags found</div>';
} catch (err) {
const msg = `<div class="evo-empty evo-empty-error">Failed to load: ${escapeHtml(err.message || err)}</div>`;
commitsDiv.innerHTML = msg;
tagsDiv.innerHTML = msg;
current.textContent = 'Branch: unknown';
}
}
async function rollback(target) {
const confirmed = await openConfirmDialog({
title: 'Roll back',
body: `Roll back to ${target}?\n\nA rescue snapshot of the current state will be saved. The server will restart.`,
confirmLabel: 'Roll back',
danger: true,
});
if (!confirmed) return;
try {
const resp = await apiFetch('/api/git/rollback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target }),
});
const data = await resp.json();
if (data.status === 'ok') {
showToast(`Rollback successful: ${data.message}. Server is restarting...`, 'success');
} else if (data.status === 'restart_required') {
showToast(`Rollback completed: ${data.message}. Restart Ouroboros to finish.`, 'error');
} else {
const suffix = data.restart_required
? ' Runtime shutdown was incomplete; restart Ouroboros before retrying.'
: '';
showToast(`Rollback failed: ${data.error || 'unknown error'}${suffix}`, 'error');
}
} catch (err) {
showToast('Rollback failed: ' + (err.message || err), 'error');
}
}
async function applyUpdate() {
if (!latestStatus?.available) return;
applyBtn.disabled = true;
applyBtn.textContent = 'Checking...';
try {
const preflight = await apiClient.updatePreflight();
const plan = preflight?.merge_plan || {};
const strategy = updateStrategyForPlan(plan);
if (!strategy) throw new Error(plan.error || 'No actionable update plan is available.');
applyBtn.textContent = 'Applying...';
const data = await apiClient.updateApply(strategy, plan);
if (data.status === 'assisted_started') {
showToast('Ouroboros is resolving the update merge under review. Watch progress in chat.', 'success');
} else if (data.status === 'restart_required') {
showToast('Update landed, but automatic restart failed. Restart Ouroboros to finish.', 'error');
} else {
showToast('Update applied. Server is restarting.', 'success');
}
} catch (err) {
const restartRequired = Boolean(err?.body?.restart_required);
const suffix = restartRequired ? ' Runtime shutdown was incomplete; restart Ouroboros before retrying.' : '';
showToast('Update failed: ' + (err.message || err) + suffix, 'error');
applyBtn.disabled = restartRequired;
applyBtn.textContent = restartRequired ? 'Restart Required' : 'Update Now';
}
}
async function replaceWithOfficial() {
const proceed = await openConfirmDialog({
title: 'Replace with official version',
body: 'Recovery will replace the active checkout with the exact official version from the selected channel.\n\nA rescue snapshot and a local keep branch preserve a copy, but the active branch will be reset. Continue?',
confirmLabel: 'Replace checkout',
danger: true,
});
if (!proceed) return;
replaceBtn.disabled = true;
try {
const preflight = await apiClient.updatePreflight();
const plan = preflight?.merge_plan || {};
if (!plan.base_sha || !plan.target_sha) {
throw new Error(plan.error || 'Could not resolve an exact recovery target.');
}
const data = await apiClient.updateApply('replace', plan, { confirmRecovery: true });
if (data.status === 'restart_required') {
showToast('Recovery landed, but automatic restart failed. Restart Ouroboros to finish.', 'error');
} else {
showToast('Official version restored. Server is restarting.', 'success');
}
} catch (err) {
const restartRequired = Boolean(err?.body?.restart_required);
const suffix = restartRequired ? ' Runtime shutdown was incomplete; restart Ouroboros before retrying.' : '';
showToast('Recovery failed: ' + (err.message || err) + suffix, 'error');
replaceBtn.disabled = restartRequired;
}
}
checkBtn.addEventListener('click', () => {
loadStatus({ fetchRemote: true });
loadVersions();
});
applyBtn.addEventListener('click', applyUpdate);
replaceBtn.addEventListener('click', replaceWithOfficial);
page.querySelector('#updates-promote').addEventListener('click', async () => {
const confirmedPromote = await openConfirmDialog({
title: 'Promote to stable',
body: 'Promote current ouroboros branch to ouroboros-stable?',
confirmLabel: 'Promote',
});
if (!confirmedPromote) return;
try {
const resp = await apiFetch('/api/git/promote', { method: 'POST' });
const data = await resp.json();
if (data.status === 'ok') {
showToast(data.message, 'success');
} else {
showToast('Error: ' + (data.error || 'unknown'), 'error');
}
} catch (err) {
showToast('Failed: ' + (err.message || err), 'error');
}
});
window.addEventListener('ouro:dashboard-subtab-shown', (event) => {
if (event.detail?.tab !== 'updates' || state.activePage !== 'dashboard') return;
loadStatus({ fetchRemote: false });
loadVersions();
});
}