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 00000000..8739cb78 --- /dev/null +++ b/docs/engineering/jvnautosci_2758_outage_candidate_2026-09-12.md @@ -0,0 +1,208 @@ +# Mobile outage candidate — 12 September 2026 + +- **Kind:** dated implementation and evidence handoff +- **Lifecycle:** release evidence +- **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. + +The initial publication was blocked on production status routing. The 14 September +operator repair and final integration evidence below supersede that observation. +Live delivery status belongs to JVNAUTOSCI-2758 and PR #665; deployment is performed +and verified by the receiving controller, not this coding process. + +## 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. + +## 14 September reviewed continuation + +Recovered PR #665 and merged `origin/main` at `22acdc223982ae34a72f5e32003a4cf74562342b` +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 + 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. +- 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. +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`, 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: +`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. + +## Historical operator handoff (superseded by repair below) + +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. 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**: + +- 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. + +## 14 September production-route repair and final integration + +The controller supplied canonical file copy +`#V#computer_file_copy_7f218d32c4104a0eac1cf8c6425657cd`, SHA-256 +`94dfc533c89653a2a35902e51021dc2ec6a0716432c23887d114e4ec07529dd1`. +The archive checksum was independently verified. Its operator handoff and receipts +record a separate fixed-file read-only status service, exact public ingress, +controller publisher binding, coherent deployment-module installation and +configuration rollback under the operator's `outage665-public-status-2a003` +evidence root. Private app routes retained Access redirects; the status child +path was denied and POST returned 405. The abandoned fixture proxy was stopped. +The operator verified attributed planned publication and cleared it only after +matching local/public readiness. Production was not stopped for that probe. +A fresh coding-run anonymous GET at 03:15:53 UTC returned HTTP 200 JSON with +`Cache-Control: no-store`, ready state and no ETA, consistent with that receipt. + +Current main `2b9379cfc` introduced push notifications after the previous outage +acceptance. Integration exposed competing worker registrations for `/von/`. +Both features now register `/von/service-worker.js`, which imports the existing +release-scoped outage worker script. The push handler, subscription, scope and +binding cache remain in the same registration; outage cache cleanup remains +confined to outage caches. Both registration callers disable import caching for +release updates. The template retains both the PWA manifest and outage styles. + +Final targeted checks passed: 22 backend deployment/maintenance/static tests and +29 frontend outage/health/push tests. The Chromium component fixture now loads +the combined production worker, verifies its active URL, delivers a real worker +message to disable a synthetic local push binding, and then proves the outage +cache still supports offline reload/reopen, recovery, draft retention and zero +HTTP effects. Three mobile viewports passed and screenshots were inspected. +Evidence: `.run/outage-release/components/`. This supplements the retained full +authenticated acceptance above; it is not a new physical-device or push-provider +display claim. Push privacy, duplicate suppression and click confinement are +covered by the existing handler tests running with the imported outage script. + +The publication decision is recorded on PR #665. Controller deployment must +read back the merged public/static revision and readiness and retain the actual +maintenance publication/clearing receipt. Physical phones, cold uncached first +visits and expired Cloudflare sessions remain the previously stated limits. diff --git a/scripts/codex_von_deploy.py b/scripts/codex_von_deploy.py index cca8c8ad..15a029dd 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 00000000..a912b404 --- /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 b8de35f5..67a056dd 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 ab7a0a1c..f4836ec9 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,11 @@ document.addEventListener('DOMContentLoaded', async () => { applyHomeAuthUnavailable(new Error(`Organisation context could not be confirmed. ${error.message}`)); return; } + let releaseOutageDraft = preserveOutageDraft(getCurrentNamespace); + document.addEventListener('orgSwitched', () => { + releaseOutageDraft?.(); + releaseOutageDraft = preserveOutageDraft(getCurrentNamespace); + }); loadDeferredSettingsFrame(); // JVNAUTOSCI-954: report bounded, client-reported capability hints (speech/audio) @@ -987,8 +995,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'; @@ -1127,6 +1135,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; @@ -2306,7 +2318,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); @@ -2382,6 +2395,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/pushNotifications.js b/src/frontend/web/von_interface/static/js/pushNotifications.js index 262aeab7..9ce1a9e0 100644 --- a/src/frontend/web/von_interface/static/js/pushNotifications.js +++ b/src/frontend/web/von_interface/static/js/pushNotifications.js @@ -16,7 +16,7 @@ export function notificationSupport() { } async function registration() { - const reg = await navigator.serviceWorker.register('/von/service-worker.js', { scope: '/von/' }); + const reg = await navigator.serviceWorker.register('/von/service-worker.js', { scope: '/von/', updateViaCache: 'none' }); // Settings lives in /settings iframe, outside the /von/ worker scope. // navigator.serviceWorker.ready would wait forever for that frame to be // controlled. Wait for this registration's activation instead. 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 00000000..0032849f --- /dev/null +++ b/src/frontend/web/von_interface/static/js/test/outageStatus.test.js @@ -0,0 +1,86 @@ +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(); +}); + +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/app.js b/src/frontend/web/von_interface/static/outage/app.js new file mode 100644 index 00000000..ff5e4761 --- /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/service-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 00000000..0ce19031 --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/draft.js @@ -0,0 +1,59 @@ +// 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); + 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(); + }; + 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. + 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); + document.removeEventListener('orgSwitchStarted', hideRecovery); + hideRecovery(); + }; +} 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 00000000..f10c2ac4 --- /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 00000000..761b05db --- /dev/null +++ b/src/frontend/web/von_interface/static/outage/offline.js @@ -0,0 +1,50 @@ +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; + 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' && + (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 }); + 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 00000000..66085ad6 --- /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 00000000..43ed0750 --- /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 00000000..2e9a64d4 --- /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/static/service-worker.js b/src/frontend/web/von_interface/static/service-worker.js index 439fa48e..8cacd4b7 100644 --- a/src/frontend/web/von_interface/static/service-worker.js +++ b/src/frontend/web/von_interface/static/service-worker.js @@ -1,4 +1,5 @@ -/* Push only: do not cache authenticated pages or intercept application fetches. */ +/* One registration owns push and the public-only outage fallback. */ +importScripts('/von/outage-worker.js'); const STATE_CACHE = 'von-push-state-v1'; const STATE_URL = new URL('/von/.push-binding', self.location.origin).href; diff --git a/src/frontend/web/von_interface/templates/von_interface.html b/src/frontend/web/von_interface/templates/von_interface.html index dfdd2d38..c7774875 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 cd467eea..58062227 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 00000000..7613a94b --- /dev/null +++ b/tests/browser/outageComponents.cjs @@ -0,0 +1,145 @@ +// 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', + 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; + if (pathname === '/von/service-worker.js') file = path.join(root, 'service-worker.js'); + else 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 workerLifecycle = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.getRegistration('/von/'); + const cache = await caches.open('von-push-state-v1'); + await cache.put('/von/.push-binding', new Response('{}')); + await new Promise((resolve, reject) => { + const channel = new MessageChannel(); + const timer = setTimeout(() => reject(new Error('Combined worker disable did not reply')), 5000); + channel.port1.onmessage = event => { + clearTimeout(timer); + event.data?.ok ? resolve() : reject(new Error('Disable failed')); + }; + reg.active.postMessage({ type: 'von-push-disable' }, [channel.port2]); + }); + return { script: reg.active.scriptURL, pushStateCleared: !(await caches.has('von-push-state-v1')) }; + }); + assert.equal(workerLifecycle.script, `${base}/von/service-worker.js`); + assert.equal(workerLifecycle.pushStateCleared, 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, workerLifecycle, 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/frontend/pushServiceWorker.test.js b/tests/frontend/pushServiceWorker.test.js index 2f922b0a..21a89cd2 100644 --- a/tests/frontend/pushServiceWorker.test.js +++ b/tests/frontend/pushServiceWorker.test.js @@ -24,6 +24,11 @@ function worker() { cache.match = async key => data.get(key)?.clone(); const context = vm.createContext({ self, fetch, URL, Response, caches: { open: async () => cache, delete: async () => data.clear() } }); + context.importScripts = url => { + expect(url).toBe('/von/outage-worker.js'); + vm.runInContext(fs.readFileSync(path.join(__dirname, + '../../src/frontend/web/von_interface/static/outage/service-worker.js'), 'utf8'), context); + }; vm.runInContext(fs.readFileSync(path.join(__dirname, '../../src/frontend/web/von_interface/static/service-worker.js'), 'utf8'), context); async function event(type, props) { diff --git a/tests/test_codex_von_deploy.py b/tests/test_codex_von_deploy.py index 9d4511bd..9272ca6c 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 00000000..d7135e42 --- /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"