From 2ded69af7a1ae17be066bf49c5a7d418d1d90065 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 21:54:29 -0400 Subject: [PATCH 1/7] Prepare mobile outage recovery and maintenance status (JVNAUTOSCI-2758) --- ...utosci_2758_outage_candidate_2026-09-12.md | 118 +++++++++++++++++ scripts/codex_von_deploy.py | 8 ++ scripts/codex_von_maintenance.py | 76 +++++++++++ src/backend/server/versioned_static.py | 22 ++- .../web/von_interface/static/js/main.js | 19 ++- .../static/js/test/outageStatus.test.js | 64 +++++++++ .../web/von_interface/static/outage/app.js | 67 ++++++++++ .../web/von_interface/static/outage/draft.js | 48 +++++++ .../von_interface/static/outage/offline.html | 3 + .../von_interface/static/outage/offline.js | 47 +++++++ .../von_interface/static/outage/outage.css | 10 ++ .../static/outage/service-worker.js | 49 +++++++ .../web/von_interface/static/outage/status.js | 48 +++++++ .../templates/von_interface.html | 2 + tests/backend/test_versioned_static.py | 12 ++ tests/browser/outageComponents.cjs | 125 ++++++++++++++++++ tests/test_codex_von_deploy.py | 39 ++++++ tests/test_codex_von_maintenance.py | 39 ++++++ 18 files changed, 792 insertions(+), 4 deletions(-) create mode 100644 docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md create mode 100644 scripts/codex_von_maintenance.py create mode 100644 src/frontend/web/von_interface/static/js/test/outageStatus.test.js create mode 100644 src/frontend/web/von_interface/static/outage/app.js create mode 100644 src/frontend/web/von_interface/static/outage/draft.js create mode 100644 src/frontend/web/von_interface/static/outage/offline.html create mode 100644 src/frontend/web/von_interface/static/outage/offline.js create mode 100644 src/frontend/web/von_interface/static/outage/outage.css create mode 100644 src/frontend/web/von_interface/static/outage/service-worker.js create mode 100644 src/frontend/web/von_interface/static/outage/status.js create mode 100644 tests/browser/outageComponents.cjs create mode 100644 tests/test_codex_von_maintenance.py diff --git a/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md new file mode 100644 index 000000000..e99baaf1a --- /dev/null +++ b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md @@ -0,0 +1,118 @@ +# Mobile outage candidate — 12 September 2026 + +- **Kind:** dated implementation and evidence handoff +- **Lifecycle:** unfinished candidate +- **Authority:** evidence only; Jira JVNAUTOSCI-2758 owns the live decision +- **Owner:** Codex DGX, for Michael Witbrock +- **Review trigger:** candidate validation, status-route preparation or release + +## Delivery boundary + +User job: recover from an unreachable Von on mobile, retain useful work, and +show an attributable return-time estimate when one exists. The fair baseline +is the existing `evaluateServerHealthState` and `main.js` polling/backoff. +The candidate adds presentation, a small public-only service-worker fallback, +and an optional operator-bound deployment status publisher. No model calls, +new workflow, HTTP write endpoint, credentials or database migration are involved. + +Merge decision: **not ready**. Component evidence supports the UI and fallback, +but does not replace the task's authenticated candidate/PWA acceptance or prove +an independently served production status record. These are blocking because +an installed client must have a usable fallback and the maintenance record +must remain available while the actual backend is stopped. The Jira connector +returned `UNAUTHORIZED / oauth_token_invalid_grant`; the supplied assignment +was used, without inventing inaccessible comments or conversation context. + +## Candidate behaviour + +- The existing sustained-failure decision opens a dismissible full-screen + dialog, retaining the DOM and focus. Active thinking suppresses takeover. + Check status uses the existing poll; recovery closes the dialog. Restart + auto-reload is suppressed during outage recovery, a non-empty composer or + active thinking. Dependency-only failures retain their existing handling. +- The service worker controls `/von/` and intercepts only home navigations and + four allowlisted public fallback files. Installation fetches release-scoped + bytes using same-origin access, retaining only asset bytes and content type. + It never caches app HTML, APIs, health responses, histories, files or + credentials. The fallback handles network errors and HTTP 5xx, including + proxy error pages. Fresh backend health plus app HTML is needed to reopen. +- An unsent composer-text copy survives a reload in the same browser tab using + namespace-bound `sessionStorage`. It is exposed only after authentication and + organisation bootstrap, as a reviewable recovery copy, never auto-filled or + resent. Scope switching hides that copy. Attachments, other composers and a + discarded browser session are not persisted by this helper; open-page state + is retained without reload. Storage eviction/quota can limit recovery. +- `/von-status/maintenance.json` is a proposed operator-bound independent + read-only static route. The browser retains only allowlisted public status + fields. Missing, stale, failed or exceeded estimates remain unknown/delayed. + Source, update time, the user's local time zone and cached provenance are shown. +- `scripts/codex_von_maintenance.py` publishes an atomic public record from + deployment configuration, before stop where configured. ETA comes only from + an explicit operator estimate, never a health timeout. Successful verified + deployment/rollback publishes `ready`; failed recovery publishes `failed`. + A crash leaves the prior update to expire. Publication failures are recorded + without rolling back an otherwise healthy release. + +## Evidence and its limits + +Baseline checkout: `ab10a303e` (`origin/main` on inspection). +Read-only local production `/health` reported clean revision +`8401c3ac375bd5288da541686892e70fcd0db75d`, AgentTest false. That is runtime context, +not acceptance of this candidate. No live service was stopped or deployed. + +Targeted checks: 22 backend tests and 24 frontend tests passed; the component +browser fixture passed. Evidence and screenshots are retained in +`.run/outage-2758/` in the task worktree. + +- `pdm run pytest tests/test_codex_von_deploy.py tests/test_codex_von_maintenance.py tests/backend/test_versioned_static.py -q` +- `node node_modules/jest/bin/jest.js --runInBand src/frontend/web/von_interface/static/js/test/outageStatus.test.js src/frontend/web/von_interface/static/js/test/serverHealthState.test.js` +- `PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/outageComponents.cjs EVIDENCE_DIR` +- Footer-build and login-template regression suites (8 of the 24 frontend tests). +- Changed main/test JS static lint and `git diff --check`. + +The Chromium component/network fixture uses production outage modules, the +health evaluator, and the service worker. It verifies portrait (390×844), +landscape (844×390), keyboard-sized (390×350) layouts, transient/active-thinking +non-takeover, retained open draft, restored focus, outage reload/reopen, device +offline, same-tab recovered draft after reconnect, exactly four cached public +files, and zero HTTP effects. It does **not** exercise full authenticated +`main.js` polling, an installed physical phone, public OAuth, or actual DGX +planned-stop publication. Backend lifecycle tests cover publication order, +verified completion, rollback and failed recovery with a disposable file. + +## Bounded operator handoff + +The README at `/home/mjw/.codex-von-worker/studio-browser/README.md` was read. +Its listed fixtures belong to other tasks or an immutable release; none is +bound to this candidate. Do not reuse or stop them. + +1. Prepare a new task-specific localhost AgentTest profile rooted at + `/home/mjw/.codex-von-worker/worker-state/worktrees/35c046837b9bd2c7`, using the + established operator-prepared authentication route, generation disabled and + existing memberships preserved. Bind a new client/supervisor request bridge + under this checkout's ignored `.run/` directory. Supply its exact profile, + port, verified SHA and restart command. No credentials belong in the checkout. +2. Confirm an existing independently served static route can map only + `/von-status/maintenance.json` to an operator-owned public file while port + 5000 is stopped. No directory listing or HTTP write route. If there is no + such route, this needs a separately scoped infrastructure setup and recovery + plan; code deployment alone does not authorise changing Cloudflare ingress. +3. Bind `public_status_root` and `maintenance_agent` in the operator deployment + configuration. Optional `maintenance_public_reason` must be suitable for + public display; optional `maintenance_estimate_seconds` must be reasoned from + the release plan. Default ETA is absent. Keep these fields outside task text + and install the new maintenance module with the coherent deployment bundle. +4. Provide a disposable independent status route/file in the candidate fixture + to test planned-stop publication, external read-back, clearing only after + verified readiness and a stale/failed update. Use isolated network faults or + the owned fixture stop, never an unnecessary public outage. +5. Resume authenticated browser acceptance, review and publication after that + setup. Preserve the task worktree. The controller must perform any eventual + authorised deployment and verify the exact merged `origin/main` SHA plus + independently served record and public readiness. No deployment SHA is + requested before the blocking evidence is obtained. + +A cold first visit with no installed service worker remains subject to the +proxy/Cloudflare response. A cleared browser cache also removes the fallback. +No independently served cold-visit app route was found in this repository; +this residual infrastructure boundary is not hidden behind a success claim. diff --git a/scripts/codex_von_deploy.py b/scripts/codex_von_deploy.py index cca8c8ad3..15a029dd7 100755 --- a/scripts/codex_von_deploy.py +++ b/scripts/codex_von_deploy.py @@ -19,9 +19,11 @@ try: from . import deploy_local_main as runtime from . import codex_von_release as releases + from . import codex_von_maintenance as maintenance except ImportError: import deploy_local_main as runtime import codex_von_release as releases + import codex_von_maintenance as maintenance @contextmanager @@ -170,6 +172,7 @@ def execute(config, commit, receipt_path, *, recover_only=False): # immutable checkout cannot affect the loaded controller/backend. receipt["previous_worker_release"] = str(previous_worker) releases.prepare(primary, config["worker_release_root"], commit) + maintenance.record_publication(config, receipt, "planned") save(receipt_path, receipt) try: runtime.deploy( @@ -182,6 +185,7 @@ def execute(config, commit, receipt_path, *, recover_only=False): verification = verify(config, commit) select_worker(config, receipt) receipt.update(status="deployed", verification=verification) + maintenance.record_publication(config, receipt, "ready") save(receipt_path, receipt) return receipt except (runtime.DeploymentError, OSError, ValueError, KeyError) as exc: @@ -201,6 +205,7 @@ def execute(config, commit, receipt_path, *, recover_only=False): ) select_worker(config, receipt) receipt.update(status="deployed", verification=verification) + maintenance.record_publication(config, receipt, "ready") save(receipt_path, receipt) return receipt except (runtime.DeploymentError, OSError, ValueError, KeyError) as exc: @@ -223,6 +228,9 @@ def execute(config, commit, receipt_path, *, recover_only=False): ) if receipt.get("worker_recovery_failure_type"): receipt["status"] = "recovery_failed" + maintenance.record_publication( + config, receipt, "ready" if receipt["status"] == "rolled_back" else "failed" + ) save(receipt_path, receipt) return receipt diff --git a/scripts/codex_von_maintenance.py b/scripts/codex_von_maintenance.py new file mode 100644 index 000000000..a912b4044 --- /dev/null +++ b/scripts/codex_von_maintenance.py @@ -0,0 +1,76 @@ +"""Publish bounded public maintenance data through an operator-owned static root. + +No HTTP write endpoint, task text, credentials, health payloads or inferred ETA. +The host operator must bind this root to an independent read-only status route. +""" +from __future__ import annotations + +import json +import os +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def publish(config, receipt, state, *, now=None): + root_value = config.get("public_status_root") + if not root_value: + return {"status": "not_configured"} + root = Path(root_value) + if not root.is_absolute() or not root.is_dir(): + raise ValueError("Public status root must be an existing absolute directory") + agent = config.get("maintenance_agent") + reason = config.get("maintenance_public_reason", "Von is being updated") + if not isinstance(agent, str) or not agent.strip() or len(agent) > 100: + raise ValueError("An operator-bound public maintenance agent is required") + if not isinstance(reason, str) or len(reason) > 280: + raise ValueError("Public maintenance reason must be at most 280 characters") + if state not in {"planned", "recovering", "failed", "ready", "cancelled"}: + raise ValueError("Invalid maintenance state") + now = now or datetime.now(timezone.utc) + start = receipt.setdefault("maintenance_planned_start", now.isoformat()) + # This estimate is explicitly supplied by the operator from the release plan. + # Never reuse the health timeout as an estimated return time. + seconds = config.get("maintenance_estimate_seconds") + if seconds is not None and (type(seconds) not in (int, float) or not 0 < seconds <= 86400): + raise ValueError("Maintenance estimate must be positive and at most one day") + ready_at = None + if seconds is not None: + ready_at = (datetime.fromisoformat(start) + timedelta(seconds=seconds)).isoformat() + record = { + "schema_version": "von_maintenance.v1", + "release_id": receipt["requested_commit"], + "agent": agent.strip(), + "reason": reason, + "state": state, + "planned_start": start, + "estimated_ready_at": ready_at if state in {"planned", "recovering"} else None, + "updated_at": now.isoformat(), + # Freshness is not an ETA. A crashed publisher leaves an explicitly stale record. + "expires_at": (now + timedelta(minutes=30)).isoformat(), + } + target = root / "maintenance.json" + fd, temporary = tempfile.mkstemp(prefix=".maintenance-", dir=root) + try: + with os.fdopen(fd, "w") as stream: + json.dump(record, stream) + stream.flush() + os.fsync(stream.fileno()) + os.fchmod(stream.fileno(), 0o644) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + if json.loads(target.read_text()) != record: + raise OSError("Public maintenance file read-back mismatch") + return {"status": "published", "record": record} + + +def record_publication(config, receipt, state): + """A status write failure must not roll back an otherwise healthy release.""" + try: + receipt["maintenance_publication"] = publish(config, receipt, state) + except (OSError, ValueError) as exc: + receipt["maintenance_publication"] = { + "status": "failed", "failure_type": type(exc).__name__ + } diff --git a/src/backend/server/versioned_static.py b/src/backend/server/versioned_static.py index b8de35f53..67a056ddc 100644 --- a/src/backend/server/versioned_static.py +++ b/src/backend/server/versioned_static.py @@ -1,7 +1,8 @@ """Release-scoped static paths keep relative ES-module graphs coherent.""" from hashlib import sha256 +from pathlib import Path -from flask import abort +from flask import abort, make_response def install_versioned_static(app, version: str) -> None: @@ -20,3 +21,22 @@ def versioned_static(build_id, filename): # Never return new bytes under an old release's URL. abort(404) return app.send_static_file(filename) + + @app.get("/von/outage-worker.js") + def outage_worker(): + # A release-specific cache name prevents mixed fallback assets. This endpoint + # contains only public static code; it never embeds session data. + source = (Path(app.static_folder) / "outage" / "service-worker.js").read_text() + source = source.replace( + "const CACHE = 'von-outage-v1';", + f"const CACHE = 'von-outage-v1-{build}';", + ) + source = source.replace( + "const STATIC_ROOT = '/static/';", + f"const STATIC_ROOT = '/static/build/{build}/';", + ) + response = make_response(source) + response.headers["Content-Type"] = "application/javascript" + response.headers["Cache-Control"] = "no-cache" + response.headers["Service-Worker-Allowed"] = "/von/" + return response diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index 54ad46ba8..0a1e7e098 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -11,6 +11,8 @@ import { getLanguageDisplayName } from './languageConfig.js'; import { escapeHtml } from './markdownUtils.js'; import './suppressTooltips.js'; import { activateTab, loadTabData, setupTabNavigation } from './tabNavigation.js'; +import { installOutageView } from '../outage/app.js'; +import { preserveOutageDraft } from '../outage/draft.js'; import { evaluateServerHealthState } from './utils/serverHealthState.js'; import { handleSelectConceptByIdDetail } from './utils/selectConceptByIdHandler.js'; import { @@ -54,6 +56,7 @@ const getCurrentNamespace = getSessionScopedNamespace; document.addEventListener('DOMContentLoaded', async () => { console.log("DOM fully loaded and parsed."); + installOutageView(); const authStatus = await initialiseHomeAuthentication(); if (!hasAuthenticatedVonActor(authStatus)) { @@ -92,6 +95,7 @@ document.addEventListener('DOMContentLoaded', async () => { applyHomeAuthUnavailable(new Error(`Organisation context could not be confirmed. ${error.message}`)); return; } + preserveOutageDraft(getCurrentNamespace); loadDeferredSettingsFrame(); // JVNAUTOSCI-954: report bounded, client-reported capability hints (speech/audio) @@ -980,8 +984,8 @@ function startHealthPolling() { ? formatUptime(Math.max(0, Date.now() - lastHealthSuccessAtMs)) : null; uptimeSpan.textContent = lastCheckLabel - ? `server down (checked ${lastCheckLabel})` - : 'server down'; + ? `cannot reach Von (checked ${lastCheckLabel})` + : 'cannot reach Von'; const baseTitle = lastSuccessLabel ? `Von server is unreachable | Last healthy response ${lastSuccessLabel} ago` : 'Von server is unreachable'; @@ -1120,6 +1124,10 @@ function startHealthPolling() { clearTimeout(timeout); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); + if (data?.status !== 'healthy' || typeof data.start_time !== 'string') { + throw new Error('Invalid Von health response'); + } + const recoveringFromOutage = serverHealthUiState === 'down'; lastHealthCheckCompletedAtMs = Date.now(); hasSeenSuccessfulHealthPoll = true; firstFailureAtMs = null; @@ -2299,7 +2307,8 @@ function startHealthPolling() { writeCachedStartTimeIso(newStart); } - if (autoReloadEnabled() && !reloadTriggered && lastIdentity.pid !== null && lastIdentity.start !== null) { + if (autoReloadEnabled() && !recoveringFromOutage && !isThinkingActive() + && !document.getElementById('promptInput')?.value && !reloadTriggered && lastIdentity.pid !== null && lastIdentity.start !== null) { if (newPid !== null && newStart !== null && (newPid !== lastIdentity.pid || newStart !== lastIdentity.start)) { reloadTriggered = true; setTimeout(() => { try { window.location.reload(); } catch (_) { /* no-op */ } }, 300); @@ -2375,6 +2384,10 @@ function startHealthPolling() { } scheduleHealthPoll(nextDelay); } + document.addEventListener('von:requestHealthCheck', () => { + if (healthPollTimerId) clearTimeout(healthPollTimerId); + void poll(); + }); poll(); // Also update busy indicator more responsively setInterval(updateBusyIndicator, 1500); diff --git a/src/frontend/web/von_interface/static/js/test/outageStatus.test.js b/src/frontend/web/von_interface/static/js/test/outageStatus.test.js new file mode 100644 index 000000000..cdb49b102 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/test/outageStatus.test.js @@ -0,0 +1,64 @@ +import { maintenanceMessage, readMaintenance } from '../../outage/status.js'; +const now = Date.parse('2026-09-12T22:00:00Z'); +const record = { + schema_version: 'von_maintenance.v1', state: 'planned', agent: 'Codex DGX', + release_id: 'fixture-release', reason: 'Von is being updated', + updated_at: '2026-09-12T21:59:00Z', expires_at: '2026-09-12T22:30:00Z', + estimated_ready_at: '2026-09-12T22:05:00Z' +}; +test('ETA is attributable, localised and explicitly an estimate', () => { + const message = maintenanceMessage(record, { now }); + expect(message).toContain('Codex DGX'); + expect(message).toContain('Estimated return:'); + expect(message).toContain('not a guarantee'); +}); +test.each([ + [null, 'unknown'], + [{ ...record, estimated_ready_at: null }, 'unknown'], + [{ ...record, expires_at: '2026-09-12T21:00:00Z' }, 'stale'], + [{ ...record, estimated_ready_at: '2026-09-12T21:59:00Z' }, 'has passed'], + [{ ...record, state: 'failed' }, 'delayed'], + [{ ...record, state: 'ready' }, 'checking the connection'], +])('missing, stale, late and terminal updates do not promise readiness', (value, expected) => { + const message = maintenanceMessage(value, { now, cached: true }); + expect(message).toContain(expected); + expect(message).not.toContain('Estimated return:'); +}); +test('status cache contains only public fields and fallback is labelled', async () => { + localStorage.clear(); + global.fetch = jest.fn().mockResolvedValue({ ok: true, + headers: { get: () => 'application/json' }, + text: async () => JSON.stringify({ ...record, private_history: 'must not store' }) + }); + expect((await readMaintenance()).cached).toBe(false); + expect(localStorage.getItem('von:publicMaintenance:v1')).not.toContain('private_history'); + expect(fetch.mock.calls[0][1].credentials).toBe('omit'); + fetch.mockRejectedValue(new TypeError('offline')); + const fallback = await readMaintenance(); + expect(maintenanceMessage(fallback.record, { now, ...fallback })).toContain('Last known update'); +}); + +const { preserveOutageDraft } = require('../../outage/draft.js'); +test('reload recovery is namespace-bound and never fills or sends the composer', () => { + sessionStorage.clear(); + document.body.innerHTML = '
'; + let namespace = 'alice@org'; + const cleanup = preserveOutageDraft(() => namespace); + document.getElementById('promptInput').value = 'Unsent draft'; + window.dispatchEvent(new Event('pagehide')); + cleanup(); + document.getElementById('promptInput').value = ''; + const otherCleanup = preserveOutageDraft(() => 'bob@org'); + expect(document.getElementById('vonRecoveredDraft')).toBeNull(); + otherCleanup(); + const reloadCleanup = preserveOutageDraft(() => namespace); + expect(document.querySelector('#vonRecoveredDraft textarea').value).toBe('Unsent draft'); + expect(document.getElementById('promptInput').value).toBe(''); + document.dispatchEvent(new Event('orgSwitchStarted')); + expect(document.getElementById('vonRecoveredDraft')).toBeNull(); + namespace = 'alice@other-org'; + document.getElementById('promptInput').value = 'Other organisation draft'; + window.dispatchEvent(new Event('pagehide')); + expect(sessionStorage.getItem('von:unsentReloadDraft:v1:alice@org')).not.toContain('Other organisation'); + reloadCleanup(); +}); diff --git a/src/frontend/web/von_interface/static/outage/app.js b/src/frontend/web/von_interface/static/outage/app.js new file mode 100644 index 000000000..6af384407 --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/app.js @@ -0,0 +1,67 @@ +import { maintenanceMessage, readMaintenance } from './status.js'; + +// The existing health poll owns the decision; this module only presents it. +export function installOutageView() { + const dialog = document.createElement('dialog'); + dialog.className = 'von-outage'; + dialog.setAttribute('aria-labelledby', 'vonOutageTitle'); + dialog.innerHTML = '

Cannot reach Von

Sorry, Von cannot connect right now.

Your open work stays in place. We’ll reconnect automatically. An interrupted message will not be sent again; check its result before resending.

'; + document.body.append(dialog); + let lastFocus = null; + let state = 'waiting'; + let dismissed = false; + let statusInFlight = false; + let status = { record: null, cached: true }; + let lastChecked = null; + const updateText = () => { + dialog.querySelector('[data-connection]').textContent = navigator.onLine === false + ? 'Your device reports that it is offline. Check your connection; Von’s status is unknown.' + : 'Cannot reach Von. The cause is unknown.'; + dialog.querySelector('[data-maintenance]').textContent = maintenanceMessage(status.record, status); + dialog.querySelector('[data-checked]').textContent = lastChecked ? `Last checked: ${new Date(lastChecked).toLocaleTimeString()}` : 'No check completed yet.'; + }; + const refreshStatus = async () => { + if (statusInFlight) return; + statusInFlight = true; + try { status = await readMaintenance(); updateText(); } + finally { statusInFlight = false; } + }; + const close = () => { + if (!dialog.open) return; + dialog.close(); + if (lastFocus?.isConnected) lastFocus.focus({ preventScroll: true }); + }; + document.addEventListener('von:healthPollDiagnostics', event => { + const detail = event.detail || {}; + const previous = state; + state = detail.state; + lastChecked = Date.now(); + updateText(); + if (state === 'healthy') { + dismissed = false; + close(); + } else if (state === 'down' && !detail.isThinkingActive && !dismissed && !dialog.open) { + lastFocus = document.activeElement; + dialog.showModal(); + } + if (state === 'down' || previous === 'down') void refreshStatus(); + }); + dialog.querySelector('[data-retry]').addEventListener('click', () => { + document.dispatchEvent(new CustomEvent('von:requestHealthCheck')); + void refreshStatus(); + }); + dialog.querySelector('[data-return]').addEventListener('click', () => { dismissed = true; close(); }); + dialog.addEventListener('cancel', event => { event.preventDefault(); dismissed = true; close(); }); + window.addEventListener('online', () => { + document.dispatchEvent(new CustomEvent('von:requestHealthCheck')); + if (state === 'down') void refreshStatus(); + }); + window.addEventListener('offline', updateText); + // Fetch before an outage so the last-known attributed record can survive it. + void refreshStatus(); + setInterval(() => { if (dialog.open) updateText(); }, 10000); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/von/outage-worker.js', { scope: '/von/', updateViaCache: 'none' }) + .catch(() => { /* The open page still recovers if installation is unavailable. */ }); + } +} diff --git a/src/frontend/web/von_interface/static/outage/draft.js b/src/frontend/web/von_interface/static/outage/draft.js new file mode 100644 index 000000000..cd22ae2de --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/draft.js @@ -0,0 +1,48 @@ +// A tab-local unsent-text recovery copy, never an API/history cache or send queue. +// Read only after canonical authentication and organisation bootstrap have succeeded. +export function preserveOutageDraft(getNamespace) { + const namespace = getNamespace(); + if (typeof namespace !== 'string' || !namespace) return; + const input = document.getElementById('promptInput'); + if (!input) return; + const key = `von:unsentReloadDraft:v1:${namespace}`; + let saved; + try { saved = JSON.parse(sessionStorage.getItem(key)); } catch { /* optional storage */ } + if (typeof saved?.text === 'string' && saved.text) { + const recovery = document.createElement('details'); + recovery.id = 'vonRecoveredDraft'; + const title = document.createElement('summary'); + title.textContent = 'Unsent draft recovered — review before sending'; + const message = document.createElement('p'); + message.textContent = 'This text was saved in this tab before the page reloaded. Check the conversation and any interrupted send before reusing it. Attachments are not stored here.'; + const text = document.createElement('textarea'); + text.readOnly = true; + text.value = saved.text; + text.setAttribute('aria-label', 'Recovered unsent draft'); + text.style.cssText = 'box-sizing:border-box;width:100%;min-height:6rem'; + const discard = document.createElement('button'); + discard.type = 'button'; + discard.textContent = 'Discard recovered draft'; + discard.addEventListener('click', () => { + try { sessionStorage.removeItem(key); } catch { /* optional */ } + recovery.remove(); + }); + recovery.append(title, message, text, discard); + input.parentElement.before(recovery); + } + document.addEventListener('orgSwitchStarted', () => { + document.getElementById('vonRecoveredDraft')?.remove(); + }, { once: true }); + const saveDraft = () => { + // Programmatic clearing after send is observed here too. Never restore it as + // an unsent prompt or automatically replay an uncertain effect. + if (getNamespace() !== namespace) return; + const text = input.value; + try { + if (text) sessionStorage.setItem(key, JSON.stringify({ text })); + else if (!document.getElementById('vonRecoveredDraft')) sessionStorage.removeItem(key); + } catch { /* The open page still keeps its draft if storage is unavailable. */ } + }; + window.addEventListener('pagehide', saveDraft); + return () => window.removeEventListener('pagehide', saveDraft); +} diff --git a/src/frontend/web/von_interface/static/outage/offline.html b/src/frontend/web/von_interface/static/outage/offline.html new file mode 100644 index 000000000..f10c2ac4d --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/offline.html @@ -0,0 +1,3 @@ + +Von — reconnecting +

Cannot reach Von

Sorry, Von cannot connect right now.

The cause is unknown. We’ll keep checking.

Return time unknown.

No check completed yet.

This page reconnects automatically. It will never resend a message. If a previous send was interrupted, check the conversation before sending again.

diff --git a/src/frontend/web/von_interface/static/outage/offline.js b/src/frontend/web/von_interface/static/outage/offline.js new file mode 100644 index 000000000..a08037c92 --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/offline.js @@ -0,0 +1,47 @@ +import { maintenanceMessage, readMaintenance } from './status.js'; +let inFlight = false; +let failures = 0; +let timer; +async function check() { + if (inFlight) return; + inFlight = true; + clearTimeout(timer); + const button = document.getElementById('retry'); + button.disabled = true; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const status = readMaintenance().then(value => { + document.getElementById('maintenance').textContent = maintenanceMessage(value.record, value); + }); + try { + const response = await fetch('/health', { cache: 'no-store', redirect: 'error', signal: controller.signal }); + const health = response.ok ? await response.json() : null; + if (health?.status === 'healthy' && typeof health.start_time === 'string' && + health.runtime_authority?.startup_seed_materialisations?.ready !== false) { + // Health may be routed independently of app HTML. Do not loop on a proxy + // error page while health alone is available. This response is never cached. + const app = await fetch(location.href, { cache: 'no-store', redirect: 'error', signal: controller.signal }); + if (app.ok && app.headers.get('content-type')?.includes('text/html') && + (await app.text()).includes('name="von-app-shell" content="1"')) { + // A fresh navigation restores authentication normally. No effects replayed. + location.reload(); + return; + } + } + } catch { /* A proxy error or device-offline event does not identify the server-side cause. */ } + finally { + clearTimeout(timeout); + await status; + document.getElementById('checked').textContent = `Last checked: ${new Date().toLocaleTimeString()}`; + document.getElementById('connection').textContent = navigator.onLine === false + ? 'Your device reports that it is offline. Check your connection; Von’s status is unknown.' + : 'Cannot reach Von. The cause is unknown. We’ll keep checking.'; + button.disabled = false; + inFlight = false; + failures++; + timer = setTimeout(check, Math.min(30000, 5000 * 2 ** Math.min(failures - 1, 3))); + } +} +document.getElementById('retry').addEventListener('click', check); +window.addEventListener('online', check); +void check(); diff --git a/src/frontend/web/von_interface/static/outage/outage.css b/src/frontend/web/von_interface/static/outage/outage.css new file mode 100644 index 000000000..66085ad63 --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/outage.css @@ -0,0 +1,10 @@ +.von-outage { color-scheme: light dark; box-sizing: border-box; color: #172b35; background: #f4f7f8; font: 1rem/1.6 system-ui, sans-serif; } +dialog.von-outage { border: 0; margin: 0; width: 100%; max-width: none; height: 100%; height: 100dvh; max-height: none; overflow: auto; padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left)); } +body.von-outage { margin: 0; min-height: 100dvh; padding: max(24px, env(safe-area-inset-top)) max(24px, env(safe-area-inset-right)) max(24px, env(safe-area-inset-bottom)) max(24px, env(safe-area-inset-left)); } +.von-outage::backdrop { background: #f4f7f8; } +.von-outage main, .von-outage section { max-width: 34rem; margin: max(1rem, 8dvh) auto; overflow-wrap: anywhere; } +.von-outage h1 { font-size: clamp(1.6rem, 6vw, 2.4rem); line-height: 1.2; } +.von-outage button { font: inherit; cursor: pointer; min-height: 48px; padding: .6rem 1rem; border: 1px solid #536c77; border-radius: .6rem; margin: .25rem .4rem .25rem 0; background: #fff; color: #172b35; } +.von-outage button:focus-visible { outline: 3px solid #077eaa; outline-offset: 3px; } +.von-outage small { display: block; } +@media (prefers-color-scheme: dark) { .von-outage, .von-outage::backdrop { background: #18252c; color: #eef4f5; } .von-outage button { background: #2b424e; color: #eef4f5; } } diff --git a/src/frontend/web/von_interface/static/outage/service-worker.js b/src/frontend/web/von_interface/static/outage/service-worker.js new file mode 100644 index 000000000..43ed07501 --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/service-worker.js @@ -0,0 +1,49 @@ +// Cache only this public, dependency-free outage shell. Never cache application HTML, +// API responses, navigation history, health payloads, credentials or uploaded files. +const CACHE = 'von-outage-v1'; +const STATIC_ROOT = '/static/'; +const ASSETS = ['/static/outage/offline.html', '/static/outage/offline.js', '/static/outage/status.js', '/static/outage/outage.css']; +self.addEventListener('install', event => { + event.waitUntil((async () => { + const cache = await caches.open(CACHE); + for (const path of ASSETS) { + const response = await fetch(path.replace('/static/', STATIC_ROOT), { cache: 'reload', credentials: 'same-origin', redirect: 'error' }); + const contentType = response.headers.get('content-type') || ''; + const expectedType = path.endsWith('.js') ? 'javascript' : path.endsWith('.css') ? 'text/css' : 'text/html'; + if (!response.ok || !contentType.includes(expectedType)) throw new Error('Outage shell could not be installed'); + // Static assets may sit behind Access. Send same-origin authentication to + // fetch them, but retain no response headers that could carry credentials. + await cache.put(path, new Response(await response.arrayBuffer(), { + status: 200, headers: { 'Content-Type': contentType } + })); + } + await self.skipWaiting(); + })()); +}); +self.addEventListener('activate', event => { + event.waitUntil((async () => { + await self.clients.claim(); + for (const name of await caches.keys()) { + if (name.startsWith('von-outage-v1-') && name !== CACHE) await caches.delete(name); + } + })()); +}); +self.addEventListener('fetch', event => { + const request = event.request; + const url = new URL(request.url); + if (request.method !== 'GET' || url.origin !== self.location.origin) return; + if (ASSETS.includes(url.pathname) && !url.search) { + event.respondWith(caches.open(CACHE).then(async cache => (await cache.match(url.pathname)) || fetch(request))); + return; + } + // Confine navigation recovery to the home app; do not intercept OAuth or API routes. + if (request.mode !== 'navigate' || !['/von/', '/von'].includes(url.pathname)) return; + event.respondWith((async () => { + try { + const response = await fetch(request); + if (response.status < 500) return response; + } catch { /* Device offline or backend unavailable. */ } + const cache = await caches.open(CACHE); + return (await cache.match('/static/outage/offline.html')) || Response.error(); + })()); +}); diff --git a/src/frontend/web/von_interface/static/outage/status.js b/src/frontend/web/von_interface/static/outage/status.js new file mode 100644 index 000000000..2e9a64d48 --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/status.js @@ -0,0 +1,48 @@ +// Public operational data only. Never store health payloads or authenticated responses. +const CACHE_KEY = 'von:publicMaintenance:v1'; +export function maintenanceMessage(record, { now = Date.now(), cached = false } = {}) { + if (!record || record.schema_version !== 'von_maintenance.v1' || + !['planned', 'recovering', 'failed', 'ready', 'cancelled'].includes(record.state) || + typeof record.agent !== 'string' || typeof record.release_id !== 'string') { + return 'Return time unknown. No maintenance update is available.'; + } + const updated = Date.parse(record.updated_at); + const expires = Date.parse(record.expires_at); + const ready = Date.parse(record.estimated_ready_at); + const format = ms => new Date(ms).toLocaleString(undefined, { timeZoneName: 'short' }); + const source = `${cached ? 'Last known update' : 'Update'} from ${record.agent.slice(0, 100)}${Number.isFinite(updated) ? ` at ${format(updated)}` : ''}.`; + if (!Number.isFinite(updated) || !Number.isFinite(expires) || now >= expires || updated > now + 60000) { + return `${source} This update is stale; return time unknown.`; + } + if (record.state === 'ready' || record.state === 'cancelled') { + return `${source} Maintenance ${record.state === 'ready' ? 'finished' : 'cancelled'}; checking the connection to Von.`; + } + const reason = typeof record.reason === 'string' ? record.reason.slice(0, 280) : 'Planned maintenance'; + if (record.state === 'failed') return `${reason}. ${source} Recovery is delayed; return time unknown.`; + if (!Number.isFinite(ready)) return `${reason}. ${source} Return time unknown.`; + if (ready <= now) return `${reason}. ${source} The estimated return time has passed. Recovery is delayed; a new return time is unknown.`; + return `${reason}. ${source} Estimated return: ${format(ready)}. This is an estimate, not a guarantee.`; +} + +export async function readMaintenance() { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + // An operator-owned independent static route; a missing route is supported. + const response = await fetch('/von-status/maintenance.json', { + cache: 'no-store', credentials: 'omit', redirect: 'error', signal: controller.signal + }); + if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) throw new Error('No public status'); + const text = await response.text(); + if (text.length > 4096) throw new Error('Oversized public status'); + const value = JSON.parse(text); + // Allowlist data even if the independent server accidentally returns extra fields. + const record = Object.fromEntries(['schema_version', 'state', 'release_id', 'agent', 'reason', 'planned_start', 'estimated_ready_at', 'updated_at', 'expires_at'].map(key => [key, value[key]])); + if (record.schema_version !== 'von_maintenance.v1') throw new Error('Unknown public status'); + try { localStorage.setItem(CACHE_KEY, JSON.stringify(record)); } catch { /* optional */ } + return { record, cached: false }; + } catch { + try { return { record: JSON.parse(localStorage.getItem(CACHE_KEY)), cached: true }; } + catch { return { record: null, cached: true }; } + } finally { clearTimeout(timer); } +} diff --git a/src/frontend/web/von_interface/templates/von_interface.html b/src/frontend/web/von_interface/templates/von_interface.html index eeafaa529..c73938b4a 100644 --- a/src/frontend/web/von_interface/templates/von_interface.html +++ b/src/frontend/web/von_interface/templates/von_interface.html @@ -3,8 +3,10 @@ + Von + diff --git a/tests/backend/test_versioned_static.py b/tests/backend/test_versioned_static.py index cd467eea0..580622273 100644 --- a/tests/backend/test_versioned_static.py +++ b/tests/backend/test_versioned_static.py @@ -32,3 +32,15 @@ def test_build_change_changes_entry_url_without_rewriting_templates(tmp_path): with app.test_request_context(): urls.append(url_for('static',filename='js/main.js')) assert urls[0] != urls[1] + + +def test_outage_worker_has_narrow_scope_and_release_specific_public_cache(): + root = Path(__file__).resolve().parents[2] / 'src/frontend/web/von_interface/static' + app = Flask(__name__, static_folder=str(root), static_url_path='/static') + install_versioned_static(app, 'outage-fixture') + response = app.test_client().get('/von/outage-worker.js') + assert response.status_code == 200 + assert response.headers['Service-Worker-Allowed'] == '/von/' + assert response.headers['Cache-Control'] == 'no-cache' + assert "const CACHE = 'von-outage-v1-" in response.text + assert 'session' not in response.text.lower() diff --git a/tests/browser/outageComponents.cjs b/tests/browser/outageComponents.cjs new file mode 100644 index 000000000..c73754408 --- /dev/null +++ b/tests/browser/outageComponents.cjs @@ -0,0 +1,125 @@ +// Component/network fixture, not authenticated Von or physical-phone acceptance. +// Uses the production outage UI, health evaluator, offline shell and service worker. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const { chromium, expect } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface/static'); +const evidence = process.argv[2]; +assert(evidence, 'Provide evidence directory'); +fs.mkdirSync(evidence, { recursive: true }); +let available = true; +let effects = 0; +let status = { + schema_version: 'von_maintenance.v1', release_id: 'component-fixture', agent: 'Codex DGX fixture', + state: 'planned', reason: 'Controlled maintenance fixture', planned_start: new Date().toISOString(), + estimated_ready_at: new Date(Date.now() + 180000).toISOString(), + updated_at: new Date().toISOString(), expires_at: new Date(Date.now() + 1800000).toISOString() +}; +const app = `
`; +const server = http.createServer((req, res) => { + if (req.method !== 'GET') effects++; + const pathname = new URL(req.url, 'http://localhost').pathname; + // This route models an independently served public file, with no write endpoint. + if (pathname === '/von-status/maintenance.json') { + res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(status)); return; + } + if (!available) { res.writeHead(503); res.end('Backend unavailable'); return; } + if (pathname === '/health') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ status: 'healthy', start_time: 'fixture' })); return; + } + if (pathname === '/von/') { res.setHeader('Content-Type', 'text/html'); res.end(app); return; } + let file; + if (pathname === '/von/outage-worker.js') file = path.join(root, 'outage/service-worker.js'); + else if (pathname.startsWith('/static/')) file = path.resolve(root, pathname.slice(8)); + if (!file?.startsWith(root + path.sep) || !fs.existsSync(file)) { res.writeHead(404); res.end(); return; } + const mime = { '.js': 'application/javascript', '.css': 'text/css', '.html': 'text/html' }[path.extname(file)]; + res.setHeader('Content-Type', mime || 'text/plain'); + res.setHeader('X-Fixture-Private-Header', 'must-not-be-cached'); + res.end(fs.readFileSync(file)); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + const browser = await chromium.launch(); + try { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const page = await context.newPage(); + await page.goto(`${base}/von/`); + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true })); + }); + const draft = page.locator('#promptInput'); + await draft.fill('Unsent local acceptance draft'); + await page.evaluate(() => window.health({ hasSeenSuccessfulHealthPoll: true, failureCount: 1, firstFailureAtMs: Date.now() })); + await expect(page.locator('dialog')).not.toBeVisible(); + await page.evaluate(() => window.health({ hasSeenSuccessfulHealthPoll: true, failureCount: 8, firstFailureAtMs: Date.now()-120000, isThinkingActive: true })); + await expect(page.locator('dialog')).not.toBeVisible(); + available = false; + await page.evaluate(() => window.health({ hasSeenSuccessfulHealthPoll: true, failureCount: 4, firstFailureAtMs: Date.now()-30000 })); + await expect(page.locator('dialog')).toBeVisible(); + await expect(page.locator('[data-maintenance]')).toContainText('Codex DGX fixture'); + for (const [label, width, height] of [['portrait',390,844], ['keyboard',390,350], ['landscape',844,390]]) { + await page.setViewportSize({ width, height }); + await page.locator('[data-retry]').click(); + await expect(draft).toHaveValue('Unsent local acceptance draft'); + assert(await page.evaluate(() => document.querySelector('dialog').scrollWidth <= innerWidth)); + await page.screenshot({ path: path.join(evidence, `${label}.png`) }); + } + available = true; + status = { ...status, state: 'ready', estimated_ready_at: null }; + await page.locator('[data-retry]').click(); + await expect(page.locator('dialog')).not.toBeVisible(); + await expect(draft).toBeFocused(); + await expect(draft).toHaveValue('Unsent local acceptance draft'); + // Reload/reopen acceptance is separate from in-memory draft retention. + available = false; + await page.reload(); + await expect(page.locator('h1')).toHaveText('Cannot reach Von'); + await expect(page.locator('#checked')).toContainText('Last checked'); + await context.setOffline(true); + await page.locator('#retry').click(); + await expect(page.locator('#connection')).toContainText('device reports that it is offline'); + const reopened = await context.newPage(); + await reopened.goto(`${base}/von/`); + await expect(reopened.locator('h1')).toHaveText('Cannot reach Von'); + const cached = await page.evaluate(async () => { + const entries = []; + for (const name of await caches.keys()) for (const request of await (await caches.open(name)).keys()) { + const response = await (await caches.open(name)).match(request); + if (response.headers.has('X-Fixture-Private-Header')) throw new Error('Non-public response header cached'); + entries.push(new URL(request.url).pathname); + } + return entries.sort(); + }); + assert.deepEqual(cached, ['/static/outage/offline.html','/static/outage/offline.js','/static/outage/outage.css','/static/outage/status.js']); + available = true; + await context.setOffline(false); + await expect(page.locator('#promptInput')).toBeVisible(); + await page.locator('#vonRecoveredDraft summary').click(); + await expect(page.getByLabel('Recovered unsent draft')).toHaveValue('Unsent local acceptance draft'); + await expect(page.locator('#promptInput')).toHaveValue(''); + assert.equal(effects, 0); + const result = { surface: 'component/network fixture', viewports: 3, transientNoOverlay: true, thinkingNoOverlay: true, + openDraftRetained: true, reloadDraftRecovery: true, focusRestored: true, offlineReloadAndReopen: true, reconnect: true, cachedPaths: cached, effects }; + fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result)); + } finally { await browser.close(); await new Promise(resolve => server.close(resolve)); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/tests/test_codex_von_deploy.py b/tests/test_codex_von_deploy.py index 9d4511bdb..9272ca6c8 100644 --- a/tests/test_codex_von_deploy.py +++ b/tests/test_codex_von_deploy.py @@ -401,3 +401,42 @@ class Server(http.server.HTTPServer):allow_reuse_address=True subprocess.run( ["bash", "run.sh", "stop", "-Port", str(port)], cwd=checkout, check=True ) + + +@pytest.mark.parametrize('outcome', ['deployed', 'rolled_back', 'recovery_failed']) +def test_maintenance_is_published_before_stop_and_cleared_only_after_readiness( + deployment, monkeypatch, outcome +): + import json + config, previous, target, events, receipt_path = deployment + config.update(public_status_root=str(receipt_path.parent), + maintenance_agent='Codex DGX', maintenance_estimate_seconds=180) + status_path = receipt_path.parent / 'maintenance.json' + + def stop_and_deploy(**kwargs): + record = json.loads(status_path.read_text()) + assert record['state'] == 'planned' + assert record['agent'] == 'Codex DGX' + assert record['estimated_ready_at'] is not None + events.append('planned-before-stop') + if outcome != 'deployed': + raise deployer.runtime.DeploymentError('fixture failure') + + def verified(*args): + assert json.loads(status_path.read_text())['state'] == 'planned' + events.append('verified-ready') + return {'commit': target} + + monkeypatch.setattr(deployer.runtime, 'deploy', stop_and_deploy) + monkeypatch.setattr(deployer, 'verify', verified) + if outcome == 'recovery_failed': + def recovery_fails(*args): + raise deployer.runtime.DeploymentError('fixture recovery failure') + monkeypatch.setattr(deployer, 'recover', recovery_fails) + result = deployer.execute(config, target, receipt_path) + assert result['status'] == outcome + record = json.loads(status_path.read_text()) + assert record['state'] == ('failed' if outcome == 'recovery_failed' else 'ready') + assert record['estimated_ready_at'] is None + if outcome == 'deployed': + assert events == ['planned-before-stop', 'verified-ready'] diff --git a/tests/test_codex_von_maintenance.py b/tests/test_codex_von_maintenance.py new file mode 100644 index 000000000..d7135e422 --- /dev/null +++ b/tests/test_codex_von_maintenance.py @@ -0,0 +1,39 @@ +import json +from datetime import datetime, timezone + +import pytest + +from scripts import codex_von_maintenance as maintenance + + +def test_attributed_record_and_eta_survive_backend_absence(tmp_path): + config = {"public_status_root": str(tmp_path), "maintenance_agent": "Codex DGX", + "maintenance_estimate_seconds": 180} + receipt = {"requested_commit": "b" * 40} + now = datetime(2026, 9, 12, 22, tzinfo=timezone.utc) + planned = maintenance.publish(config, receipt, "planned", now=now) + record = json.loads((tmp_path / "maintenance.json").read_text()) + assert record == planned["record"] + assert record["agent"] == "Codex DGX" + assert record["estimated_ready_at"] == "2026-09-12T22:03:00+00:00" + assert "hostname" not in record + ready = maintenance.publish(config, receipt, "ready", now=now) + assert ready["record"]["estimated_ready_at"] is None + assert ready["record"]["state"] == "ready" + + +def test_no_inferred_eta_and_no_unconfigured_publication(tmp_path): + receipt = {"requested_commit": "b" * 40} + assert maintenance.publish({}, receipt, "planned") == {"status": "not_configured"} + result = maintenance.publish({"public_status_root": str(tmp_path), + "maintenance_agent": "Codex DGX", + "health_timeout_seconds": 960}, receipt, "planned") + assert result["record"]["estimated_ready_at"] is None + + +def test_operator_identity_required_and_write_failure_is_explicit(tmp_path): + receipt = {"requested_commit": "b" * 40} + with pytest.raises(ValueError): + maintenance.publish({"public_status_root": str(tmp_path)}, receipt, "planned") + maintenance.record_publication({"public_status_root": str(tmp_path)}, receipt, "ready") + assert receipt["maintenance_publication"]["status"] == "failed" From 5ccbdf9e738c6b7d1bd2247739bb3240e056d2d4 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 22:35:28 -0400 Subject: [PATCH 2/7] Recover outage shell when healthy startup work is explicitly skipped --- src/frontend/web/von_interface/static/outage/offline.js | 5 ++++- tests/browser/outageComponents.cjs | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/frontend/web/von_interface/static/outage/offline.js b/src/frontend/web/von_interface/static/outage/offline.js index a08037c92..761b05dbd 100644 --- a/src/frontend/web/von_interface/static/outage/offline.js +++ b/src/frontend/web/von_interface/static/outage/offline.js @@ -16,8 +16,11 @@ async function check() { try { const response = await fetch('/health', { cache: 'no-store', redirect: 'error', signal: controller.signal }); const health = response.ok ? await response.json() : null; + const seeds = health?.runtime_authority?.startup_seed_materialisations; + // Explicitly skipped startup work (including AgentTest) is not a pending + // readiness failure. Still require healthy transport and the real app below. if (health?.status === 'healthy' && typeof health.start_time === 'string' && - health.runtime_authority?.startup_seed_materialisations?.ready !== false) { + (seeds?.ready !== false || seeds?.state === 'skipped')) { // Health may be routed independently of app HTML. Do not loop on a proxy // error page while health alone is available. This response is never cached. const app = await fetch(location.href, { cache: 'no-store', redirect: 'error', signal: controller.signal }); diff --git a/tests/browser/outageComponents.cjs b/tests/browser/outageComponents.cjs index c73754408..298c44f2e 100644 --- a/tests/browser/outageComponents.cjs +++ b/tests/browser/outageComponents.cjs @@ -42,7 +42,9 @@ const server = http.createServer((req, res) => { if (!available) { res.writeHead(503); res.end('Backend unavailable'); return; } if (pathname === '/health') { res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ status: 'healthy', start_time: 'fixture' })); return; + res.end(JSON.stringify({ status: 'healthy', start_time: 'fixture', + runtime_authority: { startup_seed_materialisations: { ready: false, state: 'skipped' } } + })); return; } if (pathname === '/von/') { res.setHeader('Content-Type', 'text/html'); res.end(app); return; } let file; From efa7f0dda6c650e87488abfe093915dcd6c8f021 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 22:38:07 -0400 Subject: [PATCH 3/7] Rebind outage draft recovery after confirmed organisation switches --- .../web/von_interface/static/js/main.js | 6 ++++- .../static/js/test/outageStatus.test.js | 22 +++++++++++++++++++ .../web/von_interface/static/outage/draft.js | 11 +++++++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index 0a1e7e098..a3928b01d 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -95,7 +95,11 @@ document.addEventListener('DOMContentLoaded', async () => { applyHomeAuthUnavailable(new Error(`Organisation context could not be confirmed. ${error.message}`)); return; } - preserveOutageDraft(getCurrentNamespace); + let releaseOutageDraft = preserveOutageDraft(getCurrentNamespace); + document.addEventListener('orgSwitched', () => { + releaseOutageDraft?.(); + releaseOutageDraft = preserveOutageDraft(getCurrentNamespace); + }); loadDeferredSettingsFrame(); // JVNAUTOSCI-954: report bounded, client-reported capability hints (speech/audio) diff --git a/src/frontend/web/von_interface/static/js/test/outageStatus.test.js b/src/frontend/web/von_interface/static/js/test/outageStatus.test.js index cdb49b102..0032849f1 100644 --- a/src/frontend/web/von_interface/static/js/test/outageStatus.test.js +++ b/src/frontend/web/von_interface/static/js/test/outageStatus.test.js @@ -62,3 +62,25 @@ test('reload recovery is namespace-bound and never fills or sends the composer', expect(sessionStorage.getItem('von:unsentReloadDraft:v1:alice@org')).not.toContain('Other organisation'); reloadCleanup(); }); + +test('confirmed organisation rebind saves new drafts without exposing the previous scope', () => { + sessionStorage.clear(); + document.body.innerHTML = '
'; + let namespace = 'alice@first'; + let cleanup = preserveOutageDraft(() => namespace); + document.getElementById('promptInput').value = 'First draft'; + window.dispatchEvent(new Event('pagehide')); + document.dispatchEvent(new Event('orgSwitchStarted')); + namespace = 'alice@second'; + cleanup(); + cleanup = preserveOutageDraft(() => namespace); + expect(document.getElementById('vonRecoveredDraft')).toBeNull(); + document.getElementById('promptInput').value = 'Second draft'; + window.dispatchEvent(new Event('pagehide')); + cleanup(); + document.getElementById('promptInput').value = ''; + cleanup = preserveOutageDraft(() => namespace); + expect(document.querySelector('#vonRecoveredDraft textarea').value).toBe('Second draft'); + expect(sessionStorage.getItem('von:unsentReloadDraft:v1:alice@first')).toContain('First draft'); + cleanup(); +}); diff --git a/src/frontend/web/von_interface/static/outage/draft.js b/src/frontend/web/von_interface/static/outage/draft.js index cd22ae2de..3b8d4299b 100644 --- a/src/frontend/web/von_interface/static/outage/draft.js +++ b/src/frontend/web/von_interface/static/outage/draft.js @@ -30,9 +30,10 @@ export function preserveOutageDraft(getNamespace) { recovery.append(title, message, text, discard); input.parentElement.before(recovery); } - document.addEventListener('orgSwitchStarted', () => { + const hideRecovery = () => { document.getElementById('vonRecoveredDraft')?.remove(); - }, { once: true }); + }; + document.addEventListener('orgSwitchStarted', hideRecovery); const saveDraft = () => { // Programmatic clearing after send is observed here too. Never restore it as // an unsent prompt or automatically replay an uncertain effect. @@ -44,5 +45,9 @@ export function preserveOutageDraft(getNamespace) { } catch { /* The open page still keeps its draft if storage is unavailable. */ } }; window.addEventListener('pagehide', saveDraft); - return () => window.removeEventListener('pagehide', saveDraft); + return () => { + window.removeEventListener('pagehide', saveDraft); + document.removeEventListener('orgSwitchStarted', hideRecovery); + hideRecovery(); + }; } From 377b030beea0989f2a514e42c01449310c6881c2 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 22:39:54 -0400 Subject: [PATCH 4/7] Record authenticated outage acceptance and remaining production status dependency --- ...utosci_2758_outage_candidate_2026-09-12.md | 131 ++++++++++++------ 1 file changed, 88 insertions(+), 43 deletions(-) diff --git a/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md index e99baaf1a..d9887b02b 100644 --- a/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md +++ b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md @@ -15,13 +15,12 @@ The candidate adds presentation, a small public-only service-worker fallback, and an optional operator-bound deployment status publisher. No model calls, new workflow, HTTP write endpoint, credentials or database migration are involved. -Merge decision: **not ready**. Component evidence supports the UI and fallback, -but does not replace the task's authenticated candidate/PWA acceptance or prove -an independently served production status record. These are blocking because -an installed client must have a usable fallback and the maintenance record -must remain available while the actual backend is stopped. The Jira connector -returned `UNAUTHORIZED / oauth_token_invalid_grant`; the supplied assignment -was used, without inventing inaccessible comments or conversation context. +Merge decision: **not ready for release/merge**. Authenticated candidate outage, +reload and draft recovery now pass (14 September continuation below). The +remaining dependency is the operator-bound independently served production +maintenance record and controller publisher configuration. The coding sandbox +cannot inspect controller configuration or change live ingress. No merge or +public deployment has occurred. ## Candidate behaviour @@ -80,39 +79,85 @@ files, and zero HTTP effects. It does **not** exercise full authenticated planned-stop publication. Backend lifecycle tests cover publication order, verified completion, rollback and failed recovery with a disposable file. -## Bounded operator handoff - -The README at `/home/mjw/.codex-von-worker/studio-browser/README.md` was read. -Its listed fixtures belong to other tasks or an immutable release; none is -bound to this candidate. Do not reuse or stop them. - -1. Prepare a new task-specific localhost AgentTest profile rooted at - `/home/mjw/.codex-von-worker/worker-state/worktrees/35c046837b9bd2c7`, using the - established operator-prepared authentication route, generation disabled and - existing memberships preserved. Bind a new client/supervisor request bridge - under this checkout's ignored `.run/` directory. Supply its exact profile, - port, verified SHA and restart command. No credentials belong in the checkout. -2. Confirm an existing independently served static route can map only - `/von-status/maintenance.json` to an operator-owned public file while port - 5000 is stopped. No directory listing or HTTP write route. If there is no - such route, this needs a separately scoped infrastructure setup and recovery - plan; code deployment alone does not authorise changing Cloudflare ingress. -3. Bind `public_status_root` and `maintenance_agent` in the operator deployment - configuration. Optional `maintenance_public_reason` must be suitable for - public display; optional `maintenance_estimate_seconds` must be reasoned from - the release plan. Default ETA is absent. Keep these fields outside task text - and install the new maintenance module with the coherent deployment bundle. -4. Provide a disposable independent status route/file in the candidate fixture - to test planned-stop publication, external read-back, clearing only after - verified readiness and a stale/failed update. Use isolated network faults or - the owned fixture stop, never an unnecessary public outage. -5. Resume authenticated browser acceptance, review and publication after that - setup. Preserve the task worktree. The controller must perform any eventual - authorised deployment and verify the exact merged `origin/main` SHA plus - independently served record and public readiness. No deployment SHA is - requested before the blocking evidence is obtained. - -A cold first visit with no installed service worker remains subject to the -proxy/Cloudflare response. A cleared browser cache also removes the fallback. -No independently served cold-visit app route was found in this repository; -this residual infrastructure boundary is not hidden behind a success claim. +## 14 September reviewed continuation + +Recovered PR #665 and merged `origin/main` at `22acdc223982ae34a72f5e32003a4cf74562342b` +without conflicts. Real acceptance found and repaired two candidate defects: + +- Healthy runtimes explicitly skipping startup materialisation could not leave + the offline shell. Explicit `skipped` now permits recovery, still requiring + healthy status and fresh marked app HTML. +- Selecting an organisation after initial page load left draft recovery bound + to the initial scope. Recovery now rebinds after the confirmed switch, + removes the previous listener/view and keeps namespace isolation. + +Tested product revision: `efa7f0dda6c650e87488abfe093915dcd6c8f021`. +`pdm run` passed 22 targeted backend tests; Jest passed 17 outage/health tests. +The production component browser passed three viewports, focus restoration, +transient and active-thinking non-takeover, offline reload/reopen, recovery, +public-only caching and zero effects. `git diff --check` passed. + +Authenticated Chromium at 390×844 through the operator's independent localhost +front (5091), backed by exact-candidate AgentTest (5087), verified synthetic +Alice login and conversation creation, transient health failure without +takeover, actual controlled backend stop, sustained outage dialog, retained +open draft, independent status HTTP 200 during outage, reload/reopen fallback, +device-offline wording and recovery after restart. The fixture creates a new +signing key on every restart, so recovery correctly reached login; after normal +synthetic login and organisation selection the original draft was available as +an explicit recovery copy. Zero generate/send-message requests occurred. Cache +inspection found exactly the four public fallback assets. Screenshots inspected. +This is desktop Chromium mobile viewport acceptance, not a physical installed +phone or public OAuth test. + +Evidence is retained under `.run/outage-resume/`: `acceptance-final.json`, +`acceptance.cjs`, `authenticated-outage.png`, `recovered.png`, and component +receipts/screenshots. Earlier failed probes are retained to explain the fixes. +The supplied canonical archive +`#V#computer_file_copy_45ea7ecc36eb4f76a78ad59e6c44e5ec` was checksum-verified: +`d0400a6ae476eca9b3f01f90759a35ff4e154685d8ac562a7db4f9ea02225b51`. +Its controlled planned-stop proof showed attributed ETA HTTP 200 while backend +returned 503 and cleared ETA only after exact-candidate health 200. That proof +belongs to revision `2ded69af7`; the publisher is unchanged in this continuation. +Fresh browser acceptance observed that retained record as stale and displayed +return time unknown, rather than inventing an estimate. + +## Remaining bounded operator handoff + +The old authentication/setup blocker is resolved. Do not prepare another login +fixture or ask Michael to sign in. The fixture proxy launcher encountered a +read-only host log path; its existing foreground `serve` mode worked without +changing its bindings. Task-owned servers are stopped after acceptance. + +The current remaining dependency is **production independent status binding**: + +- A fresh anonymous GET of the public status URL returns HTTP 302 to Cloudflare + Access, so the browser's credential-free status fetch cannot consume it. +- The local production backend status path returns HTTP 404. Local health is + healthy, AgentTest false, clean revision + `632af9653609da91218e781278b21c8b5c69acc2`. +- These observations do not prove whether a private ingress/configuration + binding exists. Controller credentials/configuration are outside coding scope. + +The coordinating operator should inspect the existing ingress and supply or +prepare a bounded route mapping **only** `/von-status/maintenance.json` to an +operator-owned public file independently of backend port 5000, with anonymous +GET access (no Access redirect), no listing or HTTP write route. Bind +`public_status_root` and `maintenance_agent` in the deployment controller and +install the maintenance module with the coherent release bundle. ETA remains +optional and explicitly reasoned; never derive it from the health timeout. +Keep private application routes behind their existing access boundary. +If this requires infrastructure changes, use an explicitly scoped operator task +with a route/configuration rollback; code deployment alone is not that grant. + +Return observed route/publisher bindings without credentials, public JSON +read-back, an independent-origin/readiness proof without an unnecessary public +outage, and the rollback reference. Then resume PR #665, required CI, merge and +controller deployment of the current merged main revision. Verify public/static +revision and readiness, and publication/clearing through the actual release. +No deployment SHA is requested before this dependency is resolved. + +A cold first visit with no installed service worker, a cleared cache, and an +expired Cloudflare Access session remain explicit infrastructure/session +boundaries. No public ingress, controller configuration or database was changed +by this coding run. Canonical task updates belong to the receiving controller. From 2c7eb97e7e05d40d8cf3b1e4c7a65b7fb732f177 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 22:40:45 -0400 Subject: [PATCH 5/7] Place recovered mobile drafts across the composer width --- src/frontend/web/von_interface/static/outage/draft.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/frontend/web/von_interface/static/outage/draft.js b/src/frontend/web/von_interface/static/outage/draft.js index 3b8d4299b..0ce190310 100644 --- a/src/frontend/web/von_interface/static/outage/draft.js +++ b/src/frontend/web/von_interface/static/outage/draft.js @@ -28,7 +28,13 @@ export function preserveOutageDraft(getNamespace) { recovery.remove(); }); recovery.append(title, message, text, discard); - input.parentElement.before(recovery); + const composer = input.closest('.chat-composer'); + if (composer) { + recovery.style.cssText = 'grid-column:1 / -1;min-width:0;width:100%;box-sizing:border-box'; + composer.append(recovery); + } else { + input.parentElement.before(recovery); + } } const hideRecovery = () => { document.getElementById('vonRecoveredDraft')?.remove(); From 7f64d6526cd7adec1861de3536d67d30a6945825 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 22:41:40 -0400 Subject: [PATCH 6/7] Record final recovered-draft mobile layout acceptance --- .../jvnautosci_2758_outage_candidate_2026-09-12.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md index d9887b02b..eef80fabf 100644 --- a/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md +++ b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md @@ -82,7 +82,7 @@ verified completion, rollback and failed recovery with a disposable file. ## 14 September reviewed continuation Recovered PR #665 and merged `origin/main` at `22acdc223982ae34a72f5e32003a4cf74562342b` -without conflicts. Real acceptance found and repaired two candidate defects: +without conflicts. Real acceptance found and repaired three candidate defects: - Healthy runtimes explicitly skipping startup materialisation could not leave the offline shell. Explicit `skipped` now permits recovery, still requiring @@ -90,6 +90,10 @@ without conflicts. Real acceptance found and repaired two candidate defects: - Selecting an organisation after initial page load left draft recovery bound to the initial scope. Recovery now rebinds after the confirmed switch, removes the previous listener/view and keeps namespace isolation. +- The recovery panel occupied a narrow mobile composer control column. It now + spans the composer width; an authenticated reload on final product revision + `2c7eb97e7e05d40d8cf3b1e4c7a65b7fb732f177` verified a readable 360px recovery + panel in a 390px viewport, with expanded draft and discard control. Tested product revision: `efa7f0dda6c650e87488abfe093915dcd6c8f021`. `pdm run` passed 22 targeted backend tests; Jest passed 17 outage/health tests. @@ -111,7 +115,7 @@ This is desktop Chromium mobile viewport acceptance, not a physical installed phone or public OAuth test. Evidence is retained under `.run/outage-resume/`: `acceptance-final.json`, -`acceptance.cjs`, `authenticated-outage.png`, `recovered.png`, and component +`acceptance.cjs`, `authenticated-outage.png`, `recovered.png`, final `recovery-layout.json`/`.png`, and component receipts/screenshots. Earlier failed probes are retained to explain the fixes. The supplied canonical archive `#V#computer_file_copy_45ea7ecc36eb4f76a78ad59e6c44e5ec` was checksum-verified: From 989db84fa245fc7025ec2c2b12634bfd8e547561 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sun, 13 Sep 2026 22:42:48 -0400 Subject: [PATCH 7/7] Record observed fixture proxy cleanup requirement --- .../jvnautosci_2758_outage_candidate_2026-09-12.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md index eef80fabf..daefe77b7 100644 --- a/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md +++ b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md @@ -20,7 +20,8 @@ reload and draft recovery now pass (14 September continuation below). The remaining dependency is the operator-bound independently served production maintenance record and controller publisher configuration. The coding sandbox cannot inspect controller configuration or change live ingress. No merge or -public deployment has occurred. +public deployment has occurred. Required GitHub syntax and secret-scanning +checks passed for the published continuation. ## Candidate behaviour @@ -131,7 +132,10 @@ return time unknown, rather than inventing an estimate. The old authentication/setup blocker is resolved. Do not prepare another login fixture or ask Michael to sign in. The fixture proxy launcher encountered a read-only host log path; its existing foreground `serve` mode worked without -changing its bindings. Task-owned servers are stopped after acceptance. +changing its bindings. Candidate ports 5087 and 5088 are confirmed closed after acceptance. The +foreground proxy session ended on interrupt, but port 5091 still listens. The +operator must identify and stop only this fixture proxy; its retained status +command reports PID 3158922, which is not proof of current process ownership. The current remaining dependency is **production independent status binding**: